diff --git a/src/api.ts b/src/api.ts index 01151eb..28d84d8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -56,7 +56,7 @@ export class ApiClient { } private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise { - const headers: Record = { 'Content-Type': 'application/json' } + const headers: Record = { 'Content-Type': 'application/json', 'Insta-Hints': '1' } if (auth && this.cfg.accessToken) headers.Authorization = `Bearer ${this.cfg.accessToken}` const res = await fetch(this.apiUrl + path, { method, diff --git a/src/commands/branch.ts b/src/commands/branch.ts index 59e32c8..2e15185 100644 --- a/src/commands/branch.ts +++ b/src/commands/branch.ts @@ -1,12 +1,13 @@ import { ApiClient, requireProject } from '../api.js' import { writeProject } from '../config.js' -import { info, die, printJson, handleApproval } from '../util.js' +import { info, die, printJson, handleApproval, renderNextActions } from '../util.js' export async function branchCreate(name: string, opts: { from?: string }): Promise { const api = await ApiClient.load() const p = await requireProject() const out = await api.request('POST', `/projects/${p.projectId}/branches`, { name, from: opts.from ?? p.branch }) info(`created branch ${out.branch.name} (${out.branch.id})`) + renderNextActions(out.nextActions) } export async function branchList(opts: { json?: boolean }): Promise { diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 2da33ac..8fd23ec 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -1,5 +1,6 @@ import { ApiClient, requireProject } from '../api.js' import { info, printJson, handleApproval } from '../util.js' +import { resolveComputeServiceId } from './services.js' type Opts = { branch?: string; group?: string; json?: boolean } @@ -41,3 +42,32 @@ function printDomain(r: any, json?: boolean): void { } if (!r.configured) info(' once DNS propagates, Fly issues the cert — re-check with `insta compute check-domain`') } + +// ---- lifecycle (start/stop/suspend/status) ---- + +type LifeOpts = { json?: boolean } + +async function lifecycle(verb: 'start' | 'stop' | 'suspend', serviceName: string | undefined, opts: LifeOpts): Promise { + const api = await ApiClient.load() + const p = await requireProject() + const { services } = await api.request('GET', `/projects/${p.projectId}/services`) + const id = resolveComputeServiceId(services, serviceName) + const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/${verb}`) + if (handleApproval(res)) return + if (opts.json) return printJson(res.body) + info(`compute ${res.body.service?.name ?? id}: ${verb} → desired=${res.body.service?.desired_state} (live: ${res.body.state})`) +} + +export const computeStart = (service: string | undefined, opts: LifeOpts) => lifecycle('start', service, opts) +export const computeStop = (service: string | undefined, opts: LifeOpts) => lifecycle('stop', service, opts) +export const computeSuspend = (service: string | undefined, opts: LifeOpts) => lifecycle('suspend', service, opts) + +export async function computeStatus(serviceName: string | undefined, opts: LifeOpts): Promise { + const api = await ApiClient.load() + const p = await requireProject() + const { services } = await api.request('GET', `/projects/${p.projectId}/services`) + const id = resolveComputeServiceId(services, serviceName) + const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/state`) + if (opts.json) return printJson(r) + info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`) +} diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index b149014..cca4098 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,7 +1,7 @@ import { resolve, join } from 'node:path' import { existsSync, readFileSync } from 'node:fs' import { ApiClient, requireProject } from '../api.js' -import { info, die, handleApproval } from '../util.js' +import { info, die, handleApproval, renderNextActions } from '../util.js' import { flyctlBuildAndPush, ensureFlyctl } from '../flyctl-build.js' type DeployOpts = { image?: string; branch?: string; group?: string; port?: string; websocket?: boolean } @@ -55,6 +55,7 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts)) if (handleApproval(res)) return info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`) + renderNextActions(res.body.nextActions) } // Source mode: mint a scoped Fly deploy token from the platform, then build+push (needs a diff --git a/src/commands/project.ts b/src/commands/project.ts index 79e3922..cabe5fa 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,6 +1,6 @@ import { ApiClient, requireProject } from '../api.js' import { writeProject } from '../config.js' -import { info, die, printJson, handleApproval } from '../util.js' +import { info, die, printJson, handleApproval, renderNextActions } from '../util.js' import { installObserve } from '../observe/install.js' import { installSkills } from '../ensure-skills.js' @@ -27,6 +27,7 @@ export async function projectCreate(name: string, opts: { org?: string }): Promi info(`created project ${out.project.id} (${name})`) info(` resources: ${out.resources.map((r: any) => r.kind).join(', ')}`) info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`) + renderNextActions(out.nextActions) tryInstallObserve() await installSkills({ cwd: process.cwd() }) } diff --git a/src/commands/services.ts b/src/commands/services.ts index d208575..2736f5f 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -1,6 +1,6 @@ // `insta services` — manage a project's opt-in services (postgres | storage | compute). import { ApiClient, requireProject } from '../api.js' -import { info, printJson, handleApproval } from '../util.js' +import { info, printJson, handleApproval, renderNextActions } from '../util.js' export const SERVICE_TYPES = ['postgres', 'storage', 'compute'] as const export type ServiceType = (typeof SERVICE_TYPES)[number] @@ -26,6 +26,19 @@ export function resolveServiceId(services: Array<{ id: string; type: string; nam return svc.id } +// Resolve a compute service id: by name, or the sole compute service when name is omitted. +export function resolveComputeServiceId(services: Array<{ id: string; type: string; name: string }>, name?: string): string { + const compute = services.filter((s) => s.type === 'compute') + if (name) { + const svc = compute.find((s) => s.name === name) + if (!svc) throw new Error(`compute service not found: ${name}`) + return svc.id + } + if (compute.length === 0) throw new Error('no compute service in this project (add one with `insta services add compute `)') + if (compute.length > 1) throw new Error(`multiple compute services — specify one: ${compute.map((s) => s.name).join(', ')}`) + return compute[0]!.id +} + // ---- commands ---- export async function servicesAdd(type: string, name: string): Promise { @@ -36,6 +49,7 @@ export async function servicesAdd(type: string, name: string): Promise { if (handleApproval(res)) return const svc = res.body.service info(`added ${type} service ${name} (${svc.id})${svc.domain ? ` — ${svc.domain}` : ''}`) + renderNextActions(res.body.nextActions) } export async function servicesList(opts: { json?: boolean }): Promise { diff --git a/src/index.ts b/src/index.ts index d375b4f..4f0f0be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,14 +103,22 @@ program.command('deploy [dir]').description('Deploy a source directory (built re .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)') .action(guard((dir, o) => deploy(dir, o))) -// ---- compute custom domains (bring your own domain → Fly cert + routing) ---- -const compute = program.command('compute').description('Compute custom domains (bring your own domain)') +// ---- compute (lifecycle control + custom domains) ---- +const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains') compute.command('set-domain ').description('Attach a custom domain to a branch compute service (gated: deploy)') .option('--branch ').option('--group ').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o))) compute.command('check-domain ').description("Show a custom domain's cert status + required DNS records") .option('--branch ').option('--group ').option('--json').action(guard((host, o) => computeCmd.checkDomain(host, o))) compute.command('remove-domain ').description('Detach a custom domain (gated: deploy)') .option('--branch ').option('--group ').action(guard((host, o) => computeCmd.removeDomain(host, o))) +compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)') + .option('--json').action(guard((service, o) => computeCmd.computeStart(service, o))) +compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`') + .option('--json').action(guard((service, o) => computeCmd.computeStop(service, o))) +compute.command('suspend [service]').description('Suspend a compute service (RAM snapshot); stays down until `start`') + .option('--json').action(guard((service, o) => computeCmd.computeSuspend(service, o))) +compute.command('status [service]').description("Show a compute service's desired vs. live state") + .option('--json').action(guard((service, o) => computeCmd.computeStatus(service, o))) // ---- manifest ---- program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o))) diff --git a/src/util.ts b/src/util.ts index 493f606..f6603c3 100644 --- a/src/util.ts +++ b/src/util.ts @@ -37,6 +37,34 @@ export function handleApproval(res: { status: number; body: any }): boolean { return false } +export type NextAction = { op: string; reason: string; args?: Record; gated?: boolean } + +// Neutral op → an `insta` command string. Unknown ops fall back to reason-only (no crash). +const OP_COMMAND: Record) => string> = { + 'service.add': (a) => `insta services add ${a.type ?? ''} ${a.name ?? ''}`, + deploy: (a) => `insta deploy${a.branch ? ` --branch ${a.branch}` : ''}`, + 'secrets.set': (a) => `insta secrets set ${a.name ?? ''} ${a.value ?? ''}`, + metrics: (a) => `insta metrics ${a.target ?? 'compute'}`, + logs: (a) => `insta logs ${a.target ?? 'compute'}`, + 'approvals.approve': (a) => `insta approvals approve ${a.approvalId ?? ''}`, +} + +// Pure — builds the printable lines (unit-tested). Empty input → []. +export function nextActionsLines(actions: NextAction[] | undefined): string[] { + if (!actions || actions.length === 0) return [] + const lines = ['Next:'] + for (const a of actions) { + const cmd = OP_COMMAND[a.op]?.(a.args ?? {}) + const gated = a.gated ? ' [needs approval]' : '' + lines.push(cmd ? ` • ${a.reason} → ${cmd}${gated}` : ` • ${a.reason}${gated}`) + } + return lines +} + +export function renderNextActions(actions: NextAction[] | undefined): void { + for (const line of nextActionsLines(actions)) info(line) +} + // Serialize a credential bundle to .env text. All values are double-quoted (connection strings // contain special chars); backslashes and quotes are escaped so dotenv parsers read them back exactly. export function serializeEnv(bundle: Record): string { diff --git a/test/services.test.ts b/test/services.test.ts index c114970..4003a48 100644 --- a/test/services.test.ts +++ b/test/services.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { assertType, parseCount, resolveServiceId, SERVICE_TYPES } from '../src/commands/services.js' +import { assertType, parseCount, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES } from '../src/commands/services.js' describe('assertType', () => { it('accepts valid service types', () => { @@ -43,3 +43,23 @@ describe('resolveServiceId', () => { expect(() => resolveServiceId(services, 'storage', 'db')).toThrow(/service not found/) }) }) + +describe('resolveComputeServiceId', () => { + const one = [{ id: 'a', type: 'postgres', name: 'db' }, { id: 'b', type: 'compute', name: 'api' }] + const two = [...one, { id: 'c', type: 'compute', name: 'worker' }] + it('returns the sole compute service when name is omitted', () => { + expect(resolveComputeServiceId(one)).toBe('b') + }) + it('resolves by name', () => { + expect(resolveComputeServiceId(two, 'worker')).toBe('c') + }) + it('errors when the named compute service is missing', () => { + expect(() => resolveComputeServiceId(two, 'nope')).toThrow(/compute service not found/) + }) + it('errors on ambiguity when name omitted', () => { + expect(() => resolveComputeServiceId(two)).toThrow(/multiple compute services/) + }) + it('errors when there is no compute service', () => { + expect(() => resolveComputeServiceId([{ id: 'a', type: 'postgres', name: 'db' }])).toThrow(/no compute service/) + }) +}) diff --git a/test/util.test.ts b/test/util.test.ts index 4875eb8..fe5b020 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { serializeEnv, handleApproval } from '../src/util.js' +import { serializeEnv, handleApproval, nextActionsLines } from '../src/util.js' describe('serializeEnv', () => { it('quotes and escapes values; ends with newline', () => { @@ -22,3 +22,35 @@ describe('handleApproval', () => { expect(handleApproval({ status: 200, body: { ok: true } })).toBe(false) }) }) + +describe('nextActionsLines', () => { + it('renders a mapped op as an insta command with args, plus its reason', () => { + const lines = nextActionsLines([{ op: 'service.add', reason: 'Add a service first.', args: { type: 'postgres', name: 'db' } }]) + expect(lines[0]).toBe('Next:') + expect(lines.join('\n')).toContain('insta services add postgres db') + expect(lines.join('\n')).toContain('Add a service first.') + }) + + it('degrades to reason-only for an unknown op and never crashes', () => { + const lines = nextActionsLines([{ op: 'totally.unknown', reason: 'Do the thing.' }]) + expect(lines.join('\n')).toContain('Do the thing.') + }) + + it('marks gated actions', () => { + const lines = nextActionsLines([{ op: 'deploy', reason: 'Deploy it.', gated: true, args: {} }]) + expect(lines.join('\n')).toContain('needs approval') + }) + + it('returns [] for empty/absent input', () => { + expect(nextActionsLines(undefined)).toEqual([]) + expect(nextActionsLines([])).toEqual([]) + }) + + it('renders metrics/logs hints with the compute target (runnable command)', () => { + const metricsLines = nextActionsLines([{ op: 'metrics', reason: 'Check metrics.', args: { projectId: 'pr_1' } }]) + expect(metricsLines.join('\n')).toContain('insta metrics compute') + + const logsLines = nextActionsLines([{ op: 'logs', reason: 'Check logs.', args: { projectId: 'pr_1' } }]) + expect(logsLines.join('\n')).toContain('insta logs compute') + }) +})