insta 0.0.48 → 0.0.51

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.
@@ -1,54 +1,354 @@
1
1
  import { ApiClient, ApiError, requireProject } from '../api.js';
2
2
  import { info, printJson, handleApproval } from '../util.js';
3
3
  import { resolveComputeServiceId, q, parseVolumeGib } from './services.js';
4
- // Attach a developer-owned custom domain to a branch's compute service. Fly issues the cert + routes
5
- // it; the platform returns the DNS records to set in your OWN zone.
6
- export async function setDomain(host, opts) {
7
- const api = await ApiClient.load();
8
- const p = await requireProject();
9
- const res = await api.rawRequest('POST', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group });
4
+ export const isWorker = (s) => s.port === 0;
5
+ // One line per compute service for the disambiguation error: name, region, default URL, status —
6
+ // enough to pick one without a second command. Pure, exported for tests.
7
+ export function computeChoiceLine(s, w = { name: 8, region: 12 }) {
8
+ const target = isWorker(s)
9
+ ? '(no HTTP endpoint — worker, cannot serve a domain)'
10
+ : `${s.domain ? `https://${s.domain}` : '(no default URL yet)'}${s.status ? ` (${s.status})` : ''}`;
11
+ return ` ${s.name.padEnd(w.name)} ${(s.region ?? '-').padEnd(w.region)} ${target}`;
12
+ }
13
+ // Which compute service a hostname binds to. Exactly one compute service → it, no flag needed.
14
+ // Several → never guess (a wrong pick routes the customer's hostname to the wrong app in the wrong
15
+ // region): refuse with the list and require --group. With --group: must exist and must not be a
16
+ // worker. Pure, exported for tests.
17
+ export function resolveDomainTarget(services, host, group) {
18
+ const compute = services.filter((s) => s.type === 'compute');
19
+ if (group) {
20
+ const svc = compute.find((s) => s.name === group);
21
+ if (!svc)
22
+ throw new Error(`compute service not found: ${group}${compute.length ? ` (have: ${compute.map((s) => s.name).join(', ')})` : ''}`);
23
+ if (isWorker(svc))
24
+ throw new Error(`${svc.name} is a worker (port 0) — it has no HTTP endpoint, so ${host} cannot serve from it`);
25
+ return svc;
26
+ }
27
+ if (compute.length === 0)
28
+ throw new Error('no compute service in this project (add one with `insta services add compute <name>`)');
29
+ if (compute.length === 1) {
30
+ const only = compute[0];
31
+ if (isWorker(only))
32
+ throw new Error(`${only.name} is a worker (port 0) — it has no HTTP endpoint, so ${host} cannot serve from it`);
33
+ return only;
34
+ }
35
+ const w = { name: Math.max(4, ...compute.map((s) => s.name.length)), region: Math.max(6, ...compute.map((s) => (s.region ?? '-').length)) };
36
+ throw new Error([
37
+ `this project has ${compute.length} compute services; pass --group to choose which one serves ${host}:`,
38
+ ...compute.map((s) => computeChoiceLine(s, w)),
39
+ ].join('\n'));
40
+ }
41
+ const targetOf = (r) => `${r.service ?? r.flyApp}${r.region ? ` (${r.region})` : ''}`;
42
+ const pad = (s, n) => s.padEnd(n);
43
+ // An older platform may omit `dns` entirely; every reader treats that as "no records", not a crash.
44
+ const recordsOf = (r) => r.dns ?? [];
45
+ const flags = (c = {}) => `${c.group ? ` --group ${c.group}` : ''}${c.branch ? ` --branch ${c.branch}` : ''}`;
46
+ // After set-domain: exactly what to do next, from the records the platform returned — never a
47
+ // hand-built template. No records = say so; a template here would send the customer publishing
48
+ // values the plane never issued. Pure, exported for tests.
49
+ export function domainGuidanceLines(r, ctx = {}) {
50
+ const records = recordsOf(r);
51
+ const out = [`${r.hostname} -> ${targetOf(r)}`];
52
+ if (!records.length) {
53
+ out.push(' the platform returned NO DNS records for this domain — the compute plane has no custom-domain CNAME target configured (or is misconfigured)');
54
+ out.push(' nothing to publish yet: ask an operator before adding any DNS record');
55
+ return out;
56
+ }
57
+ const nameW = Math.max(...records.map((d) => d.name.length));
58
+ out.push('add these DNS records at your DNS provider:');
59
+ for (const d of records)
60
+ out.push(` ${pad(d.type, 6)} ${pad(d.name, nameW)} -> ${d.value}`);
61
+ out.push(`then: insta compute check-domain ${r.hostname}${flags(ctx)}`);
62
+ return out;
63
+ }
64
+ // Whether this provider reports an edge routing target AT ALL. The compute plane's domain view
65
+ // always carries an `ssl` status, so a plane answer without `origin` is a daemon too old to report
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).
69
+ const reportsOrigin = (r) => r.ssl !== undefined;
70
+ // Where the hostname actually resolves — the region-specific origin the plane requested vs what
71
+ // Cloudflare holds. Each shape carries its action; absent fields are reported as absent.
72
+ export function domainResolveLine(r) {
73
+ const region = r.region ?? 'this region';
74
+ if (r.origin === undefined) {
75
+ if (reportsOrigin(r)) {
76
+ return {
77
+ line: ` ${pad('resolves to', 12)}UNCONFIRMED — ${region}'s daemon does not report the edge routing target, so where ${r.hostname} lands cannot be verified from here (update the region's daemon)`,
78
+ ready: false,
79
+ };
80
+ }
81
+ return { line: ` ${pad('resolves to', 12)}(this provider does not report an edge routing target)`, ready: true };
82
+ }
83
+ if (r.origin === '') {
84
+ return {
85
+ line: ` ${pad('resolves to', 12)}NOT READY — ${region} has no edge origin configured; ${r.hostname} would fall to the zone default. Ask an operator to set cf-custom-origin for ${region}`,
86
+ ready: false,
87
+ };
88
+ }
89
+ if (r.edgeOrigin && r.edgeOrigin !== r.origin) {
90
+ return {
91
+ line: ` ${pad('resolves to', 12)}${r.edgeOrigin} — Cloudflare routes ${r.hostname} to ${r.edgeOrigin}, but this service is in ${region} (${r.origin}); it is attached elsewhere — remove it there first`,
92
+ ready: false,
93
+ };
94
+ }
95
+ if (r.originOk === false) {
96
+ return { line: ` ${pad('resolves to', 12)}${r.origin} (${region} router) pending — Cloudflare does not hold this hostname yet`, ready: false };
97
+ }
98
+ return { line: ` ${pad('resolves to', 12)}${r.origin} (${region} router) ok`, ready: true };
99
+ }
100
+ // check-domain: every stage, what each still needs, and where it routes. Pure, exported for tests.
101
+ export function domainStatusLines(r, ctx = {}) {
102
+ if (r.status === 'not added') {
103
+ return [`${r.hostname} is not attached to ${targetOf(r)} — attach it with: insta compute set-domain ${r.hostname}${flags(ctx)}`];
104
+ }
105
+ const records = recordsOf(r);
106
+ const out = [`${r.hostname} -> ${targetOf(r)}`];
107
+ const txt = records.find((d) => d.type === 'TXT');
108
+ // The ROUTING records for the hostname, whatever type they take: a CNAME for a subdomain, or the
109
+ // A/AAAA PAIR an apex needs (Fly's apex path emits both). All of them, not the first one —
110
+ // a correct A beside a missing AAAA is not "routing is fine".
111
+ const isRouting = (d) => d.type !== 'TXT' && d.name === r.hostname;
112
+ const routing = records.filter(isRouting);
113
+ const blockers = [];
114
+ const stage = (label, state, detail) => out.push(` ${pad(label, 12)}${pad(state, 10)}${detail ? ` ${detail}` : ''}`);
115
+ // The ONE verdict rule, applied to EVERY record the platform returned regardless of its role.
116
+ // A record is settled only when the platform says `ok` — or, for a provider that reports no
117
+ // per-record status at all (Fly), when it vouched for the whole set with `configured`. missing,
118
+ // 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.
122
+ const verdictOf = (d) => d.status ?? (r.configured ? 'ok' : 'unchecked');
123
+ if (txt) {
124
+ const st = verdictOf(txt);
125
+ if (st === 'ok')
126
+ stage('ownership', 'verified', '(TXT found)');
127
+ else if (st === 'mismatch') {
128
+ stage('ownership', 'mismatch', `TXT ${txt.name} has a different value — set it to ${txt.value}`);
129
+ blockers.push('fix the ownership TXT');
130
+ }
131
+ else if (st === 'missing') {
132
+ stage('ownership', 'pending', `add TXT ${txt.name} -> ${txt.value}`);
133
+ blockers.push('add the ownership TXT');
134
+ }
135
+ else {
136
+ stage('ownership', 'unchecked', `TXT ${txt.name} -> ${txt.value} (the plane has not checked it yet — re-run check-domain)`);
137
+ blockers.push('ownership unchecked');
138
+ }
139
+ }
140
+ else if (reportsOrigin(r)) {
141
+ // 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
143
+ // proves ownership by TXT, so only a plane answer missing one is a problem.
144
+ stage('ownership', 'unknown', 'the platform returned no ownership TXT for this domain — nothing to publish yet; ask an operator');
145
+ blockers.push('no ownership TXT from the platform');
146
+ }
147
+ else {
148
+ stage('ownership', 'n/a', '(this provider does not use an ownership TXT)');
149
+ }
150
+ 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.
155
+ stage('cname', 'unknown', 'the platform returned no routing record for this domain — nothing to publish yet; ask an operator');
156
+ blockers.push('no routing record from the platform');
157
+ }
158
+ for (const d of routing) {
159
+ const st = verdictOf(d);
160
+ // The stage is named for the record the platform actually issued, so an apex's A record is not
161
+ // described to the user as a CNAME they cannot create.
162
+ const lbl = d.type.toLowerCase();
163
+ if (st === 'ok')
164
+ stage(lbl, 'ok', `(points at ${d.value})`);
165
+ else if (st === 'mismatch') {
166
+ stage(lbl, 'mismatch', `${d.type} ${d.name} must point at ${d.value}`);
167
+ blockers.push(`fix the ${d.type}`);
168
+ }
169
+ else if (st === 'missing') {
170
+ stage(lbl, 'pending', `add ${d.type} ${d.name} -> ${d.value}`);
171
+ blockers.push(`add the ${d.type}`);
172
+ }
173
+ else {
174
+ stage(lbl, 'unchecked', `${d.type} ${d.name} -> ${d.value} (not checked yet — re-run check-domain)`);
175
+ blockers.push(`${d.type} unchecked`);
176
+ }
177
+ }
178
+ // Everything else the platform returned — a Let's Encrypt validation CNAME, any extra record.
179
+ // Same rule, no exemption: an outstanding record is outstanding whatever its role.
180
+ for (const d of records) {
181
+ if (d === txt || isRouting(d))
182
+ continue;
183
+ const st = verdictOf(d);
184
+ const lbl = d.type.toLowerCase();
185
+ const where = `${d.name} -> ${d.value}${d.note ? ` (${d.note})` : ''}`;
186
+ if (st === 'ok')
187
+ stage(lbl, 'ok', where);
188
+ else if (st === 'mismatch') {
189
+ stage(lbl, 'mismatch', `${d.type} ${d.name} must point at ${d.value}`);
190
+ blockers.push(`fix the ${d.type} ${d.name}`);
191
+ }
192
+ else if (st === 'missing') {
193
+ stage(lbl, 'pending', `add ${where}`);
194
+ blockers.push(`add the ${d.type} ${d.name}`);
195
+ }
196
+ else {
197
+ stage(lbl, 'unchecked', `${where} (not checked yet — re-run check-domain)`);
198
+ blockers.push(`${d.type} ${d.name} unchecked`);
199
+ }
200
+ }
201
+ const ssl = r.ssl ?? (r.configured ? 'active' : undefined);
202
+ if (ssl === 'active')
203
+ stage('certificate', 'active', '(edge TLS issued)');
204
+ else if (ssl === 'external')
205
+ stage('certificate', 'external', '(this plane manages no edge certificate for custom domains)');
206
+ else if (ssl === undefined) {
207
+ stage('certificate', 'pending', `(provider status: ${r.status})`);
208
+ blockers.push('certificate');
209
+ }
210
+ else {
211
+ stage('certificate', 'pending', `(${ssl} — issues once ownership is verified)`);
212
+ blockers.push('certificate');
213
+ }
214
+ const resolve = domainResolveLine(r);
215
+ 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
217
+ // that says `error` has not been observed serving, and saying otherwise is the blackhole lie.
218
+ if (r.status === 'error') {
219
+ stage('error', r.status, r.errorReason || '(the plane reported an error state with no reason)');
220
+ blockers.push('the plane reports an error state');
221
+ }
222
+ else if (r.errorReason) {
223
+ stage('error', r.status, r.errorReason);
224
+ blockers.push(r.errorReason);
225
+ }
226
+ // An unconfirmed routing target is one blocker among the others, not a headline that hides them:
227
+ // a user fixing their DNS needs the whole outstanding list, not whichever item sorted first.
228
+ if (!resolve.ready)
229
+ blockers.push('confirm the routing target above');
230
+ // `serving` is claimed only when every stage above agreed: the provider says configured, the
231
+ // routing target is confirmed, and NOTHING is outstanding. A blocker beside a `configured: true`
232
+ // 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).
234
+ if (r.configured && blockers.length === 0)
235
+ stage('serving', `https://${r.hostname}`, '');
236
+ else
237
+ stage('serving', 'not yet', blockers.length ? `(${blockers.join(', ')})` : `(${r.status})`);
238
+ return out;
239
+ }
240
+ // The platform's 409: the hostname is already bound elsewhere. Domains are never MOVED — the only
241
+ // path is unbind there, then bind here — so the hint names the release step. Three shapes:
242
+ // owner named and present in this project → the exact remove-domain command;
243
+ // owner named but NOT in this project's services → it is held by a deleted (or other-project)
244
+ // service: an operator must release it (the plane has no self-serve orphan release yet);
245
+ // owner not named (today's plane) → the generic release instruction.
246
+ // Pure, exported for tests.
247
+ export function domainConflictMessage(host, e, services, ctx = {}) {
248
+ const m = /already attached to (\S+)(?: in (\S+))?;/.exec(e.message);
249
+ const owner = m?.[1] && m[1] !== 'another' ? m[1] : undefined;
250
+ const region = m?.[2];
251
+ // 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).
253
+ const release = (group) => `insta compute remove-domain ${host}${flags({ group, branch: ctx.branch })}`;
254
+ if (owner) {
255
+ const here = services.find((s) => s.type === 'compute' && s.name === owner);
256
+ if (here)
257
+ return `${host} is already attached to ${owner}${region ? ` (${region})` : here.region ? ` (${here.region})` : ''} — domains are not moved; release it first: ${release(owner)}, then re-run set-domain`;
258
+ return `${host} is already attached to ${owner}${region ? ` in ${region}` : ''}, which is not a service in this project — it is held by a deleted service (or one in another project); ask an operator to release the hostname before re-binding it`;
259
+ }
260
+ return `${host} is already attached to another compute service — domains are not moved; release it there first (${release('<that service>')}) or, if that service was deleted, ask an operator to release the hostname`;
261
+ }
262
+ // Resolve branch + target service, so every domain verb names the service AND its region, and an
263
+ // ambiguous project is refused with the list instead of the platform's `default` fallback picking
264
+ // one silently.
265
+ async function domainTarget(api, projectId, branch, host, group) {
266
+ const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
267
+ return { target: resolveDomainTarget(services, host, group), services };
268
+ }
269
+ async function domainDeps(deps) {
270
+ if (deps)
271
+ return deps;
272
+ const [api, project] = [await ApiClient.load(), await requireProject()];
273
+ return { api, project };
274
+ }
275
+ // Attach a developer-owned custom domain to a branch's compute service. The plane issues the edge
276
+ // cert + routes the hostname in the service's region; the platform returns the DNS records to set
277
+ // in your OWN zone, which are printed verbatim as the next step.
278
+ export async function setDomain(host, opts, deps) {
279
+ const { api, project: p } = await domainDeps(deps);
280
+ const branch = opts.branch ?? p.branch;
281
+ const { target, services } = await domainTarget(api, p.projectId, branch, host, opts.group);
282
+ const ctx = { group: target.name, branch: opts.branch };
283
+ let res;
284
+ try {
285
+ res = await api.rawRequest('POST', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch, group: target.name });
286
+ }
287
+ catch (e) {
288
+ throw e instanceof ApiError && e.status === 409 ? new Error(domainConflictMessage(host, e, services, ctx)) : e;
289
+ }
10
290
  if (handleApproval(res, opts.json))
11
291
  return;
12
- printDomain(res.body, opts.json);
292
+ if (opts.json)
293
+ return printJson(res.body);
294
+ for (const line of domainGuidanceLines(withRow(res.body, target), ctx))
295
+ info(line);
13
296
  }
14
- // Re-check a custom domain's cert status + required DNS records.
15
- export async function checkDomain(host, opts) {
16
- const api = await ApiClient.load();
17
- const p = await requireProject();
18
- const qs = new URLSearchParams({ hostname: host });
19
- if (opts.branch ?? p.branch)
20
- qs.set('branch', opts.branch ?? p.branch);
21
- if (opts.group)
22
- qs.set('group', opts.group);
23
- printDomain(await api.request('GET', `/projects/${p.projectId}/compute/domain?${qs}`), opts.json);
24
- }
25
- export async function removeDomain(host, opts) {
26
- const api = await ApiClient.load();
27
- const p = await requireProject();
28
- const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group });
297
+ // Re-check a custom domain: every stage (ownership TXT, routing CNAME, edge certificate, where it
298
+ // resolves) and what each still needs.
299
+ export async function checkDomain(host, opts, deps) {
300
+ const { api, project: p } = await domainDeps(deps);
301
+ const branch = opts.branch ?? p.branch;
302
+ const { target, services } = await domainTarget(api, p.projectId, branch, host, opts.group);
303
+ const ctx = { group: target.name, branch: opts.branch };
304
+ const qs = new URLSearchParams({ hostname: host, group: target.name });
305
+ if (branch)
306
+ qs.set('branch', branch);
307
+ let r;
308
+ try {
309
+ r = await api.request('GET', `/projects/${p.projectId}/compute/domain?${qs}`);
310
+ }
311
+ catch (e) {
312
+ throw e instanceof ApiError && e.status === 409 ? new Error(domainConflictMessage(host, e, services, ctx)) : e;
313
+ }
314
+ if (opts.json)
315
+ return printJson(r);
316
+ for (const line of domainStatusLines(withRow(r, target), ctx))
317
+ info(line);
318
+ }
319
+ export async function removeDomain(host, opts, deps) {
320
+ const { api, project: p } = await domainDeps(deps);
321
+ const branch = opts.branch ?? p.branch;
322
+ const { target } = await domainTarget(api, p.projectId, branch, host, opts.group);
323
+ const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch, group: target.name });
29
324
  if (handleApproval(res, opts.json))
30
325
  return;
31
- renderRemoveDomain(res.body, opts.json);
326
+ renderRemoveDomain(res.body, opts.json, target);
327
+ }
328
+ // An older platform answers without service/region; the CLI already holds the row it resolved the
329
+ // target from, so the human lines never lose the region. --json stays the platform body verbatim.
330
+ export function withRow(r, row) {
331
+ return { ...r, service: r.service ?? row.name, region: r.region ?? row.region ?? null };
32
332
  }
33
333
  // Split out (same pattern as applyExecResult) so the --json contract — stdout carries the platform
34
334
  // response, never prose — is unit-testable without a network mock.
35
- export function renderRemoveDomain(body, json) {
335
+ export function renderRemoveDomain(body, json, row) {
36
336
  if (json)
37
337
  return printJson(body);
38
- info(`removed custom domain ${body.hostname} from ${body.flyApp}`);
338
+ const region = body.region ?? row?.region;
339
+ info(`removed custom domain ${body.hostname} from ${body.service ?? row?.name ?? body.flyApp}${region ? ` (${region})` : ''}`);
39
340
  }
40
- function printDomain(r, json) {
41
- if (json)
42
- return printJson(r);
43
- info(`${r.hostname} → ${r.flyApp}`);
44
- info(` status: ${r.status}${r.configured ? ' ✓ configured' : ''}`);
45
- if (r.dns?.length) {
46
- info(' set these DNS records at your domain registrar:');
47
- for (const d of r.dns)
48
- info(` ${String(d.type).padEnd(5)} ${d.name} → ${d.value}${d.note ? ` # ${d.note}` : ''}`);
341
+ // The line a lifecycle verb prints. restart gets its own wording: `running` is a PRECONDITION of a
342
+ // restart (the platform refuses it in any other desired state), so echoing desired_state back says
343
+ // nothing — what the operator needs is which image came back up and whether it is live. Pure,
344
+ // exported for tests.
345
+ export function lifecycleLine(verb, fallbackName, body) {
346
+ const name = body.service?.name ?? fallbackName;
347
+ if (verb === 'restart') {
348
+ const image = body.service?.image ? ` on ${body.service.image}` : '';
349
+ return `restarted compute ${name}${image} — env re-resolved from the current secrets (live: ${body.state})`;
49
350
  }
50
- if (!r.configured)
51
- info(' once DNS propagates, Fly issues the cert — re-check with `insta compute check-domain`');
351
+ return `compute ${name}: ${verb} → desired=${body.service?.desired_state} (live: ${body.state})`;
52
352
  }
53
353
  async function lifecycle(verb, serviceName, opts) {
54
354
  const api = await ApiClient.load();
@@ -61,11 +361,12 @@ async function lifecycle(verb, serviceName, opts) {
61
361
  return;
62
362
  if (opts.json)
63
363
  return printJson(res.body);
64
- info(`compute ${res.body.service?.name ?? id}: ${verb} → desired=${res.body.service?.desired_state} (live: ${res.body.state})`);
364
+ info(lifecycleLine(verb, id, res.body));
65
365
  }
66
366
  export const computeStart = (service, opts) => lifecycle('start', service, opts);
67
367
  export const computeStop = (service, opts) => lifecycle('stop', service, opts);
68
368
  export const computeSuspend = (service, opts) => lifecycle('suspend', service, opts);
369
+ export const computeRestart = (service, opts) => lifecycle('restart', service, opts);
69
370
  export async function computeStatus(serviceName, opts) {
70
371
  const api = await ApiClient.load();
71
372
  const p = await requireProject();
@@ -86,14 +387,119 @@ export async function computeStatus(serviceName, opts) {
86
387
  // ourselves, before commander ever parses it, removes the ambiguity; this is the only place in the
87
388
  // whole CLI a bare `--` has this meaning, so nothing else is affected. Exported for a direct,
88
389
  // network-free unit test — this split is the seam most likely to regress.
89
- export function splitExecArgs(argv) {
90
- const i = argv.findIndex((a, idx) => a === 'compute' && argv[idx + 1] === 'exec');
390
+ /** The options `insta compute exec` declares — the one source of truth. index.ts builds the
391
+ * commander command from this list, and the payload scan below uses it to know where the CLI's
392
+ * own arguments stop. Adding an option here reaches both. */
393
+ export const EXEC_OPTIONS = [
394
+ ['--branch <b>'],
395
+ ['--timeout <sec>', 'command timeout in seconds, 1-180 (platform default: 30)'],
396
+ ['--json'],
397
+ ];
398
+ const names = (flags) => flags.split(/[ ,|]+/).filter((t) => t.startsWith('-'));
399
+ const TAKES_VALUE = new Set(EXEC_OPTIONS.filter(([f]) => /[<[]/.test(f)).flatMap(([f]) => names(f)));
400
+ const BARE = new Set(EXEC_OPTIONS.filter(([f]) => !/[<[]/.test(f)).flatMap(([f]) => names(f)));
401
+ const isExecOption = (t) => TAKES_VALUE.has(t) || BARE.has(t) || (t.includes('=') && TAKES_VALUE.has(t.slice(0, t.indexOf('='))));
402
+ const isHelp = (t) => t === '--help' || t === '-h';
403
+ // Indices of the tokens after `compute exec` that are NOT this command's own options. Used twice,
404
+ // for two different questions, which is why it returns positions rather than a partition:
405
+ // - how many operands sit ahead of a `--` (is that `--` where a real separator could be?)
406
+ // - where the payload starts once the separator is gone
407
+ // Note what it is never used for: reaching INSIDE the payload. Past its first token the remote
408
+ // command may have begun, and `--json` there is the command's own argument, not ours.
409
+ function operandIndices(argv, from, to = argv.length) {
410
+ const out = [];
411
+ for (let cursor = from; cursor < to; cursor++) {
412
+ const token = argv[cursor];
413
+ if (TAKES_VALUE.has(token)) {
414
+ cursor++;
415
+ continue;
416
+ }
417
+ if (isExecOption(token))
418
+ continue;
419
+ out.push(cursor);
420
+ }
421
+ return out;
422
+ }
423
+ // Where does THIS process's `compute exec` command start? Only the command path counts: `compute`
424
+ // and `exec` appearing later are payload for something else — `insta run -- compute exec app echo`
425
+ // hands those words to a LOCAL child, and rewriting argv there would eat the child's last
426
+ // argument. argv[0] and argv[1] are the runtime and this script, verified to hold for the released
427
+ // Bun standalone binary too (its `process.argv` is `["bun", "/$bunfs/root/insta", …]`), which is
428
+ // the offset commander's own parse assumes. Returns -1 for "not ours".
429
+ function execCommandIndex(argv) {
430
+ for (let cursor = 2; cursor < argv.length; cursor++) {
431
+ const token = argv[cursor];
432
+ if (token.startsWith('-'))
433
+ return -1; // a global flag, or `--`: either way not our command path
434
+ return token === 'compute' && argv[cursor + 1] === 'exec' ? cursor : -1;
435
+ }
436
+ return -1;
437
+ }
438
+ // `insta compute exec [service] -- <command> [args…]`: the command must reach the platform
439
+ // byte-for-byte and can itself contain dashes or another `--`, so it cannot be a normal commander
440
+ // positional — with `service` optional, commander cannot tell "no service, command starts here"
441
+ // from "service IS the first command token". Splitting argv ourselves, before commander parses,
442
+ // removes the ambiguity. Exported for a direct, network-free unit test.
443
+ export function splitExecArgs(argv, platform = process.platform) {
444
+ const i = execCommandIndex(argv);
91
445
  if (i === -1)
92
446
  return { argv };
93
447
  const dash = argv.indexOf('--', i + 2);
94
- if (dash === -1)
448
+ if (dash !== -1) {
449
+ // A separator that survived has at most ONE operand ahead of it — the optional service. Two or
450
+ // more mean this `--` is the remote command's own: npm's PowerShell shim strips only the first,
451
+ // so the real separator is already gone. Options are skipped wherever they sit, because with an
452
+ // intact `--` everything ahead of it belongs to the CLI.
453
+ if (platform !== 'win32' || operandIndices(argv, i + 2, dash).length <= 1) {
454
+ return { argv: argv.slice(0, dash), command: argv.slice(dash + 1) };
455
+ }
456
+ }
457
+ if (platform !== 'win32')
95
458
  return { argv };
96
- return { argv: argv.slice(0, dash), command: argv.slice(dash + 1) };
459
+ // The separator is gone. Everything from the first operand is PAYLOAD — the optional service plus
460
+ // the remote command — and nothing inside it is touched, so the command keeps its own flags AND
461
+ // its own `--json`/`--branch`. Options can only be recognised AHEAD of it; one stranded behind
462
+ // the service is reported by resolveExecFallback rather than silently applied or dropped.
463
+ const start = operandIndices(argv, i + 2)[0];
464
+ if (start === undefined)
465
+ return { argv }; // nothing to recover; no service-list round-trip needed
466
+ const payload = argv.slice(start);
467
+ if (isHelp(payload[0]))
468
+ return { argv }; // `insta compute exec --help` — local, and never remote
469
+ return { argv: argv.slice(0, start), command: payload, windowsFallback: true };
470
+ }
471
+ // The separator is gone, so the payload arrives undivided and only the service list can split it:
472
+ // `insta compute exec -- printenv PORT` and `insta compute exec printenv PORT` are byte-identical
473
+ // by the time they reach us. The reading taken is STATED on stderr — stdout stays clean for
474
+ // --json — because it is a guess in both directions: a service named `echo` would swallow the
475
+ // executable, and a mistyped service name is demoted to argv[0] and run remotely.
476
+ export function resolveExecFallback(services, payload, note = (msg) => process.stderr.write(`${msg}\n`)) {
477
+ const [head, ...rest] = payload;
478
+ if (head === undefined)
479
+ return { serviceName: undefined, command: [] };
480
+ if (!services.some((service) => service.type === 'compute' && service.name === head)) {
481
+ note(`note: no \`--\` separator was found and \`${head}\` is not a compute service, so it was read as the command. If \`${head}\` was the service, check the name with \`insta services list\`.`);
482
+ return { serviceName: undefined, command: payload };
483
+ }
484
+ // `head` really is a service, so whatever follows it cannot be the command's first token.
485
+ // A help flag there is a request for THIS command's help, which commander already answered for
486
+ // every other shape; say where to get it rather than exec `-h` on the machine.
487
+ if (isHelp(rest[0]))
488
+ throw new Error(`\`${rest[0]}\` after a service name is not a command — run \`insta compute exec --help\` for this command's help, or \`--\` before a remote command`);
489
+ // A CLI option there landed on the wrong side of a separator that is not present. It cannot be
490
+ // honoured this late — --branch and --timeout are already spent by the time the service list
491
+ // arrives — so it is reported instead of being silently dropped or exec'd as a program.
492
+ if (rest[0]?.startsWith('-')) {
493
+ throw new Error(`no \`--\` separator was found before \`${rest[0]}\` — put CLI options ahead of [service], or add \`--\` before the command: insta compute exec ${head} -- <command> [args…]`);
494
+ }
495
+ // The reading is a guess in both directions — a service named `echo` would swallow the
496
+ // executable — so state it. The escape hatch names insta.cmd: pasting a plain `--` back into the
497
+ // same PowerShell session would be eaten exactly as the first one was. The command itself is NOT
498
+ // echoed; remote argv can carry tokens and passwords, and the user already has it on screen.
499
+ if (rest.length > 0) {
500
+ note(`note: no \`--\` separator was found; read \`${head}\` as the compute service. If \`${head}\` was part of the command, re-run it through insta.cmd, which keeps \`--\`: insta.cmd compute exec -- <command>`);
501
+ }
502
+ return { serviceName: head, command: rest };
97
503
  }
98
504
  // The --timeout override, through a throwing parser like every other user-typed number in this
99
505
  // repo (parseCpu, parseCount, parsePort): junk must fail locally instead of reaching the server as
@@ -149,16 +555,23 @@ export function applyExecResult(res, json) {
149
555
  // code becomes this process's own exit code (--json still passes it through, it just skips the
150
556
  // split-stream output), since agents scripting this rely on it. Waking a scaled-to-zero machine is
151
557
  // expected — it adds latency and bills as uptime, it is not an error.
152
- export async function computeExec(serviceName, command, opts) {
153
- if (!command || command.length === 0)
558
+ export async function computeExec(serviceName, command, opts, recovery = {}) {
559
+ if (!recovery.windowsFallback && (!command || command.length === 0)) {
154
560
  throw new Error('usage: insta compute exec [service] -- <command> [args…] (see --help)');
561
+ }
155
562
  const timeoutSec = opts.timeout !== undefined ? parseTimeoutSec(opts.timeout) : undefined;
156
563
  const api = await ApiClient.load();
157
564
  const p = await requireProject();
158
565
  const branch = opts.branch ?? p.branch;
159
566
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
160
- const id = resolveComputeServiceId(services, serviceName);
161
- const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/exec`, execRequestBody(command, timeoutSec));
567
+ const target = recovery.windowsFallback
568
+ ? resolveExecFallback(services, command ?? [])
569
+ : { serviceName, command };
570
+ if (!target.command || target.command.length === 0) {
571
+ throw new Error('usage: insta compute exec [service] -- <command> [args…] (see --help)');
572
+ }
573
+ const id = resolveComputeServiceId(services, target.serviceName);
574
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/exec`, execRequestBody(target.command, timeoutSec));
162
575
  applyExecResult(res, opts.json);
163
576
  }
164
577
  // ---- always-on (opt out of scale-to-zero; all plans; billing is actual usage either way) ----