@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/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],
@@ -62,33 +72,72 @@ const HTTP_SENTENCE = new Map([
62
72
  [502, SENTENCE.platform],
63
73
  ]);
64
74
 
65
- function sentenceFor(status) {
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
- function errorMessage(status, body) {
70
- if (isRecord(body) && typeof body.message === "string") return body.message;
71
- return sentenceFor(status);
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
- 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) {
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
- return succeed(
341
- io,
342
- key,
343
- json,
344
- { application_id: id, name, accepted: true },
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
- return succeed(
527
- io,
528
- key,
529
- json,
530
- {
531
- project_id: projectId,
532
- application_id: id,
533
- name: shownName,
534
- slug,
535
- accepted: true,
536
- },
537
- `${projectId}\n${id}\n${shownName}\n${slug}\n`,
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) return hookUrl(io, opened.key, opened.json, appId);
565
- 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);
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
- return succeed(
585
- io,
586
- opened.key,
587
- opened.json,
588
- {
589
- application_id: id,
590
- status: statusText,
591
- hostnames,
592
- deployments: deploymentRows(result.body),
593
- },
594
- `${statusText}\n${hostnames.join("\n")}${hostnames.length > 0 ? "\n" : ""}`,
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
- payload.reason = body.reason;
790
- human = `${statusText}\n${body.reason}\n`;
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
- if (
903
- !isRecord(item) ||
904
- typeof item.id !== "string" ||
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
- const used = body.used.ram_mb;
953
- 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
+ }
954
1357
  if (typeof body.plan !== "string" || !isRecord(body.pools)) return null;
955
- if (!finiteNumber(body.pools.ram_mb) || !finiteNumber(body.ram_remaining_mb)) return null;
956
- 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`;
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
- 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);
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" ? `${body.note}\n` : null;
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 `${body.diagnosis.reason}\n`;
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}\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;
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, SENTENCE.refused);
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
- async function storageCreate(args, io, key) {
1384
- const parsed = parseStrict(args, STORAGE_CREATE_OPTIONS, true);
1385
- if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "create") {
1386
- return fail(io, key, wantsJson(args), SENTENCE.refused);
1387
- }
1388
- const json = parsed.values.json === true;
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(".")) continue;
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 depth = rel.split("/").length - 1;
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" && result.body.verdict !== "fixable" && result.body.verdict !== "refuse")
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(result.body)}\n`);
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(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
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
- 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) {
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/${encodeURIComponent(required.id)}/${action}`,
1998
- method: "POST",
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 composeStartStop(args, io, loaded.key, "start");
2021
- 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);
2022
2855
  return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
2023
2856
  }