@astris-labs/cli 0.1.4 → 0.2.0
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 +73 -15
- package/package.json +9 -1
- package/src/commands.mjs +920 -109
- package/src/main.mjs +11 -1
- package/src/mcp-bridge.mjs +186 -0
- package/src/new.mjs +159 -0
- package/templates.json +1 -0
package/src/commands.mjs
CHANGED
|
@@ -29,10 +29,14 @@ const SENTENCE = {
|
|
|
29
29
|
taken: "That name or hostname is already taken. Choose another and try again.",
|
|
30
30
|
noBackup: "This database has no backup job. Only Postgres databases are backed up.",
|
|
31
31
|
deleteConfirm: "This removes the database and its data. Add --yes to confirm.",
|
|
32
|
+
projectDeleteConfirm: "This removes the project. Add --yes to confirm.",
|
|
33
|
+
domainRemoveConfirm: "This removes the domain and its hostname. Add --yes to confirm.",
|
|
34
|
+
domainRemoved: "Domain removed.",
|
|
35
|
+
projectRemoved: "Project removed.",
|
|
32
36
|
bucketConfirm: "This removes the bucket and its objects. Add --yes to confirm.",
|
|
33
37
|
bucketRemoved: "Bucket removed.",
|
|
34
38
|
bucketNotEmpty: "That bucket is not empty. Remove its objects, then try again.",
|
|
35
|
-
bucketLimit: "This account has reached its bucket
|
|
39
|
+
bucketLimit: "This account has reached its bucket limit. Delete a bucket you no longer need, then try again.",
|
|
36
40
|
storageOff: "Storage is not available right now. Try again later.",
|
|
37
41
|
platform: "The platform did not accept the change. Check status, then try again.",
|
|
38
42
|
failed: "Astris failed on its side. Try again.",
|
|
@@ -45,14 +49,20 @@ const SENTENCE = {
|
|
|
45
49
|
loggedOut: "Logged out.",
|
|
46
50
|
nothingToRemove: "There was nothing to remove.",
|
|
47
51
|
appRemoved: "App removed.",
|
|
52
|
+
appStarted: "App started.",
|
|
53
|
+
appStopped: "App stopped.",
|
|
48
54
|
confirmRequired: "Send --confirm set to the application name.",
|
|
49
55
|
nameMismatch: "The name does not match the application.",
|
|
56
|
+
composeConfirm: "Send --confirm set to the stack's exact name. Removing a stack deletes its volumes and all their data.",
|
|
57
|
+
composeRemoved: "Compose stack removed.",
|
|
50
58
|
unreadableDir: "That directory cannot be read.",
|
|
51
59
|
exportEmail: "Send --email set to the account email address.",
|
|
52
60
|
exportAccepted:
|
|
53
61
|
"Accepted. If that address is on an Astris account, check its inbox for the download link. The link works for one hour.",
|
|
54
62
|
};
|
|
55
63
|
|
|
64
|
+
export const REFUSED_DATABASE_KIND = "Astris offers Postgres and Redis databases. Set kind to postgres or redis.";
|
|
65
|
+
|
|
56
66
|
const HTTP_SENTENCE = new Map([
|
|
57
67
|
[400, SENTENCE.refused],
|
|
58
68
|
[401, SENTENCE.unauthorized],
|
|
@@ -74,38 +84,60 @@ function sentenceFor(status, retryAfterSeconds = null) {
|
|
|
74
84
|
return HTTP_SENTENCE.get(status) ?? SENTENCE.failed;
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
|
|
87
|
+
// T-439: every door print of server-controlled text goes through this first.
|
|
88
|
+
// It strips ANSI escape sequences, C0 controls except tab and newline, C1
|
|
89
|
+
// controls, U+2028, U+2029 and bidi controls. Same table as sanitizeMcp.
|
|
90
|
+
export function sanitizeCli(text) {
|
|
91
|
+
return text.replace(
|
|
92
|
+
/\x1B\[[\x30-\x3F]*[\x40-\x7E]|[\x00-\x08\x0B-\x1F\x7F-\x9F\u2028\u2029\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g,
|
|
93
|
+
"",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function errorMessage(status, body, retryAfterSeconds = null) {
|
|
78
98
|
if (status === 429) return rateWaitSentence(retryAfterSeconds);
|
|
79
|
-
if (isRecord(body) && typeof body.message === "string") return body.message;
|
|
99
|
+
if (isRecord(body) && typeof body.message === "string") return sanitizeCli(body.message);
|
|
80
100
|
return sentenceFor(status, retryAfterSeconds);
|
|
81
101
|
}
|
|
82
102
|
|
|
83
|
-
|
|
103
|
+
// T-411 C4: a door's refusal is the route's refusal. The body's message wins,
|
|
104
|
+
// then its error word, and only a body with neither gets the status sentence.
|
|
105
|
+
function doorRefusal(io, key, json, result, fallback) {
|
|
106
|
+
if (result.status === 429) {
|
|
107
|
+
return fail(io, key, json, rateWaitSentence(result.retryAfterSeconds));
|
|
108
|
+
}
|
|
109
|
+
const body = isRecord(result.body) ? result.body : {};
|
|
110
|
+
if (typeof body.message === "string") return fail(io, key, json, sanitizeCli(body.message));
|
|
111
|
+
if (typeof body.error === "string") return fail(io, key, json, sanitizeCli(body.error));
|
|
112
|
+
return fail(io, key, json, fallback);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function upgradeMessage(status, body) {
|
|
84
116
|
if (status !== 403 || !isRecord(body) || body.error !== "upgrade_required" || typeof body.message !== "string") {
|
|
85
117
|
return null;
|
|
86
118
|
}
|
|
87
|
-
return body.message;
|
|
119
|
+
return sanitizeCli(body.message);
|
|
88
120
|
}
|
|
89
121
|
|
|
90
122
|
// T-275: the API pauses compose redeploy in the private preview and says why.
|
|
91
|
-
function composeUnavailableMessage(status, body) {
|
|
123
|
+
export function composeUnavailableMessage(status, body) {
|
|
92
124
|
if (status !== 403 || !isRecord(body) || body.error !== "compose_unavailable" || typeof body.message !== "string") {
|
|
93
125
|
return null;
|
|
94
126
|
}
|
|
95
|
-
return body.message;
|
|
127
|
+
return sanitizeCli(body.message);
|
|
96
128
|
}
|
|
97
129
|
|
|
98
|
-
function storageCreateMessage(status, body) {
|
|
130
|
+
export function storageCreateMessage(status, body) {
|
|
99
131
|
if (!isRecord(body)) return null;
|
|
100
132
|
if (status === 403 && body.error === "bucket_limit") {
|
|
101
133
|
if (typeof body.message === "string" && body.message.length > 0) {
|
|
102
|
-
return body.message;
|
|
134
|
+
return sanitizeCli(body.message);
|
|
103
135
|
}
|
|
104
136
|
return SENTENCE.bucketLimit;
|
|
105
137
|
}
|
|
106
138
|
if (status === 503 && body.error === "storage_unavailable") {
|
|
107
139
|
if (typeof body.message === "string" && body.message.length > 0) {
|
|
108
|
-
return body.message;
|
|
140
|
+
return sanitizeCli(body.message);
|
|
109
141
|
}
|
|
110
142
|
return null;
|
|
111
143
|
}
|
|
@@ -180,6 +212,7 @@ const DEPLOY_OPTIONS = {
|
|
|
180
212
|
command: { type: "string" },
|
|
181
213
|
schedule: { type: "string" },
|
|
182
214
|
hook: { type: "boolean" },
|
|
215
|
+
wait: { type: "boolean" },
|
|
183
216
|
};
|
|
184
217
|
|
|
185
218
|
const ENV_OPTIONS = {
|
|
@@ -220,6 +253,7 @@ function parseFlags(args, extra) {
|
|
|
220
253
|
else if (token.name === "schedule") flags.schedule = token.value;
|
|
221
254
|
else if (token.name === "names") flags.names = true;
|
|
222
255
|
else if (token.name === "hook") flags.hook = true;
|
|
256
|
+
else if (token.name === "wait") flags.wait = true;
|
|
223
257
|
}
|
|
224
258
|
return flags;
|
|
225
259
|
}
|
|
@@ -311,6 +345,22 @@ function hostnamesOf(body) {
|
|
|
311
345
|
return hostnames.filter((item) => typeof item === "string");
|
|
312
346
|
}
|
|
313
347
|
|
|
348
|
+
function checkOf(body) {
|
|
349
|
+
const raw = body?.check;
|
|
350
|
+
if (!isRecord(raw)) return null;
|
|
351
|
+
const state = raw.state;
|
|
352
|
+
if (state !== "answering" && state !== "not_answering") return null;
|
|
353
|
+
const since = raw.since;
|
|
354
|
+
if (since !== null && typeof since !== "string") return null;
|
|
355
|
+
return { state, since };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function checkLine(check) {
|
|
359
|
+
const words = check.state === "not_answering" ? "not answering" : "answering";
|
|
360
|
+
if (check.since === null) return `check: ${words}`;
|
|
361
|
+
return `check: ${words} since ${check.since}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
314
364
|
function deploymentRows(body) {
|
|
315
365
|
const rows = Array.isArray(body?.deployments) ? body.deployments.slice(0, 5) : [];
|
|
316
366
|
return rows.map((row) => ({
|
|
@@ -321,6 +371,78 @@ function deploymentRows(body) {
|
|
|
321
371
|
}));
|
|
322
372
|
}
|
|
323
373
|
|
|
374
|
+
// T-440: the wait watches deployments[0], the newest row the route returns.
|
|
375
|
+
// The stamp strings are compared to each other, never to the clock.
|
|
376
|
+
const WAIT_CEILING_MS = 600000;
|
|
377
|
+
const WAIT_POLL_MS = 3000;
|
|
378
|
+
const WAIT_RUNNING = "still running, check status";
|
|
379
|
+
|
|
380
|
+
function newerDeployment(body, mark) {
|
|
381
|
+
const [first] = deploymentRows(body);
|
|
382
|
+
if (first === undefined || first.created_at === null) return null;
|
|
383
|
+
if (mark === null || first.created_at > mark) return first;
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function waitMark(io, key, json, appId, created) {
|
|
388
|
+
const result = await call(io, key, {
|
|
389
|
+
path: `/v1/apps/${encodeURIComponent(appId)}`,
|
|
390
|
+
method: "GET",
|
|
391
|
+
});
|
|
392
|
+
if (result.network) return { exit: fail(io, key, json, SENTENCE.platform, created) };
|
|
393
|
+
if (result.status !== 200) {
|
|
394
|
+
return { exit: fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds), created) };
|
|
395
|
+
}
|
|
396
|
+
return { mark: deploymentRows(result.body)[0]?.created_at ?? null };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function waitForOutcome(io, key, json, appId, mark, created) {
|
|
400
|
+
const started = Date.now();
|
|
401
|
+
let delay = 0;
|
|
402
|
+
let rateSentence = null;
|
|
403
|
+
for (;;) {
|
|
404
|
+
if (delay > 0) {
|
|
405
|
+
if (started + WAIT_CEILING_MS > Date.now() + delay) {
|
|
406
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
407
|
+
} else {
|
|
408
|
+
return { exit: fail(io, key, json, rateSentence, created) };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const polled = await call(io, key, {
|
|
412
|
+
path: `/v1/apps/${encodeURIComponent(appId)}`,
|
|
413
|
+
method: "GET",
|
|
414
|
+
});
|
|
415
|
+
if (polled.network) return { exit: fail(io, key, json, SENTENCE.platform, created) };
|
|
416
|
+
if (polled.status === 429) {
|
|
417
|
+
rateSentence = sentenceFor(429, polled.retryAfterSeconds);
|
|
418
|
+
if (typeof polled.retryAfterSeconds !== "number") {
|
|
419
|
+
return { exit: fail(io, key, json, rateSentence, created) };
|
|
420
|
+
}
|
|
421
|
+
delay = polled.retryAfterSeconds * 1000;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (polled.status !== 200) {
|
|
425
|
+
return { exit: fail(io, key, json, sentenceFor(polled.status, polled.retryAfterSeconds), created) };
|
|
426
|
+
}
|
|
427
|
+
const row = newerDeployment(polled.body, mark);
|
|
428
|
+
if (row !== null && (row.status === "done" || row.status === "error" || row.status === "cancelled")) {
|
|
429
|
+
return { outcome: row.status };
|
|
430
|
+
}
|
|
431
|
+
const remaining = started + WAIT_CEILING_MS - Date.now();
|
|
432
|
+
if (remaining > WAIT_POLL_MS) {
|
|
433
|
+
await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
return { outcome: WAIT_RUNNING };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function waitSucceed(io, key, json, payload, outcome) {
|
|
441
|
+
if (json) writeStdout(io, key, `${JSON.stringify({ ...payload, outcome })}\n`);
|
|
442
|
+
else writeStdout(io, key, `${outcome}\n`);
|
|
443
|
+
return { exitCode: outcome === "done" ? 0 : 1 };
|
|
444
|
+
}
|
|
445
|
+
|
|
324
446
|
function envObject(stdin) {
|
|
325
447
|
if (typeof stdin !== "string") return null;
|
|
326
448
|
let value;
|
|
@@ -344,7 +466,13 @@ function findProjectId(body, name) {
|
|
|
344
466
|
return null;
|
|
345
467
|
}
|
|
346
468
|
|
|
347
|
-
async function redeploy(io, key, json, appId) {
|
|
469
|
+
async function redeploy(io, key, json, appId, wait) {
|
|
470
|
+
let mark = null;
|
|
471
|
+
if (wait) {
|
|
472
|
+
const watched = await waitMark(io, key, json, appId);
|
|
473
|
+
if (watched.exit) return watched.exit;
|
|
474
|
+
mark = watched.mark;
|
|
475
|
+
}
|
|
348
476
|
const result = await call(io, key, {
|
|
349
477
|
path: `/v1/apps/${encodeURIComponent(appId)}/deploy`,
|
|
350
478
|
method: "POST",
|
|
@@ -354,13 +482,11 @@ async function redeploy(io, key, json, appId) {
|
|
|
354
482
|
const application = result.body?.application ?? {};
|
|
355
483
|
const id = text(application.id) || appId;
|
|
356
484
|
const name = text(application.name);
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
`${id}\n${name}\n`,
|
|
363
|
-
);
|
|
485
|
+
const payload = { application_id: id, name, accepted: true };
|
|
486
|
+
if (!wait) return succeed(io, key, json, payload, `${id}\n${name}\n`);
|
|
487
|
+
const waited = await waitForOutcome(io, key, json, id, mark);
|
|
488
|
+
if (waited.exit) return waited.exit;
|
|
489
|
+
return waitSucceed(io, key, json, payload, waited.outcome);
|
|
364
490
|
}
|
|
365
491
|
|
|
366
492
|
// T-146: the create-only body keys from --build and --kind, or null when the
|
|
@@ -530,6 +656,12 @@ async function createThenDeploy(io, key, json, flags) {
|
|
|
530
656
|
`${projectId}\n${applicationId}\n${keyName}\n${keySlug}\n${deployKey.type}\n${deployKey.public_key}\n${deployKey.fingerprint}\n${SENTENCE.deployKey}\n`,
|
|
531
657
|
);
|
|
532
658
|
}
|
|
659
|
+
let mark = null;
|
|
660
|
+
if (flags.wait === true) {
|
|
661
|
+
const watched = await waitMark(io, key, json, applicationId, created);
|
|
662
|
+
if (watched.exit) return watched.exit;
|
|
663
|
+
mark = watched.mark;
|
|
664
|
+
}
|
|
533
665
|
const deployed = await call(io, key, {
|
|
534
666
|
path: `/v1/apps/${encodeURIComponent(applicationId)}/deploy`,
|
|
535
667
|
method: "POST",
|
|
@@ -540,19 +672,19 @@ async function createThenDeploy(io, key, json, flags) {
|
|
|
540
672
|
const id = text(shown.id) || applicationId;
|
|
541
673
|
const shownName = text(shown.name) || name;
|
|
542
674
|
const slug = text(shown.slug);
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
);
|
|
675
|
+
const payload = {
|
|
676
|
+
project_id: projectId,
|
|
677
|
+
application_id: id,
|
|
678
|
+
name: shownName,
|
|
679
|
+
slug,
|
|
680
|
+
accepted: true,
|
|
681
|
+
};
|
|
682
|
+
if (flags.wait !== true) {
|
|
683
|
+
return succeed(io, key, json, payload, `${projectId}\n${id}\n${shownName}\n${slug}\n`);
|
|
684
|
+
}
|
|
685
|
+
const waited = await waitForOutcome(io, key, json, id, mark, created);
|
|
686
|
+
if (waited.exit) return waited.exit;
|
|
687
|
+
return waitSucceed(io, key, json, payload, waited.outcome);
|
|
556
688
|
} catch {
|
|
557
689
|
return fail(io, key, json, SENTENCE.platform, created);
|
|
558
690
|
}
|
|
@@ -578,8 +710,11 @@ export async function deploy(args, io) {
|
|
|
578
710
|
) {
|
|
579
711
|
return fail(io, opened.key, opened.json, SENTENCE.refused);
|
|
580
712
|
}
|
|
581
|
-
if (opened.flags.hook === true)
|
|
582
|
-
|
|
713
|
+
if (opened.flags.hook === true) {
|
|
714
|
+
if (opened.flags.wait === true) return fail(io, opened.key, opened.json, SENTENCE.refused);
|
|
715
|
+
return hookUrl(io, opened.key, opened.json, appId);
|
|
716
|
+
}
|
|
717
|
+
return redeploy(io, opened.key, opened.json, appId, opened.flags.wait === true);
|
|
583
718
|
}
|
|
584
719
|
return createThenDeploy(io, opened.key, opened.json, opened.flags);
|
|
585
720
|
}
|
|
@@ -598,18 +733,19 @@ export async function status(args, io) {
|
|
|
598
733
|
const statusText = text(result.body?.status);
|
|
599
734
|
const hostnames = hostnamesOf(result.body);
|
|
600
735
|
const id = text(result.body?.application?.id) || required.appId;
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
`${
|
|
612
|
-
|
|
736
|
+
const check = checkOf(result.body);
|
|
737
|
+
const payload = {
|
|
738
|
+
application_id: id,
|
|
739
|
+
status: statusText,
|
|
740
|
+
hostnames,
|
|
741
|
+
deployments: deploymentRows(result.body),
|
|
742
|
+
};
|
|
743
|
+
let human = `${statusText}\n${hostnames.join("\n")}${hostnames.length > 0 ? "\n" : ""}`;
|
|
744
|
+
if (check !== null) {
|
|
745
|
+
payload.check = check;
|
|
746
|
+
human += `${checkLine(check)}\n`;
|
|
747
|
+
}
|
|
748
|
+
return succeed(io, opened.key, opened.json, payload, human);
|
|
613
749
|
}
|
|
614
750
|
|
|
615
751
|
// T-169: GET /v1/apps/:id/hook answers {hook: {url}}. The URL is a
|
|
@@ -685,12 +821,20 @@ const DOMAINS_VERIFY_OPTIONS = {
|
|
|
685
821
|
id: { type: "string" },
|
|
686
822
|
};
|
|
687
823
|
|
|
824
|
+
const DOMAINS_REMOVE_OPTIONS = {
|
|
825
|
+
json: { type: "boolean" },
|
|
826
|
+
app: { type: "string" },
|
|
827
|
+
id: { type: "string", multiple: true },
|
|
828
|
+
yes: { type: "boolean" },
|
|
829
|
+
};
|
|
830
|
+
|
|
688
831
|
const DOMAINS_UNION = {
|
|
689
832
|
json: { type: "boolean" },
|
|
690
833
|
app: { type: "string" },
|
|
691
834
|
slug: { type: "string" },
|
|
692
835
|
hostname: { type: "string" },
|
|
693
836
|
id: { type: "string" },
|
|
837
|
+
yes: { type: "boolean" },
|
|
694
838
|
};
|
|
695
839
|
|
|
696
840
|
function domainsPositionals(args) {
|
|
@@ -781,6 +925,56 @@ async function domainsBind(args, io, key) {
|
|
|
781
925
|
);
|
|
782
926
|
}
|
|
783
927
|
|
|
928
|
+
// T-411 R2: one domain read. GET /v1/apps/:id/domains/:domainId and nothing
|
|
929
|
+
// else; the certificate word is the route's, not the door's.
|
|
930
|
+
async function domainsStatus(args, io, key) {
|
|
931
|
+
const parsed = parseStrict(args, DOMAINS_VERIFY_OPTIONS, true);
|
|
932
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "status") {
|
|
933
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
934
|
+
}
|
|
935
|
+
const json = parsed.values.json === true;
|
|
936
|
+
const appId = appUuid(parsed.values.app);
|
|
937
|
+
if (appId === null) return fail(io, key, json, SENTENCE.app);
|
|
938
|
+
const domainId = appUuid(parsed.values.id);
|
|
939
|
+
if (domainId === null) return fail(io, key, json, SENTENCE.domainId);
|
|
940
|
+
const result = await call(io, key, {
|
|
941
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/domains/${encodeURIComponent(domainId)}`,
|
|
942
|
+
method: "GET",
|
|
943
|
+
});
|
|
944
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
945
|
+
if (result.status !== 200) {
|
|
946
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
947
|
+
}
|
|
948
|
+
const domain = isRecord(result.body) && isRecord(result.body.domain) ? result.body.domain : null;
|
|
949
|
+
if (
|
|
950
|
+
domain === null ||
|
|
951
|
+
typeof domain.id !== "string" ||
|
|
952
|
+
typeof domain.hostname !== "string" ||
|
|
953
|
+
typeof domain.kind !== "string" ||
|
|
954
|
+
typeof domain.status !== "string" ||
|
|
955
|
+
typeof domain.certificate !== "string"
|
|
956
|
+
) {
|
|
957
|
+
return fail(io, key, json, SENTENCE.failed);
|
|
958
|
+
}
|
|
959
|
+
const payload = {
|
|
960
|
+
application_id: appId,
|
|
961
|
+
domain: {
|
|
962
|
+
id: domain.id,
|
|
963
|
+
hostname: domain.hostname,
|
|
964
|
+
kind: domain.kind,
|
|
965
|
+
status: domain.status,
|
|
966
|
+
certificate: domain.certificate,
|
|
967
|
+
},
|
|
968
|
+
};
|
|
969
|
+
return succeed(
|
|
970
|
+
io,
|
|
971
|
+
key,
|
|
972
|
+
json,
|
|
973
|
+
payload,
|
|
974
|
+
`${domain.hostname}\n${domain.status}\n${domain.certificate}\n`,
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
784
978
|
async function domainsVerify(args, io, key) {
|
|
785
979
|
const parsed = parseStrict(args, DOMAINS_VERIFY_OPTIONS, true);
|
|
786
980
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "verify") {
|
|
@@ -799,22 +993,55 @@ async function domainsVerify(args, io, key) {
|
|
|
799
993
|
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
800
994
|
const body = isRecord(result.body) ? result.body : {};
|
|
801
995
|
const domain = isRecord(body.domain) ? body.domain : {};
|
|
802
|
-
const statusText = typeof domain.status === "string" ? domain.status : "";
|
|
996
|
+
const statusText = sanitizeCli(typeof domain.status === "string" ? domain.status : "");
|
|
803
997
|
const payload = { application_id: appId, domain_id: domainId, status: statusText };
|
|
804
998
|
let human = `${statusText}\n`;
|
|
805
999
|
if (typeof body.reason === "string" && body.reason !== "") {
|
|
806
|
-
|
|
807
|
-
|
|
1000
|
+
const reason = sanitizeCli(body.reason);
|
|
1001
|
+
payload.reason = reason;
|
|
1002
|
+
human = `${statusText}\n${reason}\n`;
|
|
808
1003
|
}
|
|
809
1004
|
return succeed(io, key, json, payload, human);
|
|
810
1005
|
}
|
|
811
1006
|
|
|
1007
|
+
async function domainsRemove(args, io, key) {
|
|
1008
|
+
const parsed = parseStrict(args, DOMAINS_REMOVE_OPTIONS, true);
|
|
1009
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "remove") {
|
|
1010
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1011
|
+
}
|
|
1012
|
+
const json = parsed.values.json === true;
|
|
1013
|
+
const appId = appUuid(parsed.values.app);
|
|
1014
|
+
if (appId === null) return fail(io, key, json, SENTENCE.app);
|
|
1015
|
+
const ids = Array.isArray(parsed.values.id) ? parsed.values.id : [];
|
|
1016
|
+
if (ids.length > 1) return fail(io, key, json, SENTENCE.refused);
|
|
1017
|
+
const domainId = appUuid(ids[0]);
|
|
1018
|
+
if (domainId === null) return fail(io, key, json, SENTENCE.domainId);
|
|
1019
|
+
if (parsed.values.yes !== true) return fail(io, key, json, SENTENCE.domainRemoveConfirm);
|
|
1020
|
+
const result = await call(io, key, {
|
|
1021
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/domains/${encodeURIComponent(domainId)}`,
|
|
1022
|
+
method: "DELETE",
|
|
1023
|
+
});
|
|
1024
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1025
|
+
if (result.status !== 204) {
|
|
1026
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1027
|
+
}
|
|
1028
|
+
return succeed(
|
|
1029
|
+
io,
|
|
1030
|
+
key,
|
|
1031
|
+
json,
|
|
1032
|
+
{ application_id: appId, domain_id: domainId, removed: true },
|
|
1033
|
+
`${SENTENCE.domainRemoved}\n`,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
812
1037
|
export async function domains(args, io) {
|
|
813
1038
|
const loaded = loadKey(io);
|
|
814
1039
|
if (loaded.exit) return loaded.exit;
|
|
815
1040
|
const words = domainsPositionals(args);
|
|
816
1041
|
if (words === null) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
817
1042
|
if (words.length === 1 && words[0] === "verify") return domainsVerify(args, io, loaded.key);
|
|
1043
|
+
if (words.length === 1 && words[0] === "remove") return domainsRemove(args, io, loaded.key);
|
|
1044
|
+
if (words.length === 1 && words[0] === "status") return domainsStatus(args, io, loaded.key);
|
|
818
1045
|
if (words.length !== 0) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
819
1046
|
return domainsBind(args, io, loaded.key);
|
|
820
1047
|
}
|
|
@@ -839,17 +1066,21 @@ export async function restart(args, io) {
|
|
|
839
1066
|
);
|
|
840
1067
|
}
|
|
841
1068
|
|
|
1069
|
+
const MAX_DEPLOYMENT = 5; // the server's, src/app/v1/apps/[id]/logs/route.ts:15
|
|
1070
|
+
|
|
842
1071
|
const LOGS_OPTIONS = {
|
|
843
1072
|
json: { type: "boolean" },
|
|
844
1073
|
source: { type: "string" },
|
|
845
1074
|
tail: { type: "string" },
|
|
1075
|
+
deployment: { type: "string" },
|
|
846
1076
|
};
|
|
847
1077
|
|
|
848
1078
|
const LIST_OPTIONS = {
|
|
849
1079
|
json: { type: "boolean" },
|
|
1080
|
+
id: { type: "string" },
|
|
850
1081
|
};
|
|
851
1082
|
|
|
852
|
-
function logsSuffix(source, tail) {
|
|
1083
|
+
function logsSuffix(source, tail, deployment) {
|
|
853
1084
|
const parts = [];
|
|
854
1085
|
if (source !== undefined) {
|
|
855
1086
|
if (source !== "runtime" && source !== "deploy") return null;
|
|
@@ -860,6 +1091,13 @@ function logsSuffix(source, tail) {
|
|
|
860
1091
|
const parsed = Number(tail);
|
|
861
1092
|
parts.push(`tail=${parsed > 500 ? 500 : parsed}`);
|
|
862
1093
|
}
|
|
1094
|
+
if (deployment !== undefined) {
|
|
1095
|
+
if (source !== "deploy") return null;
|
|
1096
|
+
if (!/^[1-9][0-9]*$/.test(deployment)) return null;
|
|
1097
|
+
const parsed = Number(deployment);
|
|
1098
|
+
if (parsed > MAX_DEPLOYMENT) return null;
|
|
1099
|
+
parts.push(`deployment=${parsed}`);
|
|
1100
|
+
}
|
|
863
1101
|
return parts.length === 0 ? "" : `?${parts.join("&")}`;
|
|
864
1102
|
}
|
|
865
1103
|
|
|
@@ -873,7 +1111,7 @@ export async function logs(args, io) {
|
|
|
873
1111
|
if (parsed.positionals.length !== 1) return fail(io, loaded.key, json, SENTENCE.refused);
|
|
874
1112
|
const appId = appUuid(parsed.positionals[0]);
|
|
875
1113
|
if (appId === null) return fail(io, loaded.key, json, SENTENCE.logsApp);
|
|
876
|
-
const suffix = logsSuffix(parsed.values.source, parsed.values.tail);
|
|
1114
|
+
const suffix = logsSuffix(parsed.values.source, parsed.values.tail, parsed.values.deployment);
|
|
877
1115
|
if (suffix === null) return fail(io, loaded.key, json, SENTENCE.refused);
|
|
878
1116
|
const result = await call(io, loaded.key, {
|
|
879
1117
|
path: `/v1/apps/${encodeURIComponent(appId)}/logs${suffix}`,
|
|
@@ -912,23 +1150,46 @@ function listText(resource, body) {
|
|
|
912
1150
|
return `${lines.join("\n")}\n`;
|
|
913
1151
|
}
|
|
914
1152
|
|
|
1153
|
+
function projectRow(item) {
|
|
1154
|
+
if (
|
|
1155
|
+
!isRecord(item) ||
|
|
1156
|
+
typeof item.id !== "string" ||
|
|
1157
|
+
typeof item.name !== "string" ||
|
|
1158
|
+
typeof item.created_at !== "string"
|
|
1159
|
+
) {
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
return { id: item.id, name: item.name, created_at: item.created_at };
|
|
1163
|
+
}
|
|
1164
|
+
|
|
915
1165
|
function projectRows(body) {
|
|
916
1166
|
if (!isRecord(body) || !Array.isArray(body.projects)) return null;
|
|
917
1167
|
const rows = [];
|
|
918
1168
|
for (const item of body.projects) {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
typeof item.name !== "string" ||
|
|
923
|
-
typeof item.created_at !== "string"
|
|
924
|
-
) {
|
|
925
|
-
return null;
|
|
926
|
-
}
|
|
927
|
-
rows.push({ id: item.id, name: item.name, created_at: item.created_at });
|
|
1169
|
+
const row = projectRow(item);
|
|
1170
|
+
if (row === null) return null;
|
|
1171
|
+
rows.push(row);
|
|
928
1172
|
}
|
|
929
1173
|
return rows;
|
|
930
1174
|
}
|
|
931
1175
|
|
|
1176
|
+
// T-411 R21: the one project read. GET /v1/projects/:id, nothing else.
|
|
1177
|
+
async function listProject(io, key, json, rawId) {
|
|
1178
|
+
const projectId = appUuid(rawId);
|
|
1179
|
+
if (projectId === null) return fail(io, key, json, SENTENCE.refused);
|
|
1180
|
+
const result = await call(io, key, {
|
|
1181
|
+
path: `/v1/projects/${encodeURIComponent(projectId)}`,
|
|
1182
|
+
method: "GET",
|
|
1183
|
+
});
|
|
1184
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1185
|
+
if (result.status !== 200) {
|
|
1186
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1187
|
+
}
|
|
1188
|
+
const row = projectRow(isRecord(result.body) ? result.body.project : null);
|
|
1189
|
+
if (row === null) return fail(io, key, json, SENTENCE.failed);
|
|
1190
|
+
return succeed(io, key, json, { project: row }, `${row.id} ${row.name}\n`);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
932
1193
|
export async function list(args, io) {
|
|
933
1194
|
const loaded = loadKey(io);
|
|
934
1195
|
if (loaded.exit) return loaded.exit;
|
|
@@ -940,6 +1201,11 @@ export async function list(args, io) {
|
|
|
940
1201
|
if (resource !== "apps" && resource !== "databases" && resource !== "projects") {
|
|
941
1202
|
return fail(io, loaded.key, json, SENTENCE.refused);
|
|
942
1203
|
}
|
|
1204
|
+
// Only projects has a one read here, so --id on apps or databases is refused.
|
|
1205
|
+
if (parsed.values.id !== undefined) {
|
|
1206
|
+
if (resource !== "projects") return fail(io, loaded.key, json, SENTENCE.refused);
|
|
1207
|
+
return listProject(io, loaded.key, json, parsed.values.id);
|
|
1208
|
+
}
|
|
943
1209
|
const path =
|
|
944
1210
|
resource === "apps" ? "/v1/apps" : resource === "databases" ? "/v1/databases" : "/v1/projects";
|
|
945
1211
|
const result = await call(io, loaded.key, { path, method: "GET" });
|
|
@@ -956,6 +1222,121 @@ export async function list(args, io) {
|
|
|
956
1222
|
return succeed(io, loaded.key, json, { projects: rows }, human);
|
|
957
1223
|
}
|
|
958
1224
|
|
|
1225
|
+
const PROJECT_CREATE_OPTIONS = {
|
|
1226
|
+
json: { type: "boolean" },
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
const PROJECT_LIST_OPTIONS = {
|
|
1230
|
+
json: { type: "boolean" },
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
const PROJECT_DELETE_OPTIONS = {
|
|
1234
|
+
json: { type: "boolean" },
|
|
1235
|
+
id: { type: "string", multiple: true },
|
|
1236
|
+
yes: { type: "boolean" },
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
const PROJECT_UNION = {
|
|
1240
|
+
json: { type: "boolean" },
|
|
1241
|
+
id: { type: "string" },
|
|
1242
|
+
yes: { type: "boolean" },
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
function projectPositionals(args) {
|
|
1246
|
+
try {
|
|
1247
|
+
return parseArgs({
|
|
1248
|
+
args: Array.isArray(args) ? args : [],
|
|
1249
|
+
strict: false,
|
|
1250
|
+
allowPositionals: true,
|
|
1251
|
+
options: PROJECT_UNION,
|
|
1252
|
+
}).positionals;
|
|
1253
|
+
} catch {
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
async function projectCreate(args, io, key) {
|
|
1259
|
+
const parsed = parseStrict(args, PROJECT_CREATE_OPTIONS, true);
|
|
1260
|
+
if (parsed === null || parsed.positionals.length !== 2 || parsed.positionals[0] !== "create") {
|
|
1261
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1262
|
+
}
|
|
1263
|
+
const json = parsed.values.json === true;
|
|
1264
|
+
const name = trimmed(parsed.positionals[1]);
|
|
1265
|
+
if (name === "") return fail(io, key, json, SENTENCE.refused);
|
|
1266
|
+
const result = await call(io, key, {
|
|
1267
|
+
path: "/v1/projects",
|
|
1268
|
+
method: "POST",
|
|
1269
|
+
body: { name },
|
|
1270
|
+
});
|
|
1271
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1272
|
+
if (result.status !== 201) {
|
|
1273
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1274
|
+
}
|
|
1275
|
+
if (json) return succeed(io, key, json, result.body, "");
|
|
1276
|
+
const row = projectRow(isRecord(result.body) ? result.body.project : null);
|
|
1277
|
+
if (row === null) return fail(io, key, json, SENTENCE.failed);
|
|
1278
|
+
return succeed(io, key, json, { project: row }, `${row.id}\n`);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function projectList(args, io, key) {
|
|
1282
|
+
const parsed = parseStrict(args, PROJECT_LIST_OPTIONS, true);
|
|
1283
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "list") {
|
|
1284
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1285
|
+
}
|
|
1286
|
+
const json = parsed.values.json === true;
|
|
1287
|
+
const result = await call(io, key, { path: "/v1/projects", method: "GET" });
|
|
1288
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1289
|
+
if (result.status !== 200) {
|
|
1290
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1291
|
+
}
|
|
1292
|
+
if (json) return succeed(io, key, json, result.body, "");
|
|
1293
|
+
const rows = projectRows(result.body);
|
|
1294
|
+
if (rows === null) return fail(io, key, json, SENTENCE.failed);
|
|
1295
|
+
const human = rows.length === 0 ? "" : `${rows.map((row) => `${row.id} ${row.name}`).join("\n")}\n`;
|
|
1296
|
+
return succeed(io, key, json, { projects: rows }, human);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// T-411 second fix: DELETE /v1/projects/:id, the one project delete. The route
|
|
1300
|
+
// reads no body, so the door sends the id and nothing else.
|
|
1301
|
+
async function projectDelete(args, io, key) {
|
|
1302
|
+
const parsed = parseStrict(args, PROJECT_DELETE_OPTIONS, true);
|
|
1303
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "delete") {
|
|
1304
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1305
|
+
}
|
|
1306
|
+
const json = parsed.values.json === true;
|
|
1307
|
+
const ids = Array.isArray(parsed.values.id) ? parsed.values.id : [];
|
|
1308
|
+
if (ids.length > 1) return fail(io, key, json, SENTENCE.refused);
|
|
1309
|
+
const projectId = appUuid(ids[0]);
|
|
1310
|
+
if (projectId === null) return fail(io, key, json, SENTENCE.refused);
|
|
1311
|
+
if (parsed.values.yes !== true) return fail(io, key, json, SENTENCE.projectDeleteConfirm);
|
|
1312
|
+
const result = await call(io, key, {
|
|
1313
|
+
path: `/v1/projects/${encodeURIComponent(projectId)}`,
|
|
1314
|
+
method: "DELETE",
|
|
1315
|
+
});
|
|
1316
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1317
|
+
if (result.status !== 204) {
|
|
1318
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1319
|
+
}
|
|
1320
|
+
return succeed(
|
|
1321
|
+
io,
|
|
1322
|
+
key,
|
|
1323
|
+
json,
|
|
1324
|
+
{ project_id: projectId, removed: true },
|
|
1325
|
+
`${SENTENCE.projectRemoved}\n`,
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
export async function project(args, io) {
|
|
1330
|
+
const loaded = loadKey(io);
|
|
1331
|
+
if (loaded.exit) return loaded.exit;
|
|
1332
|
+
const words = projectPositionals(args);
|
|
1333
|
+
if (words === null || words.length === 0) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1334
|
+
if (words[0] === "delete") return projectDelete(args, io, loaded.key);
|
|
1335
|
+
if (words[0] === "create") return projectCreate(args, io, loaded.key);
|
|
1336
|
+
if (words[0] === "list") return projectList(args, io, loaded.key);
|
|
1337
|
+
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
959
1340
|
const USAGE_OPTIONS = {
|
|
960
1341
|
json: { type: "boolean" },
|
|
961
1342
|
};
|
|
@@ -966,11 +1347,25 @@ function finiteNumber(value) {
|
|
|
966
1347
|
|
|
967
1348
|
function usageText(body) {
|
|
968
1349
|
if (!isRecord(body) || !isRecord(body.used) || !finiteNumber(body.used.ram_mb)) return null;
|
|
969
|
-
|
|
970
|
-
|
|
1350
|
+
if (!finiteNumber(body.used.cpu_milli) || !finiteNumber(body.used.disk_gb)) return null;
|
|
1351
|
+
const usedRam = body.used.ram_mb;
|
|
1352
|
+
const usedCpu = body.used.cpu_milli;
|
|
1353
|
+
const usedDisk = body.used.disk_gb;
|
|
1354
|
+
if (body.plan === null) {
|
|
1355
|
+
return `Plan: none\nRAM used: ${usedRam} MiB\nCPU used: ${usedCpu} mCPU\nDisk used: ${usedDisk} GB\n`;
|
|
1356
|
+
}
|
|
971
1357
|
if (typeof body.plan !== "string" || !isRecord(body.pools)) return null;
|
|
972
|
-
if (
|
|
973
|
-
|
|
1358
|
+
if (
|
|
1359
|
+
!finiteNumber(body.pools.ram_mb) ||
|
|
1360
|
+
!finiteNumber(body.pools.cpu_milli) ||
|
|
1361
|
+
!finiteNumber(body.pools.disk_gb) ||
|
|
1362
|
+
!finiteNumber(body.ram_remaining_mb) ||
|
|
1363
|
+
!finiteNumber(body.cpu_remaining_milli) ||
|
|
1364
|
+
!finiteNumber(body.disk_remaining_gb)
|
|
1365
|
+
) {
|
|
1366
|
+
return null;
|
|
1367
|
+
}
|
|
1368
|
+
return `Plan: ${body.plan}\nRAM used: ${usedRam} MiB of ${body.pools.ram_mb} MiB\nRAM left: ${body.ram_remaining_mb} MiB\nCPU used: ${usedCpu} mCPU of ${body.pools.cpu_milli} mCPU\nCPU left: ${body.cpu_remaining_milli} mCPU\nDisk used: ${usedDisk} GB of ${body.pools.disk_gb} GB\nDisk left: ${body.disk_remaining_gb} GB\n`;
|
|
974
1369
|
}
|
|
975
1370
|
|
|
976
1371
|
export async function usage(args, io) {
|
|
@@ -997,7 +1392,9 @@ function plansText(body) {
|
|
|
997
1392
|
for (const row of body.plans) {
|
|
998
1393
|
if (!isRecord(row) || typeof row.name !== "string") return null;
|
|
999
1394
|
if (!finiteNumber(row.ram_mb) || !finiteNumber(row.cpu_milli) || !finiteNumber(row.disk_gb)) return null;
|
|
1000
|
-
|
|
1395
|
+
let line = `${row.name}: RAM ${row.ram_mb} MiB, CPU ${row.cpu_milli} mCPU, disk ${row.disk_gb} GB`;
|
|
1396
|
+
if (row.on_sale === false) line += " (not on sale yet)";
|
|
1397
|
+
lines.push(line);
|
|
1001
1398
|
}
|
|
1002
1399
|
if (lines.length === 0) return "";
|
|
1003
1400
|
return `${lines.join("\n")}\n`;
|
|
@@ -1123,15 +1520,56 @@ function isRecord(value) {
|
|
|
1123
1520
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1124
1521
|
}
|
|
1125
1522
|
|
|
1523
|
+
const DIAGNOSE_LINE_CHARS = 200;
|
|
1524
|
+
const DIAGNOSE_LINE_COUNT = 50;
|
|
1525
|
+
|
|
1526
|
+
// T-522: the cut counts code points, so it never splits a surrogate pair. The
|
|
1527
|
+
// loop stops at the limit, so a long line is not copied whole first.
|
|
1528
|
+
function firstCodePoints(text, count) {
|
|
1529
|
+
let end = 0;
|
|
1530
|
+
let seen = 0;
|
|
1531
|
+
for (const char of text) {
|
|
1532
|
+
if (seen === count) break;
|
|
1533
|
+
end += char.length;
|
|
1534
|
+
seen += 1;
|
|
1535
|
+
}
|
|
1536
|
+
return text.slice(0, end);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function capLines(text) {
|
|
1540
|
+
const parts = text.split("\n");
|
|
1541
|
+
if (parts.length > 0 && parts.at(-1) === "") parts.pop();
|
|
1542
|
+
const capped = parts
|
|
1543
|
+
.slice(0, DIAGNOSE_LINE_COUNT)
|
|
1544
|
+
.map((line) => firstCodePoints(sanitizeCli(line), DIAGNOSE_LINE_CHARS));
|
|
1545
|
+
return capped.length === 0 ? "\n" : `${capped.join("\n")}\n`;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1126
1548
|
function diagnoseSentences(body) {
|
|
1127
1549
|
if (!isRecord(body)) return null;
|
|
1128
1550
|
if (body.diagnosis === null) {
|
|
1129
|
-
return typeof body.note === "string" ?
|
|
1551
|
+
return typeof body.note === "string" ? capLines(body.note) : null;
|
|
1130
1552
|
}
|
|
1131
1553
|
if (!isRecord(body.diagnosis) || typeof body.diagnosis.reason !== "string") return null;
|
|
1132
|
-
if (body.diagnosis.next_step === undefined) return
|
|
1554
|
+
if (body.diagnosis.next_step === undefined) return capLines(body.diagnosis.reason);
|
|
1133
1555
|
if (typeof body.diagnosis.next_step !== "string") return null;
|
|
1134
|
-
return `${body.diagnosis.reason}
|
|
1556
|
+
return `${capLines(body.diagnosis.reason)}${capLines(body.diagnosis.next_step)}`;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// T-439 fix round 2: the diagnose --json echo carries the ruling's named
|
|
1560
|
+
// fields, so the three strings are sanitized on a shallow copy before the
|
|
1561
|
+
// echo; the copy keeps the echo valid JSON.
|
|
1562
|
+
function diagnoseEcho(body) {
|
|
1563
|
+
if (!isRecord(body)) return body;
|
|
1564
|
+
const copy = { ...body };
|
|
1565
|
+
if (typeof copy.note === "string") copy.note = sanitizeCli(copy.note);
|
|
1566
|
+
if (isRecord(copy.diagnosis)) {
|
|
1567
|
+
const diagnosis = { ...copy.diagnosis };
|
|
1568
|
+
if (typeof diagnosis.reason === "string") diagnosis.reason = sanitizeCli(diagnosis.reason);
|
|
1569
|
+
if (typeof diagnosis.next_step === "string") diagnosis.next_step = sanitizeCli(diagnosis.next_step);
|
|
1570
|
+
copy.diagnosis = diagnosis;
|
|
1571
|
+
}
|
|
1572
|
+
return copy;
|
|
1135
1573
|
}
|
|
1136
1574
|
|
|
1137
1575
|
function databaseFields(value) {
|
|
@@ -1204,7 +1642,7 @@ export async function diagnose(args, io) {
|
|
|
1204
1642
|
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1205
1643
|
const human = diagnoseSentences(result.body);
|
|
1206
1644
|
if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
1207
|
-
return succeed(io, loaded.key, json, result.body, human);
|
|
1645
|
+
return succeed(io, loaded.key, json, diagnoseEcho(result.body), human);
|
|
1208
1646
|
}
|
|
1209
1647
|
|
|
1210
1648
|
async function databaseCreate(args, io, key) {
|
|
@@ -1221,7 +1659,7 @@ async function databaseCreate(args, io, key) {
|
|
|
1221
1659
|
}
|
|
1222
1660
|
const projectId = appUuid(project);
|
|
1223
1661
|
if (projectId === null) return fail(io, key, json, SENTENCE.projectId);
|
|
1224
|
-
if (kind !== "postgres" && kind !== "redis") return fail(io, key, json,
|
|
1662
|
+
if (kind !== "postgres" && kind !== "redis") return fail(io, key, json, REFUSED_DATABASE_KIND);
|
|
1225
1663
|
const result = await call(io, key, {
|
|
1226
1664
|
path: `/v1/projects/${encodeURIComponent(projectId)}/databases`,
|
|
1227
1665
|
method: "POST",
|
|
@@ -1254,7 +1692,7 @@ async function databaseStatus(args, io, key) {
|
|
|
1254
1692
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1255
1693
|
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1256
1694
|
const view = databaseFields(isRecord(result.body) ? result.body.database : null);
|
|
1257
|
-
const statusText = isRecord(result.body) ? result.body.status : undefined;
|
|
1695
|
+
const statusText = isRecord(result.body) && typeof result.body.status === "string" ? sanitizeCli(result.body.status) : undefined;
|
|
1258
1696
|
if (view === null || typeof statusText !== "string") return fail(io, key, json, SENTENCE.failed);
|
|
1259
1697
|
const payload = {
|
|
1260
1698
|
id: view.id,
|
|
@@ -1292,6 +1730,45 @@ async function databaseBackup(args, io, key) {
|
|
|
1292
1730
|
return succeed(io, key, json, { database_id: id, backup: "run" }, "Backup run.\n");
|
|
1293
1731
|
}
|
|
1294
1732
|
|
|
1733
|
+
// T-411 R2: the database's backup list. GET /v1/databases/:id/backups and
|
|
1734
|
+
// nothing else; a Redis database is refused by the route's own sentence.
|
|
1735
|
+
async function databaseBackups(args, io, key) {
|
|
1736
|
+
const parsed = parseStrict(args, DATABASE_STATUS_OPTIONS, true);
|
|
1737
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "backups") {
|
|
1738
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1739
|
+
}
|
|
1740
|
+
const json = parsed.values.json === true;
|
|
1741
|
+
const id = appUuid(parsed.values.id);
|
|
1742
|
+
if (id === null) return fail(io, key, json, SENTENCE.databaseId);
|
|
1743
|
+
const result = await call(io, key, {
|
|
1744
|
+
path: `/v1/databases/${encodeURIComponent(id)}/backups`,
|
|
1745
|
+
method: "GET",
|
|
1746
|
+
});
|
|
1747
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1748
|
+
if (result.status !== 200) {
|
|
1749
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1750
|
+
}
|
|
1751
|
+
const raw = isRecord(result.body) && Array.isArray(result.body.backups) ? result.body.backups : null;
|
|
1752
|
+
if (raw === null) return fail(io, key, json, SENTENCE.failed);
|
|
1753
|
+
const backups = [];
|
|
1754
|
+
for (const row of raw) {
|
|
1755
|
+
if (
|
|
1756
|
+
!isRecord(row) ||
|
|
1757
|
+
typeof row.key !== "string" ||
|
|
1758
|
+
typeof row.size_bytes !== "number" ||
|
|
1759
|
+
typeof row.last_modified !== "string"
|
|
1760
|
+
) {
|
|
1761
|
+
return fail(io, key, json, SENTENCE.failed);
|
|
1762
|
+
}
|
|
1763
|
+
backups.push({ key: row.key, size_bytes: row.size_bytes, last_modified: row.last_modified });
|
|
1764
|
+
}
|
|
1765
|
+
const human =
|
|
1766
|
+
backups.length === 0
|
|
1767
|
+
? ""
|
|
1768
|
+
: `${backups.map((row) => `${row.key} ${row.size_bytes} ${row.last_modified}`).join("\n")}\n`;
|
|
1769
|
+
return succeed(io, key, json, { database_id: id, backups }, human);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1295
1772
|
async function databaseDelete(args, io, key) {
|
|
1296
1773
|
const parsed = parseStrict(args, DATABASE_DELETE_OPTIONS, true);
|
|
1297
1774
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "delete") {
|
|
@@ -1320,6 +1797,7 @@ export async function database(args, io) {
|
|
|
1320
1797
|
if (words[0] === "create") return databaseCreate(args, io, loaded.key);
|
|
1321
1798
|
if (words[0] === "status") return databaseStatus(args, io, loaded.key);
|
|
1322
1799
|
if (words[0] === "backup") return databaseBackup(args, io, loaded.key);
|
|
1800
|
+
if (words[0] === "backups") return databaseBackups(args, io, loaded.key);
|
|
1323
1801
|
if (words[0] === "delete") return databaseDelete(args, io, loaded.key);
|
|
1324
1802
|
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1325
1803
|
}
|
|
@@ -1344,6 +1822,11 @@ const STORAGE_DELETE_OPTIONS = {
|
|
|
1344
1822
|
yes: { type: "boolean" },
|
|
1345
1823
|
};
|
|
1346
1824
|
|
|
1825
|
+
const STORAGE_ROTATE_OPTIONS = {
|
|
1826
|
+
json: { type: "boolean" },
|
|
1827
|
+
id: { type: "string" },
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1347
1830
|
const STORAGE_UNION = {
|
|
1348
1831
|
json: { type: "boolean" },
|
|
1349
1832
|
name: { type: "string" },
|
|
@@ -1397,29 +1880,12 @@ function storageLines(view) {
|
|
|
1397
1880
|
return lines;
|
|
1398
1881
|
}
|
|
1399
1882
|
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
const
|
|
1406
|
-
const name = trimmed(parsed.values.name);
|
|
1407
|
-
if (name === "") return fail(io, key, json, SENTENCE.refused);
|
|
1408
|
-
const result = await call(io, key, {
|
|
1409
|
-
path: "/v1/buckets",
|
|
1410
|
-
method: "POST",
|
|
1411
|
-
body: { name },
|
|
1412
|
-
});
|
|
1413
|
-
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1414
|
-
const refusal = storageCreateMessage(result.status, result.body);
|
|
1415
|
-
if (refusal !== null) return fail(io, key, json, refusal);
|
|
1416
|
-
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1417
|
-
if (result.status !== 201) {
|
|
1418
|
-
return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status, result.retryAfterSeconds));
|
|
1419
|
-
}
|
|
1420
|
-
const body = isRecord(result.body) ? result.body : null;
|
|
1421
|
-
const view = body === null ? null : storageFields(body.bucket);
|
|
1422
|
-
const credentials = body === null ? null : body.credentials;
|
|
1883
|
+
// A bucket create and a key rotate answer with the same body: the bucket row
|
|
1884
|
+
// and the credentials to hand to an S3 client. The secret is printed once.
|
|
1885
|
+
function storageIssued(io, key, json, body) {
|
|
1886
|
+
const root = isRecord(body) ? body : null;
|
|
1887
|
+
const view = root === null ? null : storageFields(root.bucket);
|
|
1888
|
+
const credentials = root === null ? null : root.credentials;
|
|
1423
1889
|
if (
|
|
1424
1890
|
view === null ||
|
|
1425
1891
|
!isRecord(credentials) ||
|
|
@@ -1448,6 +1914,29 @@ async function storageCreate(args, io, key) {
|
|
|
1448
1914
|
return succeed(io, key, json, { bucket: view, credentials: payloadCredentials }, `${lines.join("\n")}\n`);
|
|
1449
1915
|
}
|
|
1450
1916
|
|
|
1917
|
+
async function storageCreate(args, io, key) {
|
|
1918
|
+
const parsed = parseStrict(args, STORAGE_CREATE_OPTIONS, true);
|
|
1919
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "create") {
|
|
1920
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1921
|
+
}
|
|
1922
|
+
const json = parsed.values.json === true;
|
|
1923
|
+
const name = trimmed(parsed.values.name);
|
|
1924
|
+
if (name === "") return fail(io, key, json, SENTENCE.refused);
|
|
1925
|
+
const result = await call(io, key, {
|
|
1926
|
+
path: "/v1/buckets",
|
|
1927
|
+
method: "POST",
|
|
1928
|
+
body: { name },
|
|
1929
|
+
});
|
|
1930
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1931
|
+
const refusal = storageCreateMessage(result.status, result.body);
|
|
1932
|
+
if (refusal !== null) return fail(io, key, json, refusal);
|
|
1933
|
+
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1934
|
+
if (result.status !== 201) {
|
|
1935
|
+
return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status, result.retryAfterSeconds));
|
|
1936
|
+
}
|
|
1937
|
+
return storageIssued(io, key, json, result.body);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1451
1940
|
async function storageList(args, io, key) {
|
|
1452
1941
|
const parsed = parseStrict(args, STORAGE_LIST_OPTIONS, true);
|
|
1453
1942
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "list") {
|
|
@@ -1490,6 +1979,28 @@ async function storageShow(args, io, key) {
|
|
|
1490
1979
|
return succeed(io, key, json, { bucket: view }, `${storageLines(view).join("\n")}\n`);
|
|
1491
1980
|
}
|
|
1492
1981
|
|
|
1982
|
+
// T-411 R15: a new key for one bucket. POST /v1/buckets/:id/key and nothing
|
|
1983
|
+
// else; the route revokes the old key and says what went wrong in its body.
|
|
1984
|
+
async function storageRotate(args, io, key) {
|
|
1985
|
+
const parsed = parseStrict(args, STORAGE_ROTATE_OPTIONS, true);
|
|
1986
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "rotate") {
|
|
1987
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1988
|
+
}
|
|
1989
|
+
const json = parsed.values.json === true;
|
|
1990
|
+
const id = appUuid(parsed.values.id);
|
|
1991
|
+
if (id === null) return fail(io, key, json, SENTENCE.refused);
|
|
1992
|
+
const result = await call(io, key, {
|
|
1993
|
+
path: `/v1/buckets/${encodeURIComponent(id)}/key`,
|
|
1994
|
+
method: "POST",
|
|
1995
|
+
});
|
|
1996
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1997
|
+
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1998
|
+
if (result.status !== 200) {
|
|
1999
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
2000
|
+
}
|
|
2001
|
+
return storageIssued(io, key, json, result.body);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1493
2004
|
async function storageDelete(args, io, key) {
|
|
1494
2005
|
const parsed = parseStrict(args, STORAGE_DELETE_OPTIONS, true);
|
|
1495
2006
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "delete") {
|
|
@@ -1520,6 +2031,7 @@ export async function storage(args, io) {
|
|
|
1520
2031
|
if (words[0] === "create") return storageCreate(args, io, loaded.key);
|
|
1521
2032
|
if (words[0] === "list") return storageList(args, io, loaded.key);
|
|
1522
2033
|
if (words[0] === "show") return storageShow(args, io, loaded.key);
|
|
2034
|
+
if (words[0] === "rotate") return storageRotate(args, io, loaded.key);
|
|
1523
2035
|
if (words[0] === "delete") return storageDelete(args, io, loaded.key);
|
|
1524
2036
|
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1525
2037
|
}
|
|
@@ -1561,6 +2073,11 @@ const APP_REMOVE_OPTIONS = {
|
|
|
1561
2073
|
confirm: { type: "string" },
|
|
1562
2074
|
};
|
|
1563
2075
|
|
|
2076
|
+
const APP_START_STOP_OPTIONS = {
|
|
2077
|
+
json: { type: "boolean" },
|
|
2078
|
+
app: { type: "string" },
|
|
2079
|
+
};
|
|
2080
|
+
|
|
1564
2081
|
function appPositionals(args) {
|
|
1565
2082
|
try {
|
|
1566
2083
|
return parseArgs({
|
|
@@ -1580,9 +2097,40 @@ export async function app(args, io) {
|
|
|
1580
2097
|
const words = appPositionals(args);
|
|
1581
2098
|
if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1582
2099
|
if (words[0] === "remove") return appRemove(args, io, loaded.key);
|
|
2100
|
+
if (words[0] === "start") return appStartStop(args, io, loaded.key, "start");
|
|
2101
|
+
if (words[0] === "stop") return appStartStop(args, io, loaded.key, "stop");
|
|
1583
2102
|
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1584
2103
|
}
|
|
1585
2104
|
|
|
2105
|
+
// T-411 R2: the customer start and stop routes' doors. One POST, no body, and
|
|
2106
|
+
// the route's refusal is the caller's.
|
|
2107
|
+
async function appStartStop(args, io, key, word) {
|
|
2108
|
+
const parsed = parseStrict(args, APP_START_STOP_OPTIONS, true);
|
|
2109
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== word) {
|
|
2110
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
2111
|
+
}
|
|
2112
|
+
const json = parsed.values.json === true;
|
|
2113
|
+
const appId = appUuid(parsed.values.app);
|
|
2114
|
+
if (appId === null) return fail(io, key, json, SENTENCE.app);
|
|
2115
|
+
const result = await call(io, key, {
|
|
2116
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/${word}`,
|
|
2117
|
+
method: "POST",
|
|
2118
|
+
});
|
|
2119
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
2120
|
+
if (result.status !== 200) {
|
|
2121
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
2122
|
+
}
|
|
2123
|
+
const payload =
|
|
2124
|
+
word === "start" ? { app_id: appId, started: true } : { app_id: appId, stopped: true };
|
|
2125
|
+
return succeed(
|
|
2126
|
+
io,
|
|
2127
|
+
key,
|
|
2128
|
+
json,
|
|
2129
|
+
payload,
|
|
2130
|
+
`${word === "start" ? SENTENCE.appStarted : SENTENCE.appStopped}\n`,
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
1586
2134
|
async function appRemove(args, io, key) {
|
|
1587
2135
|
const parsed = parseStrict(args, APP_REMOVE_OPTIONS, true);
|
|
1588
2136
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "remove") {
|
|
@@ -1640,6 +2188,111 @@ function validRelPath(p) {
|
|
|
1640
2188
|
return p.split("/").every((segment) => segment.length > 0 && segment !== "..");
|
|
1641
2189
|
}
|
|
1642
2190
|
|
|
2191
|
+
function isRootEnvName(name) {
|
|
2192
|
+
return name === ".env" || name.startsWith(".env.");
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// `*` and `?` against one path segment, no regex: one cursor over the pattern and
|
|
2196
|
+
// one over the text, rewinding to the last `*` on a mismatch.
|
|
2197
|
+
function segmentMatches(pattern, text) {
|
|
2198
|
+
let p = 0;
|
|
2199
|
+
let t = 0;
|
|
2200
|
+
let starAt = -1;
|
|
2201
|
+
let rewindTo = 0;
|
|
2202
|
+
while (t < text.length) {
|
|
2203
|
+
if (p < pattern.length && (pattern[p] === "?" || pattern[p] === text[t])) {
|
|
2204
|
+
p++;
|
|
2205
|
+
t++;
|
|
2206
|
+
} else if (p < pattern.length && pattern[p] === "*") {
|
|
2207
|
+
starAt = p;
|
|
2208
|
+
rewindTo = t;
|
|
2209
|
+
p++;
|
|
2210
|
+
} else if (starAt >= 0) {
|
|
2211
|
+
p = starAt + 1;
|
|
2212
|
+
rewindTo++;
|
|
2213
|
+
t = rewindTo;
|
|
2214
|
+
} else {
|
|
2215
|
+
return false;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
while (p < pattern.length && pattern[p] === "*") p++;
|
|
2219
|
+
return p === pattern.length;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function countLiteral(haystack, needle) {
|
|
2223
|
+
let count = 0;
|
|
2224
|
+
let at = haystack.indexOf(needle);
|
|
2225
|
+
while (at !== -1) {
|
|
2226
|
+
count++;
|
|
2227
|
+
at = haystack.indexOf(needle, at + needle.length);
|
|
2228
|
+
}
|
|
2229
|
+
return count;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// The rules the client can answer with string work. A line holding `[`, a backslash
|
|
2233
|
+
// or a `**` that is not the leading `**/` sets broken, and the caller then treats
|
|
2234
|
+
// every .env as ignored rather than guessing.
|
|
2235
|
+
function parseGitignore(text) {
|
|
2236
|
+
const rules = [];
|
|
2237
|
+
let broken = false;
|
|
2238
|
+
for (const rawLine of text.split("\n")) {
|
|
2239
|
+
let line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
2240
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
2241
|
+
if (line.includes("[") || line.includes("\\")) {
|
|
2242
|
+
broken = true;
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
if (countLiteral(line, "**") > (line.startsWith("**/") ? 1 : 0)) {
|
|
2246
|
+
broken = true;
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
let negation = false;
|
|
2250
|
+
if (line.startsWith("!")) {
|
|
2251
|
+
negation = true;
|
|
2252
|
+
line = line.slice(1);
|
|
2253
|
+
}
|
|
2254
|
+
if (line.startsWith("/")) line = line.slice(1);
|
|
2255
|
+
if (line.startsWith("**/")) line = line.slice(3);
|
|
2256
|
+
if (line.endsWith("/")) continue;
|
|
2257
|
+
// A rule holding a slash is anchored to the root and names a path below it,
|
|
2258
|
+
// so it can never match a root file name. Only the root names are ever asked.
|
|
2259
|
+
if (line.length === 0 || line.includes("/")) continue;
|
|
2260
|
+
rules.push({ negation, pattern: line });
|
|
2261
|
+
}
|
|
2262
|
+
return {
|
|
2263
|
+
broken,
|
|
2264
|
+
matches(name) {
|
|
2265
|
+
let ignored = false;
|
|
2266
|
+
for (const rule of rules) {
|
|
2267
|
+
if (segmentMatches(rule.pattern, name)) ignored = !rule.negation;
|
|
2268
|
+
}
|
|
2269
|
+
return ignored;
|
|
2270
|
+
},
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// A missing .gitignore ignores nothing. One the client cannot read as a plain file
|
|
2275
|
+
// fails closed, so a .env is never posted on a guess about its rules.
|
|
2276
|
+
function rootGitignoreMatcher(root) {
|
|
2277
|
+
const file = path.join(root, ".gitignore");
|
|
2278
|
+
let stat;
|
|
2279
|
+
try {
|
|
2280
|
+
stat = lstatSync(file);
|
|
2281
|
+
} catch {
|
|
2282
|
+
return () => false;
|
|
2283
|
+
}
|
|
2284
|
+
if (!stat.isFile()) return () => true;
|
|
2285
|
+
let text;
|
|
2286
|
+
try {
|
|
2287
|
+
text = readFileSync(file, "utf8");
|
|
2288
|
+
} catch {
|
|
2289
|
+
return () => true;
|
|
2290
|
+
}
|
|
2291
|
+
const rules = parseGitignore(text);
|
|
2292
|
+
if (rules.broken) return () => true;
|
|
2293
|
+
return rules.matches;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
1643
2296
|
export function preflightFiles(root) {
|
|
1644
2297
|
let rootStat;
|
|
1645
2298
|
try {
|
|
@@ -1649,8 +2302,9 @@ export function preflightFiles(root) {
|
|
|
1649
2302
|
}
|
|
1650
2303
|
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return null;
|
|
1651
2304
|
|
|
2305
|
+
const gitignored = rootGitignoreMatcher(root);
|
|
1652
2306
|
const candidates = [];
|
|
1653
|
-
function walk(dir) {
|
|
2307
|
+
function walk(dir, depth) {
|
|
1654
2308
|
let entries;
|
|
1655
2309
|
try {
|
|
1656
2310
|
entries = readdirSync(dir);
|
|
@@ -1658,7 +2312,9 @@ export function preflightFiles(root) {
|
|
|
1658
2312
|
return false;
|
|
1659
2313
|
}
|
|
1660
2314
|
for (const name of entries) {
|
|
1661
|
-
if (name.startsWith("."))
|
|
2315
|
+
if (name.startsWith(".")) {
|
|
2316
|
+
if (depth !== 0 || !isRootEnvName(name) || gitignored(name)) continue;
|
|
2317
|
+
}
|
|
1662
2318
|
const fullPath = path.join(dir, name);
|
|
1663
2319
|
let stat;
|
|
1664
2320
|
try {
|
|
@@ -1668,19 +2324,19 @@ export function preflightFiles(root) {
|
|
|
1668
2324
|
}
|
|
1669
2325
|
if (stat.isSymbolicLink()) continue;
|
|
1670
2326
|
if (stat.isDirectory()) {
|
|
1671
|
-
if (SKIP_DIRS.has(name)) continue;
|
|
1672
|
-
const ok = walk(fullPath);
|
|
2327
|
+
if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
|
|
2328
|
+
const ok = walk(fullPath, depth + 1);
|
|
1673
2329
|
if (!ok) return false;
|
|
1674
2330
|
} else if (stat.isFile()) {
|
|
1675
2331
|
const rel = path.relative(root, fullPath).split(path.sep).join("/");
|
|
1676
|
-
const
|
|
1677
|
-
candidates.push({ rel, fullPath, depth, stat });
|
|
2332
|
+
const candidateDepth = rel.split("/").length - 1;
|
|
2333
|
+
candidates.push({ rel, fullPath, depth: candidateDepth, stat });
|
|
1678
2334
|
}
|
|
1679
2335
|
}
|
|
1680
2336
|
return true;
|
|
1681
2337
|
}
|
|
1682
2338
|
|
|
1683
|
-
if (!walk(root)) return null;
|
|
2339
|
+
if (!walk(root, 0)) return null;
|
|
1684
2340
|
|
|
1685
2341
|
candidates.sort((a, b) => {
|
|
1686
2342
|
if (a.depth !== b.depth) return a.depth - b.depth;
|
|
@@ -1775,11 +2431,29 @@ export async function preflight(args, io) {
|
|
|
1775
2431
|
!isRecord(result.body) ||
|
|
1776
2432
|
typeof result.body.verdict !== "string" ||
|
|
1777
2433
|
!Array.isArray(result.body.checks) ||
|
|
1778
|
-
(result.body.verdict !== "pass" &&
|
|
2434
|
+
(result.body.verdict !== "pass" &&
|
|
2435
|
+
result.body.verdict !== "fixable" &&
|
|
2436
|
+
result.body.verdict !== "refuse")
|
|
1779
2437
|
) {
|
|
1780
2438
|
return fail(io, loaded.key, json, SENTENCE.failed, undefined, 2);
|
|
1781
2439
|
}
|
|
1782
2440
|
|
|
2441
|
+
// T-439 fix round 2: the --json echo carries the verdict and the check
|
|
2442
|
+
// strings, so they are sanitized on a shallow copy; the copy keeps the
|
|
2443
|
+
// echo valid JSON because JSON.stringify does not escape the bidi marks.
|
|
2444
|
+
const preflightEcho = () => {
|
|
2445
|
+
const copy = { ...result.body, verdict: sanitizeCli(result.body.verdict) };
|
|
2446
|
+
copy.checks = result.body.checks.map((check) => {
|
|
2447
|
+
if (!isRecord(check)) return check;
|
|
2448
|
+
const row = { ...check };
|
|
2449
|
+
if (typeof row.name === "string") row.name = sanitizeCli(row.name);
|
|
2450
|
+
if (typeof row.result === "string") row.result = sanitizeCli(row.result);
|
|
2451
|
+
if (typeof row.reason === "string") row.reason = sanitizeCli(row.reason);
|
|
2452
|
+
return row;
|
|
2453
|
+
});
|
|
2454
|
+
return copy;
|
|
2455
|
+
};
|
|
2456
|
+
|
|
1783
2457
|
if (walked.leftOut > 0) {
|
|
1784
2458
|
const leftOutMsg =
|
|
1785
2459
|
walked.leftOut === 1
|
|
@@ -1790,11 +2464,11 @@ export async function preflight(args, io) {
|
|
|
1790
2464
|
|
|
1791
2465
|
const exitCode = result.body.verdict === "refuse" ? 1 : 0;
|
|
1792
2466
|
if (json) {
|
|
1793
|
-
writeStdout(io, loaded.key, `${JSON.stringify(
|
|
2467
|
+
writeStdout(io, loaded.key, `${JSON.stringify(preflightEcho())}\n`);
|
|
1794
2468
|
return { exitCode };
|
|
1795
2469
|
}
|
|
1796
2470
|
|
|
1797
|
-
let human = `Verdict: ${result.body.verdict}\n`;
|
|
2471
|
+
let human = `Verdict: ${sanitizeCli(result.body.verdict)}\n`;
|
|
1798
2472
|
for (const check of result.body.checks) {
|
|
1799
2473
|
if (
|
|
1800
2474
|
isRecord(check) &&
|
|
@@ -1802,7 +2476,7 @@ export async function preflight(args, io) {
|
|
|
1802
2476
|
typeof check.result === "string" &&
|
|
1803
2477
|
typeof check.reason === "string"
|
|
1804
2478
|
) {
|
|
1805
|
-
human += `${check.name}: ${check.result} - ${check.reason}\n`;
|
|
2479
|
+
human += `${sanitizeCli(check.name)}: ${sanitizeCli(check.result)} - ${sanitizeCli(check.reason)}\n`;
|
|
1806
2480
|
}
|
|
1807
2481
|
}
|
|
1808
2482
|
writeStdout(io, loaded.key, human);
|
|
@@ -1880,9 +2554,16 @@ const COMPOSE_ACTION_OPTIONS = {
|
|
|
1880
2554
|
id: { type: "string", multiple: true },
|
|
1881
2555
|
};
|
|
1882
2556
|
|
|
2557
|
+
const COMPOSE_REMOVE_OPTIONS = {
|
|
2558
|
+
json: { type: "boolean" },
|
|
2559
|
+
id: { type: "string", multiple: true },
|
|
2560
|
+
confirm: { type: "string" },
|
|
2561
|
+
};
|
|
2562
|
+
|
|
1883
2563
|
const COMPOSE_UNION = {
|
|
1884
2564
|
json: { type: "boolean" },
|
|
1885
2565
|
id: { type: "string" },
|
|
2566
|
+
confirm: { type: "string" },
|
|
1886
2567
|
};
|
|
1887
2568
|
|
|
1888
2569
|
function composePositionals(args) {
|
|
@@ -2007,7 +2688,9 @@ async function composeDeploy(args, io, key) {
|
|
|
2007
2688
|
return succeed(io, key, json, payload, `${composeLines(view).join("\n")}\n`);
|
|
2008
2689
|
}
|
|
2009
2690
|
|
|
2010
|
-
|
|
2691
|
+
// status, start and stop answer with the same body: the stack and its status.
|
|
2692
|
+
// status is the read (GET on the stack); start and stop post an action path.
|
|
2693
|
+
async function composeOne(args, io, key, action, method, pathSuffix) {
|
|
2011
2694
|
const parsed = parseStrict(args, COMPOSE_ACTION_OPTIONS, true);
|
|
2012
2695
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== action) {
|
|
2013
2696
|
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
@@ -2015,9 +2698,10 @@ async function composeStartStop(args, io, key, action) {
|
|
|
2015
2698
|
const json = parsed.values.json === true;
|
|
2016
2699
|
const required = requireComposeId(io, key, json, parsed.values.id);
|
|
2017
2700
|
if (required.exit) return required.exit;
|
|
2701
|
+
const id = encodeURIComponent(required.id);
|
|
2018
2702
|
const result = await call(io, key, {
|
|
2019
|
-
path: `/v1/compose/${
|
|
2020
|
-
method
|
|
2703
|
+
path: pathSuffix === "" ? `/v1/compose/${id}` : `/v1/compose/${id}/${pathSuffix}`,
|
|
2704
|
+
method,
|
|
2021
2705
|
});
|
|
2022
2706
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
2023
2707
|
if (result.status !== 200) {
|
|
@@ -2032,6 +2716,30 @@ async function composeStartStop(args, io, key, action) {
|
|
|
2032
2716
|
return succeed(io, key, json, payload, `${lines.join("\n")}\n`);
|
|
2033
2717
|
}
|
|
2034
2718
|
|
|
2719
|
+
// T-411 R19, R20: the route owns the name check. The CLI sends what the caller
|
|
2720
|
+
// typed and never reads the stack first to compare it.
|
|
2721
|
+
async function composeRemove(args, io, key) {
|
|
2722
|
+
const parsed = parseStrict(args, COMPOSE_REMOVE_OPTIONS, true);
|
|
2723
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "remove") {
|
|
2724
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
2725
|
+
}
|
|
2726
|
+
const json = parsed.values.json === true;
|
|
2727
|
+
const required = requireComposeId(io, key, json, parsed.values.id);
|
|
2728
|
+
if (required.exit) return required.exit;
|
|
2729
|
+
const confirm = trimmed(parsed.values.confirm);
|
|
2730
|
+
if (confirm === "") return fail(io, key, json, SENTENCE.composeConfirm);
|
|
2731
|
+
const result = await call(io, key, {
|
|
2732
|
+
path: `/v1/compose/${encodeURIComponent(required.id)}`,
|
|
2733
|
+
method: "DELETE",
|
|
2734
|
+
body: { confirm },
|
|
2735
|
+
});
|
|
2736
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
2737
|
+
if (result.status !== 204) {
|
|
2738
|
+
return doorRefusal(io, key, json, result, sentenceFor(result.status, result.retryAfterSeconds));
|
|
2739
|
+
}
|
|
2740
|
+
return succeed(io, key, json, { compose_id: required.id, removed: true }, `${SENTENCE.composeRemoved}\n`);
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2035
2743
|
export async function compose(args, io) {
|
|
2036
2744
|
const loaded = loadKey(io);
|
|
2037
2745
|
if (loaded.exit) return loaded.exit;
|
|
@@ -2039,7 +2747,110 @@ export async function compose(args, io) {
|
|
|
2039
2747
|
if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
2040
2748
|
if (words[0] === "list") return composeList(args, io, loaded.key);
|
|
2041
2749
|
if (words[0] === "deploy") return composeDeploy(args, io, loaded.key);
|
|
2042
|
-
if (words[0] === "start") return
|
|
2043
|
-
if (words[0] === "stop") return
|
|
2750
|
+
if (words[0] === "start") return composeOne(args, io, loaded.key, "start", "POST", "start");
|
|
2751
|
+
if (words[0] === "stop") return composeOne(args, io, loaded.key, "stop", "POST", "stop");
|
|
2752
|
+
if (words[0] === "status") return composeOne(args, io, loaded.key, "status", "GET", "");
|
|
2753
|
+
if (words[0] === "remove") return composeRemove(args, io, loaded.key);
|
|
2754
|
+
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// ponytail: copy of ladder in src/lib/plans/limits.ts. Sellable stays on the route. A new ladder name is refused here until this list grows.
|
|
2758
|
+
const BILLING_PLAN_NAMES = ["starter", "growth", "pro", "scale", "business"];
|
|
2759
|
+
const BILLING_OFF = "Billing is not available right now. Try again later.";
|
|
2760
|
+
|
|
2761
|
+
const BILLING_UNION = {
|
|
2762
|
+
json: { type: "boolean" },
|
|
2763
|
+
plan: { type: "string", multiple: true },
|
|
2764
|
+
};
|
|
2765
|
+
|
|
2766
|
+
const BILLING_PORTAL_OPTIONS = {
|
|
2767
|
+
json: { type: "boolean" },
|
|
2768
|
+
};
|
|
2769
|
+
|
|
2770
|
+
function billingPositionals(args) {
|
|
2771
|
+
try {
|
|
2772
|
+
return parseArgs({
|
|
2773
|
+
args: Array.isArray(args) ? args : [],
|
|
2774
|
+
strict: false,
|
|
2775
|
+
allowPositionals: true,
|
|
2776
|
+
options: BILLING_UNION,
|
|
2777
|
+
}).positionals;
|
|
2778
|
+
} catch {
|
|
2779
|
+
return null;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
function billingMessage(status, body, retryAfterSeconds) {
|
|
2784
|
+
if (isRecord(body) && typeof body.message === "string") {
|
|
2785
|
+
// sanitizeCli keeps tab and newline; a billing message is one line, so the
|
|
2786
|
+
// remaining tab and newline are removed after it.
|
|
2787
|
+
const message = sanitizeCli(body.message).replace(/[\u0000-\u001F\u007F\u0080-\u009F]/g, "");
|
|
2788
|
+
if (message.length > 0) return message;
|
|
2789
|
+
}
|
|
2790
|
+
if (status === 503) return BILLING_OFF;
|
|
2791
|
+
return sentenceFor(status, retryAfterSeconds);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
function billingUrl(result) {
|
|
2795
|
+
if (!isRecord(result.body) || typeof result.body.url !== "string") return null;
|
|
2796
|
+
const value = result.body.url;
|
|
2797
|
+
if (value.length === 0 || value.includes(" ") || /[\u0000-\u001F\u007F\u0080-\u009F]/.test(value)) return null;
|
|
2798
|
+
try {
|
|
2799
|
+
const parsed = new URL(value);
|
|
2800
|
+
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") return null;
|
|
2801
|
+
} catch {
|
|
2802
|
+
return null;
|
|
2803
|
+
}
|
|
2804
|
+
return value;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
async function billingFinish(io, key, json, result) {
|
|
2808
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
2809
|
+
if (result.status !== 201) {
|
|
2810
|
+
return fail(io, key, json, billingMessage(result.status, result.body, result.retryAfterSeconds));
|
|
2811
|
+
}
|
|
2812
|
+
const url = billingUrl(result);
|
|
2813
|
+
if (url === null) return fail(io, key, json, SENTENCE.failed);
|
|
2814
|
+
return succeed(io, key, json, result.body, `${url}\n`);
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
async function billingCheckout(args, io, key) {
|
|
2818
|
+
const parsed = parseStrict(args, BILLING_UNION, true);
|
|
2819
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "checkout") {
|
|
2820
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
2821
|
+
}
|
|
2822
|
+
const json = parsed.values.json === true;
|
|
2823
|
+
const plans = Array.isArray(parsed.values.plan) ? parsed.values.plan : [];
|
|
2824
|
+
if (plans.length !== 1) return fail(io, key, json, SENTENCE.refused);
|
|
2825
|
+
const plan = trimmed(plans[0]);
|
|
2826
|
+
if (!BILLING_PLAN_NAMES.includes(plan)) return fail(io, key, json, SENTENCE.refused);
|
|
2827
|
+
const result = await call(io, key, {
|
|
2828
|
+
path: "/v1/billing/checkout",
|
|
2829
|
+
method: "POST",
|
|
2830
|
+
body: { plan },
|
|
2831
|
+
});
|
|
2832
|
+
return billingFinish(io, key, json, result);
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
async function billingPortal(args, io, key) {
|
|
2836
|
+
const parsed = parseStrict(args, BILLING_PORTAL_OPTIONS, true);
|
|
2837
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "portal") {
|
|
2838
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
2839
|
+
}
|
|
2840
|
+
const json = parsed.values.json === true;
|
|
2841
|
+
const result = await call(io, key, {
|
|
2842
|
+
path: "/v1/billing/portal",
|
|
2843
|
+
method: "POST",
|
|
2844
|
+
});
|
|
2845
|
+
return billingFinish(io, key, json, result);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
export async function billing(args, io) {
|
|
2849
|
+
const loaded = loadKey(io);
|
|
2850
|
+
if (loaded.exit) return loaded.exit;
|
|
2851
|
+
const words = billingPositionals(args);
|
|
2852
|
+
if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
2853
|
+
if (words[0] === "checkout") return billingCheckout(args, io, loaded.key);
|
|
2854
|
+
if (words[0] === "portal") return billingPortal(args, io, loaded.key);
|
|
2044
2855
|
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
2045
2856
|
}
|