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..6e2a05d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +## 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..b37c6b8 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,25 @@ 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. + +## 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 +154,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 +182,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 +198,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 +213,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/package.json b/package.json index f198f1c..a500564 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.3", "description": "InstaWP CLI - Create and manage WordPress sites from the terminal", "type": "module", "bin": { @@ -8,7 +8,9 @@ }, "files": [ "dist", - "!dist/__tests__" + "!dist/__tests__", + "scripts/mysql2sqlite", + "CHANGELOG.md" ], "scripts": { "build": "tsc", 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__/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..313c433 100644 --- a/src/__tests__/ssh-connection.test.ts +++ b/src/__tests__/ssh-connection.test.ts @@ -116,7 +116,8 @@ 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', 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..90719fa --- /dev/null +++ b/src/commands/local.ts @@ -0,0 +1,804 @@ +import { Command } from 'commander'; +import { spawnSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import chalk from 'chalk'; +import open from 'open'; +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' ? ' Install via Git for Windows or cwRsync.' : ' 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' ? ' Install via Git for Windows or cwRsync.' : ' 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' ? ' Install via Git for Windows or cwRsync.' : ' 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 = resolve(join(new URL(import.meta.url).pathname, '..', '..', '..', '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 + const convertResult = spawnSync(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', + ); + + // Write and import into SQLite + const tmpSql = join(dir, 'sqlite-import.sql'); + writeFileSync(tmpSql, sqliteSql); + spawnSync('sqlite3', [sqliteDbPath], { + input: `.read ${tmpSql}\n`, + encoding: 'utf-8', + timeout: 120000, + }); + + // Find the table prefix and rename to wp_ + const tablesResult = spawnSync('sqlite3', [sqliteDbPath, '.tables'], { encoding: 'utf-8' }); + const allTables = (tablesResult.stdout || '').split(/\s+/).filter(Boolean); + const optionsTable = allTables.find((t: string) => t.endsWith('_options')); + const oldPrefix = optionsTable ? optionsTable.replace('options', '') : 'wp_'; + + if (oldPrefix !== 'wp_') { + // Rename tables + const renameStatements = allTables + .filter((t: string) => t.startsWith(oldPrefix)) + .map((t: string) => `ALTER TABLE \`${t}\` RENAME TO \`wp_${t.substring(oldPrefix.length)}\`;`) + .join('\n'); + spawnSync('sqlite3', [sqliteDbPath, renameStatements], { encoding: 'utf-8' }); + + // Rename meta keys and option names that contain the old prefix + const fixPrefixSql = [ + `UPDATE wp_usermeta SET meta_key = REPLACE(meta_key, '${oldPrefix}', 'wp_') WHERE meta_key LIKE '${oldPrefix}%';`, + `UPDATE wp_options SET option_name = REPLACE(option_name, '${oldPrefix}', 'wp_') WHERE option_name LIKE '${oldPrefix}%';`, + ].join('\n'); + spawnSync('sqlite3', [sqliteDbPath, fixPrefixSql], { encoding: 'utf-8' }); + } + + // Search-replace old cloud URL → localhost + 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); + + for (const oldUrl of oldUrls) { + const replaceSql = [ + `UPDATE wp_options SET option_value = REPLACE(option_value, '${oldUrl}', '${localUrl}') WHERE option_value LIKE '%${oldUrl}%';`, + `UPDATE wp_posts SET post_content = REPLACE(post_content, '${oldUrl}', '${localUrl}') WHERE post_content LIKE '%${oldUrl}%';`, + `UPDATE wp_posts SET guid = REPLACE(guid, '${oldUrl}', '${localUrl}') WHERE guid LIKE '%${oldUrl}%';`, + `UPDATE wp_postmeta SET meta_value = REPLACE(meta_value, '${oldUrl}', '${localUrl}') WHERE meta_value LIKE '%${oldUrl}%';`, + `UPDATE wp_comments SET comment_content = REPLACE(comment_content, '${oldUrl}', '${localUrl}') WHERE comment_content LIKE '%${oldUrl}%';`, + ].join('\n'); + spawnSync('sqlite3', [sqliteDbPath, replaceSql], { encoding: 'utf-8' }); + } + // Ensure siteurl/home are correct + spawnSync('sqlite3', [sqliteDbPath, + `UPDATE wp_options SET option_value='${localUrl}' WHERE option_name IN ('siteurl','home');`, + ], { encoding: 'utf-8' }); + + // Get admin username for blueprint login step + const adminResult = spawnSync('sqlite3', [sqliteDbPath, + "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);", + ], { encoding: 'utf-8' }); + adminUsername = (adminResult.stdout || '').trim() || 'admin'; + + // Count tables for output + const countResult = spawnSync('sqlite3', [sqliteDbPath, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table';", + ], { encoding: 'utf-8' }); + + // Clean up temp file + try { rmSync(tmpSql); } catch {} + + dbSpin2.succeed(`Database imported (${(countResult.stdout || '').trim()} tables, admin: ${adminUsername})`); + } 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 { + const cmd = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(cmd, ['rsync'], { stdio: 'ignore' }); + return result.status === 0; +} + +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