@astris-labs/cli 0.1.3 → 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 +74 -16
- package/package.json +9 -1
- package/src/client.mjs +20 -3
- package/src/commands.mjs +976 -143
- 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],
|
|
@@ -62,33 +72,72 @@ const HTTP_SENTENCE = new Map([
|
|
|
62
72
|
[502, SENTENCE.platform],
|
|
63
73
|
]);
|
|
64
74
|
|
|
65
|
-
function
|
|
75
|
+
function rateWaitSentence(seconds) {
|
|
76
|
+
if (typeof seconds === "number") {
|
|
77
|
+
return `This account asked for too many changes in a short time. Wait ${seconds} second${seconds === 1 ? "" : "s"}, then try again.`;
|
|
78
|
+
}
|
|
79
|
+
return "This account asked for too many changes in a short time. Wait a little, then try again.";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sentenceFor(status, retryAfterSeconds = null) {
|
|
83
|
+
if (status === 429) return rateWaitSentence(retryAfterSeconds);
|
|
66
84
|
return HTTP_SENTENCE.get(status) ?? SENTENCE.failed;
|
|
67
85
|
}
|
|
68
86
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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) {
|
|
98
|
+
if (status === 429) return rateWaitSentence(retryAfterSeconds);
|
|
99
|
+
if (isRecord(body) && typeof body.message === "string") return sanitizeCli(body.message);
|
|
100
|
+
return sentenceFor(status, retryAfterSeconds);
|
|
72
101
|
}
|
|
73
102
|
|
|
74
|
-
|
|
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) {
|
|
75
116
|
if (status !== 403 || !isRecord(body) || body.error !== "upgrade_required" || typeof body.message !== "string") {
|
|
76
117
|
return null;
|
|
77
118
|
}
|
|
78
|
-
return body.message;
|
|
119
|
+
return sanitizeCli(body.message);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// T-275: the API pauses compose redeploy in the private preview and says why.
|
|
123
|
+
export function composeUnavailableMessage(status, body) {
|
|
124
|
+
if (status !== 403 || !isRecord(body) || body.error !== "compose_unavailable" || typeof body.message !== "string") {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return sanitizeCli(body.message);
|
|
79
128
|
}
|
|
80
129
|
|
|
81
|
-
function storageCreateMessage(status, body) {
|
|
130
|
+
export function storageCreateMessage(status, body) {
|
|
82
131
|
if (!isRecord(body)) return null;
|
|
83
132
|
if (status === 403 && body.error === "bucket_limit") {
|
|
84
133
|
if (typeof body.message === "string" && body.message.length > 0) {
|
|
85
|
-
return body.message;
|
|
134
|
+
return sanitizeCli(body.message);
|
|
86
135
|
}
|
|
87
136
|
return SENTENCE.bucketLimit;
|
|
88
137
|
}
|
|
89
138
|
if (status === 503 && body.error === "storage_unavailable") {
|
|
90
139
|
if (typeof body.message === "string" && body.message.length > 0) {
|
|
91
|
-
return body.message;
|
|
140
|
+
return sanitizeCli(body.message);
|
|
92
141
|
}
|
|
93
142
|
return null;
|
|
94
143
|
}
|
|
@@ -163,6 +212,7 @@ const DEPLOY_OPTIONS = {
|
|
|
163
212
|
command: { type: "string" },
|
|
164
213
|
schedule: { type: "string" },
|
|
165
214
|
hook: { type: "boolean" },
|
|
215
|
+
wait: { type: "boolean" },
|
|
166
216
|
};
|
|
167
217
|
|
|
168
218
|
const ENV_OPTIONS = {
|
|
@@ -203,6 +253,7 @@ function parseFlags(args, extra) {
|
|
|
203
253
|
else if (token.name === "schedule") flags.schedule = token.value;
|
|
204
254
|
else if (token.name === "names") flags.names = true;
|
|
205
255
|
else if (token.name === "hook") flags.hook = true;
|
|
256
|
+
else if (token.name === "wait") flags.wait = true;
|
|
206
257
|
}
|
|
207
258
|
return flags;
|
|
208
259
|
}
|
|
@@ -294,6 +345,22 @@ function hostnamesOf(body) {
|
|
|
294
345
|
return hostnames.filter((item) => typeof item === "string");
|
|
295
346
|
}
|
|
296
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
|
+
|
|
297
364
|
function deploymentRows(body) {
|
|
298
365
|
const rows = Array.isArray(body?.deployments) ? body.deployments.slice(0, 5) : [];
|
|
299
366
|
return rows.map((row) => ({
|
|
@@ -304,6 +371,78 @@ function deploymentRows(body) {
|
|
|
304
371
|
}));
|
|
305
372
|
}
|
|
306
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
|
+
|
|
307
446
|
function envObject(stdin) {
|
|
308
447
|
if (typeof stdin !== "string") return null;
|
|
309
448
|
let value;
|
|
@@ -327,23 +466,27 @@ function findProjectId(body, name) {
|
|
|
327
466
|
return null;
|
|
328
467
|
}
|
|
329
468
|
|
|
330
|
-
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
|
+
}
|
|
331
476
|
const result = await call(io, key, {
|
|
332
477
|
path: `/v1/apps/${encodeURIComponent(appId)}/deploy`,
|
|
333
478
|
method: "POST",
|
|
334
479
|
});
|
|
335
480
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
336
|
-
if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status));
|
|
481
|
+
if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
337
482
|
const application = result.body?.application ?? {};
|
|
338
483
|
const id = text(application.id) || appId;
|
|
339
484
|
const name = text(application.name);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
`${id}\n${name}\n`,
|
|
346
|
-
);
|
|
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);
|
|
347
490
|
}
|
|
348
491
|
|
|
349
492
|
// T-146: the create-only body keys from --build and --kind, or null when the
|
|
@@ -445,7 +588,7 @@ async function createThenDeploy(io, key, json, flags) {
|
|
|
445
588
|
try {
|
|
446
589
|
const listed = await call(io, key, { path: "/v1/projects", method: "GET" });
|
|
447
590
|
if (listed.network) return fail(io, key, json, SENTENCE.platform, created);
|
|
448
|
-
if (listed.status !== 200) return fail(io, key, json, sentenceFor(listed.status), created);
|
|
591
|
+
if (listed.status !== 200) return fail(io, key, json, sentenceFor(listed.status, listed.retryAfterSeconds), created);
|
|
449
592
|
let projectId = findProjectId(listed.body, project);
|
|
450
593
|
if (projectId === null) {
|
|
451
594
|
const made = await call(io, key, {
|
|
@@ -456,7 +599,7 @@ async function createThenDeploy(io, key, json, flags) {
|
|
|
456
599
|
if (made.network) return fail(io, key, json, SENTENCE.platform, created);
|
|
457
600
|
const madeId = made.body?.project?.id;
|
|
458
601
|
if (made.status !== 201 || typeof madeId !== "string" || madeId === "") {
|
|
459
|
-
return fail(io, key, json, made.status === 201 ? SENTENCE.failed : sentenceFor(made.status), created);
|
|
602
|
+
return fail(io, key, json, made.status === 201 ? SENTENCE.failed : sentenceFor(made.status, made.retryAfterSeconds), created);
|
|
460
603
|
}
|
|
461
604
|
projectId = madeId;
|
|
462
605
|
created.projectId = madeId;
|
|
@@ -484,7 +627,7 @@ async function createThenDeploy(io, key, json, flags) {
|
|
|
484
627
|
io,
|
|
485
628
|
key,
|
|
486
629
|
json,
|
|
487
|
-
passed ?? (madeApp.status === 201 ? SENTENCE.failed : sentenceFor(madeApp.status)),
|
|
630
|
+
passed ?? (madeApp.status === 201 ? SENTENCE.failed : sentenceFor(madeApp.status, madeApp.retryAfterSeconds)),
|
|
488
631
|
created,
|
|
489
632
|
);
|
|
490
633
|
}
|
|
@@ -513,29 +656,35 @@ async function createThenDeploy(io, key, json, flags) {
|
|
|
513
656
|
`${projectId}\n${applicationId}\n${keyName}\n${keySlug}\n${deployKey.type}\n${deployKey.public_key}\n${deployKey.fingerprint}\n${SENTENCE.deployKey}\n`,
|
|
514
657
|
);
|
|
515
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
|
+
}
|
|
516
665
|
const deployed = await call(io, key, {
|
|
517
666
|
path: `/v1/apps/${encodeURIComponent(applicationId)}/deploy`,
|
|
518
667
|
method: "POST",
|
|
519
668
|
});
|
|
520
669
|
if (deployed.network) return fail(io, key, json, SENTENCE.platform, created);
|
|
521
|
-
if (deployed.status !== 202) return fail(io, key, json, sentenceFor(deployed.status), created);
|
|
670
|
+
if (deployed.status !== 202) return fail(io, key, json, sentenceFor(deployed.status, deployed.retryAfterSeconds), created);
|
|
522
671
|
const shown = deployed.body?.application ?? application;
|
|
523
672
|
const id = text(shown.id) || applicationId;
|
|
524
673
|
const shownName = text(shown.name) || name;
|
|
525
674
|
const slug = text(shown.slug);
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
);
|
|
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);
|
|
539
688
|
} catch {
|
|
540
689
|
return fail(io, key, json, SENTENCE.platform, created);
|
|
541
690
|
}
|
|
@@ -561,8 +710,11 @@ export async function deploy(args, io) {
|
|
|
561
710
|
) {
|
|
562
711
|
return fail(io, opened.key, opened.json, SENTENCE.refused);
|
|
563
712
|
}
|
|
564
|
-
if (opened.flags.hook === true)
|
|
565
|
-
|
|
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);
|
|
566
718
|
}
|
|
567
719
|
return createThenDeploy(io, opened.key, opened.json, opened.flags);
|
|
568
720
|
}
|
|
@@ -577,22 +729,23 @@ export async function status(args, io) {
|
|
|
577
729
|
method: "GET",
|
|
578
730
|
});
|
|
579
731
|
if (result.network) return fail(io, opened.key, opened.json, SENTENCE.platform);
|
|
580
|
-
if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status));
|
|
732
|
+
if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
581
733
|
const statusText = text(result.body?.status);
|
|
582
734
|
const hostnames = hostnamesOf(result.body);
|
|
583
735
|
const id = text(result.body?.application?.id) || required.appId;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
`${
|
|
595
|
-
|
|
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);
|
|
596
749
|
}
|
|
597
750
|
|
|
598
751
|
// T-169: GET /v1/apps/:id/hook answers {hook: {url}}. The URL is a
|
|
@@ -603,7 +756,7 @@ async function hookUrl(io, key, json, appId) {
|
|
|
603
756
|
method: "GET",
|
|
604
757
|
});
|
|
605
758
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
606
|
-
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
759
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
607
760
|
const url = result.body?.hook?.url;
|
|
608
761
|
if (typeof url !== "string") return fail(io, key, json, SENTENCE.failed);
|
|
609
762
|
return succeed(io, key, json, { application_id: appId, url }, `${url}\n`);
|
|
@@ -617,7 +770,7 @@ async function envNames(io, key, json, appId) {
|
|
|
617
770
|
method: "GET",
|
|
618
771
|
});
|
|
619
772
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
620
|
-
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
773
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
621
774
|
const names = result.body?.names;
|
|
622
775
|
if (!Array.isArray(names) || !names.every((name) => typeof name === "string")) {
|
|
623
776
|
return fail(io, key, json, SENTENCE.failed);
|
|
@@ -645,7 +798,7 @@ export async function env(args, io) {
|
|
|
645
798
|
body,
|
|
646
799
|
});
|
|
647
800
|
if (result.network) return fail(io, opened.key, opened.json, SENTENCE.platform);
|
|
648
|
-
if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status));
|
|
801
|
+
if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
649
802
|
return succeed(
|
|
650
803
|
io,
|
|
651
804
|
opened.key,
|
|
@@ -668,12 +821,20 @@ const DOMAINS_VERIFY_OPTIONS = {
|
|
|
668
821
|
id: { type: "string" },
|
|
669
822
|
};
|
|
670
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
|
+
|
|
671
831
|
const DOMAINS_UNION = {
|
|
672
832
|
json: { type: "boolean" },
|
|
673
833
|
app: { type: "string" },
|
|
674
834
|
slug: { type: "string" },
|
|
675
835
|
hostname: { type: "string" },
|
|
676
836
|
id: { type: "string" },
|
|
837
|
+
yes: { type: "boolean" },
|
|
677
838
|
};
|
|
678
839
|
|
|
679
840
|
function domainsPositionals(args) {
|
|
@@ -741,7 +902,7 @@ async function domainsBind(args, io, key) {
|
|
|
741
902
|
}
|
|
742
903
|
const result = await call(io, key, request);
|
|
743
904
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
744
|
-
if (result.status !== 201) return fail(io, key, json, sentenceFor(result.status));
|
|
905
|
+
if (result.status !== 201) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
745
906
|
const domain = isRecord(result.body) ? result.body.domain : undefined;
|
|
746
907
|
const hostname = text(domain?.hostname);
|
|
747
908
|
if (hostnameFlag === undefined) {
|
|
@@ -764,6 +925,56 @@ async function domainsBind(args, io, key) {
|
|
|
764
925
|
);
|
|
765
926
|
}
|
|
766
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
|
+
|
|
767
978
|
async function domainsVerify(args, io, key) {
|
|
768
979
|
const parsed = parseStrict(args, DOMAINS_VERIFY_OPTIONS, true);
|
|
769
980
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "verify") {
|
|
@@ -779,25 +990,58 @@ async function domainsVerify(args, io, key) {
|
|
|
779
990
|
method: "POST",
|
|
780
991
|
});
|
|
781
992
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
782
|
-
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
993
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
783
994
|
const body = isRecord(result.body) ? result.body : {};
|
|
784
995
|
const domain = isRecord(body.domain) ? body.domain : {};
|
|
785
|
-
const statusText = typeof domain.status === "string" ? domain.status : "";
|
|
996
|
+
const statusText = sanitizeCli(typeof domain.status === "string" ? domain.status : "");
|
|
786
997
|
const payload = { application_id: appId, domain_id: domainId, status: statusText };
|
|
787
998
|
let human = `${statusText}\n`;
|
|
788
999
|
if (typeof body.reason === "string" && body.reason !== "") {
|
|
789
|
-
|
|
790
|
-
|
|
1000
|
+
const reason = sanitizeCli(body.reason);
|
|
1001
|
+
payload.reason = reason;
|
|
1002
|
+
human = `${statusText}\n${reason}\n`;
|
|
791
1003
|
}
|
|
792
1004
|
return succeed(io, key, json, payload, human);
|
|
793
1005
|
}
|
|
794
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
|
+
|
|
795
1037
|
export async function domains(args, io) {
|
|
796
1038
|
const loaded = loadKey(io);
|
|
797
1039
|
if (loaded.exit) return loaded.exit;
|
|
798
1040
|
const words = domainsPositionals(args);
|
|
799
1041
|
if (words === null) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
800
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);
|
|
801
1045
|
if (words.length !== 0) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
802
1046
|
return domainsBind(args, io, loaded.key);
|
|
803
1047
|
}
|
|
@@ -812,7 +1056,7 @@ export async function restart(args, io) {
|
|
|
812
1056
|
method: "POST",
|
|
813
1057
|
});
|
|
814
1058
|
if (result.network) return fail(io, opened.key, opened.json, SENTENCE.platform);
|
|
815
|
-
if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status));
|
|
1059
|
+
if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
816
1060
|
return succeed(
|
|
817
1061
|
io,
|
|
818
1062
|
opened.key,
|
|
@@ -822,17 +1066,21 @@ export async function restart(args, io) {
|
|
|
822
1066
|
);
|
|
823
1067
|
}
|
|
824
1068
|
|
|
1069
|
+
const MAX_DEPLOYMENT = 5; // the server's, src/app/v1/apps/[id]/logs/route.ts:15
|
|
1070
|
+
|
|
825
1071
|
const LOGS_OPTIONS = {
|
|
826
1072
|
json: { type: "boolean" },
|
|
827
1073
|
source: { type: "string" },
|
|
828
1074
|
tail: { type: "string" },
|
|
1075
|
+
deployment: { type: "string" },
|
|
829
1076
|
};
|
|
830
1077
|
|
|
831
1078
|
const LIST_OPTIONS = {
|
|
832
1079
|
json: { type: "boolean" },
|
|
1080
|
+
id: { type: "string" },
|
|
833
1081
|
};
|
|
834
1082
|
|
|
835
|
-
function logsSuffix(source, tail) {
|
|
1083
|
+
function logsSuffix(source, tail, deployment) {
|
|
836
1084
|
const parts = [];
|
|
837
1085
|
if (source !== undefined) {
|
|
838
1086
|
if (source !== "runtime" && source !== "deploy") return null;
|
|
@@ -843,6 +1091,13 @@ function logsSuffix(source, tail) {
|
|
|
843
1091
|
const parsed = Number(tail);
|
|
844
1092
|
parts.push(`tail=${parsed > 500 ? 500 : parsed}`);
|
|
845
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
|
+
}
|
|
846
1101
|
return parts.length === 0 ? "" : `?${parts.join("&")}`;
|
|
847
1102
|
}
|
|
848
1103
|
|
|
@@ -856,14 +1111,14 @@ export async function logs(args, io) {
|
|
|
856
1111
|
if (parsed.positionals.length !== 1) return fail(io, loaded.key, json, SENTENCE.refused);
|
|
857
1112
|
const appId = appUuid(parsed.positionals[0]);
|
|
858
1113
|
if (appId === null) return fail(io, loaded.key, json, SENTENCE.logsApp);
|
|
859
|
-
const suffix = logsSuffix(parsed.values.source, parsed.values.tail);
|
|
1114
|
+
const suffix = logsSuffix(parsed.values.source, parsed.values.tail, parsed.values.deployment);
|
|
860
1115
|
if (suffix === null) return fail(io, loaded.key, json, SENTENCE.refused);
|
|
861
1116
|
const result = await call(io, loaded.key, {
|
|
862
1117
|
path: `/v1/apps/${encodeURIComponent(appId)}/logs${suffix}`,
|
|
863
1118
|
method: "GET",
|
|
864
1119
|
});
|
|
865
1120
|
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
866
|
-
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
1121
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
867
1122
|
if (!isRecord(result.body) || typeof result.body.lines !== "string") {
|
|
868
1123
|
return fail(io, loaded.key, json, SENTENCE.failed);
|
|
869
1124
|
}
|
|
@@ -895,23 +1150,46 @@ function listText(resource, body) {
|
|
|
895
1150
|
return `${lines.join("\n")}\n`;
|
|
896
1151
|
}
|
|
897
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
|
+
|
|
898
1165
|
function projectRows(body) {
|
|
899
1166
|
if (!isRecord(body) || !Array.isArray(body.projects)) return null;
|
|
900
1167
|
const rows = [];
|
|
901
1168
|
for (const item of body.projects) {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
typeof item.name !== "string" ||
|
|
906
|
-
typeof item.created_at !== "string"
|
|
907
|
-
) {
|
|
908
|
-
return null;
|
|
909
|
-
}
|
|
910
|
-
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);
|
|
911
1172
|
}
|
|
912
1173
|
return rows;
|
|
913
1174
|
}
|
|
914
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
|
+
|
|
915
1193
|
export async function list(args, io) {
|
|
916
1194
|
const loaded = loadKey(io);
|
|
917
1195
|
if (loaded.exit) return loaded.exit;
|
|
@@ -923,11 +1201,16 @@ export async function list(args, io) {
|
|
|
923
1201
|
if (resource !== "apps" && resource !== "databases" && resource !== "projects") {
|
|
924
1202
|
return fail(io, loaded.key, json, SENTENCE.refused);
|
|
925
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
|
+
}
|
|
926
1209
|
const path =
|
|
927
1210
|
resource === "apps" ? "/v1/apps" : resource === "databases" ? "/v1/databases" : "/v1/projects";
|
|
928
1211
|
const result = await call(io, loaded.key, { path, method: "GET" });
|
|
929
1212
|
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
930
|
-
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
1213
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
931
1214
|
if (resource !== "projects") {
|
|
932
1215
|
const human = listText(resource, result.body);
|
|
933
1216
|
if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
@@ -939,6 +1222,121 @@ export async function list(args, io) {
|
|
|
939
1222
|
return succeed(io, loaded.key, json, { projects: rows }, human);
|
|
940
1223
|
}
|
|
941
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
|
+
|
|
942
1340
|
const USAGE_OPTIONS = {
|
|
943
1341
|
json: { type: "boolean" },
|
|
944
1342
|
};
|
|
@@ -949,11 +1347,25 @@ function finiteNumber(value) {
|
|
|
949
1347
|
|
|
950
1348
|
function usageText(body) {
|
|
951
1349
|
if (!isRecord(body) || !isRecord(body.used) || !finiteNumber(body.used.ram_mb)) return null;
|
|
952
|
-
|
|
953
|
-
|
|
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
|
+
}
|
|
954
1357
|
if (typeof body.plan !== "string" || !isRecord(body.pools)) return null;
|
|
955
|
-
if (
|
|
956
|
-
|
|
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`;
|
|
957
1369
|
}
|
|
958
1370
|
|
|
959
1371
|
export async function usage(args, io) {
|
|
@@ -964,7 +1376,7 @@ export async function usage(args, io) {
|
|
|
964
1376
|
const json = parsed.values.json === true;
|
|
965
1377
|
const result = await call(io, loaded.key, { path: "/v1/usage", method: "GET" });
|
|
966
1378
|
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
967
|
-
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
1379
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
968
1380
|
const human = usageText(result.body);
|
|
969
1381
|
if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
970
1382
|
return succeed(io, loaded.key, json, result.body, human);
|
|
@@ -980,7 +1392,9 @@ function plansText(body) {
|
|
|
980
1392
|
for (const row of body.plans) {
|
|
981
1393
|
if (!isRecord(row) || typeof row.name !== "string") return null;
|
|
982
1394
|
if (!finiteNumber(row.ram_mb) || !finiteNumber(row.cpu_milli) || !finiteNumber(row.disk_gb)) return null;
|
|
983
|
-
|
|
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);
|
|
984
1398
|
}
|
|
985
1399
|
if (lines.length === 0) return "";
|
|
986
1400
|
return `${lines.join("\n")}\n`;
|
|
@@ -994,7 +1408,7 @@ export async function plans(args, io) {
|
|
|
994
1408
|
const json = parsed.values.json === true;
|
|
995
1409
|
const result = await call(io, loaded.key, { path: "/v1/plans", method: "GET" });
|
|
996
1410
|
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
997
|
-
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
1411
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
998
1412
|
const human = plansText(result.body);
|
|
999
1413
|
if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
1000
1414
|
return succeed(io, loaded.key, json, result.body, human);
|
|
@@ -1043,7 +1457,7 @@ export async function whoami(args, io) {
|
|
|
1043
1457
|
const json = parsed.values.json === true;
|
|
1044
1458
|
const result = await call(io, loaded.key, { path: "/v1/me", method: "GET" });
|
|
1045
1459
|
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
1046
|
-
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
1460
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1047
1461
|
const view = whoamiView(result.body);
|
|
1048
1462
|
if (view === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
1049
1463
|
const plan = view.account.plan === null ? "none" : view.account.plan;
|
|
@@ -1106,15 +1520,56 @@ function isRecord(value) {
|
|
|
1106
1520
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1107
1521
|
}
|
|
1108
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
|
+
|
|
1109
1548
|
function diagnoseSentences(body) {
|
|
1110
1549
|
if (!isRecord(body)) return null;
|
|
1111
1550
|
if (body.diagnosis === null) {
|
|
1112
|
-
return typeof body.note === "string" ?
|
|
1551
|
+
return typeof body.note === "string" ? capLines(body.note) : null;
|
|
1113
1552
|
}
|
|
1114
1553
|
if (!isRecord(body.diagnosis) || typeof body.diagnosis.reason !== "string") return null;
|
|
1115
|
-
if (body.diagnosis.next_step === undefined) return
|
|
1554
|
+
if (body.diagnosis.next_step === undefined) return capLines(body.diagnosis.reason);
|
|
1116
1555
|
if (typeof body.diagnosis.next_step !== "string") return null;
|
|
1117
|
-
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;
|
|
1118
1573
|
}
|
|
1119
1574
|
|
|
1120
1575
|
function databaseFields(value) {
|
|
@@ -1184,10 +1639,10 @@ export async function diagnose(args, io) {
|
|
|
1184
1639
|
method: "GET",
|
|
1185
1640
|
});
|
|
1186
1641
|
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
1187
|
-
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
1642
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1188
1643
|
const human = diagnoseSentences(result.body);
|
|
1189
1644
|
if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
1190
|
-
return succeed(io, loaded.key, json, result.body, human);
|
|
1645
|
+
return succeed(io, loaded.key, json, diagnoseEcho(result.body), human);
|
|
1191
1646
|
}
|
|
1192
1647
|
|
|
1193
1648
|
async function databaseCreate(args, io, key) {
|
|
@@ -1204,7 +1659,7 @@ async function databaseCreate(args, io, key) {
|
|
|
1204
1659
|
}
|
|
1205
1660
|
const projectId = appUuid(project);
|
|
1206
1661
|
if (projectId === null) return fail(io, key, json, SENTENCE.projectId);
|
|
1207
|
-
if (kind !== "postgres" && kind !== "redis") return fail(io, key, json,
|
|
1662
|
+
if (kind !== "postgres" && kind !== "redis") return fail(io, key, json, REFUSED_DATABASE_KIND);
|
|
1208
1663
|
const result = await call(io, key, {
|
|
1209
1664
|
path: `/v1/projects/${encodeURIComponent(projectId)}/databases`,
|
|
1210
1665
|
method: "POST",
|
|
@@ -1212,7 +1667,7 @@ async function databaseCreate(args, io, key) {
|
|
|
1212
1667
|
});
|
|
1213
1668
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1214
1669
|
if (result.status !== 201) {
|
|
1215
|
-
return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
|
|
1670
|
+
return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status, result.retryAfterSeconds));
|
|
1216
1671
|
}
|
|
1217
1672
|
const view = databaseFields(isRecord(result.body) ? result.body.database : null);
|
|
1218
1673
|
const password = isRecord(result.body) ? result.body.password : undefined;
|
|
@@ -1235,9 +1690,9 @@ async function databaseStatus(args, io, key) {
|
|
|
1235
1690
|
method: "GET",
|
|
1236
1691
|
});
|
|
1237
1692
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1238
|
-
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
1693
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1239
1694
|
const view = databaseFields(isRecord(result.body) ? result.body.database : null);
|
|
1240
|
-
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;
|
|
1241
1696
|
if (view === null || typeof statusText !== "string") return fail(io, key, json, SENTENCE.failed);
|
|
1242
1697
|
const payload = {
|
|
1243
1698
|
id: view.id,
|
|
@@ -1271,10 +1726,49 @@ async function databaseBackup(args, io, key) {
|
|
|
1271
1726
|
});
|
|
1272
1727
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1273
1728
|
if (result.status === 409) return fail(io, key, json, SENTENCE.noBackup);
|
|
1274
|
-
if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status));
|
|
1729
|
+
if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1275
1730
|
return succeed(io, key, json, { database_id: id, backup: "run" }, "Backup run.\n");
|
|
1276
1731
|
}
|
|
1277
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
|
+
|
|
1278
1772
|
async function databaseDelete(args, io, key) {
|
|
1279
1773
|
const parsed = parseStrict(args, DATABASE_DELETE_OPTIONS, true);
|
|
1280
1774
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "delete") {
|
|
@@ -1291,7 +1785,7 @@ async function databaseDelete(args, io, key) {
|
|
|
1291
1785
|
method: "DELETE",
|
|
1292
1786
|
});
|
|
1293
1787
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1294
|
-
if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status));
|
|
1788
|
+
if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1295
1789
|
return succeed(io, key, json, { database_id: id, removed: true }, "Database removed.\n");
|
|
1296
1790
|
}
|
|
1297
1791
|
|
|
@@ -1303,6 +1797,7 @@ export async function database(args, io) {
|
|
|
1303
1797
|
if (words[0] === "create") return databaseCreate(args, io, loaded.key);
|
|
1304
1798
|
if (words[0] === "status") return databaseStatus(args, io, loaded.key);
|
|
1305
1799
|
if (words[0] === "backup") return databaseBackup(args, io, loaded.key);
|
|
1800
|
+
if (words[0] === "backups") return databaseBackups(args, io, loaded.key);
|
|
1306
1801
|
if (words[0] === "delete") return databaseDelete(args, io, loaded.key);
|
|
1307
1802
|
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1308
1803
|
}
|
|
@@ -1327,6 +1822,11 @@ const STORAGE_DELETE_OPTIONS = {
|
|
|
1327
1822
|
yes: { type: "boolean" },
|
|
1328
1823
|
};
|
|
1329
1824
|
|
|
1825
|
+
const STORAGE_ROTATE_OPTIONS = {
|
|
1826
|
+
json: { type: "boolean" },
|
|
1827
|
+
id: { type: "string" },
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1330
1830
|
const STORAGE_UNION = {
|
|
1331
1831
|
json: { type: "boolean" },
|
|
1332
1832
|
name: { type: "string" },
|
|
@@ -1380,29 +1880,12 @@ function storageLines(view) {
|
|
|
1380
1880
|
return lines;
|
|
1381
1881
|
}
|
|
1382
1882
|
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
const
|
|
1389
|
-
const name = trimmed(parsed.values.name);
|
|
1390
|
-
if (name === "") return fail(io, key, json, SENTENCE.refused);
|
|
1391
|
-
const result = await call(io, key, {
|
|
1392
|
-
path: "/v1/buckets",
|
|
1393
|
-
method: "POST",
|
|
1394
|
-
body: { name },
|
|
1395
|
-
});
|
|
1396
|
-
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1397
|
-
const refusal = storageCreateMessage(result.status, result.body);
|
|
1398
|
-
if (refusal !== null) return fail(io, key, json, refusal);
|
|
1399
|
-
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1400
|
-
if (result.status !== 201) {
|
|
1401
|
-
return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
|
|
1402
|
-
}
|
|
1403
|
-
const body = isRecord(result.body) ? result.body : null;
|
|
1404
|
-
const view = body === null ? null : storageFields(body.bucket);
|
|
1405
|
-
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;
|
|
1406
1889
|
if (
|
|
1407
1890
|
view === null ||
|
|
1408
1891
|
!isRecord(credentials) ||
|
|
@@ -1431,6 +1914,29 @@ async function storageCreate(args, io, key) {
|
|
|
1431
1914
|
return succeed(io, key, json, { bucket: view, credentials: payloadCredentials }, `${lines.join("\n")}\n`);
|
|
1432
1915
|
}
|
|
1433
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
|
+
|
|
1434
1940
|
async function storageList(args, io, key) {
|
|
1435
1941
|
const parsed = parseStrict(args, STORAGE_LIST_OPTIONS, true);
|
|
1436
1942
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "list") {
|
|
@@ -1440,7 +1946,7 @@ async function storageList(args, io, key) {
|
|
|
1440
1946
|
const result = await call(io, key, { path: "/v1/buckets", method: "GET" });
|
|
1441
1947
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1442
1948
|
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1443
|
-
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
1949
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1444
1950
|
const items = isRecord(result.body) && Array.isArray(result.body.buckets) ? result.body.buckets : null;
|
|
1445
1951
|
if (items === null) return fail(io, key, json, SENTENCE.failed);
|
|
1446
1952
|
const views = [];
|
|
@@ -1467,12 +1973,34 @@ async function storageShow(args, io, key) {
|
|
|
1467
1973
|
});
|
|
1468
1974
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1469
1975
|
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1470
|
-
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
1976
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1471
1977
|
const view = storageFields(isRecord(result.body) ? result.body.bucket : null);
|
|
1472
1978
|
if (view === null) return fail(io, key, json, SENTENCE.failed);
|
|
1473
1979
|
return succeed(io, key, json, { bucket: view }, `${storageLines(view).join("\n")}\n`);
|
|
1474
1980
|
}
|
|
1475
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
|
+
|
|
1476
2004
|
async function storageDelete(args, io, key) {
|
|
1477
2005
|
const parsed = parseStrict(args, STORAGE_DELETE_OPTIONS, true);
|
|
1478
2006
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "delete") {
|
|
@@ -1491,7 +2019,7 @@ async function storageDelete(args, io, key) {
|
|
|
1491
2019
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1492
2020
|
if (result.status === 409) return fail(io, key, json, SENTENCE.bucketNotEmpty);
|
|
1493
2021
|
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1494
|
-
if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status));
|
|
2022
|
+
if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1495
2023
|
return succeed(io, key, json, { bucket_id: id, removed: true }, `${SENTENCE.bucketRemoved}\n`);
|
|
1496
2024
|
}
|
|
1497
2025
|
|
|
@@ -1503,6 +2031,7 @@ export async function storage(args, io) {
|
|
|
1503
2031
|
if (words[0] === "create") return storageCreate(args, io, loaded.key);
|
|
1504
2032
|
if (words[0] === "list") return storageList(args, io, loaded.key);
|
|
1505
2033
|
if (words[0] === "show") return storageShow(args, io, loaded.key);
|
|
2034
|
+
if (words[0] === "rotate") return storageRotate(args, io, loaded.key);
|
|
1506
2035
|
if (words[0] === "delete") return storageDelete(args, io, loaded.key);
|
|
1507
2036
|
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1508
2037
|
}
|
|
@@ -1525,7 +2054,7 @@ export async function login(args, io) {
|
|
|
1525
2054
|
if (key === "") return fail(io, "", false, SENTENCE.emptyKey);
|
|
1526
2055
|
const result = await call(io, key, { path: "/v1/me", method: "GET" });
|
|
1527
2056
|
if (result.network) return fail(io, key, false, SENTENCE.platform);
|
|
1528
|
-
if (result.status !== 200) return fail(io, key, false, sentenceFor(result.status));
|
|
2057
|
+
if (result.status !== 200) return fail(io, key, false, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1529
2058
|
saveApiKey(io.env, key);
|
|
1530
2059
|
io.stdout.write(`${SENTENCE.loggedIn}\n`);
|
|
1531
2060
|
return { exitCode: 0 };
|
|
@@ -1544,6 +2073,11 @@ const APP_REMOVE_OPTIONS = {
|
|
|
1544
2073
|
confirm: { type: "string" },
|
|
1545
2074
|
};
|
|
1546
2075
|
|
|
2076
|
+
const APP_START_STOP_OPTIONS = {
|
|
2077
|
+
json: { type: "boolean" },
|
|
2078
|
+
app: { type: "string" },
|
|
2079
|
+
};
|
|
2080
|
+
|
|
1547
2081
|
function appPositionals(args) {
|
|
1548
2082
|
try {
|
|
1549
2083
|
return parseArgs({
|
|
@@ -1563,9 +2097,40 @@ export async function app(args, io) {
|
|
|
1563
2097
|
const words = appPositionals(args);
|
|
1564
2098
|
if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1565
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");
|
|
1566
2102
|
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1567
2103
|
}
|
|
1568
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
|
+
|
|
1569
2134
|
async function appRemove(args, io, key) {
|
|
1570
2135
|
const parsed = parseStrict(args, APP_REMOVE_OPTIONS, true);
|
|
1571
2136
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "remove") {
|
|
@@ -1579,7 +2144,7 @@ async function appRemove(args, io, key) {
|
|
|
1579
2144
|
|
|
1580
2145
|
const got = await call(io, key, { path: `/v1/apps/${encodeURIComponent(appId)}`, method: "GET" });
|
|
1581
2146
|
if (got.network) return fail(io, key, json, SENTENCE.platform);
|
|
1582
|
-
if (got.status !== 200) return fail(io, key, json, sentenceFor(got.status));
|
|
2147
|
+
if (got.status !== 200) return fail(io, key, json, sentenceFor(got.status, got.retryAfterSeconds));
|
|
1583
2148
|
if (!isRecord(got.body)) return fail(io, key, json, SENTENCE.failed);
|
|
1584
2149
|
const application = isRecord(got.body.application) ? got.body.application : null;
|
|
1585
2150
|
if (application === null) return fail(io, key, json, SENTENCE.failed);
|
|
@@ -1593,7 +2158,7 @@ async function appRemove(args, io, key) {
|
|
|
1593
2158
|
|
|
1594
2159
|
const deleted = await call(io, key, { path: `/v1/apps/${encodeURIComponent(appId)}`, method: "DELETE" });
|
|
1595
2160
|
if (deleted.network) return fail(io, key, json, SENTENCE.platform);
|
|
1596
|
-
if (deleted.status !== 200) return fail(io, key, json, sentenceFor(deleted.status));
|
|
2161
|
+
if (deleted.status !== 200) return fail(io, key, json, sentenceFor(deleted.status, deleted.retryAfterSeconds));
|
|
1597
2162
|
return succeed(io, key, json, { app_id: appId, removed: true }, `${SENTENCE.appRemoved}\n`);
|
|
1598
2163
|
}
|
|
1599
2164
|
|
|
@@ -1623,6 +2188,111 @@ function validRelPath(p) {
|
|
|
1623
2188
|
return p.split("/").every((segment) => segment.length > 0 && segment !== "..");
|
|
1624
2189
|
}
|
|
1625
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
|
+
|
|
1626
2296
|
export function preflightFiles(root) {
|
|
1627
2297
|
let rootStat;
|
|
1628
2298
|
try {
|
|
@@ -1632,8 +2302,9 @@ export function preflightFiles(root) {
|
|
|
1632
2302
|
}
|
|
1633
2303
|
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return null;
|
|
1634
2304
|
|
|
2305
|
+
const gitignored = rootGitignoreMatcher(root);
|
|
1635
2306
|
const candidates = [];
|
|
1636
|
-
function walk(dir) {
|
|
2307
|
+
function walk(dir, depth) {
|
|
1637
2308
|
let entries;
|
|
1638
2309
|
try {
|
|
1639
2310
|
entries = readdirSync(dir);
|
|
@@ -1641,7 +2312,9 @@ export function preflightFiles(root) {
|
|
|
1641
2312
|
return false;
|
|
1642
2313
|
}
|
|
1643
2314
|
for (const name of entries) {
|
|
1644
|
-
if (name.startsWith("."))
|
|
2315
|
+
if (name.startsWith(".")) {
|
|
2316
|
+
if (depth !== 0 || !isRootEnvName(name) || gitignored(name)) continue;
|
|
2317
|
+
}
|
|
1645
2318
|
const fullPath = path.join(dir, name);
|
|
1646
2319
|
let stat;
|
|
1647
2320
|
try {
|
|
@@ -1651,19 +2324,19 @@ export function preflightFiles(root) {
|
|
|
1651
2324
|
}
|
|
1652
2325
|
if (stat.isSymbolicLink()) continue;
|
|
1653
2326
|
if (stat.isDirectory()) {
|
|
1654
|
-
if (SKIP_DIRS.has(name)) continue;
|
|
1655
|
-
const ok = walk(fullPath);
|
|
2327
|
+
if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
|
|
2328
|
+
const ok = walk(fullPath, depth + 1);
|
|
1656
2329
|
if (!ok) return false;
|
|
1657
2330
|
} else if (stat.isFile()) {
|
|
1658
2331
|
const rel = path.relative(root, fullPath).split(path.sep).join("/");
|
|
1659
|
-
const
|
|
1660
|
-
candidates.push({ rel, fullPath, depth, stat });
|
|
2332
|
+
const candidateDepth = rel.split("/").length - 1;
|
|
2333
|
+
candidates.push({ rel, fullPath, depth: candidateDepth, stat });
|
|
1661
2334
|
}
|
|
1662
2335
|
}
|
|
1663
2336
|
return true;
|
|
1664
2337
|
}
|
|
1665
2338
|
|
|
1666
|
-
if (!walk(root)) return null;
|
|
2339
|
+
if (!walk(root, 0)) return null;
|
|
1667
2340
|
|
|
1668
2341
|
candidates.sort((a, b) => {
|
|
1669
2342
|
if (a.depth !== b.depth) return a.depth - b.depth;
|
|
@@ -1751,18 +2424,36 @@ export async function preflight(args, io) {
|
|
|
1751
2424
|
});
|
|
1752
2425
|
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform, undefined, 2);
|
|
1753
2426
|
if (result.status < 200 || result.status >= 300) {
|
|
1754
|
-
return fail(io, loaded.key, json, errorMessage(result.status, result.body), undefined, 2);
|
|
2427
|
+
return fail(io, loaded.key, json, errorMessage(result.status, result.body, result.retryAfterSeconds), undefined, 2);
|
|
1755
2428
|
}
|
|
1756
2429
|
|
|
1757
2430
|
if (
|
|
1758
2431
|
!isRecord(result.body) ||
|
|
1759
2432
|
typeof result.body.verdict !== "string" ||
|
|
1760
2433
|
!Array.isArray(result.body.checks) ||
|
|
1761
|
-
(result.body.verdict !== "pass" &&
|
|
2434
|
+
(result.body.verdict !== "pass" &&
|
|
2435
|
+
result.body.verdict !== "fixable" &&
|
|
2436
|
+
result.body.verdict !== "refuse")
|
|
1762
2437
|
) {
|
|
1763
2438
|
return fail(io, loaded.key, json, SENTENCE.failed, undefined, 2);
|
|
1764
2439
|
}
|
|
1765
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
|
+
|
|
1766
2457
|
if (walked.leftOut > 0) {
|
|
1767
2458
|
const leftOutMsg =
|
|
1768
2459
|
walked.leftOut === 1
|
|
@@ -1773,11 +2464,11 @@ export async function preflight(args, io) {
|
|
|
1773
2464
|
|
|
1774
2465
|
const exitCode = result.body.verdict === "refuse" ? 1 : 0;
|
|
1775
2466
|
if (json) {
|
|
1776
|
-
writeStdout(io, loaded.key, `${JSON.stringify(
|
|
2467
|
+
writeStdout(io, loaded.key, `${JSON.stringify(preflightEcho())}\n`);
|
|
1777
2468
|
return { exitCode };
|
|
1778
2469
|
}
|
|
1779
2470
|
|
|
1780
|
-
let human = `Verdict: ${result.body.verdict}\n`;
|
|
2471
|
+
let human = `Verdict: ${sanitizeCli(result.body.verdict)}\n`;
|
|
1781
2472
|
for (const check of result.body.checks) {
|
|
1782
2473
|
if (
|
|
1783
2474
|
isRecord(check) &&
|
|
@@ -1785,7 +2476,7 @@ export async function preflight(args, io) {
|
|
|
1785
2476
|
typeof check.result === "string" &&
|
|
1786
2477
|
typeof check.reason === "string"
|
|
1787
2478
|
) {
|
|
1788
|
-
human += `${check.name}: ${check.result} - ${check.reason}\n`;
|
|
2479
|
+
human += `${sanitizeCli(check.name)}: ${sanitizeCli(check.result)} - ${sanitizeCli(check.reason)}\n`;
|
|
1789
2480
|
}
|
|
1790
2481
|
}
|
|
1791
2482
|
writeStdout(io, loaded.key, human);
|
|
@@ -1817,7 +2508,7 @@ export async function accountExport(args, io) {
|
|
|
1817
2508
|
});
|
|
1818
2509
|
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform, undefined, 2);
|
|
1819
2510
|
if (result.status < 200 || result.status >= 300) {
|
|
1820
|
-
return fail(io, loaded.key, json, errorMessage(result.status, result.body), undefined, 2);
|
|
2511
|
+
return fail(io, loaded.key, json, errorMessage(result.status, result.body, result.retryAfterSeconds), undefined, 2);
|
|
1821
2512
|
}
|
|
1822
2513
|
|
|
1823
2514
|
if (json) {
|
|
@@ -1843,7 +2534,7 @@ export async function next(args, io) {
|
|
|
1843
2534
|
const json = parsed.values.json === true;
|
|
1844
2535
|
const result = await call(io, loaded.key, { path: "/v1/me/next-step", method: "GET" });
|
|
1845
2536
|
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
1846
|
-
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
2537
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1847
2538
|
const body = isRecord(result.body) ? result.body : null;
|
|
1848
2539
|
const step = body?.step;
|
|
1849
2540
|
const sentence = body?.sentence;
|
|
@@ -1863,9 +2554,16 @@ const COMPOSE_ACTION_OPTIONS = {
|
|
|
1863
2554
|
id: { type: "string", multiple: true },
|
|
1864
2555
|
};
|
|
1865
2556
|
|
|
2557
|
+
const COMPOSE_REMOVE_OPTIONS = {
|
|
2558
|
+
json: { type: "boolean" },
|
|
2559
|
+
id: { type: "string", multiple: true },
|
|
2560
|
+
confirm: { type: "string" },
|
|
2561
|
+
};
|
|
2562
|
+
|
|
1866
2563
|
const COMPOSE_UNION = {
|
|
1867
2564
|
json: { type: "boolean" },
|
|
1868
2565
|
id: { type: "string" },
|
|
2566
|
+
confirm: { type: "string" },
|
|
1869
2567
|
};
|
|
1870
2568
|
|
|
1871
2569
|
function composePositionals(args) {
|
|
@@ -1950,7 +2648,7 @@ async function composeList(args, io, key) {
|
|
|
1950
2648
|
const json = parsed.values.json === true;
|
|
1951
2649
|
const result = await call(io, key, { path: "/v1/compose", method: "GET" });
|
|
1952
2650
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1953
|
-
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
2651
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
|
|
1954
2652
|
const items = isRecord(result.body) && Array.isArray(result.body.compose) ? result.body.compose : null;
|
|
1955
2653
|
if (items === null) return fail(io, key, json, SENTENCE.failed);
|
|
1956
2654
|
const views = [];
|
|
@@ -1977,7 +2675,12 @@ async function composeDeploy(args, io, key) {
|
|
|
1977
2675
|
});
|
|
1978
2676
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1979
2677
|
if (result.status !== 202) {
|
|
1980
|
-
return fail(
|
|
2678
|
+
return fail(
|
|
2679
|
+
io,
|
|
2680
|
+
key,
|
|
2681
|
+
json,
|
|
2682
|
+
upgradeMessage(result.status, result.body) ?? composeUnavailableMessage(result.status, result.body) ?? sentenceFor(result.status, result.retryAfterSeconds),
|
|
2683
|
+
);
|
|
1981
2684
|
}
|
|
1982
2685
|
const view = composeFields(isRecord(result.body) ? result.body.compose : null);
|
|
1983
2686
|
if (view === null) return fail(io, key, json, SENTENCE.failed);
|
|
@@ -1985,7 +2688,9 @@ async function composeDeploy(args, io, key) {
|
|
|
1985
2688
|
return succeed(io, key, json, payload, `${composeLines(view).join("\n")}\n`);
|
|
1986
2689
|
}
|
|
1987
2690
|
|
|
1988
|
-
|
|
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) {
|
|
1989
2694
|
const parsed = parseStrict(args, COMPOSE_ACTION_OPTIONS, true);
|
|
1990
2695
|
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== action) {
|
|
1991
2696
|
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
@@ -1993,13 +2698,14 @@ async function composeStartStop(args, io, key, action) {
|
|
|
1993
2698
|
const json = parsed.values.json === true;
|
|
1994
2699
|
const required = requireComposeId(io, key, json, parsed.values.id);
|
|
1995
2700
|
if (required.exit) return required.exit;
|
|
2701
|
+
const id = encodeURIComponent(required.id);
|
|
1996
2702
|
const result = await call(io, key, {
|
|
1997
|
-
path: `/v1/compose/${
|
|
1998
|
-
method
|
|
2703
|
+
path: pathSuffix === "" ? `/v1/compose/${id}` : `/v1/compose/${id}/${pathSuffix}`,
|
|
2704
|
+
method,
|
|
1999
2705
|
});
|
|
2000
2706
|
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
2001
2707
|
if (result.status !== 200) {
|
|
2002
|
-
return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
|
|
2708
|
+
return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status, result.retryAfterSeconds));
|
|
2003
2709
|
}
|
|
2004
2710
|
const body = isRecord(result.body) ? result.body : null;
|
|
2005
2711
|
const view = body === null ? null : composeFields(body.compose);
|
|
@@ -2010,6 +2716,30 @@ async function composeStartStop(args, io, key, action) {
|
|
|
2010
2716
|
return succeed(io, key, json, payload, `${lines.join("\n")}\n`);
|
|
2011
2717
|
}
|
|
2012
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
|
+
|
|
2013
2743
|
export async function compose(args, io) {
|
|
2014
2744
|
const loaded = loadKey(io);
|
|
2015
2745
|
if (loaded.exit) return loaded.exit;
|
|
@@ -2017,7 +2747,110 @@ export async function compose(args, io) {
|
|
|
2017
2747
|
if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
2018
2748
|
if (words[0] === "list") return composeList(args, io, loaded.key);
|
|
2019
2749
|
if (words[0] === "deploy") return composeDeploy(args, io, loaded.key);
|
|
2020
|
-
if (words[0] === "start") return
|
|
2021
|
-
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);
|
|
2022
2855
|
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
2023
2856
|
}
|