From 93766e37fb62b3ef944932f3a0eb87d04f4ed967 Mon Sep 17 00:00:00 2001 From: "InstaWP Engineer (bot)" Date: Tue, 14 Jul 2026 06:52:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(local):=20one-shot=20deploy=20=E2=80=94=20?= =?UTF-8?q?`local=20deploy`=20alias=20+=20DB=20by=20default=20when=20the?= =?UTF-8?q?=20push=20creates=20the=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `local push` already provisioned a cloud site when given no target, but pushed files only, so the one-shot deploy landed users on a live site with none of their pages/posts/settings — and `--with-db` was only discoverable from a hint printed after the push. The command that sounds like the deploy path, `migrate push`, can't read a Playground instance at all (it needs wp-config.php MySQL creds; `local create` is SQLite). - add `local deploy` as an alias of `local push` - a site-CREATING push now includes the database by default (`shouldPushDb`); pushing into an existing site keeps it opt-in behind --with-db. New --no-db opts out of either. - skip the overwrite prompt + safety backup when the DB being replaced is the default WordPress install this command just provisioned (`freshSite`); both guards stay in force for an existing site. - help text: say `local push` creates the site when no target is given, and point `local create` users at `local deploy`, not `migrate push`. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 +++++ CLAUDE.md | 13 ++++-- README.md | 10 ++++- package-lock.json | 4 +- package.json | 2 +- src/__tests__/local-instance.test.ts | 22 +++++++++- src/commands/local.ts | 64 ++++++++++++++++++++-------- src/index.ts | 7 ++- src/lib/local-instance.ts | 17 ++++++++ 9 files changed, 121 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f715c..b5d94e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.0.1-beta.33 (2026-07-14) + +### Added — `instawp local deploy`: a local site goes live in one command + +`local push` could already provision a cloud site when given no target, but it pushed **files only** — so the one-shot deploy landed you on a live site with none of your pages, posts or settings, and the fix (`--with-db`) was only discoverable from a hint printed after the fact. Meanwhile the command that *sounds* like the deploy path, `migrate push`, cannot read a Playground instance at all (it needs `wp-config.php` MySQL credentials; a `local create` site is SQLite). + +- **`local deploy`** is now an alias of `local push` — the discoverable verb for "make my local site live". +- A push that **creates** the cloud site now pushes the **database as well as the files** by default. Pushing into an **existing** site is unchanged: the DB stays opt-in behind `--with-db`, because it overwrites live data. New **`--no-db`** keeps any push files-only. +- A site-creating push no longer prompts to confirm an "overwrite" of, nor takes a safety backup of, the empty WordPress database it provisioned seconds earlier. Both guards remain fully in force for an existing site. +- Help text now says `local push` creates the cloud site when no target is given, and points `local create` users at `local deploy` rather than `migrate push`. + ## 0.0.1-beta.32 (2026-07-10) ### Fixed — `local create` no longer false-fails the network check on valid connections diff --git a/CLAUDE.md b/CLAUDE.md index cec3ad3..50ff7bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ instawp local create [--name ] [--wp ] [--php ] [--background] [--no-op 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 push|deploy [cloud-site] [--with-db] [--no-db] [--no-backup] [--force] [--dry-run] instawp local pull [--dry-run] instawp local list instawp local delete [--force] @@ -139,13 +139,20 @@ All commands support `--json` for machine-readable output. 9. Generate blueprint with `WP_SQLITE_AST_DRIVER=true` + `login` step with actual admin username 10. Write error suppression mu-plugin -### DB push flow (`local push --with-db`) — SQLite → MySQL (the reverse of clone) +### One-shot deploy (`local push` with no target, alias `local deploy`) +When `pushTargetRef()` yields no site (no cloud-site arg, not a cloned instance), `local push` **provisions** one (`POST /sites`, `is_reserved: true`, poll the task) and links it to the instance. Two rules follow from "this command created the site", both decided by `shouldPushDb()` (`lib/local-instance.ts`) + the `freshSite` flag passed into `pushDatabase()`: +- **The DB rides along by default.** Files-only would strand the user on a brand-new site with none of their pages/posts/settings. Pushing into an **existing** site keeps the DB opt-in (`--with-db`) — that overwrites live data. `--no-db` always wins. +- **No overwrite prompt, no safety backup.** The DB being replaced is the untouched WordPress default from seconds ago; confirming it and `wp db export`-ing it are pure cost. (Both guards stay fully in force for an existing site.) + +`local deploy` is a commander `.alias()` on the same command — the discoverable verb for "make my local site live". Not to be confused with `migrate push`, which mirrors an **on-disk** WP install (MySQL + `wp-config.php`) and cannot read a Playground SQLite instance. + +### DB push flow (`local push --with-db`, and every site-creating push) — SQLite → MySQL (the reverse of clone) Pushes the local Playground DB back to the cloud MySQL, OVERWRITING it. Implemented in `pushDatabase()` (`commands/local.ts`) + `lib/sqlite-to-mysql.ts`. 1. Read local siteurl from `wp_options` (authoritative; handles port drift) 2. Discover cloud `table_prefix` + existing tables via SSH — **MOTD-stripped** (`parseTablePrefix`/`parseSqlTableNames` in `lib/local-instance.ts`); a banner leaking in would mismatch every table → silent empty push 3. `generateMysqlDump()`: **data-only** (`TRUNCATE`+`INSERT`, no `CREATE TABLE`) for local `wp_*` tables whose cloud-prefixed name exists on the cloud (intersection — so a missing-table TRUNCATE can't abort the import). Pins `sql_mode` (backslash escaping), `safeIntegers(true)` (no >2^53 loss), BLOB→`0x` hex (empty→`''`), byte-budgeted INSERT batching 4. Refuse if 0 tables intersect (fail loud, never a silent no-op) -5. Back up cloud DB (`wp db export | gzip`), abort if it fails (unless `--no-backup`) +5. Back up cloud DB (`wp db export | gzip`), abort if it fails (unless `--no-backup`, or the push just created the site — nothing to protect) 6. scp upload → `wp db import` 7. If cloud prefix ≠ `wp_`: remap role/capability keys to the cloud prefix — `{prefix}capabilities`, `{prefix}user_level` (usermeta), `{prefix}user_roles` (options). Local is `wp_`-normalized, so without this the admin loses all capabilities and **wp-admin becomes inaccessible** (most InstaWP sites use a random prefix). Exact key names only — never touches plugin options. 8. `wp search-replace ` (serialization-safe — NOT done in SQL) → `wp cache flush` diff --git a/README.md b/README.md index 4aa0897..9016d93 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ InstaWP CLI is the connective tissue between your terminal, your InstaWP account - **🤖 Give an AI agent a real WordPress, then ship it** — Claude Code / Cursor gets a genuine WP sandbox (WASM PHP + SQLite, zero setup), builds against it, and deploys to a live site: ```bash instawp local create --name app # real WordPress on your laptop, no Docker - instawp local push app # provisions a cloud site + deploys it + instawp local deploy app # provisions a cloud site + pushes files AND database ``` - **🌉 Run WP-CLI on any site — no SSH** — pipes WP-CLI over HTTPS, so it works behind firewalls and in CI, addressed by name: ```bash @@ -122,6 +122,10 @@ instawp local clone instawp local start [name] instawp local stop [name] +# Deploy a local site to the cloud in one shot (creates the site, pushes files + DB) +instawp local deploy # alias of `local push` — the one-command "make this live" +instawp local deploy --no-db # files only — the new site keeps its empty WordPress DB + # Sync between local and cloud instawp local push [cloud-site] # push wp-content (files) up; pushes back to the cloned origin by default instawp local push --with-db # ALSO overwrite the cloud database with your local one (backs it up first) @@ -133,7 +137,9 @@ instawp local list instawp local delete --force ``` -**Files vs. database:** `local push` syncs **files** (`wp-content`) by default — so plugins/themes/uploads, but **not** content like pages or posts (those live in the database). Add **`--with-db`** to also overwrite the cloud database with your local one. It backs up the cloud DB first, converts the local Playground SQLite to MySQL (data-only — it reuses the cloud's existing schema), imports it, and rewrites local→cloud URLs (serialization-safe). Best used on a cloned site, where local and cloud schemas match; tables that exist only locally (e.g. a plugin's custom tables created after cloning) are reported and skipped. Use `--dry-run` to preview, `--no-backup` to skip the safety backup (not recommended). +**Deploying vs. syncing.** With no cloud site given (and no cloned origin), `local push` — or its alias **`local deploy`** — *provisions* the cloud site for you and pushes **files + database**, so a site you built locally goes live in one command. Pushing into an **existing** site is the opposite default: files only, because overwriting a live database is destructive. Add `--with-db` to include it there, or `--no-db` to keep a deploy files-only. + +**Files vs. database:** `local push` into an existing site syncs **files** (`wp-content`) — so plugins/themes/uploads, but **not** content like pages or posts (those live in the database). Add **`--with-db`** to also overwrite the cloud database with your local one. It backs up the cloud DB first, converts the local Playground SQLite to MySQL (data-only — it reuses the cloud's existing schema), imports it, and rewrites local→cloud URLs (serialization-safe). Best used on a cloned site, where local and cloud schemas match; tables that exist only locally (e.g. a plugin's custom tables created after cloning) are reported and skipped. Use `--dry-run` to preview, `--no-backup` to skip the safety backup (not recommended). ### Run Commands (WP-CLI + shell) diff --git a/package-lock.json b/package-lock.json index 6e02fef..92526b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@instawp/cli", - "version": "0.0.1-beta.32", + "version": "0.0.1-beta.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@instawp/cli", - "version": "0.0.1-beta.32", + "version": "0.0.1-beta.33", "license": "MIT", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index bb26326..e93ac54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@instawp/cli", - "version": "0.0.1-beta.32", + "version": "0.0.1-beta.33", "description": "InstaWP CLI - Create and manage WordPress sites from the terminal", "type": "module", "bin": { diff --git a/src/__tests__/local-instance.test.ts b/src/__tests__/local-instance.test.ts index bc86305..255aff5 100644 --- a/src/__tests__/local-instance.test.ts +++ b/src/__tests__/local-instance.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { sanitizeName, defaultInstanceName, pushTargetRef, parseTablePrefix, parseSqlTableNames } from '../lib/local-instance.js'; +import { sanitizeName, defaultInstanceName, pushTargetRef, shouldPushDb, parseTablePrefix, parseSqlTableNames } from '../lib/local-instance.js'; describe('sanitizeName', () => { it('lowercases and replaces non [a-z0-9_-] with -', () => { @@ -48,6 +48,26 @@ describe('pushTargetRef', () => { }); }); +describe('shouldPushDb', () => { + // commander's --no-db default: `db` is true unless the user passes --no-db. + const base = { db: true }; + + it('pushes the DB by default when this push creates the site', () => { + expect(shouldPushDb(base, true)).toBe(true); + }); + + it('keeps the DB opt-in when pushing into an existing site', () => { + expect(shouldPushDb(base, false)).toBe(false); + expect(shouldPushDb({ ...base, withDb: true }, false)).toBe(true); + }); + + it('lets --no-db win over both the auto-include and an explicit --with-db', () => { + expect(shouldPushDb({ db: false }, true)).toBe(false); + expect(shouldPushDb({ db: false, withDb: true }, true)).toBe(false); + expect(shouldPushDb({ db: false, withDb: true }, false)).toBe(false); + }); +}); + describe('parseTablePrefix (MOTD-resilient)', () => { it('returns the prefix on a clean single line', () => { expect(parseTablePrefix('wp_\n')).toBe('wp_'); diff --git a/src/commands/local.ts b/src/commands/local.ts index 531cf5c..2711f5c 100644 --- a/src/commands/local.ts +++ b/src/commands/local.ts @@ -32,7 +32,7 @@ import { resolveSite } from '../lib/site-resolver.js'; import { ensureSshAccess } from '../lib/ssh-keys.js'; import { syncFiles, execViaSsh, execViaSshToFile, scpUpload } from '../lib/ssh-connection.js'; import { listLocalFiles } from '../lib/sftp-sync.js'; -import { sanitizeName, defaultInstanceName, pushTargetRef, parseTablePrefix, parseSqlTableNames } from '../lib/local-instance.js'; +import { sanitizeName, defaultInstanceName, pushTargetRef, shouldPushDb, parseTablePrefix, parseSqlTableNames } from '../lib/local-instance.js'; import { generateMysqlDump } from '../lib/sqlite-to-mysql.js'; import { success, error, table, spinner, info, isJsonMode } from '../lib/output.js'; import type { LocalInstance, SshConnection } from '../types.js'; @@ -221,13 +221,15 @@ export function registerLocalCommand(program: Command): void { success(`Instance "${name}" deleted.`); } }); - // local push [cloud-site] + // local push [cloud-site] (alias: local deploy) local .command('push [cloud-site]') - .description('Push local wp-content to an InstaWP cloud site') + .alias('deploy') + .description('Push a local site to InstaWP cloud — creates the cloud site (files + database) if no cloud-site is given') .option('--include ', 'Include patterns (e.g. .git)') .option('--exclude ', 'Additional exclude patterns') - .option('--with-db', 'Also push the local database, OVERWRITING the cloud DB (backs it up first)') + .option('--with-db', 'Push the local database too, OVERWRITING the cloud DB (backs it up first). Implied when creating the site') + .option('--no-db', 'Files only — do not push the database') .option('--no-backup', 'With --with-db: skip the cloud DB backup before overwrite (DANGEROUS)') .option('--force', 'With --with-db: skip the overwrite confirmation prompt') .option('--dry-run', 'Show what would be transferred') @@ -248,6 +250,12 @@ export function registerLocalCommand(program: Command): void { // remembers its origin and pushes back to it. const targetRef = pushTargetRef(cloudSiteArg, instance); + // No target means THIS command provisions the site — which also decides + // whether the database rides along (see shouldPushDb) and whether the + // overwrite guards apply: there is no pre-existing cloud data to protect. + const isNewSite = !targetRef; + const includeDb = shouldPushDb(opts, isNewSite); + // A dry run must be side-effect free. With no target at all (no arg, not a // cloned instance), a real push would *create* a site — which a dry run // must never do — so preview the local files that would be pushed (pure @@ -257,11 +265,14 @@ export function registerLocalCommand(program: Command): void { const excludes = ['database', 'db.php', 'mu-plugins', '.git', 'node_modules', '.DS_Store', ...(opts.exclude ?? [])]; const files = listLocalFiles(join(instance.path, 'wp-content'), excludes); if (isJsonMode()) { - console.log(JSON.stringify({ success: true, dry_run: true, would_create_site: localName, files })); + console.log(JSON.stringify({ success: true, dry_run: true, would_create_site: localName, would_push_db: includeDb, files })); } else { info(`(dry run) Would create cloud site "${localName}" and push ${chalk.dim(localWpContent)}`); for (const rel of files) console.log(` ${chalk.dim('↑')} ${rel}`); info(`(dry run) ${files.length} file(s) would be pushed. No cloud site was created.`); + info(includeDb + ? '(dry run) The local database would be pushed to the new site and its URLs rewritten.' + : '(dry run) Files only (--no-db) — the new site would keep its empty WordPress database.'); } return; } @@ -369,11 +380,13 @@ export function registerLocalCommand(program: Command): void { process.exit(exitCode); } - // Optionally push the database too (OVERWRITES the cloud DB). Handles its - // own dry-run reporting and confirmation. - let dbStatus: 'done' | 'cancelled' | 'dry' | null = null; - if (opts.withDb) { - dbStatus = await pushDatabase(instance, site, conn, opts); + // Push the database too (OVERWRITES the cloud DB). Handles its own dry-run + // reporting and confirmation. `freshSite` tells it the DB it is about to + // replace is the untouched WordPress default this command just provisioned, + // so the overwrite prompt and the safety backup have nothing to protect. + let dbStatus: 'done' | 'cancelled' | 'dry' | 'skipped' | null = null; + if (includeDb) { + dbStatus = await pushDatabase(instance, site, conn, { ...opts, freshSite: isNewSite }); } // Dry-run output was already emitted by the file sync (and pushDatabase); @@ -390,7 +403,7 @@ export function registerLocalCommand(program: Command): void { if (site.url) { console.log(`\n ${chalk.dim('Cloud site:')} ${chalk.cyan.underline(site.url)}`); } - if (!opts.withDb) { + if (!includeDb || dbStatus === 'skipped') { info('Files only. Database/content changes (pages, posts, settings) were NOT pushed — add --with-db to overwrite the cloud database.'); } }); @@ -778,11 +791,20 @@ function isShellSafeUrl(u: string): boolean { * both), back up the cloud DB, upload + import, then `wp search-replace` the URL * (serialization-safe). Honors --dry-run / --no-backup / --force. */ -async function pushDatabase(instance: LocalInstance, site: any, conn: SshConnection, opts: any): Promise<'done' | 'cancelled' | 'dry'> { +async function pushDatabase(instance: LocalInstance, site: any, conn: SshConnection, opts: any): Promise<'done' | 'cancelled' | 'dry' | 'skipped'> { const sqlitePath = join(instance.path, 'wp-content', 'database', '.ht.sqlite'); if (!existsSync(sqlitePath)) { - error('No local database found (expected wp-content/database/.ht.sqlite). Skipping DB push.'); - process.exit(1); + // An explicit --with-db must fail loud — the user asked for the DB and didn't + // get it. But when the DB was included automatically (a site-creating deploy), + // the site is already provisioned and the files are already up: exiting here + // would abandon a half-finished deploy over a database that isn't there. + // Degrade to files-only and say so. + if (opts.withDb) { + error('No local database found (expected wp-content/database/.ht.sqlite). Skipping DB push.'); + process.exit(1); + } + error('No local database found (expected wp-content/database/.ht.sqlite) — deployed the files only.'); + return 'skipped'; } const wpPath = `/home/${conn.username}/web/${conn.domain}/public_html`; @@ -799,7 +821,10 @@ async function pushDatabase(instance: LocalInstance, site: any, conn: SshConnect const toUrl = String(site.url || `https://${conn.domain}`).replace(/\/+$/, ''); // Destructive confirmation (skipped on --force; --json requires --force). - if (!opts.force && !opts.dryRun) { + // A freshly-provisioned site has no user data to lose — its DB is the default + // WordPress install this command created seconds ago — so don't make the user + // confirm an "overwrite" of nothing. + if (!opts.force && !opts.dryRun && !opts.freshSite) { if (isJsonMode()) { error('--force is required with --with-db in --json mode (cannot prompt before overwriting the cloud DB).'); process.exit(1); @@ -885,9 +910,10 @@ async function pushDatabase(instance: LocalInstance, site: any, conn: SshConnect info(`${untouched.length} cloud table(s) have no local counterpart and will KEEP their existing data: ${untouched.slice(0, 8).join(', ')}${untouched.length > 8 ? ', …' : ''}`); } - // Back up the cloud DB first (unless --no-backup). Random suffix so same-second - // reruns never clobber a prior backup. - const takeBackup = opts.backup !== false; + // Back up the cloud DB first (unless --no-backup, or the site is one we just + // created — backing up an empty default install is pure cost). Random suffix so + // same-second reruns never clobber a prior backup. + const takeBackup = opts.backup !== false && !opts.freshSite; const ts = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, ''); const backupFilename = `db-backup-${ts}-${randomBytes(3).toString('hex')}.sql.gz`; const backupRemotePath = `/home/${conn.username}/${backupFilename}`; @@ -902,6 +928,8 @@ async function pushDatabase(instance: LocalInstance, site: any, conn: SshConnect process.exit(1); } bSpin.succeed(`Cloud DB backed up: ~/${backupFilename}`); + } else if (opts.freshSite) { + info('New site — no existing content to back up.'); } else { info('Skipping cloud DB backup (--no-backup).'); } diff --git a/src/index.ts b/src/index.ts index 75adf5c..35ac5b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -136,13 +136,15 @@ ${d('Local Development')} ${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 push')} Push a local site up (creates the cloud site if none given) + ${d('(alias: local deploy — files + database, one shot)')} ${c('local pull')} Pull cloud wp-content to local site ${c('local list')} List local sites ${c('local delete')} Delete a local site ${d('Migration')} - ${c('migrate push')} ${d('[path]')} Mirror a local WP install to a new hosted InstaWP site + ${c('migrate push')} ${d('[path]')} Mirror an on-disk WP install (LocalWP, Docker, MAMP…) to a new hosted site + ${d('(for a `local create` site use `local deploy` instead)')} ${d('Teams')} ${c('teams list')} List teams @@ -157,6 +159,7 @@ ${d('Examples')} $ instawp login $ instawp create --name my-site $ instawp local create --name blog + $ instawp local deploy blog $ instawp wp my-site plugin list $ instawp wp my-site -- post list --post_type=page $ instawp versions create my-site --name "before plugin update" diff --git a/src/lib/local-instance.ts b/src/lib/local-instance.ts index 8049306..b6c11c8 100644 --- a/src/lib/local-instance.ts +++ b/src/lib/local-instance.ts @@ -65,3 +65,20 @@ export function pushTargetRef( if (instance.cloudSiteId) return String(instance.cloudSiteId); return undefined; } + +/** + * Does this push carry the database as well as the files? + * + * Pushing into an EXISTING site stays opt-in (`--with-db`) — it overwrites live + * data. But when the push is what CREATES the site, the database is the whole + * point: files-only would leave the user on a brand-new site with none of their + * pages, posts or settings. So a site-creating push takes the DB by default. + * `--no-db` always wins, so the files-only behaviour stays reachable. + */ +export function shouldPushDb( + opts: { withDb?: boolean; db?: boolean }, + isNewSite: boolean, +): boolean { + if (opts.db === false) return false; + return !!opts.withDb || isNewSite; +}