From 60bd278832dc50895ea7e4d45fb22f75fa53a2f1 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Mon, 23 Mar 2026 20:23:17 +0530 Subject: [PATCH 1/6] feat: add local development with WordPress Playground and cloud sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Local dev: create, clone, start, stop, push, pull, list, delete - Clone flow: full site clone from InstaWP cloud (files + MySQL→SQLite DB) - Background mode: --background flag with PID tracking and local stop - Teams: client-side team switch with team_id injection on all API calls - Sites list: 50 per page default, --all flag, pagination hints - Site resolver: 10-minute cache for name→ID lookups - Rsync: --itemize-changes (only shows changed files) - Auto-login: finds first admin user, works with cloned sites - AST SQLite driver: WP_SQLITE_AST_DRIVER=true for WooCommerce compat - Vendor: mysql2sqlite (MIT, dumblob/mysql2sqlite) for DB conversion - Terminal: stty sane after Playground exits Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 101 +++- package.json | 5 +- scripts/mysql2sqlite | 289 ++++++++++ src/__tests__/site-resolver.test.ts | 5 + src/__tests__/ssh-connection.test.ts | 3 +- src/commands/local.ts | 756 +++++++++++++++++++++++++++ src/commands/sites.ts | 38 +- src/commands/teams.ts | 60 ++- src/index.ts | 16 + src/lib/api.ts | 7 +- src/lib/config.ts | 63 ++- src/lib/local-env.ts | 328 ++++++++++++ src/lib/site-resolver.ts | 41 +- src/lib/ssh-connection.ts | 28 +- src/types.ts | 9 + 15 files changed, 1706 insertions(+), 43 deletions(-) create mode 100755 scripts/mysql2sqlite create mode 100644 src/commands/local.ts create mode 100644 src/lib/local-env.ts 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..6989805 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.2", "description": "InstaWP CLI - Create and manage WordPress sites from the terminal", "type": "module", "bin": { @@ -8,7 +8,8 @@ }, "files": [ "dist", - "!dist/__tests__" + "!dist/__tests__", + "scripts/mysql2sqlite" ], "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/local.ts b/src/commands/local.ts new file mode 100644 index 0000000..094b7b4 --- /dev/null +++ b/src/commands/local.ts @@ -0,0 +1,756 @@ +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); + success(`Instance "${name}" created`); + + console.log(` +${chalk.dim('#')} Starting WordPress ${opts.wp} with PHP ${opts.php}... +${chalk.dim('#')} Data stored at: ${chalk.dim(dir)} +`); + 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('--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. 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('--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. 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[] = [ + '--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') + .action(async (cloudSiteArg: string, opts: any) => { + requireAuth(); + + if (!checkRsync()) { + error('rsync is required. 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]) { + error(`Local instance "${name}" already exists. Use --name to pick a different name.`); + process.exit(1); + } + + 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 rsyncExit = rsyncViaSsh(conn, remoteSource, localWpContent, [ + '--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 result = spawnSync('which', ['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; + + if (opts.background) { + const spin = spinner(`Starting "${instance.name}" in background...`); + spin.start(); + try { + const { pid, url } = await startServerBackground(instance, opts.blueprint); + spin.succeed(`Running in background (PID: ${pid})`); + printUrls(instance.port); + if (shouldOpen) await openWpAdmin(url); + info(`Stop with: instawp local stop ${instance.name}`); + info(`Logs: ${instance.path}/server.log`); + } catch (err: any) { + spin.fail('Failed to start'); + error(err.message); + process.exit(1); + } + } else { + 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/sites.ts b/src/commands/sites.ts index edf7468..679495e 100644 --- a/src/commands/sites.ts +++ b/src/commands/sites.ts @@ -15,7 +15,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 +24,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 +59,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 +71,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); diff --git a/src/commands/teams.ts b/src/commands/teams.ts index f856e04..a037353 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { requireAuth, getClient } from '../lib/api.js'; -import { success, error, table, spinner, isJsonMode } from '../lib/output.js'; +import { getTeamId, setTeamId, clearTeamId } from '../lib/config.js'; +import { success, error, table, spinner, info, isJsonMode } from '../lib/output.js'; async function fetchTeams(): Promise<{ teams: any[]; current_team_id: number | null }> { const client = getClient(); @@ -62,12 +63,15 @@ export function registerTeamsCommand(program: Command): void { return; } + const cliTeamId = getTeamId(); + const activeTeamId = cliTeamId || current_team_id; + if (isJsonMode()) { const rows = teamList.map((t: any) => ({ id: t.id, name: t.name, created_at: t.created_at || '', - is_current: t.id === current_team_id, + is_active: t.id === activeTeamId, })); console.log(JSON.stringify(rows)); return; @@ -75,11 +79,14 @@ export function registerTeamsCommand(program: Command): void { const rows = teamList.map((t: any) => ({ id: t.id, - name: t.id === current_team_id ? `${t.name} (current)` : t.name, + name: t.id === activeTeamId ? `${t.name} (active)` : t.name, created_at: t.created_at || '', })); table(['ID', 'Name', 'Created At'], rows); + if (cliTeamId) { + info(`CLI team context set to ID ${cliTeamId}. Run 'teams switch' to clear.`); + } } catch (err: any) { spin.fail('Failed to fetch teams'); error('Could not list teams', err.response?.data?.message || err.message); @@ -87,6 +94,53 @@ export function registerTeamsCommand(program: Command): void { } }); + // teams switch + teams + .command('switch [team]') + .description('Switch active team context (by ID or name). Omit to reset.') + .action(async (teamArg?: string) => { + requireAuth(); + + // No argument = clear team context + if (!teamArg) { + clearTeamId(); + if (isJsonMode()) { + console.log(JSON.stringify({ team_id: null })); + } else { + success('Team context cleared. Using default team.'); + } + return; + } + + const spin = spinner('Resolving team...'); + spin.start(); + + try { + const teamId = await resolveTeamId(teamArg); + // Verify the team exists in user's teams + const { teams: teamList } = await fetchTeams(); + const team = teamList.find((t: any) => t.id === teamId); + spin.stop(); + + if (!team) { + error(`You don't belong to team "${teamArg}"`); + process.exit(1); + } + + setTeamId(teamId); + + if (isJsonMode()) { + console.log(JSON.stringify({ team_id: team.id, team_name: team.name })); + } else { + success(`Switched to team: ${team.name} (ID: ${team.id})`); + } + } catch (err: any) { + spin.fail('Failed to switch team'); + error('Could not switch team', err.response?.data?.message || err.message); + process.exit(1); + } + }); + // teams members teams .command('members ') diff --git a/src/index.ts b/src/index.ts index 7261999..331d308 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { registerSyncCommand } from './commands/sync.js'; import { registerSshCommand } from './commands/ssh.js'; import { registerExecCommand, registerWpCommand } from './commands/exec.js'; import { registerTeamsCommand } from './commands/teams.js'; +import { registerLocalCommand } from './commands/local.js'; const require = createRequire(import.meta.url); const { version } = require('../package.json'); @@ -48,6 +49,9 @@ registerSyncCommand(program); // -- Teams -- registerTeamsCommand(program); +// -- Local dev -- +registerLocalCommand(program); + // Custom help layout program.configureHelp({ sortSubcommands: false, @@ -73,13 +77,25 @@ ${d('Remote Access')} ${c('ssh')} ${d('')} Interactive SSH session ${c('sync')} ${d('push|pull')} Sync wp-content via rsync +${d('Local Development')} + ${c('local create')} Create and start a local WordPress site + ${c('local clone')} Clone an InstaWP cloud site to local + ${c('local start')} Start an existing local site + ${c('local stop')} Stop a background local site + ${c('local push')} Push local wp-content to InstaWP cloud + ${c('local pull')} Pull cloud wp-content to local site + ${c('local list')} List local sites + ${c('local delete')} Delete a local site + ${d('Teams')} ${c('teams list')} List teams + ${c('teams switch')} Switch active team ${c('teams members')} List team members ${d('Examples')} $ instawp login $ instawp create --name my-site + $ instawp local create --name blog $ instawp wp my-site plugin list $ instawp exec my-site php -v --api $ instawp ssh my-site diff --git a/src/lib/api.ts b/src/lib/api.ts index 600e3ef..0a580ee 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,5 @@ import axios, { AxiosInstance, AxiosError } from 'axios'; -import { getToken, getApiUrl, clearConfig } from './config.js'; +import { getToken, getApiUrl, getTeamId, clearConfig } from './config.js'; import { error } from './output.js'; let client: AxiosInstance | null = null; @@ -19,6 +19,11 @@ export function getClient(): AxiosInstance { if (token) { config.headers.Authorization = `Bearer ${token}`; } + // Inject team_id into all requests if set + const teamId = getTeamId(); + if (teamId) { + config.params = { ...config.params, team_id: teamId }; + } return config; }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 1d26e70..e71c646 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,7 +1,8 @@ import Conf from 'conf'; -import type { UserInfo, SshConnectionCache } from '../types.js'; +import type { UserInfo, SshConnectionCache, LocalInstance } from '../types.js'; const SSH_CACHE_TTL = 60 * 60 * 1000; // 1 hour +const SITE_CACHE_TTL = 10 * 60 * 1000; // 10 minutes const config = new Conf({ projectName: 'instawp', @@ -10,6 +11,9 @@ const config = new Conf({ token: { type: 'string', default: '' }, user: { type: 'object', default: {} }, ssh_cache: { type: 'object', default: {} }, + site_cache: { type: 'object', default: {} }, + local_instances: { type: 'object', default: {} }, + team_id: { type: 'number', default: 0 }, }, }); @@ -42,10 +46,67 @@ export function setApiUrl(url: string): void { config.set('api_url', url); } +export function getTeamId(): number | null { + const id = config.get('team_id') as number; + return id || null; +} + +export function setTeamId(id: number): void { + config.set('team_id', id); +} + +export function clearTeamId(): void { + config.set('team_id', 0); +} + export function clearConfig(): void { config.clear(); } +// Site resolution cache: maps identifier (name/domain) → site ID +interface SiteCacheEntry { + id: number; + cachedAt: number; +} + +export function getSiteCache(identifier: string): number | null { + const cache = config.get('site_cache') as Record; + const entry = cache?.[identifier.toLowerCase()]; + if (!entry) return null; + if (Date.now() - entry.cachedAt > SITE_CACHE_TTL) { + return null; + } + return entry.id; +} + +export function setSiteCache(identifier: string, siteId: number): void { + const cache = (config.get('site_cache') as Record) || {}; + cache[identifier.toLowerCase()] = { id: siteId, cachedAt: Date.now() }; + config.set('site_cache', cache); +} + +// Local instance management +export function getLocalInstances(): Record { + return (config.get('local_instances') as Record) || {}; +} + +export function getLocalInstance(name: string): LocalInstance | null { + const instances = getLocalInstances(); + return instances[name] || null; +} + +export function setLocalInstance(instance: LocalInstance): void { + const instances = getLocalInstances(); + instances[instance.name] = instance; + config.set('local_instances', instances); +} + +export function removeLocalInstance(name: string): void { + const instances = getLocalInstances(); + delete instances[name]; + config.set('local_instances', instances); +} + export function getSshCache(siteId: number): SshConnectionCache | null { const cache = config.get('ssh_cache') as Record; const entry = cache?.[String(siteId)]; diff --git a/src/lib/local-env.ts b/src/lib/local-env.ts new file mode 100644 index 0000000..8d5afee --- /dev/null +++ b/src/lib/local-env.ts @@ -0,0 +1,328 @@ +import { spawn, execSync, spawnSync } from 'node:child_process'; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import net from 'node:net'; +import type { LocalInstance } from '../types.js'; + +const LOCAL_BASE_DIR = join(homedir(), '.instawp', 'local'); +const DEFAULT_PORT_START = 9400; + +/** + * Returns [command, prefixArgs] for running wp-playground-cli. + * Prefers the global binary (faster) over npx (slower). + */ +function getPlaygroundCommand(): [string, string[]] { + // Check for globally installed binary first (0.7s vs 1.4s npx overhead) + const result = spawnSync('which', ['wp-playground-cli'], { stdio: 'pipe' }); + if (result.status === 0) { + return ['wp-playground-cli', []]; + } + return ['npx', ['--yes', '@wp-playground/cli']]; +} + +export function getLocalBaseDir(): string { + return LOCAL_BASE_DIR; +} + +export function getInstanceDir(name: string): string { + return join(LOCAL_BASE_DIR, name); +} + +/** + * Check if downloads.w.org is reachable (WordPress Playground downloads from here). + * Returns null if OK, or an error message string. + */ +export async function checkPlaygroundConnectivity(): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + await fetch('https://downloads.w.org', { signal: controller.signal, method: 'HEAD' }); + clearTimeout(timer); + return null; + } catch { + // Check if the alternative domain works + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + await fetch('https://downloads.wordpress.org', { signal: controller.signal, method: 'HEAD' }); + clearTimeout(timer); + return 'downloads.w.org is unreachable from your network (downloads.wordpress.org works).\n' + + 'WordPress Playground CLI requires access to downloads.w.org.\n' + + 'Try: adding "192.0.77.48 downloads.w.org" to /etc/hosts, or use a VPN/different network.'; + } catch { + return 'Cannot reach WordPress download servers (downloads.w.org and downloads.wordpress.org).\n' + + 'Check your internet connection.'; + } + } +} + +async function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, '127.0.0.1'); + }); +} + +export async function getNextPort(instances: Record): Promise { + const usedPorts = new Set(Object.values(instances).map(i => i.port)); + let port = DEFAULT_PORT_START; + while (usedPorts.has(port) || !(await isPortAvailable(port))) { + port++; + if (port > 9500) throw new Error('No available ports in range 9400-9500'); + } + return port; +} + +const AUTO_LOGIN_MU_PLUGIN = ` 'administrator', 'number' => 1]); + \$user = \$admins[0] ?? null; + } + if (\$user) { + wp_set_current_user(\$user->ID); + wp_set_auth_cookie(\$user->ID, true); + wp_safe_redirect(admin_url()); + exit; + } +}); +`; + +export function createInstanceDir(name: string): string { + const dir = getInstanceDir(name); + if (existsSync(dir)) { + throw new Error(`Instance "${name}" already exists at ${dir}`); + } + + const wpContentDir = join(dir, 'wp-content'); + const muPluginsDir = join(wpContentDir, 'mu-plugins'); + mkdirSync(muPluginsDir, { recursive: true }); + writeFileSync(join(muPluginsDir, '0-instawp-auto-login.php'), AUTO_LOGIN_MU_PLUGIN); + return dir; +} + +/** + * Ensure auto-login mu-plugin exists (for instances created before this was added). + */ +export function ensureAutoLogin(instance: LocalInstance): void { + const muPluginsDir = join(instance.path, 'wp-content', 'mu-plugins'); + const pluginPath = join(muPluginsDir, '0-instawp-auto-login.php'); + if (!existsSync(pluginPath)) { + mkdirSync(muPluginsDir, { recursive: true }); + writeFileSync(pluginPath, AUTO_LOGIN_MU_PLUGIN); + } +} + +export function deleteInstanceDir(name: string): void { + const dir = getInstanceDir(name); + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } +} + +function buildServerArgs(instance: LocalInstance, blueprint?: string): string[] { + const wpContentDir = join(instance.path, 'wp-content'); + const isClone = existsSync(join(instance.path, 'sqlite-import.sql')) || + existsSync(join(wpContentDir, 'database', '.ht.sqlite')); + + const args = [ + 'server', + `--port=${instance.port}`, + `--php=${instance.php}`, + `--wp=${instance.wp}`, + '--login', + ]; + + if (isClone) { + // For cloned sites: mount subdirs individually AFTER install. + // This lets Playground set up db.php internally while our data persists. + const subdirs = ['database', 'plugins', 'themes', 'uploads', 'mu-plugins']; + for (const subdir of subdirs) { + const hostDir = join(wpContentDir, subdir); + if (existsSync(hostDir)) { + args.push(`--mount=${hostDir}:/wordpress/wp-content/${subdir}`); + } + } + // Mount non-core root files (CLAUDE.md, .htaccess, etc.) + const skipFiles = new Set(['wp-content', 'database.sql', 'sqlite-import.sql', 'clone-blueprint.json', 'import-blueprint.json', 'import-db.php']); + for (const file of readdirSync(instance.path)) { + if (skipFiles.has(file) || file.startsWith('.')) continue; + const filePath = join(instance.path, file); + const stat = statSync(filePath); + if (stat.isFile()) { + args.push(`--mount=${filePath}:/wordpress/${file}`); + } + } + + // Use clone blueprint if it exists (has AST driver + login step) + if (!blueprint) { + const cloneBlueprintPath = join(instance.path, 'clone-blueprint.json'); + if (existsSync(cloneBlueprintPath)) { + blueprint = cloneBlueprintPath; + } + } + } else { + // For fresh sites: mount entire wp-content before install for persistence + args.push(`--mount-before-install=${wpContentDir}:/wordpress/wp-content`); + } + + if (blueprint) { + args.push(`--blueprint=${blueprint}`); + } + + return args; +} + +/** + * Starts the Playground server in the foreground. + * Watches stdout for the ready URL and calls onReady when detected. + * Returns a promise that resolves when the server exits. + */ +export function startServer( + instance: LocalInstance, + opts?: { blueprint?: string; onReady?: (url: string) => void }, +): Promise { + const args = buildServerArgs(instance, opts?.blueprint); + + const [cmd, prefixArgs] = getPlaygroundCommand(); + + return new Promise((resolve, reject) => { + const child = spawn(cmd, [...prefixArgs, ...args], { + stdio: ['inherit', 'pipe', 'pipe'], + cwd: instance.path, + }); + + let readyFired = false; + + // Watch stdout for the server URL to detect readiness + child.stdout?.on('data', (data: Buffer) => { + const text = data.toString(); + process.stdout.write(data); + + if (!readyFired && opts?.onReady) { + // Playground CLI prints the URL when ready (e.g. "http://127.0.0.1:9400") + const urlMatch = text.match(/https?:\/\/127\.0\.0\.1:\d+/); + if (urlMatch) { + readyFired = true; + opts.onReady(urlMatch[0]); + } + } + }); + + child.stderr?.on('data', (data: Buffer) => { + process.stderr.write(data); + }); + + child.on('error', (err) => { + reject(err); + }); + + child.on('close', (code) => { + // Restore terminal state — Playground CLI can leave it in raw mode + if (process.stdin.isTTY) { + process.stdin.setRawMode?.(false); + } + spawnSync('stty', ['sane'], { stdio: 'inherit' }); + resolve(code ?? 0); + }); + }); +} + +/** + * Starts the Playground server in the background. + * Spawns detached with output going to a log file. + * Polls until the server is ready, then returns. + */ +export async function startServerBackground( + instance: LocalInstance, + blueprint?: string, +): Promise<{ pid: number; url: string }> { + const args = buildServerArgs(instance, blueprint); + const [cmd, prefixArgs] = getPlaygroundCommand(); + + const logFile = join(instance.path, 'server.log'); + const pidFile = join(instance.path, 'server.pid'); + + // Spawn fully detached with output to log file + const logFd = openSync(logFile, 'w'); + const child = spawn(cmd, [...prefixArgs, ...args], { + stdio: ['ignore', logFd, logFd], + cwd: instance.path, + detached: true, + }); + + const pid = child.pid!; + writeFileSync(pidFile, String(pid)); + child.unref(); + closeSync(logFd); + + // Poll the log file for the ready URL + const url = `http://127.0.0.1:${instance.port}`; + const maxWait = 120000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + await new Promise(r => setTimeout(r, 2000)); + + // Check if process is still alive + try { process.kill(pid, 0); } catch { + const log = existsSync(logFile) ? readFileSync(logFile, 'utf-8') : ''; + throw new Error(`Server exited unexpectedly. Log:\n${log.slice(-500)}`); + } + + // Check if server is responding + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + const res = await fetch(url, { signal: controller.signal, redirect: 'manual' }); + clearTimeout(timer); + if (res.status === 200 || res.status === 302) { + return { pid, url }; + } + } catch { + // Not ready yet + } + } + + throw new Error(`Server did not become ready within 120s. Check ${logFile}`); +} + +export function stopServer(instance: LocalInstance): boolean { + const pidFile = join(instance.path, 'server.pid'); + if (!existsSync(pidFile)) return false; + + const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10); + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Already dead + } + rmSync(pidFile, { force: true }); + return true; +} + +export function isServerRunning(instance: LocalInstance): boolean { + const pidFile = join(instance.path, 'server.pid'); + if (!existsSync(pidFile)) return false; + const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10); + try { + process.kill(pid, 0); + return true; + } catch { + rmSync(pidFile, { force: true }); + return false; + } +} diff --git a/src/lib/site-resolver.ts b/src/lib/site-resolver.ts index d7df04b..fe7336e 100644 --- a/src/lib/site-resolver.ts +++ b/src/lib/site-resolver.ts @@ -1,17 +1,22 @@ import { getClient } from './api.js'; +import { getSiteCache, setSiteCache } from './config.js'; import { error, info } from './output.js'; import type { SiteDetails } from '../types.js'; +async function fetchSiteById(client: any, id: string | number): Promise { + const res = await client.get(`/sites/${id}/details`); + const data = res.data?.data; + const site = data?.site || data; + return normalizeSite(site, data); +} + export async function resolveSite(identifier: string): Promise { const client = getClient(); // If purely numeric, fetch directly by ID if (/^\d+$/.test(identifier)) { try { - const res = await client.get(`/sites/${identifier}/details`); - const data = res.data?.data; - const site = data?.site || data; - return normalizeSite(site, data); + return await fetchSiteById(client, identifier); } catch (err: any) { if (err.response?.status === 404) { error(`No site found with ID ${identifier}. Use \`instawp sites list\` to see your sites.`); @@ -22,7 +27,18 @@ export async function resolveSite(identifier: string): Promise { } } - // Otherwise, search by name/domain + // Check cache for name/domain → ID mapping + const cachedId = getSiteCache(identifier); + if (cachedId) { + try { + return await fetchSiteById(client, cachedId); + } catch (err: any) { + // Cache stale (site deleted?), fall through to fresh lookup + if (err.response?.status !== 404) throw err; + } + } + + // Search by name/domain try { const res = await client.get('/sites', { params: { per_page: 100 } }); const sites: any[] = res.data?.data || []; @@ -49,15 +65,18 @@ export async function resolveSite(identifier: string): Promise { process.exit(1); } - // Single match — fetch full details + // Single match — cache the mapping and fetch full details const match = matches[0]; + setSiteCache(identifier, match.id); + try { - const detailRes = await client.get(`/sites/${match.id}/details`); - const data = detailRes.data?.data; - const site = data?.site || data; - return normalizeSite(site, data); + const details = await fetchSiteById(client, match.id); + // Also cache by name and domain for future lookups + if (match.name) setSiteCache(match.name, match.id); + if (match.sub_domain) setSiteCache(match.sub_domain, match.id); + if (match.domain?.name) setSiteCache(match.domain.name, match.id); + return details; } catch { - // Fall back to list data if details endpoint fails return normalizeSite(match, match); } } catch (err: any) { diff --git a/src/lib/ssh-connection.ts b/src/lib/ssh-connection.ts index e9b7444..53d77f2 100644 --- a/src/lib/ssh-connection.ts +++ b/src/lib/ssh-connection.ts @@ -1,7 +1,7 @@ import { spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; import path from 'node:path'; -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, openSync, closeSync } from 'node:fs'; import type { SshConnection } from '../types.js'; const KNOWN_HOSTS = path.join(homedir(), '.instawp', 'known_hosts'); @@ -49,6 +49,29 @@ export function execViaSsh(conn: SshConnection, command: string): { stdout: stri }; } +/** + * Execute a command via SSH and stream stdout directly to a file. + * Useful for large outputs like database dumps. + */ +export function execViaSshToFile(conn: SshConnection, command: string, outputPath: string): { exitCode: number; stderr: string } { + ensureKnownHosts(); + const fd = openSync(outputPath, 'w'); + try { + const result = spawnSync('ssh', ['-T', ...sshArgs(conn), sshTarget(conn)], { + input: command + '\n', + stdio: ['pipe', fd, 'pipe'], + encoding: 'utf-8', + maxBuffer: 500 * 1024 * 1024, // 500MB + }); + return { + exitCode: result.status ?? 1, + stderr: (result.stderr as string) || '', + }; + } finally { + closeSync(fd); + } +} + export function rsyncViaSsh( conn: SshConnection, source: string, @@ -61,7 +84,8 @@ export function rsyncViaSsh( const sshCmd = `ssh -i ${conn.privateKeyPath} -p ${conn.port} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${KNOWN_HOSTS}`; const args = [ - '-avz', + '-arz', + '--itemize-changes', '--exclude=.git', '--exclude=node_modules', '--exclude=.DS_Store', diff --git a/src/types.ts b/src/types.ts index f6379a4..e821af1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,3 +83,12 @@ export interface TeamInfo { name: string; created_at: string; } + +export interface LocalInstance { + name: string; + port: number; + php: string; + wp: string; + path: string; + createdAt: string; +} From 25fb74119dbd1c28de80c2583ab97ec4a65b6e05 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Mon, 23 Mar 2026 20:27:15 +0530 Subject: [PATCH 2/6] fix: use NPM_TOKEN for npm publish instead of OIDC Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/publish.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 }} From 177dc491543ae1088edccf69a95b568fcd7a8306 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Sun, 12 Apr 2026 15:05:20 +0530 Subject: [PATCH 3/6] feat: cross-platform fixes, PHP settings, site update, and UX improvements - Windows: fix ssh-keygen (remove /bin/sh, yes pipe), use where instead of which - Windows: skip stty sane, platform-specific rsync install messages - exec/wp: fix --api flag being swallowed by passThroughOptions - sites php: view and update PHP version/settings via PATCH endpoint - sites update: update site label, description, expiration - create: add --wp flag for WordPress version selection - login: show user name and team after successful login - magic login: fix URL to use /wordpress-auto-login?site={hash} - local clone: add --force to overwrite existing, --include for rsync patterns - local pull: add --include flag for rsync patterns - local stop: new command to stop background servers Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/commands/exec.ts | 23 ++++++ src/commands/local.ts | 41 ++++++++-- src/commands/login.ts | 27 +++++-- src/commands/sites.ts | 169 +++++++++++++++++++++++++++++++++++++++++- src/commands/sync.ts | 3 +- src/index.ts | 1 + src/lib/local-env.ts | 7 +- src/lib/ssh-keys.ts | 14 +++- 9 files changed, 263 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 6989805..18897a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@instawp/cli", - "version": "0.0.1-beta.2", + "version": "0.0.1-beta.3", "description": "InstaWP CLI - Create and manage WordPress sites from the terminal", "type": "module", "bin": { 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 index 094b7b4..1198c59 100644 --- a/src/commands/local.ts +++ b/src/commands/local.ts @@ -216,6 +216,7 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)} 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) => { @@ -228,7 +229,7 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)} } if (!checkRsync()) { - error('rsync is required. Install: brew install rsync'); + error('rsync is required.' + (process.platform === 'win32' ? ' Install via Git for Windows or cwRsync.' : ' Install: brew install rsync')); process.exit(1); } @@ -327,6 +328,7 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)} 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) => { @@ -339,7 +341,7 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)} } if (!checkRsync()) { - error('rsync is required. Install: brew install rsync'); + error('rsync is required.' + (process.platform === 'win32' ? ' Install via Git for Windows or cwRsync.' : ' Install: brew install rsync')); process.exit(1); } @@ -359,11 +361,17 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)} const conn = await ensureSshAccess(site.id); const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`; - const extraArgs: string[] = [ + 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}`); @@ -390,11 +398,13 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)} .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. Install: brew install rsync'); + error('rsync is required.' + (process.platform === 'win32' ? ' Install via Git for Windows or cwRsync.' : ' Install: brew install rsync')); process.exit(1); } @@ -415,8 +425,15 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)} const name = sanitizeName(opts.name || site.name || site.sub_domain || `site-${site.id}`); if (instances[name]) { - error(`Local instance "${name}" already exists. Use --name to pick a different name.`); - process.exit(1); + 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); @@ -464,7 +481,14 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)} 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', @@ -662,7 +686,8 @@ ${chalk.bold.green('Clone complete!')} } function checkRsync(): boolean { - const result = spawnSync('which', ['rsync'], { stdio: 'ignore' }); + const cmd = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(cmd, ['rsync'], { stdio: 'ignore' }); return result.status === 0; } 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 679495e..e870132 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'; @@ -88,6 +89,7 @@ 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('--no-wait', 'Do not wait for site to become active') @@ -149,6 +151,167 @@ export function registerSitesCommand(program: Command): void { process.exit(1); } }); + + // sites update + sites + .command('update ') + .description('Update site label, description, or expiration') + .option('--label