Skip to content
Closed
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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Changelog

## 0.0.1-beta.25 (2026-06-25)
## Unreleased

### Added — db push readiness/scoping + backup retention (large-DB feedback #16)

- **`db push --verify`** polls the site URL until it answers HTTP after the import (a large import can briefly return `000` right after import/flush); reported as `verified` in the summary.
- **`db push --sr-tables <table...>`** scopes `--search-replace` to specific (prefixed) tables instead of all tables — faster on big DBs whose bulk rows have no URLs. Default stays `--all-tables`.
- **`db backups list <site>`** and **`db backups prune <site> [--keep <n>] [--older-than <days>] [--force]`** manage the `~/db-backup-*.sql.gz` files `db push` leaves behind (they previously accumulated with no way to view/clean them).
- Internal: extracted the HTTP-readiness helper to `lib/http-ready.ts` (now shared by `sites create` and `db push --verify`).

### Improved — db push transport & progress (large-DB path)

Expand Down
69 changes: 69 additions & 0 deletions src/__tests__/db-backups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { parseBackupList, selectBackupsToPrune } from '../lib/db-backups.js';

describe('parseBackupList', () => {
it('parses tab-separated stat output, newest first', () => {
const out = [
'/home/u/db-backup-2026-06-20.sql.gz\t1048576\t1718870400',
'/home/u/db-backup-2026-06-25.sql.gz\t2097152\t1719273600',
].join('\n');
const list = parseBackupList(out);
expect(list).toHaveLength(2);
expect(list[0].file).toBe('/home/u/db-backup-2026-06-25.sql.gz'); // newest first
expect(list[0].sizeBytes).toBe(2097152);
expect(list[1].mtime).toBe(1718870400);
});

it('tolerates an SSH banner/MOTD and skips non-matching lines', () => {
const out = [
'Welcome to Ubuntu 22.04',
'Last login: ...',
'/home/u/db-backup-a.sql.gz\t100\t1719273600',
'some junk line without tabs',
'/home/u/notanumber.sql.gz\tNaN\tNaN',
].join('\n');
const list = parseBackupList(out);
expect(list).toHaveLength(1);
expect(list[0].file).toBe('/home/u/db-backup-a.sql.gz');
});

it('returns [] for empty input', () => {
expect(parseBackupList('')).toEqual([]);
});
});

describe('selectBackupsToPrune', () => {
const now = 1_719_273_600; // fixed "now"
const day = 86400;
// newest → oldest
const backups = [
{ file: 'd', sizeBytes: 1, mtime: now - 1 * day },
{ file: 'c', sizeBytes: 1, mtime: now - 3 * day },
{ file: 'b', sizeBytes: 1, mtime: now - 10 * day },
{ file: 'a', sizeBytes: 1, mtime: now - 30 * day },
];

it('--keep N keeps the newest N, deletes the rest', () => {
const { toDelete, toKeep } = selectBackupsToPrune(backups, { keep: 2 }, now);
expect(toKeep.map((b) => b.file)).toEqual(['d', 'c']);
expect(toDelete.map((b) => b.file)).toEqual(['b', 'a']);
});

it('--older-than D deletes backups older than D days', () => {
const { toDelete, toKeep } = selectBackupsToPrune(backups, { olderThanDays: 5 }, now);
expect(toDelete.map((b) => b.file)).toEqual(['b', 'a']); // 10d, 30d old
expect(toKeep.map((b) => b.file)).toEqual(['d', 'c']); // 1d, 3d
});

it('combines criteria with OR (deleted if beyond keep OR too old)', () => {
const { toDelete } = selectBackupsToPrune(backups, { keep: 3, olderThanDays: 5 }, now);
// beyond keep:3 → 'a'; older than 5d → 'b','a' → union {b,a}
expect(toDelete.map((b) => b.file).sort()).toEqual(['a', 'b']);
});

it('deletes nothing when no criteria are given', () => {
const { toDelete, toKeep } = selectBackupsToPrune(backups, {}, now);
expect(toDelete).toHaveLength(0);
expect(toKeep).toHaveLength(4);
});
});
22 changes: 22 additions & 0 deletions src/__tests__/http-ready.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { waitForHttp } from '../lib/http-ready.js';

afterEach(() => {
vi.unstubAllGlobals();
});

describe('waitForHttp', () => {
it('returns true as soon as the URL answers (any HTTP response)', async () => {
const fetchMock = vi.fn().mockResolvedValue({ status: 200 });
vi.stubGlobal('fetch', fetchMock);
await expect(waitForHttp('https://x.test', 5000)).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
});

it('returns false without calling fetch when the budget is already exhausted', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(waitForHttp('https://x.test', 0)).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
});
154 changes: 151 additions & 3 deletions src/commands/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { ensureSshAccess } from '../lib/ssh-keys.js';
import { execViaSsh, execViaSshToFile, scpUpload } from '../lib/ssh-connection.js';
import { parseTablePrefix } from '../lib/local-instance.js';
import { detectDumpPrefix, readSqlHead, rewriteDumpPrefix } from '../lib/sql-dump.js';
import { success, error, spinner, info, isJsonMode } from '../lib/output.js';
import { waitForHttp } from '../lib/http-ready.js';
import { parseBackupList, selectBackupsToPrune, type RemoteBackup } from '../lib/db-backups.js';
import { success, error, spinner, info, table, isJsonMode } from '../lib/output.js';
import type { SshConnection } from '../types.js';

/** Timestamp like `2026-05-23T12-34-56` (filename-safe — `:` is illegal on Windows). */
function isoTimestamp(): string {
Expand Down Expand Up @@ -50,6 +53,31 @@ function isShellSafeUrl(u: string): boolean {
return /^https?:\/\/[^\s'"\\$`]+$/.test(u);
}

/** Resolve a site and open an SSH connection (shared by the backups subcommands). */
async function resolveAndConnect(siteIdentifier: string): Promise<{ site: any; conn: SshConnection }> {
const rspin = spinner('Resolving site...');
rspin.start();
let site: any;
try {
site = await resolveSite(siteIdentifier);
rspin.succeed(`Site: ${site.name || site.sub_domain} (ID: ${site.id})`);
} catch {
rspin.fail('Site resolution failed');
process.exit(1);
}
const conn = await ensureSshAccess(site.id);
return { site, conn };
}

/** List `~/db-backup-*.sql.gz` on the remote (the files `db push` writes), newest first. */
function listRemoteBackups(conn: SshConnection): RemoteBackup[] {
// GNU stat interprets the \t escapes; the for-loop guard avoids a literal glob
// when nothing matches. parseBackupList tolerates any MOTD/banner lines.
const cmd = `for f in ~/db-backup-*.sql.gz; do [ -e "$f" ] && stat -c '%n\\t%s\\t%Y' "$f"; done`;
const res = execViaSsh(conn, cmd);
return parseBackupList(res.stdout || '');
}

export function registerDbCommand(program: Command): void {
const db = program
.command('db')
Expand Down Expand Up @@ -136,6 +164,8 @@ export function registerDbCommand(program: Command): void {
.option('--no-backup', 'Skip taking a remote backup before overwrite (DANGEROUS)')
.option('--rewrite-prefix', "Rewrite the dump's table prefix to match the remote site's prefix (e.g. wp_ → iwpa4c7_)")
.option('--search-replace <from-to...>', 'After import, run wp search-replace <from> <to> across all tables (serialization-safe, skips guid). Pass exactly two URLs.')
.option('--sr-tables <table...>', 'Scope --search-replace to these (prefixed) tables instead of all tables — faster on big DBs whose bulk has no URLs')
.option('--verify', 'After import, poll the site URL until it responds (large imports can briefly return 000)')
.addHelpText('after', `
Notes:
- Always takes a remote backup first unless --no-backup is passed.
Expand All @@ -146,10 +176,13 @@ Notes:
- URLs: after a cross-domain push, remap URLs with:
--search-replace <old-url> <new-url>
(or run 'instawp wp <site> search-replace <old-url> <new-url>' yourself).
--search-replace scans all tables by default; pass --sr-tables to limit it.
- --verify confirms the site answers HTTP after the import (reported in the summary).

Examples:
$ instawp db push my-site dump.sql --rewrite-prefix
$ instawp db push my-site dump.sql --rewrite-prefix --verify
$ instawp db push my-site dump.sql --search-replace http://localhost:10115 https://my-site.instawp.site
$ instawp db push my-site dump.sql --search-replace http://old https://new --sr-tables iwpa4c7_options iwpa4c7_posts iwpa4c7_postmeta
`)
.action(async (siteIdentifier: string, file: string, opts: any) => {
requireAuth();
Expand Down Expand Up @@ -181,6 +214,20 @@ Examples:
}
}

// Parse/validate --sr-tables (table names go into a shell command — keep identifier-safe)
const srTables: string[] = Array.isArray(opts.srTables) ? opts.srTables : [];
if (srTables.length) {
const bad = srTables.filter((t) => !/^[A-Za-z0-9_]+$/.test(t));
if (bad.length) {
error(`--sr-tables: invalid table name(s): ${bad.join(', ')} (use plain prefixed table names)`);
process.exit(1);
}
if (!srFrom) {
error('--sr-tables only applies with --search-replace');
process.exit(1);
}
}

// In JSON mode, can't prompt — require --force
if (isJsonMode() && !opts.force) {
error('--force is required when using --json (cannot prompt for confirmation)');
Expand Down Expand Up @@ -399,9 +446,11 @@ Examples:
// Skip `guid` — post GUIDs are permanent identifiers, not links, and WP
// best practice is to never rewrite them on a domain change.
if (srFrom && srTo) {
// Scope: explicit (prefixed) tables if --sr-tables given, else all tables.
const scope = srTables.length ? srTables.join(' ') : '--all-tables';
const srSpin = spinner(`Rewriting URLs (${srFrom} -> ${srTo})...`);
srSpin.start();
const srRes = execViaSsh(conn, `cd ${wpPath} && wp search-replace '${srFrom}' '${srTo}' --all-tables --skip-columns=guid --report-changed-only`);
const srRes = execViaSsh(conn, `cd ${wpPath} && wp search-replace '${srFrom}' '${srTo}' ${scope} --skip-columns=guid --report-changed-only`);
if (srRes.exitCode === 0) {
srSpin.succeed('URLs rewritten');
if (!isJsonMode() && srRes.stdout.trim()) console.log(srRes.stdout.trim());
Expand All @@ -421,6 +470,18 @@ Examples:
cleanupSpin.succeed('Cleanup complete');
}

// Step 6: Optional readiness check. A large import can briefly leave the
// site returning 000 right after import/flush; poll until it answers.
let verified: string | null = null;
if (opts.verify) {
const siteUrl = String(site.url || `https://${conn.domain}`).replace(/\/+$/, '');
const vSpin = spinner(`Verifying ${siteUrl} responds...`);
vSpin.start();
const ok = await waitForHttp(siteUrl, 90000);
if (ok) { vSpin.succeed('Site is responding'); verified = 'ok'; }
else { vSpin.fail('Site did not respond within 90s (large imports can need a moment to warm up)'); verified = 'timeout'; }
}

const elapsedSec = (Date.now() - startedAt) / 1000;
const rate = elapsedSec > 0 ? `${formatBytes(localSize / elapsedSec)}/s` : 'n/a';
const elapsedStr = `${elapsedSec < 10 ? elapsedSec.toFixed(1) : Math.round(elapsedSec)}s (${rate})`;
Expand All @@ -433,10 +494,97 @@ Examples:
rewrote_prefix: remapFromPrefix ? `${remapFromPrefix} -> ${remotePrefix}` : null,
search_replaced: srFrom && srTo ? `${srFrom} -> ${srTo}` : null,
elapsed: elapsedStr,
...(verified ? { verified } : {}),
});

if (!isJsonMode() && takeBackup) {
console.log(`\n ${chalk.dim('Backup:')} ~/${backupFilename} ${chalk.dim('(on remote)')}`);
}
});

// db backups list/prune <site> — manage the ~/db-backup-*.sql.gz files db push leaves behind
const backups = db
.command('backups')
.description('List or prune db push backups (~/db-backup-*.sql.gz) on a site');

backups
.command('list <site>')
.description('List the db-backup-*.sql.gz files on the remote site (newest first)')
.action(async (siteIdentifier: string) => {
requireAuth();
const { conn } = await resolveAndConnect(siteIdentifier);
const list = listRemoteBackups(conn);

if (isJsonMode()) {
console.log(JSON.stringify({
success: true,
data: { backups: list.map((b) => ({ file: b.file, size_bytes: b.sizeBytes, modified: new Date(b.mtime * 1000).toISOString() })) },
}));
return;
}
if (!list.length) { info('No backups found (~/db-backup-*.sql.gz).'); return; }
table(['File', 'Size', 'Modified'], list.map((b) => ({
file: b.file.replace(/^.*\//, ''),
size: formatBytes(b.sizeBytes),
modified: new Date(b.mtime * 1000).toISOString().replace('T', ' ').slice(0, 16),
})));
const total = list.reduce((n, b) => n + b.sizeBytes, 0);
info(`${list.length} backup(s), ${formatBytes(total)} total`);
});

backups
.command('prune <site>')
.description('Delete old db-backup-*.sql.gz files (keep newest N and/or drop older than D days)')
.option('--keep <n>', 'Keep the newest N backups, delete the rest', (v) => parseInt(v, 10))
.option('--older-than <days>', 'Delete backups older than this many days', (v) => parseInt(v, 10))
.option('--force', 'Skip confirmation prompt')
.action(async (siteIdentifier: string, opts: any) => {
requireAuth();
if (opts.keep === undefined && opts.olderThan === undefined) {
error('Specify --keep <n> and/or --older-than <days> (refusing to prune without a selector)');
process.exit(1);
}
if ((opts.keep !== undefined && (!Number.isInteger(opts.keep) || opts.keep < 0)) ||
(opts.olderThan !== undefined && (!Number.isInteger(opts.olderThan) || opts.olderThan < 0))) {
error('--keep / --older-than must be non-negative integers');
process.exit(1);
}
if (isJsonMode() && !opts.force) {
error('--force is required when using --json (cannot prompt for confirmation)');
process.exit(1);
}

const { conn } = await resolveAndConnect(siteIdentifier);
const list = listRemoteBackups(conn);
const { toDelete, toKeep } = selectBackupsToPrune(
list,
{ keep: opts.keep, olderThanDays: opts.olderThan },
Math.floor(Date.now() / 1000),
);

if (!toDelete.length) {
if (isJsonMode()) { console.log(JSON.stringify({ success: true, data: { deleted: [], kept: toKeep.length } })); }
else { info(`Nothing to prune (${toKeep.length} backup(s) kept).`); }
return;
}

if (!isJsonMode()) {
const freed = formatBytes(toDelete.reduce((n, b) => n + b.sizeBytes, 0));
console.log(`Will delete ${chalk.bold.red(String(toDelete.length))} backup(s) (${freed}), keeping ${toKeep.length}:`);
for (const b of toDelete) console.log(` ${chalk.dim('-')} ${b.file.replace(/^.*\//, '')}`);
if (!opts.force) {
const ok = await promptYesNo('Delete these backups? (y/N) ');
if (!ok) { info('Cancelled.'); return; }
}
}

const quoted = toDelete.map((b) => `'${b.file.replace(/'/g, "'\\''")}'`).join(' ');
const rm = execViaSsh(conn, `rm -f ${quoted}`);
if (rm.exitCode !== 0) {
error('Failed to delete some backups');
if (rm.stderr) error(rm.stderr.trim());
process.exit(1);
}
success('Backups pruned', { deleted: toDelete.length, kept: toKeep.length });
});
}
25 changes: 1 addition & 24 deletions src/commands/sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 { waitForHttp } from '../lib/http-ready.js';
import { success, error, table, spinner, info, isJsonMode } from '../lib/output.js';

export function registerSitesCommand(program: Command): void {
Expand Down Expand Up @@ -390,30 +391,6 @@ export function registerSitesCommand(program: Command): void {
});
}

/**
* Poll a URL until it answers (any HTTP status = DNS resolved + server up), or
* the budget runs out. A fresh site's DNS/edge lags task completion by 30–120s,
* so without this "Ready" lies and callers hand-roll curl-retry gates.
*/
async function waitForHttp(url: string, maxMs: number): Promise<boolean> {
const deadline = Date.now() + Math.min(Math.max(maxMs, 0), 180000);
while (Date.now() < deadline) {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
try {
await fetch(url, { signal: ctrl.signal, redirect: 'follow' });
return true; // any HTTP response means it's reachable
} finally {
clearTimeout(timer);
}
} catch {
// DNS/connection not ready yet — back off and retry.
}
await new Promise((r) => setTimeout(r, 3000));
}
return false;
}

// Shared create action used by both `sites create` and top-level `create`
async function createSiteAction(opts: any): Promise<void> {
Expand Down
Loading
Loading