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.
- package/README.md +9 -3
- package/dist/api.js +1 -1
- package/dist/commands/auth.js +37 -14
- package/dist/commands/billing.js +42 -5
- package/dist/commands/build.js +34 -9
- package/dist/commands/compute.js +458 -45
- package/dist/commands/db-query.js +102 -0
- package/dist/commands/deploy.js +20 -1
- package/dist/commands/env.js +1 -1
- package/dist/commands/setup.js +9 -84
- package/dist/commands/upgrade.js +282 -54
- package/dist/index.js +31 -18
- package/dist/spawn.js +77 -0
- package/dist/util.js +17 -2
- package/package.json +1 -1
package/dist/commands/compute.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const p = await
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
161
|
-
|
|
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) ----
|