diff --git a/src/commands/services.ts b/src/commands/services.ts index e5bdf03..9717b7f 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -62,7 +62,7 @@ export function resolveComputeServiceId(services: Array<{ id: string; type: stri // ---- commands ---- -export type ServicesAddOpts = { branch?: string; public?: boolean; image?: string; port?: string; region?: string; alwaysOn?: boolean; volume?: string } +export type ServicesAddOpts = { branch?: string; public?: boolean; image?: string; port?: string; region?: string; alwaysOn?: boolean; volume?: string; json?: boolean } // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which @@ -93,6 +93,7 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO const branch = opts.branch ?? p.branch const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts)) if (handleApproval(res)) return + if (opts.json) return printJson(res.body.service) const svc = res.body.service const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '' const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '' diff --git a/src/index.ts b/src/index.ts index b2bcfc7..54f1201 100644 --- a/src/index.ts +++ b/src/index.ts @@ -127,8 +127,9 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass .option('--port ', 'compute only: port the image listens on (default 8080)') .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)') .option('--volume ', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume --size `; any plan may attach at the default 1; larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle') + .option('--json') .action(guard(async (type, name, o) => { - const a = await resolveServiceArgs(type, name, serviceArgsDeps()) + const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json)) return services.servicesAdd(a.type, a.name, o) })) svc.command('list').option('--json').option('--branch ', 'branch (default: current)') diff --git a/src/resolve-service.ts b/src/resolve-service.ts index 90bdda2..61f2ffc 100644 --- a/src/resolve-service.ts +++ b/src/resolve-service.ts @@ -80,10 +80,11 @@ export async function promptServiceName(kind: ServiceKind): Promise { } /** Prompts on a real terminal only — an agent's stdin is not one, and must never block. */ -export function serviceArgsDeps(): ServiceArgsDeps { +export function serviceArgsDeps(json?: boolean): ServiceArgsDeps { return { selectType: promptServiceType, askName: promptServiceName, - tty: !!process.stdin.isTTY && !!process.stdout.isTTY, + // --json asked for parseable output, so a caller that happens to own a TTY still gets the error. + tty: !json && !!process.stdin.isTTY && !!process.stdout.isTTY, } } diff --git a/test/resolve-service.test.ts b/test/resolve-service.test.ts index f7cc02f..a4f17e1 100644 --- a/test/resolve-service.test.ts +++ b/test/resolve-service.test.ts @@ -8,6 +8,7 @@ import { SERVICE_KINDS, missingArgsMessage, resolveServiceArgs, + serviceArgsDeps, serviceKindLines, type ServiceArgsDeps, } from '../src/resolve-service.js' @@ -60,6 +61,19 @@ test('unknown type: passed through for assertType to report, prompts untouched', expect(r).toEqual({ type: 'mysql', name: '' }) }) +// --json promises parseable stdout; a prompt would corrupt it and hang an agent that owns a TTY. +test('--json opts out of the prompts even on a terminal', () => { + const io = [process.stdin, process.stdout] as Array<{ isTTY?: boolean }> + const saved = io.map((s) => s.isTTY) + for (const s of io) s.isTTY = true + try { + expect(serviceArgsDeps().tty).toBe(true) + expect(serviceArgsDeps(true).tty).toBe(false) + } finally { + io.forEach((s, i) => { s.isTTY = saved[i] }) + } +}) + test('kind lines stay one per type and mention what each is', () => { const lines = serviceKindLines() expect(lines).toHaveLength(SERVICE_TYPES.length)