+4
-2
@@ -5,3 +5,3 @@ // Thin API client over the platform control-plane. Handles bearer auth + one-shot refresh on 401. | ||
| import { autoResolveProject, promptChoice } from './resolve-project.js'; | ||
| import { die, info } from './util.js'; | ||
| import { die } from './util.js'; | ||
| export class ApiError extends Error { | ||
@@ -121,3 +121,5 @@ status; | ||
| 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`); | ||
| }, | ||
@@ -124,0 +126,0 @@ tty: !!process.stdin.isTTY && !!process.stderr.isTTY, |
@@ -8,2 +8,4 @@ import { ApiClient, requireProject } from '../api.js'; | ||
| 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})`); | ||
@@ -21,3 +23,3 @@ renderNextActions(out.nextActions); | ||
| } | ||
| export async function branchSwitch(name) { | ||
| export async function branchSwitch(name, opts = {}) { | ||
| const api = await ApiClient.load(); | ||
@@ -29,5 +31,7 @@ const p = await requireProject(); | ||
| 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) { | ||
| export async function branchDelete(name, opts = {}) { | ||
| const api = await ApiClient.load(); | ||
@@ -40,4 +44,6 @@ const p = await requireProject(); | ||
| const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/branches/${b.id}`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
| if (opts.json) | ||
| return printJson({ ok: true, branch: { id: b.id, name: b.name } }); | ||
| info(`deleted branch ${name}`); | ||
@@ -54,4 +60,6 @@ } | ||
| const res = await api.rawRequest('POST', `/projects/${p.projectId}/branches/${encodeURIComponent(target)}/merge`, { from: source }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
| if (opts.json) | ||
| return printJson(res.body ?? {}); | ||
| const { created = [], skipped = [] } = (res.body ?? {}); | ||
@@ -58,0 +66,0 @@ info(`merged ${source} → ${target}: ${created.length} created, ${skipped.length} skipped`); |
+20
-19
@@ -10,3 +10,3 @@ import { ApiClient, ApiError, requireProject } from '../api.js'; | ||
| const res = await api.rawRequest('POST', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -30,6 +30,13 @@ printDomain(res.body, opts.json); | ||
| const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group }); | ||
| if (handleApproval(res)) | ||
| 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, json) { | ||
| if (json) | ||
| return printJson(body); | ||
| info(`removed custom domain ${body.hostname} from ${body.flyApp}`); | ||
| } | ||
| function printDomain(r, json) { | ||
@@ -55,3 +62,3 @@ if (json) | ||
| const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/${verb}`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -113,15 +120,9 @@ if (opts.json) | ||
| // | ||
| // A 202 means the command has NOT run: unlike every other gated command (where "nothing happened" | ||
| // is the safe default), a caller chaining `insta compute exec … && next` must not see exit 0 here, | ||
| // or `next` runs believing the command succeeded. --json prints the raw envelope (so a scripted | ||
| // caller can inspect approvalId/action) instead of the human hint; either way exit 1. | ||
| // A 202 means the command has NOT run: handleApproval owns the whole contract (hint on stderr, | ||
| // raw envelope on stdout with --json, exit 2), so a caller chaining `insta compute exec … && next` | ||
| // can never mistake a pending gate for the command having succeeded — and exit 2 stays | ||
| // distinguishable from the remote command's own exit 1. | ||
| export function applyExecResult(res, json) { | ||
| if (res.status === 202 && res.body?.status === 'approval_required') { | ||
| if (json) | ||
| printJson(res.body); | ||
| else | ||
| handleApproval(res); | ||
| process.exitCode = 1; | ||
| if (handleApproval(res, json)) | ||
| return; | ||
| } | ||
| const { exitCode, stdout, stderr, truncated } = res.body; | ||
@@ -177,3 +178,3 @@ if (json) { | ||
| const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/always-on`, { enabled: mode === 'on' }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -279,3 +280,3 @@ if (opts.json) | ||
| } | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -297,3 +298,3 @@ if (opts.json) | ||
| const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/volume`, { sizeGib }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -327,3 +328,3 @@ if (opts.json) | ||
| const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/limits`, body); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -330,0 +331,0 @@ if (opts.json) |
@@ -22,3 +22,3 @@ import { ApiClient, ApiError, requireProject } from '../api.js'; | ||
| const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${qs.toString() ? `?${qs}` : ''}`, { scaleToZero: mode !== 'on' }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -115,3 +115,3 @@ if (opts.json) | ||
| } | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -235,3 +235,3 @@ if (opts.json) | ||
| } | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -238,0 +238,0 @@ if (opts.json) |
+19
-11
| 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 } from '../flyctl-build.js'; | ||
| import { info, die, printJson, handleApproval, renderNextActions } from '../util.js'; | ||
| import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, stderrBuildRunner } from '../flyctl-build.js'; | ||
| // With --json, stdout must carry exactly one JSON document (the deploy result), so every progress | ||
| // line moves to stderr. | ||
| const note = (opts) => (opts.json ? (m) => 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 | ||
@@ -39,2 +42,3 @@ // sent when set (plain deploys unchanged). | ||
| const branch = opts.branch ?? p.branch; | ||
| const log = note(opts); | ||
| let port = opts.port ? Number(opts.port) : undefined; | ||
@@ -46,3 +50,3 @@ if (dir && port === undefined) { | ||
| port = exposed; | ||
| info(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`); | ||
| log(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`); | ||
| } | ||
@@ -53,4 +57,6 @@ } | ||
| const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts)); | ||
| if (handleApproval(res)) | ||
| 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})`); | ||
@@ -77,6 +83,7 @@ renderNextActions(res.body.nextActions); | ||
| // Exported with injectable pieces for tests (the repo's DI pattern; no global mocks). | ||
| export async function buildFromSource(api, projectId, dir, branch, opts, run = defaultBuildRunner) { | ||
| export async function buildFromSource(api, projectId, dir, branch, opts, run = opts.json ? stderrBuildRunner : defaultBuildRunner) { | ||
| 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; | ||
@@ -92,17 +99,18 @@ try { | ||
| 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; | ||
| } | ||
| //# sourceMappingURL=deploy.js.map |
+18
-1
@@ -23,3 +23,16 @@ // `insta env` — show or switch the deployment environment (prod | staging). | ||
| } | ||
| export async function envUse(name) { | ||
| // 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, previous, changed, sessionDropped) { | ||
| return { | ||
| env: target, | ||
| previous, | ||
| apiUrl: ENVS[target].api, | ||
| mcpUrl: ENVS[target].mcp, | ||
| mcpServer: mcpServerName(target), | ||
| changed, | ||
| sessionDropped, | ||
| }; | ||
| } | ||
| export async function envUse(name, opts = {}) { | ||
| const want = name.trim().toLowerCase(); | ||
@@ -37,2 +50,4 @@ if (!isEnvName(want)) | ||
| if (normalizeUrl(stored.apiUrl) === normalizeUrl(nextApi)) { | ||
| if (opts.json) | ||
| return printJson(envUseResult(target, from ?? target, false, false)); | ||
| info(`already on ${target} (${nextApi})`); | ||
@@ -54,2 +69,4 @@ return; | ||
| await writeGlobal(next); | ||
| if (opts.json) | ||
| return printJson(envUseResult(target, from ?? null, true, hadSession)); | ||
| info(`switched ${from ?? '(custom)'} → ${target}`); | ||
@@ -56,0 +73,0 @@ info(` api: ${nextApi}`); |
@@ -32,8 +32,12 @@ import { ApiClient, requireProject } from '../api.js'; | ||
| 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) { | ||
| export async function approvalsDeny(id, opts = {}) { | ||
| 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})`); | ||
@@ -50,8 +54,10 @@ } | ||
| } | ||
| export async function policySet(action, decision) { | ||
| export async function policySet(action, decision, opts = {}) { | ||
| 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}`); | ||
| } | ||
| //# sourceMappingURL=govern.js.map |
@@ -143,4 +143,38 @@ import { ApiClient, requireProject } from '../api.js'; | ||
| } | ||
| // A log-window instant from the CLI: unix seconds (all digits) or anything Date.parse reads | ||
| // (ISO-8601 etc.). Throws on junk — a mistyped instant must fail here, not become NaN on the wire. | ||
| export function parseLogInstant(raw, flag) { | ||
| if (/^\d+$/.test(raw)) | ||
| return Number(raw); | ||
| const ms = Date.parse(raw); | ||
| if (Number.isNaN(ms)) | ||
| throw new Error(`invalid ${flag}: ${raw} (unix seconds or an ISO-8601 date)`); | ||
| return Math.floor(ms / 1000); | ||
| } | ||
| // '90s' | '30m' | '2h' | '1d' → seconds. Throws on junk, zero, and unknown units. | ||
| export function parseSinceSeconds(raw) { | ||
| const m = /^(\d+)([smhd])$/.exec(raw.trim()); | ||
| if (!m) | ||
| throw new Error(`invalid --since: ${raw} (e.g. 90s, 30m, 2h, 1d)`); | ||
| const n = Number(m[1]); | ||
| if (n === 0) | ||
| throw new Error(`invalid --since: ${raw} (must be > 0)`); | ||
| return n * { s: 1, m: 60, h: 3600, d: 86400 }[m[2]]; | ||
| } | ||
| // The from/to pair for the logs request, from whichever window flags were given. Pure, unit-tested. | ||
| export function resolveLogWindow(opts, now = Math.floor(Date.now() / 1000)) { | ||
| if (opts.since && opts.from) | ||
| throw new Error('pass --since or --from, not both'); | ||
| const from = opts.since ? now - parseSinceSeconds(opts.since) : opts.from !== undefined ? parseLogInstant(opts.from, '--from') : undefined; | ||
| const to = opts.to !== undefined ? parseLogInstant(opts.to, '--to') : undefined; | ||
| if (from !== undefined && to !== undefined && to < from) | ||
| throw new Error('--to is before --from'); | ||
| return { from, to }; | ||
| } | ||
| // insta logs <db|compute|redis|mysql|mongodb> [group] | ||
| export async function logs(component, group, opts) { | ||
| const windowFlags = opts.from !== undefined || opts.to !== undefined || opts.since !== undefined; | ||
| if (opts.deploy && windowFlags) | ||
| throw new Error('--from/--to/--since apply to runtime logs, not --deploy events'); | ||
| const { from, to } = resolveLogWindow(opts); | ||
| const api = await ApiClient.load(); | ||
@@ -163,3 +197,3 @@ const p = await requireProject(); | ||
| } | ||
| const res = await api.request('GET', `/projects/${p.projectId}/logs${qs({ component, group, branch: opts.branch ?? p.branch, limit: opts.limit, region: opts.region, instance: opts.instance })}`); | ||
| const res = await api.request('GET', `/projects/${p.projectId}/logs${qs({ component, group, branch: opts.branch ?? p.branch, limit: opts.limit, region: opts.region, instance: opts.instance, from: from !== undefined ? String(from) : undefined, to: to !== undefined ? String(to) : undefined })}`); | ||
| if (opts.json) | ||
@@ -166,0 +200,0 @@ return printJson(res); |
@@ -11,7 +11,9 @@ import { ApiClient } from '../api.js'; | ||
| } | ||
| export async function orgCreate(name) { | ||
| export async function orgCreate(name, opts = {}) { | ||
| 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})`); | ||
| } | ||
| //# sourceMappingURL=org.js.map |
+33
-15
@@ -16,10 +16,15 @@ import { homedir } from 'node:os'; | ||
| // Best-effort: wire the credential-audit hook into the project (no-op if assets aren't built). | ||
| function tryInstallObserve() { | ||
| // quiet: with --json the install still runs, but its note moves to stderr (stdout is JSON-only). | ||
| function tryInstallObserve(quiet = false) { | ||
| 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) => (json ? (s) => void process.stderr.write(s + '\n') : undefined); | ||
| async function resolveOrg(api, given) { | ||
@@ -55,3 +60,6 @@ if (given) | ||
| // 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>'); | ||
@@ -65,8 +73,13 @@ info(' (or just ask your coding agent — it has the insta skill and will do this for you)'); | ||
| await writeProject({ projectId: out.project.id, orgId, branch: out.defaultBranch.name }); | ||
| info(`created project ${out.project.id} (${resolved})`); | ||
| info(` resources: ${out.resources.map((r) => 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) => 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) }); | ||
| } | ||
@@ -84,9 +97,12 @@ export async function projectList(opts) { | ||
| } | ||
| export async function projectLink(id) { | ||
| export async function projectLink(id, opts = {}) { | ||
| 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) }); | ||
| } | ||
@@ -97,6 +113,8 @@ export async function projectDelete(opts) { | ||
| const res = await api.rawRequest('DELETE', `/projects/${projectId}`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
| if (opts.json) | ||
| return printJson({ ok: true, projectId }); | ||
| info(`deleted project ${projectId}`); | ||
| } | ||
| //# sourceMappingURL=project.js.map |
@@ -7,3 +7,3 @@ // `insta run -- <cmd>` — the Railway model for credentials: fetch the branch's secret bundle | ||
| import { ApiClient, requireProject } from '../api.js'; | ||
| import { die, info } from '../util.js'; | ||
| import { die, handleApproval } from '../util.js'; | ||
| /** Core, dependency-injected for tests: spawn cmd with the bundle in env, return its exit code. */ | ||
@@ -32,6 +32,8 @@ export async function runWithSecrets(cmd, args, deps) { | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets?branch=${encodeURIComponent(branch)}`); | ||
| if (res.status === 202) { | ||
| die(`secrets.read requires approval — run: insta approvals approve ${res.body.approvalId}, then re-run`); | ||
| } | ||
| info(`running with ${Object.keys(res.body.secrets).length} injected secrets (branch ${branch}) — nothing written to disk`); | ||
| // exit() with no argument honors the exit code handleApproval just set (2). | ||
| if (handleApproval(res)) | ||
| process.exit(); | ||
| // 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; | ||
@@ -38,0 +40,0 @@ }, |
@@ -15,3 +15,3 @@ import { writeFile } from 'node:fs/promises'; | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets${q(branch)}`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -51,3 +51,3 @@ const bundle = res.body.secrets; | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -73,3 +73,3 @@ const tree = res.body; | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -108,4 +108,6 @@ const tree = res.body; | ||
| const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}`, payload); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
| if (opts.json) | ||
| return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null }); | ||
| info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`); | ||
@@ -118,4 +120,6 @@ } | ||
| const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}${qs}`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
| if (opts.json) | ||
| return printJson({ ok: true, name, branch: opts.branch ?? null }); | ||
| info(`unset ${name} (${opts.branch ? `branch ${opts.branch}` : 'project-wide'})`); | ||
@@ -135,3 +139,3 @@ } | ||
| }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -149,3 +153,3 @@ if (opts.json) | ||
| const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.from)}`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -163,3 +167,3 @@ if (opts.json) | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-bindings?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.target)}`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -179,3 +183,3 @@ const bindings = res.body.bindings ?? []; | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-sources?branch=${encodeURIComponent(branch)}`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -182,0 +186,0 @@ const sources = res.body.sources ?? []; |
@@ -114,3 +114,3 @@ // `insta services` — manage a project's opt-in services (postgres | storage | compute | redis | mysql | mongodb). | ||
| const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts)); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -155,4 +155,6 @@ if (opts.json) | ||
| const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${id}`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
| if (opts.json) | ||
| return printJson({ ok: true, removed: { id, type, name, branch: branch ?? null } }); | ||
| info(`removed ${type} service ${name} from ${branch ?? 'default'}`); | ||
@@ -169,3 +171,3 @@ } | ||
| const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/rename`, { name: newName }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -193,3 +195,3 @@ if (opts.json) | ||
| const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/access`, { public: isPublic }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, _opts.json)) | ||
| return; | ||
@@ -209,3 +211,3 @@ if (_opts.json) | ||
| const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/scale`, { machineCount, region }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, _opts.json)) | ||
| return; | ||
@@ -224,3 +226,3 @@ if (_opts.json) | ||
| const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/upgrade`, { spec }); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, _opts.json)) | ||
| return; | ||
@@ -239,3 +241,3 @@ if (_opts.json) | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/services/${id}/secrets`); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -242,0 +244,0 @@ const { secrets } = res.body; |
+164
-3
@@ -9,2 +9,4 @@ // `insta setup agent` — make this machine's coding agents InstaCloud-native in one step | ||
| import { spawn } from 'node:child_process'; | ||
| import { existsSync, readFileSync, statSync } from 'node:fs'; | ||
| import { dirname, join } from 'node:path'; | ||
| import os from 'node:os'; | ||
@@ -16,2 +18,3 @@ import { ApiClient } from '../api.js'; | ||
| import { installAgentConfigs } from './mcp.js'; | ||
| import { detectChannel } from './upgrade.js'; | ||
| // The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an | ||
@@ -81,2 +84,146 @@ // "Installing to all N agents" banner, a full N-line install-path box, and a third-party | ||
| } | ||
| // ---- CLI self-install (makes `npx -y insta setup agent` a complete one-liner) ---- | ||
| // Under npx the CLI runs from the npm cache and vanishes when the process exits — but the skill | ||
| // installed below tells every agent to run `insta …`, which then wouldn't exist. So when this | ||
| // process came from the npm channel and no DURABLE `insta` is on PATH, install ourselves | ||
| // globally first. The scan must ignore any PATH entry under a node_modules directory: npx | ||
| // prepends its cache's node_modules/.bin (where this very process's `insta` shim lives), while | ||
| // durable installs (npm -g bin, nvm/volta/fnm, the native binary's ~/.insta/bin) never sit | ||
| // under one. | ||
| // On POSIX a PATH hit only counts if it would actually run: a plain non-executable file (or a | ||
| // directory) named `insta` must not suppress the self-install. Mode bits, not access(X_OK) — | ||
| // access() answers "can THIS process exec it", which for root is always yes, so a root-run | ||
| // setup would wrongly treat a non-executable file as a durable install. On Windows execute | ||
| // permission is extension-driven, so existence of a regular file is the right check. | ||
| const isRunnableFile = (p, win) => { | ||
| try { | ||
| const st = statSync(p); | ||
| if (!st.isFile()) | ||
| return false; | ||
| return win || (st.mode & 0o111) !== 0; | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| }; | ||
| /** Resolve a bare command name to its absolute PATH location (PATHEXT-aware on Windows). | ||
| * cmd.exe searches the CURRENT DIRECTORY before PATH for bare names, so handing it a bare | ||
| * `claude` would let a claude.cmd planted in the project directory shadow the real CLI — | ||
| * the cmd.exe wrapper below only ever passes absolute paths. */ | ||
| export function whichOnPath(bin, env = process.env, platform = process.platform) { | ||
| const win = platform === 'win32'; | ||
| const exts = win ? [...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';'), ''] : ['']; | ||
| for (const dir of (env.PATH ?? '').split(win ? ';' : ':')) { | ||
| if (!dir) | ||
| continue; | ||
| for (const ext of exts) { | ||
| const p = join(dir, bin + ext); | ||
| if (isRunnableFile(p, win)) | ||
| return p; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| export function findDurableOnPath(bin, env = process.env, platform = process.platform) { | ||
| const win = platform === 'win32'; | ||
| const dirs = (env.PATH ?? '').split(win ? ';' : ':'); | ||
| // npm on Windows writes insta.cmd/insta.ps1 plus an extensionless sh shim; PATHEXT covers the | ||
| // former, the bare name the latter. | ||
| const exts = win ? [...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';'), ''] : ['']; | ||
| for (const dir of dirs) { | ||
| // Case-insensitive: Windows paths (and the npx cache) may carry any casing. | ||
| if (!dir || dir.toLowerCase().includes('node_modules')) | ||
| continue; | ||
| for (const ext of exts) | ||
| if (isRunnableFile(join(dir, bin + ext), win)) | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| const cliVersion = () => { | ||
| try { | ||
| return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version; | ||
| } | ||
| catch { | ||
| return 'latest'; | ||
| } | ||
| }; | ||
| /** Best-effort: a failed global install must not block the skill/MCP setup below — the npx run | ||
| * itself still completes the agent onboarding, and the manual fallback is one line. */ | ||
| export async function ensureCliInstalled(run, channel = detectChannel(), onPath = findDurableOnPath('insta'), recheck = () => findDurableOnPath('insta')) { | ||
| if (channel !== 'npm' || onPath) | ||
| return; | ||
| info('installing the insta CLI globally (npm) …'); | ||
| // Pinned to THIS version so the one-liner installs exactly what it ran. The logical `npm` is | ||
| // resolved to a spawnable invocation ONCE, inside the default runner (resolveSpawnable) — | ||
| // handing it a pre-resolved node/npm-cli.js path here would make the runner resolve it a | ||
| // second time and, on Windows, wrap the real node.exe in cmd.exe. | ||
| const spec = `insta@${cliVersion()}`; | ||
| const res = await run('npm', ['install', '-g', spec]); | ||
| if (res.ok) { | ||
| // A clean `npm i -g` can still land in a bin dir that isn't on PATH (custom npm prefix) — | ||
| // exactly the machines this path exists for. Only claim success after re-finding the shim. | ||
| if (recheck()) { | ||
| info('✓ insta CLI — installed globally (`insta` now works in any shell)'); | ||
| } | ||
| else { | ||
| info('✓ insta CLI — installed globally, but npm\'s global bin dir is not on PATH'); | ||
| info(' add it to PATH (POSIX: `$(npm prefix -g)/bin`; Windows: the dir `npm prefix -g` prints), then verify with `insta --version`'); | ||
| } | ||
| return; | ||
| } | ||
| info(' global CLI install failed — continuing with agent setup; install manually with:'); | ||
| info(` npm install -g ${spec}`); | ||
| if (/EACCES|permission denied/i.test(res.output ?? '')) { | ||
| info(' (permission error: the npm prefix is system-owned — use a Node version manager, or elevate that one command)'); | ||
| } | ||
| } | ||
| // ---- Windows-safe spawning for npm/npx ---- | ||
| // On Windows `npm`/`npx` are .cmd shims, which spawn() without a shell refuses (Node docs: | ||
| // spawning .bat/.cmd needs a shell or cmd.exe). Rather than a shell (argument-quoting hazards), | ||
| // re-enter them as node scripts: the CLI script named by npm_execpath (swapped between | ||
| // npm-cli.js and npx-cli.js as needed), else the one shipped beside the running node, else the | ||
| // bare name (POSIX, where PATH shims resolve fine). Applied ONCE, inside the default runner, | ||
| // so every `run('npm'|'npx', …)` call site benefits and nothing is ever resolved twice. | ||
| export function resolveSpawnable(cmd, args, npmExecpath = process.env.npm_execpath, execPath = process.execPath, platform = process.platform, env = process.env) { | ||
| // Node re-entry is only valid when THIS process runs on node. On the native-binary channel | ||
| // execPath is the compiled `insta` executable — and npm scripts export npm_execpath to their | ||
| // children — so re-entering blindly would spawn `insta npx-cli.js …`. A non-node execPath | ||
| // sends npm/npx down the generic shim path below instead. | ||
| const execIsNode = /(^|[\\/])node(\.exe)?$/i.test(execPath); | ||
| if ((cmd === 'npm' || cmd === 'npx') && execIsNode) { | ||
| if (npmExecpath && /(^|[\\/])np[mx](-cli)?\.[cm]?js$/.test(npmExecpath)) { | ||
| const cli = npmExecpath.replace(/np[mx](-cli)?(\.[cm]?js)$/, `${cmd}$1$2`); | ||
| if (existsSync(cli)) | ||
| return { cmd: execPath, args: [cli, ...args] }; | ||
| } | ||
| const nodeDir = dirname(execPath); | ||
| const besideNode = platform === 'win32' | ||
| ? join(nodeDir, 'node_modules', 'npm', 'bin', `${cmd}-cli.js`) | ||
| : join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', `${cmd}-cli.js`); | ||
| if (existsSync(besideNode)) | ||
| return { cmd: execPath, args: [besideNode, ...args] }; | ||
| } | ||
| // Generic shim path — every non-npm CLI we shell out to (claude), plus npm/npx themselves | ||
| // when node isn't resolvable (native binary channel). On Windows these are .cmd shims, which | ||
| // spawn() refuses without a shell, so route them through cmd.exe. Guards, in order: | ||
| // - BARE names only: an absolute path or anything .exe (node.exe from a resolved npm/npx | ||
| // invocation passing back through here) is directly spawnable and must NOT see cmd.exe. | ||
| // - The name is resolved to its ABSOLUTE PATH location first: cmd.exe searches the current | ||
| // directory before PATH, so a bare name would let a shim planted in the project dir | ||
| // shadow the real CLI. No PATH hit → pass through (spawn fails; callers degrade). | ||
| // - No manual quoting: libuv already wraps spaced args when building the child command | ||
| // line — pre-quoting would be quoted AGAIN and arrive as literal quote characters. | ||
| // - That leaves cmd.exe metacharacters unprotectable, so an arg carrying one (e.g. a | ||
| // custom INSTA_MCP_URL with `&`) skips the wrapper: the bare-shim spawn fails and every | ||
| // caller degrades gracefully (probe → not-installed; registration → manual-add | ||
| // fallback). Never hand metacharacters to a shell. | ||
| const bareShim = !/[\\/]/.test(cmd) && !/\.exe$/i.test(cmd); | ||
| if (platform === 'win32' && bareShim && !args.some((a) => /[&|<>^%"]/.test(a))) { | ||
| const abs = whichOnPath(cmd, env, platform); | ||
| if (abs) | ||
| return { cmd: 'cmd.exe', args: ['/d', '/s', '/c', abs, ...args] }; | ||
| } | ||
| return { cmd, args }; | ||
| } | ||
| // Capture stdout+stderr silently (don't stream) so we can print our own clean summary. | ||
@@ -87,4 +234,12 @@ // stdin is 'ignore', NOT 'inherit': under the canonical `curl … | sh` install, stdin is the | ||
| // guidance. Ignoring stdin keeps the installer's own output intact. (-y means no prompt anyway.) | ||
| const defaultRunner = (cmd, args) => new Promise((resolve) => { | ||
| const defaultRunner = (cmdIn, argsIn) => new Promise((resolve) => { | ||
| const { cmd, args } = resolveSpawnable(cmdIn, argsIn); | ||
| const env = { ...process.env, AI_AGENT: process.env.AI_AGENT || 'insta', FORCE_COLOR: '0' }; | ||
| // When THIS process was launched by npx, npx exports its flags as npm_config_* env vars. | ||
| // npm_config_package pins package resolution for every nested npm/npx child — the inner | ||
| // `npx -y skills …` would then resolve `skills` against the insta package and degrade to | ||
| // `sh: skills: command not found`. Scrub the resolution-pinning vars; keep prefix/registry | ||
| // (deliberate user configuration). | ||
| delete env.npm_config_package; | ||
| delete env.npm_config_call; | ||
| const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], env }); | ||
@@ -98,2 +253,5 @@ let output = ''; | ||
| }); | ||
| // Leading -y is npx's OWN flag: on a machine where the `skills` package isn't already in the | ||
| // npx cache, non-TTY `npx skills …` refuses to auto-install and degrades to a shell lookup | ||
| // (`sh: skills: command not found`) — the trailing -y only answers the skills TOOL's prompt. | ||
| // -g = user-level (machine-global); -a '*' = every agent dir the skills tool supports | ||
@@ -103,3 +261,3 @@ // (Claude Code, Codex, Cursor, OpenCode, Copilot, …); --copy = real files, not cache symlinks. | ||
| // staging install reads the staging skill text rather than what's published on main. | ||
| export const setupArgs = (spec) => ['skills', 'add', spec, '-s', 'insta', '-a', '*', '-g', '-y', '--copy']; | ||
| export const setupArgs = (spec) => ['-y', 'skills', 'add', spec, '-s', 'insta', '-a', '*', '-g', '-y', '--copy']; | ||
| /** Production's args. Kept as a named export because it is the installed-base default and is | ||
@@ -167,6 +325,9 @@ * asserted directly by tests; runtime goes through `setupArgs(resolveEnv().skills)`. */ | ||
| } | ||
| export async function setupAgent(opts, run = defaultRunner, mint, installConfigs = installAgentConfigs) { | ||
| export async function setupAgent(opts, run = defaultRunner, mint, installConfigs = installAgentConfigs, ensure = (r) => ensureCliInstalled(r)) { | ||
| if (!opts.yes && !process.stdout.isTTY) { | ||
| info('non-interactive shell — assuming -y'); | ||
| } | ||
| // BEFORE the skill install: the skill tells agents to run `insta …`, so a durable CLI must | ||
| // exist by the time it lands. | ||
| await ensure(run); | ||
| // One resolve for the whole step, so the skills and the MCP registration below cannot disagree | ||
@@ -173,0 +334,0 @@ // about which environment this machine belongs to. |
@@ -53,3 +53,3 @@ // `insta storage` — browse, download, and delete the objects in a storage service's bucket. | ||
| const res = await api.rawRequest('GET', objectsPath(p.projectId, svc.id, { branch, prefix: opts.prefix, cursor: opts.cursor, limit })); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -147,3 +147,3 @@ if (opts.json) | ||
| const res = await api.rawRequest('GET', objectDownloadPath(p.projectId, svc.id, { branch, key })); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -169,3 +169,3 @@ // --json hands over the presigned URL instead of downloading, as `insta secrets --json` does. | ||
| const res = await api.rawRequest('DELETE', objectsPath(p.projectId, svc.id, { branch, key })); | ||
| if (handleApproval(res)) | ||
| if (handleApproval(res, opts.json)) | ||
| return; | ||
@@ -172,0 +172,0 @@ if (opts.json) |
@@ -12,2 +12,3 @@ // Install the related agent skills into a linked project so the developer's coding agent has | ||
| import { resolveEnv } from './config.js'; | ||
| import { resolveSpawnable } from './commands/setup.js'; | ||
| import { DEFAULT_ENV, ENVS } from './env.js'; | ||
@@ -24,4 +25,11 @@ // Where `npx skills add` drops skills for the agents we pin below: Claude Code → .claude/skills/, | ||
| // existing AI_AGENT (e.g. running inside another agent) rather than clobbering it. | ||
| const defaultRunner = (cmd, args, inherit = false) => new Promise((resolve) => { | ||
| const defaultRunner = (cmdIn, argsIn, inherit = false) => new Promise((resolve) => { | ||
| // resolveSpawnable: on Windows `npx` is a .cmd shim spawn() refuses without a shell — | ||
| // re-enter npm's CLI script via node instead (same treatment as `insta setup agent`). | ||
| const { cmd, args } = resolveSpawnable(cmdIn, argsIn); | ||
| const env = { ...process.env, AI_AGENT: process.env.AI_AGENT || 'insta' }; | ||
| // npx exports its flags as npm_config_* to children; npm_config_package would pin the inner | ||
| // `npx -y skills …` to whatever package launched this CLI (see setup.ts defaultRunner). | ||
| delete env.npm_config_package; | ||
| delete env.npm_config_call; | ||
| const p = spawn(cmd, args, { stdio: inherit ? 'inherit' : 'ignore', env }); | ||
@@ -36,2 +44,6 @@ p.on('error', () => resolve({ ok: false })); // e.g. npx not on PATH | ||
| const AGENT_FLAGS = ['-a', 'claude-code', '-a', 'codex', '-y', '--copy']; | ||
| // npx's OWN -y, distinct from the skills tool's -y above: without it, a machine whose npx cache | ||
| // lacks the `skills` package refuses the auto-install in non-TTY runs and the whole command | ||
| // degrades to `sh: skills: command not found`. | ||
| const NPX_YES = ['-y']; | ||
| // `instaSpec` is the insta skill source for the resolved environment (`owner/repo[@ref]`), so a | ||
@@ -41,4 +53,4 @@ // project created against staging gets the staging skill text. The third-party stack skills are | ||
| const skillTargets = (instaSpec) => [ | ||
| { label: 'insta', args: ['skills', 'add', instaSpec, '-s', 'insta', ...AGENT_FLAGS] }, | ||
| { label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills', | ||
| { label: 'insta', args: [...NPX_YES, 'skills', 'add', instaSpec, '-s', 'insta', ...AGENT_FLAGS] }, | ||
| { label: 'tigris', args: [...NPX_YES, 'skills', 'add', 'tigrisdata/skills', | ||
| '-s', 'tigris-object-operations', '-s', 'file-storage', '-s', 'tigris-sdk-guide', | ||
@@ -48,3 +60,3 @@ '-s', 'tigris-security-access-control', '-s', 'tigris-image-optimization', | ||
| ...AGENT_FLAGS] }, | ||
| { label: 'better-auth', args: ['skills', 'add', 'better-auth/skills', | ||
| { label: 'better-auth', args: [...NPX_YES, 'skills', 'add', 'better-auth/skills', | ||
| '-s', 'better-auth-best-practices', '-s', 'email-and-password-best-practices', | ||
@@ -51,0 +63,0 @@ '-s', 'better-auth-security-best-practices', ...AGENT_FLAGS] }, |
+25
-18
@@ -7,13 +7,17 @@ // Build a source directory into an image and push it to Fly's registry, using a short-lived, | ||
| import { join } from 'node:path'; | ||
| import { info } from './util.js'; | ||
| // Spawn flyctl, tee its output to the user (so they see buildkit progress) AND capture it for digest | ||
| // parsing. | ||
| export const defaultBuildRunner = (cmd, args, opts) => new Promise((resolve) => { | ||
| const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['inherit', 'pipe', 'pipe'] }); | ||
| let output = ''; | ||
| child.stdout?.on('data', (b) => { const s = b.toString(); output += s; process.stdout.write(s); }); | ||
| child.stderr?.on('data', (b) => { const s = b.toString(); output += s; process.stderr.write(s); }); | ||
| child.on('error', (err) => resolve({ code: -1, output: `${output}\n${err.message}` })); | ||
| child.on('close', (code) => resolve({ code: code ?? -1, output })); | ||
| }); | ||
| // parsing. `to` picks the tee destination for the child's stdout: `deploy --json` reserves the | ||
| // process's stdout for the final JSON document, so it tees build progress to stderr instead. | ||
| function teeRunner(to) { | ||
| return (cmd, args, opts) => new Promise((resolve) => { | ||
| const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['inherit', 'pipe', 'pipe'] }); | ||
| let output = ''; | ||
| child.stdout?.on('data', (b) => { const s = b.toString(); output += s; to.write(s); }); | ||
| child.stderr?.on('data', (b) => { const s = b.toString(); output += s; process.stderr.write(s); }); | ||
| child.on('error', (err) => resolve({ code: -1, output: `${output}\n${err.message}` })); | ||
| child.on('close', (code) => resolve({ code: code ?? -1, output })); | ||
| }); | ||
| } | ||
| export const defaultBuildRunner = teeRunner(process.stdout); | ||
| export const stderrBuildRunner = teeRunner(process.stderr); | ||
| // buildkit prints "pushing manifest for registry.fly.io/<app>:<label>@sha256:<digest>" on push. | ||
@@ -61,6 +65,9 @@ // Pin to the digest — the bare tag races on Fly's registry (MANIFEST_UNKNOWN); the digest always resolves. | ||
| // Best-effort: ensure the fly CLI is available (needed for source-directory deploys). Never blocks — | ||
| // if it can't be installed the subsequent build surfaces a clear error. | ||
| // if it can't be installed the subsequent build surfaces a clear error. Everything here is one-time | ||
| // bootstrap DIAGNOSTICS, so it all goes to stderr — including the installers' own stdout (fd 2 in | ||
| // the stdio triple) — keeping stdout clean for `deploy --json`. | ||
| export async function ensureFlyctl() { | ||
| const note = (m) => process.stderr.write(m + '\n'); | ||
| const ok = (cmd, args, inherit = false) => new Promise((resolve) => { | ||
| const p = spawn(cmd, args, { stdio: inherit ? 'inherit' : 'ignore' }); | ||
| const p = spawn(cmd, args, { stdio: inherit ? ['inherit', 2, 2] : 'ignore' }); | ||
| p.on('error', () => resolve(false)); | ||
@@ -73,4 +80,4 @@ p.on('close', (code) => resolve(code === 0)); | ||
| if (process.platform === 'darwin' && (await ok('brew', ['--version']))) { | ||
| info('flyctl not found — installing with `brew install flyctl` (one-time)…'); | ||
| info((await ok('brew', ['install', 'flyctl'], true)) ? 'flyctl installed ✓' : 'flyctl install failed — install manually: https://fly.io/docs/flyctl/install/'); | ||
| note('flyctl not found — installing with `brew install flyctl` (one-time)…'); | ||
| note((await ok('brew', ['install', 'flyctl'], true)) ? 'flyctl installed ✓' : 'flyctl install failed — install manually: https://fly.io/docs/flyctl/install/'); | ||
| return; | ||
@@ -83,3 +90,3 @@ } | ||
| // own PATH because the installer's shell-profile edit can't reach an already-running process. | ||
| info('flyctl not found — installing to ~/.fly (one-time)…'); | ||
| note('flyctl not found — installing to ~/.fly (one-time)…'); | ||
| const flyHome = `${process.env.HOME ?? '~'}/.fly`; | ||
@@ -90,10 +97,10 @@ const installed = await ok('sh', ['-c', `curl -fsSL https://fly.io/install.sh | FLYCTL_INSTALL="${flyHome}" sh`], true); | ||
| if (await ok('flyctl', ['version'])) { | ||
| info('flyctl installed ✓'); | ||
| note('flyctl installed ✓'); | ||
| return; | ||
| } | ||
| } | ||
| info('flyctl install failed — install manually: https://fly.io/docs/flyctl/install/'); | ||
| note('flyctl install failed — install manually: https://fly.io/docs/flyctl/install/'); | ||
| return; | ||
| } | ||
| info('flyctl (fly CLI) not found — install it to deploy from source: https://fly.io/docs/flyctl/install/'); | ||
| note('flyctl (fly CLI) not found — install it to deploy from source: https://fly.io/docs/flyctl/install/'); | ||
| } | ||
@@ -100,0 +107,0 @@ catch { /* best-effort convenience */ } |
+21
-17
@@ -77,3 +77,3 @@ #!/usr/bin/env node | ||
| envCmd.command('use <name>').description(`Switch environment (${ENV_NAMES.join(' | ')}) — drops the stored session, which is deployment-specific`) | ||
| .action(guard((name) => envCmd_.envUse(name))); | ||
| .option('--json').action(guard((name, o) => envCmd_.envUse(name, o))); | ||
| // ---- run (per-request secret injection — nothing written to disk) ---- | ||
@@ -86,3 +86,3 @@ program.command('run <cmd> [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)') | ||
| const setupCmd = program.command('setup').description('Set up this machine for InstaCloud agent workflows'); | ||
| setupCmd.command('agent').description('Install the insta skill user-globally for all coding agents') | ||
| setupCmd.command('agent').description('Install the insta CLI (if missing), the insta skill for all coding agents, and the MCP server') | ||
| .option('-y, --yes', 'non-interactive') | ||
@@ -100,17 +100,17 @@ .option('--mcp-token', 'register the MCP server with a minted insta_ API token instead of OAuth (headless machines / CI)') | ||
| orgCmd.command('list').option('--json').action(guard((o) => org.orgList(o))); | ||
| orgCmd.command('create <name>').action(guard((name) => org.orgCreate(name))); | ||
| orgCmd.command('create <name>').option('--json').action(guard((name, o) => org.orgCreate(name, o))); | ||
| // ---- project ---- | ||
| const pj = program.command('project').description('Manage projects'); | ||
| pj.command('create [name]').option('--org <id>', 'org to create under (default: personal)').action(guard((name, o) => project.projectCreate(name, o))); | ||
| pj.command('create [name]').option('--org <id>', 'org to create under (default: personal)').option('--json').action(guard((name, o) => project.projectCreate(name, o))); | ||
| pj.command('list').option('--org <id>').option('--json').action(guard((o) => project.projectList(o))); | ||
| pj.command('link <id>').description('Link a project to this directory').action(guard((id) => project.projectLink(id))); | ||
| pj.command('delete').option('--project <id>').action(guard((o) => project.projectDelete(o))); | ||
| pj.command('link <id>').description('Link a project to this directory').option('--json').action(guard((id, o) => project.projectLink(id, o))); | ||
| pj.command('delete').option('--project <id>').option('--json').action(guard((o) => project.projectDelete(o))); | ||
| // ---- branch ---- | ||
| const br = program.command('branch').description('Manage branch environments'); | ||
| br.command('create <name>').option('--from <branch>', 'parent branch (default: current)').action(guard((name, o) => branch.branchCreate(name, o))); | ||
| br.command('create <name>').option('--from <branch>', 'parent branch (default: current)').option('--json').action(guard((name, o) => branch.branchCreate(name, o))); | ||
| br.command('list').option('--json').action(guard((o) => branch.branchList(o))); | ||
| br.command('switch <name>').action(guard((name) => branch.branchSwitch(name))); | ||
| br.command('delete <name>').action(guard((name) => branch.branchDelete(name))); | ||
| br.command('switch <name>').option('--json').action(guard((name, o) => branch.branchSwitch(name, o))); | ||
| br.command('delete <name>').option('--json').action(guard((name, o) => branch.branchDelete(name, o))); | ||
| br.command('merge <source>').description('Merge a branch service set into another (structural, no data)') | ||
| .option('--into <branch>', 'target branch (default: current)').action(guard((source, o) => branch.branchMerge(source, o))); | ||
| .option('--into <branch>', 'target branch (default: current)').option('--json').action(guard((source, o) => branch.branchMerge(source, o))); | ||
| // ---- services (opt-in postgres/storage/compute/redis/mysql/mongodb) ---- | ||
@@ -137,3 +137,3 @@ const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis|mysql|mongodb)'); | ||
| svc.command('remove <type> <name>').description('Remove a service and destroy its resources') | ||
| .option('--branch <branch>', 'branch (default: current)') | ||
| .option('--branch <branch>', 'branch (default: current)').option('--json') | ||
| .action(guard((type, name, o) => services.servicesRemove(type, name, o))); | ||
@@ -158,5 +158,5 @@ svc.command('rename <type> <name> <new-name>').description('Rename a service and re-key its managed secret names') | ||
| .option('--branch <branch>', 'scope to one branch').option('--service <type/name>', 'bind to a branch service (implies current branch)') | ||
| .action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o))); | ||
| .option('--json').action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o))); | ||
| sec.command('unset <name>').description('Remove a user secret') | ||
| .option('--branch <branch>', 'scope to one branch').action(guard((n, o) => secretsCmd.secretsUnset(n, o))); | ||
| .option('--branch <branch>', 'scope to one branch').option('--json').action(guard((n, o) => secretsCmd.secretsUnset(n, o))); | ||
| sec.command('bind <env-name> <source>').description('Bind a service credential into a compute env var') | ||
@@ -194,2 +194,3 @@ .option('--branch <branch>', 'branch (default: current)') | ||
| .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)') | ||
| .option('--json', 'print the deploy result as JSON (build progress goes to stderr)') | ||
| .action(guard((dir, o) => deploy(dir, o))); | ||
@@ -207,3 +208,3 @@ // `insta compute exec` needs the command verbatim after a literal `--`; split it out of argv here, | ||
| 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))); | ||
| .option('--branch <b>').option('--group <g>').option('--json').action(guard((host, o) => computeCmd.removeDomain(host, o))); | ||
| compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)') | ||
@@ -274,2 +275,5 @@ .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o))); | ||
| .option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--deploy', 'show deploy events (machine lifecycle) instead of runtime logs — Fly-backed targets only, not db').option('--json') | ||
| .option('--from <t>', 'window start: unix seconds or ISO-8601 — pages history (~7-day retention); without a window one recent provider page (~100 lines) is returned') | ||
| .option('--to <t>', 'window end: unix seconds or ISO-8601 (default: now)') | ||
| .option('--since <dur>', 'relative window start, e.g. 90s, 30m, 2h, 1d (shorthand for --from now-dur)') | ||
| .action(guard((target, group, o) => obs.logs(target, group, o))); | ||
@@ -293,4 +297,4 @@ program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)') | ||
| ap.command('list').option('--status <s>', 'pending|granted|denied|consumed').option('--json').action(guard((o) => govern.approvalsList(o))); | ||
| ap.command('approve <id>').option('--always', 'also set the policy to allow').action(guard((id, o) => govern.approvalsApprove(id, o))); | ||
| ap.command('deny <id>').action(guard((id) => govern.approvalsDeny(id))); | ||
| ap.command('approve <id>').option('--always', 'also set the policy to allow').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o))); | ||
| ap.command('deny <id>').option('--json').action(guard((id, o) => govern.approvalsDeny(id, o))); | ||
| // ---- observe (local credential audit) ---- | ||
@@ -305,3 +309,3 @@ const ob = program.command('observe').description('Local credential-audit hook'); | ||
| pol.command('get').option('--json').action(guard((o) => govern.policyGet(o))); | ||
| pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').action(guard((a, d) => govern.policySet(a, d))); | ||
| pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').option('--json').action(guard((a, d, o) => govern.policySet(a, d, o))); | ||
| // ---- feedback (agent + human hurdle reports → the InstaCloud team) ---- | ||
@@ -308,0 +312,0 @@ program.command('feedback') |
+10
-3
@@ -29,6 +29,13 @@ // Output + small pure helpers (env serialization is unit-tested). | ||
| // If the platform gated the action (HTTP 202), tell the user how to get it approved. Returns | ||
| // true when an approval is pending (caller should stop). | ||
| export function handleApproval(res) { | ||
| // true when an approval is pending (caller should stop). The hint goes to STDERR and the exit | ||
| // code is set to 2: a pending gate is not success (redirected stdout must never swallow it as | ||
| // output), and not a plain error either (die() owns 1) — it's approvable and re-runnable, and | ||
| // scripts/agents branch on the distinct code. With json, stdout carries the platform's raw 202 | ||
| // envelope so a scripted caller can lift approvalId/action. | ||
| export function handleApproval(res, json) { | ||
| if (res.status === 202 && res.body?.status === 'approval_required') { | ||
| info(`approval required for ${res.body.action} — run: insta approvals approve ${res.body.approvalId}`); | ||
| if (json) | ||
| printJson(res.body); | ||
| process.stderr.write(`approval required for ${res.body.action} — run: insta approvals approve ${res.body.approvalId}\n`); | ||
| process.exitCode = 2; | ||
| return true; | ||
@@ -35,0 +42,0 @@ } |
+1
-1
| { | ||
| "name": "insta", | ||
| "version": "0.0.36", | ||
| "version": "0.0.37", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "description": "InstaCloud CLI — a thin client of the platform control-plane API.", |
+18
-6
@@ -29,11 +29,22 @@ # insta-cli | ||
| For coding agents. Installs the CLI, the `insta` skill for every agent on the machine, and | ||
| registers the MCP server: | ||
| registers the MCP server — one command for macOS, Linux, WSL, and native Windows shells | ||
| (PowerShell/cmd). Needs Node 18+ with a writable npm global prefix (a Node version manager | ||
| qualifies; if the global install can't write, setup continues and prints the exact | ||
| version-pinned `npm install -g` fallback to run yourself). E2e-validated on macOS/Linux; the | ||
| Windows spawn paths are unit-tested: | ||
| ```bash | ||
| npx -y insta setup agent | ||
| ``` | ||
| On macOS/Linux without Node, the native-binary installer puts the `insta` CLI on PATH (the | ||
| skill + MCP steps it then runs still need Node — the skills tool runs via npx). Never run it | ||
| on native Windows — PowerShell's `curl` alias and the WSL `bash` shim break it; use npx | ||
| above, or download `insta-windows-x64.exe` from the | ||
| [releases page](https://github.com/InsForge/insta-cli/releases): | ||
| ```bash | ||
| curl -fsSL agents.instacloud.com | sh | ||
| ``` | ||
| On Windows, download `insta-windows-x64.exe` from the | ||
| [releases page](https://github.com/InsForge/insta-cli/releases). | ||
| Pin a version with `INSTA_VERSION=v0.0.22`; change the install directory with | ||
@@ -105,3 +116,4 @@ `INSTA_INSTALL_DIR`. While the CLI is pre-1.0 it updates itself on new releases. Turn that | ||
| agent` installs the InstaCloud skill and registers the remote MCP server for the coding | ||
| agents on the machine. | ||
| agents on the machine — and, when running from the npx cache with no durable `insta` on | ||
| PATH, first installs the CLI itself globally. | ||
@@ -172,3 +184,3 @@ ## Environments | ||
| | `insta env` | `show` · `use <prod\|staging>` | | ||
| | `insta setup` | `agent` — install the skill and register MCP for every coding agent | | ||
| | `insta setup` | `agent` — install the CLI (if missing), the skill, and MCP for every coding agent | | ||
| | `insta mcp` | `install` — register the remote MCP server only | | ||
@@ -175,0 +187,0 @@ | `insta org` | `list` · `create` (one free org per user) | |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
293023
6.96%5100
6.16%235
5.38%59
11.32%