Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class ApiClient {
}

private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const headers: Record<string, string> = { '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,
Expand Down
3 changes: 2 additions & 1 deletion src/commands/branch.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
Expand Down
3 changes: 2 additions & 1 deletion src/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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 <dir> (needs a
Expand Down
3 changes: 2 additions & 1 deletion src/commands/project.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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() })
}
Expand Down
3 changes: 2 additions & 1 deletion src/commands/services.ts
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -49,6 +49,7 @@ export async function servicesAdd(type: string, name: string): Promise<void> {
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<void> {
Expand Down
28 changes: 28 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ export function handleApproval(res: { status: number; body: any }): boolean {
return false
}

export type NextAction = { op: string; reason: string; args?: Record<string, unknown>; gated?: boolean }

// Neutral op → an `insta` command string. Unknown ops fall back to reason-only (no crash).
const OP_COMMAND: Record<string, (a: Record<string, unknown>) => string> = {
'service.add': (a) => `insta services add ${a.type ?? '<type>'} ${a.name ?? '<name>'}`,
deploy: (a) => `insta deploy${a.branch ? ` --branch ${a.branch}` : ''}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The deploy next-action mapping renders insta deploy [--branch <b>] but the deploy command requires a <dir> or --image <url> — running the hint as-is will fail with a usage error. Consider including a placeholder for the required argument, e.g. insta deploy <dir> --branch <name>. The mapping also doesn't handle --group which the actual command supports and the deploy response includes (res.body.group).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/util.ts, line 45:

<comment>The `deploy` next-action mapping renders `insta deploy [--branch <b>]` but the deploy command requires a `<dir>` or `--image <url>` — running the hint as-is will fail with a usage error. Consider including a placeholder for the required argument, e.g. `insta deploy <dir> --branch <name>`. The mapping also doesn't handle `--group` which the actual command supports and the deploy response includes (`res.body.group`).</comment>

<file context>
@@ -37,6 +37,34 @@ export function handleApproval(res: { status: number; body: any }): boolean {
+// Neutral op → an `insta` command string. Unknown ops fall back to reason-only (no crash).
+const OP_COMMAND: Record<string, (a: Record<string, unknown>) => string> = {
+  'service.add': (a) => `insta services add ${a.type ?? '<type>'} ${a.name ?? '<name>'}`,
+  deploy: (a) => `insta deploy${a.branch ? ` --branch ${a.branch}` : ''}`,
+  'secrets.set': (a) => `insta secrets set ${a.name ?? '<NAME>'} ${a.value ?? '<value>'}`,
+  metrics: (a) => `insta metrics ${a.target ?? 'compute'}`,
</file context>
Suggested change
deploy: (a) => `insta deploy${a.branch ? ` --branch ${a.branch}` : ''}`,
deploy: (a) => `insta deploy ${a.dir ?? '<dir>'}${a.branch ? ` --branch ${a.branch}` : ''}${a.group ? ` --group ${a.group}` : ''}`,

'secrets.set': (a) => `insta secrets set ${a.name ?? '<NAME>'} ${a.value ?? '<value>'}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The secrets.set mapping uses <NAME> (uppercase) for the name placeholder while service.add uses <name> (lowercase). Keeps the affordance consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/util.ts, line 46:

<comment>The `secrets.set` mapping uses `<NAME>` (uppercase) for the name placeholder while `service.add` uses `<name>` (lowercase). Keeps the affordance consistent.</comment>

<file context>
@@ -37,6 +37,34 @@ export function handleApproval(res: { status: number; body: any }): boolean {
+const OP_COMMAND: Record<string, (a: Record<string, unknown>) => string> = {
+  'service.add': (a) => `insta services add ${a.type ?? '<type>'} ${a.name ?? '<name>'}`,
+  deploy: (a) => `insta deploy${a.branch ? ` --branch ${a.branch}` : ''}`,
+  'secrets.set': (a) => `insta secrets set ${a.name ?? '<NAME>'} ${a.value ?? '<value>'}`,
+  metrics: (a) => `insta metrics ${a.target ?? 'compute'}`,
+  logs: (a) => `insta logs ${a.target ?? 'compute'}`,
</file context>
Suggested change
'secrets.set': (a) => `insta secrets set ${a.name ?? '<NAME>'} ${a.value ?? '<value>'}`,
'secrets.set': (a) => `insta secrets set ${a.name ?? '<name>'} ${a.value ?? '<value>'}`,

metrics: (a) => `insta metrics ${a.target ?? 'compute'}`,
logs: (a) => `insta logs ${a.target ?? 'compute'}`,
'approvals.approve': (a) => `insta approvals approve ${a.approvalId ?? '<id>'}`,
}

// 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, string>): string {
Expand Down
34 changes: 33 additions & 1 deletion test/util.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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')
})
})
Loading