Skip to content

Commit 4e2b6cd

Browse files
authored
Merge pull request #33 from InsForge/devel
Devel
2 parents dfbaf53 + 80da15e commit 4e2b6cd

10 files changed

Lines changed: 144 additions & 9 deletions

File tree

src/api.ts

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

5959
private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> {
60-
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
60+
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Insta-Hints': '1' }
6161
if (auth && this.cfg.accessToken) headers.Authorization = `Bearer ${this.cfg.accessToken}`
6262
const res = await fetch(this.apiUrl + path, {
6363
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/compute.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ApiClient, requireProject } from '../api.js'
22
import { info, printJson, handleApproval } from '../util.js'
3+
import { resolveComputeServiceId } from './services.js'
34

45
type Opts = { branch?: string; group?: string; json?: boolean }
56

@@ -41,3 +42,32 @@ function printDomain(r: any, json?: boolean): void {
4142
}
4243
if (!r.configured) info(' once DNS propagates, Fly issues the cert — re-check with `insta compute check-domain`')
4344
}
45+
46+
// ---- lifecycle (start/stop/suspend/status) ----
47+
48+
type LifeOpts = { json?: boolean }
49+
50+
async function lifecycle(verb: 'start' | 'stop' | 'suspend', serviceName: string | undefined, opts: LifeOpts): Promise<void> {
51+
const api = await ApiClient.load()
52+
const p = await requireProject()
53+
const { services } = await api.request('GET', `/projects/${p.projectId}/services`)
54+
const id = resolveComputeServiceId(services, serviceName)
55+
const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/${verb}`)
56+
if (handleApproval(res)) return
57+
if (opts.json) return printJson(res.body)
58+
info(`compute ${res.body.service?.name ?? id}: ${verb} → desired=${res.body.service?.desired_state} (live: ${res.body.state})`)
59+
}
60+
61+
export const computeStart = (service: string | undefined, opts: LifeOpts) => lifecycle('start', service, opts)
62+
export const computeStop = (service: string | undefined, opts: LifeOpts) => lifecycle('stop', service, opts)
63+
export const computeSuspend = (service: string | undefined, opts: LifeOpts) => lifecycle('suspend', service, opts)
64+
65+
export async function computeStatus(serviceName: string | undefined, opts: LifeOpts): Promise<void> {
66+
const api = await ApiClient.load()
67+
const p = await requireProject()
68+
const { services } = await api.request('GET', `/projects/${p.projectId}/services`)
69+
const id = resolveComputeServiceId(services, serviceName)
70+
const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/state`)
71+
if (opts.json) return printJson(r)
72+
info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`)
73+
}

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: 15 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]
@@ -26,6 +26,19 @@ export function resolveServiceId(services: Array<{ id: string; type: string; nam
2626
return svc.id
2727
}
2828

29+
// Resolve a compute service id: by name, or the sole compute service when name is omitted.
30+
export function resolveComputeServiceId(services: Array<{ id: string; type: string; name: string }>, name?: string): string {
31+
const compute = services.filter((s) => s.type === 'compute')
32+
if (name) {
33+
const svc = compute.find((s) => s.name === name)
34+
if (!svc) throw new Error(`compute service not found: ${name}`)
35+
return svc.id
36+
}
37+
if (compute.length === 0) throw new Error('no compute service in this project (add one with `insta services add compute <name>`)')
38+
if (compute.length > 1) throw new Error(`multiple compute services — specify one: ${compute.map((s) => s.name).join(', ')}`)
39+
return compute[0]!.id
40+
}
41+
2942
// ---- commands ----
3043

3144
export async function servicesAdd(type: string, name: string): Promise<void> {
@@ -36,6 +49,7 @@ export async function servicesAdd(type: string, name: string): Promise<void> {
3649
if (handleApproval(res)) return
3750
const svc = res.body.service
3851
info(`added ${type} service ${name} (${svc.id})${svc.domain ? ` — ${svc.domain}` : ''}`)
52+
renderNextActions(res.body.nextActions)
3953
}
4054

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

src/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,22 @@ program.command('deploy [dir]').description('Deploy a source directory (built re
117117
.option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
118118
.action(guard((dir, o) => deploy(dir, o)))
119119

120-
// ---- compute custom domains (bring your own domain → Fly cert + routing) ----
121-
const compute = program.command('compute').description('Compute custom domains (bring your own domain)')
120+
// ---- compute (lifecycle control + custom domains) ----
121+
const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains')
122122
compute.command('set-domain <host>').description('Attach a custom domain to a branch compute service (gated: deploy)')
123123
.option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o)))
124124
compute.command('check-domain <host>').description("Show a custom domain's cert status + required DNS records")
125125
.option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.checkDomain(host, o)))
126126
compute.command('remove-domain <host>').description('Detach a custom domain (gated: deploy)')
127127
.option('--branch <b>').option('--group <g>').action(guard((host, o) => computeCmd.removeDomain(host, o)))
128+
compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)')
129+
.option('--json').action(guard((service, o) => computeCmd.computeStart(service, o)))
130+
compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`')
131+
.option('--json').action(guard((service, o) => computeCmd.computeStop(service, o)))
132+
compute.command('suspend [service]').description('Suspend a compute service (RAM snapshot); stays down until `start`')
133+
.option('--json').action(guard((service, o) => computeCmd.computeSuspend(service, o)))
134+
compute.command('status [service]').description("Show a compute service's desired vs. live state")
135+
.option('--json').action(guard((service, o) => computeCmd.computeStatus(service, o)))
128136

129137
// ---- manifest ----
130138
program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o)))

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/services.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { assertType, parseCount, resolveServiceId, SERVICE_TYPES } from '../src/commands/services.js'
2+
import { assertType, parseCount, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES } from '../src/commands/services.js'
33

44
describe('assertType', () => {
55
it('accepts valid service types', () => {
@@ -43,3 +43,23 @@ describe('resolveServiceId', () => {
4343
expect(() => resolveServiceId(services, 'storage', 'db')).toThrow(/service not found/)
4444
})
4545
})
46+
47+
describe('resolveComputeServiceId', () => {
48+
const one = [{ id: 'a', type: 'postgres', name: 'db' }, { id: 'b', type: 'compute', name: 'api' }]
49+
const two = [...one, { id: 'c', type: 'compute', name: 'worker' }]
50+
it('returns the sole compute service when name is omitted', () => {
51+
expect(resolveComputeServiceId(one)).toBe('b')
52+
})
53+
it('resolves by name', () => {
54+
expect(resolveComputeServiceId(two, 'worker')).toBe('c')
55+
})
56+
it('errors when the named compute service is missing', () => {
57+
expect(() => resolveComputeServiceId(two, 'nope')).toThrow(/compute service not found/)
58+
})
59+
it('errors on ambiguity when name omitted', () => {
60+
expect(() => resolveComputeServiceId(two)).toThrow(/multiple compute services/)
61+
})
62+
it('errors when there is no compute service', () => {
63+
expect(() => resolveComputeServiceId([{ id: 'a', type: 'postgres', name: 'db' }])).toThrow(/no compute service/)
64+
})
65+
})

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)