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
107 changes: 103 additions & 4 deletions src/hosted/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,77 @@ describe('webcmd setup', () => {
expect(messages.join('')).toContain('Credential backend: protected file fallback.');
});

it('persists interactive local setup before the real CLI process completes', async () => {
it('writes local mode from --mode without prompting', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-flags-'));
const messages: string[] = [];
const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv;
const question = vi.fn(async () => 'hosted');

const code = await runHostedSetup({
env,
platform: 'linux',
now: () => new Date('2026-07-08T00:00:00.000Z'),
argv: ['--mode', 'local'],
isTTY: false,
question,
write: (message) => { messages.push(message); },
});

expect(code).toBe(0);
expect(question).not.toHaveBeenCalled();
expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toEqual({
mode: 'local',
updatedAt: '2026-07-08T00:00:00.000Z',
});
expect(messages.join('')).toContain('local mode');
});

it('rejects non-TTY setup without --mode and never prompts', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-nontty-'));
const messages: string[] = [];
const stderr = collectStderr();

const code = await runHostedSetup({
env: { WEBCMD_CONFIG_DIR: tempDir },
argv: [],
isTTY: false,
stderr: stderr.stream,
write: (message) => { messages.push(message); },
});

expect(code).toBe(2);
expect(messages.join('')).toBe('');
expect(stderr.text()).toContain('setup requires --mode when stdin is not a TTY.');
expect(stderr.text()).toContain('example: webcmd setup --mode local');
});

it('rejects non-TTY hosted setup without --api-key', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-hosted-key-'));
const stderr = collectStderr();

const code = await runHostedSetup({
env: { WEBCMD_CONFIG_DIR: tempDir },
argv: ['--mode', 'hosted'],
isTTY: false,
stderr: stderr.stream,
write: () => undefined,
});

expect(code).toBe(2);
expect(stderr.text()).toContain('setup --mode hosted requires --api-key');
});

it('persists flag-driven local setup before the real CLI process completes', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-process-'));
const child = spawn(process.execPath, ['--import', 'tsx', 'src/main.ts', 'setup'], {
const child = spawn(process.execPath, ['--import', 'tsx', 'src/main.ts', 'setup', '--mode', 'local'], {
cwd: packageRoot,
env: { ...process.env, WEBCMD_CONFIG_DIR: tempDir, WEBCMD_NO_UPDATE_CHECK: '1' },
stdio: ['pipe', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk)));
child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)));
child.stdin.end('local\n');

const status = await new Promise<number | null>((resolve, reject) => {
child.once('error', reject);
Expand All @@ -114,6 +173,35 @@ describe('webcmd setup', () => {
.toMatchObject({ mode: 'local' });
}, 20_000);

it('exits immediately when setup is run with stdin detached', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-hang-'));
const child = spawn(process.execPath, ['--import', 'tsx', 'src/main.ts', 'setup'], {
cwd: packageRoot,
env: { ...process.env, WEBCMD_CONFIG_DIR: tempDir, WEBCMD_NO_UPDATE_CHECK: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const stderr: Buffer[] = [];
child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)));

const status = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('setup hung waiting for input'));
}, 8_000);
child.once('error', err => {
clearTimeout(timer);
reject(err);
});
child.once('close', code => {
clearTimeout(timer);
resolve(code);
});
});

expect(status).toBe(2);
expect(Buffer.concat(stderr).toString('utf8')).toContain('setup requires --mode when stdin is not a TTY.');
}, 12_000);

it('does not resolve until all caller-owned output writes complete', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-slow-output-'));
const output = new SetupControlledWritable();
Expand Down Expand Up @@ -172,6 +260,17 @@ describe('webcmd setup', () => {
});
});

function collectStderr(): { stream: Writable; text: () => string } {
const chunks: Buffer[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
},
});
return { stream, text: () => Buffer.concat(chunks).toString('utf8') };
}

async function within<T>(promise: Promise<T>, milliseconds = 500): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
Expand Down
136 changes: 125 additions & 11 deletions src/hosted/setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createInterface } from 'node:readline/promises';
import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
import { CLI_COMMAND } from '../brand.js';
import { ArgumentError, toEnvelope } from '../errors.js';
import { formatErrorEnvelope } from '../output.js';
import { writeToStream } from '../stream-write.js';
import { HostedClient } from './client.js';
import {
Expand All @@ -18,37 +21,97 @@ import {
export interface SetupIo extends ConfigIo, HostedCredentialIo {
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
stderr?: NodeJS.WritableStream;
fetchImpl?: typeof fetch;
question?: (prompt: string) => Promise<string>;
write?: (message: string) => void | Promise<void>;
argv?: readonly string[];
isTTY?: boolean;
}

type SetupMode = 'local' | 'hosted';

const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode <local|hosted> [--api-key <key>]`;
const SETUP_EXAMPLE = `example: ${CLI_COMMAND} setup --mode local`;
const SETUP_HELP = [
`${CLI_COMMAND} setup`,
'',
'Configure local or hosted mode.',
'',
' --mode <local|hosted> Required when stdin is not a TTY',
' --api-key <key> Required for --mode hosted when stdin is not a TTY',
' -h, --help',
'',
SETUP_EXAMPLE,
`example: ${CLI_COMMAND} setup --mode hosted --api-key <key>`,
'',
].join('\n');

export async function runHostedSetup(io: SetupIo = {}): Promise<number> {
const write = io.write
? async (message: string) => { await io.write!(message); }
: async (message: string) => writeToStream(io.output ?? defaultOutput, message);
const ownedReadline = io.question ? undefined : createInterface({
input: io.input ?? defaultInput,
output: io.output ?? defaultOutput,
let ownedReadline: ReturnType<typeof createInterface> | undefined;
const ask = io.question ?? (async (prompt: string) => {
ownedReadline ??= createInterface({
input: io.input ?? defaultInput,
output: io.output ?? defaultOutput,
});
return ownedReadline.question(prompt);
});
const ask = io.question ?? ((prompt: string) => ownedReadline!.question(prompt));

try {
await write('Webcmd setup\n');
const mode = await ask('Use hosted Webcmd Cloud or local Webcmd? [hosted/local] ');
if (mode.trim().toLowerCase().startsWith('l')) {
const parsed = parseSetupArgs(io.argv ?? []);
if (parsed.help) {
await write(SETUP_HELP);
return 0;
}

const interactive = canPrompt(io);
let mode = parsed.mode;
if (!mode) {
if (!interactive) {
throw new ArgumentError(
'setup requires --mode when stdin is not a TTY.',
`${SETUP_USAGE}\n${SETUP_EXAMPLE}`,
);
}
await write('Webcmd setup\n');
mode = (await ask('Use hosted Webcmd Cloud or local Webcmd? [hosted/local] ')).trim().toLowerCase().startsWith('l')
? 'local'
: 'hosted';
} else {
await write('Webcmd setup\n');
}

if (mode === 'local') {
if (parsed.apiKey) {
throw new ArgumentError(
'--api-key is only valid with --mode hosted.',
`${SETUP_USAGE}\n${SETUP_EXAMPLE}`,
);
}
saveWebcmdConfig(makeLocalConfig(io.now?.() ?? new Date()), io);
await write('Webcmd is now configured for local mode.\n');
return 0;
}

const apiBaseUrl = defaultHostedApiBaseUrl(io.env ?? process.env);
const apiKey = (await ask('Webcmd API key: ')).trim();
let apiKey = parsed.apiKey?.trim();
if (!apiKey) {
await write('A Webcmd API key is required for hosted mode.\n');
return 2;
if (!interactive) {
throw new ArgumentError(
'setup --mode hosted requires --api-key when stdin is not a TTY.',
`${SETUP_USAGE}\nexample: ${CLI_COMMAND} setup --mode hosted --api-key <key>`,
);
}
apiKey = (await ask('Webcmd API key: ')).trim();
if (!apiKey) {
await write('A Webcmd API key is required for hosted mode.\n');
return 2;
}
}

const apiBaseUrl = defaultHostedApiBaseUrl(io.env ?? process.env);
let accountLabel: string | undefined;
try {
const me = await new HostedClient({
Expand Down Expand Up @@ -76,11 +139,62 @@ export async function runHostedSetup(io: SetupIo = {}): Promise<number> {
await write(`Credential backend: ${credentialBackendLabel(credential.credentialBackend)}.\n`);
await write('Webcmd is now configured for hosted mode.\n');
return 0;
} catch (err) {
if (err instanceof ArgumentError) {
await writeToStream(io.stderr ?? process.stderr, formatErrorEnvelope(toEnvelope(err)));
return err.exitCode;
}
throw err;
} finally {
ownedReadline?.close();
}
}

function canPrompt(io: SetupIo): boolean {
if (io.question) return true;
if (io.isTTY !== undefined) return io.isTTY;
return process.stdin.isTTY === true && process.stdout.isTTY === true;
}

function parseSetupArgs(argv: readonly string[]): { help?: true; mode?: SetupMode; apiKey?: string } {
let mode: SetupMode | undefined;
let apiKey: string | undefined;
for (let i = 0; i < argv.length; i++) {
const token = argv[i]!;
if (token === '--help' || token === '-h') return { help: true };

if (token === '--mode' || token.startsWith('--mode=')) {
const value = token.startsWith('--mode=') ? token.slice('--mode='.length) : argv[++i];
if (value !== 'local' && value !== 'hosted') {
throw new ArgumentError(
`--mode must be one of: local, hosted${value ? ` (got: "${value}")` : ''}.`,
`${SETUP_USAGE}\n${SETUP_EXAMPLE}`,
);
}
mode = value;
continue;
}

if (token === '--api-key' || token.startsWith('--api-key=')) {
const value = token.startsWith('--api-key=') ? token.slice('--api-key='.length) : argv[++i];
if (!value || value.startsWith('-')) {
throw new ArgumentError(
'--api-key requires a value.',
`${SETUP_USAGE}\nexample: ${CLI_COMMAND} setup --mode hosted --api-key <key>`,
);
}
apiKey = value;
continue;
}

throw new ArgumentError(
`unknown flag ${token} for \`setup\``,
`valid flags for \`setup\`: --mode, --api-key, --help\n${SETUP_USAGE}`,
);
}
return { mode, apiKey };
}

function hostedAccountLabel(body: unknown): string | undefined {
if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined;
const user = (body as { user?: unknown }).user;
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ if (!fastPathHandled && argv[0] === 'completion' && argv.length >= 2) {
if (!fastPathHandled) {
if (argv[0] === 'setup') {
const { runHostedSetup } = await import('./hosted/setup.js');
process.exitCode = await runHostedSetup();
process.exitCode = await runHostedSetup({ argv: argv.slice(1) });
} else if (argv[0] === 'skills' || argv[0] === 'update') {
const { createProgram } = await import('./cli.js');
await createProgram(BUILTIN_CLIS, USER_CLIS).parseAsync(argv, { from: 'user' });
Expand Down
Loading