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
30 changes: 30 additions & 0 deletions src/commands/compute.ts
Original file line number Diff line number Diff line change
@@ -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 }

Expand Down Expand Up @@ -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<void> {
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<void> {
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}`)

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: insta compute status without [service] prints the opaque service ID even when the sole compute service has a name. Derive the name from the already-fetched services list so status output stays readable and consistent with lifecycle commands.

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

<comment>`insta compute status` without `[service]` prints the opaque service ID even when the sole compute service has a name. Derive the name from the already-fetched `services` list so status output stays readable and consistent with lifecycle commands.</comment>

<file context>
@@ -41,3 +42,32 @@ function printDomain(r: any, json?: boolean): void {
+  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}`)
+}
</file context>
Suggested change
info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`)
info(`compute ${services.find((s: { id: string; name: string }) => s.id === id)?.name ?? id}: desired=${r.desiredState} live=${r.state}`)

}
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
16 changes: 15 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 All @@ -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 <name>`)')
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<void> {
Expand All @@ -36,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
12 changes: 10 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
.option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o)))
compute.command('check-domain <host>').description("Show a custom domain's cert status + required DNS records")
.option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.checkDomain(host, o)))
compute.command('remove-domain <host>').description('Detach a custom domain (gated: deploy)')
.option('--branch <b>').option('--group <g>').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)))
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 }

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: NextAction.args is typed as Record<string, unknown>, so property accesses like a.branch and a.type in the OP_COMMAND handlers are completely unchecked. A call-site typo (e.g., {brnach: 'main'} instead of {branch: 'main'}) would silently produce a wrong placeholder command rather than a compile error. Consider using a per-op discriminated union or at least documenting the expected shape per op so callers get editor feedback on valid property names.

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

<comment>`NextAction.args` is typed as `Record<string, unknown>`, so property accesses like `a.branch` and `a.type` in the `OP_COMMAND` handlers are completely unchecked. A call-site typo (e.g., `{brnach: 'main'}` instead of `{branch: 'main'}`) would silently produce a wrong placeholder command rather than a compile error. Consider using a per-op discriminated union or at least documenting the expected shape per op so callers get editor feedback on valid property names.</comment>

<file context>
@@ -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).
</file context>


// 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'}`,
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
22 changes: 21 additions & 1 deletion test/services.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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/)
})
})
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