diff --git a/CHANGELOG.md b/CHANGELOG.md index a6f20b2..977940e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.0.1-beta.24 (2026-06-23) + +### Added — db push prefix-safety, URL remap, sync mirroring & webroot, exec shell + +These address feedback from a real LocalWP → cloud "mirror overwrite" workflow. + +- **`db push` now detects a table-prefix mismatch** (e.g. a `wp_` dump pushed to an `iwpa4c7_` site) instead of silently importing orphan tables the site never reads. It warns loudly and offers to rewrite; pass **`--rewrite-prefix`** to remap the dump's table identifiers non-interactively. Rewriting also remaps the prefix-bound role/capability keys (`{prefix}capabilities`, `{prefix}user_level`, `{prefix}user_roles`) after import so admin login survives. +- **`db push --search-replace `** runs a serialization-safe `wp search-replace` across all tables right after import — the near-universal next step after a cross-domain push. +- **`instawp wp`/`exec` no longer leak the server login banner/MOTD** into command stdout (or the `--json` payload). Output is now exactly the command's output, so `--field`, `--format=count`, and value capture are clean. +- **`exec --shell ''`** runs the arguments as one shell command line on the remote (enables pipes, `;`, `>`, globs). The misleading `-- ps aux | grep php` help example (which actually piped locally) is fixed. +- **`sync push/pull --delete`** makes the remote/local a true mirror (removes extraneous files), guarded by a confirmation prompt (skip with `--yes`; preview with `--dry-run`). Additive remains the default. Not supported on Windows (SFTP transport). +- **`sync push/pull --remote-path ` / `--webroot`** transfer files outside `wp-content/` (e.g. webroot-level `ABSPATH/variations/`). +- **`versions create`** now echoes the restore/list hint using the same site identifier you typed (no more short-name in, FQDN out). +- `db push --search-replace` now **skips the `guid` column** (WP best practice — post GUIDs are permanent identifiers, not links); same applied to `local push --with-db`. +- `db push` success summary prints readable `rewrote_prefix` / `search_replaced` lines (`from -> to`) instead of `[object Object]`. +- **`exec --stdin`** streams local stdin to the remote command (uploads, restore pipes, `tar … | exec --stdin 'tar xzf -'`) — closes the last gap from the feedback. It passes the command as an ssh argument so stdin is free to stream; the non-login remote shell it uses also means no MOTD on that path. + ## 0.0.1-beta.23 (2026-06-15) ### Added — update notifier + `instawp upgrade` diff --git a/package.json b/package.json index 8df7e0a..71f8939 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@instawp/cli", - "version": "0.0.1-beta.23", + "version": "0.0.1-beta.24", "description": "InstaWP CLI - Create and manage WordPress sites from the terminal", "type": "module", "bin": { diff --git a/src/__tests__/remote-command.test.ts b/src/__tests__/remote-command.test.ts new file mode 100644 index 0000000..97596f8 --- /dev/null +++ b/src/__tests__/remote-command.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { shellQuote, buildRemoteCommandString, sliceAfterMarker } from '../lib/remote-command.js'; + +describe('shellQuote', () => { + it('passes shell-safe tokens through unquoted', () => { + expect(shellQuote('post')).toBe('post'); + expect(shellQuote('--format=count')).toBe('--format=count'); + }); + + it('single-quotes tokens with metacharacters', () => { + expect(shellQuote('a b')).toBe("'a b'"); + expect(shellQuote('echo hi; whoami')).toBe("'echo hi; whoami'"); + }); + + it('escapes embedded single quotes', () => { + expect(shellQuote("a'b")).toBe("'a'\\''b'"); + }); +}); + +describe('buildRemoteCommandString', () => { + it('forwards normal wp-cli args verbatim', () => { + expect(buildRemoteCommandString(['post', 'list', '--format=count'])).toBe('post list --format=count'); + }); + + it('treats a single metacharacter-laden arg as one literal token (default)', () => { + // This is the documented footgun: without --shell, the whole string is one command name. + expect(buildRemoteCommandString(['echo hi; whoami'])).toBe("'echo hi; whoami'"); + }); + + it('wraps the whole line in bash -lc when shell=true', () => { + expect(buildRemoteCommandString(['ps aux | grep php'], true)).toBe("bash -lc 'ps aux | grep php'"); + }); + + it('joins multiple args before wrapping in shell mode', () => { + expect(buildRemoteCommandString(['ps', 'aux', '|', 'grep', 'php'], true)).toBe("bash -lc 'ps aux | grep php'"); + }); +}); + +describe('sliceAfterMarker', () => { + const M = '__IWP_OUT_abc123__'; + + it('strips a leading banner/MOTD up to and including the marker line', () => { + const raw = `Welcome to Ubuntu\nInstaWP banner\n${M}\nmysite.instawp.site`; + expect(sliceAfterMarker(raw, M)).toBe('mysite.instawp.site'); + }); + + it('returns the value when the marker is the first line', () => { + expect(sliceAfterMarker(`${M}\nblogname`, M)).toBe('blogname'); + }); + + it('handles CRLF after the marker', () => { + expect(sliceAfterMarker(`banner\n${M}\r\nvalue`, M)).toBe('value'); + }); + + it('returns the original string unchanged when the marker is absent', () => { + expect(sliceAfterMarker('plain output', M)).toBe('plain output'); + }); +}); diff --git a/src/__tests__/sql-dump.test.ts b/src/__tests__/sql-dump.test.ts new file mode 100644 index 0000000..97f9ad4 --- /dev/null +++ b/src/__tests__/sql-dump.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { detectDumpPrefix, readSqlHead, rewriteDumpPrefix } from '../lib/sql-dump.js'; + +const temps: string[] = []; +function tmp(ext = '.sql'): string { + const p = join(tmpdir(), `iwp-test-${randomBytes(6).toString('hex')}${ext}`); + temps.push(p); + return p; +} +afterEach(() => { + for (const p of temps.splice(0)) { if (existsSync(p)) rmSync(p, { force: true }); } +}); + +describe('detectDumpPrefix', () => { + it('detects the standard wp_ prefix', () => { + expect(detectDumpPrefix('CREATE TABLE `wp_options` (id int);')).toBe('wp_'); + }); + + it('detects a custom (random) prefix', () => { + expect(detectDumpPrefix('INSERT INTO `iwpa4c7_posts` VALUES (1);')).toBe('iwpa4c7_'); + }); + + it('detects an empty (unprefixed) prefix', () => { + expect(detectDumpPrefix('CREATE TABLE `options` (id int);')).toBe(''); + }); + + it('is case-insensitive and matches lowercase statements', () => { + expect(detectDumpPrefix('create table `wp_usermeta` (id int);')).toBe('wp_'); + }); + + it('does not confuse `posts` inside `postmeta` (trailing backtick anchors it)', () => { + expect(detectDumpPrefix('CREATE TABLE `wp_postmeta` (id int);')).toBe('wp_'); + }); + + it('skips plugin tables and keys off the first core table', () => { + const dump = [ + 'CREATE TABLE `wp_woocommerce_sessions` (id int);', + 'CREATE TABLE `wp_options` (id int);', + ].join('\n'); + expect(detectDumpPrefix(dump)).toBe('wp_'); + }); + + it('returns null when no core WordPress table is present', () => { + expect(detectDumpPrefix('CREATE TABLE `wp_woocommerce_sessions` (id int);')).toBeNull(); + }); +}); + +describe('readSqlHead', () => { + it('reads only up to the byte budget', () => { + const p = tmp(); + writeFileSync(p, 'A'.repeat(100)); + expect(readSqlHead(p, 10)).toBe('A'.repeat(10)); + }); + + it('reads the whole file when smaller than the budget', () => { + const p = tmp(); + writeFileSync(p, 'CREATE TABLE `wp_options`;'); + expect(readSqlHead(p, 1024)).toBe('CREATE TABLE `wp_options`;'); + }); +}); + +describe('rewriteDumpPrefix', () => { + it('rewrites table identifiers but leaves prefix-bound data values untouched', async () => { + const src = tmp(); + const dest = tmp(); + const dump = [ + 'DROP TABLE IF EXISTS `wp_options`;', + 'CREATE TABLE `wp_options` (option_id int, option_name varchar(191));', + "INSERT INTO `wp_options` VALUES (1,'wp_user_roles','a:1:{}');", + 'CREATE TABLE `wp_usermeta` (umeta_id int, meta_key varchar(255));', + "INSERT INTO `wp_usermeta` VALUES (1,'wp_capabilities','a:1:{}');", + ].join('\n'); + writeFileSync(src, dump); + + await rewriteDumpPrefix(src, dest, 'wp_', 'iwpa4c7_'); + const out = readFileSync(dest, 'utf-8'); + + // Backtick-quoted table identifiers are rewritten. + expect(out).toContain('`iwpa4c7_options`'); + expect(out).toContain('`iwpa4c7_usermeta`'); + expect(out).not.toContain('`wp_options`'); + expect(out).not.toContain('`wp_usermeta`'); + + // Single-quoted data VALUES (role/capability keys) are NOT touched — they + // are remapped server-side after import, not in the dump text. + expect(out).toContain("'wp_user_roles'"); + expect(out).toContain("'wp_capabilities'"); + }); +}); diff --git a/src/__tests__/ssh-connection.test.ts b/src/__tests__/ssh-connection.test.ts index a84d3ca..f5faf68 100644 --- a/src/__tests__/ssh-connection.test.ts +++ b/src/__tests__/ssh-connection.test.ts @@ -16,7 +16,7 @@ vi.mock('node:fs', async (importOriginal) => { }; }); -const { spawnInteractiveSsh, execViaSsh, rsyncViaSsh } = await import('../lib/ssh-connection.js'); +const { spawnInteractiveSsh, execViaSsh, execViaSshStreamStdin, rsyncViaSsh } = await import('../lib/ssh-connection.js'); const conn: SshConnection = { host: 'test.example.com', @@ -101,6 +101,45 @@ describe('ssh-connection', () => { }); }); + describe('execViaSshStreamStdin', () => { + it('passes the command as an ssh arg (not via stdin) so local stdin can stream', () => { + mockSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' }); + + execViaSshStreamStdin(conn, 'cat > /tmp/f', false); + + const callArgs = mockSpawnSync.mock.calls[0][1] as string[]; + expect(callArgs[0]).toBe('-T'); + // command is the LAST argument to ssh + expect(callArgs[callArgs.length - 1]).toBe('cat > /tmp/f'); + // and is NOT delivered via stdin input + const opts = mockSpawnSync.mock.calls[0][2]; + expect(opts.input).toBeUndefined(); + }); + + it('inherits all stdio in text mode (capture=false) for binary-safe passthrough', () => { + mockSpawnSync.mockReturnValue({ status: 0 }); + execViaSshStreamStdin(conn, 'tar xzf -', false); + const opts = mockSpawnSync.mock.calls[0][2]; + expect(opts.stdio).toBe('inherit'); + }); + + it('inherits stdin but captures stdout/stderr in JSON mode (capture=true)', () => { + mockSpawnSync.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' }); + const result = execViaSshStreamStdin(conn, 'whoami', true); + const opts = mockSpawnSync.mock.calls[0][2]; + expect(opts.stdio).toEqual(['inherit', 'pipe', 'pipe']); + expect(result.stdout).toBe('ok'); + expect(result.exitCode).toBe(0); + }); + + it('propagates a non-zero exit code (and null → 1)', () => { + mockSpawnSync.mockReturnValue({ status: 255 }); + expect(execViaSshStreamStdin(conn, 'x', false).exitCode).toBe(255); + mockSpawnSync.mockReturnValue({ status: null }); + expect(execViaSshStreamStdin(conn, 'x', false).exitCode).toBe(1); + }); + }); + describe('rsyncViaSsh', () => { it('builds correct rsync command for push', () => { mockSpawnSync.mockReturnValue({ status: 0 }); diff --git a/src/__tests__/sync-remote-path.test.ts b/src/__tests__/sync-remote-path.test.ts new file mode 100644 index 0000000..5b237e7 --- /dev/null +++ b/src/__tests__/sync-remote-path.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { buildRemotePath } from '../lib/paths.js'; + +const conn = { username: 'iwpuser', domain: 'my-site.instawp.site' }; +const base = '/home/iwpuser/web/my-site.instawp.site/public_html'; + +describe('buildRemotePath', () => { + it('defaults to wp-content/ with a trailing slash', () => { + expect(buildRemotePath(conn)).toBe(`${base}/wp-content/`); + }); + + it('targets the webroot with --webroot', () => { + expect(buildRemotePath(conn, { webroot: true })).toBe(`${base}/`); + }); + + it('resolves a relative --remote-path against public_html/', () => { + expect(buildRemotePath(conn, { remotePath: 'variations' })).toBe(`${base}/variations/`); + }); + + it('strips a leading ./ on a relative --remote-path', () => { + expect(buildRemotePath(conn, { remotePath: './variations/' })).toBe(`${base}/variations/`); + }); + + it('uses an absolute --remote-path verbatim', () => { + expect(buildRemotePath(conn, { remotePath: '/var/www/custom' })).toBe('/var/www/custom/'); + }); + + it('lets --remote-path take precedence over --webroot', () => { + expect(buildRemotePath(conn, { remotePath: 'mu-plugins', webroot: true })).toBe(`${base}/mu-plugins/`); + }); +}); diff --git a/src/commands/db.ts b/src/commands/db.ts index a423a1c..4896445 100644 --- a/src/commands/db.ts +++ b/src/commands/db.ts @@ -9,6 +9,8 @@ import { requireAuth } from '../lib/api.js'; import { resolveSite } from '../lib/site-resolver.js'; 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'; /** Timestamp like `2026-05-23T12-34-56` (filename-safe — `:` is illegal on Windows). */ @@ -31,14 +33,21 @@ async function gunzipFile(src: string, dest: string): Promise { await pipeline(createReadStream(src), createGunzip(), createWriteStream(dest)); } -async function promptYesNo(question: string): Promise { +async function promptYesNo(question: string, defaultYes = false): Promise { const readline = await import('node:readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise((resolve) => { rl.question(question, resolve); }); rl.close(); - return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes'; + const a = answer.trim().toLowerCase(); + if (a === '') return defaultYes; + return a === 'y' || a === 'yes'; +} + +/** True if a URL is a plain http(s) URL safe to embed in a single-quoted shell arg. */ +function isShellSafeUrl(u: string): boolean { + return /^https?:\/\/[^\s'"\\$`]+$/.test(u); } export function registerDbCommand(program: Command): void { @@ -125,12 +134,22 @@ export function registerDbCommand(program: Command): void { .description('Push local SQL dump to remote site database (creates a backup first)') .option('--force', 'Skip confirmation prompt') .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 ', 'After import, run wp search-replace across all tables (serialization-safe, skips guid). Pass exactly two URLs.') .addHelpText('after', ` Notes: - Always takes a remote backup first unless --no-backup is passed. - - After pushing to a site on a different domain, you may want to run: - instawp wp search-replace - Auto-replacing site URLs is out of scope for this command. + - Table prefix: if the dump's prefix differs from the remote site's prefix, + the import would create orphan tables the site never reads. The push detects + this and offers to rewrite; pass --rewrite-prefix to do it non-interactively + (also remaps the role/capability keys so admin login survives). + - URLs: after a cross-domain push, remap URLs with: + --search-replace + (or run 'instawp wp search-replace ' yourself). + +Examples: + $ instawp db push my-site dump.sql --rewrite-prefix + $ instawp db push my-site dump.sql --search-replace http://localhost:10115 https://my-site.instawp.site `) .action(async (siteIdentifier: string, file: string, opts: any) => { requireAuth(); @@ -146,6 +165,22 @@ Notes: process.exit(1); } + // Parse --search-replace (expects exactly two URLs: from, to) + let srFrom: string | undefined; + let srTo: string | undefined; + if (opts.searchReplace !== undefined) { + const vals = Array.isArray(opts.searchReplace) ? opts.searchReplace : [opts.searchReplace]; + if (vals.length !== 2) { + error('--search-replace expects exactly two values: '); + process.exit(1); + } + [srFrom, srTo] = vals; + if (!isShellSafeUrl(srFrom!) || !isShellSafeUrl(srTo!)) { + error('--search-replace URLs must be plain http(s) URLs (no spaces, quotes, or shell metacharacters)'); + 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)'); @@ -202,31 +237,87 @@ Notes: info('Skipping backup (--no-backup)'); } + // ---- Local temp tracking (decompressed / prefix-rewritten dumps) ---- + const localTemps = new Set(); + const cleanupLocalTemps = () => { + for (const f of localTemps) { try { unlinkSync(f); } catch { /* ignore */ } } + localTemps.clear(); + }; + const tmpDir = process.env.TMPDIR || '/tmp'; + // Step 2: Prepare local SQL (gunzip if needed) const isGzipped = file.endsWith('.gz') || file.endsWith('.gzip'); let uploadSource = file; - let tempLocalDecompressed: string | null = null; if (isGzipped) { const decompressSpin = spinner('Decompressing local dump...'); decompressSpin.start(); try { - const tmpName = `instawp-db-push-${randomBytes(6).toString('hex')}.sql`; - tempLocalDecompressed = join(process.env.TMPDIR || '/tmp', tmpName); - await gunzipFile(file, tempLocalDecompressed); - uploadSource = tempLocalDecompressed; + const decompressedPath = join(tmpDir, `instawp-db-push-${randomBytes(6).toString('hex')}.sql`); + localTemps.add(decompressedPath); + await gunzipFile(file, decompressedPath); + uploadSource = decompressedPath; const decompressedSize = statSync(uploadSource).size; decompressSpin.succeed(`Decompressed (${formatBytes(decompressedSize)})`); } catch (err: any) { decompressSpin.fail('Decompression failed'); error(err.message || String(err)); - if (tempLocalDecompressed) { - try { unlinkSync(tempLocalDecompressed); } catch { /* ignore */ } + cleanupLocalTemps(); + if (takeBackup) info(`Remote backup preserved: ~/${backupFilename}`); + process.exit(1); + } + } + + // Step 2b: Table-prefix safety. A dump whose prefix differs from the remote + // site's prefix imports "successfully" but creates orphan tables the site + // never reads (exit 0, silent breakage). Detect it, and on request rewrite + // the dump's identifiers + remap the prefix-bound role keys after import. + let remapFromPrefix: string | null = null; // set when we rewrite — fixes role/cap keys post-import + let remotePrefix = 'wp_'; + { + const pfxRes = execViaSsh(conn, `cd ${wpPath} && wp config get table_prefix`); + remotePrefix = parseTablePrefix(pfxRes.exitCode === 0 ? pfxRes.stdout : '', 'wp_'); + const dumpPrefix = detectDumpPrefix(readSqlHead(uploadSource)); + + if (dumpPrefix !== null && dumpPrefix !== remotePrefix) { + console.log(''); + console.log(chalk.yellow(`⚠ Table-prefix mismatch: dump uses '${dumpPrefix}' but ${conn.domain} uses '${remotePrefix}'.`)); + console.log(chalk.yellow(' Importing as-is would create orphan tables the site never reads.')); + + let doRewrite = opts.rewritePrefix === true; + if (!doRewrite) { + if (opts.force) { + // Non-interactive (or --json): never silently break, but respect --force. + info("Proceeding without rewrite (pass --rewrite-prefix to remap the dump's prefix)."); + } else { + doRewrite = await promptYesNo(`Rewrite the dump's prefix '${dumpPrefix}' -> '${remotePrefix}' before importing? (Y/n) `, true); + if (!doRewrite) { + const proceed = await promptYesNo('Import anyway with the mismatched prefix? (y/N) ', false); + if (!proceed) { info('Cancelled.'); cleanupLocalTemps(); return; } + } + } } - if (takeBackup) { - info(`Remote backup preserved: ~/${backupFilename}`); + + if (doRewrite) { + const rwSpin = spinner(`Rewriting dump prefix '${dumpPrefix}' -> '${remotePrefix}'...`); + rwSpin.start(); + try { + const rewrittenPath = join(tmpDir, `instawp-db-push-rw-${randomBytes(6).toString('hex')}.sql`); + localTemps.add(rewrittenPath); + await rewriteDumpPrefix(uploadSource, rewrittenPath, dumpPrefix, remotePrefix); + uploadSource = rewrittenPath; + remapFromPrefix = dumpPrefix; + rwSpin.succeed('Dump prefix rewritten'); + } catch (err: any) { + rwSpin.fail('Prefix rewrite failed'); + error(err.message || String(err)); + cleanupLocalTemps(); + if (takeBackup) info(`Remote backup preserved: ~/${backupFilename}`); + process.exit(1); + } } - process.exit(1); + } else if (dumpPrefix === null && opts.rewritePrefix) { + info("Could not detect the dump's table prefix — skipping --rewrite-prefix."); } } @@ -239,20 +330,14 @@ Notes: const scpExit = scpUpload(conn, uploadSource, remoteTempPath); if (scpExit !== 0) { uploadSpin.fail(`Upload failed (scp exit ${scpExit})`); - if (tempLocalDecompressed) { - try { unlinkSync(tempLocalDecompressed); } catch { /* ignore */ } - } - if (takeBackup) { - info(`Remote backup preserved: ~/${backupFilename}`); - } + cleanupLocalTemps(); + if (takeBackup) info(`Remote backup preserved: ~/${backupFilename}`); process.exit(1); } uploadSpin.succeed('Upload complete'); - // Clean up local temp file (we have it on remote now) - if (tempLocalDecompressed) { - try { unlinkSync(tempLocalDecompressed); } catch { /* ignore */ } - } + // Clean up local temp files (we have it on remote now) + cleanupLocalTemps(); // Step 4: Import on remote const importSpin = spinner(`Importing database on ${conn.domain}...`); @@ -285,6 +370,44 @@ Notes: } importSpin.succeed('Database imported'); + // Step 4b: If we rewrote the table prefix, the role/capability keys stored + // in the data still carry the OLD prefix (they're values, not identifiers). + // Remap them so the admin keeps its capabilities and wp-admin stays usable. + if (remapFromPrefix && remapFromPrefix !== remotePrefix) { + const capSpin = spinner('Remapping user roles/capabilities to the remote prefix...'); + capSpin.start(); + const um = `${remotePrefix}usermeta`; + const opt = `${remotePrefix}options`; + const stmts = [ + `UPDATE ${um} SET meta_key='${remotePrefix}capabilities' WHERE meta_key='${remapFromPrefix}capabilities'`, + `UPDATE ${um} SET meta_key='${remotePrefix}user_level' WHERE meta_key='${remapFromPrefix}user_level'`, + `UPDATE ${opt} SET option_name='${remotePrefix}user_roles' WHERE option_name='${remapFromPrefix}user_roles'`, + ]; + let capOk = true; + for (const s of stmts) { + const r = execViaSsh(conn, `cd ${wpPath} && wp db query "${s}"`); + if (r.exitCode !== 0) { capOk = false; if (r.stderr) error(r.stderr.trim()); } + } + if (capOk) capSpin.succeed('Roles/capabilities remapped'); + else capSpin.fail('Could not remap roles/capabilities — wp-admin access may need a manual fix'); + } + + // Step 4c: Optional URL search-replace (serialization-safe, server-side). + // 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) { + 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`); + if (srRes.exitCode === 0) { + srSpin.succeed('URLs rewritten'); + if (!isJsonMode() && srRes.stdout.trim()) console.log(srRes.stdout.trim()); + } else { + srSpin.fail('URL search-replace failed (DB imported; run it manually if links are wrong)'); + if (srRes.stderr) error(srRes.stderr.trim()); + } + } + // Step 5: Cleanup remote temp file const cleanupSpin = spinner('Cleaning up...'); cleanupSpin.start(); @@ -300,6 +423,8 @@ Notes: backup_path: takeBackup ? backupRemotePath : null, restored_from: file, size_bytes: localSize, + rewrote_prefix: remapFromPrefix ? `${remapFromPrefix} -> ${remotePrefix}` : null, + search_replaced: srFrom && srTo ? `${srFrom} -> ${srTo}` : null, }); if (!isJsonMode() && takeBackup) { diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 6965c8a..58d5b7a 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -1,27 +1,20 @@ import { Command } from 'commander'; +import { randomBytes } from 'node:crypto'; import { requireAuth, getClient } from '../lib/api.js'; import { resolveSite } from '../lib/site-resolver.js'; import { ensureSshAccess } from '../lib/ssh-keys.js'; -import { execViaSsh } from '../lib/ssh-connection.js'; +import { execViaSsh, execViaSshStreamStdin } from '../lib/ssh-connection.js'; +import { buildRemoteCommandString, sliceAfterMarker } from '../lib/remote-command.js'; import { error, spinner, isJsonMode } from '../lib/output.js'; -// POSIX shell single-quote escape: 'safe' becomes 'safe' (passthrough for -// shell-safe chars), anything else wrapped in '...' with embedded ' → '\''. -// Required because the remote shell receives joined args via stdin and would -// otherwise interpret parens, quotes, semicolons, etc. (broke `wp eval '...'`). -function shellQuote(arg: string): string { - if (arg === '') return "''"; - if (/^[a-zA-Z0-9_\-./=:@%+,]+$/.test(arg)) return arg; - return "'" + arg.replace(/'/g, "'\\''") + "'"; -} - -function joinForRemote(args: string[]): string { - return args.map(shellQuote).join(' '); -} - -async function execAction(siteIdentifier: string, args: string[], opts: { api?: boolean; timeout?: string }): Promise { +async function execAction(siteIdentifier: string, args: string[], opts: { api?: boolean; timeout?: string; shell?: boolean; stdin?: boolean }): Promise { requireAuth(); + if (opts.stdin && opts.api) { + error('--stdin is only supported with the SSH transport (not --api)'); + process.exit(1); + } + // Drop POSIX `--` end-of-options marker so users can write // instawp wp -- post list --post_type=page // and have everything after `--` reach WP-CLI verbatim. @@ -44,12 +37,12 @@ async function execAction(siteIdentifier: string, args: string[], opts: { api?: process.exit(1); } - const command = joinForRemote(args); + const command = buildRemoteCommandString(args, !!opts.shell); if (opts.api) { await execViaApi(site, command, opts); } else { - await execViaSshTransport(site, command); + await execViaSshTransport(site, command, { streamStdin: !!opts.stdin }); } } @@ -97,27 +90,51 @@ async function execViaApi(site: any, command: string, opts: { timeout?: string } } } -async function execViaSshTransport(site: any, command: string): Promise { +async function execViaSshTransport(site: any, command: string, opts: { streamStdin?: boolean } = {}): Promise { const conn = await ensureSshAccess(site.id); // Auto-cd into WordPress root so wp-cli and other tools work out of the box const wpRoot = conn.domain ? `/home/${conn.username}/web/${conn.domain}/public_html` : ''; - const fullCmd = wpRoot ? `cd ${wpRoot} && ${command}` : command; - const result = execViaSsh(conn, fullCmd); + const base = wpRoot ? `cd ${wpRoot} && ${command}` : command; + + // --stdin: stream local stdin to the remote command (uploads, restore pipes, + // `tar … | … 'tar xzf -'`). Passes the command as an ssh arg so stdin is free; + // the non-login remote shell also means no MOTD, so no marker stripping needed. + if (opts.streamStdin) { + const capture = isJsonMode(); + const result = execViaSshStreamStdin(conn, base, capture); + if (isJsonMode()) { + console.log(JSON.stringify({ + success: result.exitCode === 0, + data: { stdout: result.stdout, stderr: result.stderr, exit_code: result.exitCode }, + })); + } + // In text mode stdout/stderr were inherited (already streamed live). + process.exit(result.exitCode); + } + + // The remote login shell prepends a banner/MOTD to stdout on a cold (cold/idle) + // connection, which pollutes value capture (`--field`, `--format=count`) and + // the `--json` payload. Emit a unique marker before the real command, then + // slice stdout after it so only the command's own output is returned. Exit + // code is unaffected (the marker printf is a separate statement before `base`). + const marker = `__IWP_OUT_${randomBytes(6).toString('hex')}__`; + const result = execViaSsh(conn, `printf '%s\\n' '${marker}'; ${base}`); + const stdout = sliceAfterMarker(result.stdout, marker); if (isJsonMode()) { console.log(JSON.stringify({ success: result.exitCode === 0, data: { - stdout: result.stdout, + stdout, stderr: result.stderr, exit_code: result.exitCode, }, })); } else { - if (result.stdout) process.stdout.write(result.stdout); + if (stdout) process.stdout.write(stdout); if (result.stderr) process.stderr.write(result.stderr); } @@ -131,24 +148,36 @@ export function registerExecCommand(program: Command): void { .passThroughOptions() .allowUnknownOption() .option('--api', 'Use API transport instead of SSH') + .option('--shell', 'Run the args as one shell command line on the remote (enables pipes, ;, >, globs)') + .option('--stdin', 'Stream local stdin to the remote command (for uploads / restore pipes; SSH only)') .option('--timeout ', 'Command timeout in seconds (API mode only)', '30') .addHelpText('after', ` Examples: $ instawp exec my-site ls -la - $ instawp exec my-site -- ps aux | grep php # use -- to forward raw args + $ instawp exec my-site -- tail -n 50 wp-content/debug.log + $ instawp exec my-site --shell 'ps aux | grep php' # --shell runs the whole line on the remote + $ printf 'data' | instawp exec my-site --stdin --shell 'cat > /tmp/f' # --stdin pipes stdin to the remote $ instawp exec my-site php -v --api + +Note: shell metacharacters (| ; > globs) are NOT interpreted by default — each +arg is sent as one literal token. Use --shell (or '-- bash -lc "..."') to run a +pipeline or compound command on the remote. `) .action(async (siteIdentifier: string, args: string[], opts) => { - // passThroughOptions may swallow --api/--timeout into args — extract them + // passThroughOptions may swallow --api/--shell/--stdin/--timeout into args — extract them const extractedApi = args.includes('--api'); + const extractedShell = args.includes('--shell'); + const extractedStdin = args.includes('--stdin'); 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'); + args = args.filter(a => a !== '--api' && a !== '--shell' && a !== '--stdin'); if (extractedApi) opts.api = true; + if (extractedShell) opts.shell = true; + if (extractedStdin) opts.stdin = true; if (extractedTimeout) opts.timeout = extractedTimeout; await execAction(siteIdentifier, args, opts); }); diff --git a/src/commands/local.ts b/src/commands/local.ts index 2edbabb..531cf5c 100644 --- a/src/commands/local.ts +++ b/src/commands/local.ts @@ -968,7 +968,7 @@ async function pushDatabase(instance: LocalInstance, site: any, conn: SshConnect if (isShellSafeUrl(fromUrl) && isShellSafeUrl(toUrl)) { const srSpin = spinner(`Rewriting URLs (${fromUrl} → ${toUrl})...`); srSpin.start(); - const srRes = execViaSsh(conn, `cd ${wpPath} && wp search-replace '${fromUrl}' '${toUrl}' --all-tables --report-changed-only`); + const srRes = execViaSsh(conn, `cd ${wpPath} && wp search-replace '${fromUrl}' '${toUrl}' --all-tables --skip-columns=guid --report-changed-only`); if (srRes.exitCode !== 0) { srSpin.fail('URL rewrite failed (DB imported; run search-replace manually if links are wrong)'); if (srRes.stderr) error(srRes.stderr.trim()); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 2de1899..115366e 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,9 +1,11 @@ import { Command } from 'commander'; import { spawnSync } from 'node:child_process'; +import chalk from 'chalk'; import { requireAuth } from '../lib/api.js'; import { resolveSite } from '../lib/site-resolver.js'; import { ensureSshAccess } from '../lib/ssh-keys.js'; import { syncFiles } from '../lib/ssh-connection.js'; +import { buildRemotePath } from '../lib/paths.js'; import { success, error, spinner, info } from '../lib/output.js'; function checkRsync(): boolean { @@ -20,21 +22,30 @@ function getRsyncInstallInstructions(): string { return 'Install rsync for your platform.'; } -function buildRemotePath(conn: { username: string; domain: string }): string { - return `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`; +async function promptYesNo(question: string): Promise { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => rl.question(question, resolve)); + rl.close(); + const a = answer.trim().toLowerCase(); + return a === 'y' || a === 'yes'; } export function registerSyncCommand(program: Command): void { const sync = program .command('sync') - .description('Sync wp-content files with a remote site via rsync'); + .description('Sync files with a remote site via rsync (wp-content/ by default; --webroot or --remote-path for other paths)'); sync .command('push ') - .description('Push local wp-content/ to remote site') - .option('--path ', 'Local wp-content path', './wp-content/') + .description('Push local files to a remote site (default: wp-content/)') + .option('--path ', 'Local source path', './wp-content/') + .option('--remote-path ', 'Remote target path (absolute, or relative to public_html/). Default: wp-content/') + .option('--webroot', 'Target the site webroot (public_html/) instead of wp-content/') .option('--exclude ', 'Additional exclude patterns') .option('--include ', 'Include patterns') + .option('--delete', 'Delete remote files that are absent locally (make remote MIRROR local)') + .option('--yes', 'Skip the --delete confirmation prompt') .option('--dry-run', 'Show what would be transferred') .action(async (siteIdentifier: string, opts) => { requireAuth(); @@ -45,6 +56,11 @@ export function registerSyncCommand(program: Command): void { process.exit(1); } + if (opts.delete && process.platform === 'win32') { + error('--delete is not supported on Windows (SFTP transport). Use a macOS/Linux host.'); + process.exit(1); + } + const spin = spinner('Resolving site...'); spin.start(); @@ -60,7 +76,7 @@ export function registerSyncCommand(program: Command): void { const conn = await ensureSshAccess(site.id); const localPath = opts.path.endsWith('/') ? opts.path : opts.path + '/'; - const remotePath = buildRemotePath(conn); + const remotePath = buildRemotePath(conn, opts); const remoteTarget = `${conn.username}@${conn.host}:${remotePath}`; const extraArgs: string[] = []; @@ -74,9 +90,18 @@ export function registerSyncCommand(program: Command): void { extraArgs.push(`--include=${pattern}`); } } + if (opts.delete) extraArgs.push('--delete'); + + // --delete is destructive; confirm unless --dry-run or --yes. + if (opts.delete && !opts.dryRun && !opts.yes) { + console.log(chalk.yellow(`\n⚠ --delete will REMOVE files under ${conn.host}:${remotePath} that don't exist in ${localPath}`)); + const ok = await promptYesNo('Continue? (y/N) '); + if (!ok) { info('Cancelled.'); return; } + } info(`Pushing ${localPath} -> ${conn.host}:${remotePath}`); if (opts.dryRun) info('(dry run)'); + if (opts.delete) info('(--delete: remote will mirror local)'); const exitCode = await syncFiles(conn, localPath, remoteTarget, extraArgs, !!opts.dryRun, true); @@ -90,10 +115,14 @@ export function registerSyncCommand(program: Command): void { sync .command('pull ') - .description('Pull remote wp-content/ to local') + .description('Pull remote files to local (default: wp-content/)') .option('--path ', 'Local destination path', './wp-content/') + .option('--remote-path ', 'Remote source path (absolute, or relative to public_html/). Default: wp-content/') + .option('--webroot', 'Pull the site webroot (public_html/) instead of wp-content/') .option('--exclude ', 'Additional exclude patterns') .option('--include ', 'Include patterns') + .option('--delete', 'Delete local files that are absent on the remote (make local MIRROR remote)') + .option('--yes', 'Skip the --delete confirmation prompt') .option('--dry-run', 'Show what would be transferred') .action(async (siteIdentifier: string, opts) => { requireAuth(); @@ -104,6 +133,11 @@ export function registerSyncCommand(program: Command): void { process.exit(1); } + if (opts.delete && process.platform === 'win32') { + error('--delete is not supported on Windows (SFTP transport). Use a macOS/Linux host.'); + process.exit(1); + } + const spin = spinner('Resolving site...'); spin.start(); @@ -119,7 +153,7 @@ export function registerSyncCommand(program: Command): void { const conn = await ensureSshAccess(site.id); const localPath = opts.path.endsWith('/') ? opts.path : opts.path + '/'; - const remotePath = buildRemotePath(conn); + const remotePath = buildRemotePath(conn, opts); const remoteSource = `${conn.username}@${conn.host}:${remotePath}`; const extraArgs: string[] = []; @@ -133,9 +167,18 @@ export function registerSyncCommand(program: Command): void { extraArgs.push(`--include=${pattern}`); } } + if (opts.delete) extraArgs.push('--delete'); + + // --delete is destructive; confirm unless --dry-run or --yes. + if (opts.delete && !opts.dryRun && !opts.yes) { + console.log(chalk.yellow(`\n⚠ --delete will REMOVE files under ${localPath} that don't exist on ${conn.host}:${remotePath}`)); + const ok = await promptYesNo('Continue? (y/N) '); + if (!ok) { info('Cancelled.'); return; } + } info(`Pulling ${conn.host}:${remotePath} -> ${localPath}`); if (opts.dryRun) info('(dry run)'); + if (opts.delete) info('(--delete: local will mirror remote)'); const exitCode = await syncFiles(conn, remoteSource, localPath, extraArgs, !!opts.dryRun, true); diff --git a/src/commands/versions.ts b/src/commands/versions.ts index 0b83b68..61924b7 100644 --- a/src/commands/versions.ts +++ b/src/commands/versions.ts @@ -127,7 +127,7 @@ export function registerVersionsCommand(program: Command): void { console.log(JSON.stringify({ success: true, data: { id: versionId, status: 'progress', task_id: taskId ?? null } })); } else { success('Version started', { id: versionId, status: 'progress' }); - info('It will be restorable once complete. Check with: instawp versions list ' + label); + info('It will be restorable once complete. Check with: instawp versions list ' + siteIdentifier); } return; } @@ -135,7 +135,7 @@ export function registerVersionsCommand(program: Command): void { if (taskId) { const result = await pollTask(client, taskId, 'Creating version'); if (result !== 'completed') { - info(`Version (ID ${versionId}) is still processing. Check with: instawp versions list ${label}`); + info(`Version (ID ${versionId}) is still processing. Check with: instawp versions list ${siteIdentifier}`); process.exit(result === 'error' ? 1 : 0); } } @@ -144,7 +144,7 @@ export function registerVersionsCommand(program: Command): void { console.log(JSON.stringify({ success: true, data: { id: versionId, status: 'completed', name: versionName || null } })); } else { success('Version ready', { id: versionId, ...(versionName ? { name: versionName } : {}) }); - info(`Roll back any time with: instawp versions restore ${label} ${versionId}`); + info(`Roll back any time with: instawp versions restore ${siteIdentifier} ${versionId}`); } }); diff --git a/src/lib/paths.ts b/src/lib/paths.ts index 340365d..181507d 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -25,6 +25,30 @@ export function toRsyncPath(p: string): string { return p.replace(/\\/g, '/'); } +/** + * Resolve the remote sync target for a site. Defaults to the site's + * `wp-content/`, but `--webroot` targets `public_html/` and `--remote-path

` + * targets an explicit path (absolute, or relative to `public_html/`). Always + * returns a trailing slash so rsync/SFTP treat it as a directory. + */ +export function buildRemotePath( + conn: { username: string; domain: string }, + opts: { remotePath?: string; webroot?: boolean } = {}, +): string { + const webrootBase = `/home/${conn.username}/web/${conn.domain}/public_html`; + let p: string; + if (opts.remotePath) { + p = opts.remotePath.startsWith('/') + ? opts.remotePath + : `${webrootBase}/${opts.remotePath.replace(/^\.?\//, '')}`; + } else if (opts.webroot) { + p = webrootBase; + } else { + p = `${webrootBase}/wp-content`; + } + return p.endsWith('/') ? p : p + '/'; +} + /** * Resolve a path inside the CLI's installed directory (e.g. bundled scripts). * diff --git a/src/lib/remote-command.ts b/src/lib/remote-command.ts new file mode 100644 index 0000000..05de10d --- /dev/null +++ b/src/lib/remote-command.ts @@ -0,0 +1,46 @@ +// Helpers for building/cleaning the command sent to a remote shell over SSH. + +/** + * POSIX shell single-quote escape: shell-safe chars pass through, anything else + * is wrapped in '...' with embedded ' → '\''. Required because the remote shell + * receives joined args via stdin and would otherwise interpret parens, quotes, + * semicolons, etc. (this is what fixed `wp eval '...'`). + */ +export function shellQuote(arg: string): string { + if (arg === '') return "''"; + if (/^[a-zA-Z0-9_\-./=:@%+,]+$/.test(arg)) return arg; + return "'" + arg.replace(/'/g, "'\\''") + "'"; +} + +function joinForRemote(args: string[]): string { + return args.map(shellQuote).join(' '); +} + +/** + * Build the remote command string from raw argv. + * - default: each arg is shell-quoted then joined, so a single arg containing + * metacharacters (`;`, `|`, …) is sent as one literal token (safe for + * `wp eval '...'`, but means `-- "echo a; echo b"` runs nothing useful). + * - `shell`: join the args and wrap in `bash -lc ''` so the remote + * runs them through a real shell — enabling pipes, `;`, `>`, globs. + */ +export function buildRemoteCommandString(args: string[], shell = false): string { + if (shell) return 'bash -lc ' + shellQuote(args.join(' ')); + return joinForRemote(args); +} + +/** + * Return the portion of `stdout` after the first line equal to `marker` + * (dropping the marker line itself). InstaWP servers prepend a login banner/MOTD + * to non-interactive SSH output; emitting a unique marker before the real + * command lets us strip everything up to and including it. If the marker isn't + * present (no banner) the original string is returned unchanged. + */ +export function sliceAfterMarker(stdout: string, marker: string): string { + const idx = stdout.indexOf(marker); + if (idx === -1) return stdout; + let after = stdout.slice(idx + marker.length); + if (after.startsWith('\r')) after = after.slice(1); + if (after.startsWith('\n')) after = after.slice(1); + return after; +} diff --git a/src/lib/sql-dump.ts b/src/lib/sql-dump.ts new file mode 100644 index 0000000..78a8260 --- /dev/null +++ b/src/lib/sql-dump.ts @@ -0,0 +1,57 @@ +import { createReadStream, createWriteStream, openSync, readSync, closeSync, statSync } from 'node:fs'; +import { createInterface } from 'node:readline'; + +/** + * Detect the table prefix used inside a MySQL dump by matching the first + * statement that references a WordPress *core* table (whose suffix is fixed, + * e.g. `options`, `posts`, `usermeta`). Returns the prefix string (which may be + * empty for an unprefixed dump), or `null` if no core table is found in the + * given text — pass a generous head of the file (`wp db export`/mysqldump emit + * CREATE/INSERT for these tables near the top). Identifiers are backtick-quoted, + * so we anchor on the trailing backtick to avoid `posts` matching `postmeta`. + */ +export function detectDumpPrefix(sqlHead: string): string | null { + const re = + /(?:CREATE TABLE(?: IF NOT EXISTS)?|INSERT INTO|REPLACE INTO|LOCK TABLES|DROP TABLE(?: IF EXISTS)?)\s+`([A-Za-z0-9_]*?)(commentmeta|comments|links|options|postmeta|posts|term_relationships|term_taxonomy|termmeta|terms|usermeta|users)`/i; + const m = sqlHead.match(re); + return m ? m[1] : null; +} + +/** Read up to `bytes` from the start of a (plain, decompressed) file as UTF-8. */ +export function readSqlHead(path: string, bytes = 4 * 1024 * 1024): string { + const fd = openSync(path, 'r'); + try { + const size = Math.min(bytes, statSync(path).size); + const buf = Buffer.alloc(size); + const read = readSync(fd, buf, 0, size, 0); + return buf.toString('utf-8', 0, read); + } finally { + closeSync(fd); + } +} + +/** + * Stream-rewrite a SQL dump, replacing the table-identifier prefix + * `` `… `` → `` `… ``. `wp db export`/mysqldump backtick-quote table + * names, so anchoring on the leading backtick rewrites identifiers + * (CREATE/INSERT/LOCK/DROP) without touching ordinary string data. Done line by + * line to avoid loading large dumps fully into memory. + * + * Note: this rewrites table *names* only. Prefix-bound *values* (the + * `{prefix}capabilities` / `{prefix}user_level` usermeta keys and the + * `{prefix}user_roles` option) live in the row data and must be remapped after + * import — see the `db push` caller. + */ +export async function rewriteDumpPrefix(src: string, dest: string, fromPrefix: string, toPrefix: string): Promise { + const input = createReadStream(src, { encoding: 'utf-8' }); + const output = createWriteStream(dest, { encoding: 'utf-8' }); + const rl = createInterface({ input, crlfDelay: Infinity }); + const fromTok = '`' + fromPrefix; + const toTok = '`' + toPrefix; + for await (const line of rl) { + output.write((line.includes(fromTok) ? line.split(fromTok).join(toTok) : line) + '\n'); + } + await new Promise((resolve, reject) => { + output.end((err?: Error | null) => (err ? reject(err) : resolve())); + }); +} diff --git a/src/lib/ssh-connection.ts b/src/lib/ssh-connection.ts index 3d4ab30..53bba3e 100644 --- a/src/lib/ssh-connection.ts +++ b/src/lib/ssh-connection.ts @@ -82,6 +82,34 @@ export function execViaSsh(conn: SshConnection, command: string): { stdout: stri }; } +/** + * Execute a command via SSH while streaming the local process's stdin to the + * remote command (the way `ssh host cmd` does). Unlike execViaSsh — which + * delivers the command over stdin and therefore can't also forward piped data — + * this passes the command as an ssh argument, leaving stdin free to stream. + * + * Side effect: the remote runs a non-login shell (`$SHELL -c `), so it does + * NOT print the login banner/MOTD. `capture` controls output handling: when + * false (text mode) stdout/stderr are inherited for real-time, binary-safe + * passthrough; when true (JSON mode) they're captured and returned. + */ +export function execViaSshStreamStdin( + conn: SshConnection, + command: string, + capture: boolean, +): { stdout: string; stderr: string; exitCode: number } { + const result = spawnSync('ssh', ['-T', ...sshArgs(conn), sshTarget(conn), command], { + stdio: capture ? ['inherit', 'pipe', 'pipe'] : 'inherit', + encoding: capture ? 'utf-8' : undefined, + maxBuffer: 500 * 1024 * 1024, + }); + return { + stdout: capture ? ((result.stdout as string) || '') : '', + stderr: capture ? ((result.stderr as string) || '') : '', + exitCode: result.status ?? 1, + }; +} + /** * Execute a command via SSH and stream stdout directly to a file. * Useful for large outputs like database dumps.