insta 0.0.78 → 0.0.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -67,11 +67,9 @@ export class ApiClient {
67
67
  }
68
68
  // Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
69
69
  async request(method, path, body, opts = {}) {
70
- const res = await this.raw(method, path, body, opts.auth ?? true, opts);
70
+ const res = await this.rawRequest(method, path, body, opts);
71
71
  if (agentMode() && res.status === 202 && res.body?.status === 'approval_required')
72
72
  throw new AgentApprovalRequired(res.body);
73
- if (res.status >= 400)
74
- throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body);
75
73
  return res.body;
76
74
  }
77
75
  // Like request but returns {status, body} so callers can branch on 202 (approval_required).
@@ -93,12 +91,13 @@ export class ApiClient {
93
91
  const headers = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT };
94
92
  if (auth && this.cfg.accessToken)
95
93
  headers.Authorization = `Bearer ${this.cfg.accessToken}`;
94
+ const payload = body === undefined ? undefined : JSON.stringify(body);
96
95
  if (auth && scope.evidence !== false)
97
- Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body), scope));
96
+ Object.assign(headers, await agentHeaders(this, method, path, payload ?? '', scope));
98
97
  const res = await this.fetchImpl(this.apiUrl + path, {
99
98
  method,
100
99
  headers,
101
- body: body === undefined ? undefined : JSON.stringify(body),
100
+ body: payload,
102
101
  signal: scope.signal,
103
102
  });
104
103
  const text = await res.text();
@@ -193,7 +193,7 @@ export async function deviceGrant(post, wait = sleepSeconds, open) {
193
193
  continue;
194
194
  } // RFC 8628 §3.5: back off by 5s
195
195
  // The platform's per-IP limiter answers a bare HTTP 429 (no OAuth error code) when a poll
196
- // trips it — seen on prod 2026-09-10 after ~8 min of steady 5s polling. That is the same
196
+ // trips it. That is the same
197
197
  // instruction as slow_down: the code is still pending, so back off and keep waiting rather
198
198
  // than abort a login the human may be one click away from approving.
199
199
  if (e.status === 429) {
@@ -85,10 +85,8 @@ export async function billing(opts) {
85
85
  }
86
86
  // insta billing upgrade <tier> — start a Stripe Checkout to subscribe the org to a paid tier.
87
87
  export async function billingUpgrade(tier, opts) {
88
- // pro|team, matching what POST /orgs/:orgId/billing/checkout actually accepts. This said
89
- // pro|enterprise, which was wrong both ways: `team` is a real self-serve tier and was refused
90
- // here, and `enterprise` is per-deal and 400s at the server. The suspension hint above now names
91
- // the org's own tier, so a Team org was being sent to a command that rejected it.
88
+ // pro|team, matching what POST /orgs/:orgId/billing/checkout accepts: `team` is a real
89
+ // self-serve tier, and `enterprise` is per-deal and 400s at the server.
92
90
  if (tier !== 'pro' && tier !== 'team')
93
91
  die('tier must be pro|team');
94
92
  const api = await ApiClient.load();
@@ -64,8 +64,7 @@ export function domainGuidanceLines(r, ctx = {}) {
64
64
  // Whether this provider reports an edge routing target AT ALL. The compute plane's domain view
65
65
  // always carries an `ssl` status, so a plane answer without `origin` is a daemon too old to report
66
66
  // where the hostname resolves — we cannot confirm routing, and must not call it serving. A Fly
67
- // answer carries no `ssl` and has no per-hostname origin concept, so its own verdict stands
68
- // (r2d2 round 1 Critical: "no target reported" was previously treated as ready for both).
67
+ // answer carries no `ssl` and has no per-hostname origin concept, so its own verdict stands.
69
68
  const reportsOrigin = (r) => r.ssl !== undefined;
70
69
  // Where the hostname actually resolves — the region-specific origin the plane requested vs what
71
70
  // Cloudflare holds. Each shape carries its action; absent fields are reported as absent.
@@ -116,9 +115,8 @@ export function domainStatusLines(r, ctx = {}) {
116
115
  // A record is settled only when the platform says `ok` — or, for a provider that reports no
117
116
  // per-record status at all (Fly), when it vouched for the whole set with `configured`. missing,
118
117
  // mismatch and never-checked are each outstanding and each add a blocker. Applying this to only
119
- // the ownership TXT and the FIRST routing record was the bug (r2d2 round 3): every other record
120
- // rendered from `configured` alone and blocked nothing, so an apex whose AAAA was missing, or a
121
- // still-pending validation record, could ride under a `serving https://…` line.
118
+ // some records lets an apex whose AAAA is missing, or a still-pending validation record, ride
119
+ // under a `serving https://…` line.
122
120
  const verdictOf = (d) => d.status ?? (r.configured ? 'ok' : 'unchecked');
123
121
  if (txt) {
124
122
  const st = verdictOf(txt);
@@ -139,7 +137,7 @@ export function domainStatusLines(r, ctx = {}) {
139
137
  }
140
138
  else if (reportsOrigin(r)) {
141
139
  // The stage is drawn even with no record to draw it from: an omitted stage reads as "not
142
- // required", when in fact the platform told us nothing to publish (cubic P2). Only the plane
140
+ // required", when in fact the platform told us nothing to publish. Only the plane
143
141
  // proves ownership by TXT, so only a plane answer missing one is a problem.
144
142
  stage('ownership', 'unknown', 'the platform returned no ownership TXT for this domain — nothing to publish yet; ask an operator');
145
143
  blockers.push('no ownership TXT from the platform');
@@ -148,10 +146,10 @@ export function domainStatusLines(r, ctx = {}) {
148
146
  stage('ownership', 'n/a', '(this provider does not use an ownership TXT)');
149
147
  }
150
148
  if (routing.length === 0) {
151
- // No routing record for the hostname — whatever ELSE came back. Keying this on an entirely
152
- // empty record set was the bug (r2d2 round 2): a payload carrying only the ownership TXT
153
- // skipped the stage and added no blocker, so a `configured: true` answer with a live cert and
154
- // a confirmed origin printed `serving` for a hostname with nothing pointing at us.
149
+ // No routing record for the hostname — whatever ELSE came back. Keyed on an entirely empty
150
+ // record set, a payload carrying only the ownership TXT skips the stage and adds no blocker,
151
+ // so a `configured: true` answer with a live cert and a confirmed origin prints `serving`
152
+ // for a hostname with nothing pointing at us.
155
153
  stage('cname', 'unknown', 'the platform returned no routing record for this domain — nothing to publish yet; ask an operator');
156
154
  blockers.push('no routing record from the platform');
157
155
  }
@@ -213,7 +211,7 @@ export function domainStatusLines(r, ctx = {}) {
213
211
  }
214
212
  const resolve = domainResolveLine(r);
215
213
  out.push(resolve.line);
216
- // An error STATE is a blocker whether or not the plane sent a reason with it (cubic P2): a row
214
+ // An error STATE is a blocker whether or not the plane sent a reason with it: a row
217
215
  // that says `error` has not been observed serving, and saying otherwise is the blackhole lie.
218
216
  if (r.status === 'error') {
219
217
  stage('error', r.status, r.errorReason || '(the plane reported an error state with no reason)');
@@ -230,7 +228,7 @@ export function domainStatusLines(r, ctx = {}) {
230
228
  // `serving` is claimed only when every stage above agreed: the provider says configured, the
231
229
  // routing target is confirmed, and NOTHING is outstanding. A blocker beside a `configured: true`
232
230
  // answer means the record set and the verdict disagree — report the disagreement, never paper
233
- // over it with a URL the user would then trust (cubic P1).
231
+ // over it with a URL the user would then trust.
234
232
  if (r.configured && blockers.length === 0)
235
233
  stage('serving', `https://${r.hostname}`, '');
236
234
  else
@@ -249,7 +247,7 @@ export function domainConflictMessage(host, e, services, ctx = {}) {
249
247
  const owner = m?.[1] && m[1] !== 'another' ? m[1] : undefined;
250
248
  const region = m?.[2];
251
249
  // The release command must name the OWNER's group, and the branch the user is working on — a
252
- // command that defaults back to the linked branch would release nothing (cubic P2).
250
+ // command that defaults back to the linked branch would release nothing.
253
251
  const release = (group) => `insta compute remove-domain ${host}${flags({ group, branch: ctx.branch })}`;
254
252
  if (owner) {
255
253
  const here = services.find((s) => s.type === 'compute' && s.name === owner);
@@ -379,14 +377,6 @@ export async function computeStatus(serviceName, opts) {
379
377
  info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`);
380
378
  }
381
379
  // ---- exec (one-shot command; no interactive shell/PTY) ----
382
- // `insta compute exec [service] -- <command> [args…]`: the command must reach the platform
383
- // byte-for-byte and can itself contain dashes or another `--`, so it can't be a normal commander
384
- // positional — with `service` optional, commander flattens everything past the literal `--` into
385
- // one operand list and has no way to tell "no service, command starts here" apart from "service IS
386
- // the first command token". Splitting argv on the first literal `--` after `compute exec`
387
- // ourselves, before commander ever parses it, removes the ambiguity; this is the only place in the
388
- // whole CLI a bare `--` has this meaning, so nothing else is affected. Exported for a direct,
389
- // network-free unit test — this split is the seam most likely to regress.
390
380
  /** The options `insta compute exec` declares — the one source of truth. index.ts builds the
391
381
  * commander command from this list, and the payload scan below uses it to know where the CLI's
392
382
  * own arguments stop. Adding an option here reaches both. */
@@ -650,9 +640,9 @@ export function volumeWriteLine(name, body) {
650
640
  export function volumeDeleteLine(name) {
651
641
  return `compute ${name}: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back`;
652
642
  }
653
- // Map a DELETE .../volume failure. Pure, exported for tests (r2d2 review rounds 1+2: this is the
654
- // close-call branch worth pinning). An older backend has no DELETE route, and what its 404 looks
655
- // like depends on who answered: the real platform (Fastify, no custom notFound handler) sends its
643
+ // Map a DELETE .../volume failure. Pure, exported for tests. An older backend has no DELETE
644
+ // route, and what its 404 looks like depends on who answered: the real platform (Fastify, no
645
+ // custom notFound handler) sends its
656
646
  // default body {"message":"Route DELETE:/… not found","error":"Not Found"} → ApiError message
657
647
  // "Not Found"; a proxy or bodyless 404 leaves ApiError's own "HTTP 404" fallback. BOTH are the
658
648
  // generic route-miss shape and mean version skew, not a bug — parroting them would send the user
@@ -1007,9 +997,7 @@ const sshKeygenVerifyCert = (certPath) => {
1007
997
  * So the authority is `ssh-keygen -L`, run against a temporary file, and the
1008
998
  * real file is only replaced once it passes. Spawning it here costs nothing
1009
999
  * new: certNeedsRenewal already runs the same binary on this same path, every
1010
- * time a certificate exists. (An earlier round declined this on the grounds
1011
- * that a subprocess did not belong on the renewal path -- that reasoning was
1012
- * simply wrong about what the path already does.)
1000
+ * time a certificate exists.
1013
1001
  *
1014
1002
  * A missing ssh-keygen is a REFUSAL, not a pass: it means we cannot confirm,
1015
1003
  * and an unconfirmable certificate must not displace one that works. Nothing
@@ -1720,11 +1708,10 @@ function processAlive(pid) {
1720
1708
  * move or remove the lock, so there is no window in which the file is
1721
1709
  * missing for something to slip in through.
1722
1710
  *
1723
- * And the takeover NEVER UNLINKS THE PATH. The first version verified the
1724
- * token, unlinked the lock and created its own, and nothing tied that unlink
1725
- * to the inode it had verified: a holder releasing in between let a third
1726
- * process create a fresh lock at the path, which the breaker then deleted.
1727
- * Here the breaker writes its token INTO the inode the two names share.
1711
+ * And the takeover NEVER UNLINKS THE PATH: an unlink-then-create is not tied
1712
+ * to the inode that was verified, so a holder releasing in between lets a
1713
+ * third process create a fresh lock at the path, which the breaker then
1714
+ * deletes. Here the breaker writes its token INTO the inode the two names share.
1728
1715
  * `path` keeps its inode throughout, so there is no moment at which the lock
1729
1716
  * at `path` can have become somebody else's between the check and the act:
1730
1717
  * the holder judged dead cannot release, every other breaker is behind the
@@ -6,9 +6,7 @@ import { parseVolumeGib, q, resolveSoleService } from './services.js';
6
6
  // Toggle a postgres service between scale-to-zero (the default: instance suspends when idle,
7
7
  // cold-starts on the next connection) and always-on (instance stays warm; idle RAM bills at
8
8
  // actual usage). Thin wrapper over PATCH /database/settings {scaleToZero} — insta-db-backed
9
- // postgres only. Legacy Neon path: Neon is no longer used by any environment (postgres is 100%
10
- // insta-db) and this code is retained, not live — Neon-backed services managed their own
11
- // autosuspend and the platform returned an error for them.
9
+ // postgres only.
12
10
  export async function dbAlwaysOn(mode, opts) {
13
11
  if (mode !== 'on' && mode !== 'off')
14
12
  throw new Error('mode must be on|off');
@@ -55,9 +53,8 @@ export async function fetchDbInstance(api, projectId, suffix) {
55
53
  return { kind: 'ok', body: res.body };
56
54
  }
57
55
  catch (e) {
58
- // The platform answers a provider-shaped 502 for services with no manageable instance
59
- // (the legacy Neon path — Neon is no longer used by any environment; this branch is retained,
60
- // not live): a soft case, not a failure. Everything else stays an error — an expired
56
+ // The platform answers a provider-shaped 502 for services with no manageable instance:
57
+ // a soft case, not a failure. Everything else stays an error — an expired
61
58
  // token must not render as "no ceiling set" — but wrapped so the user sees what failed.
62
59
  if (e instanceof ApiError && e.status === 502)
63
60
  return { kind: 'no-instance' };
@@ -121,9 +118,6 @@ export async function dbLimits(opts) {
121
118
  const mem = typeof res.body?.memoryMib === 'number' ? fmtMib(res.body.memoryMib) : (opts.memory ?? 'unchanged');
122
119
  info(`postgres ${opts.group ?? 'default'}: ceiling set to ${cpu} / ${mem}`);
123
120
  }
124
- // Render the instance's volume from a database/instance read. Pure, exported for tests. Reads the
125
- // CANONICAL volume* names only — storageSize/storageGiB are deprecated aliases the platform drops
126
- // next release, so depending on them here would be a scheduled breakage.
127
121
  // Bytes → human units, one decimal above KiB. Local because the metrics payload is the only
128
122
  // bytes-denominated read in this file (fmtMib serves the MiB-denominated resize path).
129
123
  export function fmtBytes(n) {
@@ -185,6 +179,9 @@ export async function dbStats(opts) {
185
179
  for (const line of dbStatsLines(opts.group ?? 'default', res.body))
186
180
  info(line);
187
181
  }
182
+ // Render the instance's volume from a database/instance read. Pure, exported for tests. Reads the
183
+ // CANONICAL volume* names only — storageSize/storageGiB are deprecated aliases the platform drops
184
+ // next release, so depending on them here would be a scheduled breakage.
188
185
  export function dbVolumeLines(group, body) {
189
186
  const gib = typeof body?.volumeGib === 'number' ? `${body.volumeGib}Gi` : (typeof body?.volumeSize === 'string' ? body.volumeSize : undefined);
190
187
  if (gib === undefined)
@@ -38,9 +38,9 @@ async function discoverLane(api, projectId, branch, opts) {
38
38
  if (!known.includes(tag)) {
39
39
  die(`this platform answered with a source-build lane this CLI does not know (${JSON.stringify(tag)}) — upgrade with \`insta upgrade\``);
40
40
  }
41
- // The tag alone is not the contract: each branch carries a payload this code then trusts.
42
- // An `archive` with malformed limits fell back to local defaults, so the CLI would enforce
43
- // caps the SERVER does not have, and a `none` with no reason died with `undefined`.
41
+ // The tag alone is not the contract: each branch carries a payload this code then trusts, so
42
+ // it is validated too — an `archive` with malformed limits must not fall back to local
43
+ // defaults the SERVER does not enforce, and a `none` must carry its reason.
44
44
  if (tag === 'archive') {
45
45
  const l = lane.limits;
46
46
  const positive = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v > 0;
@@ -112,7 +112,7 @@ export function dockerfileExposedPort(dockerfile) {
112
112
  // This message is for the target that still REQUIRES a Dockerfile: a Fly-backed service, where a
113
113
  // directory deploy builds the Dockerfile in the directory and dies without one. On insta-compute
114
114
  // the archive lane carries the directory to the gateway and nixpacks builds it, so this dead end is
115
- // no longer universal. It names every way forward instead of the bare "add one".
115
+ // not universal. It names every way forward instead of the bare "add one".
116
116
  //
117
117
  // It deliberately does NOT say "save the Dockerfile `insta build --explain` prints": that file is
118
118
  // not standalone — it COPYs `.nixpacks/nixpkgs-<hash>.nix` support files nixpacks writes beside it,
@@ -39,7 +39,7 @@ const FEEDBACK_ENDPOINT = process.env.INSTA_FEEDBACK_URL ||
39
39
  'https://feedback.instacloud.com/v1/feedback';
40
40
  const FEEDBACK_INGEST_TOKEN = process.env.INSTA_FEEDBACK_TOKEN || 'insta-feedback-public-v1';
41
41
  // 15s gives the backend's scale-to-zero cold start room to answer (the ingest service waits out
42
- // the DB wake and persists, so a report can land after the old 10s deadline gave up on it).
42
+ // the DB wake and persists, so a report can land after a shorter deadline gave up on it).
43
43
  // An expired deadline is reported as UNCONFIRMED, not failed — the report may well be stored.
44
44
  const FEEDBACK_TIMEOUT_MS = 15_000;
45
45
  const MAX_FILE_BYTES = 256 * 1024;
@@ -153,7 +153,7 @@ export async function buildPayload(opts, ctx) {
153
153
  branch: project?.branch,
154
154
  };
155
155
  }
156
- /** One POST, 10s timeout, zero retries — feedback is a side quest and must never hang the CLI.
156
+ /** One POST, one bounded attempt (FEEDBACK_TIMEOUT_MS), zero retries — feedback is a side quest and must never hang the CLI.
157
157
  * Transport and server failures come back as a result, not an exception: the caller downgrades
158
158
  * them to a warning so a broken feedback backend can't fail the user's actual task. */
159
159
  export async function submit(payload, fetchImpl) {
@@ -6,8 +6,7 @@ import { info, printJson } from '../util.js';
6
6
  *
7
7
  * `kind` is the platform's internal resource kind, and for compute it is always 'fly' — 'fly' is
8
8
  * the compute SEAT, occupied by the microvm plane on any environment that has cut over. Printing
9
- * it is how `insta manifest` came to tell users and agents that a microvm-backed service ran on
10
- * Fly (staging, 2026-08-25: `fly(api)` for a row serving from warm pod insta-warm-00178a-16).
9
+ * it tells users and agents that a microvm-backed service runs on Fly.
11
10
  *
12
11
  * So for compute rows the label is the platform's explicit `provider`, and when that is absent --
13
12
  * an older platform, or a row whose provider the platform itself could not determine -- we fall
@@ -98,8 +98,7 @@ function printDimensions(dims) {
98
98
  // insta usage — usage across the 5 billing dimensions (cpu/memory/volume/egress/storage) for the
99
99
  // current billing cycle. Shows the whole ORG by default (with a per-project breakdown); pass --proj
100
100
  // [id] for a single project (the linked one, or a given id). Billed dimensions, not raw provider
101
- // meters. (Historical: those were fly/neon meters — Neon is no longer used by any environment,
102
- // though the adapter code is retained, not live.)
101
+ // meters.
103
102
  export async function usage(opts) {
104
103
  const api = await ApiClient.load();
105
104
  const p = await requireProject();
@@ -165,7 +165,7 @@ async function readStdin() {
165
165
  // Every platform SkipReason (applyTargets.ts) except 'unknown-service', which means the plan
166
166
  // could not resolve the service, not that it correctly chose to skip it.
167
167
  const BENIGN_SKIP_REASONS = new Set(['no-image', 'other-branch', 'caller-deploying', 'skip-deploy']);
168
- // Design doc §5's three outcomes: an unwritten entry is a hard failure; a written entry with a
168
+ // Three outcomes: an unwritten entry is a hard failure; a written entry with a
169
169
  // failed or unexplained-skip service is durable but not fully live; anything else is success.
170
170
  export function applyVerdict(entries, services) {
171
171
  if (entries.some((e) => !e.written))
@@ -85,9 +85,8 @@ export function servicesAddRequestBody(type, name, branch, opts) {
85
85
  type, name, ...(branch ? { branch } : {}), public: !!opts.public,
86
86
  ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}),
87
87
  ...(opts.region ? { region: opts.region } : {}),
88
- // Sent whenever the flag was given, false included: compute is born always-on by default
89
- // (insta-platform #385, 2026-09-07), so `--no-always-on` must reach the API as an explicit
90
- // false. Omitted means the platform default.
88
+ // Sent whenever the flag was given, false included: compute is born always-on by default,
89
+ // so `--no-always-on` must reach the API as an explicit false. Omitted means the platform default.
91
90
  ...(opts.alwaysOn !== undefined ? { alwaysOn: opts.alwaysOn } : {}),
92
91
  ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
93
92
  ...(opts.mountPath !== undefined ? { volumeMountPath: opts.mountPath } : {}),
@@ -21,7 +21,7 @@ import { projectCreate, projectLink, slugifyName } from './project.js';
21
21
  import { envUse } from './env.js';
22
22
  import { installAgentConfigs } from './mcp.js';
23
23
  import { detectChannel } from './upgrade.js';
24
- export { resolveSpawnable, whichOnPath } from '../spawn.js';
24
+ export { resolveSpawnable } from '../spawn.js';
25
25
  // The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an
26
26
  // "Installing to all N agents" banner, a full N-line install-path box, and a third-party
27
27
  // "Security Risk Assessment" that flags our OWN first-party skill as "Critical Risk". Streamed
@@ -65,7 +65,7 @@ export function parseInstalledAgents(output) {
65
65
  // The tool boxes each line ("│ → ~/.claude/skills/insta │"), so don't anchor to EOL.
66
66
  // Separator-agnostic: on Windows the tool prints C:\Users\…\.claude\skills\insta — a
67
67
  // forward-slash-only match found nothing there, collapsing the summary to a nameless
68
- // "Agents set up" (user report).
68
+ // "Agents set up".
69
69
  const m = plain.match(/→\s*(\S+)[\\/]skills[\\/][A-Za-z0-9_-]+/);
70
70
  if (m && m[1])
71
71
  paths.add(m[1]);
@@ -256,7 +256,7 @@ export function requireMcpRegistration(status) {
256
256
  /** The environment `setup agent` should target, and whether the machine must be switched to it
257
257
  * first. Pure — decides only; the caller performs the switch.
258
258
  *
259
- * The contract (CLI ≥ 0.0.38): the public one-liner `npx -y insta setup agent` means PRODUCTION,
259
+ * The contract: the public one-liner `npx -y insta setup agent` means PRODUCTION,
260
260
  * full stop — a leftover `insta env use staging` from last month must not silently give a new
261
261
  * onboarding run staging skills. Staging is an explicit ask: `--env staging` (or $INSTA_ENV).
262
262
  * Two deliberate exceptions leave the machine alone:
@@ -442,7 +442,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
442
442
  // --project / --create: bind this directory to a project inside the SAME process. Never split
443
443
  // this back into `setup agent && insta project <cmd>` as one paste: no shell joiner survives
444
444
  // every Windows shell, and in shells without bracketed paste the queued second line is eaten
445
- // as the answer to the login prompt above (console PR #290). Both need the session: without
445
+ // as the answer to the login prompt above. Both need the session: without
446
446
  // one the manual command is the hint, never a hang; a failure (bad id, no access, name taken)
447
447
  // is a REAL error — binding a project is the entire point of the flag — so it sets the exit
448
448
  // code instead of pretending setup succeeded.
@@ -139,24 +139,29 @@ export function renderConfigBlock(o) {
139
139
  // IdentitiesOnly is not tidiness. SSH offers public keys ONE AT A TIME,
140
140
  // so a user with several keys is identified non-deterministically -- the
141
141
  // server sees whichever key happened to be offered first, which may not be
142
- // the one carrying our certificate. exe.dev calls this heisen-connect.
143
- // Without this line a developer with a full ssh-agent gets intermittent,
144
- // unexplainable auth failures.
142
+ // the one carrying our certificate. Without this line a developer with a
143
+ // full ssh-agent gets intermittent, unexplainable auth failures.
145
144
  ' IdentitiesOnly yes');
146
145
  // Connection multiplexing collapses scp, an IDE's several connections and a
147
146
  // second terminal onto ONE connection; without it a single developer can
148
147
  // reach the per-service session cap in an afternoon.
149
148
  //
150
- // OMITTED ON WINDOWS, where it is not an optimisation but a broken config.
149
+ // The socket is keyed on %C -- a hash of (local host, remote host, port,
150
+ // user) -- not %r@%h:%p. A ControlPath is a Unix-domain socket, whose path
151
+ // is capped at 104 bytes on macOS (108 on Linux), and the route-key user
152
+ // plus the regional gateway hostname sailed past that: a real prod alias
153
+ // rendered a 109-byte path and every `ssh` died on `ControlPath too long`.
154
+ // %C is a fixed 40 hex chars however long the host and user grow, so the
155
+ // path can no longer overflow; it also drops the `:` the old token carried.
156
+ //
157
+ // OMITTED ON WINDOWS, where it is not an optimisation but a broken config:
151
158
  // Win32-OpenSSH does not implement ControlMaster (PowerShell/Win32-OpenSSH
152
- // #1328, #405) and fails the connection rather than ignoring the directive,
153
- // and the ControlPath itself contains a `:` before %p, which is not a legal
154
- // character in a Windows filename. Every alias would be unusable on a
155
- // platform this repo runs CI for. The effective-config tests need a real
156
- // `ssh` and skip on Windows, so this branch is asserted on the rendered
157
- // text instead.
159
+ // #1328, #405) and FAILS the connection rather than ignoring the directive,
160
+ // so every alias would be unusable on a platform this repo runs CI for. The
161
+ // effective-config tests need a real `ssh` and skip on Windows, so this
162
+ // branch is asserted on the rendered text instead.
158
163
  if ((o.platform ?? process.platform) !== 'win32') {
159
- lines.push(' ControlMaster auto', ' ControlPath ~/.insta/ssh/cm-%r@%h:%p', ' ControlPersist 10m');
164
+ lines.push(' ControlMaster auto', ' ControlPath ~/.insta/ssh/cm-%C', ' ControlPersist 10m');
160
165
  }
161
166
  if (o.ensureCertCommand) {
162
167
  // Renewal happens while OpenSSH PARSES the config, before it connects, so
@@ -328,11 +333,10 @@ function isEcdsaBody(curve, point, wantCurve, pointLen) {
328
333
  /** Exactly ONE OpenSSH public-key record: `<type> <base64>` with an optional
329
334
  * comment, and nothing else -- no second line, no leading directive.
330
335
  *
331
- * `renderCertAuthority` only trimmed, so an embedded newline in the CA value
332
- * smuggled additional known_hosts lines past it. Parsing to the three fields
333
- * we will actually write, and rebuilding the line from THOSE, means a value
334
- * either is one key record or is refused; there is no third outcome where
335
- * part of it is honoured. */
336
+ * Parsing to the three fields we will actually write, and rebuilding the line
337
+ * from THOSE, means a value either is one key record or is refused; there is
338
+ * no third outcome where part of it is honoured (an embedded newline would
339
+ * otherwise smuggle additional known_hosts lines through). */
336
340
  export function parseCAPublicKey(value) {
337
341
  if (typeof value !== 'string')
338
342
  throw new Error('the platform returned no ssh certificate authority key');
@@ -349,22 +353,18 @@ export function parseCAPublicKey(value) {
349
353
  if (!/^[A-Za-z0-9+/]+={0,3}$/.test(blob) || blob.length < 32) {
350
354
  throw new Error('refusing a certificate authority key whose body is not base64');
351
355
  }
352
- // The blob's OWN type must agree with the text field. Base64-shaped is not
353
- // the same as "is a key": an anchor built from a mislabelled or arbitrary
354
- // blob installs silently and then fails at connect time, where the message
355
- // points at known_hosts rather than at the response that produced it.
356
- // The WHOLE blob, not just its first field. A first-field check rejects
357
- // arbitrary base64 and still accepts a correct type name followed by noise --
358
- // and an anchor built from that installs silently, then fails at connect
359
- // time, where the message points at known_hosts rather than at the response
360
- // that produced it.
356
+ // The blob's OWN type must agree with the text field, and the WHOLE blob is
357
+ // checked, not just its first field: a first-field check rejects arbitrary
358
+ // base64 and still accepts a correct type name followed by noise -- and an
359
+ // anchor built from that installs silently, then fails at connect time,
360
+ // where the message points at known_hosts rather than at the response that
361
+ // produced it.
361
362
  const fields = sshBlobFields(blob);
362
363
  if (!fields || fields.length < 2 || fields[0].toString('utf8') !== type) {
363
364
  throw new Error(`refusing a certificate authority key whose body does not match its type ${JSON.stringify(type.slice(0, 32))}`);
364
365
  }
365
- // And the fields must be the ones THIS type has. ed25519 was the only type
366
- // held to its shape at first, so a correct RSA or ECDSA type name followed by
367
- // any well-formed fields passed -- see CA_KEY_SHAPES.
366
+ // And the fields must be the ones THIS type has (see CA_KEY_SHAPES): a
367
+ // correct RSA or ECDSA type name followed by any well-formed fields must not pass.
368
368
  if (!shape(fields)) {
369
369
  throw new Error(`refusing a certificate authority key whose body is not the shape of ${JSON.stringify(type.slice(0, 32))}`);
370
370
  }
@@ -662,8 +662,7 @@ export function planCertAuthority(existing, hostPattern, caKey) {
662
662
  export function revertCertAuthority(current, plan) {
663
663
  const kept = current.split('\n').filter((l) => l.trimEnd() !== plan.line);
664
664
  // Only the ONE trailing blank the split leaves behind a final newline. A
665
- // blank line before that is the user's -- trailing blanks included, which a
666
- // loop popping every empty tail line was deleting on the failure path.
665
+ // blank line before that is the user's -- trailing blanks included.
667
666
  if (kept.length > 0 && kept[kept.length - 1] === '')
668
667
  kept.pop();
669
668
  // Only the anchors that are genuinely gone: a concurrent install may already
@@ -329,7 +329,7 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
329
329
  manifest = fetched.manifest;
330
330
  source = fetched.source;
331
331
  vars = collectManifestVariables(manifest);
332
- // Spec 4.4: exactly ONE line in front of today's output. A second "deploying template …" line
332
+ // Exactly ONE line in front of today's output. A second "deploying template …" line
333
333
  // would read as a duplicate of the "deploying template <code> to branch <branch>" line below,
334
334
  // so the manifest's code@version rides on this one instead.
335
335
  if (!quiet) {
@@ -9,9 +9,8 @@
9
9
  // `resolveLatest()`, reading npm's `latest` dist-tag, the same thing `npx insta@latest` and
10
10
  // `npm i -g insta` resolve. Both the background check and `insta upgrade` go through it, and the
11
11
  // binary channel then installs THAT version by tag (INSTA_VERSION=v<latest>) rather than asking
12
- // GitHub independently for /releases/latest. Two independent resolvers is how the two paths came
13
- // to disagree; they have drifted before (v0.0.46 was merged but never tagged, so it exists on
14
- // neither npm nor GitHub).
12
+ // GitHub independently for /releases/latest. Two independent resolvers is how the two paths
13
+ // drift apart: a version can be merged but never tagged, so it exists on neither npm nor GitHub.
15
14
  //
16
15
  // A CACHE MUST NOT LIE. ~/.insta/update-check.json is a cache of that one answer, never a second
17
16
  // source. Every path that learns the real latest rewrites it (including a successful upgrade),
@@ -348,7 +347,7 @@ export async function autoupdate(mode) {
348
347
  * `upgrade`/`autoupdate` are the update machinery itself. Every `__` command
349
348
  * is internal machinery rather than a user at a prompt, and the rule is
350
349
  * written as a PREFIX so the next such command inherits it instead of
351
- * rediscovering it -- which is exactly how this was missed:
350
+ * rediscovering it:
352
351
  * `__ssh-ensure-cert` is run by OpenSSH while it PARSES ssh_config, on every
353
352
  * ssh, scp, `ssh -G` and IDE connection, so a nudge here is written straight
354
353
  * into the ssh session's stderr and an auto-upgrade spawns a detached process
package/dist/config.js CHANGED
@@ -16,9 +16,8 @@ const PROJECT_FILE = 'project.json';
16
16
  // CLI treat the shared link as foreign. It records the project id too, because project.json is
17
17
  // committed and this file is not: a pull or checkout can replace the project underneath it.
18
18
  const LINK_PLANE_FILE = 'link-plane.json';
19
- // The cloud API default. Uses the instacloud.com brand domain (matches the agents.instacloud.com
20
- // onboarding), NOT the legacy beta-api.insta.insforge.dev host — same backend, branded domain.
21
- // Only affects fresh installs: a persisted apiUrl (from a prior login) or INSTA_API_URL wins below.
19
+ // The cloud API default. Only affects fresh installs: a persisted apiUrl (from a prior login) or
20
+ // INSTA_API_URL wins below.
22
21
  const DEFAULT_API = ENVS[DEFAULT_ENV].api;
23
22
  export async function readGlobal() {
24
23
  // Precedence, most explicit first:
@@ -147,7 +146,7 @@ export async function findProjectRoot(cwd = process.cwd()) {
147
146
  }
148
147
  /** The link that applies to `cwd`, and whether it was made against a DIFFERENT control plane. A
149
148
  * project id means nothing on another control plane (cloud, staging and every insta-oss box each
150
- * have their own), and the CLI used to reuse a link against whatever API it was pointed at. */
149
+ * have their own). */
151
150
  export async function resolveProjectLink(cwd = process.cwd()) {
152
151
  // Linkless targeting (CI / one-offs / agents): INSTA_PROJECT_ID resolves the project with no
153
152
  // link file, and beats one when both exist — an explicit parameter outranks ambient state.
@@ -170,8 +169,8 @@ export async function resolveProjectLink(cwd = process.cwd()) {
170
169
  catch {
171
170
  return null;
172
171
  }
173
- // No sidecar — a link from before this existed, or a teammate who just cloned — resolves as it
174
- // always did: there is nothing to say which control plane it belongs to.
172
+ // No sidecar (a teammate who just cloned): nothing says which control plane the link belongs
173
+ // to, so it resolves unchecked.
175
174
  const record = await readLinkPlane(root);
176
175
  if (record) {
177
176
  const current = safeUrl((await readGlobal()).apiUrl);
@@ -1,6 +1,6 @@
1
1
  // Build a source directory into an image and push it to Fly's registry, using a short-lived,
2
2
  // app-scoped deploy token minted by the platform (the CLI never holds a standing Fly credential).
3
- // Shells out to `flyctl deploy --build-only --push` (remote builder). Ported from firth.
3
+ // Shells out to `flyctl deploy --build-only --push` (remote builder).
4
4
  import { spawn } from 'node:child_process';
5
5
  import { existsSync, writeFileSync, unlinkSync } from 'node:fs';
6
6
  import { join } from 'node:path';
@@ -80,7 +80,7 @@ export async function ensureFlyctl() {
80
80
  return;
81
81
  }
82
82
  if (process.platform === 'linux') {
83
- // A fresh Linux machine (CI containers included — insta-e2e run 31284364163) has no flyctl
83
+ // A fresh Linux machine (CI containers included) has no flyctl
84
84
  // and no brew; without this branch `insta deploy <dir>` dead-ends on a hand-install of a
85
85
  // third-party CLI. Official installer, pinned into ~/.fly; the current process extends its
86
86
  // own PATH because the installer's shell-profile edit can't reach an already-running process.
@@ -7,7 +7,6 @@ import { tmpdir } from 'node:os';
7
7
  import { join, sep } from 'node:path';
8
8
  import { parseManifestYaml, MANIFEST_FILE } from './template-manifest.js';
9
9
  const GITHUB_HOST = /^(?:https?:\/\/)?(?:www\.)?github\.com\//i;
10
- // An explicit address: a scheme, or an scp-style user@host:path. Never a local path.
11
10
  // A scheme (with or without its slashes) or an scp-style user@host:path. The slashes are optional
12
11
  // because `https:/github.com/o/r`, a URL that lost one, must be named as a bad address rather than
13
12
  // resolved as a directory called `https:`. Two or more scheme characters are required so a Windows
@@ -49,7 +48,7 @@ function decodedSegments(parts, target) {
49
48
  }
50
49
  /** Parse a github.com URL into owner, repo and the still-unsplit ref+path tail.
51
50
  * null = not URL-shaped, so the caller's local-directory and registry modes still get a look.
52
- * A URL-shaped target that is not a github.com repository URL throws (spec 4.1). */
51
+ * A URL-shaped target that is not a github.com repository URL throws. */
53
52
  export function parseGitHubTemplateUrl(target) {
54
53
  if (!GITHUB_HOST.test(target)) {
55
54
  // Name a non-GitHub address for what it is; let everything else reach local/registry mode.
@@ -140,7 +139,7 @@ function releaseChild(child) {
140
139
  }
141
140
  catch { /* already gone */ }
142
141
  }
143
- /** spawnFn is injected so the timeout path is testable without a network (spec FAQ 7.15). */
142
+ /** spawnFn is injected so the timeout path is testable without a network. */
144
143
  export function makeGitRunner(spawnFn = nodeSpawn) {
145
144
  return (args, opts) => new Promise((resolve) => {
146
145
  // Spawned directly, NOT through resolveSpawnable: that wrapper exists for npm-installed
@@ -232,7 +231,7 @@ export function splitRefAndPath(refAndPath, refs) {
232
231
  }
233
232
  /** One round trip answers two questions: the default branch, and where the ref ends. The commit
234
233
  * is NOT taken from here — an annotated tag lists its tag object, and a branch can move before
235
- * the clone. fetchGitHubTemplate reads it from the checkout instead (spec FAQ 7.10). */
234
+ * the clone. fetchGitHubTemplate reads it from the checkout instead. */
236
235
  export async function resolveGitHubRef(t, run) {
237
236
  // HEAD is listed EXPLICITLY: adding refspecs otherwise drops the symref line that names the
238
237
  // default branch (measured on this repo: 375 refs unfiltered, 188 with the filter, and no
@@ -285,7 +284,7 @@ export async function fetchGitHubTemplate(target, run = defaultGitRunner, parse
285
284
  const dir = mkdtempSync(join(tmpdir(), 'insta-tpl-gh-'));
286
285
  try {
287
286
  // The short name, not resolved.qualifiedRef: `--branch` rejects a fully-qualified ref, and
288
- // git's own short-name tie-break already agrees with splitRefAndPath (spec FAQ 7.14).
287
+ // git's own short-name tie-break already agrees with splitRefAndPath.
289
288
  const cloned = await run(['clone', '--depth', '1', '--quiet', '--branch', resolved.ref, repoUrl(target), dir], { timeoutMs: CLONE_TIMEOUT_MS });
290
289
  if (cloned.timedOut)
291
290
  throw new Error(`timed out after ${CLONE_TIMEOUT_MS / 1000}s cloning https://github.com/${target.owner}/${target.repo}`);
@@ -294,7 +293,7 @@ export async function fetchGitHubTemplate(target, run = defaultGitRunner, parse
294
293
  if (cloned.code !== 0)
295
294
  throw new Error(unreadableRepoMessage(target, cloned.stderr));
296
295
  // The deployed commit is the one in the checkout: an annotated tag's listing entry is its tag
297
- // object, and a branch can move between ls-remote and here (spec FAQ 7.10).
296
+ // object, and a branch can move between ls-remote and here.
298
297
  const head = await run(['-C', dir, 'rev-parse', 'HEAD'], { timeoutMs: LS_REMOTE_TIMEOUT_MS });
299
298
  // Same three outcomes the other two calls distinguish, so a timeout or a missing git here is
300
299
  // not reported as an unreadable repository.
package/dist/index.js CHANGED
@@ -448,12 +448,8 @@ program.command('autoupdate [mode]').description('Show or set auto-update: on |
448
448
  program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck(cliVersion())));
449
449
  // The ssh_config renewal hook. Hidden, and named with the `__` prefix that
450
450
  // trackCommand skips, because OpenSSH runs it while PARSING the config on EVERY
451
- // ssh/scp/`ssh -G`/IDE connection. Under the normal `compute ssh --ensure-cert`
452
- // path, guard's trackCommand ran afterwards regardless of the action returning
453
- // early -- reading config, possibly creating ~/.insta/telemetry.json, and
454
- // making a PostHog request with a timeout of up to 1.5s. That is a network
455
- // round trip on the critical path of every ordinary ssh, which is exactly what
456
- // the hook was specified not to do.
451
+ // ssh/scp/`ssh -G`/IDE connection: a telemetry round trip here would sit on the
452
+ // critical path of every ordinary ssh.
457
453
  program.command('__ssh-ensure-cert <alias>', { hidden: true })
458
454
  .action(guard((alias) => computeCmd.ensureCertForAlias(alias)));
459
455
  selfUpdate.maybeUpdate(cliVersion(), process.argv);
@@ -1,5 +1,5 @@
1
1
  // PostToolUse hook: reads a tool-use event on stdin (Claude Code / Codex), scans every string
2
- // surface for credential exposure, and appends findings to ./.insta/audit.jsonl. Ported from firth.
2
+ // surface for credential exposure, and appends findings to ./.insta/audit.jsonl.
3
3
  import { appendFileSync, mkdirSync } from 'node:fs';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -1,5 +1,5 @@
1
1
  // Install the observe hook into a project's agent harness (Claude Code / Codex) and materialize
2
- // the standalone hook + scanner into ./.insta/observe. Ported from firth (.firth -> .insta).
2
+ // the standalone hook + scanner into ./.insta/observe.
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -139,8 +139,8 @@ function posixClass(name, negated) {
139
139
  // literal. docker hands the class to Go's regexp: a `]` right after the opening `[` (or after
140
140
  // the `^`) is a MEMBER, not the close, `\` quotes the next character, and only `^` negates, so a
141
141
  // `!` is an ordinary member. Unlike `*` and `?`, these Go regexp classes CAN match a separator.
142
- // Verified against moby/patternmatcher compile() on 2026-09-11: it preserves bracket expressions
143
- // without adding a separator exclusion (private[^x]token matches private/token).
142
+ // moby/patternmatcher compile() preserves bracket expressions without adding a separator
143
+ // exclusion (private[^x]token matches private/token).
144
144
  function bracket(p, start) {
145
145
  let j = start + 1;
146
146
  let negated = false;
@@ -1,8 +1,8 @@
1
1
  // "One command, just works": when a command needs a project and the directory isn't linked
2
2
  // (and no INSTA_PROJECT_ID is set), resolve it instead of lecturing about `project link` —
3
3
  // one project auto-selects silently, several get a one-keystroke picker, and either way the
4
- // choice is SAVED so this happens at most once per directory. (Railway prompts every unlinked
5
- // machine; we persist the answer and, via the committed link file, share it with the team.)
4
+ // choice is SAVED so this happens at most once per directory, and the committed link file shares
5
+ // it with the team.
6
6
  import { createInterface } from 'node:readline/promises';
7
7
  export async function autoResolveProject(orgId, deps) {
8
8
  const projects = await deps.listProjects();
@@ -125,7 +125,7 @@ export function validateManifest(m) {
125
125
  problems.push(`${where}: web services must declare a healthcheck path`);
126
126
  if (svc.healthcheck && !String(svc.healthcheck).startsWith('/'))
127
127
  problems.push(`${where}: healthcheck must be an absolute path (start with /)`);
128
- // Sizing is the platform's, capped for the org's plan (insta-platform#357). Same answers the
128
+ // Sizing is the platform's, capped for the org's plan. Same answers the
129
129
  // publish endpoint gives, said here so an author does not upload to find out. Read as unknown:
130
130
  // the type above admits only what is SUPPORTED, and the document is a cast over YAML.parse, so
131
131
  // a refused shape arrives as a value that type does not describe.
package/dist/util.js CHANGED
@@ -101,8 +101,8 @@ export function resolveThroughSymlink(path) {
101
101
  /** Linux allows 40; the exact number does not matter, only that the walk ends. */
102
102
  const MAX_SYMLINK_HOPS = 40;
103
103
  /** How to launch the default browser for `url` on `platform`. Pure so the Windows encoding is
104
- * testable. On Windows NO shell may ever parse the URL: cmd.exe splits at bare `&` (which #138
105
- * fixed by quoting) but ALSO expands `%…%` sequences even inside quotes, and a percent-encoded
104
+ * testable. On Windows NO shell may ever parse the URL: cmd.exe splits at bare `&` (quoting
105
+ * fixes that) but ALSO expands `%…%` sequences even inside quotes, and a percent-encoded
106
106
  * OAuth redirect (`http%3A%2F%2F127.0.0.1…`) is nothing but such sequences. So the launch goes
107
107
  * through PowerShell's -EncodedCommand: a pure-ASCII script travels as base64(UTF-16LE) — no
108
108
  * argument parsing anywhere — and the URL itself rides as a second base64 payload INSIDE that
@@ -113,11 +113,10 @@ export function openUrlSpawn(url, platform = process.platform,
113
113
  // directory before PATH, so a planted powershell.exe beside the user's shell would win.
114
114
  systemRoot = process.env.SYSTEMROOT ?? process.env.windir ?? 'C:\\Windows') {
115
115
  if (platform === 'win32') {
116
- // The URL never appears in PowerShell SOURCE at all: it travels as base64 inside the script
117
- // and is decoded by .NET at runtime. Interpolating it into a quoted literal is not enough —
118
- // PowerShell honors smart quotes (U+2018–U+201B) as string delimiters too, so ASCII-only
119
- // escaping still leaves a breakout. The script below is pure ASCII by construction (the
120
- // base64 alphabet), so no byte of any URL can terminate anything.
116
+ // Interpolating the URL into a quoted literal is not enough — PowerShell honors smart quotes
117
+ // (U+2018–U+201B) as string delimiters too, so ASCII-only escaping still leaves a breakout.
118
+ // The script below is pure ASCII by construction (the base64 alphabet), so no byte of any
119
+ // URL can terminate anything.
121
120
  const urlB64 = Buffer.from(url, 'utf8').toString('base64');
122
121
  const script = `Start-Process ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${urlB64}')))`;
123
122
  return {
@@ -149,8 +148,7 @@ export function openUrl(url) {
149
148
  }
150
149
  export class CliExit extends Error {
151
150
  constructor() {
152
- // Preserve the observable error used by direct command-unit tests that previously mocked
153
- // process.exit(1) by throwing `Error('exit 1')`.
151
+ // Command-unit tests assert this exact message.
154
152
  super('exit 1');
155
153
  this.name = 'CliExit';
156
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.78",
3
+ "version": "0.0.79",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [