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
6 changes: 4 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// 2xx (including 202 approval_required) returns the parsed body; >=400 throws ApiError.
import { readGlobal, writeGlobal, readProject, writeProject, type GlobalConfig, type ProjectConfig } from './config.js'
import { autoResolveProject, promptChoice, type ProjectItem } from './resolve-project.js'
import { die, info } from './util.js'
import { die } from './util.js'

export class ApiError extends Error {
constructor(public status: number, msg: string) { super(msg); this.name = 'ApiError' }
Expand Down Expand Up @@ -115,7 +115,9 @@ export async function requireProject(): Promise<ProjectConfig> {
promptChoice,
save: async (c) => {
await writeProject(c)
info(`auto-linked project ${c.projectId} → ./.insta/project.json`)
// stderr: this is a diagnostic that can precede ANY command's output — under --json,
// stdout must stay one parseable document.
process.stderr.write(`auto-linked project ${c.projectId} → ./.insta/project.json\n`)
},
tty: !!process.stdin.isTTY && !!process.stderr.isTTY,
})
Expand Down
16 changes: 10 additions & 6 deletions src/commands/branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { ApiClient, requireProject } from '../api.js'
import { writeProject } from '../config.js'
import { info, die, printJson, handleApproval, renderNextActions } from '../util.js'

export async function branchCreate(name: string, opts: { from?: string }): Promise<void> {
export async function branchCreate(name: string, opts: { from?: string; json?: boolean }): 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 })
if (opts.json) return printJson(out)
info(`created branch ${out.branch.name} (${out.branch.id})`)
renderNextActions(out.nextActions)
}
Expand All @@ -18,35 +19,38 @@ export async function branchList(opts: { json?: boolean }): Promise<void> {
for (const b of branches) info(`${b.is_default ? '*' : ' '} ${b.name} [${b.status}] ${b.id}`)
}

export async function branchSwitch(name: string): Promise<void> {
export async function branchSwitch(name: string, opts: { json?: boolean } = {}): Promise<void> {
const api = await ApiClient.load()
const p = await requireProject()
const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`)
if (!branches.some((b: any) => b.name === name)) die(`branch not found: ${name}`)
await writeProject({ ...p, branch: name })
if (opts.json) return printJson({ projectId: p.projectId, branch: name })
info(`switched to branch ${name} — run \`insta secrets\` to refresh .env`)
}

export async function branchDelete(name: string): Promise<void> {
export async function branchDelete(name: string, opts: { json?: boolean } = {}): Promise<void> {
const api = await ApiClient.load()
const p = await requireProject()
const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`)
const b = branches.find((x: any) => x.name === name || x.id === name)
if (!b) die(`branch not found: ${name}`)
const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/branches/${b.id}`)
if (handleApproval(res)) return
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson({ ok: true, branch: { id: b.id, name: b.name } })
info(`deleted branch ${name}`)
}

// insta branch merge <source> [--into <target>] — structurally merge source's services into target
// (default target: current branch). No data is copied; existing services are left untouched.
export async function branchMerge(source: string, opts: { into?: string } = {}): Promise<void> {
export async function branchMerge(source: string, opts: { into?: string; json?: boolean } = {}): Promise<void> {
const api = await ApiClient.load()
const p = await requireProject()
const target = opts.into ?? p.branch
if (!target) throw new Error('no target branch — pass --into <branch> (or link a branch first)')
const res = await api.rawRequest('POST', `/projects/${p.projectId}/branches/${encodeURIComponent(target)}/merge`, { from: source })
if (handleApproval(res)) return
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson(res.body ?? {})
const { created = [], skipped = [] } = (res.body ?? {}) as {
created?: Array<{ type: string; name: string }>
skipped?: Array<{ type: string; name: string; reason: string }>
Expand Down
9 changes: 8 additions & 1 deletion src/commands/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ export async function removeDomain(host: string, opts: Opts): Promise<void> {
const p = await requireProject()
const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group })
if (handleApproval(res, opts.json)) return
info(`removed custom domain ${res.body.hostname} from ${res.body.flyApp}`)
renderRemoveDomain(res.body, opts.json)
}

// Split out (same pattern as applyExecResult) so the --json contract — stdout carries the platform
// response, never prose — is unit-testable without a network mock.
export function renderRemoveDomain(body: any, json?: boolean): void {
if (json) return printJson(body)
info(`removed custom domain ${body.hostname} from ${body.flyApp}`)
}

function printDomain(r: any, json?: boolean): void {
Expand Down
30 changes: 19 additions & 11 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { resolve, join } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { ApiClient, ApiError, requireProject } from '../api.js'
import { info, die, handleApproval, renderNextActions } from '../util.js'
import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, type BuildRunner } from '../flyctl-build.js'
import { info, die, printJson, handleApproval, renderNextActions } from '../util.js'
import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, stderrBuildRunner, type BuildRunner } from '../flyctl-build.js'

type DeployOpts = { image?: string; branch?: string; group?: string; port?: string; websocket?: boolean }
type DeployOpts = { image?: string; branch?: string; group?: string; port?: string; websocket?: boolean; json?: boolean }

// With --json, stdout must carry exactly one JSON document (the deploy result), so every progress
// line moves to stderr.
const note = (opts: DeployOpts) => (opts.json ? (m: string) => void process.stderr.write(m + '\n') : info)

// Map CLI options to the platform deploy request body. Pure, so it's unit-tested. --websocket is only
// sent when set (plain deploys unchanged).
Expand Down Expand Up @@ -39,21 +43,23 @@ export async function deploy(dir: string | undefined, opts: DeployOpts): Promise
const api = await ApiClient.load()
const p = await requireProject()
const branch = opts.branch ?? p.branch
const log = note(opts)

let port = opts.port ? Number(opts.port) : undefined
if (dir && port === undefined) {
const dockerfile = join(resolve(process.cwd(), dir), 'Dockerfile')
const exposed = existsSync(dockerfile) ? dockerfileExposedPort(readFileSync(dockerfile, 'utf8')) : undefined
if (exposed) {
port = exposed
info(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`)
log(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`)
}
}

const effOpts = { ...opts, port: port?.toString() }
const image = dir ? await buildFromSource(api, p.projectId, dir, branch, effOpts) : opts.image!
const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts))
if (handleApproval(res)) return
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson({ image, ...res.body })
info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`)
renderNextActions(res.body.nextActions)
}
Expand Down Expand Up @@ -83,10 +89,11 @@ export async function buildFromSource(
dir: string,
branch: string,
opts: DeployOpts,
run: BuildRunner = defaultBuildRunner,
run: BuildRunner = opts.json ? stderrBuildRunner : defaultBuildRunner,
Comment thread
jwfing marked this conversation as resolved.
): Promise<string> {
const absDir = resolve(process.cwd(), dir)
if (!existsSync(join(absDir, 'Dockerfile'))) die(`no Dockerfile at ${join(absDir, 'Dockerfile')} — add one, or use --image <url>`)
const log = note(opts)

let tok
try {
Expand All @@ -96,18 +103,19 @@ export async function buildFromSource(
// daemon deploys from the SAME docker this shell uses, so build locally and hand it the tag.
if (!(e instanceof ApiError) || e.status !== 501) throw e
const tag = localImageTag(projectId, opts.group)
info(`no remote builder on this daemon — building ${dir} locally with docker…`)
log(`no remote builder on this daemon — building ${dir} locally with docker…`)
const built = await dockerBuildLocal(absDir, tag, run)
info(` built ${built}`)
log(` built ${built}`)
return built
}
if (handleApproval(tok)) die('deploy requires approval — get it approved, then re-run')
// exit() with no argument honors the exit code handleApproval just set (2).
if (handleApproval(tok, opts.json)) process.exit()
const { token, flyApp } = tok.body

await ensureFlyctl() // cloud path only — the local path needs docker, which the daemon requires anyway
const port = opts.port ? Number(opts.port) : 8080
info(`building ${dir} for ${flyApp} (remote builder)…`)
log(`building ${dir} for ${flyApp} (remote builder)…`)
const { imageRef } = await flyctlBuildAndPush({ dir: absDir, flyApp, imageLabel: `insta-${Date.now()}`, token, port }, run)
info(` pushed ${imageRef}`)
log(` pushed ${imageRef}`)
return imageRef
}
18 changes: 17 additions & 1 deletion src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,21 @@ export async function envShow(opts: { json?: boolean }): Promise<void> {
if (!env) info(' (custom apiUrl — `insta env use <name>` to switch to a named environment)')
}

export async function envUse(name: string): Promise<void> {
// One stable schema for BOTH envUse outcomes (no-op and real switch), so a scripted caller can key
// on any field — mcpServer, previous — without probing which branch ran. Pure, unit-tested.
export function envUseResult(target: EnvName, previous: string | null, changed: boolean, sessionDropped: boolean) {
return {
env: target,
previous,
apiUrl: ENVS[target].api,
mcpUrl: ENVS[target].mcp,
mcpServer: mcpServerName(target),
changed,
sessionDropped,
}
}

export async function envUse(name: string, opts: { json?: boolean } = {}): Promise<void> {
const want = name.trim().toLowerCase()
if (!isEnvName(want)) die(`unknown environment "${name}" — expected one of: ${ENV_NAMES.join(', ')}`)
const target: EnvName = want
Expand All @@ -34,6 +48,7 @@ export async function envUse(name: string): Promise<void> {
// how envForApiUrl already treats it) instead of being rewritten as a "switch" that needlessly
// drops a perfectly good session.
if (normalizeUrl(stored.apiUrl) === normalizeUrl(nextApi)) {
if (opts.json) return printJson(envUseResult(target, from ?? target, false, false))
info(`already on ${target} (${nextApi})`)
return
}
Expand All @@ -52,6 +67,7 @@ export async function envUse(name: string): Promise<void> {
delete next.user
await writeGlobal(next)

if (opts.json) return printJson(envUseResult(target, from ?? null, true, hadSession))
info(`switched ${from ?? '(custom)'} → ${target}`)
info(` api: ${nextApi}`)
info(` mcp: ${ENVS[target].mcp} (registers as \`${mcpServerName(target)}\`)`)
Expand Down
11 changes: 7 additions & 4 deletions src/commands/govern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,19 @@ export async function approvalsList(opts: { status?: string; json?: boolean }):
for (const a of approvals) info(`${a.id} ${a.action} [${a.status}] ${a.requested_at}`)
}

export async function approvalsApprove(id: string, opts: { always?: boolean }): Promise<void> {
export async function approvalsApprove(id: string, opts: { always?: boolean; json?: boolean }): Promise<void> {
const api = await ApiClient.load()
const p = await requireProject()
const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/approve`, { always: !!opts.always })
if (opts.json) return printJson(out)
info(`approved ${out.approval.action} (${id})${opts.always ? ' — policy set to allow' : ''}`)
}

export async function approvalsDeny(id: string): Promise<void> {
export async function approvalsDeny(id: string, opts: { json?: boolean } = {}): Promise<void> {
const api = await ApiClient.load()
const p = await requireProject()
const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/deny`)
if (opts.json) return printJson(out)
info(`denied ${out.approval.action} (${id})`)
}

Expand All @@ -43,9 +45,10 @@ export async function policyGet(opts: { json?: boolean }): Promise<void> {
for (const [action, decision] of Object.entries(policy)) info(`${action}: ${decision}`)
}

export async function policySet(action: string, decision: string): Promise<void> {
export async function policySet(action: string, decision: string, opts: { json?: boolean } = {}): Promise<void> {
const api = await ApiClient.load()
const p = await requireProject()
await api.request('PUT', `/projects/${p.projectId}/policy/${action}`, { decision })
const out = await api.request('PUT', `/projects/${p.projectId}/policy/${action}`, { decision })
if (opts.json) return printJson({ action, decision, ...(out ?? {}) })
info(`policy ${action} = ${decision}`)
}
3 changes: 2 additions & 1 deletion src/commands/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ export async function orgList(opts: { json?: boolean }): Promise<void> {
for (const o of orgs) info(`${o.id} ${o.name}${o.is_personal ? ' (personal)' : ''} [${o.role}]`)
}

export async function orgCreate(name: string): Promise<void> {
export async function orgCreate(name: string, opts: { json?: boolean } = {}): Promise<void> {
const api = await ApiClient.load()
const { org } = await api.request('POST', '/orgs', { name })
if (opts.json) return printJson(org)
info(`created org ${org.id} (${org.name})`)
}
47 changes: 31 additions & 16 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,20 @@ const GENERIC_DIRS = new Set([
])

// Best-effort: wire the credential-audit hook into the project (no-op if assets aren't built).
function tryInstallObserve(): void {
// quiet: with --json the install still runs, but its note moves to stderr (stdout is JSON-only).
function tryInstallObserve(quiet = false): void {
try {
const r = installObserve({ cwd: process.cwd() })
if (r.claude || r.codex) info(' installed observe hook (credential audit) → ./.insta/observe')
if (r.claude || r.codex) {
const line = ' installed observe hook (credential audit) → ./.insta/observe'
quiet ? process.stderr.write(line + '\n') : info(line)
}
} catch { /* assets missing (dev/unbuilt) — skip silently */ }
}

// installSkills prints to stdout by default; with --json its notes go to stderr instead.
const skillsPrint = (json?: boolean) => (json ? (s: string) => void process.stderr.write(s + '\n') : undefined)

async function resolveOrg(api: ApiClient, given?: string): Promise<string> {
if (given) return given
const { orgs } = await api.request('GET', '/orgs')
Expand All @@ -47,11 +54,13 @@ export function resolveProjectName(nameArg: string | undefined, cwd = process.cw
return null
}

export async function projectCreate(name: string | undefined, opts: { org?: string }): Promise<void> {
export async function projectCreate(name: string | undefined, opts: { org?: string; json?: boolean }): Promise<void> {
const resolved = resolveProjectName(name, process.cwd())
if (!resolved) {
// No name given and the cwd name is generic — don't provision resources under a junk name.
// Guide instead (no hang, no error): name it explicitly, or just ask the skill-equipped agent.
// A terminal gets guidance (no hang, no error); --json is a scripted caller with no human to
// guide, so it gets a hard error instead of an empty success.
if (opts.json) die('no project name — pass one: insta project create <name>')
info('name your project: insta project create <name>')
info(' (or just ask your coding agent — it has the insta skill and will do this for you)')
return
Expand All @@ -60,12 +69,16 @@ export async function projectCreate(name: string | undefined, opts: { org?: stri
const orgId = await resolveOrg(api, opts.org)
const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved })
await writeProject({ projectId: out.project.id, orgId, branch: out.defaultBranch.name })
info(`created project ${out.project.id} (${resolved})`)
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() })
if (opts.json) {
printJson({ ...out, linked: { projectId: out.project.id, orgId, branch: out.defaultBranch.name } })
} else {
info(`created project ${out.project.id} (${resolved})`)
info(` resources: ${out.resources.map((r: any) => r.kind).join(', ')}`)
info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`)
renderNextActions(out.nextActions)
}
tryInstallObserve(opts.json)
await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) })
}

export async function projectList(opts: { org?: string; json?: boolean }): Promise<void> {
Expand All @@ -77,19 +90,21 @@ export async function projectList(opts: { org?: string; json?: boolean }): Promi
for (const p of projects) info(`${p.id} ${p.name} [${p.status}]`)
}

export async function projectLink(id: string): Promise<void> {
export async function projectLink(id: string, opts: { json?: boolean } = {}): Promise<void> {
const api = await ApiClient.load()
const { project } = await api.request('GET', `/projects/${id}`)
await writeProject({ projectId: project.id, orgId: project.org_id, branch: 'main' })
info(`linked project ${project.id} (${project.name})`)
tryInstallObserve()
await installSkills({ cwd: process.cwd() })
if (opts.json) printJson({ project, linked: { projectId: project.id, orgId: project.org_id, branch: 'main' } })
else info(`linked project ${project.id} (${project.name})`)
tryInstallObserve(opts.json)
await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) })
}

export async function projectDelete(opts: { project?: string }): Promise<void> {
export async function projectDelete(opts: { project?: string; json?: boolean }): Promise<void> {
const api = await ApiClient.load()
const projectId = opts.project ?? (await requireProject()).projectId
const res = await api.rawRequest('DELETE', `/projects/${projectId}`)
if (handleApproval(res)) return
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson({ ok: true, projectId })
Comment thread
jwfing marked this conversation as resolved.
info(`deleted project ${projectId}`)
}
6 changes: 4 additions & 2 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// as the process does.
import { spawn } from 'node:child_process'
import { ApiClient, requireProject } from '../api.js'
import { die, info, handleApproval } from '../util.js'
import { die, handleApproval } from '../util.js'

export type RunDeps = {
fetchBundle: () => Promise<Record<string, string>>
Expand Down Expand Up @@ -37,7 +37,9 @@ export async function run(cmdAndArgs: string[], opts: { branch?: string }): Prom
const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets?branch=${encodeURIComponent(branch)}`)
// exit() with no argument honors the exit code handleApproval just set (2).
if (handleApproval(res)) process.exit()
info(`running with ${Object.keys(res.body.secrets).length} injected secrets (branch ${branch}) — nothing written to disk`)
// stderr, not stdout: `insta run`'s stdout belongs entirely to the child command (that's why
// run has no --json — wrapping would break the child's own output contract).
process.stderr.write(`running with ${Object.keys(res.body.secrets).length} injected secrets (branch ${branch}) — nothing written to disk\n`)
return res.body.secrets as Record<string, string>
},
})
Expand Down
Loading
Loading