Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <from> <to>`** 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 '<cmdline>'`** 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 <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`
Expand Down
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.23",
"version": "0.0.1-beta.24",
"description": "InstaWP CLI - Create and manage WordPress sites from the terminal",
"type": "module",
"bin": {
Expand Down
58 changes: 58 additions & 0 deletions src/__tests__/remote-command.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
93 changes: 93 additions & 0 deletions src/__tests__/sql-dump.test.ts
Original file line number Diff line number Diff line change
@@ -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'");
});
});
41 changes: 40 additions & 1 deletion src/__tests__/ssh-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 });
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/sync-remote-path.test.ts
Original file line number Diff line number Diff line change
@@ -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/`);
});
});
Loading
Loading