@astris-labs/cli 0.1.4 → 0.2.0

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