Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 10 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ instawp local create [--name <n>] [--wp <v>] [--php <v>] [--background] [--no-op
instawp local clone <cloud-site> [--name <n>] [--no-start]
instawp local start [name] [--background] [--no-open]
instawp local stop [name]
instawp local push <local-name> [cloud-site] [--dry-run]
instawp local push|deploy <local-name> [cloud-site] [--with-db] [--no-db] [--no-backup] [--force] [--dry-run]
instawp local pull <local-name> <cloud-site> [--dry-run]
instawp local list
instawp local delete <name> [--force]
Expand Down Expand Up @@ -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 <local-url> <cloud-url>` (serialization-safe — NOT done in SQL) → `wp cache flush`
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -122,6 +122,10 @@ instawp local clone <cloud-site>
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 <local-name> # alias of `local push` — the one-command "make this live"
instawp local deploy <local-name> --no-db # files only — the new site keeps its empty WordPress DB

# Sync between local and cloud
instawp local push <local-name> [cloud-site] # push wp-content (files) up; pushes back to the cloned origin by default
instawp local push <local-name> --with-db # ALSO overwrite the cloud database with your local one (backs it up first)
Expand All @@ -133,7 +137,9 @@ instawp local list
instawp local delete <name> --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)

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
22 changes: 21 additions & 1 deletion src/__tests__/local-instance.test.ts
Original file line number Diff line number Diff line change
@@ -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 -', () => {
Expand Down Expand Up @@ -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_');
Expand Down
64 changes: 46 additions & 18 deletions src/commands/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -221,13 +221,15 @@ export function registerLocalCommand(program: Command): void {
success(`Instance "${name}" deleted.`);
}
});
// local push <local-name> [cloud-site]
// local push <local-name> [cloud-site] (alias: local deploy)
local
.command('push <local-name> [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 <pattern...>', 'Include patterns (e.g. .git)')
.option('--exclude <pattern...>', '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')
Expand All @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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.');
}
});
Expand Down Expand Up @@ -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`;
Expand All @@ -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);
Expand Down Expand Up @@ -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}`;
Expand All @@ -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).');
}
Expand Down
Loading
Loading