diff --git a/src/hosted/setup.test.ts b/src/hosted/setup.test.ts index 1946d74d..0532dce4 100644 --- a/src/hosted/setup.test.ts +++ b/src/hosted/setup.test.ts @@ -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((resolve, reject) => { child.once('error', reject); @@ -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((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(); @@ -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(promise: Promise, milliseconds = 500): Promise { let timer: ReturnType | undefined; try { diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index 0d395b8e..1b102cd5 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -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 { @@ -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; write?: (message: string) => void | Promise; + argv?: readonly string[]; + isTTY?: boolean; } +type SetupMode = 'local' | 'hosted'; + +const SETUP_USAGE = `usage: ${CLI_COMMAND} setup --mode [--api-key ]`; +const SETUP_EXAMPLE = `example: ${CLI_COMMAND} setup --mode local`; +const SETUP_HELP = [ + `${CLI_COMMAND} setup`, + '', + 'Configure local or hosted mode.', + '', + ' --mode Required when stdin is not a TTY', + ' --api-key Required for --mode hosted when stdin is not a TTY', + ' -h, --help', + '', + SETUP_EXAMPLE, + `example: ${CLI_COMMAND} setup --mode hosted --api-key `, + '', +].join('\n'); + export async function runHostedSetup(io: SetupIo = {}): Promise { 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 | 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 `, + ); + } + 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({ @@ -76,11 +139,62 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { 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 `, + ); + } + 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; diff --git a/src/main.ts b/src/main.ts index e24d4d1a..29501de5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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' });