insta 0.0.48 → 0.0.49

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.
@@ -154,14 +154,31 @@ export async function buildReport(dirArg, opts, deps) {
154
154
  const envExample = join(dir, '.env.example');
155
155
  const envKeys = existsSync(envExample) ? envKeysFromDotEnvExample(readFileSync(envExample, 'utf8')) : [];
156
156
  const checks = [];
157
- checks.push({
158
- id: 'dockerfile',
159
- severity: 'critical',
160
- status: dockerfile.source ? 'pass' : 'fail',
161
- title: 'Dockerfile',
162
- detail: dockerfileDetail,
163
- ...(dockerfile.source ? {} : { nextAction: `add a Dockerfile at ${userDockerfilePath}, or install nixpacks (https://nixpacks.com/docs/install) so insta can generate one` }),
164
- });
157
+ // Only a Dockerfile IN the directory passes. A nixpacks-generated one is a real plan, but it is
158
+ // not a plan `insta deploy <dir>` can execute: that path builds the directory's own Dockerfile and
159
+ // dies without one, and the nixpacks lane runs server-side for GitHub-connected repos only. This
160
+ // check used to pass on the generated Dockerfile, so a verifier said "deployable" about a
161
+ // directory `deploy` refuses — the whole point of the command is to not do that.
162
+ checks.push(dockerfile.source === 'nixpacks'
163
+ ? {
164
+ id: 'dockerfile',
165
+ severity: 'warning',
166
+ status: 'fail',
167
+ title: 'Dockerfile',
168
+ detail: `${dockerfileDetail} — but \`insta deploy <dir>\` builds the directory's own Dockerfile; the nixpacks lane runs server-side for GitHub-connected repos only`,
169
+ // NOT "save the generated Dockerfile here": it is not standalone (it COPYs the
170
+ // .nixpacks/nixpkgs-<hash>.nix support files nixpacks writes beside it, which this
171
+ // directory does not have). The detected commands above are the reusable part.
172
+ nextAction: `to deploy this directory, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point; or connect the repo on GitHub to use the nixpacks lane`,
173
+ }
174
+ : {
175
+ id: 'dockerfile',
176
+ severity: 'critical',
177
+ status: dockerfile.source ? 'pass' : 'fail',
178
+ title: 'Dockerfile',
179
+ detail: dockerfileDetail,
180
+ ...(dockerfile.source ? {} : { nextAction: `add a Dockerfile at ${userDockerfilePath}, or install nixpacks (https://nixpacks.com/docs/install) so insta can show you the one it would generate` }),
181
+ });
165
182
  if (builder === 'dockerfile') {
166
183
  const hasCmd = /^\s*(CMD|ENTRYPOINT)\s/im.test(dockerfile.content ?? '');
167
184
  checks.push({
@@ -207,7 +224,10 @@ const MARK = { pass: '✓', fail: '✗', skip: '·' };
207
224
  export function renderReport(r, explain) {
208
225
  const lines = [];
209
226
  lines.push(`plan for ${r.dir}:`);
210
- lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}`);
227
+ // The builder line is the first thing read (and the thing an agent scrapes), so it carries the
228
+ // lane caveat too — "builder: nixpacks" on its own reads as a promise `insta deploy <dir>` breaks.
229
+ const lane = r.plan.builder === 'nixpacks' ? ' — GitHub lane only; `insta deploy <dir>` needs a Dockerfile' : '';
230
+ lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}${lane}`);
211
231
  if (r.plan.installCommand)
212
232
  lines.push(` install: ${r.plan.installCommand}`);
213
233
  if (r.plan.buildCommand)
@@ -226,6 +246,11 @@ export function renderReport(r, explain) {
226
246
  }
227
247
  if (explain && r.dockerfile.content) {
228
248
  lines.push(`dockerfile (${r.dockerfile.source}):`);
249
+ // A nixpacks Dockerfile is shown for inspection, NOT for copying: it COPYs the
250
+ // .nixpacks/nixpkgs-<hash>.nix support files nixpacks generates beside it, so saving this text
251
+ // alone as ./Dockerfile produces a build that fails on the missing COPY.
252
+ if (r.dockerfile.source === 'nixpacks')
253
+ lines.push(' # for inspection — not standalone: it COPYs .nixpacks/ support files generated alongside it');
229
254
  for (const l of r.dockerfile.content.trimEnd().split('\n'))
230
255
  lines.push(` ${l}`);
231
256
  }
@@ -1,54 +1,342 @@
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}`);
39
- }
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}` : ''}`);
49
- }
50
- if (!r.configured)
51
- info(' once DNS propagates, Fly issues the cert — re-check with `insta compute check-domain`');
338
+ const region = body.region ?? row?.region;
339
+ info(`removed custom domain ${body.hostname} from ${body.service ?? row?.name ?? body.flyApp}${region ? ` (${region})` : ''}`);
52
340
  }
53
341
  async function lifecycle(verb, serviceName, opts) {
54
342
  const api = await ApiClient.load();
@@ -86,14 +374,119 @@ export async function computeStatus(serviceName, opts) {
86
374
  // ourselves, before commander ever parses it, removes the ambiguity; this is the only place in the
87
375
  // whole CLI a bare `--` has this meaning, so nothing else is affected. Exported for a direct,
88
376
  // 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');
377
+ /** The options `insta compute exec` declares — the one source of truth. index.ts builds the
378
+ * commander command from this list, and the payload scan below uses it to know where the CLI's
379
+ * own arguments stop. Adding an option here reaches both. */
380
+ export const EXEC_OPTIONS = [
381
+ ['--branch <b>'],
382
+ ['--timeout <sec>', 'command timeout in seconds, 1-180 (platform default: 30)'],
383
+ ['--json'],
384
+ ];
385
+ const names = (flags) => flags.split(/[ ,|]+/).filter((t) => t.startsWith('-'));
386
+ const TAKES_VALUE = new Set(EXEC_OPTIONS.filter(([f]) => /[<[]/.test(f)).flatMap(([f]) => names(f)));
387
+ const BARE = new Set(EXEC_OPTIONS.filter(([f]) => !/[<[]/.test(f)).flatMap(([f]) => names(f)));
388
+ const isExecOption = (t) => TAKES_VALUE.has(t) || BARE.has(t) || (t.includes('=') && TAKES_VALUE.has(t.slice(0, t.indexOf('='))));
389
+ const isHelp = (t) => t === '--help' || t === '-h';
390
+ // Indices of the tokens after `compute exec` that are NOT this command's own options. Used twice,
391
+ // for two different questions, which is why it returns positions rather than a partition:
392
+ // - how many operands sit ahead of a `--` (is that `--` where a real separator could be?)
393
+ // - where the payload starts once the separator is gone
394
+ // Note what it is never used for: reaching INSIDE the payload. Past its first token the remote
395
+ // command may have begun, and `--json` there is the command's own argument, not ours.
396
+ function operandIndices(argv, from, to = argv.length) {
397
+ const out = [];
398
+ for (let cursor = from; cursor < to; cursor++) {
399
+ const token = argv[cursor];
400
+ if (TAKES_VALUE.has(token)) {
401
+ cursor++;
402
+ continue;
403
+ }
404
+ if (isExecOption(token))
405
+ continue;
406
+ out.push(cursor);
407
+ }
408
+ return out;
409
+ }
410
+ // Where does THIS process's `compute exec` command start? Only the command path counts: `compute`
411
+ // and `exec` appearing later are payload for something else — `insta run -- compute exec app echo`
412
+ // hands those words to a LOCAL child, and rewriting argv there would eat the child's last
413
+ // argument. argv[0] and argv[1] are the runtime and this script, verified to hold for the released
414
+ // Bun standalone binary too (its `process.argv` is `["bun", "/$bunfs/root/insta", …]`), which is
415
+ // the offset commander's own parse assumes. Returns -1 for "not ours".
416
+ function execCommandIndex(argv) {
417
+ for (let cursor = 2; cursor < argv.length; cursor++) {
418
+ const token = argv[cursor];
419
+ if (token.startsWith('-'))
420
+ return -1; // a global flag, or `--`: either way not our command path
421
+ return token === 'compute' && argv[cursor + 1] === 'exec' ? cursor : -1;
422
+ }
423
+ return -1;
424
+ }
425
+ // `insta compute exec [service] -- <command> [args…]`: the command must reach the platform
426
+ // byte-for-byte and can itself contain dashes or another `--`, so it cannot be a normal commander
427
+ // positional — with `service` optional, commander cannot tell "no service, command starts here"
428
+ // from "service IS the first command token". Splitting argv ourselves, before commander parses,
429
+ // removes the ambiguity. Exported for a direct, network-free unit test.
430
+ export function splitExecArgs(argv, platform = process.platform) {
431
+ const i = execCommandIndex(argv);
91
432
  if (i === -1)
92
433
  return { argv };
93
434
  const dash = argv.indexOf('--', i + 2);
94
- if (dash === -1)
435
+ if (dash !== -1) {
436
+ // A separator that survived has at most ONE operand ahead of it — the optional service. Two or
437
+ // more mean this `--` is the remote command's own: npm's PowerShell shim strips only the first,
438
+ // so the real separator is already gone. Options are skipped wherever they sit, because with an
439
+ // intact `--` everything ahead of it belongs to the CLI.
440
+ if (platform !== 'win32' || operandIndices(argv, i + 2, dash).length <= 1) {
441
+ return { argv: argv.slice(0, dash), command: argv.slice(dash + 1) };
442
+ }
443
+ }
444
+ if (platform !== 'win32')
95
445
  return { argv };
96
- return { argv: argv.slice(0, dash), command: argv.slice(dash + 1) };
446
+ // The separator is gone. Everything from the first operand is PAYLOAD — the optional service plus
447
+ // the remote command — and nothing inside it is touched, so the command keeps its own flags AND
448
+ // its own `--json`/`--branch`. Options can only be recognised AHEAD of it; one stranded behind
449
+ // the service is reported by resolveExecFallback rather than silently applied or dropped.
450
+ const start = operandIndices(argv, i + 2)[0];
451
+ if (start === undefined)
452
+ return { argv }; // nothing to recover; no service-list round-trip needed
453
+ const payload = argv.slice(start);
454
+ if (isHelp(payload[0]))
455
+ return { argv }; // `insta compute exec --help` — local, and never remote
456
+ return { argv: argv.slice(0, start), command: payload, windowsFallback: true };
457
+ }
458
+ // The separator is gone, so the payload arrives undivided and only the service list can split it:
459
+ // `insta compute exec -- printenv PORT` and `insta compute exec printenv PORT` are byte-identical
460
+ // by the time they reach us. The reading taken is STATED on stderr — stdout stays clean for
461
+ // --json — because it is a guess in both directions: a service named `echo` would swallow the
462
+ // executable, and a mistyped service name is demoted to argv[0] and run remotely.
463
+ export function resolveExecFallback(services, payload, note = (msg) => process.stderr.write(`${msg}\n`)) {
464
+ const [head, ...rest] = payload;
465
+ if (head === undefined)
466
+ return { serviceName: undefined, command: [] };
467
+ if (!services.some((service) => service.type === 'compute' && service.name === head)) {
468
+ 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\`.`);
469
+ return { serviceName: undefined, command: payload };
470
+ }
471
+ // `head` really is a service, so whatever follows it cannot be the command's first token.
472
+ // A help flag there is a request for THIS command's help, which commander already answered for
473
+ // every other shape; say where to get it rather than exec `-h` on the machine.
474
+ if (isHelp(rest[0]))
475
+ 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`);
476
+ // A CLI option there landed on the wrong side of a separator that is not present. It cannot be
477
+ // honoured this late — --branch and --timeout are already spent by the time the service list
478
+ // arrives — so it is reported instead of being silently dropped or exec'd as a program.
479
+ if (rest[0]?.startsWith('-')) {
480
+ 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…]`);
481
+ }
482
+ // The reading is a guess in both directions — a service named `echo` would swallow the
483
+ // executable — so state it. The escape hatch names insta.cmd: pasting a plain `--` back into the
484
+ // same PowerShell session would be eaten exactly as the first one was. The command itself is NOT
485
+ // echoed; remote argv can carry tokens and passwords, and the user already has it on screen.
486
+ if (rest.length > 0) {
487
+ 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>`);
488
+ }
489
+ return { serviceName: head, command: rest };
97
490
  }
98
491
  // The --timeout override, through a throwing parser like every other user-typed number in this
99
492
  // repo (parseCpu, parseCount, parsePort): junk must fail locally instead of reaching the server as
@@ -149,16 +542,23 @@ export function applyExecResult(res, json) {
149
542
  // code becomes this process's own exit code (--json still passes it through, it just skips the
150
543
  // split-stream output), since agents scripting this rely on it. Waking a scaled-to-zero machine is
151
544
  // 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)
545
+ export async function computeExec(serviceName, command, opts, recovery = {}) {
546
+ if (!recovery.windowsFallback && (!command || command.length === 0)) {
154
547
  throw new Error('usage: insta compute exec [service] -- <command> [args…] (see --help)');
548
+ }
155
549
  const timeoutSec = opts.timeout !== undefined ? parseTimeoutSec(opts.timeout) : undefined;
156
550
  const api = await ApiClient.load();
157
551
  const p = await requireProject();
158
552
  const branch = opts.branch ?? p.branch;
159
553
  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));
554
+ const target = recovery.windowsFallback
555
+ ? resolveExecFallback(services, command ?? [])
556
+ : { serviceName, command };
557
+ if (!target.command || target.command.length === 0) {
558
+ throw new Error('usage: insta compute exec [service] -- <command> [args…] (see --help)');
559
+ }
560
+ const id = resolveComputeServiceId(services, target.serviceName);
561
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/exec`, execRequestBody(target.command, timeoutSec));
162
562
  applyExecResult(res, opts.json);
163
563
  }
164
564
  // ---- always-on (opt out of scale-to-zero; all plans; billing is actual usage either way) ----
@@ -29,6 +29,25 @@ export function dockerfileExposedPort(dockerfile) {
29
29
  }
30
30
  return port;
31
31
  }
32
+ // A directory deploy builds the Dockerfile IN the directory — there is no no-Dockerfile lane here.
33
+ // The nixpacks (no-Dockerfile) lane is real but server-side: it runs on the build gateway for
34
+ // GitHub-connected repos only, and nothing reachable from `insta deploy <dir>` can enter it. So the
35
+ // dead-end message names every way forward instead of the bare "add one".
36
+ //
37
+ // It deliberately does NOT say "save the Dockerfile `insta build --explain` prints": that file is
38
+ // not standalone — it COPYs `.nixpacks/nixpkgs-<hash>.nix` support files nixpacks writes beside it,
39
+ // which the source dir does not have. Pointing at it would swap one false promise for another. The
40
+ // detected install/start commands ARE reusable, so the message points at those.
41
+ // Pure, so it's unit-tested.
42
+ export function noDockerfileMessage(absDir) {
43
+ return [
44
+ `no Dockerfile at ${join(absDir, 'Dockerfile')} — a directory deploy builds the Dockerfile in the directory.`,
45
+ 'Options:',
46
+ ` - add a Dockerfile to ${absDir} (\`insta build ${absDir}\` prints the install/start commands nixpacks detected, as a starting point)`,
47
+ ' - deploy a prebuilt image instead: `insta deploy --image <url>`',
48
+ " - connect the app's GitHub repo in the console — that lane builds Dockerfile-less repos with nixpacks server-side",
49
+ ].join('\n');
50
+ }
32
51
  // Deploy either a prebuilt image (`--image`) or a source directory (positional `<dir>`, built
33
52
  // remotely on Fly and pushed with a short-lived platform-minted token). Exactly one mode.
34
53
  export async function deploy(dir, opts) {
@@ -80,7 +99,7 @@ export async function dockerBuildLocal(absDir, tag, run = defaultBuildRunner) {
80
99
  export async function buildFromSource(api, projectId, dir, branch, opts, run = opts.json ? stderrBuildRunner : defaultBuildRunner) {
81
100
  const absDir = resolve(process.cwd(), dir);
82
101
  if (!existsSync(join(absDir, 'Dockerfile')))
83
- die(`no Dockerfile at ${join(absDir, 'Dockerfile')} — add one, or use --image <url>`);
102
+ die(noDockerfileMessage(absDir));
84
103
  const log = note(opts);
85
104
  let tok;
86
105
  try {