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/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 5397254..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] @@ -49,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/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/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') + }) +})