insta 0.0.77 → 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
@@ -750,7 +740,7 @@ import { homedir } from 'node:os';
750
740
  import { dirname, join } from 'node:path';
751
741
  import { execFileSync } from 'node:child_process';
752
742
  import { createHash, randomUUID } from 'node:crypto';
753
- import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, } from './ssh-config.js';
743
+ import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, ownedBlock, ownedBlockIsFirst } from './ssh-config.js';
754
744
  /** Where this CLI keeps its own SSH material. Deliberately NOT ~/.ssh: we never
755
745
  * touch a key the user already had, and a dedicated key pairs with
756
746
  * IdentitiesOnly to avoid being identified by the wrong one. */
@@ -769,6 +759,15 @@ export const instaAliasStorePath = () => join(instaSSHDir(), 'aliases.json');
769
759
  export const userKnownHostsPath = () => join(homedir(), '.ssh', 'known_hosts');
770
760
  /** The renewal-hook command prefix. ssh-config.ts appends the validated alias. */
771
761
  export const ENSURE_CERT_COMMAND = 'insta __ssh-ensure-cert';
762
+ /** The gateway's public port when the plane does not say: :2222, the port the
763
+ * lane shipped on (:22 waits for a compliance exception). The plane's answer
764
+ * in a mint response ALWAYS wins over this; it exists for stores and planes
765
+ * that predate the `port` field, so a working alias does not stop working. */
766
+ export const DEFAULT_SSH_PORT = 2222;
767
+ /** A TCP port as the plane returns it: an integer, 1..65535. */
768
+ export function isValidPort(v) {
769
+ return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 65535;
770
+ }
772
771
  /** Never throws: a missing or hand-mangled store must degrade to "nothing is
773
772
  * set up", not break `ssh` for every alias. */
774
773
  export function readAliasStore(path = instaAliasStorePath()) {
@@ -800,6 +799,7 @@ export function isValidAliasRecord(r) {
800
799
  return typeof v.projectId === 'string' && v.projectId !== ''
801
800
  && typeof v.serviceId === 'string' && v.serviceId !== ''
802
801
  && (v.branch === undefined || typeof v.branch === 'string')
802
+ && (v.port === undefined || isValidPort(v.port))
803
803
  && isSafeConfigValue(v.host) && isSafeConfigValue(v.username);
804
804
  }
805
805
  /** Refuse to point an existing alias at a different service.
@@ -839,7 +839,7 @@ export function hostEntries(store) {
839
839
  // sit where the rendering does.
840
840
  .filter(([alias, r]) => isSafeAlias(alias) && isValidAliasRecord(r))
841
841
  .sort(([a], [b]) => a.localeCompare(b))
842
- .map(([alias, r]) => ({ alias, hostName: r.host, user: r.username, certificateFile: instaCertPath(alias) }));
842
+ .map(([alias, r]) => ({ alias, hostName: r.host, user: r.username, port: r.port ?? DEFAULT_SSH_PORT, certificateFile: instaCertPath(alias) }));
843
843
  }
844
844
  /**
845
845
  * When `ssh-keygen -L` output says the certificate stops being valid, or
@@ -969,6 +969,11 @@ export function validateCertResponse(body) {
969
969
  // are refused rather than rendered.
970
970
  if (!isSafeTimestamp(b.expiresAt))
971
971
  throw new Error('the platform returned an unusable certificate expiry');
972
+ // Optional only because planes predating the field exist; PRESENT and wrong
973
+ // is refused, never coerced -- a port we made up is a connection that hangs.
974
+ if (b.port !== undefined && !isValidPort(b.port)) {
975
+ throw new Error(`the platform returned an unusable ssh port: ${JSON.stringify(String(b.port).slice(0, 16))}`);
976
+ }
972
977
  // Parsed here rather than at install time so a malformed key fails before
973
978
  // anything is written, instead of after the alias is already recorded.
974
979
  if (b.caPublicKey !== undefined)
@@ -992,9 +997,7 @@ const sshKeygenVerifyCert = (certPath) => {
992
997
  * So the authority is `ssh-keygen -L`, run against a temporary file, and the
993
998
  * real file is only replaced once it passes. Spawning it here costs nothing
994
999
  * new: certNeedsRenewal already runs the same binary on this same path, every
995
- * time a certificate exists. (An earlier round declined this on the grounds
996
- * that a subprocess did not belong on the renewal path -- that reasoning was
997
- * simply wrong about what the path already does.)
1000
+ * time a certificate exists.
998
1001
  *
999
1002
  * A missing ssh-keygen is a REFUSAL, not a pass: it means we cannot confirm,
1000
1003
  * and an unconfirmable certificate must not displace one that works. Nothing
@@ -1235,6 +1238,40 @@ function withLockedFile(name, fn, { waitMs, staleMs, sleep, busy }) {
1235
1238
  sleep(LOCK_POLL_MS);
1236
1239
  }
1237
1240
  }
1241
+ /** The block ssh_config SHOULD carry for this store, rendered the one way. */
1242
+ function renderInstalledBlock(store) {
1243
+ return renderConfigBlock({
1244
+ entries: hostEntries(store),
1245
+ identityFile: instaKeyPath(),
1246
+ knownHostsFile: userKnownHostsPath(),
1247
+ ensureCertCommand: ENSURE_CERT_COMMAND,
1248
+ });
1249
+ }
1250
+ /** Whether the installed block differs from what the store renders -- the ONE
1251
+ * predicate for "rewrite ssh_config". A host move, a port the plane changed,
1252
+ * and a stanza written before the Port line existed all show up here; a
1253
+ * renewal whose response changed nothing does not, which matters because
1254
+ * OpenSSH is reading the file while the hook runs and a rename over an open
1255
+ * file fails on Windows. No installed block = nothing to repair (setup owns
1256
+ * the first write). */
1257
+ function configBlockStale(store) {
1258
+ let existing;
1259
+ try {
1260
+ existing = readFileSync(join(homedir(), '.ssh', 'config'), 'utf8');
1261
+ }
1262
+ catch {
1263
+ return false;
1264
+ }
1265
+ const installed = ownedBlock(existing);
1266
+ if (installed === undefined)
1267
+ return false;
1268
+ // Position is content too: a block that has slid below other configuration
1269
+ // is shadowed keyword by keyword (first obtained value wins), whatever its
1270
+ // text says. The rewrite puts it back where setup wrote it, at the top.
1271
+ if (!ownedBlockIsFirst(existing))
1272
+ return true;
1273
+ return installed.replace(/\n+$/, '') !== renderInstalledBlock(store).replace(/\n+$/, '');
1274
+ }
1238
1275
  function installConfigBlock(store) {
1239
1276
  const cfg = join(homedir(), '.ssh', 'config');
1240
1277
  mkdirSync(dirname(cfg), { recursive: true, mode: 0o700 });
@@ -1246,12 +1283,7 @@ function installConfigBlock(store) {
1246
1283
  // target says.
1247
1284
  const existedBefore = pathExists(cfg);
1248
1285
  const existing = readUserText(cfg);
1249
- const block = renderConfigBlock({
1250
- entries: hostEntries(store),
1251
- identityFile: instaKeyPath(),
1252
- knownHostsFile: userKnownHostsPath(),
1253
- ensureCertCommand: ENSURE_CERT_COMMAND,
1254
- });
1286
+ const block = renderInstalledBlock(store);
1255
1287
  // Backed up: this is the file that decides whether the user can ssh anywhere
1256
1288
  // at all, and our block goes at the TOP of it.
1257
1289
  writeFileAtomicSync(cfg, upsertConfigBlock(existing, block), { mode: 0o600, backup: true });
@@ -1370,6 +1402,20 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
1370
1402
  try {
1371
1403
  if (!isSafeAlias(alias))
1372
1404
  return;
1405
+ // Repair BEFORE the renewal gate, because the stanza can lag the store
1406
+ // with no certificate due: an alias set up before the Port line existed
1407
+ // dials :22 on every connection, and waiting for its certificate to age
1408
+ // would leave it failing for up to the certificate's lifetime. The
1409
+ // predicate is content drift, so the common case -- an up-to-date stanza --
1410
+ // is one file read and a string compare, with no lock taken; only a stale
1411
+ // stanza takes the alias-store lock, re-checks under it and rewrites.
1412
+ if (configBlockStale(readAliasStore())) {
1413
+ withAliasStoreLock(() => {
1414
+ const store = readAliasStore();
1415
+ if (store[alias] && configBlockStale(store))
1416
+ installConfigBlock(store);
1417
+ }, { waitMs: KNOWN_HOSTS_LOCK_WAIT_MS });
1418
+ }
1373
1419
  if (!certNeedsRenewal(instaCertPath(alias)))
1374
1420
  return;
1375
1421
  // An IDE opens several connections at once and `scp` adds more, so the
@@ -1433,9 +1479,16 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
1433
1479
  return;
1434
1480
  if (!certNeedsRenewal(instaCertPath(alias)))
1435
1481
  return;
1482
+ // The plane's answer wins, on every field it answers. `moved` is the
1483
+ // host/user half and keeps its CA requirement below (a new host needs
1484
+ // an anchor); the port has no such requirement but is part of the
1485
+ // record all the same, and a legacy record without one gains it here
1486
+ // so the stanza can carry the line.
1487
+ const port = out.port ?? held.port ?? DEFAULT_SSH_PORT;
1436
1488
  const moved = held.host !== out.host || held.username !== out.username;
1437
- const store = moved
1438
- ? { ...before, [alias]: { ...held, host: out.host, username: out.username } }
1489
+ const recordChanged = moved || held.port !== port;
1490
+ const store = recordChanged
1491
+ ? { ...before, [alias]: { ...held, host: out.host, username: out.username, port } }
1439
1492
  : before;
1440
1493
  const installed = configBlockInstalled();
1441
1494
  const ca = out.caPublicKey;
@@ -1463,11 +1516,13 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
1463
1516
  // host into a renewal that gives up -- one failed renewal and a
1464
1517
  // `--setup` to repair it, never a half-moved alias.
1465
1518
  const steps = [];
1466
- if (moved)
1519
+ if (recordChanged)
1467
1520
  steps.push(() => { const back = snapshotForUndo(instaAliasStorePath()); writeAliasStore(store); return back; });
1468
1521
  if (ca !== undefined)
1469
1522
  steps.push(() => installCertAuthority(hostPatternFor(out.host), ca));
1470
- if (moved && installed)
1523
+ // Drift, not "moved": a changed port rewrites the stanza; an unchanged
1524
+ // response leaves the file alone (see configBlockStale).
1525
+ if (installed && configBlockStale(store))
1471
1526
  steps.push(() => installConfigBlock(store));
1472
1527
  steps.push(() => out.staged.commit());
1473
1528
  // ONE section from reading the anchor to committing the certificate
@@ -1653,11 +1708,10 @@ function processAlive(pid) {
1653
1708
  * move or remove the lock, so there is no window in which the file is
1654
1709
  * missing for something to slip in through.
1655
1710
  *
1656
- * And the takeover NEVER UNLINKS THE PATH. The first version verified the
1657
- * token, unlinked the lock and created its own, and nothing tied that unlink
1658
- * to the inode it had verified: a holder releasing in between let a third
1659
- * process create a fresh lock at the path, which the breaker then deleted.
1660
- * 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.
1661
1715
  * `path` keeps its inode throughout, so there is no moment at which the lock
1662
1716
  * at `path` can have become somebody else's between the check and the act:
1663
1717
  * the holder judged dead cannot release, every other breaker is behind the
@@ -1752,7 +1806,7 @@ export async function computeSSH(serviceName, opts, deps = {}) {
1752
1806
  assertAliasFree(before, alias, { projectId: p.projectId, serviceId: svc.id, branch });
1753
1807
  const store = {
1754
1808
  ...before,
1755
- [alias]: { projectId: p.projectId, ...(branch ? { branch } : {}), serviceId: svc.id, host: out.host, username: out.username },
1809
+ [alias]: { projectId: p.projectId, ...(branch ? { branch } : {}), serviceId: svc.id, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT },
1756
1810
  };
1757
1811
  // Whether ~/.ssh is BACKING this store, not merely whether --setup was
1758
1812
  // passed. The store and the certificate are rewritten by every issuance,
@@ -1811,9 +1865,9 @@ export async function computeSSH(serviceName, opts, deps = {}) {
1811
1865
  out.staged.discard();
1812
1866
  }
1813
1867
  if (opts.json)
1814
- return printJson({ alias, host: out.host, username: out.username, expiresAt: out.expiresAt, configured: installed });
1868
+ return printJson({ alias, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT, expiresAt: out.expiresAt, configured: installed });
1815
1869
  for (const line of sshAdvice({
1816
- alias, host: out.host, username: out.username, expiresAt: out.expiresAt, serviceName: svc.name,
1870
+ alias, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT, expiresAt: out.expiresAt, serviceName: svc.name,
1817
1871
  configured: installed, identityFile: instaKeyPath(), certificateFile: instaCertPath(alias),
1818
1872
  }))
1819
1873
  emit(line);
@@ -1830,7 +1884,7 @@ export async function computeSSH(serviceName, opts, deps = {}) {
1830
1884
  */
1831
1885
  export function sshAdvice(r) {
1832
1886
  const head = r.configured
1833
- ? [`ssh ${r.alias} → ${r.username}@${r.host}`]
1887
+ ? [`ssh ${r.alias} → ${r.username}@${r.host} (port ${r.port})`]
1834
1888
  : [
1835
1889
  // Every option here is load-bearing. The key lives at
1836
1890
  // ~/.insta/ssh/id_ed25519 and the certificate at <alias>-cert.pub;
@@ -1839,7 +1893,9 @@ export function sshAdvice(r) {
1839
1893
  // this command just issued -- it fails, having printed success.
1840
1894
  // IdentitiesOnly stops a loaded agent from spending the server's
1841
1895
  // MaxAuthTries on unrelated keys before ours is ever tried.
1842
- `ssh -i ${shQuote(r.identityFile)} -o CertificateFile=${shQuote(r.certificateFile)} -o IdentitiesOnly=yes ${shQuote(`${r.username}@${r.host}`)}`,
1896
+ // -p is not optional: the gateway is on :2222 and :22 is closed, so a
1897
+ // pasted command without it fails before the credential is ever tried.
1898
+ `ssh -p ${r.port} -i ${shQuote(r.identityFile)} -o CertificateFile=${shQuote(r.certificateFile)} -o IdentitiesOnly=yes ${shQuote(`${r.username}@${r.host}`)}`,
1843
1899
  ` run \`insta compute ssh ${r.serviceName} --setup\` once for the shorter \`ssh ${r.alias}\`, automatic renewal, and scp/-L support`,
1844
1900
  ];
1845
1901
  return [...head, ` certificate valid until ${r.expiresAt}`];
@@ -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();
@@ -162,27 +162,66 @@ async function readStdin() {
162
162
  data += chunk;
163
163
  return data.trim();
164
164
  }
165
+ // Every platform SkipReason (applyTargets.ts) except 'unknown-service', which means the plan
166
+ // could not resolve the service, not that it correctly chose to skip it.
167
+ const BENIGN_SKIP_REASONS = new Set(['no-image', 'other-branch', 'caller-deploying', 'skip-deploy']);
168
+ // Three outcomes: an unwritten entry is a hard failure; a written entry with a
169
+ // failed or unexplained-skip service is durable but not fully live; anything else is success.
170
+ export function applyVerdict(entries, services) {
171
+ if (entries.some((e) => !e.written))
172
+ return 'not-written';
173
+ const degraded = services.some((s) => s.result === 'failed' || (s.result === 'skipped' && !BENIGN_SKIP_REASONS.has(s.reason ?? '')));
174
+ return degraded ? 'degraded' : 'ok';
175
+ }
176
+ // util.ts already claims 1 (plain failure) and 2 (nothing ran, re-run is safe); this state fits
177
+ // neither, so it takes the next code rather than overload one of theirs.
178
+ export function applyExitCode(verdict) {
179
+ return verdict === 'ok' ? 0 : verdict === 'not-written' ? 1 : 3;
180
+ }
181
+ // Pure — one line per service, extending `branch.ts`'s create/skip convention; `started` matters most, it means new billing.
182
+ export function applyServiceLines(services) {
183
+ return services.map((s) => {
184
+ if (s.result === 'started')
185
+ return ` + ${s.serviceId} started (was stopped — this now bills)`;
186
+ if (s.result === 'deployed')
187
+ return ` ~ ${s.serviceId} redeployed`;
188
+ if (s.result === 'failed')
189
+ return ` ! ${s.serviceId} failed${s.reason ? `: ${s.reason}` : ''}`;
190
+ return ` = ${s.serviceId}${s.reason ? ` (${s.reason})` : ''}`;
191
+ });
192
+ }
165
193
  // Set a user secret. Project-wide by default; --branch scopes it to one branch. --service binds
166
194
  // it to a branch service instead, which implies the current branch (binding requires one). Value
167
195
  // comes from the argument, or stdin when omitted (keeps secret values out of shell history).
168
- export async function secretsSet(name, value, opts) {
196
+ export async function secretsSet(name, value, opts, deps) {
169
197
  // An empty --service must not fall through to a project-wide WRITE. The scoping test below is a
170
198
  // truthiness check, so `--service ''` (a client interpolating an absent variable) would have put
171
199
  // the secret at a WIDER scope than the caller asked for, visible to every service on the branch.
172
200
  assertServiceRef(opts.service);
173
- const api = await ApiClient.load();
174
- const p = await requireProject();
201
+ const d = deps ?? (await loadDeps());
175
202
  const v = value ?? (await readStdin());
176
203
  if (!v)
177
204
  die('value is required (pass as an argument or on stdin)');
178
- const branch = opts.service ? (opts.branch ?? p.branch) : opts.branch;
179
- const payload = { value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
180
- const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}`, payload);
205
+ const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
206
+ // The batch always deploys on ONE branch, so the top level falls back to the linked one even when the entry itself is project-wide.
207
+ const entry = { kind: 'set', name, value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
208
+ const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
181
209
  if (handleApproval(res, opts.json))
182
210
  return;
211
+ const entries = res.body.entries ?? [];
212
+ const services = res.body.services ?? [];
213
+ const verdict = applyVerdict(entries, services);
214
+ process.exitCode = applyExitCode(verdict);
183
215
  if (opts.json)
184
- return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
216
+ return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
185
217
  info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
218
+ for (const line of applyServiceLines(services))
219
+ info(line);
220
+ // Verdict note on stderr, after the per-service lines: not success, and not the same failure either.
221
+ if (verdict === 'not-written')
222
+ process.stderr.write(`error: ${name} was not written\n`);
223
+ else if (verdict === 'degraded')
224
+ process.stderr.write(`warning: ${name} is saved but not applied everywhere — retry the deploy for the services marked failed above, do not resend this value\n`);
186
225
  }
187
226
  // Remove a user secret. --service removes only THAT service's copy (the platform has always
188
227
  // honoured ?service= here; without the flag a name several services define stays defined).
@@ -192,21 +231,26 @@ export async function secretsUnset(name, opts, deps) {
192
231
  // Service scoping REQUIRES a branch (a service exists on a branch, so the platform rejects the
193
232
  // pair without one) — so --service defaults to the linked branch, exactly as `secrets set` does.
194
233
  const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
195
- const parts = [];
196
- if (branch)
197
- parts.push(`branch=${encodeURIComponent(branch)}`);
198
- if (opts.service)
199
- parts.push(`service=${encodeURIComponent(opts.service)}`);
200
- const qs = parts.length ? `?${parts.join('&')}` : '';
201
- const res = await d.api.rawRequest('DELETE', `/projects/${d.projectId}/secrets/${encodeURIComponent(name)}${qs}`);
234
+ const entry = { kind: 'delete', name, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
235
+ const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
202
236
  if (handleApproval(res, opts.json))
203
237
  return;
238
+ const entries = res.body.entries ?? [];
239
+ const services = res.body.services ?? [];
240
+ const verdict = applyVerdict(entries, services);
241
+ process.exitCode = applyExitCode(verdict);
204
242
  // The EFFECTIVE branch, not the flag: with --service and no --branch the scope that was deleted
205
243
  // is the linked branch's, and the output has to say which scope it actually touched.
206
244
  if (opts.json)
207
- return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
245
+ return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
208
246
  const scope = opts.service ? `${opts.service}, branch ${branch}` : branch ? `branch ${branch}` : 'project-wide';
209
247
  info(`unset ${name} (${scope})`);
248
+ for (const line of applyServiceLines(services))
249
+ info(line);
250
+ if (verdict === 'not-written')
251
+ process.stderr.write(`error: ${name} was not removed\n`);
252
+ else if (verdict === 'degraded')
253
+ process.stderr.write(`warning: ${name} is removed but not applied everywhere — retry the deploy for the services marked failed above\n`);
210
254
  }
211
255
  export async function secretsBind(envName, source, opts) {
212
256
  if (!opts.to)
@@ -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.
@@ -115,10 +115,12 @@ export function renderConfigBlock(o) {
115
115
  throw new Error(`refusing to write an unsafe ssh HostName into ssh_config: ${JSON.stringify(e.hostName)}`);
116
116
  if (!isSafeConfigValue(e.user))
117
117
  throw new Error(`refusing to write an unsafe ssh User into ssh_config: ${JSON.stringify(e.user)}`);
118
+ if (!Number.isInteger(e.port) || e.port < 1 || e.port > 65535)
119
+ throw new Error(`refusing to write an unusable ssh Port into ssh_config: ${JSON.stringify(e.port)}`);
118
120
  lines.push(`Host ${e.alias}`,
119
121
  // Without HostName and User the alias is not routing at all: ssh resolves
120
122
  // `api.insta` in DNS and logs in as the local OS username.
121
- ` HostName ${e.hostName}`, ` User ${e.user}`, ` IdentityFile ${quoteConfigPath(o.identityFile)}`, ` CertificateFile ${quoteConfigPath(e.certificateFile)}`,
123
+ ` HostName ${e.hostName}`, ` User ${e.user}`, ` Port ${e.port}`, ` IdentityFile ${quoteConfigPath(o.identityFile)}`, ` CertificateFile ${quoteConfigPath(e.certificateFile)}`,
122
124
  // WRITTEN, not left to the default, and this is the one keyword where
123
125
  // being first in the file does not save us. First-wins settles a keyword
124
126
  // two blocks both set; a keyword we never set at all goes on being filled
@@ -137,24 +139,29 @@ export function renderConfigBlock(o) {
137
139
  // IdentitiesOnly is not tidiness. SSH offers public keys ONE AT A TIME,
138
140
  // so a user with several keys is identified non-deterministically -- the
139
141
  // server sees whichever key happened to be offered first, which may not be
140
- // the one carrying our certificate. exe.dev calls this heisen-connect.
141
- // Without this line a developer with a full ssh-agent gets intermittent,
142
- // 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.
143
144
  ' IdentitiesOnly yes');
144
145
  // Connection multiplexing collapses scp, an IDE's several connections and a
145
146
  // second terminal onto ONE connection; without it a single developer can
146
147
  // reach the per-service session cap in an afternoon.
147
148
  //
148
- // 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:
149
158
  // Win32-OpenSSH does not implement ControlMaster (PowerShell/Win32-OpenSSH
150
- // #1328, #405) and fails the connection rather than ignoring the directive,
151
- // and the ControlPath itself contains a `:` before %p, which is not a legal
152
- // character in a Windows filename. Every alias would be unusable on a
153
- // platform this repo runs CI for. The effective-config tests need a real
154
- // `ssh` and skip on Windows, so this branch is asserted on the rendered
155
- // 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.
156
163
  if ((o.platform ?? process.platform) !== 'win32') {
157
- 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');
158
165
  }
159
166
  if (o.ensureCertCommand) {
160
167
  // Renewal happens while OpenSSH PARSES the config, before it connects, so
@@ -210,6 +217,35 @@ export function upsertConfigBlock(existing, block) {
210
217
  export function hasOwnedBlock(existing) {
211
218
  return existing.split('\n').some(isMarkerLine(BLOCK_BEGIN));
212
219
  }
220
+ /** Whether nothing that OpenSSH would read precedes the owned block. OpenSSH
221
+ * takes the FIRST obtained value for each keyword, so a `Host *` stanza -- or a
222
+ * bare global `Port 22` -- above our block silently overrides the block's
223
+ * Port, HostName, User and credential; that is why upsertConfigBlock writes
224
+ * the block at the top. Comments and blank lines above it are harmless and do
225
+ * not count. false when there is no owned block at all. */
226
+ export function ownedBlockIsFirst(existing) {
227
+ const lines = existing.split('\n');
228
+ const begin = lines.findIndex(isMarkerLine(BLOCK_BEGIN));
229
+ if (begin === -1)
230
+ return false;
231
+ return lines.slice(0, begin).every((l) => /^\s*(#.*)?$/.test(l));
232
+ }
233
+ /** The installed owned block, BEGIN through END marker inclusive, exactly as it
234
+ * sits in the file; undefined when there is none (or an unterminated one). It
235
+ * exists so a writer can compare what IS installed with what it WOULD render
236
+ * and touch the file only when the two differ -- a stanza written before a
237
+ * keyword existed (the Port line) is the case, and "nothing changed in the
238
+ * response" is not the same question as "nothing would change in the file". */
239
+ export function ownedBlock(existing) {
240
+ const lines = existing.split('\n');
241
+ const begin = lines.findIndex(isMarkerLine(BLOCK_BEGIN));
242
+ if (begin === -1)
243
+ return undefined;
244
+ const end = lines.findIndex((l, n) => n > begin && isMarkerLine(BLOCK_END)(l));
245
+ if (end === -1)
246
+ return undefined;
247
+ return lines.slice(begin, end + 1).join('\n');
248
+ }
213
249
  /** A marker is a WHOLE LINE, never a substring of one.
214
250
  *
215
251
  * Matching the marker text wherever it occurred made a user's comment that
@@ -297,11 +333,10 @@ function isEcdsaBody(curve, point, wantCurve, pointLen) {
297
333
  /** Exactly ONE OpenSSH public-key record: `<type> <base64>` with an optional
298
334
  * comment, and nothing else -- no second line, no leading directive.
299
335
  *
300
- * `renderCertAuthority` only trimmed, so an embedded newline in the CA value
301
- * smuggled additional known_hosts lines past it. Parsing to the three fields
302
- * we will actually write, and rebuilding the line from THOSE, means a value
303
- * either is one key record or is refused; there is no third outcome where
304
- * 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). */
305
340
  export function parseCAPublicKey(value) {
306
341
  if (typeof value !== 'string')
307
342
  throw new Error('the platform returned no ssh certificate authority key');
@@ -318,22 +353,18 @@ export function parseCAPublicKey(value) {
318
353
  if (!/^[A-Za-z0-9+/]+={0,3}$/.test(blob) || blob.length < 32) {
319
354
  throw new Error('refusing a certificate authority key whose body is not base64');
320
355
  }
321
- // The blob's OWN type must agree with the text field. Base64-shaped is not
322
- // the same as "is a key": an anchor built from a mislabelled or arbitrary
323
- // blob installs silently and then fails at connect time, where the message
324
- // points at known_hosts rather than at the response that produced it.
325
- // The WHOLE blob, not just its first field. A first-field check rejects
326
- // arbitrary base64 and still accepts a correct type name followed by noise --
327
- // and an anchor built from that installs silently, then fails at connect
328
- // time, where the message points at known_hosts rather than at the response
329
- // 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.
330
362
  const fields = sshBlobFields(blob);
331
363
  if (!fields || fields.length < 2 || fields[0].toString('utf8') !== type) {
332
364
  throw new Error(`refusing a certificate authority key whose body does not match its type ${JSON.stringify(type.slice(0, 32))}`);
333
365
  }
334
- // And the fields must be the ones THIS type has. ed25519 was the only type
335
- // held to its shape at first, so a correct RSA or ECDSA type name followed by
336
- // 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.
337
368
  if (!shape(fields)) {
338
369
  throw new Error(`refusing a certificate authority key whose body is not the shape of ${JSON.stringify(type.slice(0, 32))}`);
339
370
  }
@@ -631,8 +662,7 @@ export function planCertAuthority(existing, hostPattern, caKey) {
631
662
  export function revertCertAuthority(current, plan) {
632
663
  const kept = current.split('\n').filter((l) => l.trimEnd() !== plan.line);
633
664
  // Only the ONE trailing blank the split leaves behind a final newline. A
634
- // blank line before that is the user's -- trailing blanks included, which a
635
- // loop popping every empty tail line was deleting on the failure path.
665
+ // blank line before that is the user's -- trailing blanks included.
636
666
  if (kept.length > 0 && kept[kept.length - 1] === '')
637
667
  kept.pop();
638
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.77",
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": [