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
34 changes: 16 additions & 18 deletions src/commands/wp.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {execSync} from 'node:child_process';
import path from 'node:path';
import {Box, Text, useApp} from 'ink';
import {argument} from 'pastel';
import {useEffect} from 'react';
import zod from 'zod';
import {readProjectConfig} from '../lib/config.js';
import {getProjectRuntimeDir} from '../lib/paths.js';
import {runWpCliInherit} from '../lib/wpcli.js';

export const description = 'Run a WP-CLI command';

Expand All @@ -26,7 +26,14 @@ export default function Wp(_props: Props) {
const {exit} = useApp();

useEffect(() => {
const pc = readProjectConfig();
let pc: ReturnType<typeof readProjectConfig>;
try {
pc = readProjectConfig();
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
exit(new Error());
return;
}
if (!pc) {
console.error('This project is not initialized. Run "kiqr init" first.');
exit(new Error());
Expand All @@ -36,25 +43,16 @@ export default function Wp(_props: Props) {
const runtimeDir = getProjectRuntimeDir(pc.project_id);
const composePath = path.join(runtimeDir, 'compose.yaml');

// Grab everything after "wp" from the raw process args
// Grab everything after "wp" from the raw process args. Each element is a
// single argument, so values with spaces/quotes (e.g.
// --post_title="Hello World") are preserved when passed as an array.
const wpIndex = process.argv.indexOf('wp');
const rawArgs = wpIndex >= 0 ? process.argv.slice(wpIndex + 1) : [];
const wpArgs = rawArgs.length === 0 ? ['--help'] : rawArgs;

if (rawArgs.length === 0) {
execSync(`docker compose -f "${composePath}" run --rm wpcli --help`, {
stdio: 'inherit',
});
exit();
return;
}

try {
execSync(`docker compose -f "${composePath}" run --rm wpcli ${rawArgs.join(' ')}`, {
stdio: 'inherit',
});
} catch {
// WP-CLI exits with non-zero for errors — already printed to stderr
}
// Use the array-based, shell-free spawn so arguments are passed verbatim.
// WP-CLI exits non-zero on errors, which it has already printed to stderr.
runWpCliInherit(composePath, wpArgs);

exit();
}, []);
Expand Down
26 changes: 25 additions & 1 deletion src/lib/wpcli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@ import {type ChildProcessByStdio, spawn, spawnSync} from 'node:child_process';
import type {Readable, Writable} from 'node:stream';

export function buildWpCliArgs(composePath: string, wpArgs: string[]): string[] {
return ['compose', '-f', composePath, 'run', '--rm', '-T', 'wpcli', ...wpArgs];
return [
'compose',
'-f',
composePath,
'--profile',
'cli',
'run',
'--rm',
'-T',
'wpcli',
...wpArgs,
];
}

export type WpCliProcess = ChildProcessByStdio<Writable, Readable, Readable>;
Expand All @@ -13,6 +24,19 @@ export function spawnWpCli(composePath: string, wpArgs: string[]): WpCliProcess
}) as WpCliProcess;
}

/**
* Run WP-CLI with the parent process's stdio inherited so output streams
* straight to the terminal. Uses spawnSync with an args array (no shell), so
* arguments containing spaces, quotes, or other special characters are passed
* through verbatim. Returns the child's exit code (or 1 if it was signalled).
*/
export function runWpCliInherit(composePath: string, wpArgs: string[]): number {
const result = spawnSync('docker', buildWpCliArgs(composePath, wpArgs), {
stdio: 'inherit',
});
return result.status ?? 1;
}

export function isWordPressInstalled(composePath: string): boolean {
const result = spawnSync(
'docker',
Expand Down
23 changes: 22 additions & 1 deletion tests/lib/wpcli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import {describe, expect, it} from 'vitest';
import {buildWpCliArgs} from '../../src/lib/wpcli.js';

describe('buildWpCliArgs', () => {
it('produces a docker compose run invocation with -T to disable TTY', () => {
it('produces a docker compose run invocation with --profile cli and -T to disable TTY', () => {
const args = buildWpCliArgs('/path/compose.yaml', ['wp', 'db', 'export', '-']);
expect(args).toEqual([
'compose',
'-f',
'/path/compose.yaml',
'--profile',
'cli',
'run',
'--rm',
'-T',
Expand All @@ -18,4 +20,23 @@ describe('buildWpCliArgs', () => {
'-',
]);
});

it('enables the cli profile so the profiled wpcli service can start', () => {
const args = buildWpCliArgs('/path/compose.yaml', ['wp', 'core', 'is-installed']);
const profileIdx = args.indexOf('--profile');
expect(profileIdx).toBeGreaterThanOrEqual(0);
expect(args[profileIdx + 1]).toBe('cli');
// --profile must come before the `run` subcommand to take effect.
expect(profileIdx).toBeLessThan(args.indexOf('run'));
});

it('passes WP-CLI args verbatim as separate array elements (no shell joining)', () => {
const args = buildWpCliArgs('/path/compose.yaml', [
'wp',
'post',
'create',
'--post_title=Hello World',
]);
expect(args).toContain('--post_title=Hello World');
});
});
Loading