diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..93483d6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`phio` is a CLI tool for managing PocketHost instances. It provides commands to authenticate, deploy code, watch for changes, and tail logs from PocketHost instances. + +## Development Commands + +- **Run CLI in dev mode**: `npm run dev` or `tsx ./src/cli.ts` +- **Test CLI command**: `bunx phio ` (after building) +- **Format code**: Uses Prettier with organize-imports plugin (config in package.json) + +## Architecture + +### Entry Point & Command Structure + +- **Entry**: `src/cli.ts` - Uses Commander.js to register all commands +- **Commands**: Each command in `src/commands/` exports a factory function that returns a Commander Command instance +- All commands follow the pattern: `export const XCommand = () => new Command('name')...` + +### Authentication Flow + +1. **Environment variables** (highest priority): `PHIO_USERNAME`, `PHIO_PASSWORD`, `PHIO_INSTANCE_NAME` +2. **Saved config** (fallback): Stored in `~/.config/phio/config.json` (or `PHIO_HOME` if set) +3. Config stores: `email` and `pb_auth` (PocketBase auth cookie) +4. `getClient()` in `src/lib/getClient.ts` manages a singleton PocketBase client instance +5. `ensureLoggedIn()` prompts for login if needed + +### Instance Configuration Resolution + +The CLI looks for instance names in this order: +1. Environment variable: `PHIO_INSTANCE_NAME` +2. `package.json` under `pockethost.instanceName` +3. `pockethost.json` under `instanceName` +4. Command-line argument if provided + +This is handled by `savedInstanceName()` in `src/lib/defaultInstanceId.ts`. + +### Deploy/Dev Architecture + +- **DevCommand**: Uses `chokidar` to watch files and FTP deploy on changes + - Debounces uploads (200ms) and uses Bottleneck for concurrency control (maxConcurrent: 1) + - File matching uses `multimatch` with include/exclude patterns + - Default includes: `pb_*`, `package.json`, `bun.lockb`, `patches` + - Default excludes: `pb_data` and its contents + +- **DeployCommand**: One-time deploy using the same `deployMyCode()` function + - FTP server: `ftp.pockethost.io` + - Username: `__auth__` (special auth marker) + - Password: PocketBase auth cookie + +- **FTP Deploy**: Uses `@samkirkland/ftp-deploy` from a forked GitHub repo (benallfree/ftp-deploy#132389e) + +### Logs Streaming + +- **LogsCommand**: Streams logs using Server-Sent Events (SSE) via `@sentool/fetch-event-source` +- Fetches from: `https://{instanceName}.pockethost.io/logs` +- Implements automatic reconnection with exponential backoff (100ms initial) +- Returns `[Promise, Unsubscribe]` tuple for clean shutdown +- Handles `SIGINT`, `SIGTERM`, `SIGHUP` for graceful termination + +### Utilities + +- **Task runner** (`src/lib/Task.ts`): Runs async tasks with `ora` spinners for visual feedback +- **Constants** (`src/lib/constants.ts`): Centralized environment variable access and path resolution +- **Config** (`src/lib/config.ts`): JSON file-based config storage using `fs-extra` + +## TypeScript Configuration + +- Uses `moduleResolution: "bundler"` (optimized for Bun/modern bundlers) +- `skipLibCheck: true` is set to avoid lib errors with the simplified tsconfig +- Runtime: Designed to run with `tsx` or `bun` + +## PocketBase Integration + +- API URL: `https://pockethost-central.pockethost.io` (configurable via `PHIO_MOTHERSHIP_URL`) +- Collections used: + - `users`: Authentication + - `instances`: Instance management (fields in `src/lib/InstanceFields.ts`) +- Authentication persisted via PocketBase auth cookie + +## Key Dependencies + +- `commander`: CLI framework +- `pocketbase`: PocketBase SDK +- `@inquirer/prompts`: Interactive prompts +- `chokidar`: File watching +- `ora`: Progress spinners +- `bottleneck`: Rate limiting +- `multimatch`: Glob pattern matching +- Custom SSE implementation via submodule: `@sentool/fetch-event-source` diff --git a/README.md b/README.md index 712b62a..04b34d0 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,80 @@ bunx phio deploy [instance] bunx phio logs [instance] ``` +**List remote files** + +```bash +bunx phio lsfiles [instance] [path] + +# List pb_public directory (default) +bunx phio lsfiles + +# List a specific directory +bunx phio lsfiles my-instance pb_hooks + +# Output example: +# Connecting to FTP server... +# Listing contents of my-instance/pb_public: +# +# DIR assets 2025-01-06 +# FILE index.html (464 B) 2025-01-06 +# FILE vite.svg (1.46 KB) 2025-01-06 +``` + +**Sync local directory to remote** + +Features: +- Scans local directory and remote pb_public +- Shows preview of changes (NEW, UPDATE, DELETE) +- Requires confirmation unless --yes flag is used +- Uploads all local files +- Optionally deletes remote files with --delete flag + +Preview output: +- Shows files to upload (NEW/UPDATE) with sizes +- Shows files to delete (if --delete is used) +- Limits preview to 20 files, shows count if more +- Confirms before making changes +- +```bash +bunx phio sync-public [instance] [options] + +# Options: +# -l, --local-dir Local directory to sync (default: "./pb_public") +# -r, --remote-dir Remote directory (default: "pb_public") +# -y, --yes Skip confirmation prompt +# --delete Delete remote files not in local directory + +# Examples: +bunx phio sync-public # Sync ./pb_public to pb_public +bunx phio sync-public -l ./dist # Sync ./dist to pb_public +bunx phio sync-public -l ./dist -r pb_public # Explicit local and remote +bunx phio sync-public -l ./hooks -r pb_hooks # Sync hooks directory +bunx phio sync-public --yes --delete # Auto-confirm and delete extra files + +# bunx phio sync-public -l ./dist +# Connecting to FTP server... +# Scanning remote directory... +# Scanning local directory... + +# šŸ“‹ Preview of changes: + +# šŸ“¤ Files to upload (3): +# UPDATE index.html (1.09 MB) +# UPDATE vite.svg (1.46 KB) +# NEW test.md (0 B) + +# ? Proceed with syncing 3 uploads and 0 deletions? (y/N) Yes + +# šŸ“¤ Uploading files... +# āœ“ index.html +# āœ“ vite.svg +# āœ“ test.md + +# āœ… Sync complete! +``` + + ## Configuration Use `pockethost` in your `package.json` to save your instance name so you don't need to keep typing it: diff --git a/bun.lock b/bun.lock index 9b3384d..3001e4e 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "env-var": "^7.5.0", "event-stream": "^4.0.1", "fs-extra": "^11.3.0", + "globby": "^15.0.0", "multimatch": "^7.0.0", "ora": "^8.2.0", "pocketbase": "^0.25.1", @@ -164,6 +165,8 @@ "@sentool/fetch-event-source": ["@sentool/fetch-event-source@github:pockethost/sentool-fetch-event-source#c975adc", {}, "pockethost-sentool-fetch-event-source-c975adc"], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@types/bun": ["@types/bun@1.2.3", "", { "dependencies": { "bun-types": "1.2.3" } }, "sha512-054h79ipETRfjtsCW9qJK8Ipof67Pw9bodFWmkfkaUaRiIQ1dIV2VTlheshlBx3mpKr0KeK8VqnMMCtgN9rQtw=="], "@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="], @@ -288,7 +291,7 @@ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "globby": ["globby@15.0.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "path-type": "^6.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.3.0" } }, "sha512-oB4vkQGqlMl682wL1IlWd02tXCbquGWM4voPEI85QmNKCaw8zGTm1f1rubFgkg3Eli2PtKlFgrnmUqasbQWlkw=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -296,7 +299,7 @@ "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -380,7 +383,7 @@ "path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="], - "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "path-type": ["path-type@6.0.0", "", {}, "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ=="], "pause-stream": ["pause-stream@0.0.11", "", { "dependencies": { "through": "~2.3" } }, "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A=="], @@ -430,7 +433,7 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], @@ -470,6 +473,8 @@ "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -510,6 +515,8 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@manypkg/get-packages/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "@samkirkland/ftp-deploy/multimatch": ["multimatch@5.0.0", "", { "dependencies": { "@types/minimatch": "^3.0.3", "array-differ": "^3.0.0", "array-union": "^2.1.0", "arrify": "^2.0.1", "minimatch": "^3.0.4" } }, "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -518,12 +525,12 @@ "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "dir-glob/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "glob/minimatch": ["minimatch@10.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ=="], - "globby/array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], - "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -578,6 +585,12 @@ "@manypkg/get-packages/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "@manypkg/get-packages/globby/array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + + "@manypkg/get-packages/globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@manypkg/get-packages/globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@samkirkland/ftp-deploy/multimatch/array-differ": ["array-differ@3.0.0", "", {}, "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg=="], "@samkirkland/ftp-deploy/multimatch/array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], diff --git a/package.json b/package.json index 9bb9a6c..ffa48bc 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "env-var": "^7.5.0", "event-stream": "^4.0.1", "fs-extra": "^11.3.0", + "globby": "^15.0.0", "multimatch": "^7.0.0", "ora": "^8.2.0", "pocketbase": "^0.25.1", @@ -64,4 +65,4 @@ "prettier-plugin-organize-imports" ] } -} +} \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index e68990e..c754a2d 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,9 +6,11 @@ import { DevCommand } from './commands/DevCommand' import { InfoCommand } from './commands/InfoCommand' import { LinkCommand } from './commands/LinkCommand' import { ListCommand } from './commands/ListCommand' +import { ListFilesCommand } from './commands/ListFilesCommand' import { LoginCommand } from './commands/LoginCommand' import { LogoutCommand } from './commands/LogoutCommand' import { LogsCommand } from './commands/LogsCommand' +import { SyncPublicCommand } from './commands/SyncPublicCommand' import { WhoAmICommand } from './commands/WhoAmICommand' program @@ -20,6 +22,8 @@ program .addCommand(DevCommand()) .addCommand(WhoAmICommand()) .addCommand(ListCommand()) + .addCommand(ListFilesCommand()) + .addCommand(SyncPublicCommand()) .addCommand(LinkCommand()) .addCommand(DeployCommand()) .addCommand(LogoutCommand()) diff --git a/src/commands/ListFilesCommand.ts b/src/commands/ListFilesCommand.ts new file mode 100644 index 0000000..21db065 --- /dev/null +++ b/src/commands/ListFilesCommand.ts @@ -0,0 +1,82 @@ +import { Client } from 'basic-ftp' +import { Command } from 'commander' +import { savedInstanceName } from '../lib/defaultInstanceId' +import { ensureLoggedIn } from '../lib/ensureLoggedIn' +import { getClient, getInstanceBySubdomainCnameOrId } from '../lib/getClient' + +export const listFiles = async ( + instanceName: string, + path: string = 'pb_public' +) => { + if (!instanceName) { + throw new Error( + `No instance name provided and none was found in package.json or pockethost.json. Use 'phio link '` + ) + } + + await ensureLoggedIn() + const pbClient = await getClient() + + // Verify instance exists + try { + await getInstanceBySubdomainCnameOrId(instanceName) + } catch (error) { + throw new Error(`Instance ${instanceName} not found`) + } + + const ftpClient = new Client() + ftpClient.ftp.verbose = false + + try { + console.log(`Connecting to FTP server...`) + await ftpClient.access({ + host: 'ftp.pockethost.io', + user: '__auth__', + password: pbClient.authStore.exportToCookie(), + secure: false, + }) + + const remotePath = `${instanceName}/${path}` + console.log(`Listing contents of ${remotePath}:\n`) + + const files = await ftpClient.list(remotePath) + + if (files.length === 0) { + console.log('(empty directory)') + } else { + files.forEach((file) => { + const type = file.isDirectory ? 'DIR ' : 'FILE' + const size = file.isDirectory ? '' : `(${formatBytes(file.size)})` + const date = file.modifiedAt + ? file.modifiedAt.toISOString().split('T')[0] + : '' + console.log( + `${type} ${file.name.padEnd(40)} ${size.padEnd(12)} ${date}` + ) + }) + } + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to list files: ${error.message}`) + } + throw error + } finally { + ftpClient.close() + } +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i] +} + +export const ListFilesCommand = () => { + return new Command('lsfiles') + .argument('[instanceName]', 'Instance name', savedInstanceName()) + .argument('[path]', 'Path to list (relative to instance root)', 'pb_public') + .description('List files in instance directory') + .action(listFiles) +} diff --git a/src/commands/SyncPublicCommand.ts b/src/commands/SyncPublicCommand.ts new file mode 100644 index 0000000..66c6c9b --- /dev/null +++ b/src/commands/SyncPublicCommand.ts @@ -0,0 +1,229 @@ +import { confirm } from '@inquirer/prompts' +import { Client } from 'basic-ftp' +import { Command } from 'commander' +import { existsSync, statSync } from 'fs' +import { globby } from 'globby' +import { join } from 'path' +import { savedInstanceName } from '../lib/defaultInstanceId' +import { ensureLoggedIn } from '../lib/ensureLoggedIn' +import { getClient, getInstanceBySubdomainCnameOrId } from '../lib/getClient' + +type FileInfo = { + path: string + size: number + isDirectory: boolean +} + +export const syncPublic = async ( + instanceName: string, + options: { yes: boolean; delete: boolean; localDir: string; remoteDir: string } +) => { + const { localDir, remoteDir } = options + if (!instanceName) { + throw new Error( + `No instance name provided and none was found in package.json or pockethost.json. Use 'phio link '` + ) + } + + if (!existsSync(localDir)) { + throw new Error(`Local directory '${localDir}' does not exist`) + } + + if (!statSync(localDir).isDirectory()) { + throw new Error(`'${localDir}' is not a directory`) + } + + await ensureLoggedIn() + const pbClient = await getClient() + + // Verify instance exists + try { + await getInstanceBySubdomainCnameOrId(instanceName) + } catch (error) { + throw new Error(`Instance ${instanceName} not found`) + } + + const ftpClient = new Client() + ftpClient.ftp.verbose = false + + try { + console.log(`Connecting to FTP server...`) + await ftpClient.access({ + host: 'ftp.pockethost.io', + user: '__auth__', + password: pbClient.authStore.exportToCookie(), + secure: false, + }) + + const remotePath = `${instanceName}/${remoteDir}` + + // Get remote files + console.log(`Scanning remote directory...`) + const remoteFiles = await getRemoteFiles(ftpClient, remotePath) + const remoteFileSet = new Set(remoteFiles.map((f) => f.path)) + + // Get local files + console.log(`Scanning local directory...`) + const localFiles = await getLocalFiles(localDir) + const localFileSet = new Set(localFiles.map((f) => f.path)) + + // Calculate changes + const toUpload = localFiles.filter((f) => !f.isDirectory) + const toDelete = options.delete + ? remoteFiles.filter((f) => !localFileSet.has(f.path) && !f.isDirectory) + : [] + + // Show preview + console.log(`\nšŸ“‹ Preview of changes:\n`) + + if (toUpload.length > 0) { + console.log(`šŸ“¤ Files to upload (${toUpload.length}):`) + toUpload.slice(0, 20).forEach((f) => { + const status = remoteFileSet.has(f.path) ? 'UPDATE' : 'NEW ' + console.log(` ${status} ${f.path} (${formatBytes(f.size)})`) + }) + if (toUpload.length > 20) { + console.log(` ... and ${toUpload.length - 20} more files`) + } + console.log() + } + + if (toDelete.length > 0) { + console.log(`šŸ—‘ļø Files to delete (${toDelete.length}):`) + toDelete.slice(0, 20).forEach((f) => { + console.log(` DELETE ${f.path}`) + }) + if (toDelete.length > 20) { + console.log(` ... and ${toDelete.length - 20} more files`) + } + console.log() + } + + if (toUpload.length === 0 && toDelete.length === 0) { + console.log(`✨ No changes detected - remote and local are in sync!`) + return + } + + // Confirm + if (!options.yes) { + const confirmed = await confirm({ + message: `Proceed with syncing ${toUpload.length} uploads and ${toDelete.length} deletions?`, + default: false, + }) + + if (!confirmed) { + console.log(`āŒ Sync cancelled`) + return + } + } + + // Upload files + if (toUpload.length > 0) { + console.log(`\nšŸ“¤ Uploading files...`) + for (const file of toUpload) { + const localPath = join(localDir, file.path) + const remoteFilePath = `${instanceName}/${remoteDir}/${file.path}` + await ftpClient.uploadFrom(localPath, remoteFilePath) + console.log(` āœ“ ${file.path}`) + } + } + + // Delete files + if (toDelete.length > 0) { + console.log(`\nšŸ—‘ļø Deleting remote files...`) + for (const file of toDelete) { + const remoteFilePath = `${instanceName}/${remoteDir}/${file.path}` + await ftpClient.remove(remoteFilePath) + console.log(` āœ“ ${file.path}`) + } + } + + console.log(`\nāœ… Sync complete!`) + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to sync: ${error.message}`) + } + throw error + } finally { + ftpClient.close() + } +} + +async function getRemoteFiles( + ftpClient: Client, + remotePath: string +): Promise { + const files: FileInfo[] = [] + + async function scan(path: string, prefix: string = '') { + try { + const list = await ftpClient.list(path) + for (const item of list) { + const relativePath = prefix ? `${prefix}/${item.name}` : item.name + if (item.isDirectory) { + files.push({ + path: relativePath, + size: 0, + isDirectory: true, + }) + await scan(`${path}/${item.name}`, relativePath) + } else { + files.push({ + path: relativePath, + size: item.size, + isDirectory: false, + }) + } + } + } catch (error) { + // Directory might not exist, that's ok + } + } + + await scan(remotePath) + return files +} + +async function getLocalFiles(localDir: string): Promise { + const files: FileInfo[] = [] + const paths = await globby('**/*', { + cwd: localDir, + dot: true, + followSymbolicLinks: false, + }) + + for (const path of paths) { + const fullPath = join(localDir, path) + const stat = statSync(fullPath) + files.push({ + path, + size: stat.size, + isDirectory: stat.isDirectory(), + }) + } + + return files +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i] +} + +export const SyncPublicCommand = () => { + return new Command('sync-public') + .argument('[instanceName]', 'Instance name', savedInstanceName()) + .description('Sync local directory with remote directory') + .option('-l, --local-dir ', 'Local directory to sync', './pb_public') + .option( + '-r, --remote-dir ', + 'Remote directory (relative to instance root)', + 'pb_public' + ) + .option('-y, --yes', 'Skip confirmation prompt', false) + .option('--delete', 'Delete remote files that do not exist locally', false) + .action(syncPublic) +} diff --git a/tsconfig.json b/tsconfig.json index bc2f1d1..bdda232 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,13 @@ "resolveJsonModule": true, "moduleResolution": "bundler", "strict": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + + // šŸ’¬ 2025-10-06 rvion: + // without this, we have 20+ errors because this simplified + // tsconfig file that lacks modern lib entries + // and use "moduleResolution": "bundler" + // proposal: use "skipLibCheck" for now ? + "skipLibCheck": true } }