From 2c88dcd49319c935935aeb157d473094bca42f12 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 14:49:43 -0700 Subject: [PATCH 01/19] config: pickApiUrl seam with a runtime --api-url override above INSTA_API_URL Co-Authored-By: Claude Haiku 4.5 --- src/config.ts | 77 +++++++++++++++++++++-------------- test/api-url-override.test.ts | 40 ++++++++++++++++++ 2 files changed, 86 insertions(+), 31 deletions(-) create mode 100644 test/api-url-override.test.ts diff --git a/src/config.ts b/src/config.ts index 59d6e50..df3d03f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -33,41 +33,56 @@ export type ProjectConfig = { projectId: string; orgId: string; branch: string } // INSTA_API_URL wins below. const DEFAULT_API = ENVS[DEFAULT_ENV].api +// The runtime `--api-url` flag, set by index.ts's preAction hook before any action loads config. +// Process-local, never persisted: a debugging override must not rewrite the machine's login. +let cliApiUrlOverride: string | undefined +export function setApiUrlOverride(url: string | undefined): void { + cliApiUrlOverride = url +} + +/** Which control plane this process talks to, and which stored session (if any) may travel with + * it. Pure: `parsed` is the persisted file (null when absent or unreadable), `env` the process + * environment, `cliOverride` the runtime flag. Precedence, most explicit first: + * 1. --api-url (runtime flag) — this invocation only. + * 2. INSTA_API_URL — a literal URL. Overrides the persisted apiUrl, not just the default, + * otherwise the env var is silently ignored as soon as any login has written a config file. + * It also outranks INSTA_ENV: a hand-written URL is the more specific instruction, and it + * is the only way to reach a host no environment name covers (insta-oss, a preview). + * 3. INSTA_ENV — a named environment (see env.ts), resolved to its api host. + * 4. the persisted apiUrl, written by `insta login --env|--api-url` or `insta env use`. + * 5. DEFAULT_API. + * + * An override that points at a DIFFERENT deployment than the stored session was minted for must + * not carry that session along. `env use` already drops it on an explicit switch; without this, + * `INSTA_ENV=staging insta …` on a prod-logged-in machine sends prod's bearer to staging and then + * — on the 401 — POSTs prod's REFRESH token to staging's /auth/refresh (api.ts), which is the + * cross-deployment credential leak env.ts's header calls out as never allowed. In-memory only: + * the file keeps the real login, so unsetting the override restores it. A custom host (insta-oss, + * a preview) is treated the same way — its session is equally foreign. */ +export function pickApiUrl(parsed: GlobalConfig | null, env: NodeJS.ProcessEnv, cliOverride?: string): GlobalConfig { + const named = envFromEnvVar(env.INSTA_ENV) + const override = cliOverride ?? env.INSTA_API_URL ?? (named ? ENVS[named].api : undefined) + if (!parsed) return { apiUrl: override ?? DEFAULT_API } + const persisted = parsed.apiUrl ?? DEFAULT_API + if (override && normalizeUrl(override) !== normalizeUrl(persisted)) { + const scrubbed: GlobalConfig = { ...parsed, apiUrl: override } + delete scrubbed.accessToken + delete scrubbed.refreshToken + delete scrubbed.user + delete scrubbed.agentCredential + return scrubbed + } + return { ...parsed, apiUrl: override ?? persisted } +} + export async function readGlobal(): Promise { - // Precedence, most explicit first: - // 1. INSTA_API_URL — a literal URL. Overrides the persisted apiUrl, not just the default, - // otherwise the env var is silently ignored as soon as any login has written a config file. - // It also outranks INSTA_ENV: a hand-written URL is the more specific instruction, and it - // is the only way to reach a host no environment name covers (insta-oss, a preview). - // 2. INSTA_ENV — a named environment (see env.ts), resolved to its api host. - // 3. the persisted apiUrl, written by `insta login --env|--api-url` or `insta env use`. - // 4. DEFAULT_API. - const envApi = process.env.INSTA_API_URL - const named = envFromEnvVar() - const override = envApi ?? (named ? ENVS[named].api : undefined) + let parsed: GlobalConfig | null try { - const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as GlobalConfig - const persisted = parsed.apiUrl ?? DEFAULT_API - // An override that points at a DIFFERENT deployment than the stored session was minted for - // must not carry that session along. `env use` already drops it on an explicit switch; without - // this, `INSTA_ENV=staging insta …` on a prod-logged-in machine sends prod's bearer to staging - // and then — on the 401 — POSTs prod's REFRESH token to staging's /auth/refresh (api.ts), which - // is the cross-deployment credential leak env.ts's header calls out as never allowed. - // - // In-memory only: the file keeps the real login, so unsetting the override restores it. A - // custom host (insta-oss, a preview) is treated the same way — its session is equally foreign. - if (override && normalizeUrl(override) !== normalizeUrl(persisted)) { - const scrubbed: GlobalConfig = { ...parsed, apiUrl: override } - delete scrubbed.accessToken - delete scrubbed.refreshToken - delete scrubbed.user - delete scrubbed.agentCredential - return scrubbed - } - return { ...parsed, apiUrl: override ?? persisted } + parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as GlobalConfig } catch { - return { apiUrl: override ?? DEFAULT_API } + parsed = null } + return pickApiUrl(parsed, process.env, cliApiUrlOverride) } /** The environment the CLI is currently pointed at, plus everything derived from it. `env` is null diff --git a/test/api-url-override.test.ts b/test/api-url-override.test.ts new file mode 100644 index 0000000..00fc6fd --- /dev/null +++ b/test/api-url-override.test.ts @@ -0,0 +1,40 @@ +// The runtime `--api-url` flag: highest precedence, never persisted, and — like the env-var +// override before it — a URL for another deployment must not carry the stored session with it. +import { describe, expect, it } from 'vitest' +import { pickApiUrl } from '../src/config.js' + +const PROD = 'https://api.instacloud.com' +const STAGING = 'https://api.staging.instacloud.com' +const stored = { + apiUrl: PROD, accessToken: 'at', refreshToken: 'rt', + user: { id: 'u1', email: 'a@b.c', name: null }, agentCredential: true, +} + +describe('pickApiUrl', () => { + it('defaults to prod with nothing stored and nothing set', () => { + expect(pickApiUrl(null, {})).toEqual({ apiUrl: PROD }) + }) + + it('the runtime flag beats INSTA_API_URL, INSTA_ENV and the stored url', () => { + const r = pickApiUrl(stored, { INSTA_API_URL: 'https://env.example', INSTA_ENV: 'staging' }, 'http://127.0.0.1:8080') + expect(r.apiUrl).toBe('http://127.0.0.1:8080') + }) + + it('INSTA_API_URL beats INSTA_ENV, which beats the stored url', () => { + expect(pickApiUrl(stored, { INSTA_API_URL: 'https://env.example', INSTA_ENV: 'staging' }).apiUrl).toBe('https://env.example') + expect(pickApiUrl(stored, { INSTA_ENV: 'staging' }).apiUrl).toBe(STAGING) + expect(pickApiUrl(stored, {}).apiUrl).toBe(PROD) + }) + + it('a runtime flag pointing at another deployment scrubs the stored session in memory', () => { + expect(pickApiUrl(stored, {}, STAGING)).toEqual({ apiUrl: STAGING }) + }) + + it('a runtime flag equal to the stored url (trailing slash tolerated) keeps the session', () => { + expect(pickApiUrl(stored, {}, `${PROD}/`)).toEqual({ ...stored, apiUrl: `${PROD}/` }) + }) + + it('with no stored file, the flag alone decides', () => { + expect(pickApiUrl(null, { INSTA_ENV: 'staging' }, 'http://oss.local:9000')).toEqual({ apiUrl: 'http://oss.local:9000' }) + }) +}) From 19346eb5aa4f0b468d71370b044e0d869e4647e1 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 14:56:17 -0700 Subject: [PATCH 02/19] postgres: rename db.ts, every verb takes a trailing [service] instead of --group Co-Authored-By: Claude Fable 5.1 --- src/commands/{db.ts => postgres.ts} | 56 ++++++++++++++--------------- src/commands/storage.ts | 2 +- src/index.ts | 14 ++++---- test/db-stats.test.ts | 2 +- test/db-url.test.ts | 4 +-- test/limits.test.ts | 4 +-- test/volume.test.ts | 2 +- 7 files changed, 42 insertions(+), 42 deletions(-) rename src/commands/{db.ts => postgres.ts} (86%) diff --git a/src/commands/db.ts b/src/commands/postgres.ts similarity index 86% rename from src/commands/db.ts rename to src/commands/postgres.ts index c434066..334e6b7 100644 --- a/src/commands/db.ts +++ b/src/commands/postgres.ts @@ -4,25 +4,25 @@ import { ApiClient, ApiError, requireProject } from '../api.js' import { info, printJson, handleApproval, relayExitCode } from '../util.js' import { parseVolumeGib, q, resolveSoleService } from './services.js' -type Opts = { branch?: string; group?: string; json?: boolean } +type Opts = { branch?: string; json?: boolean } // Toggle a postgres service between scale-to-zero (the default: instance suspends when idle, // cold-starts on the next connection) and always-on (instance stays warm; idle RAM bills at // actual usage). Thin wrapper over PATCH /database/settings {scaleToZero} — insta-db-backed // postgres only. -export async function dbAlwaysOn(mode: string, opts: Opts): Promise { +export async function dbAlwaysOn(mode: string, service: string | undefined, opts: Opts): Promise { if (mode !== 'on' && mode !== 'off') throw new Error('mode must be on|off') const api = await ApiClient.load() const p = await requireProject() const qs = new URLSearchParams() const branch = opts.branch ?? p.branch if (branch) qs.set('branch', branch) - if (opts.group) qs.set('group', opts.group) + if (service) qs.set('group', service) const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${qs.toString() ? `?${qs}` : ''}`, { scaleToZero: mode !== 'on' }) if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) const s2z = res.body?.scaleToZero - info(`postgres ${opts.group ?? 'default'}: always-on ${s2z === false ? 'ENABLED — instance stays warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default; first connection after idle cold-starts)'}`) + info(`postgres ${service ?? 'default'}: always-on ${s2z === false ? 'ENABLED — instance stays warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default; first connection after idle cold-starts)'}`) } // Validated pass-throughs for the provider's quantity strings. The insta-db resize API takes @@ -74,19 +74,19 @@ export async function fetchDbInstance( // Show or set a postgres service's resource ceiling (insta-db-backed only). Paid plans — the // ceiling is the tier lever now that billing follows actual usage. Moves both directions: // unlike storage it is a cgroup limit, not a provisioned volume. -export async function dbLimits(opts: Opts & { cpu?: string; memory?: string }): Promise { +export async function dbLimits(service: string | undefined, opts: Opts & { cpu?: string; memory?: string }): Promise { const api = await ApiClient.load() const p = await requireProject() const qs = new URLSearchParams() const branch = opts.branch ?? p.branch if (branch) qs.set('branch', branch) - if (opts.group) qs.set('group', opts.group) + if (service) qs.set('group', service) const suffix = qs.toString() ? `?${qs}` : '' if (!opts.cpu && !opts.memory) { const read = await fetchDbInstance(api, p.projectId, suffix) if (read.kind === 'no-instance') { - info(`postgres ${opts.group ?? 'default'}: no manageable instance (this service manages its own resources)`) + info(`postgres ${service ?? 'default'}: no manageable instance (this service manages its own resources)`) return } if (opts.json) return printJson(read.body) @@ -94,10 +94,10 @@ export async function dbLimits(opts: Opts & { cpu?: string; memory?: string }): const mib = read.body?.memoryMib if (typeof cpuMilli === 'number' && typeof mib === 'number') { const cpu = cpuMilli % 1000 === 0 ? `${cpuMilli / 1000}` : `${cpuMilli}m` - info(`postgres ${opts.group ?? 'default'}: ceiling ${cpu} vCPU / ${fmtMib(mib)}`) + info(`postgres ${service ?? 'default'}: ceiling ${cpu} vCPU / ${fmtMib(mib)}`) info(' billing is actual usage — the ceiling caps what the database may burn, it is not a price') } else { - info(`postgres ${opts.group ?? 'default'}: provider reported no ceiling — set one with --cpu/--memory`) + info(`postgres ${service ?? 'default'}: provider reported no ceiling — set one with --cpu/--memory`) } return } @@ -116,7 +116,7 @@ export async function dbLimits(opts: Opts & { cpu?: string; memory?: string }): if (opts.json) return printJson(res.body) const cpu = typeof res.body?.cpuMilli === 'number' ? `${res.body.cpuMilli / 1000} vCPU` : (opts.cpu ?? 'unchanged') const mem = typeof res.body?.memoryMib === 'number' ? fmtMib(res.body.memoryMib) : (opts.memory ?? 'unchanged') - info(`postgres ${opts.group ?? 'default'}: ceiling set to ${cpu} / ${mem}`) + info(`postgres ${service ?? 'default'}: ceiling set to ${cpu} / ${mem}`) } // Bytes → human units, one decimal above KiB. Local because the metrics payload is the only @@ -163,16 +163,16 @@ export function dbStatsLines(group: string, body: any): string[] { // anywhere, and the code that handled it is retained, not live. Neon-backed: the platform read // over a direct SQL connection, so a one-shot call could wake a suspended endpoint — acceptable // for an explicit command, which is why nothing here polls. -export async function dbStats(opts: Opts): Promise { +export async function dbStats(service: string | undefined, opts: Opts): Promise { const api = await ApiClient.load() const p = await requireProject() const qs = new URLSearchParams() const branch = opts.branch ?? p.branch if (branch) qs.set('branch', branch) - if (opts.group) qs.set('group', opts.group) + if (service) qs.set('group', service) const res = await api.rawRequest('GET', `/projects/${p.projectId}/database/metrics${qs.toString() ? `?${qs}` : ''}`) if (opts.json) return printJson(res.body) - for (const line of dbStatsLines(opts.group ?? 'default', res.body)) info(line) + for (const line of dbStatsLines(service ?? 'default', res.body)) info(line) } // Render the instance's volume from a database/instance read. Pure, exported for tests. Reads the @@ -193,23 +193,23 @@ export function dbVolumeLines(group: string, body: any): string[] { // is available on every plan; growth is paid and grow-only — both gates are the backend's to // enforce, so nothing here pre-blocks: its 403/400 messages carry the upgrade hints and are wrapped // with context but kept verbatim. -export async function dbVolume(opts: Opts & { size?: string }): Promise { +export async function dbVolume(service: string | undefined, opts: Opts & { size?: string }): Promise { const api = await ApiClient.load() const p = await requireProject() const qs = new URLSearchParams() const branch = opts.branch ?? p.branch if (branch) qs.set('branch', branch) - if (opts.group) qs.set('group', opts.group) + if (service) qs.set('group', service) const suffix = qs.toString() ? `?${qs}` : '' if (!opts.size) { const read = await fetchDbInstance(api, p.projectId, suffix) if (read.kind === 'no-instance') { - info(`postgres ${opts.group ?? 'default'}: no manageable instance (this service manages its own storage)`) + info(`postgres ${service ?? 'default'}: no manageable instance (this service manages its own storage)`) return } if (opts.json) return printJson(read.body) - for (const line of dbVolumeLines(opts.group ?? 'default', read.body)) info(line) + for (const line of dbVolumeLines(service ?? 'default', read.body)) info(line) return } @@ -224,12 +224,12 @@ export async function dbVolume(opts: Opts & { size?: string }): Promise { if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) const vg = res.body?.volumeGib - info(`postgres ${opts.group ?? 'default'}: volume ${typeof vg === 'number' ? `grown to ${vg}Gi` : `set to ${sizeGib}Gi`}`) + info(`postgres ${service ?? 'default'}: volume ${typeof vg === 'number' ? `grown to ${vg}Gi` : `set to ${sizeGib}Gi`}`) } export type DbUrlResolution = { serviceName: string; url: string } -// Resolve the postgres service (sole, or --group) and its connection string. Two reads: the +// Resolve the postgres service (sole, or the named one) and its connection string. Two reads: the // branch's services list names the service; GET /services/:id/credentials (gated secrets.read) // carries the value. Provider-minted credentials are canonical within their source service // (DATABASE_URL) and deliberately absent from the general `insta secrets` bundle, so this is the @@ -244,27 +244,27 @@ export async function resolveDbUrl( }, projectId: string, branch: string | undefined, - group: string | undefined, + service: string | undefined, json?: boolean, ): Promise { const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`) - const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, 'postgres', group) + const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, 'postgres', service) const res = await api.rawRequest('GET', `/projects/${projectId}/services/${svc.id}/credentials`) if (handleApproval(res, json)) return null const url = res.body?.credentials?.DATABASE_URL if (typeof url !== 'string' || !url) { - throw new Error(`postgres ${svc.name} has no DATABASE_URL credential yet — still provisioning? (\`insta services list\` shows status)`) + throw new Error(`postgres ${svc.name} has no DATABASE_URL credential yet — still provisioning? (\`insta service list\` shows status)`) } return { serviceName: svc.name, url } } // Print the postgres connection string: the bare DSN on stdout, nothing else — pipe-friendly -// (`psql "$(insta db url)"`), like `storage get --json` keeps stdout parseable. -export async function dbUrl(opts: Opts): Promise { +// (`psql "$(insta postgres url)"`), like `storage get --json` keeps stdout parseable. +export async function dbUrl(service: string | undefined, opts: Opts): Promise { const api = await ApiClient.load() const p = await requireProject() const branch = opts.branch ?? p.branch - const r = await resolveDbUrl(api, p.projectId, branch, opts.group, opts.json) + const r = await resolveDbUrl(api, p.projectId, branch, service, opts.json) if (!r) return if (opts.json) return printJson({ service: r.serviceName, branch: branch ?? null, url: r.url }) process.stdout.write(r.url + '\n') @@ -303,7 +303,7 @@ export async function connectWithPsql(url: string, spawnImpl: typeof spawn = spa const child = spawnImpl('psql', [], { stdio: 'inherit', env }) child.on('error', (e: NodeJS.ErrnoException) => reject(e.code === 'ENOENT' - ? new Error('psql not found on PATH — install the postgres client, or print the DSN with `insta db url`') + ? new Error('psql not found on PATH — install the postgres client, or print the DSN with `insta postgres url`') : e)) // Signal death reports code null — map to the conventional 128+signo (full table from // os.constants) so the advertised exit-status passthrough holds for Ctrl-C'd/killed sessions. @@ -315,11 +315,11 @@ export async function connectWithPsql(url: string, spawnImpl: typeof spawn = spa // Open an interactive psql session on the postgres service. The DSN never touches disk or argv // history beyond the child process. Exits with psql's own exit code (agents rely on this, as // with `compute exec`). -export async function dbConnect(opts: Opts): Promise { +export async function dbConnect(service: string | undefined, opts: Opts): Promise { const api = await ApiClient.load() const p = await requireProject() const branch = opts.branch ?? p.branch - const r = await resolveDbUrl(api, p.projectId, branch, opts.group, opts.json) + const r = await resolveDbUrl(api, p.projectId, branch, service, opts.json) if (!r) return // stderr: stdout belongs to psql (the `insta run` rule). process.stderr.write(`psql → postgres/${r.serviceName}${branch ? ` (branch ${branch})` : ''} — a suspended instance wakes on connect, so the first prompt can take a few seconds\n`) diff --git a/src/commands/storage.ts b/src/commands/storage.ts index 5836ecb..8a8f10a 100644 --- a/src/commands/storage.ts +++ b/src/commands/storage.ts @@ -7,7 +7,7 @@ import { pipeline } from 'node:stream/promises' import { ApiClient, requireProject } from '../api.js' import { info, printJson, handleApproval } from '../util.js' import { q, resolveSoleService } from './services.js' -import { fmtBytes } from './db.js' // the repo's tested bytes formatter — don't grow a third copy +import { fmtBytes } from './postgres.js' // the repo's tested bytes formatter — don't grow a third copy type Common = { branch?: string; service?: string; json?: boolean } diff --git a/src/index.ts b/src/index.ts index 6a34577..6eb3ea6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ import { deploy } from './commands/deploy.js' import { build } from './commands/build.js' import * as computeCmd from './commands/compute.js' import * as githubCmd from './commands/github.js' -import * as dbCmd from './commands/db.js' +import * as dbCmd from './commands/postgres.js' import * as dbQueryCmd from './commands/db-query.js' import * as storageCmd from './commands/storage.js' import { manifest } from './commands/manifest.js' @@ -300,24 +300,24 @@ compute.command('volume [service]').description("Show, attach, grow, or delete a const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero) + managed-DB query (mysql/redis/mongodb)') db.command('url').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta db url)"` (gated: secrets.read). Provider credentials are not in `insta secrets` — this is the command that yields the DSN') .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbUrl(o))) + .action(guard((o) => dbCmd.dbUrl(o.group, o))) db.command('connect').description("Open an interactive psql session on the postgres service (needs psql on PATH; gated: secrets.read). A suspended instance wakes on connect — the first prompt can take a few seconds. Exits with psql's own exit code") .option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbConnect(o))) + .action(guard((o) => dbCmd.dbConnect(o.group, o))) db.command('limits').description("Show or set a postgres service's resource ceiling (any plan within the free cap, paid above it; insta-db-backed only). Moves both directions") .option('--cpu ', "vCPU ceiling, e.g. 2 or 2500m").option('--memory ', "memory ceiling, e.g. 4Gi") .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbLimits(o))) + .action(guard((o) => dbCmd.dbLimits(o.group, o))) db.command('stats').description("Postgres stats snapshot: connections vs the server's max (active count), cache hit rate, database size. insta-db-backed services answer without waking a suspended instance") .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbStats(o))) + .action(guard((o) => dbCmd.dbStats(o.group, o))) db.command('always-on ').description('Set a postgres service always-on (mode: on|off). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only') .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((mode, o) => dbCmd.dbAlwaysOn(mode, o))) + .action(guard((mode, o) => dbCmd.dbAlwaysOn(mode, o.group, o))) db.command('volume').description("Show or grow a postgres service's provisioned volume (block disk; insta-db-backed only). No --size: print size and the plan cap (any plan). --size grows it (paid plans; grow-only — a provisioned disk cannot shrink). Billing is actual data stored — the size is a cap, not a price") .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbVolume(o))) + .action(guard((o) => dbCmd.dbVolume(o.group, o))) db.command('query [args...]').description('Run a query/command against a managed database (mysql/redis/mongodb) via the console exec API. mysql/mongodb take one quoted statement; redis takes a pre-tokenized argv (e.g. `GET mykey`). Not for postgres — use `insta db url|connect` / the SQL editor') .option('--database ', 'mongodb only — the database to run against (default admin)') .option('--branch ', 'branch (default: current)') diff --git a/test/db-stats.test.ts b/test/db-stats.test.ts index 86a54eb..5fb0143 100644 --- a/test/db-stats.test.ts +++ b/test/db-stats.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { dbStatsLines, fmtBytes } from '../src/commands/db.js' +import { dbStatsLines, fmtBytes } from '../src/commands/postgres.js' describe('dbStatsLines', () => { it('renders the measured block: x / y with active count, cache hit percent, size', () => { diff --git a/test/db-url.test.ts b/test/db-url.test.ts index 5945cf3..fa5a7f6 100644 --- a/test/db-url.test.ts +++ b/test/db-url.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { describe, expect, it } from 'vitest' -import { connectWithPsql, psqlEnvFromUrl, resolveDbUrl } from '../src/commands/db.js' +import { connectWithPsql, psqlEnvFromUrl, resolveDbUrl } from '../src/commands/postgres.js' function stubApi(services: Array<{ id: string; type: string; name: string }>, credentials: Record, status = 200) { const paths: string[] = [] @@ -127,6 +127,6 @@ describe('connectWithPsql', () => { queueMicrotask(() => child.emit('error', Object.assign(new Error('spawn psql ENOENT'), { code: 'ENOENT' }))) return child }) as any) - await expect(code).rejects.toThrow(/psql not found on PATH.*insta db url/) + await expect(code).rejects.toThrow(/psql not found on PATH.*insta postgres url/) }) }) diff --git a/test/limits.test.ts b/test/limits.test.ts index fe04b9f..8590359 100644 --- a/test/limits.test.ts +++ b/test/limits.test.ts @@ -39,9 +39,9 @@ describe('parseMemoryMb', () => { // Review round 2: both jwfing and cubic independently flagged the bare Number() on --cpu (NaN // serializes to null on the wire) and the unvalidated db strings. These pin the new seams. import { parseCpu, fmtMb } from '../src/commands/compute.js' -import { fetchDbInstance } from '../src/commands/db.js' +import { fetchDbInstance } from '../src/commands/postgres.js' import { ApiError } from '../src/api.js' -import { parseDbCpu, parseDbMemory, fmtMib } from '../src/commands/db.js' +import { parseDbCpu, parseDbMemory, fmtMib } from '../src/commands/postgres.js' describe('parseCpu (compute --cpu override)', () => { it('accepts exactly the provider grid the help text advertises', () => { diff --git a/test/volume.test.ts b/test/volume.test.ts index b10e7c4..071e04a 100644 --- a/test/volume.test.ts +++ b/test/volume.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest' import { parseVolumeGib, servicesAddRequestBody, servicesAdd, serviceListLine } from '../src/commands/services.js' import { volumeLines, volumeWriteLine, volumeDeleteLine, volumeDeleteError, computeVolume } from '../src/commands/compute.js' import { ApiError } from '../src/api.js' -import { dbVolumeLines } from '../src/commands/db.js' +import { dbVolumeLines } from '../src/commands/postgres.js' describe('parseVolumeGib', () => { it('parses whole Gi, with or without a suffix', () => { From 2c87c8b6a5a569ab522ecf18a0fdaf8ef2306e25 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 15:03:45 -0700 Subject: [PATCH 03/19] managed databases: type-generalized limits/volume/always-on, status over runtime-health, query engine guard Co-Authored-By: Claude Fable 5.1 --- src/commands/compute.ts | 62 +++++++++++++++++++-------------- src/commands/db-query.ts | 31 +++++++++-------- src/commands/managed-db.ts | 48 ++++++++++++++++++++++++++ test/db-query.test.ts | 10 ++++-- test/managed-db-status.test.ts | 63 ++++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 42 deletions(-) create mode 100644 src/commands/managed-db.ts create mode 100644 test/managed-db-status.test.ts diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 3c19175..7b11b54 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -4,6 +4,11 @@ import { resolveComputeServiceId, resolveSoleService, q, parseVolumeGib } from ' type Opts = { branch?: string; group?: string; json?: boolean } +// The service types that share compute's settings verbs (limits / volume / always-on) on the +// platform: a "compute or managed-database" service. `status` is compute-only on the platform and +// stays under resolveComputeServiceId (see managed-db.ts for the managed-DB status read). +export type ManagedType = 'compute' | 'redis' | 'mysql' | 'mongodb' + // ---- custom domains (bring your own hostname) ---- // // A compute service's region is fixed at creation (`insta services add compute --region`), and a @@ -579,19 +584,20 @@ export async function computeExec( // ---- always-on (opt out of scale-to-zero; all plans; billing is actual usage either way) ---- -export async function computeAlwaysOn(mode: string, serviceName: string | undefined, opts: LifeOpts): Promise { +export async function serviceAlwaysOn(type: ManagedType, mode: string, serviceName: string | undefined, opts: LifeOpts): Promise { if (mode !== 'on' && mode !== 'off') throw new Error('mode must be on|off') const api = await ApiClient.load() const p = await requireProject() const branch = opts.branch ?? p.branch const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) - const id = resolveComputeServiceId(services, serviceName) - const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/always-on`, { enabled: mode === 'on' }) + const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, type, serviceName) + const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/always-on`, { enabled: mode === 'on' }) if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) const on = res.body.service?.always_on - info(`compute ${res.body.service?.name ?? id}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle'}`) + info(`${type} ${res.body.service?.name ?? svc.name}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle'}`) } +export const computeAlwaysOn = (mode: string, serviceName: string | undefined, opts: LifeOpts): Promise => serviceAlwaysOn('compute', mode, serviceName, opts) // ---- limits (the resource ceiling; paid plans) ---- @@ -627,12 +633,14 @@ export function parseCpu(raw: string): number { // Render the volume read. Pure, exported for tests (mirrors serviceListLine). Every plan may view; // only growth is paid — that gate is the backend's to enforce, so nothing here pre-blocks. -export function volumeLines(name: string, volume: { sizeGib: number; mountPath: string } | null, cap: { volumeGib: number }): string[] { +export function volumeLines(name: string, volume: { sizeGib: number; mountPath: string } | null, cap: { volumeGib: number }, type: ManagedType = 'compute'): string[] { if (!volume) return [ - `compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size \` — it mounts at /data on the next deploy)`, + type === 'compute' + ? `compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size \` — it mounts at /data on the next deploy)` + : `${type} ${name}: no volume attached (attach one: \`insta ${type} volume ${name} --size \` — it mounts at the image's data directory)`, ] return [ - `compute ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`, + `${type} ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`, ' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only), delete with --delete (destroys the data)', ] } @@ -640,18 +648,18 @@ export function volumeLines(name: string, volume: { sizeGib: number; mountPath: // Render the PUT result. Pure, exported for tests. `attached` comes from the backend and is what // tells a FIRST attach (no disk yet — it mounts on the next deploy) apart from a grow (the live // disk was already extended); the wire size is authoritative in both cases. -export function volumeWriteLine(name: string, body: { volume: { sizeGib: number; mountPath: string }; cap: { volumeGib: number }; attached?: boolean }): string { +export function volumeWriteLine(name: string, body: { volume: { sizeGib: number; mountPath: string }; cap: { volumeGib: number }; attached?: boolean }, type: ManagedType = 'compute'): string { if (body.attached) { - return `compute ${name}: volume ${body.volume.sizeGib}Gi attached — mounts at ${body.volume.mountPath} on the next deploy (plan max ${body.cap.volumeGib}Gi)` + return `${type} ${name}: volume ${body.volume.sizeGib}Gi attached — mounts at ${body.volume.mountPath} on the next deploy (plan max ${body.cap.volumeGib}Gi)` } - return `compute ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)` + return `${type} ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)` } // Render the DELETE result. Pure, exported for tests. Deleting is the only way off the volume // path (there is no detach), so the line says what came back with it: the two constraints the // volume imposed. -export function volumeDeleteLine(name: string): string { - return `compute ${name}: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back` +export function volumeDeleteLine(name: string, type: ManagedType = 'compute'): string { + return `${type} ${name}: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back` } // Map a DELETE .../volume failure. Pure, exported for tests. An older backend has no DELETE @@ -678,7 +686,7 @@ type VolumeOpts = LifeOpts & { size?: string; mountPath?: string; delete?: boole // no undo; billing stops now). The paid/cap/machine-count gates all belong to the backend, whose // 403/400 messages carry the upgrade hints and must reach the user verbatim (the guard prints // ApiError messages as-is). -export async function computeVolume(serviceName: string | undefined, opts: VolumeOpts): Promise { +export async function serviceVolume(type: ManagedType, serviceName: string | undefined, opts: VolumeOpts): Promise { if (opts.delete && opts.mountPath !== undefined) throw new Error('--delete cannot be combined with --mount-path') if (opts.mountPath !== undefined && !opts.size) throw new Error('--mount-path requires --size when attaching a volume') if (opts.delete && opts.size) throw new Error('--delete cannot be combined with --size (one changes the volume, the other destroys it)') @@ -686,48 +694,49 @@ export async function computeVolume(serviceName: string | undefined, opts: Volum const p = await requireProject() const branch = opts.branch ?? p.branch const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) - const id = resolveComputeServiceId(services, serviceName) + const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, type, serviceName) if (opts.delete) { let res - try { res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${id}/volume`) } + try { res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${svc.id}/volume`) } catch (e) { throw volumeDeleteError(e) } if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) - info(volumeDeleteLine(res.body.service?.name ?? serviceName ?? id)) + info(volumeDeleteLine(res.body.service?.name ?? svc.name, type)) return } if (!opts.size) { - const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/volume`) + const r = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/volume`) if (opts.json) return printJson(r) - for (const line of volumeLines(serviceName ?? id, r.volume, r.cap)) info(line) + for (const line of volumeLines(svc.name, r.volume, r.cap, type)) info(line) return } const sizeGib = parseVolumeGib(opts.size) - const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/volume`, { sizeGib, ...(opts.mountPath !== undefined ? { mountPath: opts.mountPath } : {}) }) + const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/volume`, { sizeGib, ...(opts.mountPath !== undefined ? { mountPath: opts.mountPath } : {}) }) if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) - info(volumeWriteLine(res.body.service?.name ?? serviceName ?? id, res.body)) + info(volumeWriteLine(res.body.service?.name ?? svc.name, res.body, type)) } +export const computeVolume = (serviceName: string | undefined, opts: VolumeOpts): Promise => serviceVolume('compute', serviceName, opts) type LimitsOpts = LifeOpts & { cpu?: string; memory?: string } // Show or set a compute service's ceiling. With no --memory it PRINTS the current limits and the // plan cap (so `insta compute limits` is a safe read), which is also what a UI renders as a slider // with its plan-limit marker. -export async function computeLimits(serviceName: string | undefined, opts: LimitsOpts): Promise { +export async function serviceLimits(type: ManagedType, serviceName: string | undefined, opts: LimitsOpts): Promise { const api = await ApiClient.load() const p = await requireProject() const branch = opts.branch ?? p.branch const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) - const id = resolveComputeServiceId(services, serviceName) + const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, type, serviceName) if (!opts.memory && !opts.cpu) { - const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/limits`) + const r = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/limits`) if (opts.json) return printJson(r) - info(`compute ${serviceName ?? id}: ceiling ${r.limits.cpu} vCPU / ${fmtMb(r.limits.memoryMb)} (plan max ${r.cap.cpu} vCPU / ${fmtMb(r.cap.memoryMb)})`) + info(`${type} ${svc.name}: ceiling ${r.limits.cpu} vCPU / ${fmtMb(r.limits.memoryMb)} (plan max ${r.cap.cpu} vCPU / ${fmtMb(r.cap.memoryMb)})`) info(' billing is actual usage — the ceiling caps what the app may burn, it is not a price') return } @@ -735,12 +744,13 @@ export async function computeLimits(serviceName: string | undefined, opts: Limit const body: Record = { memoryMb: parseMemoryMb(opts.memory) } if (opts.cpu) body.cpu = parseCpu(opts.cpu) - const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/limits`, body) + const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/limits`, body) if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) const l = res.body.limits - info(`compute ${res.body.service?.name ?? id}: ceiling set to ${l.cpu} vCPU / ${fmtMb(l.memoryMb)}`) + info(`${type} ${res.body.service?.name ?? svc.name}: ceiling set to ${l.cpu} vCPU / ${fmtMb(l.memoryMb)}`) } +export const computeLimits = (serviceName: string | undefined, opts: LimitsOpts): Promise => serviceLimits('compute', serviceName, opts) // ---- ssh (interactive sessions) -------------------------------------------- diff --git a/src/commands/db-query.ts b/src/commands/db-query.ts index 7e80da3..0119542 100644 --- a/src/commands/db-query.ts +++ b/src/commands/db-query.ts @@ -1,9 +1,9 @@ -// `insta db query [args...]` — run a query/command against a MANAGED database -// (mysql/redis/mongodb) through the platform's console exec API. Postgres is not a console target -// (it has the SQL editor / DATABASE_URL, and `insta db url|connect`), so a postgres service is -// rejected here. The shape logic — path, request body, result rendering — lives in pure, -// unit-tested seams; the handler just resolves the service and wires them to the API, this repo's -// pure-seam convention. +// `insta redis|mysql|mongodb query [args...]` — run a query/command against a MANAGED +// database through the platform's console exec API. Postgres is not a console target (it has the +// SQL editor / DATABASE_URL, and `insta postgres url|connect`), so a postgres service is rejected +// here. The shape logic — path, request body, result rendering — lives in pure, unit-tested seams; +// the handler just resolves the service and wires them to the API, this repo's pure-seam +// convention. import { ApiClient, requireProject } from '../api.js' import { info, printJson, die, handleApproval } from '../util.js' import { q } from './services.js' @@ -78,11 +78,13 @@ async function dbQueryDeps(deps?: DbQueryDeps): Promise { } // Resolve (a service NAME) to its id + engine, then dispatch to the console exec API. -export async function dbQuery(service: string, args: string[], opts: Opts = {}, deps?: DbQueryDeps): Promise { +// `engine` is the group the command was typed under (`insta redis query …`): a service of another +// type is refused with the right group named, never queried through the wrong renderer. +export async function dbQuery(service: string, args: string[], opts: Opts = {}, deps?: DbQueryDeps, engine?: Engine): Promise { // An empty command is never valid — reject it before loading config or hitting the network, // rather than posting an empty statement/argv to the console. if (args.length === 0) { - die('usage: insta db query (mysql/mongodb: one quoted statement; redis: e.g. GET mykey)') + die(`usage: insta ${engine ?? ''} query (mysql/mongodb: one quoted statement; redis: e.g. GET mykey)`) } const { api, project: p } = await dbQueryDeps(deps) const branch = opts.branch ?? p.branch @@ -90,20 +92,21 @@ export async function dbQuery(service: string, args: string[], opts: Opts = {}, const svc = (services as Array<{ id: string; type: string; name: string }>).find((s) => s.name === service) if (!svc) die(`service not found: ${service}`) if (!(MANAGED_ENGINES as readonly string[]).includes(svc.type)) { - die('db query is for managed databases (mysql/redis/mongodb); postgres uses the SQL editor / DATABASE_URL') + die('query is for managed databases (mysql/redis/mongodb); postgres uses `insta postgres url|connect` / the SQL editor') } - const engine = svc.type as Engine + if (engine && svc.type !== engine) die(`${service} is a ${svc.type} service — use: insta ${svc.type} query ${service} …`) + const resolved = svc.type as Engine // --database is a mongodb-only selector (execBody drops it for the others). Rejecting it here, // rather than silently ignoring it, keeps the documented mongodb-only contract honest. - if (opts.database !== undefined && engine !== 'mongodb') { + if (opts.database !== undefined && resolved !== 'mongodb') { die('--database is only supported for mongodb services') } - const res = await api.rawRequest('POST', consoleExecPath(p.projectId, svc.id), execBody(engine, args, opts.database)) + const res = await api.rawRequest('POST', consoleExecPath(p.projectId, svc.id), execBody(resolved, args, opts.database)) if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) - if (engine === 'mysql') { + if (resolved === 'mysql') { for (const line of renderMysqlRows(res.body ?? {})) info(line) - } else if (engine === 'redis') { + } else if (resolved === 'redis') { info(renderRedisReply(res.body?.reply)) } else { info(renderMongoResult(res.body?.result)) diff --git a/src/commands/managed-db.ts b/src/commands/managed-db.ts new file mode 100644 index 0000000..b979ec5 --- /dev/null +++ b/src/commands/managed-db.ts @@ -0,0 +1,48 @@ +// `insta redis|mysql|mongodb …` — the managed Fly databases. Their settings verbs (limits / volume / +// always-on) reuse compute.ts's type-generalized handlers: the platform's /services/:id/{limits, +// volume,always-on} accept "compute or managed-database" services. `status` cannot: GET +// /services/:id/state is compute-only on the platform (services.state() throws for anything else), +// so it reads the project's runtime-health — which covers compute, postgres and the managed DBs in +// one call — and picks this service's entry. +import { ApiClient, requireProject } from '../api.js' +import { info, printJson } from '../util.js' +import { q, resolveSoleService } from './services.js' +import type { Engine } from './db-query.js' + +// One entry of GET /projects/:id/runtime-health. `status` is the platform's health vocabulary: +// healthy | crashed | starting | standby | none | unknown — `standby` means scaled to zero and +// waking on request, which is normal, not a failure. +export type HealthEntry = { serviceId: string; status: string; machines: number; failing: number } + +// pure: `redis cache: standby (1 machine, 0 failing)`. Exported for tests. +export function statusLine(type: string, name: string, entry: HealthEntry | undefined): string { + if (!entry) return `${type} ${name}: unknown (the runtime-health read did not include this service)` + const machines = `${entry.machines} machine${entry.machines === 1 ? '' : 's'}` + return `${type} ${name}: ${entry.status} (${machines}, ${entry.failing} failing)` +} + +// The API surface this command needs, injectable so the flow is testable without a network mock +// (the DomainDeps convention in compute.ts). Production loads a real ApiClient + requireProject(). +export type ManagedApi = Pick +export type ManagedDeps = { api: ManagedApi; project: { projectId: string; branch?: string } } +async function managedDeps(deps?: ManagedDeps): Promise { + if (deps) return deps + const [api, project] = [await ApiClient.load(), await requireProject()] + return { api, project } +} + +export async function managedStatus( + type: Engine, + serviceName: string | undefined, + opts: { branch?: string; json?: boolean }, + deps?: ManagedDeps, +): Promise { + const { api, project: p } = await managedDeps(deps) + const branch = opts.branch ?? p.branch + const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) + const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, type, serviceName) + const res = await api.request<{ services?: HealthEntry[] }>('GET', `/projects/${p.projectId}/runtime-health${q(branch)}`) + const entry = res.services?.find((e) => e.serviceId === svc.id) + if (opts.json) return printJson(entry ?? { serviceId: svc.id, status: 'unknown', machines: 0, failing: 0 }) + info(statusLine(type, svc.name, entry)) +} diff --git a/test/db-query.test.ts b/test/db-query.test.ts index 0440b75..73803f7 100644 --- a/test/db-query.test.ts +++ b/test/db-query.test.ts @@ -139,7 +139,7 @@ describe('dbQuery (handler flow, injected api — no network)', () => { const { deps: d, calls } = deps([{ id: 'svc_pg', type: 'postgres', name: 'db' }]) await expect(dbQuery('db', ['select 1'], {}, d)).rejects.toThrow('exit 1') expect(process.exitCode).toBe(1) - expect(err()).toMatch(/managed databases \(mysql\/redis\/mongodb\); postgres uses the SQL editor/) + expect(err()).toMatch(/managed databases \(mysql\/redis\/mongodb\); postgres uses `insta postgres url\|connect` \/ the SQL editor/) expect(calls.map((c) => c.method)).toEqual(['GET']) // never reached the POST }) @@ -168,10 +168,16 @@ describe('dbQuery (handler flow, injected api — no network)', () => { const { deps: d, calls } = deps(mysql) await expect(dbQuery('shop', [], {}, d)).rejects.toThrow('exit 1') expect(process.exitCode).toBe(1) - expect(err()).toMatch(/usage: insta db query/) + expect(err()).toContain('usage: insta query') expect(calls).toEqual([]) // not even the service lookup ran }) + it('refuses a service of another engine and names the right group', async () => { + const { deps: d } = deps([{ id: 'm1', type: 'mysql', name: 'db' }]) + await expect(dbQuery('db', ['PING'], {}, d, 'redis')).rejects.toThrow('exit 1') + expect(err()).toContain('db is a mysql service — use: insta mysql query db') + }) + it('relays a 202 approval gate: exit 2, hint on stderr, stdout untouched (non-json)', async () => { const body = { status: 'approval_required', action: 'db.query', approvalId: 'appr_1' } const { deps: d } = deps(mysql, { status: 202, body }) diff --git a/test/managed-db-status.test.ts b/test/managed-db-status.test.ts new file mode 100644 index 0000000..d879b66 --- /dev/null +++ b/test/managed-db-status.test.ts @@ -0,0 +1,63 @@ +// `insta redis|mysql|mongodb status` — the platform's /services/:id/state is compute-only, so this +// reads the project's runtime-health and picks the service's entry. DI seam, no network. +import { describe, it, expect, vi, afterEach, afterAll } from 'vitest' +import { managedStatus, statusLine, type ManagedDeps } from '../src/commands/managed-db.js' + +const services = [ + { id: 'r1', type: 'redis', name: 'cache', status: 'running' }, + { id: 'm1', type: 'mysql', name: 'db', status: 'running' }, + { id: 'c1', type: 'compute', name: 'api', status: 'running' }, +] +const health = { services: [ + { serviceId: 'r1', status: 'standby', machines: 1, failing: 0 }, + { serviceId: 'm1', status: 'crashed', machines: 1, failing: 1 }, + { serviceId: 'c1', status: 'healthy', machines: 2, failing: 0, desiredReplicas: 2 }, +] } +function deps() { + const calls: string[] = [] + const api = { + request: async (_m: string, path: string) => { + calls.push(path) + return path.includes('/runtime-health') ? health : { services } + }, + } + return { deps: { api, project: { projectId: 'p1', branch: 'main' } } as unknown as ManagedDeps, calls } +} + +const stdout: string[] = [] +const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((c: any) => { stdout.push(String(c)); return true }) +afterEach(() => { stdout.length = 0 }) +afterAll(() => outSpy.mockRestore()) +const out = () => stdout.join('') + +describe('statusLine', () => { + it('names the type, the service, the status and the machine counts', () => { + expect(statusLine('redis', 'cache', { serviceId: 'r1', status: 'standby', machines: 1, failing: 0 })) + .toBe('redis cache: standby (1 machine, 0 failing)') + expect(statusLine('mysql', 'db', { serviceId: 'm1', status: 'crashed', machines: 2, failing: 1 })) + .toBe('mysql db: crashed (2 machines, 1 failing)') + }) + it('says unknown when the health read omitted the service', () => { + expect(statusLine('mongodb', 'docs', undefined)).toContain('mongodb docs: unknown') + }) +}) + +describe('managedStatus', () => { + it('resolves the sole service of the type, then reads runtime-health for the branch', async () => { + const { deps: d, calls } = deps() + await managedStatus('redis', undefined, {}, d) + expect(calls[0]).toBe('/projects/p1/services?branch=main') + expect(calls[1]).toBe('/projects/p1/runtime-health?branch=main') + expect(out()).toContain('redis cache: standby (1 machine, 0 failing)') + }) + it("--json prints that service's entry verbatim", async () => { + const { deps: d } = deps() + await managedStatus('mysql', 'db', { json: true }, d) + expect(JSON.parse(out())).toEqual({ serviceId: 'm1', status: 'crashed', machines: 1, failing: 1 }) + }) + it('refuses a name of another type, and a type with no service', async () => { + const { deps: d } = deps() + await expect(managedStatus('redis', 'db', {}, d)).rejects.toThrow('redis service not found: db') + await expect(managedStatus('mongodb', undefined, {}, d)).rejects.toThrow(/no mongodb service/) + }) +}) From 81c0f432f3a3d4910c9fdf95b7c8beecbd5e74f8 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 15:12:11 -0700 Subject: [PATCH 04/19] compute scale + storage set-access replace the services scale/set-access/upgrade/secrets verbs Co-Authored-By: Claude Fable 5.1 --- src/commands/compute.ts | 20 +++++++++++++- src/commands/services.ts | 58 +--------------------------------------- src/commands/storage.ts | 16 ++++++++++- src/index.ts | 8 ------ 4 files changed, 35 insertions(+), 67 deletions(-) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 7b11b54..f2997eb 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -1,6 +1,6 @@ import { ApiClient, ApiError, requireProject } from '../api.js' import { info, printJson, handleApproval, relayExitCode, writeFileAtomicSync, resolveThroughSymlink } from '../util.js' -import { resolveComputeServiceId, resolveSoleService, q, parseVolumeGib } from './services.js' +import { resolveComputeServiceId, resolveSoleService, q, parseVolumeGib, parseCount } from './services.js' type Opts = { branch?: string; group?: string; json?: boolean } @@ -599,6 +599,24 @@ export async function serviceAlwaysOn(type: ManagedType, mode: string, serviceNa } export const computeAlwaysOn = (mode: string, serviceName: string | undefined, opts: LifeOpts): Promise => serviceAlwaysOn('compute', mode, serviceName, opts) +// ---- scale (same-region replica count; paid plans) ---- +type ScaleOpts = LifeOpts & { region?: string } + +// `insta compute scale [service] [--region ]` — POST /services/:id/scale. Count is +// validated locally (1..10); the paid-plan gate is the backend's and its 403 flows verbatim. +export async function computeScale(count: string, serviceName: string | undefined, opts: ScaleOpts): Promise { + const machineCount = parseCount(count) + const api = await ApiClient.load() + const p = await requireProject() + const branch = opts.branch ?? p.branch + const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) + const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, 'compute', serviceName) + const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${svc.id}/scale`, { machineCount, region: opts.region }) + if (handleApproval(res, opts.json)) return + if (opts.json) return printJson(res.body.service) + info(`scaled compute ${svc.name} to ${machineCount} replica(s)${opts.region ? ` in ${opts.region}` : ''}`) +} + // ---- limits (the resource ceiling; paid plans) ---- // Parse a human memory value into MB: "512", "512mb", "1gb", "2g", "1.5gb". diff --git a/src/commands/services.ts b/src/commands/services.ts index bbfce3d..c350f7b 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -1,4 +1,4 @@ -// `insta services` — manage a project's opt-in services (postgres | storage | compute | redis | mysql | mongodb). +// `insta service` — manage a project's opt-in services (postgres | storage | compute | redis | mysql | mongodb). import { ApiClient, requireProject } from '../api.js' import { info, printJson, handleApproval, renderNextActions } from '../util.js' @@ -212,59 +212,3 @@ export function parseAccess(raw: string): boolean { if (raw === 'private') return false throw new Error(`access must be public|private, got: ${raw}`) } - -// insta services set-access storage -export async function servicesSetAccess(type: string, name: string, access: string, _opts: { json?: boolean }): Promise { - assertType(type, ['storage']) - const isPublic = parseAccess(access) - const api = await ApiClient.load() - const p = await requireProject() - const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(p.branch)}`) - const id = resolveServiceId(services, type, name) - const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/access`, { public: isPublic }) - if (handleApproval(res, _opts.json)) return - if (_opts.json) return printJson(res.body.service) - info(`set storage ${name} access to ${access}`) -} - -// insta services scale compute [region] -export async function servicesScale(type: string, name: string, number: string, region: string | undefined, _opts: { json?: boolean; branch?: string }): Promise { - assertType(type, ['compute']) - const machineCount = parseCount(number) - const api = await ApiClient.load() - const p = await requireProject() - const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(_opts.branch ?? p.branch)}`) - const id = resolveServiceId(services, type, name) - const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/scale`, { machineCount, region }) - if (handleApproval(res, _opts.json)) return - if (_opts.json) return printJson(res.body.service) - info(`scaled compute ${name} to ${machineCount} replica(s)${region ? ` in ${region}` : ''}`) -} - -// insta services upgrade -export async function servicesUpgrade(type: string, name: string, spec: string, _opts: { json?: boolean; branch?: string }): Promise { - assertType(type, ['compute', 'postgres']) - const api = await ApiClient.load() - const p = await requireProject() - const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(_opts.branch ?? p.branch)}`) - const id = resolveServiceId(services, type, name) - const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/upgrade`, { spec }) - if (handleApproval(res, _opts.json)) return - if (_opts.json) return printJson(res.body.service) - info(`upgraded ${type} ${name} to ${spec}`) -} - -// insta services secrets — the secret names bound to a service. -export async function servicesSecrets(type: string, name: string, opts: { branch?: string; json?: boolean } = {}): Promise { - assertType(type) - const api = await ApiClient.load() - const p = await requireProject() - const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(opts.branch ?? p.branch)}`) - const id = resolveServiceId(services, type, name) - const res = await api.rawRequest('GET', `/projects/${p.projectId}/services/${id}/secrets`) - if (handleApproval(res, opts.json)) return - const { secrets } = res.body - if (opts.json) return printJson(secrets) - if (!secrets.length) return info(`(no secrets bound to ${type}/${name})`) - for (const n of secrets) info(n) -} diff --git a/src/commands/storage.ts b/src/commands/storage.ts index 8a8f10a..702effd 100644 --- a/src/commands/storage.ts +++ b/src/commands/storage.ts @@ -6,7 +6,7 @@ import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { ApiClient, requireProject } from '../api.js' import { info, printJson, handleApproval } from '../util.js' -import { q, resolveSoleService } from './services.js' +import { q, resolveSoleService, parseAccess } from './services.js' import { fmtBytes } from './postgres.js' // the repo's tested bytes formatter — don't grow a third copy type Common = { branch?: string; service?: string; json?: boolean } @@ -174,3 +174,17 @@ export async function storageDelete(key: string, opts: Common): Promise { if (opts.json) return printJson(res.body) info(`deleted ${key} from storage/${svc.name} (branch ${branch})`) } + +// `insta storage set-access [--service ]` — PUT /services/:id/access. +// public = anonymous public-read on the bucket; private is the default a bucket is born with. +export async function storageSetAccess(access: string, opts: Common): Promise { + const isPublic = parseAccess(access) + const api = await ApiClient.load() + const p = await requireProject() + const branch = opts.branch ?? p.branch + const svc = await storageTarget(api, p.projectId, branch, opts.service) + const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/access`, { public: isPublic }) + if (handleApproval(res, opts.json)) return + if (opts.json) return printJson(res.body.service) + info(`set storage ${svc.name} access to ${access}`) +} diff --git a/src/index.ts b/src/index.ts index 6eb3ea6..a0039b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -167,14 +167,6 @@ svc.command('remove ').description('Remove a service and destroy it svc.command('rename ').description('Rename a service and re-key its managed secret names') .option('--json').option('--branch ', 'branch (default: current)') .action(guard((type, name, newName, o) => services.servicesRename(type, name, newName, o))) -svc.command('set-access ').description('Set a storage service bucket access mode (access: public|private)') - .option('--json').action(guard((type, name, access, o) => services.servicesSetAccess(type, name, access, o))) -svc.command('scale [region]').description('Set a compute service same-region replica count from 1 to 10 (paid plans only)') - .option('--json').option('--branch ', 'branch (default: current)').action(guard((type, name, number, region, o) => services.servicesScale(type, name, number, region, o))) -svc.command('upgrade ').description('Change a compute service spec (paid plans only). Postgres upgrades are rejected by the platform — use `insta db limits` instead') - .option('--json').option('--branch ', 'branch (default: current)').action(guard((type, name, spec, o) => services.servicesUpgrade(type, name, spec, o))) -svc.command('secrets ').description("List a service's secret names") - .option('--branch ').option('--json').action(guard((type, name, o) => services.servicesSecrets(type, name, o))) // ---- secrets (seam) ---- const sec = program.command('secrets').description('Fetch the credential bundle (secret seam) into .env') From 52744de8ee7f90bae0476feffc1eb2f750e6529b Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 15:20:23 -0700 Subject: [PATCH 05/19] domain attach covers bought and bring-your-own hostnames; check/detach move under domain Co-Authored-By: Claude Fable 5.1 --- src/commands/compute.ts | 6 +++--- src/commands/domain.ts | 11 +++++++++-- test/compute-domain-flow.test.ts | 2 +- test/compute-domain-region.test.ts | 18 +++++++++--------- test/domain.test.ts | 15 +++++++++------ 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index f2997eb..bb80514 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -94,7 +94,7 @@ export function domainGuidanceLines(r: DomainView, ctx: DomainCmdCtx = {}): stri const nameW = Math.max(...records.map((d) => d.name.length)) out.push('add these DNS records at your DNS provider:') for (const d of records) out.push(` ${pad(d.type, 6)} ${pad(d.name, nameW)} -> ${d.value}`) - out.push(`then: insta compute check-domain ${r.hostname}${flags(ctx)}`) + out.push(`then: insta domain check ${r.hostname}${flags(ctx)}`) return out } @@ -138,7 +138,7 @@ export function domainResolveLine(r: DomainView): { line: string; ready: boolean // check-domain: every stage, what each still needs, and where it routes. Pure, exported for tests. export function domainStatusLines(r: DomainView, ctx: DomainCmdCtx = {}): string[] { if (r.status === 'not added') { - return [`${r.hostname} is not attached to ${targetOf(r)} — attach it with: insta compute set-domain ${r.hostname}${flags(ctx)}`] + return [`${r.hostname} is not attached to ${targetOf(r)} — attach it with: insta domain attach ${r.hostname}${flags(ctx)}`] } const records = recordsOf(r) const out = [`${r.hostname} -> ${targetOf(r)}`] @@ -251,7 +251,7 @@ export function domainConflictMessage(host: string, e: ApiError, services: Compu const region = m?.[2] // The release command must name the OWNER's group, and the branch the user is working on — a // command that defaults back to the linked branch would release nothing. - const release = (group: string) => `insta compute remove-domain ${host}${flags({ group, branch: ctx.branch })}` + const release = (group: string) => `insta domain detach ${host}${flags({ group, branch: ctx.branch })}` if (owner) { const here = services.find((s) => s.type === 'compute' && s.name === owner) if (here) return `${host} is already attached to ${owner}${region ? ` (${region})` : here.region ? ` (${here.region})` : ''} — domains are not moved; release it first: ${release(owner)}, then re-run set-domain` diff --git a/src/commands/domain.ts b/src/commands/domain.ts index 1169806..91fbac9 100644 --- a/src/commands/domain.ts +++ b/src/commands/domain.ts @@ -1,7 +1,7 @@ import { ApiClient } from '../api.js' import { info, printJson, handleApproval, die } from '../util.js' import { presentUrl, resolveOrgId } from './billing.js' -import { domainDeps, domainTarget, type DomainDeps } from './compute.js' +import { domainDeps, domainTarget, setDomain, checkDomain, removeDomain, type DomainDeps } from './compute.js' type Quote = { domainName: string; purchasable: boolean; priceCents?: number; renewalPriceCents?: number; reason?: string } type Order = { id: string; domainName: string; years: number; status: string; priceCents: number; renewalPriceCents: number | null; checkoutUrl?: string; failedReason: string | null } @@ -74,7 +74,9 @@ export async function domainAttach(host: string, opts: { branch?: string; group? const { items: orders } = await api.request<{ items: Order[] }>('GET', `/projects/${p.projectId}/domains/orders`) const o = ownerOf(name, orders) if (o) die(`${o.domainName} is not registered yet — its order is ${o.status}: insta domain status ${o.domainName}`) - die(`no domain this org bought covers ${name} — for a domain you own elsewhere: insta compute set-domain ${name}`) + // Not a name this org bought: a domain owned elsewhere. Same verb, the bring-your-own path — + // the plane issues the edge cert and the DNS records to publish in your own zone are printed. + return setDomain(name, opts, { api, project: p }) } const branch = opts.branch ?? p.branch const { target } = await domainTarget(api, p.projectId, branch, name, opts.group) @@ -193,3 +195,8 @@ export async function domainRecordsRemove(domainName: string, id: string, opts: if (opts.json) return printJson(r) info(`removed record ${id} from ${domainName}`) } + +// `insta domain check|detach ` — the hostname-level reads and writes, for bought and +// bring-your-own names alike (both are routed through the compute plane's custom-domain surface). +export const domainCheck = checkDomain +export const domainDetach = removeDomain diff --git a/test/compute-domain-flow.test.ts b/test/compute-domain-flow.test.ts index 02af9ae..12b5e23 100644 --- a/test/compute-domain-flow.test.ts +++ b/test/compute-domain-flow.test.ts @@ -73,7 +73,7 @@ describe('set-domain flow', () => { // The owner (`web`) IS a service in this project, so the message can name the exact command. const { deps: d } = deps({ post: new ApiError(409, 'app.customer.com is already attached to web in us-west; remove it there first') }) await expect(setDomain('app.customer.com', { group: 'api', branch: 'preview' }, d)).rejects.toThrow( - /domains are not moved; release it first: insta compute remove-domain app\.customer\.com --group web --branch preview/, + /domains are not moved; release it first: insta domain detach app\.customer\.com --group web --branch preview/, ) }) diff --git a/test/compute-domain-region.test.ts b/test/compute-domain-region.test.ts index 55bcb0f..2ba3846 100644 --- a/test/compute-domain-region.test.ts +++ b/test/compute-domain-region.test.ts @@ -1,4 +1,4 @@ -// `insta compute set-domain / check-domain` — region-aware, and never guessing. A compute service +// `insta domain attach / check-domain` — region-aware, and never guessing. A compute service // lives in ONE region (fixed at creation) and a custom hostname routes in that region's router, so: // the target service is resolved from the project (sole compute service → bind; several → refuse // with the list, regions shown, --group required; workers unbindable), the guidance after set-domain @@ -71,7 +71,7 @@ describe('domainGuidanceLines (after set-domain: what to do next, from the platf 'add these DNS records at your DNS provider:', ' CNAME app.customer.com -> cname.instacloud-dns.com', ' TXT _insta-verify.app.customer.com -> insta-verify=tok123', - 'then: insta compute check-domain app.customer.com', + 'then: insta domain check app.customer.com', ]) }) @@ -87,9 +87,9 @@ describe('domainGuidanceLines (after set-domain: what to do next, from the platf // on the very ambiguity error this feature raises, and without --branch it checks the linked // branch instead (cubic P2). it('the follow-up check-domain command carries the resolved group and the invoked branch', () => { - expect(domainGuidanceLines(bound, { group: 'api' }).at(-1)).toBe('then: insta compute check-domain app.customer.com --group api') + expect(domainGuidanceLines(bound, { group: 'api' }).at(-1)).toBe('then: insta domain check app.customer.com --group api') expect(domainGuidanceLines(bound, { group: 'api', branch: 'preview' }).at(-1)) - .toBe('then: insta compute check-domain app.customer.com --group api --branch preview') + .toBe('then: insta domain check app.customer.com --group api --branch preview') }) it('an older platform without service/region: withRow fills them from the resolved row', () => { @@ -340,10 +340,10 @@ describe('domainStatusLines (check-domain: every stage + where it routes)', () = it('not added: one line pointing at set-domain, region still named, group + branch carried', () => { expect(domainStatusLines({ ...bound, status: 'not added', dns: [] })).toEqual([ - 'app.customer.com is not attached to api (us-east) — attach it with: insta compute set-domain app.customer.com', + 'app.customer.com is not attached to api (us-east) — attach it with: insta domain attach app.customer.com', ]) expect(domainStatusLines({ ...bound, status: 'not added', dns: [] }, { group: 'api', branch: 'preview' })[0]) - .toBe('app.customer.com is not attached to api (us-east) — attach it with: insta compute set-domain app.customer.com --group api --branch preview') + .toBe('app.customer.com is not attached to api (us-east) — attach it with: insta domain attach app.customer.com --group api --branch preview') }) }) @@ -352,7 +352,7 @@ describe('domainConflictMessage (bound elsewhere — domains are released, never it('owner named and in this project: the exact remove-domain command', () => { expect(domainConflictMessage('app.customer.com', conflict('app.customer.com is already attached to web in us-west; remove it there first'), [api, web])) - .toBe('app.customer.com is already attached to web (us-west) — domains are not moved; release it first: insta compute remove-domain app.customer.com --group web, then re-run set-domain') + .toBe('app.customer.com is already attached to web (us-west) — domains are not moved; release it first: insta domain detach app.customer.com --group web, then re-run set-domain') }) it('owner named but not a service here: held by a deleted service → operator must release', () => { @@ -363,12 +363,12 @@ describe('domainConflictMessage (bound elsewhere — domains are released, never it('the release command carries the branch the user was working on', () => { const e = conflict('app.customer.com is already attached to web in us-west; remove it there first') expect(domainConflictMessage('app.customer.com', e, [api, web], { group: 'api', branch: 'preview' })) - .toContain('insta compute remove-domain app.customer.com --group web --branch preview') + .toContain('insta domain detach app.customer.com --group web --branch preview') }) it("owner not named (today's plane): generic release instruction, still no 'move'", () => { const msg = domainConflictMessage('app.customer.com', conflict('app.customer.com is already attached to another compute service; remove it there first'), [api, web]) - expect(msg).toBe('app.customer.com is already attached to another compute service — domains are not moved; release it there first (insta compute remove-domain app.customer.com --group ) or, if that service was deleted, ask an operator to release the hostname') + expect(msg).toBe('app.customer.com is already attached to another compute service — domains are not moved; release it there first (insta domain detach app.customer.com --group ) or, if that service was deleted, ask an operator to release the hostname') expect(msg).not.toMatch(/move it|transfer/) }) }) diff --git a/test/domain.test.ts b/test/domain.test.ts index 0f5f74f..4ac6370 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -111,12 +111,15 @@ describe('domain attach', () => { expect(out()).toContain('docs.myapp.com will attach to web') expect(out()).not.toContain('www.myapp.com') }) - it('a hostname under no bought name is refused before any service lookup', async () => { - const { deps: d, calls } = deps({ '/domains/orders': { items: [] }, ...inventory }) - await expect(domainAttach('api.other.com', { group: 'web' }, d)).rejects.toThrow('exit 1') - expect(stderr.join('')).toContain('no domain this org bought covers api.other.com') - expect(stderr.join('')).toContain('insta compute set-domain api.other.com') - expect(calls.map((c) => c.method)).toEqual(['GET', 'GET']) + // Same verb for a domain owned elsewhere: no bought name covers it → the bring-your-own path, + // POST /compute/domain, and the DNS records to publish are printed as the next step. + it('a hostname under no bought name takes the bring-your-own path', async () => { + const byo = { hostname: 'api.other.com', flyApp: 'app-web', configured: false, status: 'pending', dns: [{ type: 'CNAME', name: 'api.other.com', value: 'web.edge.instacloud.com' }] } + const { deps: d, calls } = deps({ '/domains/orders': { items: [] }, ...inventory }, { status: 200, body: byo }) + await domainAttach('api.other.com', { group: 'web' }, d) + expect(calls.at(-1)).toMatchObject({ method: 'POST', path: '/projects/p1/compute/domain', body: { hostname: 'api.other.com', branch: 'main', group: 'web' } }) + expect(out()).toContain('CNAME api.other.com -> web.edge.instacloud.com') + expect(out()).toContain('then: insta domain check api.other.com --group web') }) // `buy` says to run this next; before the registrar answers the name is an order, and ours. it('a bought name still registering is refused as an order, not as someone else\'s domain', async () => { From 5601f7024bcb987e475ee0bd32563b05454a12c2 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 15:28:17 -0700 Subject: [PATCH 06/19] domain: conflict hint names the new attach path Co-Authored-By: Claude Fable 5.1 --- src/commands/compute.ts | 14 +++++++------- test/compute-domain-flow.test.ts | 6 +++--- test/compute-domain-region.test.ts | 28 ++++++++++++++-------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index bb80514..03de4d8 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -80,7 +80,7 @@ export type DomainCmdCtx = { group?: string; branch?: string } const flags = (c: DomainCmdCtx = {}) => `${c.group ? ` --group ${c.group}` : ''}${c.branch ? ` --branch ${c.branch}` : ''}` -// After set-domain: exactly what to do next, from the records the platform returned — never a +// After attach: exactly what to do next, from the records the platform returned — never a // hand-built template. No records = say so; a template here would send the customer publishing // values the plane never issued. Pure, exported for tests. export function domainGuidanceLines(r: DomainView, ctx: DomainCmdCtx = {}): string[] { @@ -135,7 +135,7 @@ export function domainResolveLine(r: DomainView): { line: string; ready: boolean return { line: ` ${pad('resolves to', 12)}${r.origin} (${region} router) ok`, ready: true } } -// check-domain: every stage, what each still needs, and where it routes. Pure, exported for tests. +// check: every stage, what each still needs, and where it routes. Pure, exported for tests. export function domainStatusLines(r: DomainView, ctx: DomainCmdCtx = {}): string[] { if (r.status === 'not added') { return [`${r.hostname} is not attached to ${targetOf(r)} — attach it with: insta domain attach ${r.hostname}${flags(ctx)}`] @@ -164,7 +164,7 @@ export function domainStatusLines(r: DomainView, ctx: DomainCmdCtx = {}): string if (st === 'ok') stage('ownership', 'verified', '(TXT found)') else if (st === 'mismatch') { stage('ownership', 'mismatch', `TXT ${txt.name} has a different value — set it to ${txt.value}`); blockers.push('fix the ownership TXT') } else if (st === 'missing') { stage('ownership', 'pending', `add TXT ${txt.name} -> ${txt.value}`); blockers.push('add the ownership TXT') } - else { stage('ownership', 'unchecked', `TXT ${txt.name} -> ${txt.value} (the plane has not checked it yet — re-run check-domain)`); blockers.push('ownership unchecked') } + else { stage('ownership', 'unchecked', `TXT ${txt.name} -> ${txt.value} (the plane has not checked it yet — re-run insta domain check)`); blockers.push('ownership unchecked') } } else if (reportsOrigin(r)) { // The stage is drawn even with no record to draw it from: an omitted stage reads as "not // required", when in fact the platform told us nothing to publish. Only the plane @@ -191,7 +191,7 @@ export function domainStatusLines(r: DomainView, ctx: DomainCmdCtx = {}): string if (st === 'ok') stage(lbl, 'ok', `(points at ${d.value})`) else if (st === 'mismatch') { stage(lbl, 'mismatch', `${d.type} ${d.name} must point at ${d.value}`); blockers.push(`fix the ${d.type}`) } else if (st === 'missing') { stage(lbl, 'pending', `add ${d.type} ${d.name} -> ${d.value}`); blockers.push(`add the ${d.type}`) } - else { stage(lbl, 'unchecked', `${d.type} ${d.name} -> ${d.value} (not checked yet — re-run check-domain)`); blockers.push(`${d.type} unchecked`) } + else { stage(lbl, 'unchecked', `${d.type} ${d.name} -> ${d.value} (not checked yet — re-run insta domain check)`); blockers.push(`${d.type} unchecked`) } } // Everything else the platform returned — a Let's Encrypt validation CNAME, any extra record. @@ -204,7 +204,7 @@ export function domainStatusLines(r: DomainView, ctx: DomainCmdCtx = {}): string if (st === 'ok') stage(lbl, 'ok', where) else if (st === 'mismatch') { stage(lbl, 'mismatch', `${d.type} ${d.name} must point at ${d.value}`); blockers.push(`fix the ${d.type} ${d.name}`) } else if (st === 'missing') { stage(lbl, 'pending', `add ${where}`); blockers.push(`add the ${d.type} ${d.name}`) } - else { stage(lbl, 'unchecked', `${where} (not checked yet — re-run check-domain)`); blockers.push(`${d.type} ${d.name} unchecked`) } + else { stage(lbl, 'unchecked', `${where} (not checked yet — re-run insta domain check)`); blockers.push(`${d.type} ${d.name} unchecked`) } } const ssl = r.ssl ?? (r.configured ? 'active' : undefined) @@ -240,7 +240,7 @@ export function domainStatusLines(r: DomainView, ctx: DomainCmdCtx = {}): string // The platform's 409: the hostname is already bound elsewhere. Domains are never MOVED — the only // path is unbind there, then bind here — so the hint names the release step. Three shapes: -// owner named and present in this project → the exact remove-domain command; +// owner named and present in this project → the exact detach command; // owner named but NOT in this project's services → it is held by a deleted (or other-project) // service: an operator must release it (the plane has no self-serve orphan release yet); // owner not named (today's plane) → the generic release instruction. @@ -254,7 +254,7 @@ export function domainConflictMessage(host: string, e: ApiError, services: Compu const release = (group: string) => `insta domain detach ${host}${flags({ group, branch: ctx.branch })}` if (owner) { const here = services.find((s) => s.type === 'compute' && s.name === owner) - if (here) return `${host} is already attached to ${owner}${region ? ` (${region})` : here.region ? ` (${here.region})` : ''} — domains are not moved; release it first: ${release(owner)}, then re-run set-domain` + if (here) return `${host} is already attached to ${owner}${region ? ` (${region})` : here.region ? ` (${here.region})` : ''} — domains are not moved; release it first: ${release(owner)}, then re-run insta domain attach` return `${host} is already attached to ${owner}${region ? ` in ${region}` : ''}, which is not a service in this project — it is held by a deleted service (or one in another project); ask an operator to release the hostname before re-binding it` } return `${host} is already attached to another compute service — domains are not moved; release it there first (${release('')}) or, if that service was deleted, ask an operator to release the hostname` diff --git a/test/compute-domain-flow.test.ts b/test/compute-domain-flow.test.ts index 12b5e23..5e79dbf 100644 --- a/test/compute-domain-flow.test.ts +++ b/test/compute-domain-flow.test.ts @@ -41,7 +41,7 @@ afterEach(() => { stdout.length = 0 }) afterAll(() => { outSpy.mockRestore() }) const out = () => stdout.join('') -describe('set-domain flow', () => { +describe('setDomain flow', () => { it('looks the services up first, then sends the RESOLVED group (never the platform default)', async () => { const { deps: d, calls } = deps() await setDomain('app.customer.com', { group: 'web' }, d) @@ -90,7 +90,7 @@ describe('set-domain flow', () => { }) }) -describe('check-domain flow', () => { +describe('checkDomain flow', () => { it('sends hostname + resolved group + branch, and renders the stages', async () => { const { deps: d, calls } = deps({ only: true }) await checkDomain('app.customer.com', {}, d) @@ -105,7 +105,7 @@ describe('check-domain flow', () => { }) }) -describe('remove-domain flow', () => { +describe('removeDomain flow', () => { it('sends the resolved group and names the service and region it was removed from', async () => { const { deps: d, calls } = deps({ only: true }) await removeDomain('app.customer.com', {}, d) diff --git a/test/compute-domain-region.test.ts b/test/compute-domain-region.test.ts index 2ba3846..ae43f70 100644 --- a/test/compute-domain-region.test.ts +++ b/test/compute-domain-region.test.ts @@ -1,8 +1,8 @@ -// `insta domain attach / check-domain` — region-aware, and never guessing. A compute service +// `insta domain attach / check` — region-aware, and never guessing. A compute service // lives in ONE region (fixed at creation) and a custom hostname routes in that region's router, so: // the target service is resolved from the project (sole compute service → bind; several → refuse -// with the list, regions shown, --group required; workers unbindable), the guidance after set-domain -// is the platform's records VERBATIM (never a template), and check-domain renders every stage plus +// with the list, regions shown, --group required; workers unbindable), the guidance after attach +// is the platform's records VERBATIM (never a template), and check renders every stage plus // where the hostname resolves. Pure-function tests, same pattern as compute-exec.test.ts. import { describe, it, expect } from 'vitest' import { @@ -64,8 +64,8 @@ const bound: DomainView = { ssl: 'initializing', } -describe('domainGuidanceLines (after set-domain: what to do next, from the platform records)', () => { - it('renders the adapter records verbatim, region named, then the check-domain step', () => { +describe('domainGuidanceLines (after attach: what to do next, from the platform records)', () => { + it('renders the adapter records verbatim, region named, then the check step', () => { expect(domainGuidanceLines(bound)).toEqual([ 'app.customer.com -> api (us-east)', 'add these DNS records at your DNS provider:', @@ -86,7 +86,7 @@ describe('domainGuidanceLines (after set-domain: what to do next, from the platf // The printed follow-up must reach the same service on the same branch: without --group it dies // on the very ambiguity error this feature raises, and without --branch it checks the linked // branch instead (cubic P2). - it('the follow-up check-domain command carries the resolved group and the invoked branch', () => { + it('the follow-up check command carries the resolved group and the invoked branch', () => { expect(domainGuidanceLines(bound, { group: 'api' }).at(-1)).toBe('then: insta domain check app.customer.com --group api') expect(domainGuidanceLines(bound, { group: 'api', branch: 'preview' }).at(-1)) .toBe('then: insta domain check app.customer.com --group api --branch preview') @@ -100,7 +100,7 @@ describe('domainGuidanceLines (after set-domain: what to do next, from the platf }) }) -describe('domainStatusLines (check-domain: every stage + where it routes)', () => { +describe('domainStatusLines (check: every stage + where it routes)', () => { const active: DomainView = { ...bound, configured: true, status: 'active', ssl: 'active', dns: bound.dns.map((d) => ({ ...d, status: 'ok' })), @@ -207,7 +207,7 @@ describe('domainStatusLines (check-domain: every stage + where it routes)', () = it('ownership unchecked: rendered as unchecked AND counted as a blocker', () => { const noStatus = { ...bound, dns: bound.dns.map(({ status: _s, ...d }) => d) } const lines = domainStatusLines(noStatus) - expect(lines[1]).toBe(' ownership unchecked TXT _insta-verify.app.customer.com -> insta-verify=tok123 (the plane has not checked it yet — re-run check-domain)') + expect(lines[1]).toBe(' ownership unchecked TXT _insta-verify.app.customer.com -> insta-verify=tok123 (the plane has not checked it yet — re-run insta domain check)') expect(lines.at(-1)).toContain('ownership unchecked') }) @@ -246,7 +246,7 @@ describe('domainStatusLines (check-domain: every stage + where it routes)', () = dns: [{ type: 'A', name: 'customer.com', value: '66.66.66.66', note: 'apex → the Fly app' }], } const lines = domainStatusLines(apex) - expect(lines[2]).toBe(' a unchecked A customer.com -> 66.66.66.66 (not checked yet — re-run check-domain)') + expect(lines[2]).toBe(' a unchecked A customer.com -> 66.66.66.66 (not checked yet — re-run insta domain check)') expect(lines.join('\n')).not.toContain('no routing record from the platform') expect(lines.join('\n')).not.toContain('add CNAME customer.com') }) @@ -333,12 +333,12 @@ describe('domainStatusLines (check-domain: every stage + where it routes)', () = const lines = domainStatusLines(fly) // Fly issues no ownership TXT, so that stage is drawn as unknown rather than silently dropped. expect(lines[1]).toBe(' ownership n/a (this provider does not use an ownership TXT)') - expect(lines[2]).toBe(' cname unchecked CNAME app.customer.com -> insta-main-api-ab12.fly.dev (not checked yet — re-run check-domain)') - expect(lines[3]).toBe(" cname unchecked _acme-challenge.app.customer.com -> app.customer.com.abc.flydns.net (Let's Encrypt validation) (not checked yet — re-run check-domain)") + expect(lines[2]).toBe(' cname unchecked CNAME app.customer.com -> insta-main-api-ab12.fly.dev (not checked yet — re-run insta domain check)') + expect(lines[3]).toBe(" cname unchecked _acme-challenge.app.customer.com -> app.customer.com.abc.flydns.net (Let's Encrypt validation) (not checked yet — re-run insta domain check)") expect(lines[4]).toBe(' certificate pending (provider status: Awaiting configuration)') }) - it('not added: one line pointing at set-domain, region still named, group + branch carried', () => { + it('not added: one line pointing at attach, region still named, group + branch carried', () => { expect(domainStatusLines({ ...bound, status: 'not added', dns: [] })).toEqual([ 'app.customer.com is not attached to api (us-east) — attach it with: insta domain attach app.customer.com', ]) @@ -350,9 +350,9 @@ describe('domainStatusLines (check-domain: every stage + where it routes)', () = describe('domainConflictMessage (bound elsewhere — domains are released, never moved)', () => { const conflict = (msg: string) => new ApiError(409, msg, { error: msg }) - it('owner named and in this project: the exact remove-domain command', () => { + it('owner named and in this project: the exact detach command', () => { expect(domainConflictMessage('app.customer.com', conflict('app.customer.com is already attached to web in us-west; remove it there first'), [api, web])) - .toBe('app.customer.com is already attached to web (us-west) — domains are not moved; release it first: insta domain detach app.customer.com --group web, then re-run set-domain') + .toBe('app.customer.com is already attached to web (us-west) — domains are not moved; release it first: insta domain detach app.customer.com --group web, then re-run insta domain attach') }) it('owner named but not a service here: held by a deleted service → operator must release', () => { From 7dd53f510392fc0f25f8c2a5390d16c9694a5f07 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 15:43:13 -0700 Subject: [PATCH 07/19] index: noun-first command tree (24 top-level), per-resource logs/metrics, agent + config groups, global --api-url Co-Authored-By: Claude Fable 5.1 --- src/commands/compute.ts | 2 + src/index.ts | 358 ++++++++++++++++++++---------------- test/compute-exec.test.ts | 7 + test/help-surface.test.ts | 119 ++++++++++++ test/mcp-token-cli.test.ts | 2 +- test/retired-policy.test.ts | 18 +- 6 files changed, 344 insertions(+), 162 deletions(-) create mode 100644 test/help-surface.test.ts diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 03de4d8..b3dcd10 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -427,6 +427,8 @@ function execCommandIndex(argv: string[]): number { for (let cursor = 2; cursor < argv.length; cursor++) { const token = argv[cursor]! if (token === '--agent') continue + if (token === '--api-url') { cursor++; continue } // root flag with a value: skip both tokens + if (token.startsWith('--api-url=')) continue if (token.startsWith('-')) return -1 // a global flag, or `--`: either way not our command path return token === 'compute' && argv[cursor + 1] === 'exec' ? cursor : -1 } diff --git a/src/index.ts b/src/index.ts index a0039b7..b9ba08c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { Command } from 'commander' +import { Command, Option } from 'commander' import { configureAgent, detectAgent } from './agent.js' +import { setApiUrlOverride } from './config.js' import * as agentPolicy from './commands/agent-policy.js' import { ApiError, AgentApprovalRequired } from './api.js' import { CliCancel, CliExit, fail, relayedExitCode } from './util.js' @@ -23,8 +24,9 @@ import { deploy } from './commands/deploy.js' import { build } from './commands/build.js' import * as computeCmd from './commands/compute.js' import * as githubCmd from './commands/github.js' -import * as dbCmd from './commands/postgres.js' +import * as pgCmd from './commands/postgres.js' import * as dbQueryCmd from './commands/db-query.js' +import * as managedDb from './commands/managed-db.js' import * as storageCmd from './commands/storage.js' import { manifest } from './commands/manifest.js' import * as template from './commands/template.js' @@ -68,8 +70,13 @@ const program = new Command() // group's own options only match before the subcommand name, so occurrences after it are matched // against the subcommand's own (identically-named) option instead. program.enablePositionalOptions() -program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(cliVersion()) +program.name('insta').description('InstaCloud CLI — manage projects, branches, services, deploys').version(cliVersion()) program.option('--agent', 'run as an agent with a verified project session and project agent policy') +program.option('--api-url ', 'control-plane API base URL for this invocation only — beats INSTA_API_URL, INSTA_ENV and the stored login; a URL for another deployment runs logged-out (internal debugging). Accepted before or after any subcommand') +// The runtime --api-url must be in place before any action loads config (ApiClient.load → +// readGlobal). optsWithGlobals merges the root's, a group's and the leaf's copy of the flag +// (addApiUrlEverywhere below), so it is honoured wherever it was typed; typed twice, the outermost wins. +program.hook('preAction', (_root, action) => setApiUrlOverride((action.optsWithGlobals() as { apiUrl?: string }).apiUrl)) program.hook('preAction', () => configureAgent(detectAgent(!!program.opts().agent))) // ---- auth ---- @@ -86,38 +93,13 @@ program.command('login').description('Log in — bare: sign in from your browser program.command('logout').description('Log out and clear local tokens').action(guard(() => auth.logout())) program.command('status').description('Show login + linked project').option('--json').action(guard((o) => auth.status(o))) -// ---- environment (prod | staging) ---- -const envCmd = program.command('env').description('Show or switch the deployment environment (prod | staging)') +// ---- environment (prod | staging) — hidden: `--api-url` covers the debugging case; kept working ---- +const envCmd = program.command('env', { hidden: true }).description('Show or switch the deployment environment (prod | staging)') envCmd.command('show', { isDefault: true }).description('Show the current environment and its hosts') .option('--json').action(guard((o) => envCmd_.envShow(o))) envCmd.command('use ').description(`Switch environment (${ENV_NAMES.join(' | ')}) — drops the stored session, which is deployment-specific`) .option('--json').action(guard((name, o) => envCmd_.envUse(name, o))) -// ---- run (per-request secret injection — nothing written to disk) ---- -program.command('run [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)') - .option('--branch ', 'branch bundle to inject (default: linked branch)') - .option('--service ', "inject one compute service's own slice of the branch bundle, e.g. compute/api — the unambiguous read when several services define the same name (NOT the container's env: it also carries the branch's provider credentials, which a container gets only where bound)") - .option('--ignore-collisions', 'run even when several services define the same name; every such name is REMOVED from the child environment (never inherited from your shell)') - .passThroughOptions().allowUnknownOption() - .action(guard((cmd, args, o) => runCmd.run([cmd, ...(args ?? [])], o))) - -// ---- agent setup (the `curl … | sh --agents` target) ---- -const setupCmd = program.command('setup').description('Set up this machine for InstaCloud agent workflows') -setupCmd.command('agent').description('Install the insta CLI (if missing), the insta skill for all coding agents, and the MCP server — targets production; pass --env staging for the staging deployment') - .option('-y, --yes', 'non-interactive') - .option('--env ', 'deployment to set this machine up for (default: prod — switches and persists, like `insta env use`)') - .option('--mcp-token', 'register Claude Code with a minted insta_ API token instead of OAuth (requires login and token-creation permission)') - .option('--project ', 'also link this directory to an existing project after setup (flows through login first if needed)') - .option('--create [name]', 'also create a new project and link this directory after setup (default name: this directory; mutually exclusive with --project)') - .action(guard((o) => setup.setupAgent(o))) - -// ---- MCP server integration ---- -const mcpCmd = program.command('mcp').description('insta-cloud remote MCP server integration') -mcpCmd.command('install').description('Register the remote MCP server with coding agents (default: Claude Code + all detected)') - .option('--agent ', 'one agent: claude-code, cursor, codex, opencode, copilot, factory-droid') - .option('--mcp-token', 'claude-code only: minted insta_ API token instead of OAuth (requires login and token-creation permission)') - .action(guard((o) => mcp.mcpInstall(o))) - // ---- org ---- const orgCmd = program.command('org').description('Manage organizations') orgCmd.command('list').option('--json').action(guard((o) => org.orgList(o))) @@ -139,8 +121,8 @@ br.command('delete ').option('--json').action(guard((name, o) => branch.br br.command('merge ').description('Merge a branch service set into another (structural, no data)') .option('--into ', 'target branch (default: current)').option('--json').action(guard((source, o) => branch.branchMerge(source, o))) -// ---- services (opt-in postgres/storage/compute/redis/mysql/mongodb) ---- -const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis|mysql|mongodb)') +// ---- service (opt-in postgres/storage/compute/redis/mysql/mongodb) ---- +const svc = program.command('service').aliases(['services', 'svc']).description('Manage project services: add / list / remove / rename (postgres|storage|compute|redis|mysql|mongodb)') // [type] [name] are optional so the command can answer "what can I add?" — a terminal is walked // through the dashboard's Add Service kinds, anything else gets that list back as an error // (resolve-service.ts). Picking Docker Image also fills in --image/--port from the answers. @@ -205,20 +187,60 @@ sec.command('sources').description('List service credential sources available fo sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json') .action(guard((o) => secretsCmd.secretsTree(o))) -// ---- build (pre-push verification — local, offline, deploys nothing) ---- -program.command('build [dir]').description('Verify a source directory would build before deploying: detection plan + the Dockerfile (yours, or the one nixpacks would generate server-side) + static checks. Local and offline — no login needed, nothing pushed. Exit 1 when the verdict is failed') - .option('--explain', 'include the Dockerfile content in the output') - .option('--port

', 'port the app listens on (else the Dockerfile EXPOSE)') - .option('--json') - .action(guard((dir, o) => build(dir, o))) +// ---- domain (bought here, or bring your own; hostnames on compute services; DNS of bought zones) ---- +const dom = program.command('domain').description('Domains: buy through InstaCloud or bring your own — attach / check / detach hostnames on compute services; DNS records of bought domains') +dom.command('search ').description('Search purchasable names with prices (a label like "myapp" or a full name like "myapp.com")') + .option('--tlds ', 'comma-separated TLDs to include').option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((keyword, o) => domainCmd.domainSearch(keyword, o))) +dom.command('buy ').description('Buy a domain — pay at the printed Stripe Checkout link. It serves nothing until you attach it (gated: domain.purchase)') + .option('--years ', 'registration term in years (default 1)') + .option('--no-open', 'print the checkout URL instead of opening a browser').option('--json') + .action(guard((name, o) => domainCmd.domainBuy(name, o))) +dom.command('attach ').description('Point a hostname at a compute service. A domain bought here: `abc.com` binds it and its www, `api.abc.com` binds only that. A domain you own elsewhere: the DNS records to publish in your own zone are printed (gated: deploy)') + .option('--branch ').option('--group ', "compute service (default: the branch's sole compute service)").option('--json') + .action(guard((hostname, o) => domainCmd.domainAttach(hostname, o))) +dom.command('check ').description("A hostname's attach state — ownership TXT, routing CNAME, edge certificate, where it resolves — and what each still needs") + .option('--branch ').option('--group ', "compute service (default: the branch's sole compute service)").option('--json') + .action(guard((hostname, o) => domainCmd.domainCheck(hostname, o))) +dom.command('detach ').description('Detach a hostname from its compute service (gated: deploy)') + .option('--branch ').option('--group ', "compute service (default: the branch's sole compute service)").option('--json') + .action(guard((hostname, o) => domainCmd.domainDetach(hostname, o))) +dom.command('list').description("Domains bought through InstaCloud in this org — a domain belongs to the org, each of its hostnames to a service").option('--json') + .action(guard((o) => domainCmd.domainList(o))) +dom.command('status ').description("A bought domain's order and attach state").option('--json') + .action(guard((name, o) => domainCmd.domainStatus(name, o))) +const rec = dom.command('records').description('DNS records of a bought domain — the zone InstaCloud holds at the registrar') +rec.command('list ').description('Every record in the zone, managed ones marked') + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, o) => domainCmd.domainRecordsList(domain, o))) +rec.command('add ').description('Add a record — type A|AAAA|CNAME|ANAME|MX|TXT|SRV|NS; name "@" for the domain itself, a label like "www", or the full hostname under it') + .option('--ttl ', 'time to live in seconds (default 300)').option('--priority ', 'MX and SRV only') + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, type, name, content, o) => domainCmd.domainRecordsAdd(domain, type, name, content, o))) +rec.command('set ').description('Change a record by its id (from `records list`); fields you omit keep their value') + .option('--type ', 'A|AAAA|CNAME|ANAME|MX|TXT|SRV|NS').option('--name ', '"@" for the domain itself, a label like "www", or the full hostname under it').option('--content ', 'the answer').option('--ttl ', 'time to live in seconds').option('--priority ', 'MX and SRV only') + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, id, o) => domainCmd.domainRecordsSet(domain, id, o))) +rec.command('remove ').description('Remove a record by its id (a record InstaCloud published for a live hostname is refused)') + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, id, o) => domainCmd.domainRecordsRemove(domain, id, o))) -// ---- deploy ---- -program.command('deploy [dir]').description('Deploy a source directory (built remotely; on insta-compute a Dockerfile is optional and nixpacks detects the runtime) or a prebuilt --image to a branch compute group') - .option('--image ', 'prebuilt container image to deploy (instead of a source dir)').option('--branch ').option('--group ').option('--port

') - .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)') - .option('--replace-source', 'the service deploys from a connected GitHub repo: switch it to this image and remove the repo connection (admin); without it such a deploy is refused') - .option('--json', 'print the deploy result as JSON (build progress goes to stderr)') - .action(guard((dir, o) => deploy(dir, o))) +// logs/metrics live under each resource; the platform component is fixed by the parent. One +// registration path so the five groups cannot drift apart in flags or wording. +function addObservability(group: Command, component: 'compute' | 'db' | 'redis' | 'mysql' | 'mongodb', noun: string): void { + group.command('metrics [service]').description(`${noun} metrics — last value per series (--json for the points)`) + .option('--branch ').option('--from ').option('--to ').option('--step ').option('--json') + .action(guard((service, o) => obs.metrics(component, service, o))) + const logs = group.command('logs [service]').description(component === 'db' + ? `${noun} logs (runtime; a window pages ~7 days of history)` + : `${noun} logs (runtime by default; --deploy = machine lifecycle events)`) + .option('--branch ').option('--limit ').option('--region ').option('--instance ').option('--json') + .option('--from ', '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 ', 'window end: unix seconds or ISO-8601 (default: now)') + .option('--since ', 'relative window start, e.g. 90s, 30m, 2h, 1d (shorthand for --from now-dur)') + if (component !== 'db') logs.option('--deploy', 'show deploy events (machine lifecycle) instead of runtime logs') + logs.action(guard((service, o) => obs.logs(component, service, o))) +} // `insta compute exec` needs the command verbatim after a literal `--`; split it out of argv here, // before commander parses anything (see splitExecArgs's own comment for why `service` being @@ -229,14 +251,8 @@ const { windowsFallback: execWindowsFallback, } = computeCmd.splitExecArgs(process.argv) -// ---- compute (lifecycle control + custom domains) ---- -const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/restart/status) + custom domains') -compute.command('set-domain ').description('Attach a custom domain to a branch compute service (gated: deploy)') - .option('--branch ').option('--group ').option('--json').action(guard((host, o) => computeCmd.setDomain(host, o))) -compute.command('check-domain ').description("Show a custom domain's cert status + required DNS records") - .option('--branch ').option('--group ').option('--json').action(guard((host, o) => computeCmd.checkDomain(host, o))) -compute.command('remove-domain ').description('Detach a custom domain (gated: deploy)') - .option('--branch ').option('--group ').option('--json').action(guard((host, o) => computeCmd.removeDomain(host, o))) +// ---- compute ---- +const compute = program.command('compute').description('Compute services: lifecycle (start/stop/suspend/restart/status), scale, limits, volume, always-on, exec, ssh, GitHub source, logs, metrics') compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)') .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o))) compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`') @@ -247,6 +263,10 @@ compute.command('restart [service]').description("Restart a compute service by r .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(service, o))) compute.command('status [service]').description("Show a compute service's desired vs. live state") .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o))) +compute.command('scale [service]').description('Set a compute service same-region replica count, 1 to 10 (paid plans only)') + .option('--region ', 'region to scale in (default: the service region)') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((count, service, o) => computeCmd.computeScale(count, service, o))) compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price") .option('--memory ', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu ', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)') .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o))) @@ -287,37 +307,61 @@ compute.command('volume [service]').description("Show, attach, grow, or delete a .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)') .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o))) +addObservability(compute, 'compute', 'compute') -// ---- db (postgres service controls + managed-DB query) ---- -const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero) + managed-DB query (mysql/redis/mongodb)') -db.command('url').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta db url)"` (gated: secrets.read). Provider credentials are not in `insta secrets` — this is the command that yields the DSN') - .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbUrl(o.group, o))) -db.command('connect').description("Open an interactive psql session on the postgres service (needs psql on PATH; gated: secrets.read). A suspended instance wakes on connect — the first prompt can take a few seconds. Exits with psql's own exit code") - .option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbConnect(o.group, o))) -db.command('limits').description("Show or set a postgres service's resource ceiling (any plan within the free cap, paid above it; insta-db-backed only). Moves both directions") - .option('--cpu ', "vCPU ceiling, e.g. 2 or 2500m").option('--memory ', "memory ceiling, e.g. 4Gi") - .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbLimits(o.group, o))) -db.command('stats').description("Postgres stats snapshot: connections vs the server's max (active count), cache hit rate, database size. insta-db-backed services answer without waking a suspended instance") - .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbStats(o.group, o))) -db.command('always-on ').description('Set a postgres service always-on (mode: on|off). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only') - .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((mode, o) => dbCmd.dbAlwaysOn(mode, o.group, o))) -db.command('volume').description("Show or grow a postgres service's provisioned volume (block disk; insta-db-backed only). No --size: print size and the plan cap (any plan). --size grows it (paid plans; grow-only — a provisioned disk cannot shrink). Billing is actual data stored — the size is a cap, not a price") - .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') - .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') - .action(guard((o) => dbCmd.dbVolume(o.group, o))) -db.command('query [args...]').description('Run a query/command against a managed database (mysql/redis/mongodb) via the console exec API. mysql/mongodb take one quoted statement; redis takes a pre-tokenized argv (e.g. `GET mykey`). Not for postgres — use `insta db url|connect` / the SQL editor') - .option('--database ', 'mongodb only — the database to run against (default admin)') +// ---- postgres ---- +const pg = program.command('postgres').description('Postgres services: connection string, psql, stats, resource ceiling, volume, always-on, logs, metrics') +pg.command('url [service]').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta postgres url)"` (gated: secrets.read). Provider credentials are not in `insta secrets` — this is the command that yields the DSN') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => pgCmd.dbUrl(service, o))) +pg.command('connect [service]').description("Open an interactive psql session on the postgres service (needs psql on PATH; gated: secrets.read). A suspended instance wakes on connect — the first prompt can take a few seconds. Exits with psql's own exit code") .option('--branch ', 'branch (default: current)') - .option('--json') - .action(guard((service, args, o) => dbQueryCmd.dbQuery(service, args, o))) + .action(guard((service, o) => pgCmd.dbConnect(service, o))) +pg.command('stats [service]').description("Postgres stats snapshot: connections vs the server's max (active count), cache hit rate, database size. insta-db-backed services answer without waking a suspended instance") + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => pgCmd.dbStats(service, o))) +pg.command('limits [service]').description("Show or set a postgres service's resource ceiling (any plan within the free cap, paid above it; insta-db-backed only). Moves both directions") + .option('--cpu ', 'vCPU ceiling, e.g. 2 or 2500m').option('--memory ', 'memory ceiling, e.g. 4Gi') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => pgCmd.dbLimits(service, o))) +pg.command('volume [service]').description("Show or grow a postgres service's provisioned volume (block disk; insta-db-backed only). No --size: print size and the plan cap (any plan). --size grows it (paid plans; grow-only — a provisioned disk cannot shrink). Billing is actual data stored — the size is a cap, not a price") + .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => pgCmd.dbVolume(service, o))) +pg.command('always-on [service]').description('Set a postgres service always-on (mode: on|off). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((mode, service, o) => pgCmd.dbAlwaysOn(mode, service, o))) +addObservability(pg, 'db', 'postgres') + +// ---- redis / mysql / mongodb (managed Fly databases) ---- +for (const type of ['redis', 'mysql', 'mongodb'] as const) { + const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, always-on, logs, metrics`) + const query = g.command('query [args...]').description(type === 'redis' + ? 'Run a redis command against the service via the console exec API — a pre-tokenized argv, e.g. `GET mykey`' + : `Run one quoted ${type} statement against the service via the console exec API`) + .option('--branch ', 'branch (default: current)').option('--json') + if (type === 'mongodb') query.option('--database ', 'the database to run against (default admin)') + query.action(guard((service, args, o) => dbQueryCmd.dbQuery(service, args, o, undefined, type))) + g.command('status [service]').description(`A ${type} service's live runtime health: healthy | crashed | starting | standby (scaled to zero, wakes on request — normal) | none | unknown`) + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => managedDb.managedStatus(type, service, o))) + g.command('limits [service]').description(`Show or set a ${type} service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the database may burn, it is not a price`) + .option('--memory ', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu ', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => computeCmd.serviceLimits(type, service, o))) + g.command('volume [service]').description(`Show, grow, or delete a ${type} service's data volume (mounted at the image's data directory). No flag: size and the plan cap (any plan). --size grows it (paid plans; grow-only). --delete DESTROYS the disk and ALL its data immediately (no undo). Billing is actual data stored — the size is a cap, not a price`) + .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') + .option('--delete', 'destroy the volume and ALL its data (irreversible; back up first)') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => computeCmd.serviceVolume(type, service, o))) + g.command('always-on [service]').description(`Set a ${type} service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero. Billing is actual usage either way`) + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((mode, service, o) => computeCmd.serviceAlwaysOn(type, mode, service, o))) + addObservability(g, type, type) +} -// ---- storage (bucket objects) ---- -const storage = program.command('storage').description("Browse, download, and delete a storage service's bucket objects") +// ---- storage (bucket objects + access mode) ---- +const storage = program.command('storage').description("Storage services: browse, download, delete bucket objects; set the bucket's access mode") storage.command('list').description("List the bucket's objects. S3 filters by prefix only — there is no substring search") .option('--prefix

', 'only keys starting with this prefix (applied server-side)') .option('--cursor ', 'continue from the nextCursor a previous page printed') @@ -335,6 +379,33 @@ storage.command('delete ').description('DELETES one object from the bucket .option('--service ', 'storage service (default: the sole one on the branch)') .option('--branch ', 'branch (default: current)').option('--json') .action(guard((key, o) => storageCmd.storageDelete(key, o))) +storage.command('set-access ').description("Set the bucket's access mode — public (anonymous public-read) or private (the default)") + .option('--service ', 'storage service (default: the sole one on the branch)') + .option('--branch ', 'branch (default: current)').option('--json') + .action(guard((access, o) => storageCmd.storageSetAccess(access, o))) + +// ---- build (pre-push verification — local, offline, deploys nothing) ---- +program.command('build [dir]').description('Verify a source directory would build before deploying: detection plan + the Dockerfile (yours, or the one nixpacks would generate server-side) + static checks. Local and offline — no login needed, nothing pushed. Exit 1 when the verdict is failed') + .option('--explain', 'include the Dockerfile content in the output') + .option('--port

', 'port the app listens on (else the Dockerfile EXPOSE)') + .option('--json') + .action(guard((dir, o) => build(dir, o))) + +// ---- deploy ---- +program.command('deploy [dir]').description('Deploy a source directory (built remotely; on insta-compute a Dockerfile is optional and nixpacks detects the runtime) or a prebuilt --image to a branch compute group') + .option('--image ', 'prebuilt container image to deploy (instead of a source dir)').option('--branch ').option('--group ').option('--port

') + .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)') + .option('--replace-source', 'the service deploys from a connected GitHub repo: switch it to this image and remove the repo connection (admin); without it such a deploy is refused') + .option('--json', 'print the deploy result as JSON (build progress goes to stderr)') + .action(guard((dir, o) => deploy(dir, o))) + +// ---- run (per-request secret injection — nothing written to disk) ---- +program.command('run [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)') + .option('--branch ', 'branch bundle to inject (default: linked branch)') + .option('--service ', "inject one compute service's own slice of the branch bundle, e.g. compute/api — the unambiguous read when several services define the same name (NOT the container's env: it also carries the branch's provider credentials, which a container gets only where bound)") + .option('--ignore-collisions', 'run even when several services define the same name; every such name is REMOVED from the child environment (never inherited from your shell)') + .passThroughOptions().allowUnknownOption() + .action(guard((cmd, args, o) => runCmd.run([cmd, ...(args ?? [])], o))) // ---- templates (registry, local insta.template.yaml, or a GitHub URL) ---- const tpl = program.command('template').description('Browse and deploy app templates (registry, a local dir, or a GitHub URL)') @@ -349,85 +420,31 @@ tpl.command('deploy ').description('Deploy a template onto a .option('--json') .action(guard((target, o) => template.templateDeploy(target, o))) -// ---- manifest ---- -program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o))) - -// ---- regions ---- -program.command('regions').description('List regions available for postgres/compute services').option('--json').action(guard((o) => regions.regionsList(o))) - -// ---- observability ---- -program.command('metrics [group]').description('Service metrics (target: db|compute|redis|mysql|mongodb)') - .option('--branch ').option('--from ').option('--to ').option('--step ').option('--json') - .action(guard((target, group, o) => obs.metrics(target, group, o))) -program.command('logs [group]').description('Service logs (runtime by default; --deploy = machine lifecycle events; target: db|compute|redis|mysql|mongodb)') - .option('--branch ').option('--limit ').option('--region ').option('--instance ').option('--deploy', 'show deploy events (machine lifecycle) instead of runtime logs — Fly-backed targets only, not db').option('--json') - .option('--from ', '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 ', 'window end: unix seconds or ISO-8601 (default: now)') - .option('--since ', 'relative window start, e.g. 90s, 30m, 2h, 1d (shorthand for --from now-dur)') - .action(guard((target, group, o) => obs.logs(target, group, o))) -program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)') - .option('--from ').option('--to ').option('--proj [id]', 'show one project (the linked one, or a given id) instead of the whole org').option('--json') - .action(guard((o) => obs.usage(o))) -// ---- domains bought through InstaCloud (BYO domains: `insta compute set-domain`) ---- -const dom = program.command('domain').description('Buy a domain through InstaCloud and attach it to a compute service (your own domain: `insta compute set-domain`)') -dom.command('search ').description('Search purchasable names with prices (a label like "myapp" or a full name like "myapp.com")') - .option('--tlds ', 'comma-separated TLDs to include').option('--org ', "target org (default: linked project's org)").option('--json') - .action(guard((keyword, o) => domainCmd.domainSearch(keyword, o))) -dom.command('buy ').description('Buy a domain — pay at the printed Stripe Checkout link. It serves nothing until you attach it (gated: domain.purchase)') - .option('--years ', 'registration term in years (default 1)') - .option('--no-open', 'print the checkout URL instead of opening a browser').option('--json') - .action(guard((name, o) => domainCmd.domainBuy(name, o))) -dom.command('attach ').description('Point a bought domain, or any subdomain of one, at a compute service — `abc.com` binds it and its www, `api.abc.com` binds only that (gated: deploy)') - .option('--branch ').option('--group ', "compute service (default: the branch's sole compute service)").option('--json') - .action(guard((hostname, o) => domainCmd.domainAttach(hostname, o))) -dom.command('list').description("Domains bought through InstaCloud in this org — a domain belongs to the org, each of its hostnames to a service").option('--json') - .action(guard((o) => domainCmd.domainList(o))) -dom.command('status ').description("A bought domain's order and attach state").option('--json') - .action(guard((name, o) => domainCmd.domainStatus(name, o))) -const rec = dom.command('records').description('DNS records of a bought domain — the zone InstaCloud holds at the registrar') -rec.command('list ').description('Every record in the zone, managed ones marked') - .option('--org ', "target org (default: linked project's org)").option('--json') - .action(guard((domain, o) => domainCmd.domainRecordsList(domain, o))) -rec.command('add ').description('Add a record — type A|AAAA|CNAME|ANAME|MX|TXT|SRV|NS; name "@" for the domain itself, a label like "www", or the full hostname under it') - .option('--ttl ', 'time to live in seconds (default 300)').option('--priority ', 'MX and SRV only') - .option('--org ', "target org (default: linked project's org)").option('--json') - .action(guard((domain, type, name, content, o) => domainCmd.domainRecordsAdd(domain, type, name, content, o))) -rec.command('set ').description('Change a record by its id (from `records list`); fields you omit keep their value') - .option('--type ', 'A|AAAA|CNAME|ANAME|MX|TXT|SRV|NS').option('--name ', '"@" for the domain itself, a label like "www", or the full hostname under it').option('--content ', 'the answer').option('--ttl ', 'time to live in seconds').option('--priority ', 'MX and SRV only') - .option('--org ', "target org (default: linked project's org)").option('--json') - .action(guard((domain, id, o) => domainCmd.domainRecordsSet(domain, id, o))) -rec.command('remove ').description('Remove a record by its id (a record InstaCloud published for a live hostname is refused)') - .option('--org ', "target org (default: linked project's org)").option('--json') - .action(guard((domain, id, o) => domainCmd.domainRecordsRemove(domain, id, o))) - -const bill = program.command('billing').description('Current billing cycle overview (tier / used / included / overage / credits / forecast + per-dimension & per-project breakdown)') +// ---- billing ---- +const bill = program.command('billing').description('Billing: current cycle overview (bare), subscribe to a tier, Stripe portal, usage by dimension') .option('--org ', 'target org (default: linked project\'s org)').option('--json') .action(guard((o) => billing(o))) -bill.command('upgrade ').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout') +bill.command('subscribe ').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout') .option('--org ').option('--no-open', 'print the URL instead of opening a browser').option('--json') .action(guard((tier, o) => billingUpgrade(tier, o))) bill.command('portal').description('Open the Stripe Customer Portal (change plan / card / cancel)') .option('--org ').option('--no-open', 'print the URL instead of opening a browser').option('--json') .action(guard((o) => billingPortal(o))) +bill.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)') + .option('--from ').option('--to ').option('--proj [id]', 'show one project (the linked one, or a given id) instead of the whole org').option('--json') + .action(guard((o) => obs.usage(o))) -// ---- events (audit timeline) ---- -program.command('events').description('Show the audit + agent-event timeline').option('--branch ').option('--limit ').option('--json').action(guard((o) => govern.events(o))) - -// ---- approvals ---- -const ap = program.command('approvals').description('Governance approvals (HITL)') -ap.command('list').option('--status ', 'pending|granted|denied|consumed').option('--json').action(guard((o) => govern.approvalsList(o))) -ap.command('approve ').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o))) -ap.command('deny ').option('--json').action(guard((id, o) => govern.approvalsDeny(id, o))) - -// ---- observe (local credential audit) ---- -const ob = program.command('observe').description('Local credential-audit hook') -ob.command('install').description('Install the PostToolUse hook into this project').action(guard(() => observe.observeInstall())) -ob.command('uninstall').action(guard(() => observe.observeUninstall())) -ob.command('report').description('Render the local credential audit').option('--json').action(guard((o) => observe.observeReport(o))) -ob.command('sync').description('Upload findings into the project timeline').action(guard(() => observe.observeSync())) - -// ---- policy ---- -const agentPol = program.command('agent-policy').description('Project agent access policy') +// ---- agent (this machine's coding agents + the project's agent governance) ---- +const agent = program.command('agent').description('Agents: set up this machine, the project manifest, access policy, approvals (HITL), the local credential audit, the event timeline') +agent.command('setup').description('Install the insta CLI (if missing), the insta skill for all coding agents, and the MCP server — targets production; pass --env staging for the staging deployment') + .option('-y, --yes', 'non-interactive') + .option('--env ', 'deployment to set this machine up for (default: prod — switches and persists, like `insta env use`)') + .option('--mcp-token', 'register Claude Code with a minted insta_ API token instead of OAuth (requires login and token-creation permission)') + .option('--project ', 'also link this directory to an existing project after setup (flows through login first if needed)') + .option('--create [name]', 'also create a new project and link this directory after setup (default name: this directory; mutually exclusive with --project)') + .action(guard((o) => setup.setupAgent(o))) +agent.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o))) +const agentPol = agent.command('policy').description('Project agent access policy') agentPol.command('get').option('--json').action(guard((o) => agentPolicy.get(o))) agentPol.command('set ').description('full-access | read-only | branch-specific (resets rules; customize comes from `rule set`)') .option('--json').action(guard((mode, o) => agentPolicy.set(mode, o))) @@ -437,6 +454,25 @@ agentPol.command('rule').command('set ').description('Set an .option('--json').action(guard((action, decision, o) => agentPolicy.rule(action, decision, o))) agentPol.command('revoke-sessions').description('Revoke ALL CLI agent sessions for this project') .option('--json').action(guard((o) => agentPolicy.revoke(o))) +const ap = agent.command('approvals').description('Governance approvals (HITL)') +ap.command('list').option('--status ', 'pending|granted|denied|consumed').option('--json').action(guard((o) => govern.approvalsList(o))) +ap.command('approve ').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o))) +ap.command('deny ').option('--json').action(guard((id, o) => govern.approvalsDeny(id, o))) +const ob = agent.command('observe').description('Local credential-audit hook') +ob.command('install').description('Install the PostToolUse hook into this project').action(guard(() => observe.observeInstall())) +ob.command('uninstall').action(guard(() => observe.observeUninstall())) +ob.command('report').description('Render the local credential audit').option('--json').action(guard((o) => observe.observeReport(o))) +ob.command('sync').description('Upload findings into the project timeline').action(guard(() => observe.observeSync())) +agent.command('events').description('Show the audit + agent-event timeline').option('--branch ').option('--limit ').option('--json').action(guard((o) => govern.events(o))) + +// ---- config (this machine's CLI configuration) ---- +const cfg = program.command('config').description('CLI configuration: register the remote MCP server with coding agents, list regions, auto-update') +cfg.command('install-mcp').description('Register the remote MCP server with coding agents (default: Claude Code + all detected)') + .option('--agent ', 'one agent: claude-code, cursor, codex, opencode, copilot, factory-droid') + .option('--mcp-token', 'claude-code only: minted insta_ API token instead of OAuth (requires login and token-creation permission)') + .action(guard((o) => mcp.mcpInstall(o))) +cfg.command('regions').description('List regions available for postgres/compute services').option('--json').action(guard((o) => regions.regionsList(o))) +cfg.command('autoupdate [mode]').description('Show or set auto-update: on | off (default: on while pre-1.0)').action(guard((mode) => selfUpdate.autoupdate(mode))) // ---- feedback (agent + human hurdle reports → the InstaCloud team) ---- program.command('feedback') @@ -459,8 +495,6 @@ program.command('feedback') // ---- self-update ---- program.command('upgrade').description('Update the insta CLI to the latest release (binary or npm install)') .action(guard(() => selfUpdate.upgrade(cliVersion()))) -program.command('autoupdate [mode]').description('Show or set auto-update: on | off (default: on while pre-1.0)') - .action(guard((mode) => selfUpdate.autoupdate(mode))) program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck(cliVersion()))) // The ssh_config renewal hook. Hidden, and named with the `__` prefix that // trackCommand skips, because OpenSSH runs it while PARSING the config on EVERY @@ -469,5 +503,21 @@ program.command('__update-check', { hidden: true }).action(guard(() => selfUpdat program.command('__ssh-ensure-cert ', { hidden: true }) .action(guard((alias: string) => computeCmd.ensureCertForAlias(alias))) +// `--api-url` reaches every command, hidden from each command's own help (the root documents it +// once). It must be declared per command: positional-options mode matches the root's options only +// BEFORE the subcommand name, so `insta compute status --api-url X` is legal only if `status` knows +// the flag. `login` keeps its own copy (that one persists the URL). `compute exec` is skipped: +// splitExecArgs reads argv ahead of commander and does not know this option takes a value — for +// exec, pass it at the root: `insta --api-url X compute exec …` (execCommandIndex skips it there). +function addApiUrlEverywhere(cmd: Command): void { + for (const sub of cmd.commands) { + if (!(cmd.name() === 'compute' && sub.name() === 'exec') && !sub.options.some((o) => o.long === '--api-url')) { + sub.addOption(new Option('--api-url ').hideHelp()) + } + addApiUrlEverywhere(sub) + } +} +addApiUrlEverywhere(program) + selfUpdate.maybeUpdate(cliVersion(), process.argv) program.parseAsync(computeArgv) diff --git a/test/compute-exec.test.ts b/test/compute-exec.test.ts index 17c572a..3cb2b6c 100644 --- a/test/compute-exec.test.ts +++ b/test/compute-exec.test.ts @@ -42,6 +42,13 @@ describe('splitExecArgs', () => { expect(splitExecArgs(A('app', 'echo', 'hi'), 'linux')).toEqual({ argv: A('app', 'echo', 'hi') }) }) + it('finds `compute exec` behind a root --api-url (with a value, or =value)', () => { + expect(splitExecArgs(['node', 'insta', '--api-url', 'http://x', 'compute', 'exec', 'api', '--', 'ls'], 'linux')) + .toEqual({ argv: ['node', 'insta', '--api-url', 'http://x', 'compute', 'exec', 'api'], command: ['ls'] }) + expect(splitExecArgs(['node', 'insta', '--api-url=http://x', '--agent', 'compute', 'exec', '--', 'ls'], 'linux')) + .toEqual({ argv: ['node', 'insta', '--api-url=http://x', '--agent', 'compute', 'exec'], command: ['ls'] }) + }) + // ---- the shim ate the separator (win32 only) ---- // // Everything from the first non-option token is PAYLOAD and is never interpreted, so the remote diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts new file mode 100644 index 0000000..8073566 --- /dev/null +++ b/test/help-surface.test.ts @@ -0,0 +1,119 @@ +// The command surface is a contract with the skill docs and every agent that read them. This pins +// the visible top level (spec 2026-09-17-cli-command-reorg-design §4), the permanent aliases, the +// hidden `env`, that retired paths are GONE (hard cutover, no hidden aliases), and that the runtime +// --api-url is honoured wherever it is typed. +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const entry = fileURLToPath(new URL('../src/index.ts', import.meta.url)) +const env = { ...process.env, INSTA_NO_AUTOUPDATE: '1', INSTA_NO_TELEMETRY: '1' } +const run = (args: string[], extraEnv: NodeJS.ProcessEnv = {}) => + spawnSync(process.execPath, ['--import', 'tsx', entry, ...args], { encoding: 'utf8', timeout: 30_000, env: { ...env, ...extraEnv } }) + +// Adding a name here is a design decision — see "Command architecture" in +// .claude/skills/developing-insta-cli/SKILL.md. +const VISIBLE = [ + 'login', 'logout', 'status', + 'org', 'project', 'branch', + 'service', 'secrets', 'domain', 'compute', 'postgres', 'redis', 'mysql', 'mongodb', 'storage', + 'build', 'deploy', 'run', 'template', + 'billing', 'agent', 'config', + 'feedback', 'upgrade', +] +// Asserted WITHOUT a trailing --help: commander answers `--help` on an unrecognised path by +// printing the nearest known command's help and exiting 0 (it looks for the help flag before it +// decides the command is unknown), so `insta db --help` would pass against any tree. The bare +// path is the invocation that actually has to fail, and it does. +const RETIRED: string[][] = [ + ['services', 'scale'], ['services', 'upgrade'], ['services', 'set-access'], ['services', 'secrets'], + ['compute', 'set-domain'], ['compute', 'check-domain'], ['compute', 'remove-domain'], + ['db'], ['metrics'], ['logs'], ['usage'], ['manifest'], ['approvals'], ['agent-policy'], ['observe'], ['events'], + ['setup'], ['mcp'], ['regions'], ['autoupdate'], +] + +// Command names from a commander help page: the lines indented exactly two spaces under +// "Commands:" (continuation lines of a wrapped description are indented to the description column). +function commandNames(help: string): string[] { + const section = help.slice(help.indexOf('Commands:')) + return section.split('\n').slice(1) + .map((l) => /^ {2}(\S+)/.exec(l)?.[1]) + .filter((n): n is string => !!n) + .map((n) => n.split('|')[0]!) + .filter((n) => n !== 'help') +} + +describe('top-level surface', () => { + it('lists exactly the designed 24 commands, in order', () => { + const r = run(['--help']) + expect(r.status).toBe(0) + expect(commandNames(r.stdout)).toEqual(VISIBLE) + }) + it('documents --api-url once, on the root', () => { + expect(run(['--help']).stdout).toContain('--api-url ') + expect(run(['compute', 'status', '--help']).stdout).not.toContain('--api-url') + }) + it('keeps services and svc as aliases of service', () => { + for (const alias of ['services', 'svc']) { + const r = run([alias, '--help']) + expect(r.status, alias).toBe(0) + expect(r.stdout).toMatch(/^\s+add\b/m) + expect(r.stdout).not.toMatch(/^\s+scale\b/m) + } + }) + it('hides env from the root help but keeps it working', () => { + expect(run(['--help']).stdout).not.toMatch(/^\s+env\b/m) + const r = run(['env', '--help']) + expect(r.status).toBe(0) + expect(r.stdout).toMatch(/^\s+use\b/m) + }) + it.each(RETIRED)('retired path `%s` is gone', (...path) => { + const r = run([...path]) + expect(r.status).not.toBe(0) + expect(r.stderr).toMatch(/unknown command/) + }, 30_000) +}) + +describe('group shapes', () => { + it('postgres verbs take a trailing [service] and no --group', () => { + for (const verb of ['url', 'connect', 'stats', 'limits', 'volume', 'logs', 'metrics']) { + const r = run(['postgres', verb, '--help']) + expect(r.stdout, verb).toMatch(new RegExp(`Usage: insta postgres ${verb} \\[options\\] \\[service\\]`)) + expect(r.stdout, verb).not.toContain('--group') + } + expect(run(['postgres', 'always-on', '--help']).stdout).toContain('Usage: insta postgres always-on [options] [service]') + }) + it('each managed database has the same verbs', () => { + for (const type of ['redis', 'mysql', 'mongodb']) { + const names = commandNames(run([type, '--help']).stdout) + expect(names, type).toEqual(['query', 'status', 'limits', 'volume', 'always-on', 'metrics', 'logs']) + } + expect(run(['mongodb', 'query', '--help']).stdout).toContain('--database') + expect(run(['redis', 'query', '--help']).stdout).not.toContain('--database') + }) + it('compute has scale, logs, metrics and no domain verbs; domain has attach, check, detach', () => { + const compute = commandNames(run(['compute', '--help']).stdout) + expect(compute).toEqual(expect.arrayContaining(['scale', 'logs', 'metrics', 'limits', 'volume', 'always-on', 'exec', 'ssh'])) + expect(compute).not.toEqual(expect.arrayContaining(['set-domain'])) + const domain = commandNames(run(['domain', '--help']).stdout) + expect(domain).toEqual(expect.arrayContaining(['attach', 'check', 'detach', 'records'])) + expect(run(['compute', 'scale', '--help']).stdout).toContain('Usage: insta compute scale [options] [service]') + expect(run(['storage', 'set-access', '--help']).stdout).toContain('Usage: insta storage set-access [options] ') + }) + it('agent, config and billing carry the moved verbs', () => { + expect(commandNames(run(['agent', '--help']).stdout)).toEqual(['setup', 'manifest', 'policy', 'approvals', 'observe', 'events']) + expect(commandNames(run(['config', '--help']).stdout)).toEqual(['install-mcp', 'regions', 'autoupdate']) + expect(commandNames(run(['billing', '--help']).stdout)).toEqual(['subscribe', 'portal', 'usage']) + }) +}) + +describe('--api-url placement', () => { + const URL_A = 'http://127.0.0.1:1' + const URL_B = 'http://127.0.0.1:2' + // `env --json` reads config and prints the resolved apiUrl without touching the network. + it('is honoured after the subcommand, before it, and over INSTA_API_URL', () => { + expect(JSON.parse(run(['env', '--json', '--api-url', URL_A]).stdout).apiUrl).toBe(URL_A) + expect(JSON.parse(run(['--api-url', URL_A, 'env', '--json']).stdout).apiUrl).toBe(URL_A) + expect(JSON.parse(run(['env', '--json', '--api-url', URL_A], { INSTA_API_URL: URL_B }).stdout).apiUrl).toBe(URL_A) + }) +}) diff --git a/test/mcp-token-cli.test.ts b/test/mcp-token-cli.test.ts index 09c751e..77220fc 100644 --- a/test/mcp-token-cli.test.ts +++ b/test/mcp-token-cli.test.ts @@ -52,7 +52,7 @@ test.each([true, false])('CLI exits nonzero for failed token registration (store for (const key of ['INSTA_ENV', 'INSTA_PROJECT_ID', 'INSTA_ORG_ID', 'INSTA_BRANCH']) delete childEnv[key as keyof typeof childEnv] const output = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { const child = spawn(process.execPath, ['--import', loader, entry, - '--agent', 'mcp', 'install', '--agent', 'claude-code', '--mcp-token'], { cwd: home, env: childEnv }) + '--agent', 'config', 'install-mcp', '--agent', 'claude-code', '--mcp-token'], { cwd: home, env: childEnv }) let stdout = '', stderr = '' const timer = setTimeout(() => { child.kill(); reject(new Error('CLI timed out')) }, 10000) child.stdout.on('data', (chunk) => { stdout += chunk }) diff --git a/test/retired-policy.test.ts b/test/retired-policy.test.ts index 88c5037..30734f6 100644 --- a/test/retired-policy.test.ts +++ b/test/retired-policy.test.ts @@ -5,21 +5,25 @@ import { expect, it } from 'vitest' const entry = fileURLToPath(new URL('../src/index.ts', import.meta.url)) const run = (...args: string[]) => spawnSync(process.execPath, ['--import', 'tsx', entry, ...args], { encoding: 'utf8', timeout: 10000 }) -it('only exposes agent-policy and rejects the retired policy command', () => { +it('exposes policy only under agent, and rejects the retired top-level names', () => { const help = run('--help') expect(help.status).toBe(0) - expect(help.stdout).toContain('agent-policy') + expect(help.stdout).toMatch(/^\s+agent\s/m) expect(help.stdout).not.toMatch(/^\s+policy\s/m) - const retired = run('policy', 'get') - expect(retired.status).not.toBe(0) - expect(retired.stderr).toContain("unknown command 'policy'") + expect(help.stdout).not.toMatch(/^\s+agent-policy\s/m) + for (const retired of [['policy', 'get'], ['agent-policy', 'get']]) { + const r = run(...retired) + expect(r.status).not.toBe(0) + expect(r.stderr).toContain(`unknown command '${retired[0]}'`) + } + expect(run('agent', 'policy', 'get', '--help').status).toBe(0) }) it('rejects approval --always instead of promising a permanent grant', () => { - const help = run('approvals', 'approve', '--help') + const help = run('agent', 'approvals', 'approve', '--help') expect(help.status).toBe(0) expect(help.stdout).not.toContain('--always') - const retired = run('approvals', 'approve', 'test-id', '--always') + const retired = run('agent', 'approvals', 'approve', 'test-id', '--always') expect(retired.status).not.toBe(0) expect(retired.stderr).toContain("unknown option '--always'") }) From 54e65bfccbb3443061ec9a58085fdfdb53d8cc53 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 15:50:45 -0700 Subject: [PATCH 08/19] index: --region hints name config regions; surface test timeouts Co-Authored-By: Claude Fable 5.1 --- src/index.ts | 4 ++-- test/help-surface.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index b9ba08c..db5b058 100644 --- a/src/index.ts +++ b/src/index.ts @@ -128,7 +128,7 @@ const svc = program.command('service').aliases(['services', 'svc']).description( // (resolve-service.ts). Picking Docker Image also fills in --image/--port from the answers. svc.command('add [type] [name]').description('Provision a service on demand (assigns a default domain for postgres/compute); with no type/name, a terminal picks from the service kinds') .option('--branch ', 'target branch (default: current)') - .option('--region ', 'region for postgres/compute/managed databases, e.g. us-east (see `insta regions`)') + .option('--region ', 'region for postgres/compute/managed databases, e.g. us-east (see `insta config regions`)') .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)') .option('--image ', 'compute only: run this container image at creation') .option('--port ', 'compute only: port the image listens on (default 8080)') @@ -414,7 +414,7 @@ tpl.command('info ').description('Show a template: version, upstream pin, .option('--json').action(guard((code, o) => template.templateInfo(code, o))) tpl.command('deploy ').description('Deploy a template onto a branch — a registry code, a local directory containing insta.template.yaml (a path-looking target is always read as a directory), or a github.com URL (https://github.com//[/tree/[/

]]) whose manifest is fetched with your own git credentials. Missing required variables are prompted for on a terminal; generator-backed (secret:N) and defaulted ones are resolved by the platform') .option('--branch ', 'target branch (default: current)') - .option('--region ', 'region for every service the template creates, e.g. us-east (see `insta regions`)') + .option('--region ', 'region for every service the template creates, e.g. us-east (see `insta config regions`)') .option('--set ', 'set a template variable (repeatable)', (v: string, prev: string[]) => [...prev, v], [] as string[]) .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting') .option('--json') diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index 8073566..57a61bf 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -82,7 +82,7 @@ describe('group shapes', () => { expect(r.stdout, verb).not.toContain('--group') } expect(run(['postgres', 'always-on', '--help']).stdout).toContain('Usage: insta postgres always-on [options] [service]') - }) + }, 30_000) it('each managed database has the same verbs', () => { for (const type of ['redis', 'mysql', 'mongodb']) { const names = commandNames(run([type, '--help']).stdout) @@ -90,7 +90,7 @@ describe('group shapes', () => { } expect(run(['mongodb', 'query', '--help']).stdout).toContain('--database') expect(run(['redis', 'query', '--help']).stdout).not.toContain('--database') - }) + }, 30_000) it('compute has scale, logs, metrics and no domain verbs; domain has attach, check, detach', () => { const compute = commandNames(run(['compute', '--help']).stdout) expect(compute).toEqual(expect.arrayContaining(['scale', 'logs', 'metrics', 'limits', 'volume', 'always-on', 'exec', 'ssh'])) @@ -99,7 +99,7 @@ describe('group shapes', () => { expect(domain).toEqual(expect.arrayContaining(['attach', 'check', 'detach', 'records'])) expect(run(['compute', 'scale', '--help']).stdout).toContain('Usage: insta compute scale [options] [service]') expect(run(['storage', 'set-access', '--help']).stdout).toContain('Usage: insta storage set-access [options] ') - }) + }, 30_000) it('agent, config and billing carry the moved verbs', () => { expect(commandNames(run(['agent', '--help']).stdout)).toEqual(['setup', 'manifest', 'policy', 'approvals', 'observe', 'events']) expect(commandNames(run(['config', '--help']).stdout)).toEqual(['install-mcp', 'regions', 'autoupdate']) From 13dcc03594053827fa1b2deab0d26ce7e3673464 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 16:04:57 -0700 Subject: [PATCH 09/19] hints, next-action commands and telemetry redaction follow the new command paths Co-Authored-By: Claude Fable 5.1 --- src/agent.ts | 4 ++-- src/api.ts | 2 +- src/commands/agent-policy.ts | 2 +- src/commands/billing.ts | 6 +++--- src/commands/compute.ts | 6 +++--- src/commands/deploy.ts | 2 +- src/commands/env.ts | 2 +- src/commands/mcp.ts | 4 ++-- src/commands/metrics.ts | 11 ++++++----- src/commands/observe.ts | 4 ++-- src/commands/regions.ts | 2 +- src/commands/services.ts | 15 ++++++++------- src/commands/setup.ts | 14 +++++++------- src/commands/template.ts | 8 ++++---- src/commands/upgrade.ts | 8 ++++---- src/ensure-skills.ts | 2 +- src/observe/hook.ts | 2 +- src/observe/install.ts | 2 +- src/resolve-service.ts | 8 ++++---- src/telemetry.ts | 16 ++++++++-------- src/util.ts | 15 +++++++++------ test/agent.test.ts | 4 ++-- test/billing.test.ts | 12 ++++++------ test/compute-domain.test.ts | 2 +- test/compute-exec.test.ts | 2 +- test/db-query.test.ts | 4 ++-- test/deploy-lane.test.ts | 2 +- test/domain.test.ts | 2 +- test/limits.test.ts | 2 +- test/logs-deploy.test.ts | 2 +- test/logs-window-flags.test.ts | 2 +- test/manifest-label.test.ts | 2 +- test/metrics-line.test.ts | 2 +- test/regions.test.ts | 2 +- test/resolve-service.test.ts | 20 ++++++++++---------- test/storage.test.ts | 2 +- test/telemetry.test.ts | 16 ++++++++-------- test/template.test.ts | 2 +- test/util.test.ts | 8 ++++---- test/volume.test.ts | 2 +- 40 files changed, 115 insertions(+), 110 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index be2fd99..4e1133c 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -21,7 +21,7 @@ export function canonicalTarget(path: string): string { const url = new URL(path, 'https://platform.invalid') return url.pathname + url.search } -const guidance = 'agent session missing, expired, or for another project/environment — run `insta setup agent`' +const guidance = 'agent session missing, expired, or for another project/environment — run `insta agent setup`' export async function issueAgentSession(api: SessionApi, projectId?: string): Promise { const pair = generateKeyPairSync('ed25519') @@ -35,7 +35,7 @@ export async function issueAgentSession(api: SessionApi, projectId?: string): Pr export async function saveAgentSession(session: Session, cwd = process.cwd()): Promise { const root = await findProjectRoot(cwd) ?? cwd const rel = '.insta/agent-session.json' - if (alreadyTracked(root, [rel]).length) throw new Error('agent-session.json is tracked by Git; untrack it before running insta setup agent') + if (alreadyTracked(root, [rel]).length) throw new Error('agent-session.json is tracked by Git; untrack it before running insta agent setup') ensureGitignore(root, [rel], '# Local agent credentials') const dir = join(root, '.insta') await mkdir(dir, { recursive: true }) diff --git a/src/api.ts b/src/api.ts index 63244e8..abb1ee5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -138,7 +138,7 @@ export async function requireProject(deps: RequireProjectDeps = {}): Promise`') + if (agentMode()) die('agent mode requires a linked project — run `insta agent setup --project `') if (deps.autoResolve) return deps.autoResolve() // One command, just works: unlinked ≠ error. Resolve the project (auto when there's one, // one-keystroke picker when several) and persist the choice so this happens once per dir. diff --git a/src/commands/agent-policy.ts b/src/commands/agent-policy.ts index 24cc851..efc6749 100644 --- a/src/commands/agent-policy.ts +++ b/src/commands/agent-policy.ts @@ -81,7 +81,7 @@ export function applyRule(policy: Record, out: Record, // old one. Nothing below applies there. if (!catalog) return { ...policy, branchDeveloperRules: { ...(policy.branchDeveloperRules ?? {}), [action]: decision } } const entry = catalog.find(e => e.action === action) - if (!entry) throw new Error(`unknown action: ${action}\nrun: insta agent-policy get --json`) + if (!entry) throw new Error(`unknown action: ${action}\nrun: insta agent policy get --json`) if (!entry.editable) throw new Error(`${action} is a fixed policy invariant and cannot be overridden`) // Snapshot taken from the resolved unprotected-branch view, which is the context these rules // apply to; protected branches stay a separate, fixed denial. diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 55b5623..a4a8af8 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -60,7 +60,7 @@ export function billingLines(s: BillingOverview, org?: string): string[] { const ended = s.subscriptionStatus === 'canceled' || s.subscriptionStatus === 'incomplete_expired' lines.push( s.tier === 'free' - ? `⚠ org suspended — billing limit reached; resumes next cycle (or \`insta billing upgrade pro${flag}\`)` + ? `⚠ org suspended — billing limit reached; resumes next cycle (or \`insta billing subscribe pro${flag}\`)` : lapsed ? `⚠ org suspended — subscription payment did not go through; settle it in \`insta billing portal${flag}\`` : ended @@ -70,7 +70,7 @@ export function billingLines(s: BillingOverview, org?: string): string[] { ? '⚠ org suspended — the subscription ended; contact support to restore this plan' // Their OWN tier, not a hardcoded one: suggesting `upgrade pro` to a Team org // resubscribes it onto the wrong plan. - : `⚠ org suspended — the subscription ended; resubscribe with \`insta billing upgrade ${s.tier}${flag}\`` + : `⚠ org suspended — the subscription ended; resubscribe with \`insta billing subscribe ${s.tier}${flag}\`` // Deliberately claims nothing about the subscription: `incomplete` reaches here too, // and that one is neither current nor failed. All this branch knows is that the // suspension has no billing cause it can name. @@ -97,7 +97,7 @@ export async function billing(opts: OrgOpt & { json?: boolean }): Promise for (const l of billingLines(s, opts.org)) info(l) } -// insta billing upgrade — start a Stripe Checkout to subscribe the org to a paid tier. +// insta billing subscribe — start a Stripe Checkout to subscribe the org to a paid tier. export async function billingUpgrade(tier: string, opts: OrgOpt & { open?: boolean; json?: boolean }): Promise { // pro|team, matching what POST /orgs/:orgId/billing/checkout accepts: `team` is a real // self-serve tier, and `enterprise` is per-deal and 400s at the server. diff --git a/src/commands/compute.ts b/src/commands/compute.ts index b3dcd10..54ba332 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -11,7 +11,7 @@ export type ManagedType = 'compute' | 'redis' | 'mysql' | 'mongodb' // ---- custom domains (bring your own hostname) ---- // -// A compute service's region is fixed at creation (`insta services add compute --region`), and a +// A compute service's region is fixed at creation (`insta service add compute --region`), and a // custom hostname routes in the router of the region that OWNS the service. So the region is // DETECTED from the service, never chosen for the domain — the customer's DNS is one region-agnostic // CNAME target either way — and every line below names it so the user knows where traffic lands. @@ -43,7 +43,7 @@ export function resolveDomainTarget(services: ComputeRow[], host: string, group? if (isWorker(svc)) throw new Error(`${svc.name} is a worker (port 0) — it has no HTTP endpoint, so ${host} cannot serve from it`) return svc } - if (compute.length === 0) throw new Error('no compute service in this project (add one with `insta services add compute `)') + if (compute.length === 0) throw new Error('no compute service in this project (add one with `insta service add compute `)') if (compute.length === 1) { const only = compute[0]! if (isWorker(only)) throw new Error(`${only.name} is a worker (port 0) — it has no HTTP endpoint, so ${host} cannot serve from it`) @@ -481,7 +481,7 @@ export function resolveExecFallback( const [head, ...rest] = payload if (head === undefined) return { serviceName: undefined, command: [] } if (!services.some((service) => service.type === 'compute' && service.name === head)) { - note(`note: no \`--\` separator was found and \`${head}\` is not a compute service, so it was read as the command. If \`${head}\` was the service, check the name with \`insta services list\`.`) + note(`note: no \`--\` separator was found and \`${head}\` is not a compute service, so it was read as the command. If \`${head}\` was the service, check the name with \`insta service list\`.`) return { serviceName: undefined, command: payload } } // `head` really is a service, so whatever follows it cannot be the command's first token. diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index b3f0a10..b5a97d3 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -75,7 +75,7 @@ async function discoverLane(api: Pick, projectId: strin if (e instanceof ApiError && e.status === 404) { // The route answered and the TARGET is what is missing: say so, or flyctl turns it into "no Dockerfile". // Both ways out: the project has several groups and none is named, or it has none at all. - if (/compute group not found|branch not found/.test(e.message)) die(`${e.message} — name it with \`--group \` (see \`insta services list\`), or add one: \`insta services add compute \``) + if (/compute group not found|branch not found/.test(e.message)) die(`${e.message} — name it with \`--group \` (see \`insta service list\`), or add one: \`insta service add compute \``) return { lane: 'legacy' } } throw e diff --git a/src/commands/env.ts b/src/commands/env.ts index c02a151..b19e722 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -77,5 +77,5 @@ export async function envUse(name: string, opts: { json?: boolean } = {}): Promi // files were written for the previous environment and are keyed by a different server name, so // they keep talking to it until setup is re-run. --env is REQUIRED in the hint: since 0.0.38 a // bare `setup agent` forces prod, which would silently undo the switch the user just made. - info(` re-point this machine's agents at it with: insta setup agent --env ${target}`) + info(` re-point this machine's agents at it with: insta agent setup --env ${target}`) } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 5b8538b..6961b93 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1,4 +1,4 @@ -// `insta mcp install` — write the insta-cloud remote MCP server into each coding agent's own +// `insta config install-mcp` — write the insta-cloud remote MCP server into each coding agent's own // config format. Claude Code is NOT handled here — it has a real registry CLI (`claude mcp add`, // see setup.ts registerMcp); these are the config-file agents. All entries are OAuth (no // credential written): each client discovers the platform AS via RFC 9728 and runs the browser @@ -110,7 +110,7 @@ export async function installAgentConfigs(agent?: string, home: string = os.home return done } -// `insta mcp install [--agent ] [--mcp-token]` — claude-code goes through its registry CLI +// `insta config install-mcp [--agent ] [--mcp-token]` — claude-code goes through its registry CLI // (registerMcp); everything else is a config-file write. No --agent = claude-code + all detected. export async function mcpInstall( opts: { agent?: string; mcpToken?: boolean }, diff --git a/src/commands/metrics.ts b/src/commands/metrics.ts index 3a3f214..b275987 100644 --- a/src/commands/metrics.ts +++ b/src/commands/metrics.ts @@ -60,7 +60,7 @@ function humanBytes(v: number, base: 1000 | 1024): string { return `${shown} ${units[i]}` } -// insta metrics [group] +// shared handler behind `insta metrics [service]` export async function metrics(component: string, group: string | undefined, opts: { branch?: string; from?: string; to?: string; step?: string; json?: boolean }): Promise { const api = await ApiClient.load() const p = await requireProject() @@ -83,7 +83,7 @@ export function cycleLine(res: { from: number; to: number }): string { return `billing cycle ${day(res.from)} → ${day(res.to - 86400)}` } -// Pure: format the per-dimension lines (label: qty unit (cost)). Shared by `insta usage` and +// Pure: format the per-dimension lines (label: qty unit (cost)). Shared by `insta billing usage` and // `insta billing` so both render dimensions identically. export function dimensionLines(dims: Dim[]): string[] { return dims.map((d) => { @@ -97,7 +97,7 @@ function printDimensions(dims: Dim[]): void { for (const l of dimensionLines(dims)) info(l) } -// insta usage — usage across the 5 billing dimensions (cpu/memory/volume/egress/storage) for the +// insta billing usage — usage across the 5 billing dimensions (cpu/memory/volume/egress/storage) for the // current billing cycle. Shows the whole ORG by default (with a per-project breakdown); pass --proj // [id] for a single project (the linked one, or a given id). Billed dimensions, not raw provider // meters. @@ -128,7 +128,8 @@ export async function usage(opts: { from?: string; to?: string; json?: boolean; } } -// pure: platform path for a deploy-events request (used by `insta logs --deploy`). Any Fly-backed +// pure: platform path for a deploy-events request (used by `insta compute logs --deploy` and the +// equivalent on redis/mysql/mongodb). Any Fly-backed // component (compute or a managed database) has machine lifecycle events; omitted → the platform // defaults to compute. export function deployEventsPath(projectId: string, opts: { component?: string; group?: string; branch?: string; limit?: string; instance?: string }): string { @@ -171,7 +172,7 @@ export function resolveLogWindow( return { from, to } } -// insta logs [group] +// shared handler behind `insta logs [service]` export async function logs(component: string, group: string | undefined, opts: { branch?: string; limit?: string; region?: string; instance?: string; json?: boolean; deploy?: boolean; from?: string; to?: string; since?: string }): Promise { 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') diff --git a/src/commands/observe.ts b/src/commands/observe.ts index b6b582f..5ceae3b 100644 --- a/src/commands/observe.ts +++ b/src/commands/observe.ts @@ -1,4 +1,4 @@ -// `insta observe` — the local credential-audit hook. install wires a PostToolUse hook into the +// `insta agent observe` — the local credential-audit hook. install wires a PostToolUse hook into the // agent harness; report renders the local audit; sync uploads findings into the project timeline // (idempotent via a stable dedup key, matching the platform's audit-event ingest). import { existsSync } from 'node:fs' @@ -67,7 +67,7 @@ export async function observeInstall(): Promise { const hint = untrackHint(res.tracked) if (hint) info(hint) info('it scans agent tool-use for credential exposure; findings append to ./.insta/audit.jsonl') - info('run `insta observe report` to review, `insta observe sync` to upload to the project timeline') + info('run `insta agent observe report` to review, `insta agent observe sync` to upload to the project timeline') } export async function observeUninstall(): Promise { diff --git a/src/commands/regions.ts b/src/commands/regions.ts index 1d7eeb6..a0154ae 100644 --- a/src/commands/regions.ts +++ b/src/commands/regions.ts @@ -1,7 +1,7 @@ import { ApiClient } from '../api.js' import { info, printJson } from '../util.js' -// insta regions — list the regions a postgres/compute service can be created in. +// insta config regions — list the regions a postgres/compute service can be created in. export async function regionsList(opts: { json?: boolean } = {}, deps?: { api: Pick }): Promise { const api = deps?.api ?? await ApiClient.load() const { regions } = await api.request('GET', '/regions') diff --git a/src/commands/services.ts b/src/commands/services.ts index c350f7b..07af91d 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -69,7 +69,7 @@ export function resolveSoleService\`)`) + if (of.length === 0) throw new Error(`no ${type} service in this project (add one with \`insta service add ${type} \`)`) if (of.length > 1) throw new Error(`multiple ${type} services — specify one: ${of.map((s) => s.name).join(', ')}`) return of[0]! } @@ -113,10 +113,10 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO parsePort(opts.port) // junk fails here, before any config/network access } // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only. - if (opts.alwaysOn !== undefined && type !== 'compute') throw new Error('--always-on / --no-always-on is only valid for compute services (for postgres, use `insta db always-on on|off` after creation)') + if (opts.alwaysOn !== undefined && type !== 'compute') throw new Error('--always-on / --no-always-on is only valid for compute services (for postgres, use `insta postgres always-on on|off` after creation)') if (opts.mountPath !== undefined && (type !== 'compute' || opts.volume === undefined)) throw new Error('--mount-path requires --volume on a compute service') if (opts.volume !== undefined) { - if (type !== 'compute') throw new Error('--volume is only valid for compute services (postgres has one by default — grow it with `insta db volume --size`)') + if (type !== 'compute') throw new Error('--volume is only valid for compute services (postgres has one by default — grow it with `insta postgres volume --size`)') parseVolumeGib(opts.volume) // junk fails here, before any config/network access } const api = await ApiClient.load() @@ -131,9 +131,10 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO // general `insta secrets` bundle — without this line nothing in the product says how to reach it. if (type === 'postgres') { // The hint must be runnable as printed: carry --branch when the service was created on a - // branch other than the linked one, and --group so it survives multiple postgres services. - const flags = `${opts.branch ? ` --branch ${opts.branch}` : ''} --group ${name}` - info(` connect: \`insta db url${flags}\` prints the connection string, \`insta db connect${flags}\` opens psql (--group optional with a single postgres service)`) + // branch other than the linked one, and the service name so it survives multiple postgres + // services (a trailing positional on `postgres url`/`postgres connect`, not a flag). + const branchFlag = opts.branch ? ` --branch ${opts.branch}` : '' + info(` connect: \`insta postgres url ${name}${branchFlag}\` prints the connection string, \`insta postgres connect ${name}${branchFlag}\` opens psql ([service] optional with a single postgres service)`) } renderNextActions(res.body.nextActions) } @@ -175,7 +176,7 @@ export async function servicesList(opts: { json?: boolean; branch?: string }): P const branch = opts.branch ?? p.branch const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) if (opts.json) return printJson(services) - if (!services.length) return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add \`)`) + if (!services.length) return info(`(no services on ${branch ?? 'default'} — add one with \`insta service add \`)`) for (const s of services) info(serviceListLine(s)) } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 36bdd1f..f0f724d 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,4 +1,4 @@ -// `insta setup agent` — make this machine's coding agents InstaCloud-native in one step +// `insta agent setup` — make this machine's coding agents InstaCloud-native in one step // (the Railway `railway setup agent` pattern). Installs the `insta` skill USER-GLOBALLY for // every agent the skills tool knows: the skill is pure product knowledge with brand-gated // triggers — no project state in it (the project binding is carried by ./.insta/project.json @@ -92,7 +92,7 @@ export function summarizeInstall(output: string): string { export type Runner = (cmd: string, args: string[]) => Promise<{ ok: boolean; output?: string }> -// ---- CLI self-install (makes `npx -y insta setup agent` a complete one-liner) ---- +// ---- CLI self-install (makes `npx -y insta agent setup` 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 @@ -242,7 +242,7 @@ const defaultMinter: TokenMinter = async () => mintMcpToken(await ApiClient.load // token-creation permission; agent governance still applies. Existing registrations stay intact. /** Outcome of the Claude Code MCP registration. `announce` controls the SUCCESS lines only — * `setup agent` passes false and folds Claude Code into one combined MCP line with the - * config-file agents; `insta mcp install` keeps the default self-narration. Failure surfaces + * config-file agents; `insta config install-mcp` keeps the default self-narration. Failure surfaces * (manual-add fallback, missing token) always print — silence there would read as success. */ export type McpStatus = 'new' | 'existing' | 'no-claude' | 'no-token' | 'failed' export async function registerMcp(run: Runner = defaultRunner, mint: TokenMinter = defaultMinter, useToken = false, announce = true): Promise { @@ -256,7 +256,7 @@ export async function registerMcp(run: Runner = defaultRunner, mint: TokenMinter if (useToken) { const token = await mint() if (!token) { - info(' MCP not registered (--mcp-token needs a login) — run `insta login`, then `insta setup agent --mcp-token` again') + info(' MCP not registered (--mcp-token needs a login) — run `insta login`, then `insta agent setup --mcp-token` again') return 'no-token' } args.push('--header', `Authorization: Bearer ${token}`) @@ -286,7 +286,7 @@ export function requireMcpRegistration(status: McpStatus): boolean { /** The environment `setup agent` should target, and whether the machine must be switched to it * first. Pure — decides only; the caller performs the switch. * - * The contract: the public one-liner `npx -y insta setup agent` means PRODUCTION, + * The contract: the public one-liner `npx -y insta agent setup` means PRODUCTION, * full stop — a leftover `insta env use staging` from last month must not silently give a new * onboarding run staging skills. Staging is an explicit ask: `--env staging` (or $INSTA_ENV). * Two deliberate exceptions leave the machine alone: @@ -502,9 +502,9 @@ export async function setupAgent( } } if (loggedIn) { - if (await enroll()) info('✓ Project agent session ready (expires in 24 hours; refresh with insta setup agent)') + if (await enroll()) info('✓ Project agent session ready (expires in 24 hours; refresh with insta agent setup)') } else { - info(' project agent session not created — run `insta login`, then `insta setup agent`') + info(' project agent session not created — run `insta login`, then `insta agent setup`') } // THE summary line. The restart note exists because config-file agents only read their MCP // config at startup; the skill files need no restart. diff --git a/src/commands/template.ts b/src/commands/template.ts index aee47ab..7fa6392 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -21,7 +21,7 @@ export type TemplateIndexEntry = { } // One aligned row per template; numeric columns right-aligned. Plain padded columns, as the rest -// of the CLI (storage list, compute check-domain) — no table library. +// of the CLI (storage list, domain check) — no table library. export function templateListLines(templates: TemplateIndexEntry[]): string[] { if (!templates.length) return ['(no templates published yet)'] const head = ['CODE', 'VERSION', 'CATEGORY', 'PROJECTS', 'SUCCESS', 'NAME'] @@ -252,7 +252,7 @@ export function partialMessage(dep: any): string { ...serviceStateLines(dep), ...(dep?.error ? [` ${dep.error}`] : []), ...(dep?.logsTail ? ['--- log tail ---', String(dep.logsTail).trimEnd()] : []), - 'created services are kept — inspect with `insta logs compute `, re-run the deploy to retry, or remove them with `insta services remove `', + 'created services are kept — inspect with `insta compute logs `, re-run the deploy to retry, or remove them with `insta service remove `', ].join('\n') } @@ -300,7 +300,7 @@ export async function watchDeployment( } await wait(2) } - throw new Error(`timed out after ${Math.round(timeoutMs / 60_000)}m waiting for template deployment ${id} — check \`insta events\``) + throw new Error(`timed out after ${Math.round(timeoutMs / 60_000)}m waiting for template deployment ${id} — check \`insta agent events\``) } // ---- commands ---- @@ -428,6 +428,6 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts = info(`template ${codeLabel} deployed to branch ${branchName}`) for (const u of deploymentUrls(dep)) info(` ${u}`) // Provider credentials are not in the `insta secrets` bundle — point at the paths that exist. - info('next: `insta db url` prints the postgres DSN; bind service credentials into compute with `insta secrets bind`; `insta secrets` refreshes user-defined secrets in .env') + info('next: `insta postgres url` prints the postgres DSN; bind service credentials into compute with `insta secrets bind`; `insta secrets` refreshes user-defined secrets in .env') renderNextActions(dep.nextActions) } diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 48bee3d..589eeae 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -2,7 +2,7 @@ // release installer; npm via `npm i -g`). A background version check (detached, cached in // ~/.insta/update-check.json) powers an update nudge — and, since the CLI is young and moves // fast, AUTO-UPDATE IS ON BY DEFAULT: when a newer version is known, a quiet upgrade runs in the -// background. `insta autoupdate off` (or INSTA_NO_AUTOUPDATE=1) disables that, leaving just the +// background. `insta config autoupdate off` (or INSTA_NO_AUTOUPDATE=1) disables that, leaving just the // stderr nudge. // // ONE SOURCE OF TRUTH. "What is the latest insta?" is answered in exactly one place — @@ -366,7 +366,7 @@ export async function backgroundCheck(current: string, deps: CheckDeps = {}): Pr return 'auto' } -// `insta autoupdate [on|off]` — toggle / show the auto-update preference (default: on). +// `insta config autoupdate [on|off]` — toggle / show the auto-update preference (default: on). export async function autoupdate(mode?: string): Promise { const cfg = await readGlobal() if (mode === 'on' || mode === 'off') { @@ -375,7 +375,7 @@ export async function autoupdate(mode?: string): Promise { return } const enabled = cfg.autoUpdate !== false && !process.env.INSTA_NO_AUTOUPDATE - info(`autoupdate: ${enabled ? 'on' : 'off'} (default on while the CLI is pre-1.0 — \`insta autoupdate off\` to disable)`) + info(`autoupdate: ${enabled ? 'on' : 'off'} (default on while the CLI is pre-1.0 — \`insta config autoupdate off\` to disable)`) } // Called once at CLI start-up. Never blocks: reads the cache synchronously, prints at most one @@ -411,7 +411,7 @@ export function maybeUpdate(current: string, argv: string[]): void { } else if (action === 'auto') { writeCache({ ...cache!, lastAutoAt: now }) respawnDetached(['upgrade']) - console.error(`↑ auto-updating insta ${current} → ${cache!.latest} in the background (\`insta autoupdate off\` to disable)`) + console.error(`↑ auto-updating insta ${current} → ${cache!.latest} in the background (\`insta config autoupdate off\` to disable)`) } } diff --git a/src/ensure-skills.ts b/src/ensure-skills.ts index 8d834d4..9bc6759 100644 --- a/src/ensure-skills.ts +++ b/src/ensure-skills.ts @@ -33,7 +33,7 @@ export type Runner = (cmd: string, args: string[], inherit?: boolean) => Promise const defaultRunner: Runner = (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`). + // re-enter npm's CLI script via node instead (same treatment as `insta agent setup`). const { cmd, args } = resolveSpawnable(cmdIn, argsIn) const env: NodeJS.ProcessEnv = { ...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 diff --git a/src/observe/hook.ts b/src/observe/hook.ts index 64584ba..b90397d 100644 --- a/src/observe/hook.ts +++ b/src/observe/hook.ts @@ -36,7 +36,7 @@ async function readStdin(): Promise { // Where findings go. The materialized hook lives at /.insta/observe/hook.js, so its // own entry path names the linked project root — the one directory whose .insta/audit.jsonl is -// gitignored and that `insta observe report` reads. Anything else (the harness's project-dir env, +// gitignored and that `insta agent observe report` reads. Anything else (the harness's project-dir env, // the event cwd) is only a guess: Codex passes the SESSION cwd, which in a monorepo can be a // subdirectory of the project, and writing there would leave an unignored audit log behind. export function projectRootFor(entry: string | undefined, env: NodeJS.ProcessEnv, eventCwd: string | undefined): string { diff --git a/src/observe/install.ts b/src/observe/install.ts index ac225df..9e14069 100644 --- a/src/observe/install.ts +++ b/src/observe/install.ts @@ -9,7 +9,7 @@ const MARKER = 'insta-observe' // What the hook leaves under ./.insta that is machine-local, not project source: observe/ is a // copy of this CLI version's hook + scanner (regenerated by every `project link`), and -// audit.jsonl is this machine's findings (fingerprints + redacted context — `insta observe sync` +// audit.jsonl is this machine's findings (fingerprints + redacted context — `insta agent observe sync` // is the share path). ./.insta/project.json stays committable: it is the team's project binding. const LOCAL_PATHS = ['.insta/observe/', '.insta/audit.jsonl'] const GITIGNORE_COMMENT = '# InstaCloud: local observe-hook state (regenerated per machine, not source)' diff --git a/src/resolve-service.ts b/src/resolve-service.ts index 9401522..e489961 100644 --- a/src/resolve-service.ts +++ b/src/resolve-service.ts @@ -1,4 +1,4 @@ -// `insta services add` with no type (or no name): the kinds are otherwise only discoverable by +// `insta service add` with no type (or no name): the kinds are otherwise only discoverable by // guessing wrong and reading `type must be postgres|storage|compute|redis|mysql|mongodb`, so missing arguments answer // "what can I add?" instead. The list mirrors the dashboard's Add Service menu (frontend // `add-service-button.tsx`) — Docker Image sits BESIDE Empty Service, not under it, because @@ -65,8 +65,8 @@ export function suggestServiceName(ref: string): string { /** The non-interactive command for a kind — what an agent should run instead of being asked. */ export function kindCommand(k: ServiceKind): string { - if (k.needsImage) return `insta services add compute --image --port ` - return `insta services add ${k.type} ${k.defaultName}` + if (k.needsImage) return `insta service add compute --image --port ` + return `insta service add ${k.type} ${k.defaultName}` } /** The kind list, one line each — what a terminal picks from and an agent reads. */ @@ -83,7 +83,7 @@ export function missingArgsMessage(type?: string): string { } /** - * Fill in whatever `insta services add` was not given. An unknown type passes straight through so + * Fill in whatever `insta service add` was not given. An unknown type passes straight through so * `assertType` — not this — reports it, keeping one wording for a bad type everywhere. Flags that * were already supplied are never asked for again. */ diff --git a/src/telemetry.ts b/src/telemetry.ts index b8a38ad..b828ceb 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -35,18 +35,18 @@ const SERVICE = oneOf(SERVICE_TYPES) // `login`/`env use` accept the name case-insensitively; so does this. const ENV: Check = (v) => isEnvName(v.trim().toLowerCase()) const ON_OFF = oneOf(['on', 'off']) -const TARGET = oneOf(['db', 'compute', 'redis', 'mysql', 'mongodb']) const POLICY_ACTION: Check = (v) => /^(?:secrets|deploy|project|branch|service|storage)(?:\.[a-zA-Z]+)?$/.test(v) const SAFE_ARGS: Record> = { 'env use': { 0: ENV }, 'project link': { 0: ID }, - 'services add': { 0: SERVICE }, 'services remove': { 0: SERVICE }, 'services rename': { 0: SERVICE }, 'services secrets': { 0: SERVICE }, - 'services set-access': { 0: SERVICE, 2: oneOf(['public', 'private']) }, - 'services scale': { 0: SERVICE, 2: NUMBER, 3: REGION }, 'services upgrade': { 0: SERVICE, 2: SLUG }, - 'compute always-on': { 0: ON_OFF }, 'db always-on': { 0: ON_OFF }, metrics: { 0: TARGET }, logs: { 0: TARGET }, - 'template info': { 0: SLUG }, 'billing upgrade': { 0: oneOf(['pro', 'team']) }, - 'approvals approve': { 0: ID }, 'approvals deny': { 0: ID }, - 'agent-policy rule set': { 0: POLICY_ACTION, 1: oneOf(['allow', 'deny', 'approve']) }, autoupdate: { 0: ON_OFF }, + 'service add': { 0: SERVICE }, 'service remove': { 0: SERVICE }, 'service rename': { 0: SERVICE }, + 'storage set-access': { 0: oneOf(['public', 'private']) }, + 'compute scale': { 0: NUMBER }, + 'compute always-on': { 0: ON_OFF }, 'postgres always-on': { 0: ON_OFF }, + 'redis always-on': { 0: ON_OFF }, 'mysql always-on': { 0: ON_OFF }, 'mongodb always-on': { 0: ON_OFF }, + 'template info': { 0: SLUG }, 'billing subscribe': { 0: oneOf(['pro', 'team']) }, + 'agent approvals approve': { 0: ID }, 'agent approvals deny': { 0: ID }, + 'agent policy rule set': { 0: POLICY_ACTION, 1: oneOf(['allow', 'deny', 'approve']) }, 'config autoupdate': { 0: ON_OFF }, } const SAFE_OPTIONS: Record = { org: ID, project: ID, region: REGION, env: ENV, oauth: oneOf(['github', 'google']), diff --git a/src/util.ts b/src/util.ts index 767494b..2a04a7a 100644 --- a/src/util.ts +++ b/src/util.ts @@ -208,7 +208,7 @@ export function info(msg: string): void { export function handleApproval(res: { status: number; body: any }, json?: boolean): boolean { if (res.status === 202 && res.body?.status === 'approval_required') { if (json) printJson(res.body) - process.stderr.write(`approval required for ${res.body.action} — run: insta approvals approve ${res.body.approvalId}\n`) + process.stderr.write(`approval required for ${res.body.action} — run: insta agent approvals approve ${res.body.approvalId}\n`) process.exitCode = 2 return true } @@ -217,14 +217,17 @@ export function handleApproval(res: { status: number; body: any }, json?: boolea export type NextAction = { op: string; reason: string; args?: Record; gated?: boolean } -// Neutral op → an `insta` command string. Unknown ops fall back to reason-only (no crash). +// Neutral op → an `insta` command string. Unknown ops fall back to reason-only (no crash). The +// platform names an observability target with its component word (`db` for postgres); the CLI +// groups by service type, so the target becomes the parent command. +const targetGroup = (a: Record): string => (a.target === 'db' ? 'postgres' : String(a.target ?? 'compute')) const OP_COMMAND: Record) => string> = { - 'service.add': (a) => `insta services add ${a.type ?? ''} ${a.name ?? ''}`, + 'service.add': (a) => `insta service add ${a.type ?? ''} ${a.name ?? ''}`, deploy: (a) => `insta deploy${a.branch ? ` --branch ${a.branch}` : ''}`, 'secrets.set': (a) => `insta secrets set ${a.name ?? ''} ${a.value ?? ''}`, - metrics: (a) => `insta metrics ${a.target ?? 'compute'}`, - logs: (a) => `insta logs ${a.target ?? 'compute'}`, - 'approvals.approve': (a) => `insta approvals approve ${a.approvalId ?? ''}`, + metrics: (a) => `insta ${targetGroup(a)} metrics`, + logs: (a) => `insta ${targetGroup(a)} logs`, + 'approvals.approve': (a) => `insta agent approvals approve ${a.approvalId ?? ''}`, } // Pure — builds the printable lines (unit-tested). Empty input → []. diff --git a/test/agent.test.ts b/test/agent.test.ts index fd811a4..57de8ed 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -27,7 +27,7 @@ it('never sends a project request as human when agent session is missing', async configureAgent({ source: 'cli-detected', client: 'codex' }) const fetcher = vi.fn() const api = new ApiClient({ apiUrl: 'https://test.invalid', accessToken: 'user' }, fetcher) - await expect(api.request('POST', '/projects/p/services', { type: 'compute' })).rejects.toThrow(/insta setup agent/) + await expect(api.request('POST', '/projects/p/services', { type: 'compute' })).rejects.toThrow(/insta agent setup/) expect(fetcher).not.toHaveBeenCalled() }) it('stores private material with ignore/permissions and signs exact request fields from nested directories', async () => { @@ -95,7 +95,7 @@ it('signs a project-owned request that lacks /projects/ in its path with the nam expect(fetcher).toHaveBeenCalledOnce() expect((fetcher.mock.calls[0] as any[])[1].headers['Insta-Agent-Session']).toBe('ags_test') // A session for another project is still refused: the scope is a selector, not a bypass. - await expect(api.request('GET', '/template-deployments/d1', undefined, { projectId: 'other' })).rejects.toThrow(/insta setup agent/) + await expect(api.request('GET', '/template-deployments/d1', undefined, { projectId: 'other' })).rejects.toThrow(/insta agent setup/) }) it('an agent-minted key sends the bearer alone and needs no session', async () => { // No .insta/agent-session.json anywhere near cwd — agentCredential must skip enrollment regardless. diff --git a/test/billing.test.ts b/test/billing.test.ts index 86c8306..1a7760c 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -60,18 +60,18 @@ describe('billingLines', () => { // Stripe Checkout, so dropping the flag there subscribes the wrong org. it.each([ ['paid', 'pro', 'past_due', 'insta billing portal --org org_123'], - ['ended', 'pro', 'canceled', 'insta billing upgrade pro --org org_123'], - ['free', 'free', null, 'insta billing upgrade pro --org org_123'], + ['ended', 'pro', 'canceled', 'insta billing subscribe pro --org org_123'], + ['free', 'free', null, 'insta billing subscribe pro --org org_123'], ])('carries --org into the %s hint', (_label, tier, subscriptionStatus, expected) => { const out = billingLines({ ...base, tier, subscriptionStatus, billingStatus: 'suspended' }, 'org_123').join('\n') expect(out).toContain(expected) }) - // The resubscribe hint has to name the org's OWN tier. `insta billing upgrade pro` on a Team org + // The resubscribe hint has to name the org's OWN tier. `insta billing subscribe pro` on a Team org // resubscribes it onto the wrong plan, and enterprise has no self-serve command at all. it.each([ - ['pro', 'insta billing upgrade pro'], - ['team', 'insta billing upgrade team'], + ['pro', 'insta billing subscribe pro'], + ['team', 'insta billing subscribe team'], ])('suspended after a cancellation on %s: names that tier', (tier, expected) => { const out = billingLines({ ...base, tier, billingStatus: 'suspended', subscriptionStatus: 'canceled' }).join('\n') expect(out).toContain(expected) @@ -80,7 +80,7 @@ describe('billingLines', () => { it('suspended after a cancellation on enterprise: no self-serve command exists, so it says so', () => { const out = billingLines({ ...base, tier: 'enterprise', billingStatus: 'suspended', subscriptionStatus: 'canceled' }).join('\n') expect(out).toContain('contact support') - expect(out).not.toContain('insta billing upgrade') + expect(out).not.toContain('insta billing subscribe') }) // A cancelled subscription suspends the org and keeps its tier (platform#300), so "your diff --git a/test/compute-domain.test.ts b/test/compute-domain.test.ts index f0e0a94..4bfce3f 100644 --- a/test/compute-domain.test.ts +++ b/test/compute-domain.test.ts @@ -1,4 +1,4 @@ -// renderRemoveDomain — the `compute remove-domain --json` contract: stdout carries the platform +// renderRemoveDomain — the `domain detach --json` contract: stdout carries the platform // response as JSON, never prose. Split out of removeDomain (same pattern as applyExecResult) so // this is testable without a network mock; the flag was once wired in index.ts without the handler // honoring it, which is exactly the regression this locks out. diff --git a/test/compute-exec.test.ts b/test/compute-exec.test.ts index 3cb2b6c..6390d4b 100644 --- a/test/compute-exec.test.ts +++ b/test/compute-exec.test.ts @@ -299,7 +299,7 @@ describe('applyExecResult', () => { it('202: exits 2 with the human approval hint on stderr, stdout untouched (non-json)', () => { applyExecResult({ status: 202, body: { status: 'approval_required', action: 'deploy', approvalId: 'appr_1' } }) expect(process.exitCode).toBe(2) - expect(stderr.join('')).toMatch(/approval required for deploy — run: insta approvals approve appr_1/) + expect(stderr.join('')).toMatch(/approval required for deploy — run: insta agent approvals approve appr_1/) expect(stdout.join('')).toBe('') }) diff --git a/test/db-query.test.ts b/test/db-query.test.ts index 73803f7..94f91c9 100644 --- a/test/db-query.test.ts +++ b/test/db-query.test.ts @@ -1,4 +1,4 @@ -// `insta db query` seams — the pure renderers plus the handler flow through an injected api seam +// `insta query` seams — the pure renderers plus the handler flow through an injected api seam // (the DomainDeps convention), so nothing here reaches a backend (mirrors db-stats.test.ts / // compute-domain-flow.test.ts). import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' @@ -183,7 +183,7 @@ describe('dbQuery (handler flow, injected api — no network)', () => { const { deps: d } = deps(mysql, { status: 202, body }) await dbQuery('shop', ['select 1'], {}, d) // handleApproval returns, no throw expect(process.exitCode).toBe(2) - expect(err()).toMatch(/approval required for db\.query — run: insta approvals approve appr_1/) + expect(err()).toMatch(/approval required for db.query — run: insta agent approvals approve appr_1/) expect(out()).toBe('') }) diff --git a/test/deploy-lane.test.ts b/test/deploy-lane.test.ts index 50ad956..e8fe2cf 100644 --- a/test/deploy-lane.test.ts +++ b/test/deploy-lane.test.ts @@ -66,7 +66,7 @@ describe('prepareSource — lane dispatch', () => { const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) try { await expect(prepareSource(api, 'p1', srcDir(false), 'main', {}, noRun)).rejects.toThrow() - expect(stderr.mock.calls.map((c) => String(c[0])).join('')).toMatch(/compute group not found: default.*--group .*insta services add compute/) + expect(stderr.mock.calls.map((c) => String(c[0])).join('')).toMatch(/compute group not found: default.*--group .*insta service add compute/) expect(paths).not.toContain('POST /projects/p1/deploy-token') } finally { stderr.mockRestore() } }) diff --git a/test/domain.test.ts b/test/domain.test.ts index 4ac6370..b80f7af 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -77,7 +77,7 @@ describe('domain buy', () => { it('a gated order prints the approval hint on stderr and exits 2', async () => { const { deps: d } = deps({}, { status: 202, body: { status: 'approval_required', approvalId: 'ap1', action: 'domain.purchase' } }) await domainBuy('myapp.com', {}, d) - expect(stderr.join('')).toContain('insta approvals approve ap1') + expect(stderr.join('')).toContain('insta agent approvals approve ap1') expect(process.exitCode).toBe(2) expect(out()).toBe('') }) diff --git a/test/limits.test.ts b/test/limits.test.ts index 8590359..7e01ef1 100644 --- a/test/limits.test.ts +++ b/test/limits.test.ts @@ -1,4 +1,4 @@ -// `insta compute limits` / `insta db limits` — the ceiling controls that replace spec picking. +// `insta compute limits` / `insta postgres limits` — the ceiling controls that replace spec picking. // The parsing seam is what these pin: a user types "1gb", the API takes MB, and getting that // conversion wrong sets a ceiling an order of magnitude off in either direction. import { describe, it, expect } from 'vitest' diff --git a/test/logs-deploy.test.ts b/test/logs-deploy.test.ts index 5dc16ff..0e481de 100644 --- a/test/logs-deploy.test.ts +++ b/test/logs-deploy.test.ts @@ -1,4 +1,4 @@ -// `insta logs --deploy` targets /deploy-events and renders each machine event as one line. +// `insta compute logs --deploy` (and the equivalent on redis/mysql/mongodb) targets /deploy-events and renders each machine event as one line. import { describe, it, expect } from 'vitest' import { deployEventsPath, deployEventLine } from '../src/commands/metrics.js' diff --git a/test/logs-window-flags.test.ts b/test/logs-window-flags.test.ts index 6e73448..dd53104 100644 --- a/test/logs-window-flags.test.ts +++ b/test/logs-window-flags.test.ts @@ -1,4 +1,4 @@ -// The `insta logs` window flags: --from/--to accept unix seconds or ISO-8601, --since is relative +// The per-resource `logs` window flags: --from/--to accept unix seconds or ISO-8601, --since is relative // sugar, and junk must die locally instead of reaching the platform as NaN. Pure helpers, same // throwing-parser pattern as parseTimeoutSec / parseCpu. import { describe, it, expect } from 'vitest' diff --git a/test/manifest-label.test.ts b/test/manifest-label.test.ts index f5159fd..1c6cc30 100644 --- a/test/manifest-label.test.ts +++ b/test/manifest-label.test.ts @@ -1,4 +1,4 @@ -// `insta manifest` resource labels — the prefix names WHERE a service runs, and `insta manifest` +// `insta agent manifest` resource labels — the prefix names WHERE a service runs, and `insta agent manifest` // is explicitly the agent-legible view of the project, so a wrong prefix misinforms agents too. // // The bug this locks down: the platform's resource `kind` is 'fly' for EVERY compute row ('fly' is diff --git a/test/metrics-line.test.ts b/test/metrics-line.test.ts index ed3912c..6dae25f 100644 --- a/test/metrics-line.test.ts +++ b/test/metrics-line.test.ts @@ -1,4 +1,4 @@ -// `insta metrics` series rendering. The seam exists because compute's egress/ingress series arrive +// per-resource `metrics` series rendering. The seam exists because compute's egress/ingress series arrive // as raw bytes per second: printed unscaled, real traffic reads as an 8-digit number nobody can // size at a glance. import { describe, it, expect } from 'vitest' diff --git a/test/regions.test.ts b/test/regions.test.ts index fde1a6f..9d01140 100644 --- a/test/regions.test.ts +++ b/test/regions.test.ts @@ -9,7 +9,7 @@ const regions = [ afterEach(() => vi.restoreAllMocks()) -describe('insta regions', () => { +describe('insta config regions', () => { it.each([false, true])('prints only the platform catalog (json=%s)', async (json) => { const request = vi.fn().mockResolvedValue({ regions }) const chunks: string[] = [] diff --git a/test/resolve-service.test.ts b/test/resolve-service.test.ts index fbdf63c..472f289 100644 --- a/test/resolve-service.test.ts +++ b/test/resolve-service.test.ts @@ -1,4 +1,4 @@ -// `insta services add` used to answer a missing type with commander's "missing required argument", +// `insta service add` used to answer a missing type with commander's "missing required argument", // which never says what the types are. Resolution: both args given → untouched (no prompt anywhere // near the fast path); TTY → the dashboard's Add Service kinds, then a name (and for Docker Image, // the ref first and the port after); no TTY → the kind list as an error, because nothing was @@ -97,15 +97,15 @@ test('no TTY: throws, and the message lists every kind with its command', async await expect(resolveServiceArgs(undefined, undefined, deps({ tty: false }))).rejects.toThrow(/what to add/) const msg = missingArgsMessage() for (const k of SERVICE_KINDS) expect(msg).toContain(k.label) - expect(msg).toContain('insta services add postgres main-db') - expect(msg).toContain('insta services add redis cache') - expect(msg).toContain('insta services add mysql mysql-db') - expect(msg).toContain('insta services add mongodb mongo-db') + expect(msg).toContain('insta service add postgres main-db') + expect(msg).toContain('insta service add redis cache') + expect(msg).toContain('insta service add mysql mysql-db') + expect(msg).toContain('insta service add mongodb mongo-db') expect(msg).toContain('--image ') }) test('no TTY with a type: asks for the missing half, not the whole list', () => { - expect(missingArgsMessage('storage')).toBe('name the service: insta services add storage assets') + expect(missingArgsMessage('storage')).toBe('name the service: insta service add storage assets') }) test('unknown type: passed through for assertType to report, prompts untouched', async () => { @@ -116,10 +116,10 @@ test('unknown type: passed through for assertType to report, prompts untouched', test('kind lines stay one per kind and carry a runnable command', () => { const lines = serviceKindLines() expect(lines).toHaveLength(SERVICE_KINDS.length) - expect(lines.join('\n')).toContain('insta services add storage assets') - expect(lines.join('\n')).toContain('insta services add redis cache') - expect(lines.join('\n')).toContain('insta services add mysql mysql-db') - expect(lines.join('\n')).toContain('insta services add mongodb mongo-db') + expect(lines.join('\n')).toContain('insta service add storage assets') + expect(lines.join('\n')).toContain('insta service add redis cache') + expect(lines.join('\n')).toContain('insta service add mysql mysql-db') + expect(lines.join('\n')).toContain('insta service add mongodb mongo-db') }) // Same rules as the dashboard's helpers, so a ref names the service identically in both. diff --git a/test/storage.test.ts b/test/storage.test.ts index 0c80f21..189662f 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -215,7 +215,7 @@ describe('resolveSoleService (storage)', () => { expect(() => resolveSoleService(two, 'storage')).toThrow(/multiple storage services — specify one: files, assets/) }) it('errors when the branch has no storage service, pointing at `services add`', () => { - expect(() => resolveSoleService([one[0]!], 'storage')).toThrow(/insta services add storage /) + expect(() => resolveSoleService([one[0]!], 'storage')).toThrow(/insta service add storage /) expect(() => resolveSoleService(two, 'storage', 'nope')).toThrow(/storage service not found: nope/) }) }) diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 9611fd2..603dbde 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -85,14 +85,14 @@ describe('redaction', () => { it('drops allowlisted values that do not have the declared shape', () => { expect(redactOptions({ port: 'yesterday', limit: '100', region: 'New York', org: 'acme', project: 'proj_1', env: 'stagng', memory: '512mb' })) .toEqual({ port: '[REDACTED]', limit: '100', region: '[REDACTED]', org: '[REDACTED]', project: 'proj_1', env: '[REDACTED]', memory: '512mb' }) - expect(redactArgs('services add', ['lambda', 'x'])).toEqual(['[REDACTED]', '[REDACTED]']) + expect(redactArgs('service add', ['lambda', 'x'])).toEqual(['[REDACTED]', '[REDACTED]']) expect(redactArgs('env use', ['stagng'])).toEqual(['[REDACTED]']) expect(redactArgs('env use', ['STAGING'])).toEqual(['STAGING']) expect(redactOptions({ env: 'Prod' })).toEqual({ env: 'Prod' }) - expect(redactArgs('approvals approve', ['appr_1'])).toEqual(['appr_1']) - expect(redactArgs('approvals approve', ['please'])).toEqual(['[REDACTED]']) - expect(redactArgs('metrics', ['db'])).toEqual(['db']) - expect(redactArgs('metrics', ['prod-db'])).toEqual(['[REDACTED]']) + expect(redactArgs('agent approvals approve', ['appr_1'])).toEqual(['appr_1']) + expect(redactArgs('agent approvals approve', ['please'])).toEqual(['[REDACTED]']) + expect(redactArgs('postgres metrics', ['prod-db'])).toEqual(['[REDACTED]']) + expect(redactArgs('compute logs', ['api'])).toEqual(['[REDACTED]']) }) it('drops --set assignments whole, names included', () => { @@ -100,9 +100,9 @@ describe('redaction', () => { }) it('keeps positionals only where the command declares an id or enum', () => { - expect(redactArgs('services add', ['postgres', 'main'])).toEqual(['postgres', '[REDACTED]']) - expect(redactArgs('services scale', ['compute', 'api', '3', 'us-east'])).toEqual(['compute', '[REDACTED]', '3', 'us-east']) - expect(redactArgs('agent-policy rule set', ['deploy', 'approve'])).toEqual(['deploy', 'approve']) + expect(redactArgs('service add', ['postgres', 'main'])).toEqual(['postgres', '[REDACTED]']) + expect(redactArgs('compute scale', ['3', 'api'])).toEqual(['3', '[REDACTED]']) + expect(redactArgs('agent policy rule set', ['deploy', 'approve'])).toEqual(['deploy', 'approve']) expect(redactArgs('run', ['/Users/jane/bin/dev.sh', 'x'])).toEqual(['[REDACTED]', '[REDACTED]']) expect(redactArgs('branch create', ['feat/acme-pilot'])).toEqual(['[REDACTED]']) expect(redactArgs('secrets set', ['DB_PASSWORD', 's3cret'])).toEqual(['[REDACTED]', '[REDACTED]']) diff --git a/test/template.test.ts b/test/template.test.ts index dc29f9f..02b5a37 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -559,7 +559,7 @@ describe('templateDeploy', () => { await templateDeploy('plausible', { json: true }, { api, project: PROJECT, wait: NO_WAIT }) expect(JSON.parse(stdout.join(''))).toEqual(GATED.body) expect(stdout.join('')).not.toMatch(/approval required for/) - expect(stderr.join('')).toMatch(/approval required for template\.deploy — run: insta approvals approve appr_1/) + expect(stderr.join('')).toMatch(/approval required for template\.deploy — run: insta agent approvals approve appr_1/) expect(process.exitCode).toBe(2) expect(polls).toEqual([]) // nothing was deployed, so nothing is polled }) diff --git a/test/util.test.ts b/test/util.test.ts index 061ab82..08737ce 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -114,7 +114,7 @@ describe('handleApproval', () => { it('202: returns true, hint on stderr, stdout untouched, exit code 2', () => { expect(handleApproval(gated)).toBe(true) - expect(stderr.join('')).toMatch(/approval required for deploy — run: insta approvals approve a1/) + expect(stderr.join('')).toMatch(/approval required for deploy — run: insta agent approvals approve a1/) expect(stdout.join('')).toBe('') expect(process.exitCode).toBe(2) }) @@ -139,7 +139,7 @@ describe('nextActionsLines', () => { it('renders a mapped op as an insta command with args, plus its reason', () => { const lines = nextActionsLines([{ op: 'service.add', reason: 'Add a service first.', args: { type: 'postgres', name: 'db' } }]) expect(lines[0]).toBe('Next:') - expect(lines.join('\n')).toContain('insta services add postgres db') + expect(lines.join('\n')).toContain('insta service add postgres db') expect(lines.join('\n')).toContain('Add a service first.') }) @@ -160,9 +160,9 @@ describe('nextActionsLines', () => { it('renders metrics/logs hints with the compute target (runnable command)', () => { const metricsLines = nextActionsLines([{ op: 'metrics', reason: 'Check metrics.', args: { projectId: 'pr_1' } }]) - expect(metricsLines.join('\n')).toContain('insta metrics compute') + expect(metricsLines.join('\n')).toContain('insta compute metrics') const logsLines = nextActionsLines([{ op: 'logs', reason: 'Check logs.', args: { projectId: 'pr_1' } }]) - expect(logsLines.join('\n')).toContain('insta logs compute') + expect(logsLines.join('\n')).toContain('insta compute logs') }) }) diff --git a/test/volume.test.ts b/test/volume.test.ts index 071e04a..50bd61f 100644 --- a/test/volume.test.ts +++ b/test/volume.test.ts @@ -46,7 +46,7 @@ describe('servicesAddRequestBody --volume', () => { describe('servicesAdd --volume validation (throws before any network/config access)', () => { it('rejects --volume for a non-compute type, pointing at the db command instead', async () => { await expect(servicesAdd('postgres', 'db', { volume: '10' })).rejects.toThrow(/--volume is only valid for compute services/) - await expect(servicesAdd('storage', 'bkt', { volume: '10' })).rejects.toThrow(/insta db volume --size/) + await expect(servicesAdd('storage', 'bkt', { volume: '10' })).rejects.toThrow(/insta postgres volume --size/) }) it('rejects junk sizes locally instead of deferring to the server', async () => { await expect(servicesAdd('compute', 'api', { volume: '1.5' })).rejects.toThrow(/invalid volume size/) From 5c982aeb83ae40d35cf82228b177671dffea9cca Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 16:12:25 -0700 Subject: [PATCH 10/19] tests: keep the db.query literal-dot escape; comment wording follows billing subscribe / service add Co-Authored-By: Claude Fable 5.1 --- src/commands/billing.ts | 4 ++-- test/db-query.test.ts | 2 +- test/storage.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/billing.ts b/src/commands/billing.ts index a4a8af8..0c30bfa 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -65,10 +65,10 @@ export function billingLines(s: BillingOverview, org?: string): string[] { ? `⚠ org suspended — subscription payment did not go through; settle it in \`insta billing portal${flag}\`` : ended ? s.tier === 'enterprise' - // Per-deal, and `billing upgrade` cannot create one: naming a self-serve tier here + // Per-deal, and `billing subscribe` cannot create one: naming a self-serve tier here // would move them off the plan they negotiated. ? '⚠ org suspended — the subscription ended; contact support to restore this plan' - // Their OWN tier, not a hardcoded one: suggesting `upgrade pro` to a Team org + // Their OWN tier, not a hardcoded one: suggesting `subscribe pro` to a Team org // resubscribes it onto the wrong plan. : `⚠ org suspended — the subscription ended; resubscribe with \`insta billing subscribe ${s.tier}${flag}\`` // Deliberately claims nothing about the subscription: `incomplete` reaches here too, diff --git a/test/db-query.test.ts b/test/db-query.test.ts index 94f91c9..3de921d 100644 --- a/test/db-query.test.ts +++ b/test/db-query.test.ts @@ -183,7 +183,7 @@ describe('dbQuery (handler flow, injected api — no network)', () => { const { deps: d } = deps(mysql, { status: 202, body }) await dbQuery('shop', ['select 1'], {}, d) // handleApproval returns, no throw expect(process.exitCode).toBe(2) - expect(err()).toMatch(/approval required for db.query — run: insta agent approvals approve appr_1/) + expect(err()).toMatch(/approval required for db\.query — run: insta agent approvals approve appr_1/) expect(out()).toBe('') }) diff --git a/test/storage.test.ts b/test/storage.test.ts index 189662f..ca04040 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -214,7 +214,7 @@ describe('resolveSoleService (storage)', () => { expect(resolveSoleService(two, 'storage', 'assets').id).toBe('c') expect(() => resolveSoleService(two, 'storage')).toThrow(/multiple storage services — specify one: files, assets/) }) - it('errors when the branch has no storage service, pointing at `services add`', () => { + it('errors when the branch has no storage service, pointing at `service add`', () => { expect(() => resolveSoleService([one[0]!], 'storage')).toThrow(/insta service add storage /) expect(() => resolveSoleService(two, 'storage', 'nope')).toThrow(/storage service not found: nope/) }) From 8f2bb7a4acfdd63d083eb079188d2d823d3d85a9 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 16:18:27 -0700 Subject: [PATCH 11/19] docs: command table, agent-setup probe in install.sh, command-architecture rules in the dev skill Co-Authored-By: Claude Fable 5.1 --- .claude/skills/developing-insta-cli/SKILL.md | 31 ++++++++++++++- README.md | 42 ++++++++++---------- install.sh | 20 ++++++---- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/.claude/skills/developing-insta-cli/SKILL.md b/.claude/skills/developing-insta-cli/SKILL.md index f000d3e..a9d6a09 100644 --- a/.claude/skills/developing-insta-cli/SKILL.md +++ b/.claude/skills/developing-insta-cli/SKILL.md @@ -26,7 +26,7 @@ npx tsx src/index.ts --help # run the CLI from source | `index.ts` | commander program — registers every command | | `api.ts` | typed platform-API client (auth headers, token refresh, error mapping) | | `config.ts` | global `~/.insta/config.json` (apiUrl + tokens + user) · project `./.insta/project.json` (projectId / orgId / current branch) · machine-local `./.insta/link-plane.json` (the control plane the link was made on; a foreign link fails closed in `requireProject`) | -| `commands/` | one file per command group: `auth` `org` `project` `services` `branch` `secrets` `build` `deploy` `compute` `upgrade` `metrics` (+`logs`) `billing` `govern` (policy/approvals) `manifest` `observe` | +| `commands/` | one file per command group: `auth` `org` `project` `branch` `services` (the `service` group) `secrets` `domain` `compute` (+ BYO domain functions, type-generalized limits/volume/always-on) `postgres` `managed-db` (redis/mysql/mongodb status) `db-query` `storage` `build` `deploy` `run` `template` `billing` `metrics` (+`logs`, `usage`) `govern` (approvals/events) `agent-policy` `observe` `manifest` `setup` `mcp` `regions` `env` `feedback` `upgrade` | | `observe/` | local `insta observe` hook — `scanner.ts` (AWS/GitHub/Stripe/LLM/DB cred detection), `hook.ts`, `install.ts`, `report.ts` (→ platform event ingest) | | `flyctl-build.ts` | source-directory deploy build glue (Fly build context) | | `nixpacks.ts` | nixpacks glue for `insta build` — plan detection + Dockerfile generation (no Docker daemon) | @@ -37,6 +37,34 @@ npx tsx src/index.ts --help # run the CLI from source `skills/` submodule) — that reference doc is how agents learn the CLI surface, so a new or renamed command/flag is only half-done until it's updated there, in the same change set. +## Command architecture (read before adding or moving a command) + +The tree in `src/index.ts` follows five rules (design: superproject +`docs/superpowers/specs/2026-09-17-cli-command-reorg-design.md`). `test/help-surface.test.ts` +pins the visible top level; changing it is a design decision, not a code change. + +1. **Level 1 is a resource (noun, singular).** The only verbs at level 1 are `login`, `logout`, + `status`, `build`, `deploy`, `run`, `feedback`, `upgrade`. Nothing else joins without a design note. +2. **Level 2 is a verb on that resource.** A third level makes level 2 a noun again + (`domain records add`, `agent policy set`). +3. **One capability, one path.** Before adding a command, grep `src/commands/` for the platform + endpoint it calls. If another command already calls it, add a flag or mode there instead. +4. **Same shape for the same thing.** `compute|postgres|redis|mysql|mongodb [service]` — + trailing optional positional, sole/default service when omitted (`resolveSoleService`); + `storage --service `; org-scoped verbs take `--org `. A new verb copies its + group's shape; a new group copies the closest existing group. +5. **Renames are hard cutovers.** No hidden aliases (only `services|svc` → `service` are permanent). + A rename changes, in the same change set: `skills/insta/cli-reference.md`, `e2e/`, console copy + in `frontend/`, MCP copy, and platform error strings that spell the path — and it ships in the + order the design's §9 gives (docs/copy merge right after the CLI release, never before). + +Where things go: settings (limits/volume/always-on/scale) live under the service type; +`logs`/`metrics` live under the service type via `addObservability()` in `index.ts`; anything +about this machine's agents or the project's agent governance lives under `agent`; anything about +this machine's CLI configuration lives under `config`. `--api-url` is injected on every command by +`addApiUrlEverywhere()` — never declare it on a new command by hand (except a command that must +persist it, like `login`). + ## Getting a PR merged (main is protected — this exact flow, no other works) 1. Branch from `origin/main`: `feat/*` or `fix/*`. PRs target `main`. **Squash merge.** @@ -70,6 +98,7 @@ npx tsx src/index.ts --help # run the CLI from source | `npx insta@latest` behind the GH release | `publish-npm` job failed (OIDC trust/config?) — see step 4 | | `'C:\Program' is not recognized` from a spawned tool (win CI only) | `resolveSpawnable`'s cmd.exe hop strips the quotes around a spaced executable path (`C:\Program Files\…`). It exists for npm-installed `.cmd` shims — a real `.exe` (git, …) must be spawned directly, which finds it through PATHEXT anyway | | CLI hits the wrong server in tests | Persisted `~/.insta/config.json` apiUrl; set `INSTA_API_URL` (≥0.0.7) or move the config aside | +| `insta --api-url X compute exec …` works but `insta compute exec --api-url X …` says unknown option | exec's argv is split before commander (`splitExecArgs`); pass `--api-url` at the root for exec | ## agents.instacloud.com diff --git a/README.md b/README.md index 2d801dc..f6b7dc3 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ version-pinned `npm install -g` fallback to run yourself). E2e-validated on macO and Windows (PowerShell + cmd): ```bash -npx -y insta@latest setup agent +npx -y insta@latest agent setup ``` This command means **production** (CLI ≥ 0.0.38): if the machine was previously switched to @@ -41,7 +41,7 @@ staging it switches back — announced, session dropped, like `insta env use pro its own explicit command, which also persists the choice: ```bash -npx -y insta@latest setup agent --env staging +npx -y insta@latest agent setup --env staging ``` On macOS/Linux without Node, the native-binary installer puts the `insta` CLI on PATH (the @@ -143,8 +143,8 @@ mean rules are unavailable. Text output also lists effective rules. The old `pol ### Agents get the same surface -`insta manifest` prints an agent-legible view of every branch and its URLs. `insta setup -agent` installs the InstaCloud skill and registers the remote MCP server for the coding +`insta manifest` prints an agent-legible view of every branch and its URLs. `insta agent +setup` installs the InstaCloud skill and registers the remote MCP server for the coding agents on the machine — and, when running from the npx cache with no durable `insta` on PATH, first installs the CLI itself globally. @@ -211,28 +211,26 @@ build never reaches a production installer. | Command | What it covers | |---|---| | `insta login` · `logout` · `status` | Browser sign-in (default), `--email` + password, or `--oauth github\|google`; `status` shows the environment, login and linked project/branch | -| `insta env` | `show` · `use ` | -| `insta setup` | `agent` — install the CLI (if missing), the skill, and MCP for every coding agent; targets prod, `--env staging` for staging | -| `insta mcp` | `install` — register the remote MCP server only | | `insta org` | `list` · `create` (one free org per user) | | `insta project` | `create` · `list` · `link` · `delete` | | `insta branch` | `create` · `list` · `switch` · `delete` · `merge` | -| `insta services` | `add` · `list` · `remove` · `rename` · `set-access` · `scale` · `upgrade` · `secrets` | -| `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `tree` | +| `insta service` (`services`, `svc`) | `add` · `list` · `remove` · `rename` | +| `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `bind` · `unbind` · `bindings` · `sources` · `tree` | +| `insta domain` | Bought or bring-your-own: `attach` · `check` · `detach`; buy through InstaCloud: `search` · `buy` · `list` · `status` · `records …` | +| `insta compute` | `start` · `stop` · `suspend` · `restart` · `status` · `scale` · `limits` · `volume` · `always-on` · `exec` · `ssh` · `repo` · `connect-repo` · `watch-paths` · `disconnect-repo` · `logs` · `metrics` | +| `insta postgres` | `url` (print the DSN) · `connect` (psql) · `stats` · `limits` · `volume` · `always-on` · `logs` · `metrics` — every verb takes `[service]` | +| `insta redis` · `mysql` · `mongodb` | `query` · `status` · `limits` · `volume` · `always-on` · `logs` · `metrics` | +| `insta storage` | `list` · `get` · `delete` · `set-access` | +| `insta build [dir]` · `deploy [dir]` | Verify a source dir would build; deploy a source directory (built remotely) or `--image ` | | `insta run ` | Run a command with the branch bundle injected, nothing written to disk | -| `insta deploy [dir]` | Deploy a source directory (built remotely) or `--image ` | -| `insta compute` | `start` · `stop` · `suspend` · `status` · `set-domain` · `check-domain` · `remove-domain` | -| `insta domain` | Buy a domain through InstaCloud: `search` · `buy` · `attach` · `list` · `status` | -| `insta db` | `url` (print the postgres DSN) · `connect` (psql session) · `limits` · `stats` · `always-on` · `volume` | -| `insta regions` | Regions available for postgres and compute | -| `insta manifest` | Agent-legible view of every branch and its URLs | -| `insta metrics` · `logs` · `events` | Service metrics; runtime logs (`--deploy` for deploy events); audit timeline | -| `insta usage` · `billing` | Usage by billing dimension; `billing upgrade` · `billing portal` | -| `insta approvals` | `list` · `approve` · `deny` | -| `insta agent-policy` | `get` · `set ` · `protect-branch` · `unprotect-branch` · `rule set ` · `revoke-sessions` | -| `insta observe` | `install` · `uninstall` · `report` · `sync` — local credential audit | +| `insta template` | `list` · `info` · `deploy` | +| `insta billing` | Current cycle overview; `subscribe ` · `portal` · `usage` | +| `insta agent` | `setup` (this machine's coding agents) · `manifest` · `policy …` · `approvals …` · `observe …` · `events` | +| `insta config` | `install-mcp` · `regions` · `autoupdate` | | `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; works logged-out | -| `insta upgrade` · `autoupdate` | Update the CLI; show or set auto-update | +| `insta upgrade` | Update the CLI | + +Every command accepts `--api-url ` for this invocation only (internal debugging); `insta --help` documents it. ## Configuration @@ -255,7 +253,7 @@ build never reaches a production installer. ## Agent skills The `insta` skill and its task guides live in -[InsForge/instacloud-skills](https://github.com/InsForge/instacloud-skills). `insta setup agent` +[InsForge/instacloud-skills](https://github.com/InsForge/instacloud-skills). `insta agent setup` installs it user-globally for every coding agent on the machine. `insta project create` and `insta project link` additionally install the stack skills (Tigris, Better Auth) into the project, along with the `insta observe` credential-audit hook. Postgres needs no stack diff --git a/install.sh b/install.sh index a059021..9a49032 100644 --- a/install.sh +++ b/install.sh @@ -10,7 +10,7 @@ # (equivalent to piping this script with: sh -s -- --agents; add -y for a hard non-interactive run) # # Flags: -# --agents after installing, run `insta setup agent` (skills for Claude Code/Codex/Cursor/…) +# --agents after installing, run `insta agent setup` (`setup agent` on older CLIs) (skills for Claude Code/Codex/Cursor/…) # -y non-interactive # --staging target the staging deployment (shorthand for --env staging) # --env target a named deployment: prod (default) | staging @@ -201,7 +201,7 @@ if [ "$ON_PATH" != "1" ]; then fi # ---- environment (--staging / --env) ---- -# MUST run before `setup agent`: that step registers the MCP server, and it derives the MCP host and +# MUST run before `agent setup` (`setup agent` on older CLIs): that step registers the MCP server, and it derives the MCP host and # registration name from the persisted environment. Switching afterwards would leave the machine's # agents pointed at production's MCP server while the CLI talked to staging. if [ -n "$ENV_NAME" ]; then @@ -211,7 +211,7 @@ if [ -n "$ENV_NAME" ]; then # install is still pointed at PRODUCTION. Carrying on would be the worst outcome: the canonical # usage is `curl … | sh && insta project create`, often run unattended by an agent, which would # then provision real production infrastructure believing it was staging. Exiting here also - # stops `setup agent` from wiring this machine's agents to the wrong environment. + # stops `agent setup` (`setup agent` on older CLIs) from wiring this machine's agents to the wrong environment. echo "error: could not select environment '$ENV_NAME' — this install is still pointed at PRODUCTION." >&2 echo " The installed CLI ($("$INSTALL_DIR/$BIN" --version 2>/dev/null | tail -1)) may predate \`insta env\` (needs >= 0.0.23)." >&2 echo " Upgrade, then retry: insta upgrade && insta env use $ENV_NAME" >&2 @@ -223,8 +223,8 @@ fi # ---- agent setup (--agents) ---- if [ "$AGENTS" = "1" ]; then echo - # `insta setup agent` prints its own "setting up coding-agent skills …" line + clean summary. - # CLI >= 0.0.38: bare `setup agent` FORCES prod (switching the machine if needed), so a staging + # `insta agent setup` (`setup agent` on older CLIs) prints its own "setting up coding-agent skills …" line + clean summary. + # CLI >= 0.0.38: bare `agent setup` (`setup agent` on older CLIs) FORCES prod (switching the machine if needed), so a staging # install must pass the environment explicitly. The persisted env already matches (env use above), # so --env is a no-op switch there — it just stops setup from "correcting" the machine to prod. # Older CLIs (a pinned INSTA_VERSION) reject the flag; ONLY that exact case (commander's @@ -240,14 +240,18 @@ if [ "$AGENTS" = "1" ]; then YFLAG="" [ "$YES" = "1" ] && YFLAG="-y" SETUP_ERR="${TMPDIR:-/tmp}/insta-setup-err.$$" - if "$INSTALL_DIR/$BIN" setup agent $YFLAG $SETUP_ENV_ARGS 2>"$SETUP_ERR"; then + # CLI releases after 0.0.79 spell it `insta agent setup`; older binaries only know `setup agent`. + # Probe the installed binary rather than parse a version: `agent --help` exits 0 only where the + # group exists. Remove this probe once every install target is a release with `agent setup`. + if "$INSTALL_DIR/$BIN" agent --help >/dev/null 2>&1; then SETUP_CMD="agent setup"; else SETUP_CMD="setup agent"; fi + if "$INSTALL_DIR/$BIN" $SETUP_CMD $YFLAG $SETUP_ENV_ARGS 2>"$SETUP_ERR"; then cat "$SETUP_ERR" >&2 else cat "$SETUP_ERR" >&2 if [ -n "$SETUP_ENV_ARGS" ] && grep -qi "unknown option" "$SETUP_ERR"; then - "$INSTALL_DIR/$BIN" setup agent $YFLAG || echo "warn: agent setup failed — run: insta setup agent" + "$INSTALL_DIR/$BIN" $SETUP_CMD $YFLAG || echo "warn: agent setup failed — run: insta $SETUP_CMD" else - echo "warn: agent setup failed — run: insta setup agent ${SETUP_ENV_ARGS}" + echo "warn: agent setup failed — run: insta $SETUP_CMD ${SETUP_ENV_ARGS}" fi fi rm -f "$SETUP_ERR" From 3633c7317d339b9fac8e184961b1077e1808f12c Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 16:30:48 -0700 Subject: [PATCH 12/19] install.sh: probe the root help for the agent group; README names only live paths Co-Authored-By: Claude Fable 5.1 --- README.md | 14 +++++++------- install.sh | 9 +++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f6b7dc3..8026f84 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ curl -fsSL agents.instacloud.com | sh Pin a version with `INSTA_VERSION=v0.0.22`; change the install directory with `INSTA_INSTALL_DIR`. While the CLI is pre-1.0 it updates itself on new releases. Turn that -off with `insta autoupdate off`. +off with `insta config autoupdate off`. ## Quickstart @@ -72,7 +72,7 @@ insta deploy . `project create` makes an empty project and links the current directory. Services are opt-in, so you add only what you need. `secrets` writes the current branch's user-defined -secrets to `./.env` (the postgres connection string is read with `insta db url`). `deploy .` +secrets to `./.env` (the postgres connection string is read with `insta postgres url`). `deploy .` builds the directory remotely and ships it to the branch's compute service, with no local Docker. Whether it needs a `Dockerfile` depends on where the service runs: on insta-compute it is optional, and a directory without one is built @@ -119,7 +119,7 @@ the two branches diverge independently. A project is capped at 10 branches. only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`, `AWS_ACCESS_KEY_ID`, …) are not in that bundle — they reach compute through explicit `insta secrets bind` rules, and the postgres connection string is read directly with -`insta db url` (or `insta db connect` for a psql session). +`insta postgres url` (or `insta postgres connect` for a psql session). Secrets can be scoped per compute service, so several services may each define the same name — and a flat bundle cannot carry two values for one name. Such a name is **withheld** from the bundle and @@ -133,8 +133,8 @@ it as `insta run --service compute/` to inject exactly what that one servi Agent requests are governed by the project's `agent-policy`; human requests use normal RBAC. Where the agent policy says `approve`, the command stops and prints an approval id for a human -admin to grant with `insta approvals approve `. The agent then retries the unchanged request. -Run `insta --agent agent-policy get --json` for stored overrides, `defaultRules`, `effectiveRules`, +admin to grant with `insta agent approvals approve `. The agent then retries the unchanged request. +Run `insta --agent agent policy get --json` for stored overrides, `defaultRules`, `effectiveRules`, `bootstrapRules` and `ruleNotes`. Rules distinguish no affected branches (`project`), unprotected branches and protected branches. They describe policy, not authorization: RBAC, session checks, actual affected resources and compound actions still apply. An empty override object does not @@ -143,7 +143,7 @@ mean rules are unavailable. Text output also lists effective rules. The old `pol ### Agents get the same surface -`insta manifest` prints an agent-legible view of every branch and its URLs. `insta agent +`insta agent manifest` prints an agent-legible view of every branch and its URLs. `insta agent setup` installs the InstaCloud skill and registers the remote MCP server for the coding agents on the machine — and, when running from the npx cache with no durable `insta` on PATH, first installs the CLI itself globally. @@ -256,7 +256,7 @@ The `insta` skill and its task guides live in [InsForge/instacloud-skills](https://github.com/InsForge/instacloud-skills). `insta agent setup` installs it user-globally for every coding agent on the machine. `insta project create` and `insta project link` additionally install the stack skills (Tigris, Better Auth) into the -project, along with the `insta observe` credential-audit hook. Postgres needs no stack +project, along with the `insta agent observe` credential-audit hook. Postgres needs no stack skill — it's plain Postgres, reached directly via `DATABASE_URL`. ## Contributing diff --git a/install.sh b/install.sh index 9a49032..3235a2b 100644 --- a/install.sh +++ b/install.sh @@ -10,7 +10,7 @@ # (equivalent to piping this script with: sh -s -- --agents; add -y for a hard non-interactive run) # # Flags: -# --agents after installing, run `insta agent setup` (`setup agent` on older CLIs) (skills for Claude Code/Codex/Cursor/…) +# --agents after installing, run `insta agent setup` — or `setup agent` on older CLIs — installing skills for Claude Code/Codex/Cursor/… # -y non-interactive # --staging target the staging deployment (shorthand for --env staging) # --env target a named deployment: prod (default) | staging @@ -241,9 +241,10 @@ if [ "$AGENTS" = "1" ]; then [ "$YES" = "1" ] && YFLAG="-y" SETUP_ERR="${TMPDIR:-/tmp}/insta-setup-err.$$" # CLI releases after 0.0.79 spell it `insta agent setup`; older binaries only know `setup agent`. - # Probe the installed binary rather than parse a version: `agent --help` exits 0 only where the - # group exists. Remove this probe once every install target is a release with `agent setup`. - if "$INSTALL_DIR/$BIN" agent --help >/dev/null 2>&1; then SETUP_CMD="agent setup"; else SETUP_CMD="setup agent"; fi + # Probe the ROOT help for an `agent` command group. Not `agent --help`: commander prints the root + # help and exits 0 for ANY unknown command when --help is present, so that never discriminates. + # The pattern needs the trailing space/EOL so the old `agent-policy` row cannot match. + if "$INSTALL_DIR/$BIN" --help 2>/dev/null | grep -qE '^ +agent( |$)'; then SETUP_CMD="agent setup"; else SETUP_CMD="setup agent"; fi if "$INSTALL_DIR/$BIN" $SETUP_CMD $YFLAG $SETUP_ENV_ARGS 2>"$SETUP_ERR"; then cat "$SETUP_ERR" >&2 else From b32d648dbbe55e1feae4b55ea338a9640c202465 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 16:52:09 -0700 Subject: [PATCH 13/19] fix: retired billing upgrade fails loudly; autoupdate never persists --api-url; spawn-test timeouts; wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - billing/secrets group commands now set allowExcessArguments(false), so a retired/mistyped subcommand under either group (`billing upgrade pro`, `secrets lst`) fails instead of silently running the group's own default action (commander 12 defaults allowExcessArguments to true). - `insta config autoupdate ` now reads via readPersistedGlobal() before its read-modify-write, instead of readGlobal() — readGlobal() folds in a runtime --api-url/INSTA_API_URL override and scrubs the stored session when it points elsewhere, so writing it back re-pointed ~/.insta/config.json and logged the user out just from toggling autoupdate. - Add explicit 30s timeouts to the remaining spawnSync-backed vitest `it` cases in help-surface.test.ts and retired-policy.test.ts (CI's windows-latest spawns are 3-5x slower than the 5s vitest default). - Wording: SKILL.md's observe hook reference, the root --api-url description (compute exec needs it before `compute`), volumeWriteLine and serviceLimits now say "database" instead of implying every managed type has a deploy step. - Tests: pin `billing upgrade` as retired and `secrets lst` as loud-failing; broaden the retired-path stderr regex to also accept commander's "too many arguments"; add a managedStatus-level (not just statusLine-level) test for runtime-health omitting the resolved service. Co-Authored-By: Claude Fable 5.1 --- .claude/skills/developing-insta-cli/SKILL.md | 2 +- src/commands/compute.ts | 9 ++++++-- src/commands/upgrade.ts | 10 +++++++-- src/index.ts | 8 ++++++- test/help-surface.test.ts | 22 +++++++++++++------- test/managed-db-status.test.ts | 18 ++++++++++++++++ test/retired-policy.test.ts | 4 ++-- 7 files changed, 58 insertions(+), 15 deletions(-) diff --git a/.claude/skills/developing-insta-cli/SKILL.md b/.claude/skills/developing-insta-cli/SKILL.md index a9d6a09..1579771 100644 --- a/.claude/skills/developing-insta-cli/SKILL.md +++ b/.claude/skills/developing-insta-cli/SKILL.md @@ -27,7 +27,7 @@ npx tsx src/index.ts --help # run the CLI from source | `api.ts` | typed platform-API client (auth headers, token refresh, error mapping) | | `config.ts` | global `~/.insta/config.json` (apiUrl + tokens + user) · project `./.insta/project.json` (projectId / orgId / current branch) · machine-local `./.insta/link-plane.json` (the control plane the link was made on; a foreign link fails closed in `requireProject`) | | `commands/` | one file per command group: `auth` `org` `project` `branch` `services` (the `service` group) `secrets` `domain` `compute` (+ BYO domain functions, type-generalized limits/volume/always-on) `postgres` `managed-db` (redis/mysql/mongodb status) `db-query` `storage` `build` `deploy` `run` `template` `billing` `metrics` (+`logs`, `usage`) `govern` (approvals/events) `agent-policy` `observe` `manifest` `setup` `mcp` `regions` `env` `feedback` `upgrade` | -| `observe/` | local `insta observe` hook — `scanner.ts` (AWS/GitHub/Stripe/LLM/DB cred detection), `hook.ts`, `install.ts`, `report.ts` (→ platform event ingest) | +| `observe/` | local `insta agent observe` hook — `scanner.ts` (AWS/GitHub/Stripe/LLM/DB cred detection), `hook.ts`, `install.ts`, `report.ts` (→ platform event ingest) | | `flyctl-build.ts` | source-directory deploy build glue (Fly build context) | | `nixpacks.ts` | nixpacks glue for `insta build` — plan detection + Dockerfile generation (no Docker daemon) | | `ensure-skills.ts` | installs/refreshes the agent skills into the user's project | diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 54ba332..695b4a9 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -670,7 +670,12 @@ export function volumeLines(name: string, volume: { sizeGib: number; mountPath: // disk was already extended); the wire size is authoritative in both cases. export function volumeWriteLine(name: string, body: { volume: { sizeGib: number; mountPath: string }; cap: { volumeGib: number }; attached?: boolean }, type: ManagedType = 'compute'): string { if (body.attached) { - return `${type} ${name}: volume ${body.volume.sizeGib}Gi attached — mounts at ${body.volume.mountPath} on the next deploy (plan max ${body.cap.volumeGib}Gi)` + // Only a compute service has a deploy step for the mount to wait on; a managed database has + // no deploy, so its disk is simply mounted. + const mounts = type === 'compute' + ? `mounts at ${body.volume.mountPath} on the next deploy` + : `mounts at ${body.volume.mountPath}` + return `${type} ${name}: volume ${body.volume.sizeGib}Gi attached — ${mounts} (plan max ${body.cap.volumeGib}Gi)` } return `${type} ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)` } @@ -757,7 +762,7 @@ export async function serviceLimits(type: ManagedType, serviceName: string | und const r = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/limits`) if (opts.json) return printJson(r) info(`${type} ${svc.name}: ceiling ${r.limits.cpu} vCPU / ${fmtMb(r.limits.memoryMb)} (plan max ${r.cap.cpu} vCPU / ${fmtMb(r.cap.memoryMb)})`) - info(' billing is actual usage — the ceiling caps what the app may burn, it is not a price') + info(` billing is actual usage — the ceiling caps what the ${type === 'compute' ? 'app' : 'database'} may burn, it is not a price`) return } if (!opts.memory) throw new Error('--memory is required when setting limits (cpu is derived from it; pass --cpu only to override)') diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 589eeae..aaaf464 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -20,7 +20,7 @@ import { spawn } from 'node:child_process' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' -import { readGlobal, writeGlobal } from '../config.js' +import { readGlobal, readPersistedGlobal, writeGlobal } from '../config.js' import { resolveSpawnable } from '../spawn.js' import { info } from '../util.js' @@ -368,12 +368,18 @@ export async function backgroundCheck(current: string, deps: CheckDeps = {}): Pr // `insta config autoupdate [on|off]` — toggle / show the auto-update preference (default: on). export async function autoupdate(mode?: string): Promise { - const cfg = await readGlobal() if (mode === 'on' || mode === 'off') { + // Read-modify-write must use the PERSISTED config (like `env use` does), not readGlobal()'s + // runtime view: readGlobal() folds in a --api-url/INSTA_API_URL override and, when that override + // points at a different deployment, scrubs the stored session. Writing that view back to disk + // here would silently re-point ~/.insta/config.json at the override and log the user out just + // from toggling autoupdate. + const cfg = await readPersistedGlobal() await writeGlobal({ ...cfg, autoUpdate: mode === 'on' }) info(`autoupdate ${mode}`) return } + const cfg = await readGlobal() const enabled = cfg.autoUpdate !== false && !process.env.INSTA_NO_AUTOUPDATE info(`autoupdate: ${enabled ? 'on' : 'off'} (default on while the CLI is pre-1.0 — \`insta config autoupdate off\` to disable)`) } diff --git a/src/index.ts b/src/index.ts index db5b058..0dd3538 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,7 +72,7 @@ const program = new Command() program.enablePositionalOptions() program.name('insta').description('InstaCloud CLI — manage projects, branches, services, deploys').version(cliVersion()) program.option('--agent', 'run as an agent with a verified project session and project agent policy') -program.option('--api-url ', 'control-plane API base URL for this invocation only — beats INSTA_API_URL, INSTA_ENV and the stored login; a URL for another deployment runs logged-out (internal debugging). Accepted before or after any subcommand') +program.option('--api-url ', 'control-plane API base URL for this invocation only — beats INSTA_API_URL, INSTA_ENV and the stored login; a URL for another deployment runs logged-out (internal debugging). Accepted before or after any subcommand (except `compute exec`: pass it before `compute` there)') // The runtime --api-url must be in place before any action loads config (ApiClient.load → // readGlobal). optsWithGlobals merges the root's, a group's and the leaf's copy of the flag // (addApiUrlEverywhere below), so it is honoured wherever it was typed; typed twice, the outermost wins. @@ -156,6 +156,9 @@ const sec = program.command('secrets').description('Fetch the credential bundle .option('--service ', "read one compute service's own slice of the bundle instead of the branch-wide merge, e.g. compute/api") .option('-o, --output ', 'output file (default .env)').option('--print', 'print instead of writing').option('--json') .action(guard((o) => secretsCmd.secrets(o))) +// commander 12 defaults allowExcessArguments to true, so a mistyped/retired subcommand (e.g. +// `secrets lst`) would otherwise run this group's own action instead of failing. +sec.allowExcessArguments(false) sec.command('list').description('List secret names, grouped by service').option('--branch ').option('--json').action(guard((o) => secretsCmd.secretsList(o))) sec.command('set [value]').description('Set a user secret (project-wide; value from stdin if omitted)') .option('--branch ', 'scope to one branch').option('--service ', 'bind to a branch service (implies current branch)') @@ -424,6 +427,9 @@ tpl.command('deploy ').description('Deploy a template onto a const bill = program.command('billing').description('Billing: current cycle overview (bare), subscribe to a tier, Stripe portal, usage by dimension') .option('--org ', 'target org (default: linked project\'s org)').option('--json') .action(guard((o) => billing(o))) +// commander 12 defaults allowExcessArguments to true, so a mistyped/retired subcommand (e.g. +// `billing upgrade pro`) would otherwise silently run the overview action instead of failing. +bill.allowExcessArguments(false) bill.command('subscribe ').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout') .option('--org ').option('--no-open', 'print the URL instead of opening a browser').option('--json') .action(guard((tier, o) => billingUpgrade(tier, o))) diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index 57a61bf..644a2bf 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -30,6 +30,7 @@ const RETIRED: string[][] = [ ['compute', 'set-domain'], ['compute', 'check-domain'], ['compute', 'remove-domain'], ['db'], ['metrics'], ['logs'], ['usage'], ['manifest'], ['approvals'], ['agent-policy'], ['observe'], ['events'], ['setup'], ['mcp'], ['regions'], ['autoupdate'], + ['billing', 'upgrade'], ] // Command names from a commander help page: the lines indented exactly two spaces under @@ -48,11 +49,11 @@ describe('top-level surface', () => { const r = run(['--help']) expect(r.status).toBe(0) expect(commandNames(r.stdout)).toEqual(VISIBLE) - }) + }, 30_000) it('documents --api-url once, on the root', () => { expect(run(['--help']).stdout).toContain('--api-url ') expect(run(['compute', 'status', '--help']).stdout).not.toContain('--api-url') - }) + }, 30_000) it('keeps services and svc as aliases of service', () => { for (const alias of ['services', 'svc']) { const r = run([alias, '--help']) @@ -60,17 +61,24 @@ describe('top-level surface', () => { expect(r.stdout).toMatch(/^\s+add\b/m) expect(r.stdout).not.toMatch(/^\s+scale\b/m) } - }) + }, 30_000) it('hides env from the root help but keeps it working', () => { expect(run(['--help']).stdout).not.toMatch(/^\s+env\b/m) const r = run(['env', '--help']) expect(r.status).toBe(0) expect(r.stdout).toMatch(/^\s+use\b/m) - }) + }, 30_000) it.each(RETIRED)('retired path `%s` is gone', (...path) => { const r = run([...path]) expect(r.status).not.toBe(0) - expect(r.stderr).toMatch(/unknown command/) + // commander 12's allowExcessArguments(false) rejects a retired subcommand under a still-live + // group (e.g. `billing upgrade`) with "too many arguments", not "unknown command". + expect(r.stderr).toMatch(/unknown command|too many arguments/) + }, 30_000) + it('retired `secrets lst` fails loudly instead of silently writing .env', () => { + const r = run(['secrets', 'lst']) + expect(r.status).not.toBe(0) + expect(r.stderr).toMatch(/unknown command|too many arguments/) }, 30_000) }) @@ -104,7 +112,7 @@ describe('group shapes', () => { expect(commandNames(run(['agent', '--help']).stdout)).toEqual(['setup', 'manifest', 'policy', 'approvals', 'observe', 'events']) expect(commandNames(run(['config', '--help']).stdout)).toEqual(['install-mcp', 'regions', 'autoupdate']) expect(commandNames(run(['billing', '--help']).stdout)).toEqual(['subscribe', 'portal', 'usage']) - }) + }, 30_000) }) describe('--api-url placement', () => { @@ -115,5 +123,5 @@ describe('--api-url placement', () => { expect(JSON.parse(run(['env', '--json', '--api-url', URL_A]).stdout).apiUrl).toBe(URL_A) expect(JSON.parse(run(['--api-url', URL_A, 'env', '--json']).stdout).apiUrl).toBe(URL_A) expect(JSON.parse(run(['env', '--json', '--api-url', URL_A], { INSTA_API_URL: URL_B }).stdout).apiUrl).toBe(URL_A) - }) + }, 30_000) }) diff --git a/test/managed-db-status.test.ts b/test/managed-db-status.test.ts index d879b66..7c250ab 100644 --- a/test/managed-db-status.test.ts +++ b/test/managed-db-status.test.ts @@ -60,4 +60,22 @@ describe('managedStatus', () => { await expect(managedStatus('redis', 'db', {}, d)).rejects.toThrow('redis service not found: db') await expect(managedStatus('mongodb', undefined, {}, d)).rejects.toThrow(/no mongodb service/) }) + it('falls back to unknown when runtime-health omits the resolved service (not just via statusLine)', async () => { + const calls: string[] = [] + const healthMissingR1 = { services: health.services.filter((e) => e.serviceId !== 'r1') } + const api = { + request: async (_m: string, path: string) => { + calls.push(path) + return path.includes('/runtime-health') ? healthMissingR1 : { services } + }, + } + const d = { api, project: { projectId: 'p1', branch: 'main' } } as unknown as ManagedDeps + + await managedStatus('redis', undefined, {}, d) + expect(out()).toContain('redis cache: unknown (the runtime-health read did not include this service)') + + stdout.length = 0 + await managedStatus('redis', undefined, { json: true }, d) + expect(JSON.parse(out())).toEqual({ serviceId: 'r1', status: 'unknown', machines: 0, failing: 0 }) + }) }) diff --git a/test/retired-policy.test.ts b/test/retired-policy.test.ts index 30734f6..794c822 100644 --- a/test/retired-policy.test.ts +++ b/test/retired-policy.test.ts @@ -17,7 +17,7 @@ it('exposes policy only under agent, and rejects the retired top-level names', ( expect(r.stderr).toContain(`unknown command '${retired[0]}'`) } expect(run('agent', 'policy', 'get', '--help').status).toBe(0) -}) +}, 30_000) it('rejects approval --always instead of promising a permanent grant', () => { const help = run('agent', 'approvals', 'approve', '--help') @@ -26,4 +26,4 @@ it('rejects approval --always instead of promising a permanent grant', () => { const retired = run('agent', 'approvals', 'approve', 'test-id', '--always') expect(retired.status).not.toBe(0) expect(retired.stderr).toContain("unknown option '--always'") -}) +}, 30_000) From fc91e0775482e6667e57a9474378ec93605fdca6 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 17:34:33 -0700 Subject: [PATCH 14/19] setup agent stays as a hidden alias of agent setup (the console one-liner) Co-Authored-By: Claude Fable 5.1 --- .claude/skills/developing-insta-cli/SKILL.md | 2 +- README.md | 2 ++ install.sh | 16 +++++------- src/index.ts | 27 +++++++++++++++----- test/help-surface.test.ts | 16 +++++++++++- 5 files changed, 45 insertions(+), 18 deletions(-) diff --git a/.claude/skills/developing-insta-cli/SKILL.md b/.claude/skills/developing-insta-cli/SKILL.md index 1579771..787d664 100644 --- a/.claude/skills/developing-insta-cli/SKILL.md +++ b/.claude/skills/developing-insta-cli/SKILL.md @@ -53,7 +53,7 @@ pins the visible top level; changing it is a design decision, not a code change. trailing optional positional, sole/default service when omitted (`resolveSoleService`); `storage --service `; org-scoped verbs take `--org `. A new verb copies its group's shape; a new group copies the closest existing group. -5. **Renames are hard cutovers.** No hidden aliases (only `services|svc` → `service` are permanent). +5. **Renames are hard cutovers.** No hidden aliases, with two permanent exceptions: `services|svc` → `service`, and hidden `setup agent` → `agent setup` (the console one-liner is printed in too many places to cut over). A rename changes, in the same change set: `skills/insta/cli-reference.md`, `e2e/`, console copy in `frontend/`, MCP copy, and platform error strings that spell the path — and it ships in the order the design's §9 gives (docs/copy merge right after the CLI release, never before). diff --git a/README.md b/README.md index 8026f84..993056d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ and Windows (PowerShell + cmd): npx -y insta@latest agent setup ``` +`insta setup agent` is still accepted as a hidden alias, so older instructions keep working. + This command means **production** (CLI ≥ 0.0.38): if the machine was previously switched to staging it switches back — announced, session dropped, like `insta env use prod`. Staging is its own explicit command, which also persists the choice: diff --git a/install.sh b/install.sh index 3235a2b..e160bbe 100644 --- a/install.sh +++ b/install.sh @@ -10,7 +10,7 @@ # (equivalent to piping this script with: sh -s -- --agents; add -y for a hard non-interactive run) # # Flags: -# --agents after installing, run `insta agent setup` — or `setup agent` on older CLIs — installing skills for Claude Code/Codex/Cursor/… +# --agents after installing, run `insta setup agent` (alias of `insta agent setup`) — installing skills for Claude Code/Codex/Cursor/… # -y non-interactive # --staging target the staging deployment (shorthand for --env staging) # --env target a named deployment: prod (default) | staging @@ -240,19 +240,17 @@ if [ "$AGENTS" = "1" ]; then YFLAG="" [ "$YES" = "1" ] && YFLAG="-y" SETUP_ERR="${TMPDIR:-/tmp}/insta-setup-err.$$" - # CLI releases after 0.0.79 spell it `insta agent setup`; older binaries only know `setup agent`. - # Probe the ROOT help for an `agent` command group. Not `agent --help`: commander prints the root - # help and exits 0 for ANY unknown command when --help is present, so that never discriminates. - # The pattern needs the trailing space/EOL so the old `agent-policy` row cannot match. - if "$INSTALL_DIR/$BIN" --help 2>/dev/null | grep -qE '^ +agent( |$)'; then SETUP_CMD="agent setup"; else SETUP_CMD="setup agent"; fi - if "$INSTALL_DIR/$BIN" $SETUP_CMD $YFLAG $SETUP_ENV_ARGS 2>"$SETUP_ERR"; then + # `setup agent` is the permanent compatibility alias of `insta agent setup` (canonical since the + # command re-organization). It works on every release, which is exactly what a script fetched from + # `main` and run against whatever binary is current needs — do not "modernize" this call. + if "$INSTALL_DIR/$BIN" setup agent $YFLAG $SETUP_ENV_ARGS 2>"$SETUP_ERR"; then cat "$SETUP_ERR" >&2 else cat "$SETUP_ERR" >&2 if [ -n "$SETUP_ENV_ARGS" ] && grep -qi "unknown option" "$SETUP_ERR"; then - "$INSTALL_DIR/$BIN" $SETUP_CMD $YFLAG || echo "warn: agent setup failed — run: insta $SETUP_CMD" + "$INSTALL_DIR/$BIN" setup agent $YFLAG || echo "warn: agent setup failed — run: insta setup agent" else - echo "warn: agent setup failed — run: insta $SETUP_CMD ${SETUP_ENV_ARGS}" + echo "warn: agent setup failed — run: insta setup agent ${SETUP_ENV_ARGS}" fi fi rm -f "$SETUP_ERR" diff --git a/src/index.ts b/src/index.ts index 0dd3538..f7cdbe6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -440,15 +440,21 @@ bill.command('usage').description('Usage for the current billing cycle by billin .option('--from ').option('--to ').option('--proj [id]', 'show one project (the linked one, or a given id) instead of the whole org').option('--json') .action(guard((o) => obs.usage(o))) +// `agent setup` and its hidden alias `setup agent` declare their options from ONE list, so a flag +// added to the canonical command cannot be missing from the one-liner the console prints. +function withSetupAgentOptions(cmd: Command): Command { + return cmd + .option('-y, --yes', 'non-interactive') + .option('--env ', 'deployment to set this machine up for (default: prod — switches and persists, like `insta env use`)') + .option('--mcp-token', 'register Claude Code with a minted insta_ API token instead of OAuth (requires login and token-creation permission)') + .option('--project ', 'also link this directory to an existing project after setup (flows through login first if needed)') + .option('--create [name]', 'also create a new project and link this directory after setup (default name: this directory; mutually exclusive with --project)') + .action(guard((o) => setup.setupAgent(o))) +} + // ---- agent (this machine's coding agents + the project's agent governance) ---- const agent = program.command('agent').description('Agents: set up this machine, the project manifest, access policy, approvals (HITL), the local credential audit, the event timeline') -agent.command('setup').description('Install the insta CLI (if missing), the insta skill for all coding agents, and the MCP server — targets production; pass --env staging for the staging deployment') - .option('-y, --yes', 'non-interactive') - .option('--env ', 'deployment to set this machine up for (default: prod — switches and persists, like `insta env use`)') - .option('--mcp-token', 'register Claude Code with a minted insta_ API token instead of OAuth (requires login and token-creation permission)') - .option('--project ', 'also link this directory to an existing project after setup (flows through login first if needed)') - .option('--create [name]', 'also create a new project and link this directory after setup (default name: this directory; mutually exclusive with --project)') - .action(guard((o) => setup.setupAgent(o))) +withSetupAgentOptions(agent.command('setup').description('Install the insta CLI (if missing), the insta skill for all coding agents, and the MCP server — targets production; pass --env staging for the staging deployment')) agent.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o))) const agentPol = agent.command('policy').description('Project agent access policy') agentPol.command('get').option('--json').action(guard((o) => agentPolicy.get(o))) @@ -480,6 +486,13 @@ cfg.command('install-mcp').description('Register the remote MCP server with codi cfg.command('regions').description('List regions available for postgres/compute services').option('--json').action(guard((o) => regions.regionsList(o))) cfg.command('autoupdate [mode]').description('Show or set auto-update: on | off (default: on while pre-1.0)').action(guard((mode) => selfUpdate.autoupdate(mode))) +// ---- setup (hidden compatibility alias of `agent setup`) ---- +// `npx -y insta@latest setup agent [--project ]` is printed by the console, the landing page +// and third-party docs; it must keep working on every release. Permanent, like `services|svc`; +// hidden so the canonical `agent setup` is the only one help advertises. +const setupCompat = program.command('setup', { hidden: true }).description('Compatibility alias: `insta setup agent` is `insta agent setup`') +withSetupAgentOptions(setupCompat.command('agent').description('Alias of `insta agent setup`, kept for the console one-liner')) + // ---- feedback (agent + human hurdle reports → the InstaCloud team) ---- program.command('feedback') .description('Report an InstaCloud-side hurdle (bug / missing feature / friction) to the InstaCloud team — about the insta toolkit itself, NEVER about the app you are building. Works logged-out and unlinked.') diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index 644a2bf..f9a08d3 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -29,7 +29,7 @@ const RETIRED: string[][] = [ ['services', 'scale'], ['services', 'upgrade'], ['services', 'set-access'], ['services', 'secrets'], ['compute', 'set-domain'], ['compute', 'check-domain'], ['compute', 'remove-domain'], ['db'], ['metrics'], ['logs'], ['usage'], ['manifest'], ['approvals'], ['agent-policy'], ['observe'], ['events'], - ['setup'], ['mcp'], ['regions'], ['autoupdate'], + ['mcp'], ['regions'], ['autoupdate'], ['billing', 'upgrade'], ] @@ -80,6 +80,20 @@ describe('top-level surface', () => { expect(r.status).not.toBe(0) expect(r.stderr).toMatch(/unknown command|too many arguments/) }, 30_000) + // The console, the landing page and third-party docs print `npx -y insta@latest setup agent …`; + // that string must keep working on every release, so `setup agent` is a permanent hidden alias + // of `agent setup` — the same class as `services|svc`. + it('keeps `setup agent` as a hidden alias of `agent setup` with identical options', () => { + expect(run(['--help']).stdout).not.toMatch(/^\s+setup\b/m) + const alias = run(['setup', 'agent', '--help']) + const canonical = run(['agent', 'setup', '--help']) + expect(alias.status).toBe(0) + expect(canonical.status).toBe(0) + // Option flags only (the column before the description), sorted: a flag added to one + // registration cannot silently be missing from the other. + const flags = (help: string) => help.split('\n').filter((l) => /^\s+-/.test(l)).map((l) => l.trim().split(/\s{2,}/)[0]).sort() + expect(flags(alias.stdout)).toEqual(flags(canonical.stdout)) + }, 30_000) }) describe('group shapes', () => { From 0e86d0e583857fe3e1937e5d3c40584f6c07bbe6 Mon Sep 17 00:00:00 2001 From: jwfing Date: Thu, 17 Sep 2026 19:45:38 -0700 Subject: [PATCH 15/19] =?UTF-8?q?managed=20databases:=20volume=20offers=20?= =?UTF-8?q?no=20--delete=20(the=20platform=20refuses=20it=20=E2=80=94=20th?= =?UTF-8?q?e=20volume=20is=20the=20data=20directory)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- src/index.ts | 3 +-- test/help-surface.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index f7cdbe6..d4f30d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -352,9 +352,8 @@ for (const type of ['redis', 'mysql', 'mongodb'] as const) { .option('--memory ', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu ', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)') .option('--json').option('--branch ', 'branch (default: current)') .action(guard((service, o) => computeCmd.serviceLimits(type, service, o))) - g.command('volume [service]').description(`Show, grow, or delete a ${type} service's data volume (mounted at the image's data directory). No flag: size and the plan cap (any plan). --size grows it (paid plans; grow-only). --delete DESTROYS the disk and ALL its data immediately (no undo). Billing is actual data stored — the size is a cap, not a price`) + g.command('volume [service]').description(`Show or grow a ${type} service's data volume (the image's data directory). No flag: size and the plan cap (any plan). --size grows it (paid plans; grow-only). A managed database's volume cannot be deleted — remove the service instead. Billing is actual data stored — the size is a cap, not a price`) .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') - .option('--delete', 'destroy the volume and ALL its data (irreversible; back up first)') .option('--json').option('--branch ', 'branch (default: current)') .action(guard((service, o) => computeCmd.serviceVolume(type, service, o))) g.command('always-on [service]').description(`Set a ${type} service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero. Billing is actual usage either way`) diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index f9a08d3..c66efdb 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -127,6 +127,14 @@ describe('group shapes', () => { expect(commandNames(run(['config', '--help']).stdout)).toEqual(['install-mcp', 'regions', 'autoupdate']) expect(commandNames(run(['billing', '--help']).stdout)).toEqual(['subscribe', 'portal', 'usage']) }, 30_000) + it('offers --delete on compute volume only — a managed database volume is its data directory', () => { + expect(run(['compute', 'volume', '--help']).stdout).toContain('--delete') + for (const type of ['redis', 'mysql', 'mongodb']) { + const help = run([type, 'volume', '--help']).stdout + expect(help, type).not.toContain('--delete') + expect(help, type).toContain('--size') + } + }, 30_000) }) describe('--api-url placement', () => { From 663391cbc02f72212312f6d5241488065fa42e58 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 18 Sep 2026 10:39:25 -0700 Subject: [PATCH 16/19] review: address PR #252 findings (persisted --api-url, domain detach on bought names, managed-DB volume help, shape-rule exception) Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/developing-insta-cli/SKILL.md | 16 +++-- README.md | 2 +- src/api.ts | 20 ++++++- src/commands/auth.ts | 23 ++++---- src/commands/compute.ts | 14 ++++- src/commands/domain.ts | 40 +++++++++++-- src/commands/services.ts | 21 ++++++- src/commands/setup.ts | 5 +- src/commands/upgrade.ts | 9 ++- src/config.ts | 6 +- src/index.ts | 4 +- test/api-url-override.test.ts | 61 ++++++++++++++++++++ test/domain.test.ts | 40 ++++++++++++- test/help-surface.test.ts | 28 +++++++++ test/retired-policy.test.ts | 10 +++- test/services.test.ts | 9 ++- test/upgrade.test.ts | 6 ++ test/volume.test.ts | 27 ++++++++- 18 files changed, 299 insertions(+), 42 deletions(-) diff --git a/.claude/skills/developing-insta-cli/SKILL.md b/.claude/skills/developing-insta-cli/SKILL.md index 787d664..642a8f2 100644 --- a/.claude/skills/developing-insta-cli/SKILL.md +++ b/.claude/skills/developing-insta-cli/SKILL.md @@ -50,9 +50,13 @@ pins the visible top level; changing it is a design decision, not a code change. 3. **One capability, one path.** Before adding a command, grep `src/commands/` for the platform endpoint it calls. If another command already calls it, add a flag or mode there instead. 4. **Same shape for the same thing.** `compute|postgres|redis|mysql|mongodb [service]` — - trailing optional positional, sole/default service when omitted (`resolveSoleService`); - `storage --service `; org-scoped verbs take `--org `. A new verb copies its - group's shape; a new group copies the closest existing group. + trailing optional positional, sole/default service when omitted (`resolveSoleService`) — except + managed-database `query`, where the service is required and LEADS (`query [args…]`), + because a trailing optional service cannot be told apart from the query argv (`insta redis query + GET key`) without a `--` separator like `compute exec` uses; the design's §8 records it as + deferred, and `test/help-surface.test.ts` pins it. `storage --service `; org-scoped + verbs take `--org `. A new verb copies its group's shape; a new group copies the closest + existing group. 5. **Renames are hard cutovers.** No hidden aliases, with two permanent exceptions: `services|svc` → `service`, and hidden `setup agent` → `agent setup` (the console one-liner is printed in too many places to cut over). A rename changes, in the same change set: `skills/insta/cli-reference.md`, `e2e/`, console copy in `frontend/`, MCP copy, and platform error strings that spell the path — and it ships in the @@ -62,8 +66,10 @@ Where things go: settings (limits/volume/always-on/scale) live under the service `logs`/`metrics` live under the service type via `addObservability()` in `index.ts`; anything about this machine's agents or the project's agent governance lives under `agent`; anything about this machine's CLI configuration lives under `config`. `--api-url` is injected on every command by -`addApiUrlEverywhere()` — never declare it on a new command by hand (except a command that must -persist it, like `login`). +`addApiUrlEverywhere()`, with two deliberate exceptions: the root and `login` declare the option +themselves (`login` is the one command that may persist the URL it is given), and `compute exec` is +skipped because its argv is split before commander ever sees it — there, pass the flag at the root +(`insta --api-url X compute exec …`). Never declare `--api-url` on a new command by hand. ## Getting a PR merged (main is protected — this exact flow, no other works) diff --git a/README.md b/README.md index cc0daff..7988d1d 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ build never reaches a production installer. | `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; works logged-out | | `insta upgrade` | Update the CLI | -Every command accepts `--api-url ` for this invocation only (internal debugging); `insta --help` documents it. +Every command accepts `--api-url ` for this invocation only (internal debugging); for `compute exec`, place it before `compute`. `insta --help` documents it. ## Configuration diff --git a/src/api.ts b/src/api.ts index abb1ee5..3cf4901 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,6 @@ // Thin API client over the platform control-plane. Handles bearer auth + one-shot refresh on 401. // 2xx (including 202 approval_required) returns the parsed body; >=400 throws ApiError. -import { readGlobal, writeGlobal, readProject, persistAutoLink, resolveProjectLink, foreignLinkMessage, type GlobalConfig, type ProjectConfig } from './config.js' +import { readGlobal, readPersistedGlobal, writeGlobal, readProject, persistAutoLink, resolveProjectLink, foreignLinkMessage, type GlobalConfig, type ProjectConfig } from './config.js' import { autoResolveProject, promptChoice, type ProjectItem } from './resolve-project.js' import { die } from './util.js' import { USER_AGENT } from './version.js' @@ -40,9 +40,23 @@ export class ApiClient { get apiUrl(): string { return this.cfg.apiUrl } get config(): GlobalConfig { return this.cfg } - async persist(): Promise { await writeGlobal(this.cfg) } + // `this.cfg` came from readGlobal(), which has already folded in the AMBIENT control-plane + // override (--api-url, INSTA_API_URL, INSTA_ENV) — so writing it back verbatim persists a + // debugging override as this machine's control plane. `insta logout --api-url ` did + // exactly that: it re-pointed the stored apiUrl at staging and dropped the real login with it + // (readGlobal scrubs the session of a foreign deployment, and that scrubbed view was the thing + // being written). Only an EXPLICIT setApiUrl — a login, which chose the deployment it + // authenticated against — may move the stored URL; everything else keeps what is on disk. + async persist(): Promise { + if (this.apiUrlExplicit) return writeGlobal(this.cfg) + await writeGlobal({ ...this.cfg, apiUrl: (await readPersistedGlobal()).apiUrl }) + } - setApiUrl(url: string): void { this.cfg.apiUrl = url } + private apiUrlExplicit = false + setApiUrl(url: string): void { + this.cfg.apiUrl = url + this.apiUrlExplicit = true + } setSession(tokens: { accessToken: string; refreshToken: string }, user?: GlobalConfig['user']): void { this.cfg.accessToken = tokens.accessToken diff --git a/src/commands/auth.ts b/src/commands/auth.ts index ed1c354..beaf882 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -7,7 +7,13 @@ import { info, die, printJson, promptPassword, openUrl } from '../util.js' /** --api-url and --env both set the target host; --api-url wins (more specific), matching the * INSTA_API_URL > INSTA_ENV precedence in config.ts. Returns the URL to point at, or undefined - * to leave whatever is already resolved alone. */ + * to leave whatever is already resolved alone. + * + * Every login entry point feeds this into `api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl)`: a + * login is the one command that MAY move the machine's stored control-plane URL, and it must + * store the deployment it actually authenticated against — flag, env var or stored URL alike. + * Without the explicit set, ApiClient.persist() keeps the URL already on disk (see its comment), + * which would file a session minted on one deployment under another one's URL. */ function targetApiUrl(opts: { apiUrl?: string; env?: string }): string | undefined { if (opts.apiUrl) return opts.apiUrl if (!opts.env) return undefined @@ -46,8 +52,7 @@ export async function login( return device(opts, openUrl) } const api = await ApiClient.load() - const target = targetApiUrl(opts) - if (target) api.setApiUrl(target) + api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl) const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword()) const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false }) api.setSession(res, res.user) @@ -60,8 +65,7 @@ export async function login( export async function loginOauth(provider: string, opts: { apiUrl?: string; env?: string }): Promise { if (provider !== 'github' && provider !== 'google') die('provider must be github or google') const api = await ApiClient.load() - const target = targetApiUrl(opts) - if (target) api.setApiUrl(target) + api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl) const token = await browserOauth(api.apiUrl, provider) api.setSession({ accessToken: token, refreshToken: token }) const me = await api.request<{ user: { id: string; email: string | null; name: string | null } }>('GET', '/me') @@ -77,8 +81,7 @@ export async function loginOauth(provider: string, opts: { apiUrl?: string; env? // (which owns the signin round-trip), and poll the platform until they approve. export async function loginDevice(opts: { apiUrl?: string; env?: string }, open?: (url: string) => boolean): Promise { const api = await ApiClient.load() - const target = targetApiUrl(opts) - if (target) api.setApiUrl(target) + api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl) const token = await deviceGrant((path, body) => api.request('POST', path, body, { auth: false }), sleepSeconds, open) api.setSession({ accessToken: token, refreshToken: token }) const me = await api.request<{ user: { id: string; email: string | null; name: string | null } }>('GET', '/me') @@ -91,8 +94,7 @@ export async function loginDevice(opts: { apiUrl?: string; env?: string }, open? // the console, the platform mints an insta_ key, and it is stored exactly as --api-key stores one. export async function loginClaim(email: string, opts: { apiUrl?: string; env?: string }, open?: (url: string) => boolean, grant: typeof claimGrant = claimGrant): Promise { const api = await ApiClient.load() - const target = targetApiUrl(opts) - if (target) api.setApiUrl(target) + api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl) const client = agentMode()?.client ?? 'unknown' const key = await grant(email, client, (path, body, signal) => api.request('POST', path, body, { auth: false, signal }), sleepSeconds, open) const user = await applyApiKeyLogin(api, key) @@ -103,8 +105,7 @@ export async function loginClaim(email: string, opts: { apiUrl?: string; env?: s // Non-interactive login with a durable insta_ key (minted via POST /tokens): store it and confirm against /me. No browser, no polling. export async function loginApiKey(key: string, opts: { apiUrl?: string; env?: string }): Promise { const api = await ApiClient.load() - const target = targetApiUrl(opts) - if (target) api.setApiUrl(target) + api.setApiUrl(targetApiUrl(opts) ?? api.apiUrl) const user = await applyApiKeyLogin(api, key) await api.persist() info(`logged in as ${user.email ?? user.id} @ ${api.apiUrl}`) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 695b4a9..d8a547f 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -659,9 +659,15 @@ export function volumeLines(name: string, volume: { sizeGib: number; mountPath: ? `compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size \` — it mounts at /data on the next deploy)` : `${type} ${name}: no volume attached (attach one: \`insta ${type} volume ${name} --size \` — it mounts at the image's data directory)`, ] + // `--delete` exists on `compute volume` only — a managed database's volume IS its data + // directory, so the group never registered the flag (index.ts) and naming it here would print + // a command that fails. + const grow = type === 'compute' + ? 'grow with --size (grow-only), delete with --delete (destroys the data)' + : 'grow with --size (grow-only); the volume cannot be deleted — remove the service instead' return [ `${type} ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`, - ' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only), delete with --delete (destroys the data)', + ` billing is actual data stored — the size is a cap, not a price; ${grow}`, ] } @@ -684,7 +690,11 @@ export function volumeWriteLine(name: string, body: { volume: { sizeGib: number; // path (there is no detach), so the line says what came back with it: the two constraints the // volume imposed. export function volumeDeleteLine(name: string, type: ManagedType = 'compute'): string { - return `${type} ${name}: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back` + // The two constraints a volume imposes — no suspend fast-wake, no scale-out — are compute-plane + // facts. `--delete` is registered on compute only, so the other branch is unreachable today; + // it is written type-aware anyway so the line cannot start lying if it ever becomes reachable. + const regained = type === 'compute' ? '; suspend fast-wake and scale-out are back' : '' + return `${type} ${name}: volume deleted — the disk and its data are gone${regained}` } // Map a DELETE .../volume failure. Pure, exported for tests. An older backend has no DELETE diff --git a/src/commands/domain.ts b/src/commands/domain.ts index 3083fb1..60dd875 100644 --- a/src/commands/domain.ts +++ b/src/commands/domain.ts @@ -199,7 +199,39 @@ export async function domainRecordsRemove(domainName: string, id: string, opts: info(`removed record ${id} from ${domainName}`) } -// `insta domain check|detach ` — the hostname-level reads and writes, for bought and -// bring-your-own names alike (both are routed through the compute plane's custom-domain surface). -export const domainCheck = checkDomain -export const domainDetach = removeDomain +// `insta domain check|detach ` — the hostname-level reads and writes. Both normalize the +// hostname exactly as `attach` does: DNS is case-insensitive, `attach` lowercases before it sends, +// and forwarding `Docs.MyApp.com` verbatim asked the plane about a binding it never wrote. +type HostOpts = { branch?: string; group?: string; json?: boolean } + +export async function domainCheck(host: string, opts: HostOpts, deps?: DomainDeps): Promise { + return checkDomain(host.trim().toLowerCase(), opts, deps) +} + +/** + * Release a hostname from its compute service. Bring-your-own only: a hostname under a domain + * bought through InstaCloud is refused here. + * + * `attach` writes a bought hostname into the platform's DOMAINS record (state, serviceId, + * releaseFrom) and lets a reconciler bind it on the compute plane; this verb's only route, + * DELETE /projects/:id/compute/domain, unbinds on the compute plane alone and the platform + * exposes no detach route for the domains record. Running it would leave the record still + * claiming a binding that no longer exists — and, for an apex, `attach` binds both the name and + * its www while this takes one hostname. The supported way to move a bought hostname is another + * `attach`, which releases it from the old service itself. + */ +export async function domainDetach(host: string, opts: HostOpts, deps?: DomainDeps): Promise { + const d = await domainDeps(deps) + const name = host.trim().toLowerCase() + // Without an org there is no domains list to check against (INSTA_PROJECT_ID-only CI links name + // none); a bring-your-own detach must keep working there, so the guard is simply not applied. + if (d.project.orgId) { + const { items } = await d.api.request<{ items: Purchased[] }>('GET', `/orgs/${d.project.orgId}/domains`) + const owner = ownerOf(name, items) + if (owner) { + die(`${name} belongs to ${owner.domainName}, a domain bought through InstaCloud — its binding lives on the domain, not on the compute plane, so detaching it here would leave the domain still claiming it. ` + + `Move it with \`insta domain attach ${name} --group \` (attach releases it from the current one), and see where it stands with \`insta domain status ${owner.domainName}\`.`) + } + } + return removeDomain(name, opts, d) +} diff --git a/src/commands/services.ts b/src/commands/services.ts index 07af91d..227cb8f 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -21,6 +21,23 @@ export function assertServiceName(name: string): void { if (!SERVICE_NAME_RE.test(name)) throw new Error('service name must be lower-kebab (a-z, 0-9, -)') } +// Every service type that owns an always-on / volume verb of its OWN. The creation-time flags are +// compute-only, so the refusal has to name the group the user actually typed: pointing a redis or +// mongodb user at `insta postgres …` sends them at a different service type (and, on a project +// with no postgres, at nothing at all). +const DB_TYPES = ['postgres', 'redis', 'mysql', 'mongodb'] as const +const hasOwnGroup = (type: string): boolean => (DB_TYPES as readonly string[]).includes(type) + +export function alwaysOnTypeError(type: string): string { + const base = '--always-on / --no-always-on is only valid for compute services' + return hasOwnGroup(type) ? `${base} (for ${type}, use \`insta ${type} always-on on|off\` after creation)` : base +} + +export function volumeTypeError(type: string): string { + const base = '--volume is only valid for compute services' + return hasOwnGroup(type) ? `${base} (${type} has one by default — grow it with \`insta ${type} volume --size \`)` : base +} + const MAX_COMPUTE_REPLICAS = 10 // Parse a replica count inside the compute plane's current safety ceiling. @@ -113,10 +130,10 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO parsePort(opts.port) // junk fails here, before any config/network access } // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only. - if (opts.alwaysOn !== undefined && type !== 'compute') throw new Error('--always-on / --no-always-on is only valid for compute services (for postgres, use `insta postgres always-on on|off` after creation)') + if (opts.alwaysOn !== undefined && type !== 'compute') throw new Error(alwaysOnTypeError(type)) if (opts.mountPath !== undefined && (type !== 'compute' || opts.volume === undefined)) throw new Error('--mount-path requires --volume on a compute service') if (opts.volume !== undefined) { - if (type !== 'compute') throw new Error('--volume is only valid for compute services (postgres has one by default — grow it with `insta postgres volume --size`)') + if (type !== 'compute') throw new Error(volumeTypeError(type)) parseVolumeGib(opts.volume) // junk fails here, before any config/network access } const api = await ApiClient.load() diff --git a/src/commands/setup.ts b/src/commands/setup.ts index f0f724d..7006250 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,5 +1,6 @@ -// `insta agent setup` — make this machine's coding agents InstaCloud-native in one step -// (the Railway `railway setup agent` pattern). Installs the `insta` skill USER-GLOBALLY for +// `insta agent setup` — make this machine's coding agents InstaCloud-native in one step. +// (`setup agent` remains a permanent hidden alias; the canonical order is `agent setup`, per the +// noun-first tree — see the Command architecture rules.) Installs the `insta` skill USER-GLOBALLY for // every agent the skills tool knows: the skill is pure product knowledge with brand-gated // triggers — no project state in it (the project binding is carried by ./.insta/project.json // at command time), so one machine-level copy is strictly better than per-project copies. diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index aaaf464..de94708 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -397,12 +397,17 @@ export async function autoupdate(mode?: string): Promise { * into the ssh session's stderr and an auto-upgrade spawns a detached process * mid-connection. Same prefix rule trackCommand already applies to telemetry. */ -export function skipsUpdateCheck(cmd: string | undefined): boolean { +export function skipsUpdateCheck(cmd: string | undefined, sub?: string | undefined): boolean { + // The autoupdate PREFERENCE moved to `insta config autoupdate` in the command re-organization, + // so the level-1 name alone stopped matching it: `insta config autoupdate off` would run the + // very check it is being typed to switch off (and could auto-upgrade before the handler lands). + // The retired top-level spelling stays exempt too — it costs nothing and cannot regress. + if (cmd === 'config' && sub === 'autoupdate') return true return cmd === 'upgrade' || cmd === 'autoupdate' || !!cmd?.startsWith('__') } export function maybeUpdate(current: string, argv: string[]): void { - if (skipsUpdateCheck(argv[2])) return + if (skipsUpdateCheck(argv[2], argv[3])) return const channel = detectChannel() const cache = readCache() const now = Date.now() diff --git a/src/config.ts b/src/config.ts index df3d03f..46b2fa1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,7 +60,11 @@ export function setApiUrlOverride(url: string | undefined): void { * the file keeps the real login, so unsetting the override restores it. A custom host (insta-oss, * a preview) is treated the same way — its session is equally foreign. */ export function pickApiUrl(parsed: GlobalConfig | null, env: NodeJS.ProcessEnv, cliOverride?: string): GlobalConfig { - const named = envFromEnvVar(env.INSTA_ENV) + // `env` is the environment this call is deciding for, so an ABSENT property means unset — not + // "ask the real process". envFromEnvVar defaults its parameter to process.env.INSTA_ENV, so + // passing it `env.INSTA_ENV` unguarded made a caller handing in `{}` (every test, and any future + // caller with a synthetic environment) silently read the ambient one instead. + const named = env.INSTA_ENV === undefined ? null : envFromEnvVar(env.INSTA_ENV) const override = cliOverride ?? env.INSTA_API_URL ?? (named ? ENVS[named].api : undefined) if (!parsed) return { apiUrl: override ?? DEFAULT_API } const persisted = parsed.apiUrl ?? DEFAULT_API diff --git a/src/index.ts b/src/index.ts index 3c1ccd0..29830a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -206,7 +206,7 @@ dom.command('attach ').description('Point a hostname at a compute serv dom.command('check ').description("A hostname's attach state — ownership TXT, routing CNAME, edge certificate, where it resolves — and what each still needs") .option('--branch ').option('--group ', "compute service (default: the branch's sole compute service)").option('--json') .action(guard((hostname, o) => domainCmd.domainCheck(hostname, o))) -dom.command('detach ').description('Detach a hostname from its compute service (gated: deploy)') +dom.command('detach ').description('Detach a hostname from its compute service (gated: deploy). Bring-your-own hostnames only — a hostname under a domain bought here is moved with `insta domain attach`, which releases it from its current service') .option('--branch ').option('--group ', "compute service (default: the branch's sole compute service)").option('--json') .action(guard((hostname, o) => domainCmd.domainDetach(hostname, o))) dom.command('list').description("Domains bought through InstaCloud in this org — a domain belongs to the org, each of its hostnames to a service") @@ -355,7 +355,7 @@ for (const type of ['redis', 'mysql', 'mongodb'] as const) { .option('--memory ', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu ', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)') .option('--json').option('--branch ', 'branch (default: current)') .action(guard((service, o) => computeCmd.serviceLimits(type, service, o))) - g.command('volume [service]').description(`Show or grow a ${type} service's data volume (the image's data directory). No flag: size and the plan cap (any plan). --size grows it (paid plans; grow-only). A managed database's volume cannot be deleted — remove the service instead. Billing is actual data stored — the size is a cap, not a price`) + g.command('volume [service]').description(`Show, attach, or grow a ${type} service's data volume (the image's data directory). No flag: size and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap; a size above the free cap is paid); on a volume-bearing one it grows (paid plans; grow-only once attached — a provisioned disk cannot shrink). A managed database's volume cannot be deleted — remove the service instead. Billing is actual data stored — the size is a cap, not a price`) .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') .option('--json').option('--branch ', 'branch (default: current)') .action(guard((service, o) => computeCmd.serviceVolume(type, service, o))) diff --git a/test/api-url-override.test.ts b/test/api-url-override.test.ts index 00fc6fd..683afcb 100644 --- a/test/api-url-override.test.ts +++ b/test/api-url-override.test.ts @@ -1,8 +1,24 @@ // The runtime `--api-url` flag: highest precedence, never persisted, and — like the env-var // override before it — a URL for another deployment must not carry the stored session with it. import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { pickApiUrl } from '../src/config.js' +const entry = fileURLToPath(new URL('../src/index.ts', import.meta.url)) + +// A child that reads ONLY the temp home: the ambient control-plane env vars are deleted (a value +// of `undefined` in spawnSync's env is passed through as the string "undefined" on some platforms). +function childEnv(home: string): NodeJS.ProcessEnv { + const e = { ...process.env, INSTA_NO_AUTOUPDATE: '1', INSTA_NO_TELEMETRY: '1', HOME: home, USERPROFILE: home } + delete e.INSTA_API_URL + delete e.INSTA_ENV + return e +} + const PROD = 'https://api.instacloud.com' const STAGING = 'https://api.staging.instacloud.com' const stored = { @@ -38,3 +54,48 @@ describe('pickApiUrl', () => { expect(pickApiUrl(null, { INSTA_ENV: 'staging' }, 'http://oss.local:9000')).toEqual({ apiUrl: 'http://oss.local:9000' }) }) }) + +// pickApiUrl decides for the environment it is HANDED. envFromEnvVar defaults its parameter to +// process.env.INSTA_ENV, so an absent property used to fall through to the ambient environment — +// which made this pure function's answer depend on the shell the suite happened to run in. +describe('pickApiUrl purity', () => { + it('treats an absent INSTA_ENV as unset, not as the ambient one', () => { + const before = process.env.INSTA_ENV + process.env.INSTA_ENV = 'staging' + try { + expect(pickApiUrl(stored, {}).apiUrl).toBe(PROD) + expect(pickApiUrl(null, {}).apiUrl).toBe(PROD) + } finally { + if (before === undefined) delete process.env.INSTA_ENV + else process.env.INSTA_ENV = before + } + }) +}) + +// The runtime override must never become this machine's control plane. `persist()` is reached by +// logout and by the 401 refresh, and it used to write back the override-resolved config — so +// `insta logout --api-url ` re-pointed the stored apiUrl AND dropped the real +// login with it (readGlobal scrubs a foreign deployment's session, and that view was what got +// written). A spawn test is the honest one: GLOBAL_FILE is derived from homedir() at import time. +describe('--api-url is never persisted', () => { + it('logout --api-url leaves the stored apiUrl and only clears the session', () => { + const home = mkdtempSync(join(tmpdir(), 'insta-apiurl-')) + try { + mkdirSync(join(home, '.insta'), { recursive: true }) + const file = join(home, '.insta', 'config.json') + writeFileSync(file, JSON.stringify(stored, null, 2)) + const r = spawnSync(process.execPath, ['--import', 'tsx', entry, 'logout', '--api-url', STAGING], { + encoding: 'utf8', + timeout: 30_000, + env: childEnv(home), + }) + expect(r.status, r.stderr).toBe(0) + const after = JSON.parse(readFileSync(file, 'utf8')) + expect(after.apiUrl).toBe(PROD) + expect(after.accessToken).toBeUndefined() + expect(after.refreshToken).toBeUndefined() + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) +}) diff --git a/test/domain.test.ts b/test/domain.test.ts index 6924c3e..e2438c2 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach, afterAll } from 'vitest' -import { domainSearch, domainBuy, domainAttach, domainList, domainStatus, domainRecordsAdd, domainRecordsList, domainRecordsRemove, domainRecordsSet, ownerOf, searchLines } from '../src/commands/domain.js' +import { domainSearch, domainBuy, domainAttach, domainCheck, domainDetach, domainList, domainStatus, domainRecordsAdd, domainRecordsList, domainRecordsRemove, domainRecordsSet, ownerOf, searchLines } from '../src/commands/domain.js' import type { DomainDeps } from '../src/commands/compute.js' const services = [ @@ -137,6 +137,44 @@ describe('domain attach', () => { }) }) +// The compute plane is not where a bought hostname's binding lives: `attach` writes it into the +// platform's DOMAINS record and a reconciler binds it, while `detach`'s only route unbinds on the +// compute plane. The platform exposes no detach for the record, so running it would leave the +// domain still claiming a binding that is gone. +describe('domain detach', () => { + const inventory = { '/orgs/org1/domains': { items: [{ domainName: 'myapp.com', status: 'registered', hostnames: [], expiresAt: null, autorenew: true }] } } + it('refuses a bought hostname before any compute-plane call, and says how to move it', async () => { + const { deps: d, calls } = deps(inventory) + await expect(domainDetach('API.MyApp.com', { group: 'web' }, d)).rejects.toThrow('exit 1') + expect(stderr.join('')).toContain('api.myapp.com belongs to myapp.com, a domain bought through InstaCloud') + expect(stderr.join('')).toContain('insta domain attach api.myapp.com --group ') + expect(stderr.join('')).toContain('insta domain status myapp.com') + // The inventory read and nothing else: no service lookup, no DELETE. + expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual(['GET /orgs/org1/domains']) + }) + it('the apex of a bought domain is refused too', async () => { + const { deps: d } = deps(inventory) + await expect(domainDetach('myapp.com', {}, d)).rejects.toThrow('exit 1') + expect(stderr.join('')).toContain('a domain bought through InstaCloud') + }) + it('a bring-your-own hostname still reaches the compute plane, lowercased', async () => { + const { deps: d, calls } = deps(inventory, { status: 200, body: { hostname: 'api.other.com', service: 'web' } }) + await domainDetach('API.Other.com', { group: 'web' }, d) + expect(calls.at(-1)).toMatchObject({ method: 'DELETE', path: '/projects/p1/compute/domain', body: { hostname: 'api.other.com', branch: 'main', group: 'web' } }) + expect(out()).toContain('removed custom domain api.other.com from web') + }) +}) + +// `attach` lowercases before it sends, so `check` asking about the mixed-case spelling asks about +// a binding the plane never wrote. +describe('domain check', () => { + it('normalizes the hostname before asking the plane', async () => { + const { deps: d, calls } = deps({ '/compute/domain': { hostname: 'docs.myapp.com', status: 'pending', dns: [] } }) + await domainCheck(' Docs.MyApp.com ', { group: 'web', json: true }, d) + expect(calls.at(-1)!.path).toBe('/projects/p1/compute/domain?hostname=docs.myapp.com&group=web&branch=main') + }) +}) + describe('domain list / status', () => { const purchased = { domainName: 'myapp.com', status: 'attaching', expiresAt: '2027-09-10T00:00:00Z', autorenew: true, hostnames: [{ hostname: 'api.myapp.com', state: 'active', service: 'api' }, { hostname: 'www.myapp.com', state: 'failed', service: 'web', reason: 'already attached to another compute service' }] } diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index a4c7743..646bf6b 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -135,6 +135,19 @@ describe('group shapes', () => { expect(commandNames(run(['config', '--help']).stdout)).toEqual(['install-mcp', 'regions', 'autoupdate']) expect(commandNames(run(['billing', '--help']).stdout)).toEqual(['subscribe', 'portal', 'usage']) }, 30_000) + // Rule 4's documented exception (SKILL.md "Command architecture", design §8): the managed-db + // `query` service is REQUIRED and LEADS, because a trailing optional service cannot be told + // apart from the query argv (`insta redis query GET key`) without a `--` separator. Pinned here + // so a later "make it consistent" refactor has to be a deliberate design change. + it('managed-database query takes the service first, and required', () => { + for (const type of ['redis', 'mysql', 'mongodb']) { + const r = run([type, 'query', '--help']) + expect(r.status, type).toBe(0) + expect(r.stdout, type).toContain(`Usage: insta ${type} query [options] [args...]`) + } + const bare = run(['redis', 'query']) + expect(bare.status).not.toBe(0) + }, 30_000) it('offers --delete on compute volume only — a managed database volume is its data directory', () => { expect(run(['compute', 'volume', '--help']).stdout).toContain('--delete') for (const type of ['redis', 'mysql', 'mongodb']) { @@ -154,4 +167,19 @@ describe('--api-url placement', () => { expect(JSON.parse(run(['--api-url', URL_A, 'env', '--json']).stdout).apiUrl).toBe(URL_A) expect(JSON.parse(run(['env', '--json', '--api-url', URL_A], { INSTA_API_URL: URL_B }).stdout).apiUrl).toBe(URL_A) }, 30_000) + // `env` is hidden and hand-rolled, so it proves nothing about the 24 visible groups: a leaf that + // addApiUrlEverywhere() missed would only surface as `unknown option '--api-url'` at runtime. + // One representative leaf per group, plus a level-3 leaf. URL_A is unroutable, so every one of + // these fails on the connection — that is the SUCCESS condition here: the process got past + // commander's option parsing, which is the only thing being asserted. + const LEAVES: string[][] = [ + ['service', 'list'], ['secrets', 'list'], ['domain', 'list'], ['compute', 'status'], + ['postgres', 'url'], ['redis', 'status'], ['mysql', 'status'], ['mongodb', 'status'], + ['storage', 'list'], ['template', 'list'], ['billing', 'usage'], ['agent', 'manifest'], + ['config', 'regions'], ['domain', 'records', 'list', 'example.com'], + ] + it.each(LEAVES)('`%s %s` accepts --api-url after the subcommand', (...path) => { + const r = run([...path, '--api-url', URL_A]) + expect(`${r.stderr}${r.stdout}`, path.join(' ')).not.toContain('unknown option') + }, 30_000) }) diff --git a/test/retired-policy.test.ts b/test/retired-policy.test.ts index 794c822..b844b13 100644 --- a/test/retired-policy.test.ts +++ b/test/retired-policy.test.ts @@ -3,7 +3,10 @@ import { fileURLToPath } from 'node:url' import { expect, it } from 'vitest' const entry = fileURLToPath(new URL('../src/index.ts', import.meta.url)) -const run = (...args: string[]) => spawnSync(process.execPath, ['--import', 'tsx', entry, ...args], { encoding: 'utf8', timeout: 10000 }) +// 30s per spawn, matching test/help-surface.test.ts: a cold `tsx` start on a Windows runner can +// pass 10s, and a spawn killed by the timeout returns status null with empty stderr — which fails +// the assertions below for a reason that has nothing to do with what they check. +const run = (...args: string[]) => spawnSync(process.execPath, ['--import', 'tsx', entry, ...args], { encoding: 'utf8', timeout: 30_000 }) it('exposes policy only under agent, and rejects the retired top-level names', () => { const help = run('--help') @@ -17,7 +20,8 @@ it('exposes policy only under agent, and rejects the retired top-level names', ( expect(r.stderr).toContain(`unknown command '${retired[0]}'`) } expect(run('agent', 'policy', 'get', '--help').status).toBe(0) -}, 30_000) + // 4 spawns x a 30s per-spawn cap: the budget only has to outlast the failure path. +}, 120_000) it('rejects approval --always instead of promising a permanent grant', () => { const help = run('agent', 'approvals', 'approve', '--help') @@ -26,4 +30,4 @@ it('rejects approval --always instead of promising a permanent grant', () => { const retired = run('agent', 'approvals', 'approve', 'test-id', '--always') expect(retired.status).not.toBe(0) expect(retired.stderr).toContain("unknown option '--always'") -}, 30_000) +}, 120_000) diff --git a/test/services.test.ts b/test/services.test.ts index 434fbfe..f4f9bcc 100644 --- a/test/services.test.ts +++ b/test/services.test.ts @@ -153,8 +153,15 @@ describe('servicesAdd validation (throws before any network/config access)', () it('rejects --port for a non-compute type', async () => { await expect(servicesAdd('postgres', 'db', { port: '3000' })).rejects.toThrow(/--port is only valid for compute services/) }) - it('rejects --always-on for a non-compute type, pointing at the db command instead', async () => { + it('rejects --always-on for a non-compute type, pointing at that type\'s own command', async () => { await expect(servicesAdd('postgres', 'db', { alwaysOn: true })).rejects.toThrow(/--always-on \/ --no-always-on is only valid for compute services/) + // Each managed database has its own always-on verb; naming postgres' would send a redis user + // at a different service type. + for (const type of ['postgres', 'redis', 'mysql', 'mongodb']) { + await expect(servicesAdd(type, 'db', { alwaysOn: true })).rejects.toThrow(new RegExp(`insta ${type} always-on on\\|off`)) + } + // Storage owns no always-on verb: the bare rule, with no command to run. + await expect(servicesAdd('storage', 'bkt', { alwaysOn: true })).rejects.toThrow(/only valid for compute services$/) }) it('rejects --no-always-on for a non-compute type too: an explicit false is just as compute-only', async () => { // Presence check, not truthiness: a truthiness check would let `postgres db --no-always-on` diff --git a/test/upgrade.test.ts b/test/upgrade.test.ts index 69c5315..8fc7757 100644 --- a/test/upgrade.test.ts +++ b/test/upgrade.test.ts @@ -56,6 +56,12 @@ test('the update machinery itself is exempt from the update check', () => { expect(skipsUpdateCheck('upgrade')).toBe(true) expect(skipsUpdateCheck('autoupdate')).toBe(true) expect(skipsUpdateCheck('__update-check')).toBe(true) + // The preference lives at `insta config autoupdate` since the command re-organization; matching + // on argv[2] alone let the command that turns auto-update OFF trigger an auto-update first. + expect(skipsUpdateCheck('config', 'autoupdate')).toBe(true) + // Narrow: the rest of the `config` group is an ordinary command. + expect(skipsUpdateCheck('config', 'regions')).toBe(false) + expect(skipsUpdateCheck('config')).toBe(false) }) test('the ssh renewal hook is exempt, by the name the config block invokes', () => { diff --git a/test/volume.test.ts b/test/volume.test.ts index 50bd61f..4cd8afa 100644 --- a/test/volume.test.ts +++ b/test/volume.test.ts @@ -44,9 +44,16 @@ describe('servicesAddRequestBody --volume', () => { }) describe('servicesAdd --volume validation (throws before any network/config access)', () => { - it('rejects --volume for a non-compute type, pointing at the db command instead', async () => { + // The hint must name the type the user TYPED: `insta postgres volume` resolves a postgres + // service, so pointing a redis/mysql/mongodb user at it sends them at the wrong service type. + it('rejects --volume for a non-compute type, pointing at that type\'s own command', async () => { await expect(servicesAdd('postgres', 'db', { volume: '10' })).rejects.toThrow(/--volume is only valid for compute services/) - await expect(servicesAdd('storage', 'bkt', { volume: '10' })).rejects.toThrow(/insta postgres volume --size/) + await expect(servicesAdd('postgres', 'db', { volume: '10' })).rejects.toThrow(/insta postgres volume --size /) + for (const type of ['redis', 'mysql', 'mongodb']) { + await expect(servicesAdd(type, 'db', { volume: '10' })).rejects.toThrow(new RegExp(`insta ${type} volume --size `)) + } + // Storage owns no volume verb, so it gets the bare rule and no command to run. + await expect(servicesAdd('storage', 'bkt', { volume: '10' })).rejects.toThrow(/--volume is only valid for compute services$/) }) it('rejects junk sizes locally instead of deferring to the server', async () => { await expect(servicesAdd('compute', 'api', { volume: '1.5' })).rejects.toThrow(/invalid volume size/) @@ -79,6 +86,17 @@ describe('volumeLines (compute read display)', () => { expect(lines[0]).toMatch(/insta compute volume api --size /) expect(lines[0]).toMatch(/next deploy/) }) + // `--delete` is registered on `compute volume` only (index.ts), so naming it on a managed + // database's read prints a command that does not exist. + it('never offers --delete on a managed database, and says what to do instead', () => { + for (const type of ['redis', 'mysql', 'mongodb'] as const) { + const lines = volumeLines('cache', { sizeGib: 10, mountPath: '/data' }, { volumeGib: 50 }, type) + expect(lines[0], type).toBe(`${type} cache: volume 10Gi at /data (plan max 50Gi)`) + expect(lines[1], type).not.toMatch(/--delete/) + expect(lines[1], type).toMatch(/grow with --size \(grow-only\)/) + expect(lines[1], type).toMatch(/remove the service instead/) + } + }) }) describe('volumeWriteLine (compute PUT result display)', () => { @@ -104,6 +122,11 @@ describe('volumeDeleteLine (compute DELETE result display)', () => { const line = volumeDeleteLine('api') expect(line).toBe('compute api: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back') }) + // Unreachable today (no `--delete` outside compute), but the regained constraints are + // compute-plane facts: the line must not claim them for a managed database if it ever is. + it('claims no compute-plane effects for a managed database', () => { + expect(volumeDeleteLine('cache', 'redis')).toBe('redis cache: volume deleted — the disk and its data are gone') + }) }) describe('volumeDeleteError (older-backend 404 mapping)', () => { From 0a7417f30cb9b67c62f4c787ae58cdae3eb9dbbb Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 18 Sep 2026 10:44:12 -0700 Subject: [PATCH 17/19] logout acts on the stored session, not on a runtime --api-url Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/auth.ts | 13 +++++++- test/api-url-override.test.ts | 63 ++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index beaf882..9ceda0c 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,6 +1,7 @@ import { createServer } from 'node:http' import { randomBytes } from 'node:crypto' import { ApiClient, ApiError, linkedProject } from '../api.js' +import { readPersistedGlobal } from '../config.js' import { agentMode } from '../agent.js' import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js' import { info, die, printJson, promptPassword, openUrl } from '../util.js' @@ -322,8 +323,18 @@ function browserOauth(apiUrl: string, provider: string): Promise { }) } +/** Log out: revoke the session on the server, then clear the local tokens. + * + * Built from the PERSISTED config, not from `ApiClient.load()`'s override-resolved view. There is + * exactly one stored session, so a runtime `--api-url` (or INSTA_API_URL / INSTA_ENV) has no + * subject here — and pointing at a foreign deployment made this actively unsafe: readGlobal() + * scrubs a foreign deployment's tokens, so the revoke below was skipped for want of a refresh + * token while the local tokens were deleted anyway, leaving the session valid on the server with + * nothing left on this machine to revoke it with. The revoke now always goes to the deployment + * the session belongs to, with the real refresh token. `persist()` keeps the stored URL (see its + * comment): logout never sets one explicitly. */ export async function logout(): Promise { - const api = await ApiClient.load() + const api = new ApiClient(await readPersistedGlobal()) if (api.config.refreshToken) { try { await api.request('POST', '/auth/logout', { refreshToken: api.config.refreshToken }, { auth: false }) } catch { /* ignore */ } } diff --git a/test/api-url-override.test.ts b/test/api-url-override.test.ts index 683afcb..941af3d 100644 --- a/test/api-url-override.test.ts +++ b/test/api-url-override.test.ts @@ -1,7 +1,9 @@ // The runtime `--api-url` flag: highest precedence, never persisted, and — like the env-var // override before it — a URL for another deployment must not carry the stored session with it. import { describe, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,6 +12,19 @@ import { pickApiUrl } from '../src/config.js' const entry = fileURLToPath(new URL('../src/index.ts', import.meta.url)) +// The async twin of the spawnSync helper above, for the one test that must keep serving HTTP +// while the child runs. +function runAsync(args: string[], home: string): Promise<{ status: number | null; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, ['--import', 'tsx', entry, ...args], { env: childEnv(home) }) + let stderr = '' + child.stderr.on('data', (c) => { stderr += c }) + child.stdout.resume() + const timer = setTimeout(() => child.kill('SIGKILL'), 25_000) + child.on('close', (status) => { clearTimeout(timer); resolve({ status, stderr }) }) + }) +} + // A child that reads ONLY the temp home: the ambient control-plane env vars are deleted (a value // of `undefined` in spawnSync's env is passed through as the string "undefined" on some platforms). function childEnv(home: string): NodeJS.ProcessEnv { @@ -99,3 +114,49 @@ describe('--api-url is never persisted', () => { } }, 30_000) }) + +// `logout` has no subject for a runtime override: there is exactly one stored session, and the +// CLI cannot log out of one deployment while keeping another. Under a FOREIGN override the old +// behaviour was worse than surprising — readGlobal() scrubs the foreign deployment's tokens, so +// the `POST /auth/logout` revoke was skipped for want of a refresh token while the local tokens +// were deleted anyway: the session stayed valid on the server with nothing left to revoke it. +// The request path is what matters, so this test stands up a real listener as the PERSISTED host. +describe('logout ignores the runtime override', () => { + it('revokes against the stored deployment with the stored refresh token', async () => { + const seen: Array<{ method: string; url: string; body: string }> = [] + const server = createServer((req, res) => { + let body = '' + req.on('data', (c) => { body += c }) + req.on('end', () => { + seen.push({ method: req.method!, url: req.url!, body }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{}') + }) + }) + await new Promise((ok) => server.listen(0, '127.0.0.1', ok)) + const port = (server.address() as AddressInfo).port + const home = mkdtempSync(join(tmpdir(), 'insta-logout-')) + const persisted = `http://127.0.0.1:${port}` + try { + mkdirSync(join(home, '.insta'), { recursive: true }) + const file = join(home, '.insta', 'config.json') + writeFileSync(file, JSON.stringify({ ...stored, apiUrl: persisted }, null, 2)) + // An unroutable override: if logout honoured it, nothing would reach the listener at all. + // spawn, not spawnSync: the listener lives in THIS process, and a synchronous spawn blocks + // the event loop, so the child's request would never be answered. + const r = await runAsync(['logout', '--api-url', 'http://127.0.0.1:1'], home) + expect(r.status, r.stderr).toBe(0) + expect(seen).toHaveLength(1) + expect(seen[0]!.method).toBe('POST') + expect(seen[0]!.url).toBe('/auth/logout') + expect(JSON.parse(seen[0]!.body)).toEqual({ refreshToken: 'rt' }) + const after = JSON.parse(readFileSync(file, 'utf8')) + expect(after.apiUrl).toBe(persisted) + expect(after.accessToken).toBeUndefined() + expect(after.refreshToken).toBeUndefined() + } finally { + rmSync(home, { recursive: true, force: true }) + await new Promise((ok) => server.close(() => ok())) + } + }, 30_000) +}) From 27a3f0f54af48ac85c108d83df6a7ba6e8a1dfdd Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 18 Sep 2026 10:44:12 -0700 Subject: [PATCH 18/19] deploy: the build-log hint names `insta build logs` Co-Authored-By: Claude Opus 5 (1M context) --- src/build-logs.ts | 2 +- src/deploy-archive.ts | 2 +- test/deploy-archive.test.ts | 4 ++-- test/deploy-lane.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/build-logs.ts b/src/build-logs.ts index e69c09e..0cfa21b 100644 --- a/src/build-logs.ts +++ b/src/build-logs.ts @@ -155,7 +155,7 @@ export function archiveLogWatcher(api: Api, projectId: string, write: (message: } catch (error) { if (error instanceof ApiError && error.status === 400) follow.tails.clear() printer.finishLine(write) - const message = `Could not read build logs. Retry with: insta build-logs ${buildId}\n` + const message = `Could not read build logs. Retry with: insta build logs ${buildId}\n` if (warned !== message) write(message) warned = message } diff --git a/src/deploy-archive.ts b/src/deploy-archive.ts index 4c867c5..ee0e61e 100644 --- a/src/deploy-archive.ts +++ b/src/deploy-archive.ts @@ -124,7 +124,7 @@ export async function deployArchive( if (handleApproval(started, opts.json)) return null const operationId = started.body?.operationId if (typeof operationId !== 'string' || !operationId) throw new Error('the platform accepted the deploy but returned no operation id — re-run the deploy') - log(`build logs: insta build-logs ${operationId}`) + log(`build logs: insta build logs ${operationId}`) if (started.body?.resumed === true) log('resuming the deploy this archive already started') const deadline = now() + DEPLOY_DEADLINE_MS diff --git a/test/deploy-archive.test.ts b/test/deploy-archive.test.ts index f2bf9b8..aebf0ad 100644 --- a/test/deploy-archive.test.ts +++ b/test/deploy-archive.test.ts @@ -130,7 +130,7 @@ describe('deployArchive — the gated call and the poll after it', () => { const notes: string[] = [] const out = await deployArchive(a, 'p1', ref, 'main', { group: 'api', port: '3000', websocket: true, replaceSource: true }, Date.now, noWait, message => { notes.push(message) }) - expect(notes).toContain('build logs: insta build-logs op_1') + expect(notes).toContain('build logs: insta build logs op_1') expect(out).toEqual({ image: 'ecr.example/app@sha256:aa', url: 'https://app.example', branch: 'main', group: 'api', machineId: 'm1' }) expect(calls[0]!.body).toEqual({ branch: 'main', group: 'api', archive: ref, port: 3000, websocket: true, replaceSource: true }) @@ -177,7 +177,7 @@ describe('deployArchive — the gated call and the poll after it', () => { expect(out).toMatchObject({ url: 'https://app.example' }) expect(n).toBe(states.length + 1) // Progress is narrated once per state change, not once per poll. - expect(seen).toEqual(['build logs: insta build-logs op_1', 'queued…', 'building…', 'image built, deploying it']) + expect(seen).toEqual(['build logs: insta build logs op_1', 'queued…', 'building…', 'image built, deploying it']) }) it('returns the operation’s own failure sentence rather than throwing', async () => { diff --git a/test/deploy-lane.test.ts b/test/deploy-lane.test.ts index 2aab7e6..bfd830b 100644 --- a/test/deploy-lane.test.ts +++ b/test/deploy-lane.test.ts @@ -148,7 +148,7 @@ describe('prepareSource — lane dispatch', () => { const out = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) try { await prepareSource(api, 'p1', srcDir(false), 'main', { json: true }, noRun) - expect(err.mock.calls.map((c) => String(c[0])).join('')).toContain('build logs: insta build-logs op_1') + expect(err.mock.calls.map((c) => String(c[0])).join('')).toContain('build logs: insta build logs op_1') expect(out.mock.calls.map((c) => String(c[0])).join('')).toBe('') } finally { out.mockRestore() From 3383cf457cc9ddfad2e34a70435777f287b833df Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 18 Sep 2026 10:54:59 -0700 Subject: [PATCH 19/19] review: persist guard test, detach fallback, volume/logout wording Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/auth.ts | 13 ++++++-- src/commands/domain.ts | 19 +++++++++-- src/commands/services.ts | 8 ++++- src/index.ts | 2 +- test/api-url-override.test.ts | 4 +++ test/domain.test.ts | 22 +++++++++++++ test/help-surface.test.ts | 5 ++- test/persist-guard.test.ts | 61 +++++++++++++++++++++++++++++++++++ 8 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 test/persist-guard.test.ts diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 9ceda0c..5caeb8d 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,9 +1,9 @@ import { createServer } from 'node:http' import { randomBytes } from 'node:crypto' import { ApiClient, ApiError, linkedProject } from '../api.js' -import { readPersistedGlobal } from '../config.js' +import { readGlobal, readPersistedGlobal } from '../config.js' import { agentMode } from '../agent.js' -import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js' +import { ENVS, ENV_NAMES, envForApiUrl, isEnvName, normalizeUrl } from '../env.js' import { info, die, printJson, promptPassword, openUrl } from '../util.js' /** --api-url and --env both set the target host; --api-url wins (more specific), matching the @@ -334,7 +334,14 @@ function browserOauth(apiUrl: string, provider: string): Promise { * the session belongs to, with the real refresh token. `persist()` keeps the stored URL (see its * comment): logout never sets one explicitly. */ export async function logout(): Promise { - const api = new ApiClient(await readPersistedGlobal()) + const stored = await readPersistedGlobal() + // Say so rather than ignoring it silently: exiting 0 with a bare "logged out" while the flag + // named a different deployment reads as if that deployment was the one logged out of. + const resolved = (await readGlobal()).apiUrl + if (normalizeUrl(resolved) !== normalizeUrl(stored.apiUrl)) { + info(`note: the control-plane override (${resolved}) does not apply to logout — there is one stored session; logging out of ${stored.apiUrl}`) + } + const api = new ApiClient(stored) if (api.config.refreshToken) { try { await api.request('POST', '/auth/logout', { refreshToken: api.config.refreshToken }, { auth: false }) } catch { /* ignore */ } } diff --git a/src/commands/domain.ts b/src/commands/domain.ts index 60dd875..494fbe4 100644 --- a/src/commands/domain.ts +++ b/src/commands/domain.ts @@ -208,6 +208,22 @@ export async function domainCheck(host: string, opts: HostOpts, deps?: DomainDep return checkDomain(host.trim().toLowerCase(), opts, deps) } +/** The bought domain `host` sits under, or null — including when the question cannot be answered. + * + * This lookup is a GUARD on a command that worked without it: a control plane without the + * org-scoped domains route (an older deployment, insta-oss), a 403, or a transient 5xx must not + * turn a bring-your-own detach into a hard failure. A failed read means "nothing says this is a + * bought name", which is exactly how the command behaved before the guard existed. A read that + * SUCCEEDS is authoritative, and its refusal stands. */ +async function boughtOwner(d: DomainDeps, orgId: string, host: string): Promise { + try { + const { items } = await d.api.request<{ items: Purchased[] }>('GET', `/orgs/${orgId}/domains`) + return ownerOf(host, items) + } catch { + return null + } +} + /** * Release a hostname from its compute service. Bring-your-own only: a hostname under a domain * bought through InstaCloud is refused here. @@ -226,8 +242,7 @@ export async function domainDetach(host: string, opts: HostOpts, deps?: DomainDe // Without an org there is no domains list to check against (INSTA_PROJECT_ID-only CI links name // none); a bring-your-own detach must keep working there, so the guard is simply not applied. if (d.project.orgId) { - const { items } = await d.api.request<{ items: Purchased[] }>('GET', `/orgs/${d.project.orgId}/domains`) - const owner = ownerOf(name, items) + const owner = await boughtOwner(d, d.project.orgId, name) if (owner) { die(`${name} belongs to ${owner.domainName}, a domain bought through InstaCloud — its binding lives on the domain, not on the compute plane, so detaching it here would leave the domain still claiming it. ` + `Move it with \`insta domain attach ${name} --group \` (attach releases it from the current one), and see where it stands with \`insta domain status ${owner.domainName}\`.`) diff --git a/src/commands/services.ts b/src/commands/services.ts index 227cb8f..11bb4e1 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -35,7 +35,13 @@ export function alwaysOnTypeError(type: string): string { export function volumeTypeError(type: string): string { const base = '--volume is only valid for compute services' - return hasOwnGroup(type) ? `${base} (${type} has one by default — grow it with \`insta ${type} volume --size \`)` : base + // insta-db-backed postgres is provisioned WITH its disk (dbVolume goes through + // PATCH /database/settings), so there is nothing to attach — only to grow. A managed Fly + // database can be volumeless, and `--size` attaches there (ServicesService.setVolumeSize's + // attach branch runs for isFlyRuntimeType), which is what `volumeLines` already prints. + if (type === 'postgres') return `${base} (postgres has one by default — grow it with \`insta postgres volume --size \`)` + if (hasOwnGroup(type)) return `${base} (attach or grow one after creation with \`insta ${type} volume --size \`)` + return base } const MAX_COMPUTE_REPLICAS = 10 diff --git a/src/index.ts b/src/index.ts index 29830a4..4611860 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,7 +91,7 @@ program.command('login').description('Log in — bare: sign in from your browser .option('--api-url ', 'control-plane API base URL') .option('--env ', `deployment environment: ${ENV_NAMES.join(' | ')}`) .action(guard((o) => auth.login(o))) -program.command('logout').description('Log out and clear local tokens').action(guard(() => auth.logout())) +program.command('logout').description('Log out and clear local tokens — always the stored session, so --api-url (and INSTA_API_URL / INSTA_ENV) do not apply here').action(guard(() => auth.logout())) program.command('status').description('Show login + linked project').option('--json').action(guard((o) => auth.status(o))) // ---- environment (prod | staging) — hidden: `--api-url` covers the debugging case; kept working ---- diff --git a/test/api-url-override.test.ts b/test/api-url-override.test.ts index 941af3d..4e4b3bb 100644 --- a/test/api-url-override.test.ts +++ b/test/api-url-override.test.ts @@ -109,6 +109,10 @@ describe('--api-url is never persisted', () => { expect(after.apiUrl).toBe(PROD) expect(after.accessToken).toBeUndefined() expect(after.refreshToken).toBeUndefined() + // Ignoring the flag silently would read as "logged out of staging". Say which deployment + // the session actually belonged to, and still exit 0. + expect(r.stdout).toContain(`does not apply to logout`) + expect(r.stdout).toContain(PROD) } finally { rmSync(home, { recursive: true, force: true }) } diff --git a/test/domain.test.ts b/test/domain.test.ts index e2438c2..bd82bcf 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -157,6 +157,28 @@ describe('domain detach', () => { await expect(domainDetach('myapp.com', {}, d)).rejects.toThrow('exit 1') expect(stderr.join('')).toContain('a domain bought through InstaCloud') }) + // The lookup is a guard on a command that worked without it. A control plane with no org-scoped + // domains route (an older deployment, insta-oss), a 403 or a transient 5xx must not turn a + // bring-your-own detach into a hard failure. + it('falls through to the compute plane when the ownership lookup fails', async () => { + const calls: Array<{ method: string; path: string; body?: unknown }> = [] + const api = { + request: async (method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }) + if (path.includes('/orgs/')) throw new Error('HTTP 404') + if (path.includes('/services')) return { services } + throw new Error(`unexpected ${method} ${path}`) + }, + rawRequest: async (method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }) + return { status: 200, body: { hostname: 'api.other.com', service: 'web' } } + }, + } + const d = { api, project: { projectId: 'p1', orgId: 'org1', branch: 'main' } } as unknown as DomainDeps + await domainDetach('api.other.com', { group: 'web' }, d) + expect(calls[0]!.path).toBe('/orgs/org1/domains') + expect(calls.at(-1)).toMatchObject({ method: 'DELETE', path: '/projects/p1/compute/domain', body: { hostname: 'api.other.com', branch: 'main', group: 'web' } }) + }) it('a bring-your-own hostname still reaches the compute plane, lowercased', async () => { const { deps: d, calls } = deps(inventory, { status: 200, body: { hostname: 'api.other.com', service: 'web' } }) await domainDetach('API.Other.com', { group: 'web' }, d) diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index 646bf6b..fe46fbb 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -173,12 +173,15 @@ describe('--api-url placement', () => { // these fails on the connection — that is the SUCCESS condition here: the process got past // commander's option parsing, which is the only thing being asserted. const LEAVES: string[][] = [ + ['status'], ['org', 'list'], ['project', 'list'], ['branch', 'list'], ['service', 'list'], ['secrets', 'list'], ['domain', 'list'], ['compute', 'status'], ['postgres', 'url'], ['redis', 'status'], ['mysql', 'status'], ['mongodb', 'status'], ['storage', 'list'], ['template', 'list'], ['billing', 'usage'], ['agent', 'manifest'], ['config', 'regions'], ['domain', 'records', 'list', 'example.com'], + // The level-1 verbs: they carry no group, so nothing else here would catch a miss on them. + ['build'], ['deploy'], ['run', 'true'], ['feedback'], ] - it.each(LEAVES)('`%s %s` accepts --api-url after the subcommand', (...path) => { + it.each(LEAVES)('`%s %s` accepts --api-url after the command', (...path) => { const r = run([...path, '--api-url', URL_A]) expect(`${r.stderr}${r.stdout}`, path.join(' ')).not.toContain('unknown option') }, 30_000) diff --git a/test/persist-guard.test.ts b/test/persist-guard.test.ts new file mode 100644 index 0000000..a26bc7a --- /dev/null +++ b/test/persist-guard.test.ts @@ -0,0 +1,61 @@ +// `ApiClient.persist()`'s override guard, exercised directly. +// +// The guard's live path is the 401 `refresh()` — `logout()` now builds its client from +// readPersistedGlobal(), so the logout spawn test in api-url-override.test.ts no longer proves +// anything about persist() itself. This does: a client whose in-memory apiUrl differs from the +// file's (exactly what readGlobal() hands back under --api-url / INSTA_API_URL / INSTA_ENV) must +// leave the file's URL alone, while an explicit setApiUrl — a login choosing its deployment — must +// move it. +// +// The global config file is derived from homedir() when config.ts is EVALUATED, so HOME is pointed +// at a temp directory before the dynamic import below. Static imports would be hoisted above that. +import { afterAll, beforeEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const home = mkdtempSync(join(tmpdir(), 'insta-persist-')) +process.env.HOME = home +process.env.USERPROFILE = home +delete process.env.INSTA_API_URL +delete process.env.INSTA_ENV +mkdirSync(join(home, '.insta'), { recursive: true }) +const file = join(home, '.insta', 'config.json') + +const { ApiClient } = await import('../src/api.js') + +const PROD = 'https://api.instacloud.com' +const STAGING = 'https://api.staging.instacloud.com' +const OSS = 'http://oss.local:9000' + +const onDisk = () => JSON.parse(readFileSync(file, 'utf8')) + +beforeEach(() => { + writeFileSync(file, JSON.stringify({ apiUrl: PROD, accessToken: 'at', refreshToken: 'rt', autoUpdate: false }, null, 2)) +}) +afterAll(() => rmSync(home, { recursive: true, force: true })) + +describe('ApiClient.persist', () => { + it('keeps the stored apiUrl when the in-memory one came from an ambient override', async () => { + // What readGlobal() returns under `--api-url https://api.staging…` on a prod-logged-in machine. + const api = new ApiClient({ apiUrl: STAGING, accessToken: 'staging-at' }) + await api.persist() + expect(onDisk().apiUrl).toBe(PROD) + // Everything else the client holds is still written — only the URL is pinned to the file's. + expect(onDisk().accessToken).toBe('staging-at') + }) + + it('moves the stored apiUrl when it was set explicitly — a login choosing its deployment', async () => { + const api = new ApiClient({ apiUrl: STAGING, accessToken: 'staging-at' }) + api.setApiUrl(OSS) + await api.persist() + expect(onDisk().apiUrl).toBe(OSS) + expect(onDisk().accessToken).toBe('staging-at') + }) + + it('leaves an unoverridden config exactly as it is', async () => { + const api = new ApiClient({ apiUrl: PROD, accessToken: 'at', refreshToken: 'rt2' }) + await api.persist() + expect(onDisk()).toMatchObject({ apiUrl: PROD, refreshToken: 'rt2' }) + }) +})