diff --git a/CHANGELOG.md b/CHANGELOG.md index 977940e..54785cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.0.1-beta.25 (2026-06-25) + +### Improved — db push transport & progress (large-DB path) + +From round-3 feedback (a 166 MB full-parity push — the transform logic held; transport/progress needed polish): + +- **`db push` compresses the dump in transit** — scp now uses `-C` (SQL compresses ~5–10×), cutting upload time on large dumps. (#13) +- **Non-interactive runs show progress** — when stdout isn't a TTY (pipe / CI / background), each phase prints a one-line marker to **stderr** (Backing up… / Uploading… / Importing… / Rewriting URLs…) instead of going silent until the final summary. (#14) +- **`db push` summary reports timing** — adds an `elapsed: 144s (1.2 MB/s)` line. (#15) + ## 0.0.1-beta.24 (2026-06-23) ### Added — db push prefix-safety, URL remap, sync mirroring & webroot, exec shell diff --git a/package.json b/package.json index 71f8939..751cb3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@instawp/cli", - "version": "0.0.1-beta.24", + "version": "0.0.1-beta.25", "description": "InstaWP CLI - Create and manage WordPress sites from the terminal", "type": "module", "bin": { diff --git a/src/__tests__/output.test.ts b/src/__tests__/output.test.ts index 4999089..a8747f7 100644 --- a/src/__tests__/output.test.ts +++ b/src/__tests__/output.test.ts @@ -132,10 +132,27 @@ describe('output', () => { s.stop(); }); - it('returns an ora spinner in human mode', () => { + it('returns a working spinner in human mode', () => { const s = spinner('Loading...'); expect(s.start).toBeTypeOf('function'); expect(s.succeed).toBeTypeOf('function'); }); + + it('emits a phase marker to stderr in non-TTY mode (heartbeat for long ops)', () => { + // The vitest runner is non-TTY, so spinner() returns the marker stub. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + spinner('Uploading...').start(); + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0]).toContain('Uploading...'); + }); + + it('emits no marker in json mode (machine output stays clean)', () => { + setJsonMode(true); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const s = spinner('Uploading...'); + s.start(); + s.succeed('done'); + expect(spy).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/__tests__/ssh-connection.test.ts b/src/__tests__/ssh-connection.test.ts index f5faf68..8f01518 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, execViaSshStreamStdin, rsyncViaSsh } = await import('../lib/ssh-connection.js'); +const { spawnInteractiveSsh, execViaSsh, execViaSshStreamStdin, scpUpload, rsyncViaSsh } = await import('../lib/ssh-connection.js'); const conn: SshConnection = { host: 'test.example.com', @@ -140,6 +140,22 @@ describe('ssh-connection', () => { }); }); + describe('scpUpload', () => { + it('enables compression (-C) and returns the exit code', () => { + mockSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' }); + + const code = scpUpload(conn, '/local/dump.sql', '/tmp/dump.sql'); + + expect(code).toBe(0); + const cmd = mockSpawnSync.mock.calls[0][0]; + const args = mockSpawnSync.mock.calls[0][1] as string[]; + expect(cmd).toBe('scp'); + expect(args).toContain('-C'); + expect(args).toContain('/local/dump.sql'); + expect(args[args.length - 1]).toBe('testuser@test.example.com:/tmp/dump.sql'); + }); + }); + describe('rsyncViaSsh', () => { it('builds correct rsync command for push', () => { mockSpawnSync.mockReturnValue({ status: 0 }); diff --git a/src/commands/db.ts b/src/commands/db.ts index 4896445..5c794ae 100644 --- a/src/commands/db.ts +++ b/src/commands/db.ts @@ -221,6 +221,9 @@ Examples: } } + // Start the clock once the actual work begins (excludes prompt think-time). + const startedAt = Date.now(); + // Step 1: Backup if (takeBackup) { const backupSpin = spinner(`Backing up remote database to ~/${backupFilename}...`); @@ -418,6 +421,10 @@ Examples: cleanupSpin.succeed('Cleanup complete'); } + 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})`; + success('Push complete', { site_id: site.id, backup_path: takeBackup ? backupRemotePath : null, @@ -425,6 +432,7 @@ Examples: size_bytes: localSize, rewrote_prefix: remapFromPrefix ? `${remapFromPrefix} -> ${remotePrefix}` : null, search_replaced: srFrom && srTo ? `${srFrom} -> ${srTo}` : null, + elapsed: elapsedStr, }); if (!isJsonMode() && takeBackup) { diff --git a/src/lib/output.ts b/src/lib/output.ts index 2e72e78..65ef03a 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -57,20 +57,32 @@ export function table(headers: string[], rows: Record[]): void { console.log(t.toString()); } -function shouldSuppressSpinner(): boolean { - if (jsonMode) return true; - if (!process.stdout.isTTY) return true; - if (process.env.CI) return true; - if (process.env.INSTAWP_QUIET) return true; - if (process.env.NO_COLOR) return true; - return false; +/** Truly silent: machine output (--json) or an explicit quiet request. */ +function spinnerSilent(): boolean { + return jsonMode || !!process.env.INSTAWP_QUIET; } export function spinner(text: string): Ora | { text: string; start: () => any; succeed: (t?: string) => void; fail: (t?: string) => void; stop: () => void } { - if (shouldSuppressSpinner()) { + if (spinnerSilent()) { return { text: '', start() { return this; }, succeed() {}, fail() {}, stop() {} }; } - return ora(text); + + // Animated spinner only on an interactive TTY (and when colors aren't disabled). + if (process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR) { + return ora(text); + } + + // Non-interactive (pipe / CI / redirected / NO_COLOR): no animation, but emit + // one-line phase markers to STDERR so long operations show a heartbeat instead + // of going silent (e.g. a 144s db push looked hung). stderr keeps stdout/the + // final summary clean for capture. ora itself writes to stderr, so this matches. + return { + text, + start() { console.error(chalk.cyan('→') + ' ' + this.text); return this; }, + succeed(t?: string) { console.error(chalk.green('✓') + ' ' + (t || this.text)); }, + fail(t?: string) { console.error(chalk.red('✗') + ' ' + (t || this.text)); }, + stop() {}, + }; } export function info(msg: string): void { diff --git a/src/lib/ssh-connection.ts b/src/lib/ssh-connection.ts index 53bba3e..77ac4dd 100644 --- a/src/lib/ssh-connection.ts +++ b/src/lib/ssh-connection.ts @@ -40,6 +40,7 @@ function sshTarget(conn: SshConnection): string { function scpArgs(conn: SshConnection): string[] { ensureKnownHosts(); return [ + '-C', // compress in transit — SQL dumps compress ~5–10×; near-free on already-compressed inputs '-i', conn.privateKeyPath, '-P', String(conn.port), '-o', 'StrictHostKeyChecking=accept-new',