diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 473eb4a..9262c4a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,4 +24,6 @@ jobs: - run: npm run build - name: Publish to npm - run: npm publish --access public --tag beta --provenance + run: npm publish --access public --tag beta + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd74063 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +## 0.0.1-beta.4 (2026-05-22) + +### Windows — Zero-Install Support +- Bundled `rsync.exe` (with msys2 runtime DLLs) and BusyBox-w64 (`awk` provider) in `bin/win32/`. No more "install Git for Windows / cwRsync" prerequisite — `instawp local clone`, `local push/pull`, and `sync push/pull` work out of the box on Windows. +- Replaced the external `sqlite3` CLI dependency with the `better-sqlite3` Node module. +- New `src/lib/windows-binaries.ts` resolves bundled binaries; falls back to PATH then common Git-for-Windows install dirs. + +### Bug Fixes (Windows) +- `instawp local clone` now resolves the bundled `mysql2sqlite` script correctly (was broken by `new URL(import.meta.url).pathname` returning `/C:/...`). +- `mysql2sqlite` is invoked as `awk -f script` explicitly; no longer relies on shebang interpretation. +- `rsync` no longer treats Windows drive paths (`C:\...`) as remote hostnames — paths are converted to msys style (`/c/...`) inside `rsyncViaSsh`. +- `-e ssh -i ` argument uses forward slashes + quoted paths so msys/cygwin sh inside rsync parses the key path correctly. +- Eliminated the SQL injection risk in `local clone`'s URL search-replace (now uses bound parameters via better-sqlite3). + +### Internals +- New `scripts/fetch-windows-binaries.sh` (maintainer-only) refreshes the Windows bundle from MSYS2 + frippery.org. +- 32 new tests covering path conversion and bundled-binary resolution. + +## 0.0.1-beta.3 (2026-04-12) + +### New Commands +- `local create` — Create local WordPress sites (powered by WordPress Playground, no Docker needed) +- `local clone ` — Clone an InstaWP cloud site to local (files + database) +- `local start/stop` — Start in foreground or `--background` mode +- `local push/pull` — Sync wp-content between local and cloud (incremental rsync) +- `local list` — Show local sites with running/stopped status +- `local delete` — Remove local sites +- `sites php ` — View or update PHP version and settings +- `sites update ` — Update site label, description, or expiration +- `teams switch ` — Switch active team context + +### Improvements +- `create --wp ` — Specify WordPress version when creating sites +- `sites list` — 50 per page default, `--all` flag, pagination hints +- Login now shows user name and team after success +- Site resolver caches name-to-ID lookups for 10 minutes +- rsync only shows actually changed files (`--itemize-changes`) +- Magic login URL fixed to use correct `/wordpress-auto-login` endpoint + +### Bug Fixes +- Windows: SSH key generation now works (removed Unix-specific shell commands) +- Windows: command detection uses `where` instead of `which` +- `exec/wp --api` flag now works at any position in the command +- Terminal restored after local site Ctrl+C (`stty sane`) + +## 0.0.1-beta.2 (2026-03-23) + +### New Commands +- `local create/clone/start/stop/push/pull/list/delete` — Full local development workflow +- `teams switch` — Client-side team context + +### Improvements +- Site resolver caching +- Incremental rsync output + +## 0.0.1-beta.1 (2026-03-02) + +### Initial Release +- `login` — OAuth browser flow or `--token` +- `whoami` — Show current session +- `create` — Create WordPress sites with provisioning progress +- `sites list/delete` — Manage sites +- `exec/wp` — Run commands via SSH or API +- `ssh` — Interactive SSH sessions +- `sync push/pull` — rsync wp-content via SSH +- `teams list/members` — View teams +- `--json` mode for all commands diff --git a/CLAUDE.md b/CLAUDE.md index 7c9522b..a8dfe0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,34 +22,55 @@ src/ │ ├── exec.ts # exec + wp commands (merged, --api/--ssh transport) │ ├── ssh.ts # Interactive SSH shell │ ├── sync.ts # rsync push/pull via SSH -│ └── teams.ts # teams list/members +│ ├── teams.ts # teams list/switch/members +│ └── local.ts # local create/clone/start/stop/push/pull/list/delete ├── lib/ -│ ├── api.ts # Axios client, auth interceptor, 401/429 handling +│ ├── api.ts # Axios client, auth interceptor, team_id injection │ ├── auth.ts # OAuth flow (local HTTP server for callback) │ ├── config.ts # Conf-based persistent config (~/.config/instawp/) +│ ├── local-env.ts # Playground server management, background mode │ ├── output.ts # chalk/ora output helpers, --json mode -│ ├── site-resolver.ts # Resolve site by ID, name, or domain +│ ├── site-resolver.ts # Resolve site by ID/name/domain with caching │ ├── ssh-keys.ts # SSH key generation, upload, caching │ └── ssh-connection.ts # SSH/rsync spawn helpers -└── __tests__/ # Vitest tests (148 tests) +├── __tests__/ # Vitest tests (148 tests) +scripts/ +└── mysql2sqlite # MySQL→SQLite dump converter (vendored) ``` ## Commands ``` +# Auth instawp login [--token ] [--api-url ] instawp whoami -instawp sites list [--status ] [--page ] -instawp sites create --name [--php ] [--config ] -instawp create --name # alias for sites create + +# Sites (cloud) +instawp sites list [--status ] [--page ] [--per-page ] [--all] +instawp create --name [--php ] [--config ] instawp sites delete [--force] + +# Remote access instawp exec [--api] [--timeout ] -instawp wp [--api] # shorthand: prepends `wp` to args +instawp wp [--api] instawp ssh instawp sync push [--path] [--exclude] [--dry-run] instawp sync pull [--path] [--exclude] [--dry-run] + +# Teams instawp teams list +instawp teams switch [team] # client-side team context instawp teams members + +# Local development (powered by WordPress Playground) +instawp local create [--name ] [--wp ] [--php ] [--background] [--no-open] +instawp local clone [--name ] [--no-start] +instawp local start [name] [--background] [--no-open] +instawp local stop [name] +instawp local push [cloud-site] [--dry-run] +instawp local pull [--dry-run] +instawp local list +instawp local delete [--force] ``` All commands support `--json` for machine-readable output. @@ -62,11 +83,41 @@ All commands support `--json` for machine-readable output. - `--api`: uses `POST /sites/{id}/run-cmd` API → cloud-app → InstaCP `v-instawp-run-cmd` - Both transports can run arbitrary commands (API is not WP-only despite the name) -### Site resolution +### Site resolution + caching - `resolveSite()` accepts ID (numeric), name, or domain - Numeric → direct `GET /sites/{id}/details` - String → fetches list, matches by name/sub_domain/domain, then fetches details -- Errors on zero matches or ambiguous multiple matches +- **Caches** name→ID mappings for 10 minutes (avoids list call on repeat lookups) + +### Team context +- `teams switch` stores team_id locally (no server-side change) +- API interceptor injects `team_id` as query param on all requests +- Client-app `SiteService::getList()` already accepts `team_id` parameter + +### Local development architecture +- Uses **WordPress Playground** (`@wp-playground/cli`) — WASM PHP + SQLite, no Docker needed +- NOT a hard dependency — auto-downloaded via `npx`, faster if installed globally (`npm i -g @wp-playground/cli`) +- Instance data stored at `~/.instawp/local//` +- Fresh sites: mount entire `wp-content` before install (`--mount-before-install`) +- Cloned sites: mount subdirs individually after install (`--mount`) so Playground sets up `db.php` internally + +### Clone flow (local clone) +1. Export MySQL dump via SSH (`wp db export`) +2. Strip SSH MOTD from dump output +3. Convert MySQL → SQLite using `mysql2sqlite` (awk script) +4. Import directly into `.ht.sqlite` via `sqlite3` CLI +5. Rename table prefix to `wp_` (tables + meta keys + option names) +6. Search-replace cloud URL → `http://127.0.0.1:` across all tables +7. Pull wp-content via rsync (plugins, themes, uploads) +8. Pull non-core root files (CLAUDE.md, .htaccess, etc.) +9. Generate blueprint with `WP_SQLITE_AST_DRIVER=true` + `login` step with actual admin username +10. Write error suppression mu-plugin + +### Background mode +- `--background` flag spawns detached process, polls until server responds, returns immediately +- PID stored at `/server.pid`, logs at `/server.log` +- `local stop` kills the background process +- `local list` shows `running`/`stopped` status ### SSH key management - Auto-generates RSA 4096 key at `~/.instawp/cli_key` if needed @@ -77,7 +128,49 @@ All commands support `--json` for machine-readable output. ### Config storage - Uses `conf` package → `~/.config/instawp/config.json` - Env overrides: `INSTAWP_TOKEN`, `INSTAWP_API_URL` -- SSH cache with TTL stored alongside auth config +- Stores: auth, SSH cache, site cache, team_id, local instances + +## Vendored Dependencies + +### `scripts/mysql2sqlite` +- **Source**: https://github.com/dumblob/mysql2sqlite +- **License**: MIT +- **What**: AWK script that converts MySQL dump files to SQLite-compatible SQL +- **Used by**: `local clone` for database import +- **Version**: Vendored from master branch (2026-03-23) +- **Update procedure**: Download latest from `https://raw.githubusercontent.com/dumblob/mysql2sqlite/master/mysql2sqlite` and replace `scripts/mysql2sqlite`. Test with `instawp local clone` on a WooCommerce site to verify compatibility. + +## Windows Support + +Windows ships with `ssh`/`scp` but not `rsync`, `awk`, or `sqlite3`. The CLI works on Windows with zero extra installs via: + +- **`better-sqlite3`** (npm dep) — replaces the sqlite3 CLI. Native module, prebuilt binaries for win32-x64. +- **`bin/win32/busybox.exe`** — provides `awk` for the `mysql2sqlite` script (invoked as `busybox awk -f ...`). +- **`bin/win32/rsync.exe`** + `msys-*.dll` — used for sync/push/pull. The DLLs must remain colocated with rsync.exe. + +### Resolution order +- `findAwk()` / `bundledRsync()` (`src/lib/windows-binaries.ts`) prefer bundled binaries on Windows, then fall back to system PATH, then check common Git-for-Windows install dirs. +- On macOS/Linux, the bundle is ignored — `rsync`/`awk` come from PATH. + +### Refreshing the Windows bundle +```bash +# Maintainer-only — run on macOS/Linux with curl + 7z (`brew install p7zip`) +bash scripts/fetch-windows-binaries.sh +git add bin/win32 && git commit -m 'chore: refresh Windows binaries' +``` +See `bin/win32/NOTICE.md` for sources and license obligations (BusyBox is GPL-2.0, rsync is GPL-3.0). + +### Cross-platform path handling +- `src/lib/paths.ts` → `toRsyncPath()` converts `C:\foo\bar` → `/c/foo/bar` so msys rsync doesn't interpret `C:` as a hostname. +- `rsyncViaSsh()` applies `toRsyncPath` centrally; the embedded `-e "ssh -i ... -o ..."` string uses forward slashes + quotes so msys/cygwin sh parses key paths correctly. + +## Known Limitations + +### Local clone + SQLite +- **WP_SQLITE_AST_DRIVER=true** is required for complex plugins (WooCommerce). The new AST-based SQLite driver (v2.2.1+) handles 99% of MySQL queries. +- Some MySQL-specific queries may still fail at runtime (rare edge cases in complex plugins) +- PHP deprecation warnings can crash WASM PHP — suppressed via mu-plugin (`error_reporting(E_ERROR | E_PARSE)`) +- `downloads.w.org` is unreachable on some networks — connectivity pre-check warns the user ## API Endpoints Used @@ -85,7 +178,7 @@ All commands support `--json` for machine-readable output. |----------|---------| | `GET /api/v2/sites` | sites list, site resolver | | `GET /api/v2/sites/{id}/details` | site resolver | -| `POST /api/v2/sites` | sites create | +| `POST /api/v2/sites` | sites create, local push (auto-create) | | `DELETE /api/v2/sites/{id}` | sites delete | | `POST /api/v2/sites/{id}/run-cmd` | exec --api, wp --api | | `GET /api/v2/tasks/{id}/status` | create (poll provisioning) | @@ -113,6 +206,8 @@ npm run build node dist/index.js --help node dist/index.js login --token node dist/index.js sites list +node dist/index.js local create --name test --background --no-open +node dist/index.js local stop test ``` Or link globally: @@ -127,8 +222,8 @@ instawp --help ```bash # Bump version in package.json, then: -git tag v0.0.1-beta.1 -git push origin v0.0.1-beta.1 +git tag v0.0.1-beta.2 +git push origin v0.0.1-beta.2 ``` - Publishes with `--tag beta` (install via `npm i -g @instawp/cli@beta`) @@ -142,3 +237,5 @@ git push origin v0.0.1-beta.1 - Spinners stop before printing output (no interleaved text) - JSON mode returns `{ success, data }` or `{ success: false, error }` - Version reads from package.json at runtime (single source of truth) +- rsync uses `--itemize-changes` (only shows actually changed files) +- Terminal restored with `stty sane` after Playground exits diff --git a/bin/win32/NOTICE.md b/bin/win32/NOTICE.md new file mode 100644 index 0000000..8ff7ba5 --- /dev/null +++ b/bin/win32/NOTICE.md @@ -0,0 +1,38 @@ +# Bundled Windows binaries + +Files in this directory are third-party binaries bundled into the npm package +so that `instawp local clone`, `instawp local push/pull`, and `instawp sync` +work on Windows without requiring users to install rsync, awk, or sqlite3. + +Populate this directory by running `scripts/fetch-windows-binaries.sh`. + +## Components and licenses + +### busybox.exe +- **Source**: BusyBox-w64 by Ron Yorston — https://frippery.org/busybox/ +- **License**: GPL-2.0 +- **Used for**: provides `awk` (invoked as `busybox.exe awk -f ...`) for + converting MySQL dumps to SQLite via the vendored `scripts/mysql2sqlite` + awk script. + +### rsync.exe + msys-*.dll +- **Source**: Git for Windows portable distribution — https://gitforwindows.org/ +- **License**: rsync is GPL-3.0; msys2-runtime DLLs are mixed (mostly LGPL/MIT) +- **Used for**: file sync between local and remote sites in `sync push/pull` + and `local push/pull/clone`. + +The `msys-2.0.dll` and other `msys-*.dll` files must remain colocated with +rsync.exe — rsync.exe links against them at runtime. + +## License compliance + +The CLI is MIT-licensed, but the bundled GPL binaries impose obligations on +**redistribution**: + +- Users who receive the binaries are entitled to the corresponding source. +- BusyBox source: https://busybox.net/downloads/ +- rsync source: https://download.samba.org/pub/rsync/src/ +- Git for Windows source: https://github.com/git-for-windows/git + +The maintainer's responsibility is to keep this NOTICE.md shipped alongside +the binaries in the npm tarball. diff --git a/bin/win32/busybox.exe b/bin/win32/busybox.exe new file mode 100644 index 0000000..8d28cba Binary files /dev/null and b/bin/win32/busybox.exe differ diff --git a/bin/win32/msys-2.0.dll b/bin/win32/msys-2.0.dll new file mode 100755 index 0000000..280e633 Binary files /dev/null and b/bin/win32/msys-2.0.dll differ diff --git a/bin/win32/msys-crypto-3.dll b/bin/win32/msys-crypto-3.dll new file mode 100755 index 0000000..a7c8cf2 Binary files /dev/null and b/bin/win32/msys-crypto-3.dll differ diff --git a/bin/win32/msys-iconv-2.dll b/bin/win32/msys-iconv-2.dll new file mode 100755 index 0000000..42f6106 Binary files /dev/null and b/bin/win32/msys-iconv-2.dll differ diff --git a/bin/win32/msys-lz4-1.dll b/bin/win32/msys-lz4-1.dll new file mode 100755 index 0000000..8107a57 Binary files /dev/null and b/bin/win32/msys-lz4-1.dll differ diff --git a/bin/win32/msys-xxhash-0.dll b/bin/win32/msys-xxhash-0.dll new file mode 100755 index 0000000..1a3cd08 Binary files /dev/null and b/bin/win32/msys-xxhash-0.dll differ diff --git a/bin/win32/msys-zstd-1.dll b/bin/win32/msys-zstd-1.dll new file mode 100755 index 0000000..1058b16 Binary files /dev/null and b/bin/win32/msys-zstd-1.dll differ diff --git a/bin/win32/rsync.exe b/bin/win32/rsync.exe new file mode 100755 index 0000000..7256f9f Binary files /dev/null and b/bin/win32/rsync.exe differ diff --git a/package-lock.json b/package-lock.json index e916316..71de7e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "@instawp/cli", - "version": "0.1.0", + "version": "0.0.1-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@instawp/cli", - "version": "0.1.0", + "version": "0.0.1-beta.4", + "license": "MIT", "dependencies": { "axios": "^1.7.0", + "better-sqlite3": "^11.10.0", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -20,6 +22,7 @@ "instawp": "dist/index.js" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20.0.0", "typescript": "^5.5.0", "vitest": "^4.0.18" @@ -844,6 +847,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1072,6 +1085,81 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1122,6 +1210,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1223,6 +1317,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -1272,6 +1390,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", @@ -1307,6 +1434,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -1423,6 +1559,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1473,6 +1618,12 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1509,6 +1660,12 @@ "node": ">= 6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1582,6 +1739,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1633,6 +1796,38 @@ "node": ">= 0.4" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1806,6 +2001,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1825,6 +2047,24 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1836,6 +2076,15 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -1971,12 +2220,78 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2059,6 +2374,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2090,6 +2425,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2126,6 +2506,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2176,6 +2565,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", @@ -2191,6 +2589,34 @@ "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2235,6 +2661,18 @@ "node": ">=14.0.0" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -2280,6 +2718,12 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -2456,6 +2900,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/package.json b/package.json index f198f1c..a211206 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@instawp/cli", - "version": "0.0.1-beta.1", + "version": "0.0.1-beta.4", "description": "InstaWP CLI - Create and manage WordPress sites from the terminal", "type": "module", "bin": { @@ -8,7 +8,10 @@ }, "files": [ "dist", - "!dist/__tests__" + "!dist/__tests__", + "scripts/mysql2sqlite", + "bin/win32/**", + "CHANGELOG.md" ], "scripts": { "build": "tsc", @@ -40,6 +43,7 @@ "license": "MIT", "dependencies": { "axios": "^1.7.0", + "better-sqlite3": "^11.10.0", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -48,6 +52,7 @@ "ora": "^8.1.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20.0.0", "typescript": "^5.5.0", "vitest": "^4.0.18" diff --git a/scripts/fetch-windows-binaries.sh b/scripts/fetch-windows-binaries.sh new file mode 100755 index 0000000..7c633d4 --- /dev/null +++ b/scripts/fetch-windows-binaries.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Fetches the Windows binaries that ship in the npm package: +# - busybox.exe (provides `awk` for mysql2sqlite) +# - rsync.exe + msys-*.dll runtime deps (for sync/push/pull/clone) +# +# Sources: +# - BusyBox-w64 from https://frippery.org/busybox/ (GPL-2.0) +# - rsync and msys2-runtime DLLs from MSYS2 official repo +# https://repo.msys2.org/msys/x86_64/ (mixed FOSS licenses) +# +# Run this once before publishing. Binaries land in bin/win32/ and should be +# committed to git so they ship in the npm tarball. +# +# Requires: curl, tar, zstd (macOS: `brew install zstd`). +# +# To bump versions, edit the constants below or set them via env: +# RSYNC_VER=3.4.0-1 bash scripts/fetch-windows-binaries.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BIN_DIR="$REPO_ROOT/bin/win32" +MSYS_BASE="https://repo.msys2.org/msys/x86_64" + +# Pinned versions (update by browsing the MSYS2 index) +RSYNC_VER="${RSYNC_VER:-3.4.0-1}" +RUNTIME_VER="${RUNTIME_VER:-3.3-3.3.6-11}" +OPENSSL_VER="${OPENSSL_VER:-3.3.2-1}" +ICONV_VER="${ICONV_VER:-1.17-1}" +LZ4_VER="${LZ4_VER:-1.10.0-1}" +XXHASH_VER="${XXHASH_VER:-0.8.2-1}" +ZSTD_VER="${ZSTD_VER:-1.5.6-1}" + +# Package → DLL/exe file we want from it +declare -a PACKAGES=( + "rsync-${RSYNC_VER} usr/bin/rsync.exe" + "msys2-runtime-${RUNTIME_VER} usr/bin/msys-2.0.dll" + "libopenssl-${OPENSSL_VER} usr/bin/msys-crypto-3.dll" + "libiconv-${ICONV_VER} usr/bin/msys-iconv-2.dll" + "liblz4-${LZ4_VER} usr/bin/msys-lz4-1.dll" + "libxxhash-${XXHASH_VER} usr/bin/msys-xxhash-0.dll" + "libzstd-${ZSTD_VER} usr/bin/msys-zstd-1.dll" +) + +for cmd in curl tar zstd; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Error: $cmd not found." >&2 + if [[ "$cmd" == "zstd" ]] && [[ "$(uname)" == "Darwin" ]]; then + echo "Install with: brew install zstd" >&2 + fi + exit 1 + fi +done + +mkdir -p "$BIN_DIR" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +echo "==> BusyBox-w64 (awk provider)" +# busybox64u.exe = 64-bit UCRT build (Windows 10+, modern Microsoft C runtime). +# Node 18+ already requires Windows 10, so this matches. +curl -fL --progress-bar -o "$BIN_DIR/busybox.exe" \ + "https://frippery.org/files/busybox/busybox64u.exe" + +echo +echo "==> MSYS2 packages → rsync.exe + DLLs" +for entry in "${PACKAGES[@]}"; do + # Split by whitespace into name + path + read -r pkg path <<< "$entry" + url="$MSYS_BASE/${pkg}-x86_64.pkg.tar.zst" + archive="$TMPDIR/${pkg}.pkg.tar.zst" + echo " - $pkg" + curl -fL --silent -o "$archive" "$url" + # Extract just the file we care about + zstd -d "$archive" -c | tar -xf - -C "$TMPDIR" "$path" + cp "$TMPDIR/$path" "$BIN_DIR/" +done + +echo +echo "==> Done. Bundle contents:" +ls -lh "$BIN_DIR" +echo +echo "Total size: $(du -sh "$BIN_DIR" | cut -f1)" +echo +echo "Next: git add bin/win32 && git commit -m 'chore: refresh Windows binaries'" diff --git a/scripts/mysql2sqlite b/scripts/mysql2sqlite new file mode 100755 index 0000000..2a3e374 --- /dev/null +++ b/scripts/mysql2sqlite @@ -0,0 +1,289 @@ +#!/usr/bin/awk -f + +# Authors: @esperlu, @artemyk, @gkuenning, @dumblob + +# FIXME detect empty input file and issue a warning + +function printerr( s ){ print s | "cat >&2" } + +BEGIN { + if( ARGC != 2 ){ + printerr( \ + "USAGE:\n"\ + " mysql2sqlite dump_mysql.sql > dump_sqlite3.sql\n" \ + " OR\n" \ + " mysql2sqlite dump_mysql.sql | sqlite3 sqlite.db\n" \ + "\n" \ + "NOTES:\n" \ + " Dash in filename is not supported, because dash (-) means stdin." ) + no_END = 1 + exit 1 + } + + # Find INT_MAX supported by both this AWK (usually an ISO C signed int) + # and SQlite. + # On non-8bit-based architectures, the additional bits are safely ignored. + + # 8bit (lower precision should not exist) + s="127" + # "63" + 0 avoids potential parser misbehavior + if( (s + 0) "" == s ){ INT_MAX_HALF = "63" + 0 } + # 16bit + s="32767" + if( (s + 0) "" == s ){ INT_MAX_HALF = "16383" + 0 } + # 32bit + s="2147483647" + if( (s + 0) "" == s ){ INT_MAX_HALF = "1073741823" + 0 } + # 64bit (as INTEGER in SQlite3) + s="9223372036854775807" + if( (s + 0) "" == s ){ INT_MAX_HALF = "4611686018427387904" + 0 } +# # 128bit +# s="170141183460469231731687303715884105728" +# if( (s + 0) "" == s ){ INT_MAX_HALF = "85070591730234615865843651857942052864" + 0 } +# # 256bit +# s="57896044618658097711785492504343953926634992332820282019728792003956564819968" +# if( (s + 0) "" == s ){ INT_MAX_HALF = "28948022309329048855892746252171976963317496166410141009864396001978282409984" + 0 } +# # 512bit +# s="6703903964971298549787012499102923063739682910296196688861780721860882015036773488400937149083451713845015929093243025426876941405973284973216824503042048" +# if( (s + 0) "" == s ){ INT_MAX_HALF = "3351951982485649274893506249551461531869841455148098344430890360930441007518386744200468574541725856922507964546621512713438470702986642486608412251521024" + 0 } +# # 1024bit +# s="89884656743115795386465259539451236680898848947115328636715040578866337902750481566354238661203768010560056939935696678829394884407208311246423715319737062188883946712432742638151109800623047059726541476042502884419075341171231440736956555270413618581675255342293149119973622969239858152417678164812112068608" +# if( (s + 0) "" == s ){ INT_MAX_HALF = "44942328371557897693232629769725618340449424473557664318357520289433168951375240783177119330601884005280028469967848339414697442203604155623211857659868531094441973356216371319075554900311523529863270738021251442209537670585615720368478277635206809290837627671146574559986811484619929076208839082406056034304" + 0 } +# # higher precision probably not needed + + FS=",$" + print "PRAGMA synchronous = OFF;" + print "PRAGMA journal_mode = MEMORY;" + print "BEGIN TRANSACTION;" +} + +# historically 3 spaces separate non-argument local variables +function bit_to_int( str_bit, powtwo, i, res, bit, overflow ){ + powtwo = 1 + overflow = 0 + # 011101 = 1*2^0 + 0*2^1 + 1*2^2 ... + for( i = length( str_bit ); i > 0; --i ){ + bit = substr( str_bit, i, 1 ) + if( overflow || ( bit == 1 && res > INT_MAX_HALF ) ){ + printerr( \ + NR ": WARN Bit field overflow, number truncated (LSBs saved, MSBs ignored)." ) + break + } + res = res + bit * powtwo + # no warning here as it might be the last iteration + if( powtwo > INT_MAX_HALF ){ overflow = 1; continue } + powtwo = powtwo * 2 + } + return res +} + +# CREATE TRIGGER statements have funny commenting. Remember we are in trigger. +/^\/\*.*(CREATE.*TRIGGER|create.*trigger)/ { + gsub( /^.*(TRIGGER|trigger)/, "CREATE TRIGGER" ) + print + inTrigger = 1 + next +} +# The end of CREATE TRIGGER has a stray comment terminator +/(END|end) \*\/;;/ { gsub( /\*\//, "" ); print; inTrigger = 0; next } +# The rest of triggers just get passed through +inTrigger != 0 { print; next } + +# CREATE VIEW looks like a TABLE in comments +/^\/\*.*(CREATE.*TABLE|create.*table)/ { + inView = 1 + next +} +# end of CREATE VIEW +/^(\).*(ENGINE|engine).*\*\/;)/ { + inView = 0 + next +} +# content of CREATE VIEW +inView != 0 { next } + +# skip comments +/^\/\*/ { next } + +# skip PARTITION statements +/^ *[(]?(PARTITION|partition) +[^ ]+/ { next } + +# print all INSERT lines +( /^ *\(/ && /\) *[,;] *$/ ) || /^(INSERT|insert|REPLACE|replace)/ { + prev = "" + + # first replace \\ by \_ that mysqldump never generates to deal with + # sequnces like \\n that should be translated into \n, not \. + # After we convert all escapes we replace \_ by backslashes. + gsub( /\\\\/, "\\_" ) + + # single quotes are escaped by another single quote + gsub( /\\'/, "''" ) + gsub( /\\n/, "\n" ) + gsub( /\\r/, "\r" ) + gsub( /\\"/, "\"" ) + gsub( /\\\032/, "\032" ) # substitute char + + gsub( /\\_/, "\\" ) + + # sqlite3 is limited to 16 significant digits of precision + while( match( $0, /0x[0-9a-fA-F]{17}/ ) ){ + hexIssue = 1 + sub( /0x[0-9a-fA-F]+/, substr( $0, RSTART, RLENGTH-1 ), $0 ) + } + if( hexIssue ){ + printerr( \ + NR ": WARN Hex number trimmed (length longer than 16 chars)." ) + hexIssue = 0 + } + print + next +} + +# CREATE DATABASE is not supported +/^(CREATE DATABASE|create database)/ { next } + +# print the CREATE line as is and capture the table name +/^(CREATE|create)/ { + if( $0 ~ /IF NOT EXISTS|if not exists/ || $0 ~ /TEMPORARY|temporary/ ){ + caseIssue = 1 + printerr( \ + NR ": WARN Potential case sensitivity issues with table/column naming\n" \ + " (see INFO at the end)." ) + } + if( match( $0, /`[^`]+/ ) ){ + tableName = substr( $0, RSTART+1, RLENGTH-1 ) + } + aInc = 0 + prev = "" + firstInTable = 1 + print + next +} + +# Replace `FULLTEXT KEY` (probably other `XXXXX KEY`) +/^ (FULLTEXT KEY|fulltext key)/ { gsub( /[A-Za-z ]+(KEY|key)/, " KEY" ) } + +# Get rid of field lengths in KEY lines +/ (PRIMARY |primary )?(KEY|key)/ { gsub( /\([0-9]+\)/, "" ) } + +aInc == 1 && /PRIMARY KEY|primary key/ { next } + +# Replace COLLATE xxx_xxxx_xx statements with COLLATE BINARY +/ (COLLATE|collate) [a-z0-9_]*/ { gsub( /(COLLATE|collate) [a-z0-9_]*/, "COLLATE BINARY" ) } + +# Print all fields definition lines except the `KEY` lines. +/^ / && !/^( (KEY|key)|\);)/ { + if( match( $0, /[^"`]AUTO_INCREMENT|auto_increment[^"`]/) ){ + aInc = 1 + gsub( /AUTO_INCREMENT|auto_increment/, "PRIMARY KEY AUTOINCREMENT" ) + } + gsub( /(UNIQUE KEY|unique key) (`.*`|".*") /, "UNIQUE " ) + gsub( /(CHARACTER SET|character set) [^ ]+[ ,]/, "" ) + # FIXME + # CREATE TRIGGER [UpdateLastTime] + # AFTER UPDATE + # ON Package + # FOR EACH ROW + # BEGIN + # UPDATE Package SET LastUpdate = CURRENT_TIMESTAMP WHERE ActionId = old.ActionId; + # END + gsub( /(ON|on) (UPDATE|update) (CURRENT_TIMESTAMP|current_timestamp)(\(\))?/, "" ) + gsub( /(DEFAULT|default) (CURRENT_TIMESTAMP|current_timestamp)(\(\))?/, "DEFAULT current_timestamp") + gsub( /(COLLATE|collate) [^ ]+ /, "" ) + gsub( /(ENUM|enum)[^)]+\)/, "text " ) + gsub( /(SET|set)\([^)]+\)/, "text " ) + gsub( /UNSIGNED|unsigned/, "" ) + gsub( /_utf8mb3/, "" ) + gsub( /` [^ ]*(INT|int|BIT|bit)[^ ]*/, "` integer" ) + gsub( /" [^ ]*(INT|int|BIT|bit)[^ ]*/, "\" integer" ) + ere_bit_field = "[bB]'[10]+'" + if( match($0, ere_bit_field) ){ + sub( ere_bit_field, bit_to_int( substr( $0, RSTART +2, RLENGTH -2 -1 ) ) ) + } + + # remove USING BTREE and other suffixes for USING, for example: "UNIQUE KEY + # `hostname_domain` (`hostname`,`domain`) USING BTREE," + gsub( / USING [^, ]+/, "" ) + + # field comments are not supported + gsub( / (COMMENT|comment).+$/, "" ) + # Get commas off end of line + gsub( /,.?$/, "" ) + if( prev ){ + if( firstInTable ){ + print prev + firstInTable = 0 + } + else { + print "," prev + } + } + else { + # FIXME check if this is correct in all cases + if( match( $1, + /(CONSTRAINT|constraint) ["].*["] (FOREIGN KEY|foreign key)/ ) ){ + print "," + } + } + prev = $1 +} + +/ ENGINE| engine/ { + if( prev ){ + if( firstInTable ){ + print prev + firstInTable = 0 + } + else { + print "," prev + } + } + prev="" + print ");" + next +} +# `KEY` lines are extracted from the `CREATE` block and stored in array for later print +# in a separate `CREATE KEY` command. The index name is prefixed by the table name to +# avoid a sqlite error for duplicate index name. +/^( (KEY|key)|\);)/ { + if( prev ){ + if( firstInTable ){ + print prev + firstInTable = 0 + } + else { + print "," prev + } + } + prev = "" + if( $0 == ");" ){ + print + } + else { + if( match( $0, /`[^`]+/ ) ){ + indexName = substr( $0, RSTART+1, RLENGTH-1 ) + } + if( match( $0, /\([^()]+/ ) ){ + indexKey = substr( $0, RSTART+1, RLENGTH-1 ) + } + # idx_ prefix to avoid name clashes (they really happen!) + key[tableName] = key[tableName] "CREATE INDEX \"idx_" \ + tableName "_" indexName "\" ON \"" tableName "\" (" indexKey ");\n" + } +} + +END { + if( no_END ){ exit 1} + # print all KEY creation lines. + for( table in key ){ printf key[table] } + + print "END TRANSACTION;" + + if( caseIssue ){ + printerr( \ + "INFO Pure sqlite identifiers are case insensitive (even if quoted\n" \ + " or if ASCII) and doesnt cross-check TABLE and TEMPORARY TABLE\n" \ + " identifiers. Thus expect errors like \"table T has no column named F\".") + } +} diff --git a/src/__tests__/paths.test.ts b/src/__tests__/paths.test.ts new file mode 100644 index 0000000..61ad5d1 --- /dev/null +++ b/src/__tests__/paths.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { toRsyncPath, resolveFromModule } from '../lib/paths.js'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function mockPlatform(p: NodeJS.Platform): void { + vi.spyOn(process, 'platform', 'get').mockReturnValue(p); +} + +describe('toRsyncPath', () => { + it('is a no-op on non-Windows', () => { + mockPlatform('darwin'); + expect(toRsyncPath('/Users/vikas/foo')).toBe('/Users/vikas/foo'); + expect(toRsyncPath('./wp-content/')).toBe('./wp-content/'); + }); + + it('converts a Windows drive path to msys style', () => { + mockPlatform('win32'); + expect(toRsyncPath('C:\\Users\\vikas\\wp-content\\')).toBe('/c/Users/vikas/wp-content/'); + expect(toRsyncPath('D:\\some\\path')).toBe('/d/some/path'); + }); + + it('accepts forward-slash drive paths too', () => { + mockPlatform('win32'); + expect(toRsyncPath('C:/Users/vikas')).toBe('/c/Users/vikas'); + }); + + it('passes remote user@host: specs through unchanged', () => { + mockPlatform('win32'); + expect(toRsyncPath('runcloud@example.com:/home/runcloud/web')) + .toBe('runcloud@example.com:/home/runcloud/web'); + }); + + it('normalizes UNC-style separators on Windows', () => { + mockPlatform('win32'); + expect(toRsyncPath('\\\\server\\share\\path')).toBe('//server/share/path'); + }); + + it('preserves trailing slash (matters for rsync semantics)', () => { + mockPlatform('win32'); + expect(toRsyncPath('C:\\foo\\bar\\')).toBe('/c/foo/bar/'); + }); +}); + +describe('resolveFromModule', () => { + it('resolves paths relative to the importing module', () => { + const fakeModuleUrl = 'file:///Users/vikas/Playground/repos/cli/dist/commands/local.js'; + const result = resolveFromModule(fakeModuleUrl, '..', '..', 'scripts', 'mysql2sqlite'); + expect(result).toBe('/Users/vikas/Playground/repos/cli/scripts/mysql2sqlite'); + }); +}); diff --git a/src/__tests__/site-resolver.test.ts b/src/__tests__/site-resolver.test.ts index 5df5554..6240bbc 100644 --- a/src/__tests__/site-resolver.test.ts +++ b/src/__tests__/site-resolver.test.ts @@ -11,6 +11,11 @@ vi.mock('../lib/output.js', () => ({ info: vi.fn(), })); +vi.mock('../lib/config.js', () => ({ + getSiteCache: vi.fn().mockReturnValue(null), + setSiteCache: vi.fn(), +})); + // Mock process.exit to throw instead vi.mock('node:process', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/__tests__/ssh-connection.test.ts b/src/__tests__/ssh-connection.test.ts index 7deb93c..a84d3ca 100644 --- a/src/__tests__/ssh-connection.test.ts +++ b/src/__tests__/ssh-connection.test.ts @@ -116,11 +116,12 @@ describe('ssh-connection', () => { expect(code).toBe(0); expect(mockSpawnSync).toHaveBeenCalledWith('rsync', expect.arrayContaining([ - '-avz', + '-arz', + '--itemize-changes', '--exclude=.git', '--exclude=node_modules', '--exclude=.DS_Store', - '-e', expect.stringContaining('-i /home/user/.instawp/cli_key'), + '-e', expect.stringContaining('-i "/home/user/.instawp/cli_key"'), './wp-content/', 'testuser@test.example.com:/home/testuser/web/site.example.com/public_html/wp-content/', ]), expect.any(Object)); diff --git a/src/__tests__/windows-binaries.test.ts b/src/__tests__/windows-binaries.test.ts new file mode 100644 index 0000000..58db10c --- /dev/null +++ b/src/__tests__/windows-binaries.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { ...actual, existsSync: vi.fn() }; +}); + +import { existsSync } from 'node:fs'; + +const mockExists = existsSync as unknown as ReturnType; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + mockExists.mockReset(); +}); + +beforeEach(() => { + vi.resetModules(); +}); + +function mockPlatform(p: NodeJS.Platform): void { + vi.spyOn(process, 'platform', 'get').mockReturnValue(p); +} + +describe('windows-binaries', () => { + it('returns null on non-Windows regardless of file presence', async () => { + mockPlatform('darwin'); + mockExists.mockReturnValue(true); + const { bundledRsync, bundledBusybox } = await import('../lib/windows-binaries.js'); + expect(bundledRsync()).toBeNull(); + expect(bundledBusybox()).toBeNull(); + }); + + it('returns null on Windows when binaries are absent', async () => { + mockPlatform('win32'); + mockExists.mockReturnValue(false); + const { bundledRsync, bundledBusybox } = await import('../lib/windows-binaries.js'); + expect(bundledRsync()).toBeNull(); + expect(bundledBusybox()).toBeNull(); + }); + + it('returns the path on Windows when binaries are present', async () => { + mockPlatform('win32'); + mockExists.mockReturnValue(true); + const { bundledRsync, bundledBusybox } = await import('../lib/windows-binaries.js'); + expect(bundledRsync()).toMatch(/rsync\.exe$/); + expect(bundledBusybox()).toMatch(/busybox\.exe$/); + }); +}); diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 2bbf59c..5ddaaa1 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -114,6 +114,17 @@ export function registerExecCommand(program: Command): void { .option('--api', 'Use API transport instead of SSH') .option('--timeout ', 'Command timeout in seconds (API mode only)', '30') .action(async (siteIdentifier: string, args: string[], opts) => { + // passThroughOptions may swallow --api/--timeout into args — extract them + const extractedApi = args.includes('--api'); + const timeoutIdx = args.indexOf('--timeout'); + let extractedTimeout: string | undefined; + if (timeoutIdx !== -1 && args[timeoutIdx + 1]) { + extractedTimeout = args[timeoutIdx + 1]; + args = args.filter((_, i) => i !== timeoutIdx && i !== timeoutIdx + 1); + } + args = args.filter(a => a !== '--api'); + if (extractedApi) opts.api = true; + if (extractedTimeout) opts.timeout = extractedTimeout; await execAction(siteIdentifier, args, opts); }); } @@ -127,6 +138,18 @@ export function registerWpCommand(program: Command): void { .option('--api', 'Use API transport instead of SSH') .option('--timeout ', 'Command timeout in seconds (API mode only)', '30') .action(async (siteIdentifier: string, args: string[], opts) => { + // passThroughOptions may swallow --api/--timeout into args — extract them + const extractedApi = args.includes('--api'); + const timeoutIdx = args.indexOf('--timeout'); + let extractedTimeout: string | undefined; + if (timeoutIdx !== -1 && args[timeoutIdx + 1]) { + extractedTimeout = args[timeoutIdx + 1]; + args = args.filter((_, i) => i !== timeoutIdx && i !== timeoutIdx + 1); + } + args = args.filter(a => a !== '--api'); + if (extractedApi) opts.api = true; + if (extractedTimeout) opts.timeout = extractedTimeout; + await execAction(siteIdentifier, ['wp', ...args], opts); }); } diff --git a/src/commands/local.ts b/src/commands/local.ts new file mode 100644 index 0000000..f69dd20 --- /dev/null +++ b/src/commands/local.ts @@ -0,0 +1,847 @@ +import { Command } from 'commander'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import chalk from 'chalk'; +import open from 'open'; +import Database from 'better-sqlite3'; +import { resolveFromModule } from '../lib/paths.js'; +import { bundledBusybox, bundledRsync } from '../lib/windows-binaries.js'; +import { + getLocalInstances, + getLocalInstance, + setLocalInstance, + removeLocalInstance, +} from '../lib/config.js'; +import { + getInstanceDir, + getNextPort, + createInstanceDir, + deleteInstanceDir, + startServer, + startServerBackground, + stopServer as stopServerProcess, + isServerRunning, + checkPlaygroundConnectivity, + ensureAutoLogin, +} from '../lib/local-env.js'; +import { requireAuth, getClient } from '../lib/api.js'; +import { resolveSite } from '../lib/site-resolver.js'; +import { ensureSshAccess } from '../lib/ssh-keys.js'; +import { rsyncViaSsh, execViaSsh, execViaSshToFile } from '../lib/ssh-connection.js'; +import { success, error, table, spinner, info, isJsonMode } from '../lib/output.js'; +import type { LocalInstance } from '../types.js'; + +export function registerLocalCommand(program: Command): void { + const local = program + .command('local') + .description('Manage local WordPress sites (powered by WordPress Playground)'); + + // local create + local + .command('create') + .description('Create and start a local WordPress site') + .option('--name ', 'Instance name (auto-generated if omitted)') + .option('--wp ', 'WordPress version', 'latest') + .option('--php ', 'PHP version (7.4-8.5)', '8.3') + .option('--port ', 'Server port') + .option('--blueprint ', 'Blueprint JSON file for setup') + .option('--no-open', 'Do not open browser') + .option('--background', 'Run server in background and return immediately') + .action(async (opts) => { + const instances = getLocalInstances(); + const name = sanitizeName(opts.name || nextAutoName(instances)); + + if (instances[name]) { + error(`Instance "${name}" already exists. Use 'instawp local start ${name}' or choose a different name.`); + process.exit(1); + } + + const spin = spinner(`Creating local WordPress site "${name}"...`); + spin.start(); + + try { + // Pre-check connectivity + const connErr = await checkPlaygroundConnectivity(); + if (connErr) { + spin.fail('Network check failed'); + error(connErr); + process.exit(1); + } + + const port = opts.port ? parseInt(opts.port) : await getNextPort(instances); + const dir = createInstanceDir(name); + spin.stop(); + + const instance: LocalInstance = { + name, + port, + php: opts.php, + wp: opts.wp, + path: dir, + createdAt: new Date().toISOString(), + }; + + setLocalInstance(instance); + + if (!isJsonMode()) { + success(`Instance "${name}" created`); + console.log(`\n${chalk.dim('#')} Starting WordPress ${opts.wp} with PHP ${opts.php}...`); + console.log(`${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}\n`); + } + + await launchServer(instance, opts); + } catch (err: any) { + spin.stop(); + // Clean up on failure + deleteInstanceDir(name); + removeLocalInstance(name); + error('Failed to create local site', err.message); + process.exit(1); + } + }); + + // local start + local + .command('start [name]') + .description('Start a local WordPress site') + .option('--blueprint ', 'Blueprint JSON file') + .option('--no-open', 'Do not open browser') + .option('--background', 'Run server in background and return immediately') + .action(async (name: string | undefined, opts: any) => { + const instanceName = name || 'my-site'; + const instance = getLocalInstance(instanceName); + + if (!instance) { + error(`Instance "${instanceName}" not found. Run 'instawp local create --name ${instanceName}' first.`); + const instances = getLocalInstances(); + const names = Object.keys(instances); + if (names.length > 0) { + info(`Available instances: ${names.join(', ')}`); + } + process.exit(1); + } + + ensureAutoLogin(instance); + await launchServer(instance, opts); + }); + + // local stop [name] + local + .command('stop [name]') + .description('Stop a background local site') + .action((name: string | undefined) => { + const instanceName = name || 'my-site'; + const instance = getLocalInstance(instanceName); + + if (!instance) { + error(`Instance "${instanceName}" not found.`); + process.exit(1); + } + + if (stopServerProcess(instance)) { + success(`Stopped "${instanceName}"`); + } else { + info(`"${instanceName}" is not running in background.`); + } + }); + + // local list + local + .command('list') + .description('List local WordPress sites') + .action(() => { + const instances = getLocalInstances(); + const entries = Object.values(instances); + + if (entries.length === 0) { + if (isJsonMode()) { + console.log(JSON.stringify([])); + } else { + info('No local sites. Create one with: instawp local create'); + } + return; + } + + if (isJsonMode()) { + console.log(JSON.stringify(entries)); + return; + } + + const rows = entries.map((i: LocalInstance) => ({ + name: i.name, + status: isServerRunning(i) ? 'running' : 'stopped', + url: `http://127.0.0.1:${i.port}`, + wp: i.wp, + php: i.php, + path: i.path, + })); + + table(['Name', 'Status', 'URL', 'WP', 'PHP', 'Path'], rows); + }); + + // local delete + local + .command('delete ') + .description('Delete a local WordPress site and its data') + .option('--force', 'Skip confirmation') + .action(async (name: string, opts: any) => { + const instance = getLocalInstance(name); + + if (!instance) { + error(`Instance "${name}" not found.`); + process.exit(1); + } + + if (!opts.force && !isJsonMode()) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question(`Delete local site "${name}" and all its data? (y/N) `, resolve); + }); + rl.close(); + + if (answer.toLowerCase() !== 'y') { + info('Cancelled.'); + return; + } + } + + deleteInstanceDir(name); + removeLocalInstance(name); + + if (isJsonMode()) { + console.log(JSON.stringify({ deleted: name })); + } else { + success(`Instance "${name}" deleted.`); + } + }); + // local push [cloud-site] + local + .command('push [cloud-site]') + .description('Push local wp-content to an InstaWP cloud site') + .option('--include ', 'Include patterns (e.g. .git)') + .option('--exclude ', 'Additional exclude patterns') + .option('--dry-run', 'Show what would be transferred') + .action(async (localName: string, cloudSiteArg: string | undefined, opts: any) => { + requireAuth(); + + const instance = getLocalInstance(localName); + if (!instance) { + error(`Local instance "${localName}" not found.`); + process.exit(1); + } + + if (!checkRsync()) { + error('rsync is required.' + (process.platform === 'win32' ? ' Reinstall the CLI — the bundled rsync.exe is missing.' : ' Install: brew install rsync')); + process.exit(1); + } + + const localWpContent = join(instance.path, 'wp-content') + '/'; + + // If no cloud site specified, create one + let site; + if (!cloudSiteArg) { + const spin = spinner('Creating cloud site...'); + spin.start(); + try { + const client = getClient(); + const res = await client.post('/sites', { site_name: localName }); + site = res.data?.data; + if (!site?.id) throw new Error('Unexpected API response'); + spin.succeed(`Cloud site created (ID: ${site.id})`); + + // Wait for provisioning + const provSpin = spinner('Waiting for site to provision...'); + provSpin.start(); + const taskId = site.task_id; + const maxWait = 5 * 60 * 1000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + if (taskId) { + try { + const taskRes = await client.get(`/tasks/${taskId}/status`); + const task = taskRes.data?.data; + if (task?.status === 'completed' || parseFloat(task?.percentage_complete) >= 100) { + provSpin.succeed('Site provisioned'); + break; + } + if (task?.status === 'error') { + provSpin.fail('Provisioning failed'); + error(task?.comment || 'Unknown error'); + process.exit(1); + } + provSpin.text = `Provisioning... (${Math.round(parseFloat(task?.percentage_complete) || 0)}%)`; + } catch { /* ignore poll errors */ } + } + await new Promise(r => setTimeout(r, 3000)); + } + + // Re-resolve to get full details + site = await resolveSite(String(site.id)); + } catch (err: any) { + spin.fail('Failed to create cloud site'); + error(err.response?.data?.message || err.message); + process.exit(1); + } + } else { + const spin = spinner('Resolving cloud site...'); + spin.start(); + try { + site = await resolveSite(cloudSiteArg); + spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`); + } catch { + spin.fail('Site resolution failed'); + process.exit(1); + } + } + + // Get SSH access + const conn = await ensureSshAccess(site.id); + const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`; + + const extraArgs: string[] = [ + '--exclude=database', // Don't push SQLite database to cloud (cloud uses MySQL) + '--exclude=db.php', + '--exclude=mu-plugins', // Playground mu-plugins are local-only + ]; + if (opts.exclude) { + for (const pattern of opts.exclude) { + extraArgs.push(`--exclude=${pattern}`); + } + } + + const remoteTarget = `${conn.username}@${conn.host}:${remotePath}`; + info(`Pushing ${chalk.dim(localWpContent)} -> ${chalk.dim(conn.host + ':' + remotePath)}`); + if (opts.dryRun) info('(dry run)'); + + const exitCode = rsyncViaSsh(conn, localWpContent, remoteTarget, extraArgs, !!opts.dryRun, true); + + if (exitCode === 0) { + success('Push complete!'); + if (site.url) { + console.log(`\n ${chalk.dim('Cloud site:')} ${chalk.cyan.underline(site.url)}`); + } + } else { + error(`rsync exited with code ${exitCode}`); + process.exit(exitCode); + } + }); + + // local pull + local + .command('pull ') + .description('Pull wp-content from an InstaWP cloud site to local') + .option('--include ', 'Include patterns (e.g. .git)') + .option('--exclude ', 'Additional exclude patterns') + .option('--dry-run', 'Show what would be transferred') + .action(async (localName: string, cloudSiteArg: string, opts: any) => { + requireAuth(); + + const instance = getLocalInstance(localName); + if (!instance) { + error(`Local instance "${localName}" not found.`); + process.exit(1); + } + + if (!checkRsync()) { + error('rsync is required.' + (process.platform === 'win32' ? ' Reinstall the CLI — the bundled rsync.exe is missing.' : ' Install: brew install rsync')); + process.exit(1); + } + + const localWpContent = join(instance.path, 'wp-content') + '/'; + + const spin = spinner('Resolving cloud site...'); + spin.start(); + let site; + try { + site = await resolveSite(cloudSiteArg); + spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`); + } catch { + spin.fail('Site resolution failed'); + process.exit(1); + } + + const conn = await ensureSshAccess(site.id); + const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`; + + const extraArgs: string[] = []; + if (opts.include) { + for (const pattern of opts.include) { + extraArgs.push(`--include=${pattern}`); + } + } + extraArgs.push( + '--exclude=database', // Don't overwrite local SQLite database + '--exclude=db.php', + '--exclude=mu-plugins', + ); + if (opts.exclude) { + for (const pattern of opts.exclude) { + extraArgs.push(`--exclude=${pattern}`); + } + } + + const remoteSource = `${conn.username}@${conn.host}:${remotePath}`; + info(`Pulling ${chalk.dim(conn.host + ':' + remotePath)} -> ${chalk.dim(localWpContent)}`); + if (opts.dryRun) info('(dry run)'); + + const exitCode = rsyncViaSsh(conn, remoteSource, localWpContent, extraArgs, !!opts.dryRun, true); + + if (exitCode === 0) { + success('Pull complete! Restart the local site to see changes.'); + } else { + error(`rsync exited with code ${exitCode}`); + process.exit(exitCode); + } + }); + + // local clone + local + .command('clone ') + .description('Clone a complete InstaWP cloud site to local') + .option('--name ', 'Local instance name (defaults to cloud site name)') + .option('--no-start', 'Do not start the local site after cloning') + .option('--force', 'Overwrite existing local instance') + .option('--include ', 'Include patterns for rsync (e.g. .git)') + .action(async (cloudSiteArg: string, opts: any) => { + requireAuth(); + + if (!checkRsync()) { + error('rsync is required.' + (process.platform === 'win32' ? ' Reinstall the CLI — the bundled rsync.exe is missing.' : ' Install: brew install rsync')); + process.exit(1); + } + + // 1. Resolve cloud site + const spin = spinner('Resolving cloud site...'); + spin.start(); + let site; + try { + site = await resolveSite(cloudSiteArg); + spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`); + } catch { + spin.fail('Site resolution failed'); + process.exit(1); + } + + // 2. Create local instance + const instances = getLocalInstances(); + const name = sanitizeName(opts.name || site.name || site.sub_domain || `site-${site.id}`); + + if (instances[name]) { + if (!opts.force) { + error(`Local instance "${name}" already exists. Use --force to overwrite or --name to pick a different name.`); + process.exit(1); + } + // Force: delete existing instance first + stopServerProcess(instances[name]); + deleteInstanceDir(name); + removeLocalInstance(name); + info(`Existing instance "${name}" removed.`); + } + + const port = await getNextPort(instances); + const dir = createInstanceDir(name); + + const instance: LocalInstance = { + name, + port, + php: normalizePhpVersion(site.php_version) || '8.3', + wp: site.wp_version || 'latest', + path: dir, + createdAt: new Date().toISOString(), + }; + setLocalInstance(instance); + success(`Local instance "${name}" created`); + + // 3. Get SSH access + const conn = await ensureSshAccess(site.id); + + // 4. Export database from cloud + const dumpPath = join(dir, 'database.sql'); + const dbSpin = spinner('Exporting database...'); + dbSpin.start(); + try { + const wpPath = `/home/${conn.username}/web/${conn.domain}/public_html`; + const { exitCode, stderr } = execViaSshToFile( + conn, + `cd ${wpPath} && wp db export --single-transaction -`, + dumpPath, + ); + if (exitCode !== 0) { + dbSpin.fail('Database export failed (will start with fresh DB)'); + if (stderr) info(stderr.trim()); + } else { + const size = statSync(dumpPath).size; + dbSpin.succeed(`Database exported (${(size / 1024 / 1024).toFixed(1)} MB)`); + } + } catch (err: any) { + dbSpin.fail('Database export failed: ' + err.message); + } + + // 5. Pull wp-content via rsync + const localWpContent = join(dir, 'wp-content') + '/'; + const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`; + const remoteSource = `${conn.username}@${conn.host}:${remotePath}`; + + info(`Pulling wp-content from ${chalk.dim(conn.domain)}...`); + const includeArgs: string[] = []; + if (opts.include) { + for (const pattern of opts.include) { + includeArgs.push(`--include=${pattern}`); + } + } + const rsyncExit = rsyncViaSsh(conn, remoteSource, localWpContent, [ + ...includeArgs, + '--exclude=cache', + '--exclude=upgrade', + '--exclude=wflogs', + '--exclude=backup*', + ], false, true); + + if (rsyncExit !== 0) { + error(`wp-content sync failed (rsync exit code ${rsyncExit})`); + } + + // 5b. Pull non-core root files (CLAUDE.md, .htaccess, wp-cli.yml, etc.) + const remoteRoot = `/home/${conn.username}/web/${conn.domain}/public_html/`; + const rootRemote = `${conn.username}@${conn.host}:${remoteRoot}`; + rsyncViaSsh(conn, rootRemote, dir + '/', [ + '--exclude=wp-admin/', + '--exclude=wp-includes/', + '--exclude=wp-content/', + '--exclude=wp-*.php', + '--exclude=index.php', + '--exclude=xmlrpc.php', + '--exclude=license.txt', + '--exclude=readme.html', + ], false, false); + + // 6. Ensure auto-login mu-plugin + ensureAutoLogin(instance); + + // 7. Convert MySQL dump → SQLite, import directly, fix URLs and table prefix + const hasDump = existsSync(dumpPath) && statSync(dumpPath).size > 0; + let adminUsername = 'admin'; + if (hasDump) { + const dbSpin2 = spinner('Importing database...'); + dbSpin2.start(); + try { + const mysql2sqlitePath = resolveFromModule(import.meta.url, '..', '..', 'scripts', 'mysql2sqlite'); + const dbDir = join(dir, 'wp-content', 'database'); + const sqliteDbPath = join(dbDir, '.ht.sqlite'); + + // Clean slate for database dir + if (existsSync(dbDir)) rmSync(dbDir, { recursive: true, force: true }); + mkdirSync(dbDir, { recursive: true }); + + // Strip SSH MOTD from dump + const rawDump = readFileSync(dumpPath, 'utf-8'); + const sqlStart = rawDump.search(/^(\/\*|--|CREATE |DROP |SET |INSERT )/m); + if (sqlStart > 0) { + writeFileSync(dumpPath, rawDump.substring(sqlStart)); + } + + // Convert MySQL → SQLite via awk (mysql2sqlite is an awk script). + // Windows doesn't honor shebangs, so invoke awk explicitly. + const awk = findAwk(); + if (!awk) { + throw new Error('awk not found. ' + (process.platform === 'win32' + ? 'Reinstall the CLI — the bundled busybox.exe is missing.' + : 'Install awk/gawk.')); + } + const convertResult = spawnSync(awk.cmd, [...awk.prefixArgs, '-f', mysql2sqlitePath, dumpPath], { + encoding: 'utf-8', + maxBuffer: 500 * 1024 * 1024, + timeout: 120000, + }); + if (convertResult.status !== 0) { + throw new Error(convertResult.stderr || 'mysql2sqlite conversion failed'); + } + + // Add DROP TABLE before each CREATE TABLE + let sqliteSql = convertResult.stdout; + sqliteSql = sqliteSql.replace( + /^(CREATE TABLE `([^`]+)`)/gm, + 'DROP TABLE IF EXISTS `$2`;\n$1', + ); + + // Import directly via better-sqlite3 (no external sqlite3 CLI needed) + const db = new Database(sqliteDbPath); + try { + db.exec(sqliteSql); + + // Find the table prefix and rename to wp_ + const tableRows = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + ).all() as { name: string }[]; + const allTables = tableRows.map(r => r.name); + const optionsTable = allTables.find(t => t.endsWith('_options')); + const oldPrefix = optionsTable ? optionsTable.replace('options', '') : 'wp_'; + + if (oldPrefix !== 'wp_') { + const renames = allTables + .filter(t => t.startsWith(oldPrefix)) + .map(t => `ALTER TABLE \`${t}\` RENAME TO \`wp_${t.substring(oldPrefix.length)}\``); + db.exec(renames.join(';\n') + ';'); + + // Rename meta keys and option names that contain the old prefix + db.prepare( + 'UPDATE wp_usermeta SET meta_key = REPLACE(meta_key, ?, ?) WHERE meta_key LIKE ?', + ).run(oldPrefix, 'wp_', oldPrefix + '%'); + db.prepare( + 'UPDATE wp_options SET option_name = REPLACE(option_name, ?, ?) WHERE option_name LIKE ?', + ).run(oldPrefix, 'wp_', oldPrefix + '%'); + } + + // Search-replace old cloud URL → localhost (bound params: no SQL injection) + const localUrl = `http://127.0.0.1:${instance.port}`; + const oldDomain = site.url || site.sub_domain || ''; + const oldUrls = [ + oldDomain, + oldDomain.replace('https://', 'http://'), + ].filter(Boolean); + + const replaceStmts = [ + 'UPDATE wp_options SET option_value = REPLACE(option_value, ?, ?) WHERE option_value LIKE ?', + 'UPDATE wp_posts SET post_content = REPLACE(post_content, ?, ?) WHERE post_content LIKE ?', + 'UPDATE wp_posts SET guid = REPLACE(guid, ?, ?) WHERE guid LIKE ?', + 'UPDATE wp_postmeta SET meta_value = REPLACE(meta_value, ?, ?) WHERE meta_value LIKE ?', + 'UPDATE wp_comments SET comment_content = REPLACE(comment_content, ?, ?) WHERE comment_content LIKE ?', + ].map(s => db.prepare(s)); + + for (const oldUrl of oldUrls) { + const likePattern = '%' + oldUrl + '%'; + for (const stmt of replaceStmts) { + stmt.run(oldUrl, localUrl, likePattern); + } + } + db.prepare( + "UPDATE wp_options SET option_value = ? WHERE option_name IN ('siteurl','home')", + ).run(localUrl); + + // Get admin username for blueprint login step + const adminRow = db.prepare( + "SELECT user_login FROM wp_users WHERE ID = (SELECT user_id FROM wp_usermeta WHERE meta_key = 'wp_capabilities' AND meta_value LIKE '%administrator%' LIMIT 1)", + ).get() as { user_login?: string } | undefined; + adminUsername = adminRow?.user_login || 'admin'; + + const tableCount = (db.prepare( + "SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table'", + ).get() as { c: number }).c; + + dbSpin2.succeed(`Database imported (${tableCount} tables, admin: ${adminUsername})`); + } finally { + db.close(); + } + } catch (err: any) { + dbSpin2.fail('Database import failed: ' + err.message); + } + } + + // 8. Write clone blueprint with AST driver + login as actual admin user + const cloneBlueprintPath = join(dir, 'clone-blueprint.json'); + const cloneBlueprint = { + steps: [ + { + step: 'defineWpConfigConsts', + consts: { + WP_SQLITE_AST_DRIVER: true, + WP_DEBUG: false, + WP_DEBUG_DISPLAY: false, + }, + }, + { + step: 'login', + username: adminUsername, + }, + ], + }; + writeFileSync(cloneBlueprintPath, JSON.stringify(cloneBlueprint)); + + // 9. Write error suppression mu-plugin + const muDir = join(dir, 'wp-content', 'mu-plugins'); + mkdirSync(muDir, { recursive: true }); + writeFileSync(join(muDir, '0-suppress-errors.php'), + " openWpAdmin(url), + }); + } catch (err: any) { + error('Failed to start local site', err.message); + process.exit(1); + } + } else { + info(`Start with: instawp local start ${name}`); + } + }); +} + +function checkRsync(): boolean { + if (bundledRsync()) return true; + const cmd = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(cmd, ['rsync'], { stdio: 'ignore' }); + return result.status === 0; +} + +/** + * Locate an awk-compatible interpreter. Resolution order: + * 1. Bundled BusyBox-w64 in bin/win32/ (Windows only — invoked as `busybox awk`) + * 2. `awk` or `gawk` in PATH + * 3. Common Git-for-Windows install dirs (Windows only) + * + * Returns the command path plus any arg-prefix that must precede the awk + * arguments (busybox uses `busybox awk -f script input`). + */ +function findAwk(): { cmd: string; prefixArgs: string[] } | null { + const bb = bundledBusybox(); + if (bb) return { cmd: bb, prefixArgs: ['awk'] }; + + const cmd = process.platform === 'win32' ? 'where' : 'which'; + for (const name of ['awk', 'gawk']) { + const r = spawnSync(cmd, [name], { stdio: 'pipe' }); + if (r.status === 0) return { cmd: name, prefixArgs: [] }; + } + if (process.platform === 'win32') { + const candidates = [ + 'C:\\Program Files\\Git\\usr\\bin\\awk.exe', + 'C:\\Program Files (x86)\\Git\\usr\\bin\\awk.exe', + ]; + if (process.env.PROGRAMFILES) { + candidates.push(process.env.PROGRAMFILES + '\\Git\\usr\\bin\\awk.exe'); + } + for (const c of candidates) { + if (existsSync(c)) return { cmd: c, prefixArgs: [] }; + } + } + return null; +} + +function sanitizeName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); +} + +// Playground supports: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 +const PLAYGROUND_PHP_VERSIONS = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']; + +function normalizePhpVersion(version?: string): string { + if (!version) return '8.3'; + // Extract major.minor (e.g., "8.2.15" → "8.2") + const match = version.match(/^(\d+\.\d+)/); + const majorMinor = match ? match[1] : version; + if (PLAYGROUND_PHP_VERSIONS.includes(majorMinor)) return majorMinor; + // Fall back to closest supported version, prefer not going higher + return '8.3'; +} + +function nextAutoName(instances: Record): string { + let i = 1; + while (instances[`insta-local-site-${i}`]) i++; + return `insta-local-site-${i}`; +} + +function printUrls(port: number): void { + const url = `http://127.0.0.1:${port}`; + console.log(` ${chalk.dim('Site:')} ${chalk.cyan.underline(url)}`); + console.log(` ${chalk.dim('WP Admin:')} ${chalk.cyan.underline(`${url}/?instawp-login`)}`); +} + +async function launchServer(instance: LocalInstance, opts: any): Promise { + const shouldOpen = opts.open !== false; + const json = isJsonMode(); + + if (opts.background) { + const spin = json ? null : spinner(`Starting "${instance.name}" in background...`); + spin?.start(); + try { + const { pid, url } = await startServerBackground(instance, opts.blueprint); + if (json) { + console.log(JSON.stringify({ + success: true, + data: { + name: instance.name, + url, + port: instance.port, + pid, + wp: instance.wp, + php: instance.php, + path: instance.path, + }, + })); + } else { + spin?.succeed(`Running in background (PID: ${pid})`); + printUrls(instance.port); + info(`Stop with: instawp local stop ${instance.name}`); + info(`Logs: ${instance.path}/server.log`); + } + if (shouldOpen) await openWpAdmin(url); + } catch (err: any) { + if (json) { + console.log(JSON.stringify({ success: false, error: err.message })); + } else { + spin?.fail('Failed to start'); + error(err.message); + } + process.exit(1); + } + } else { + if (!json) { + printUrls(instance.port); + console.log(chalk.dim('\nPress Ctrl+C to stop.\n')); + } + try { + await startServer(instance, { + blueprint: opts.blueprint, + onReady: shouldOpen ? (url: string) => openWpAdmin(url) : undefined, + }); + } catch (err: any) { + error('Failed to start local site', err.message); + process.exit(1); + } + } +} + +async function openWpAdmin(serverUrl: string): Promise { + // Use the magic login URL — hits frontend (no auth wall), + // sets cookie via mu-plugin, then redirects to wp-admin + const loginUrl = `${serverUrl}/?instawp-login`; + + // Wait for WordPress to be fully ready + for (let i = 0; i < 30; i++) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + const res = await fetch(serverUrl, { + signal: controller.signal, + redirect: 'manual', + }); + clearTimeout(timer); + if (res.status === 200 || res.status === 302) { + break; + } + } catch { + // Server not ready yet + } + await new Promise(r => setTimeout(r, 1000)); + } + + open(loginUrl).catch(() => {}); +} diff --git a/src/commands/login.ts b/src/commands/login.ts index 32cac1d..7523780 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -46,12 +46,27 @@ export function registerLoginCommand(program: Command): void { const res = await client.get('/sites', { params: { per_page: 1 } }); spin2.succeed('Token validated'); - // Try to extract user info from response if available - // The sites endpoint may return user info in the meta/auth context - // If not, we at least know the token is valid - success('Logged in successfully', { - api_url: getApiUrl(), - }); + // Fetch user and team info + let userName = ''; + let teamName = ''; + try { + const teamsRes = await client.get('/teams'); + const currentTeamId = teamsRes.data?.current_team_id; + const teams = teamsRes.data?.data || []; + const currentTeam = teams.find((t: any) => t.id === currentTeamId); + teamName = currentTeam?.name || ''; + + // Get user info from team owner or first member + if (currentTeam?.owner) { + userName = currentTeam.owner.name || currentTeam.owner.email || ''; + setUser({ id: currentTeam.owner.id, name: currentTeam.owner.name || '', email: currentTeam.owner.email || '' }); + } + } catch { /* non-critical */ } + + success('Logged in successfully'); + if (userName) info(`User: ${userName}`); + if (teamName) info(`Team: ${teamName}`); + info(`API: ${getApiUrl()}`); } catch (err: any) { spin2.fail('Token validation failed'); error('Invalid token. Please check and try again.'); diff --git a/src/commands/sites.ts b/src/commands/sites.ts index edf7468..dbcaa22 100644 --- a/src/commands/sites.ts +++ b/src/commands/sites.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { requireAuth, getClient } from '../lib/api.js'; +import { getApiUrl } from '../lib/config.js'; import { resolveSite } from '../lib/site-resolver.js'; import { success, error, table, spinner, info, isJsonMode } from '../lib/output.js'; @@ -15,7 +16,8 @@ export function registerSitesCommand(program: Command): void { .description('List all sites') .option('--status ', 'Filter by status') .option('--page ', 'Page number', '1') - .option('--per-page ', 'Results per page', '20') + .option('--per-page ', 'Results per page', '50') + .option('--all', 'Fetch all pages') .action(async (opts) => { requireAuth(); const spin = spinner('Fetching sites...'); @@ -23,17 +25,33 @@ export function registerSitesCommand(program: Command): void { try { const client = getClient(); - const params: Record = { - page: parseInt(opts.page), - per_page: parseInt(opts.perPage), - }; + let allSites: any[] = []; + let page = parseInt(opts.page); + const perPage = parseInt(opts.perPage); + let lastPage = 1; + let total = 0; + + // Fetch first page + const params: Record = { page, per_page: perPage }; if (opts.status) params.status = opts.status; const res = await client.get('/sites', { params }); + allSites = res.data?.data || []; + const meta = res.data?.meta || {}; + lastPage = meta.last_page || 1; + total = meta.total || allSites.length; + + // If --all, fetch remaining pages + if (opts.all && lastPage > page) { + for (let p = page + 1; p <= lastPage; p++) { + const r = await client.get('/sites', { params: { ...params, page: p } }); + allSites = allSites.concat(r.data?.data || []); + } + } + spin.stop(); - const sites = res.data?.data || []; - if (sites.length === 0) { + if (allSites.length === 0) { if (isJsonMode()) { console.log(JSON.stringify([])); } else { @@ -42,7 +60,7 @@ export function registerSitesCommand(program: Command): void { return; } - const rows = sites.map((s: any) => ({ + const rows = allSites.map((s: any) => ({ id: s.id, name: s.name || '', domain: s.domain?.name || s.sub_domain || '', @@ -54,6 +72,11 @@ export function registerSitesCommand(program: Command): void { })); table(['ID', 'Name', 'URL', 'Status', 'WP Version', 'PHP Version'], rows); + + // Show pagination hint if there are more pages and not fetching all + if (!opts.all && !isJsonMode() && lastPage > page) { + info(`Showing ${allSites.length} of ${total} sites (page ${page}/${lastPage}). Use --all to fetch all.`); + } } catch (err: any) { spin.fail('Failed to fetch sites'); error('Could not list sites', err.response?.data?.message || err.message); @@ -66,8 +89,10 @@ export function registerSitesCommand(program: Command): void { .command('create') .description('Create a new WordPress site') .requiredOption('--name ', 'Site name') + .option('--wp ', 'WordPress version (e.g., 6.8)') .option('--php ', 'PHP version (e.g., 8.2)') .option('--config ', 'Configuration ID') + .option('--temporary', 'Create as temporary site (default: permanent)') .option('--no-wait', 'Do not wait for site to become active') .action(createSiteAction); @@ -127,6 +152,167 @@ export function registerSitesCommand(program: Command): void { process.exit(1); } }); + + // sites update + sites + .command('update ') + .description('Update site label, description, or expiration') + .option('--label