Skip to content

Commit 80da15e

Browse files
authored
Merge pull request #32 from InsForge/feature/next-actions
feat(nextactions): send Insta-Hints and render Next: affordance
2 parents d2d2b8b + c6d4949 commit 80da15e

7 files changed

Lines changed: 70 additions & 6 deletions

File tree

src/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class ApiClient {
5656
}
5757

5858
private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> {
59-
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
59+
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Insta-Hints': '1' }
6060
if (auth && this.cfg.accessToken) headers.Authorization = `Bearer ${this.cfg.accessToken}`
6161
const res = await fetch(this.apiUrl + path, {
6262
method,

src/commands/branch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { ApiClient, requireProject } from '../api.js'
22
import { writeProject } from '../config.js'
3-
import { info, die, printJson, handleApproval } from '../util.js'
3+
import { info, die, printJson, handleApproval, renderNextActions } from '../util.js'
44

55
export async function branchCreate(name: string, opts: { from?: string }): Promise<void> {
66
const api = await ApiClient.load()
77
const p = await requireProject()
88
const out = await api.request('POST', `/projects/${p.projectId}/branches`, { name, from: opts.from ?? p.branch })
99
info(`created branch ${out.branch.name} (${out.branch.id})`)
10+
renderNextActions(out.nextActions)
1011
}
1112

1213
export async function branchList(opts: { json?: boolean }): Promise<void> {

src/commands/deploy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { resolve, join } from 'node:path'
22
import { existsSync, readFileSync } from 'node:fs'
33
import { ApiClient, requireProject } from '../api.js'
4-
import { info, die, handleApproval } from '../util.js'
4+
import { info, die, handleApproval, renderNextActions } from '../util.js'
55
import { flyctlBuildAndPush, ensureFlyctl } from '../flyctl-build.js'
66

77
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
5555
const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts))
5656
if (handleApproval(res)) return
5757
info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`)
58+
renderNextActions(res.body.nextActions)
5859
}
5960

6061
// Source mode: mint a scoped Fly deploy token from the platform, then build+push <dir> (needs a

src/commands/project.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ApiClient, requireProject } from '../api.js'
22
import { writeProject } from '../config.js'
3-
import { info, die, printJson, handleApproval } from '../util.js'
3+
import { info, die, printJson, handleApproval, renderNextActions } from '../util.js'
44
import { installObserve } from '../observe/install.js'
55
import { installSkills } from '../ensure-skills.js'
66

@@ -27,6 +27,7 @@ export async function projectCreate(name: string, opts: { org?: string }): Promi
2727
info(`created project ${out.project.id} (${name})`)
2828
info(` resources: ${out.resources.map((r: any) => r.kind).join(', ')}`)
2929
info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`)
30+
renderNextActions(out.nextActions)
3031
tryInstallObserve()
3132
await installSkills({ cwd: process.cwd() })
3233
}

src/commands/services.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// `insta services` — manage a project's opt-in services (postgres | storage | compute).
22
import { ApiClient, requireProject } from '../api.js'
3-
import { info, printJson, handleApproval } from '../util.js'
3+
import { info, printJson, handleApproval, renderNextActions } from '../util.js'
44

55
export const SERVICE_TYPES = ['postgres', 'storage', 'compute'] as const
66
export type ServiceType = (typeof SERVICE_TYPES)[number]
@@ -49,6 +49,7 @@ export async function servicesAdd(type: string, name: string): Promise<void> {
4949
if (handleApproval(res)) return
5050
const svc = res.body.service
5151
info(`added ${type} service ${name} (${svc.id})${svc.domain ? ` — ${svc.domain}` : ''}`)
52+
renderNextActions(res.body.nextActions)
5253
}
5354

5455
export async function servicesList(opts: { json?: boolean }): Promise<void> {

src/util.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,34 @@ export function handleApproval(res: { status: number; body: any }): boolean {
3737
return false
3838
}
3939

40+
export type NextAction = { op: string; reason: string; args?: Record<string, unknown>; gated?: boolean }
41+
42+
// Neutral op → an `insta` command string. Unknown ops fall back to reason-only (no crash).
43+
const OP_COMMAND: Record<string, (a: Record<string, unknown>) => string> = {
44+
'service.add': (a) => `insta services add ${a.type ?? '<type>'} ${a.name ?? '<name>'}`,
45+
deploy: (a) => `insta deploy${a.branch ? ` --branch ${a.branch}` : ''}`,
46+
'secrets.set': (a) => `insta secrets set ${a.name ?? '<NAME>'} ${a.value ?? '<value>'}`,
47+
metrics: (a) => `insta metrics ${a.target ?? 'compute'}`,
48+
logs: (a) => `insta logs ${a.target ?? 'compute'}`,
49+
'approvals.approve': (a) => `insta approvals approve ${a.approvalId ?? '<id>'}`,
50+
}
51+
52+
// Pure — builds the printable lines (unit-tested). Empty input → [].
53+
export function nextActionsLines(actions: NextAction[] | undefined): string[] {
54+
if (!actions || actions.length === 0) return []
55+
const lines = ['Next:']
56+
for (const a of actions) {
57+
const cmd = OP_COMMAND[a.op]?.(a.args ?? {})
58+
const gated = a.gated ? ' [needs approval]' : ''
59+
lines.push(cmd ? ` • ${a.reason}${cmd}${gated}` : ` • ${a.reason}${gated}`)
60+
}
61+
return lines
62+
}
63+
64+
export function renderNextActions(actions: NextAction[] | undefined): void {
65+
for (const line of nextActionsLines(actions)) info(line)
66+
}
67+
4068
// Serialize a credential bundle to .env text. All values are double-quoted (connection strings
4169
// contain special chars); backslashes and quotes are escaped so dotenv parsers read them back exactly.
4270
export function serializeEnv(bundle: Record<string, string>): string {

test/util.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { serializeEnv, handleApproval } from '../src/util.js'
2+
import { serializeEnv, handleApproval, nextActionsLines } from '../src/util.js'
33

44
describe('serializeEnv', () => {
55
it('quotes and escapes values; ends with newline', () => {
@@ -22,3 +22,35 @@ describe('handleApproval', () => {
2222
expect(handleApproval({ status: 200, body: { ok: true } })).toBe(false)
2323
})
2424
})
25+
26+
describe('nextActionsLines', () => {
27+
it('renders a mapped op as an insta command with args, plus its reason', () => {
28+
const lines = nextActionsLines([{ op: 'service.add', reason: 'Add a service first.', args: { type: 'postgres', name: 'db' } }])
29+
expect(lines[0]).toBe('Next:')
30+
expect(lines.join('\n')).toContain('insta services add postgres db')
31+
expect(lines.join('\n')).toContain('Add a service first.')
32+
})
33+
34+
it('degrades to reason-only for an unknown op and never crashes', () => {
35+
const lines = nextActionsLines([{ op: 'totally.unknown', reason: 'Do the thing.' }])
36+
expect(lines.join('\n')).toContain('Do the thing.')
37+
})
38+
39+
it('marks gated actions', () => {
40+
const lines = nextActionsLines([{ op: 'deploy', reason: 'Deploy it.', gated: true, args: {} }])
41+
expect(lines.join('\n')).toContain('needs approval')
42+
})
43+
44+
it('returns [] for empty/absent input', () => {
45+
expect(nextActionsLines(undefined)).toEqual([])
46+
expect(nextActionsLines([])).toEqual([])
47+
})
48+
49+
it('renders metrics/logs hints with the compute target (runnable command)', () => {
50+
const metricsLines = nextActionsLines([{ op: 'metrics', reason: 'Check metrics.', args: { projectId: 'pr_1' } }])
51+
expect(metricsLines.join('\n')).toContain('insta metrics compute')
52+
53+
const logsLines = nextActionsLines([{ op: 'logs', reason: 'Check logs.', args: { projectId: 'pr_1' } }])
54+
expect(logsLines.join('\n')).toContain('insta logs compute')
55+
})
56+
})

0 commit comments

Comments
 (0)