@astris-labs/cli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ Requests go to `https://api.astris.run`.
30
30
 
31
31
  ## Commands
32
32
 
33
- v0 commands are `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `database`, `storage`, `list`, `logs`, `usage`, `plans`, `whoami`, `login`, `logout`, `app`, `preflight`, and `export`.
33
+ v0 commands are `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `database`, `storage`, `compose`, `list`, `logs`, `usage`, `plans`, `whoami`, `next`, `login`, `logout`, `app`, `preflight`, and `export`.
34
34
 
35
35
  `--json` writes one JSON object to stdout. Without `--json`, success is text on stdout and an error is a sentence on stderr.
36
36
 
@@ -93,6 +93,18 @@ Optional flags are `--build-path`, `--dockerfile`, `--port`, `--build`, `--publi
93
93
 
94
94
  `astris database delete --id <id> --yes` calls `DELETE /v1/databases/:id` and sends no body. Without `--yes` it makes no request and says the database and its data are removed and that `--yes` confirms. A repeated `--id` is refused and makes no request. Human output is `Database removed.` `--json` prints one JSON object `{"database_id":"<id>","removed":true}`.
95
95
 
96
+ ### compose
97
+
98
+ `astris compose list` calls `GET /v1/compose`. Human output is one line per stack in the order the API returned: the id, the name, and the project name. An empty list prints nothing. `--json` prints one JSON object `{"compose":[...]}` of the stack rows.
99
+
100
+ `astris compose deploy --id <id>` calls `POST /v1/compose/:id/deploy` and sends no body. In the private preview the API refuses every redeploy, so the command prints the API's sentence and exits 1. Otherwise human output is the stack id, project id, name, git URL, branch, compose path, memory limit, created_at, and updated_at, one per line. `--json` prints one JSON object `{"compose":{...}}`.
101
+
102
+ `astris compose start --id <id>` calls `POST /v1/compose/:id/start` and sends no body. Human output is the status the API returned, then the same stack fields as deploy, one per line. `--json` prints one JSON object `{"compose":{...},"status":"<status>"}`.
103
+
104
+ `astris compose stop --id <id>` calls `POST /v1/compose/:id/stop` and sends no body. Human output matches start. `--json` prints one JSON object `{"compose":{...},"status":"<status>"}`.
105
+
106
+ Without `--id`, or with an id that is not a stack id, deploy, start, and stop make no request and say `Send --id set to the compose stack id.`
107
+
96
108
  ### storage
97
109
 
98
110
  `astris storage create --name <name>` calls `POST /v1/buckets` with `name` after trim. Human output is the bucket id, the name, the endpoint, the bucket, the access key id, and the secret key, one value per line. The secret key is printed on this command only, so save it now. `--json` prints one JSON object of the response body: the bucket row and the credentials.
@@ -125,6 +137,10 @@ When storage is not configured, all four commands print `Storage is not availabl
125
137
 
126
138
  `astris whoami` calls `GET /v1/me`. It takes no flags besides `--json`. `--json` prints the account id, email, status, plan, and created_at, and the key id, name, and key_prefix. Human output is three lines: `Account: <email>`, `Plan: <plan>` (`Plan: none` when plan is null), and `Key: <name> (<key_prefix>)`.
127
139
 
140
+ ### next
141
+
142
+ `astris next` prints this account's next step as one sentence. `--json` prints `{step, sentence}`.
143
+
128
144
  ### login
129
145
 
130
146
  `astris login` takes no key as an argument. On a terminal it asks `Paste your Astris API key:` and does not show what is typed. With a pipe it reads the key from stdin. When the API accepts the key, it writes `Logged in.`
@@ -160,18 +176,22 @@ Exit code 0 means the command succeeded. Any error exits with a non-zero code. W
160
176
 
161
177
  Some arguments are refused on this machine, before any request.
162
178
 
163
- The command name is checked first. The words `help`, `--help`, and `-h` print the usage and the command names, exit 0, and make no request. `deploy`, `status`, `env`, `domains`, `restart`, `logs`, `rollback`, `diagnose`, `database`, `storage`, `list`, `usage`, `plans`, `whoami`, `login`, `logout`, `app`, `preflight`, and `export` are known. Any other name writes `Unknown command.` and makes no request.
179
+ The command name is checked first. The words `help`, `--help`, and `-h` print the usage and the command names, exit 0, and make no request. `deploy`, `status`, `env`, `domains`, `restart`, `logs`, `rollback`, `diagnose`, `database`, `storage`, `compose`, `list`, `usage`, `plans`, `whoami`, `next`, `login`, `logout`, `app`, `preflight`, and `export` are known. Any other name writes `Unknown command.` and makes no request.
164
180
 
165
181
  Login asks for the key on a terminal and reads it from stdin otherwise. Logout deletes that one file. Every other known command uses this sentence: When `ASTRIS_API_KEY` is set to a non-empty value, that value is the key, and the saved file is used only when the variable is missing or empty. A missing or empty variable and a missing or empty file write `ASTRIS_API_KEY is not set` to stderr and make no request. That is also true when `--json` is set.
166
182
 
167
183
  `login` and `logout` take no arguments. An argument is refused, and `logout` does not delete the file.
168
184
 
169
- After the key, `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `database`, and `storage` refuse a flag they do not know, a flag with no value, and a bare word that is not a flag. The sentence is `Those arguments were refused. Check the flags and send them again.`
185
+ After the key, `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `database`, `storage`, `compose`, and `next` refuse a flag they do not know, a flag with no value, and a bare word that is not a flag. The sentence is `Those arguments were refused. Check the flags and send them again.`
170
186
 
171
187
  `diagnose` requires `--app` set to an application id, the same check as `status`. `database` takes one word, `create`, `status`, `backup`, or `delete`. `create` requires `--project`, `--kind`, and `--name`. `--project` is a project id. `--kind` is exactly `postgres` or `redis`. `status` and `backup` each require `--id` set to a database id. `backup` is accepted. `delete` requires `--id` set to a database id and `--yes`, and a repeated `--id` is refused with `Those arguments were refused. Check the flags and send them again.` and makes no request. The plural `backups` is refused and makes no request.
172
188
 
173
189
  `storage` takes one word, `create`, `list`, `show`, or `delete`. `create` requires `--name`. `show` and `delete` each require `--id` set to a bucket id, and a `--id` that is not a bucket id makes no request. `delete` requires `--yes`, and a repeated `--id` is refused with `Those arguments were refused. Check the flags and send them again.` and makes no request. `list` takes no word, so `storage list buckets` is refused and makes no request.
174
190
 
191
+ `compose` takes one word, `list`, `deploy`, `start`, or `stop`. `deploy`, `start`, and `stop` each require `--id` set to a compose stack id, and a repeated `--id` is refused with `Those arguments were refused. Check the flags and send them again.` and makes no request. `list` rejects `--id`.
192
+
193
+ `next` takes no word and no flag besides `--json`.
194
+
175
195
  `domains` binds with no word, or verifies with the one word `verify`. `--hostname` with `--slug` is refused. An empty `--hostname`, or one containing a space, is refused before any request. Other hostname rules are left to the API. `verify` requires `--app` set to an application id and `--id` set to a domain id; a missing or non-UUID `--id`, and a word other than `verify`, make no request.
176
196
 
177
197
  `--app` is refused unless the value, after trim, is an id of eight, four, four, four, and twelve hexadecimal digits, with hyphens, in either case. The sentence is `Send --app set to the application id.` `status`, `env`, `domains`, and `restart` require that id. `deploy` requires it when `--app` is present or when `--hook` is sent (`--hook` without `--app` is refused with `Send --app set to the application id.`), and then refuses `--build`, `--publish-directory`, `--spa`, `--kind`, `--command`, and `--schedule`; the other create flags are ignored.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astris-labs/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "The Astris CLI, a command-line client of the Astris API.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/client.mjs CHANGED
@@ -18,6 +18,22 @@ function swallow(promise) {
18
18
  promise.catch(() => undefined);
19
19
  }
20
20
 
21
+ function retryAfterSecondsOf(headers) {
22
+ if (!headers) return null;
23
+ let raw;
24
+ if (typeof headers.get === "function") {
25
+ raw = headers.get("retry-after");
26
+ } else if (typeof headers === "object") {
27
+ raw = headers["retry-after"] ?? headers["Retry-After"];
28
+ }
29
+ if (typeof raw !== "string") return null;
30
+ const trimmed = raw.trim();
31
+ if (!/^[0-9]+$/.test(trimmed)) return null;
32
+ const seconds = Number(trimmed);
33
+ if (seconds < 1 || seconds > 3600) return null;
34
+ return seconds;
35
+ }
36
+
21
37
  export async function astrisFetch({ key, baseUrl, path, method, body, fetchImpl }) {
22
38
  const url = baseUrl + path;
23
39
  const headers = { Authorization: `Bearer ${key}` };
@@ -54,14 +70,15 @@ export async function astrisFetch({ key, baseUrl, path, method, body, fetchImpl
54
70
  }
55
71
  swallow(reading);
56
72
  const text = await Promise.race([reading, timeout]);
73
+ const retryAfterSeconds = retryAfterSecondsOf(response?.headers);
57
74
 
58
75
  if (text === "") {
59
- return { status: response.status, body: null };
76
+ return { status: response.status, body: null, retryAfterSeconds };
60
77
  }
61
78
  try {
62
- return { status: response.status, body: JSON.parse(text) };
79
+ return { status: response.status, body: JSON.parse(text), retryAfterSeconds };
63
80
  } catch {
64
- return { status: response.status, body: null };
81
+ return { status: response.status, body: null, retryAfterSeconds };
65
82
  }
66
83
  } catch (err) {
67
84
  throw new Error(redact(failureMessage(err), key));
package/src/commands.mjs CHANGED
@@ -16,6 +16,7 @@ const SENTENCE = {
16
16
  projectId: "Send --project set to the project id.",
17
17
  databaseFlags: "Those arguments were refused. Send --project, --kind, and --name.",
18
18
  databaseId: "Send --id set to the database id.",
19
+ composeId: "Send --id set to the compose stack id.",
19
20
  domainId: "Send --id set to the domain id.",
20
21
  git: "The Git URL must not include a user name or password.",
21
22
  // T-169: reused word for word from the MCP's SENTENCE.deployKey (T-139).
@@ -57,16 +58,26 @@ const HTTP_SENTENCE = new Map([
57
58
  [401, SENTENCE.unauthorized],
58
59
  [404, SENTENCE.notFound],
59
60
  [409, SENTENCE.taken],
61
+ [429, "This account asked for too many changes in a short time. Wait a little, then try again."],
60
62
  [502, SENTENCE.platform],
61
63
  ]);
62
64
 
63
- function sentenceFor(status) {
65
+ function rateWaitSentence(seconds) {
66
+ if (typeof seconds === "number") {
67
+ return `This account asked for too many changes in a short time. Wait ${seconds} second${seconds === 1 ? "" : "s"}, then try again.`;
68
+ }
69
+ return "This account asked for too many changes in a short time. Wait a little, then try again.";
70
+ }
71
+
72
+ function sentenceFor(status, retryAfterSeconds = null) {
73
+ if (status === 429) return rateWaitSentence(retryAfterSeconds);
64
74
  return HTTP_SENTENCE.get(status) ?? SENTENCE.failed;
65
75
  }
66
76
 
67
- function errorMessage(status, body) {
77
+ function errorMessage(status, body, retryAfterSeconds = null) {
78
+ if (status === 429) return rateWaitSentence(retryAfterSeconds);
68
79
  if (isRecord(body) && typeof body.message === "string") return body.message;
69
- return sentenceFor(status);
80
+ return sentenceFor(status, retryAfterSeconds);
70
81
  }
71
82
 
72
83
  function upgradeMessage(status, body) {
@@ -76,6 +87,14 @@ function upgradeMessage(status, body) {
76
87
  return body.message;
77
88
  }
78
89
 
90
+ // T-275: the API pauses compose redeploy in the private preview and says why.
91
+ function composeUnavailableMessage(status, body) {
92
+ if (status !== 403 || !isRecord(body) || body.error !== "compose_unavailable" || typeof body.message !== "string") {
93
+ return null;
94
+ }
95
+ return body.message;
96
+ }
97
+
79
98
  function storageCreateMessage(status, body) {
80
99
  if (!isRecord(body)) return null;
81
100
  if (status === 403 && body.error === "bucket_limit") {
@@ -331,7 +350,7 @@ async function redeploy(io, key, json, appId) {
331
350
  method: "POST",
332
351
  });
333
352
  if (result.network) return fail(io, key, json, SENTENCE.platform);
334
- if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status));
353
+ if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
335
354
  const application = result.body?.application ?? {};
336
355
  const id = text(application.id) || appId;
337
356
  const name = text(application.name);
@@ -443,7 +462,7 @@ async function createThenDeploy(io, key, json, flags) {
443
462
  try {
444
463
  const listed = await call(io, key, { path: "/v1/projects", method: "GET" });
445
464
  if (listed.network) return fail(io, key, json, SENTENCE.platform, created);
446
- if (listed.status !== 200) return fail(io, key, json, sentenceFor(listed.status), created);
465
+ if (listed.status !== 200) return fail(io, key, json, sentenceFor(listed.status, listed.retryAfterSeconds), created);
447
466
  let projectId = findProjectId(listed.body, project);
448
467
  if (projectId === null) {
449
468
  const made = await call(io, key, {
@@ -454,7 +473,7 @@ async function createThenDeploy(io, key, json, flags) {
454
473
  if (made.network) return fail(io, key, json, SENTENCE.platform, created);
455
474
  const madeId = made.body?.project?.id;
456
475
  if (made.status !== 201 || typeof madeId !== "string" || madeId === "") {
457
- return fail(io, key, json, made.status === 201 ? SENTENCE.failed : sentenceFor(made.status), created);
476
+ return fail(io, key, json, made.status === 201 ? SENTENCE.failed : sentenceFor(made.status, made.retryAfterSeconds), created);
458
477
  }
459
478
  projectId = madeId;
460
479
  created.projectId = madeId;
@@ -482,7 +501,7 @@ async function createThenDeploy(io, key, json, flags) {
482
501
  io,
483
502
  key,
484
503
  json,
485
- passed ?? (madeApp.status === 201 ? SENTENCE.failed : sentenceFor(madeApp.status)),
504
+ passed ?? (madeApp.status === 201 ? SENTENCE.failed : sentenceFor(madeApp.status, madeApp.retryAfterSeconds)),
486
505
  created,
487
506
  );
488
507
  }
@@ -516,7 +535,7 @@ async function createThenDeploy(io, key, json, flags) {
516
535
  method: "POST",
517
536
  });
518
537
  if (deployed.network) return fail(io, key, json, SENTENCE.platform, created);
519
- if (deployed.status !== 202) return fail(io, key, json, sentenceFor(deployed.status), created);
538
+ if (deployed.status !== 202) return fail(io, key, json, sentenceFor(deployed.status, deployed.retryAfterSeconds), created);
520
539
  const shown = deployed.body?.application ?? application;
521
540
  const id = text(shown.id) || applicationId;
522
541
  const shownName = text(shown.name) || name;
@@ -575,7 +594,7 @@ export async function status(args, io) {
575
594
  method: "GET",
576
595
  });
577
596
  if (result.network) return fail(io, opened.key, opened.json, SENTENCE.platform);
578
- if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status));
597
+ if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status, result.retryAfterSeconds));
579
598
  const statusText = text(result.body?.status);
580
599
  const hostnames = hostnamesOf(result.body);
581
600
  const id = text(result.body?.application?.id) || required.appId;
@@ -601,7 +620,7 @@ async function hookUrl(io, key, json, appId) {
601
620
  method: "GET",
602
621
  });
603
622
  if (result.network) return fail(io, key, json, SENTENCE.platform);
604
- if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
623
+ if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
605
624
  const url = result.body?.hook?.url;
606
625
  if (typeof url !== "string") return fail(io, key, json, SENTENCE.failed);
607
626
  return succeed(io, key, json, { application_id: appId, url }, `${url}\n`);
@@ -615,7 +634,7 @@ async function envNames(io, key, json, appId) {
615
634
  method: "GET",
616
635
  });
617
636
  if (result.network) return fail(io, key, json, SENTENCE.platform);
618
- if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
637
+ if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
619
638
  const names = result.body?.names;
620
639
  if (!Array.isArray(names) || !names.every((name) => typeof name === "string")) {
621
640
  return fail(io, key, json, SENTENCE.failed);
@@ -643,7 +662,7 @@ export async function env(args, io) {
643
662
  body,
644
663
  });
645
664
  if (result.network) return fail(io, opened.key, opened.json, SENTENCE.platform);
646
- if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status));
665
+ if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status, result.retryAfterSeconds));
647
666
  return succeed(
648
667
  io,
649
668
  opened.key,
@@ -739,7 +758,7 @@ async function domainsBind(args, io, key) {
739
758
  }
740
759
  const result = await call(io, key, request);
741
760
  if (result.network) return fail(io, key, json, SENTENCE.platform);
742
- if (result.status !== 201) return fail(io, key, json, sentenceFor(result.status));
761
+ if (result.status !== 201) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
743
762
  const domain = isRecord(result.body) ? result.body.domain : undefined;
744
763
  const hostname = text(domain?.hostname);
745
764
  if (hostnameFlag === undefined) {
@@ -777,7 +796,7 @@ async function domainsVerify(args, io, key) {
777
796
  method: "POST",
778
797
  });
779
798
  if (result.network) return fail(io, key, json, SENTENCE.platform);
780
- if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
799
+ if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
781
800
  const body = isRecord(result.body) ? result.body : {};
782
801
  const domain = isRecord(body.domain) ? body.domain : {};
783
802
  const statusText = typeof domain.status === "string" ? domain.status : "";
@@ -810,7 +829,7 @@ export async function restart(args, io) {
810
829
  method: "POST",
811
830
  });
812
831
  if (result.network) return fail(io, opened.key, opened.json, SENTENCE.platform);
813
- if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status));
832
+ if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status, result.retryAfterSeconds));
814
833
  return succeed(
815
834
  io,
816
835
  opened.key,
@@ -861,7 +880,7 @@ export async function logs(args, io) {
861
880
  method: "GET",
862
881
  });
863
882
  if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
864
- if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
883
+ if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
865
884
  if (!isRecord(result.body) || typeof result.body.lines !== "string") {
866
885
  return fail(io, loaded.key, json, SENTENCE.failed);
867
886
  }
@@ -925,7 +944,7 @@ export async function list(args, io) {
925
944
  resource === "apps" ? "/v1/apps" : resource === "databases" ? "/v1/databases" : "/v1/projects";
926
945
  const result = await call(io, loaded.key, { path, method: "GET" });
927
946
  if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
928
- if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
947
+ if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
929
948
  if (resource !== "projects") {
930
949
  const human = listText(resource, result.body);
931
950
  if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
@@ -962,7 +981,7 @@ export async function usage(args, io) {
962
981
  const json = parsed.values.json === true;
963
982
  const result = await call(io, loaded.key, { path: "/v1/usage", method: "GET" });
964
983
  if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
965
- if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
984
+ if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
966
985
  const human = usageText(result.body);
967
986
  if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
968
987
  return succeed(io, loaded.key, json, result.body, human);
@@ -992,7 +1011,7 @@ export async function plans(args, io) {
992
1011
  const json = parsed.values.json === true;
993
1012
  const result = await call(io, loaded.key, { path: "/v1/plans", method: "GET" });
994
1013
  if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
995
- if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
1014
+ if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
996
1015
  const human = plansText(result.body);
997
1016
  if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
998
1017
  return succeed(io, loaded.key, json, result.body, human);
@@ -1041,7 +1060,7 @@ export async function whoami(args, io) {
1041
1060
  const json = parsed.values.json === true;
1042
1061
  const result = await call(io, loaded.key, { path: "/v1/me", method: "GET" });
1043
1062
  if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
1044
- if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
1063
+ if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
1045
1064
  const view = whoamiView(result.body);
1046
1065
  if (view === null) return fail(io, loaded.key, json, SENTENCE.failed);
1047
1066
  const plan = view.account.plan === null ? "none" : view.account.plan;
@@ -1182,7 +1201,7 @@ export async function diagnose(args, io) {
1182
1201
  method: "GET",
1183
1202
  });
1184
1203
  if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
1185
- if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
1204
+ if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
1186
1205
  const human = diagnoseSentences(result.body);
1187
1206
  if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
1188
1207
  return succeed(io, loaded.key, json, result.body, human);
@@ -1210,7 +1229,7 @@ async function databaseCreate(args, io, key) {
1210
1229
  });
1211
1230
  if (result.network) return fail(io, key, json, SENTENCE.platform);
1212
1231
  if (result.status !== 201) {
1213
- return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
1232
+ return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status, result.retryAfterSeconds));
1214
1233
  }
1215
1234
  const view = databaseFields(isRecord(result.body) ? result.body.database : null);
1216
1235
  const password = isRecord(result.body) ? result.body.password : undefined;
@@ -1233,7 +1252,7 @@ async function databaseStatus(args, io, key) {
1233
1252
  method: "GET",
1234
1253
  });
1235
1254
  if (result.network) return fail(io, key, json, SENTENCE.platform);
1236
- if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
1255
+ if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
1237
1256
  const view = databaseFields(isRecord(result.body) ? result.body.database : null);
1238
1257
  const statusText = isRecord(result.body) ? result.body.status : undefined;
1239
1258
  if (view === null || typeof statusText !== "string") return fail(io, key, json, SENTENCE.failed);
@@ -1269,7 +1288,7 @@ async function databaseBackup(args, io, key) {
1269
1288
  });
1270
1289
  if (result.network) return fail(io, key, json, SENTENCE.platform);
1271
1290
  if (result.status === 409) return fail(io, key, json, SENTENCE.noBackup);
1272
- if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status));
1291
+ if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
1273
1292
  return succeed(io, key, json, { database_id: id, backup: "run" }, "Backup run.\n");
1274
1293
  }
1275
1294
 
@@ -1289,7 +1308,7 @@ async function databaseDelete(args, io, key) {
1289
1308
  method: "DELETE",
1290
1309
  });
1291
1310
  if (result.network) return fail(io, key, json, SENTENCE.platform);
1292
- if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status));
1311
+ if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
1293
1312
  return succeed(io, key, json, { database_id: id, removed: true }, "Database removed.\n");
1294
1313
  }
1295
1314
 
@@ -1396,7 +1415,7 @@ async function storageCreate(args, io, key) {
1396
1415
  if (refusal !== null) return fail(io, key, json, refusal);
1397
1416
  if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
1398
1417
  if (result.status !== 201) {
1399
- return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
1418
+ return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status, result.retryAfterSeconds));
1400
1419
  }
1401
1420
  const body = isRecord(result.body) ? result.body : null;
1402
1421
  const view = body === null ? null : storageFields(body.bucket);
@@ -1438,7 +1457,7 @@ async function storageList(args, io, key) {
1438
1457
  const result = await call(io, key, { path: "/v1/buckets", method: "GET" });
1439
1458
  if (result.network) return fail(io, key, json, SENTENCE.platform);
1440
1459
  if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
1441
- if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
1460
+ if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
1442
1461
  const items = isRecord(result.body) && Array.isArray(result.body.buckets) ? result.body.buckets : null;
1443
1462
  if (items === null) return fail(io, key, json, SENTENCE.failed);
1444
1463
  const views = [];
@@ -1465,7 +1484,7 @@ async function storageShow(args, io, key) {
1465
1484
  });
1466
1485
  if (result.network) return fail(io, key, json, SENTENCE.platform);
1467
1486
  if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
1468
- if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
1487
+ if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
1469
1488
  const view = storageFields(isRecord(result.body) ? result.body.bucket : null);
1470
1489
  if (view === null) return fail(io, key, json, SENTENCE.failed);
1471
1490
  return succeed(io, key, json, { bucket: view }, `${storageLines(view).join("\n")}\n`);
@@ -1489,7 +1508,7 @@ async function storageDelete(args, io, key) {
1489
1508
  if (result.network) return fail(io, key, json, SENTENCE.platform);
1490
1509
  if (result.status === 409) return fail(io, key, json, SENTENCE.bucketNotEmpty);
1491
1510
  if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
1492
- if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status));
1511
+ if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
1493
1512
  return succeed(io, key, json, { bucket_id: id, removed: true }, `${SENTENCE.bucketRemoved}\n`);
1494
1513
  }
1495
1514
 
@@ -1523,7 +1542,7 @@ export async function login(args, io) {
1523
1542
  if (key === "") return fail(io, "", false, SENTENCE.emptyKey);
1524
1543
  const result = await call(io, key, { path: "/v1/me", method: "GET" });
1525
1544
  if (result.network) return fail(io, key, false, SENTENCE.platform);
1526
- if (result.status !== 200) return fail(io, key, false, sentenceFor(result.status));
1545
+ if (result.status !== 200) return fail(io, key, false, sentenceFor(result.status, result.retryAfterSeconds));
1527
1546
  saveApiKey(io.env, key);
1528
1547
  io.stdout.write(`${SENTENCE.loggedIn}\n`);
1529
1548
  return { exitCode: 0 };
@@ -1577,7 +1596,7 @@ async function appRemove(args, io, key) {
1577
1596
 
1578
1597
  const got = await call(io, key, { path: `/v1/apps/${encodeURIComponent(appId)}`, method: "GET" });
1579
1598
  if (got.network) return fail(io, key, json, SENTENCE.platform);
1580
- if (got.status !== 200) return fail(io, key, json, sentenceFor(got.status));
1599
+ if (got.status !== 200) return fail(io, key, json, sentenceFor(got.status, got.retryAfterSeconds));
1581
1600
  if (!isRecord(got.body)) return fail(io, key, json, SENTENCE.failed);
1582
1601
  const application = isRecord(got.body.application) ? got.body.application : null;
1583
1602
  if (application === null) return fail(io, key, json, SENTENCE.failed);
@@ -1591,7 +1610,7 @@ async function appRemove(args, io, key) {
1591
1610
 
1592
1611
  const deleted = await call(io, key, { path: `/v1/apps/${encodeURIComponent(appId)}`, method: "DELETE" });
1593
1612
  if (deleted.network) return fail(io, key, json, SENTENCE.platform);
1594
- if (deleted.status !== 200) return fail(io, key, json, sentenceFor(deleted.status));
1613
+ if (deleted.status !== 200) return fail(io, key, json, sentenceFor(deleted.status, deleted.retryAfterSeconds));
1595
1614
  return succeed(io, key, json, { app_id: appId, removed: true }, `${SENTENCE.appRemoved}\n`);
1596
1615
  }
1597
1616
 
@@ -1749,7 +1768,7 @@ export async function preflight(args, io) {
1749
1768
  });
1750
1769
  if (result.network) return fail(io, loaded.key, json, SENTENCE.platform, undefined, 2);
1751
1770
  if (result.status < 200 || result.status >= 300) {
1752
- return fail(io, loaded.key, json, errorMessage(result.status, result.body), undefined, 2);
1771
+ return fail(io, loaded.key, json, errorMessage(result.status, result.body, result.retryAfterSeconds), undefined, 2);
1753
1772
  }
1754
1773
 
1755
1774
  if (
@@ -1815,7 +1834,7 @@ export async function accountExport(args, io) {
1815
1834
  });
1816
1835
  if (result.network) return fail(io, loaded.key, json, SENTENCE.platform, undefined, 2);
1817
1836
  if (result.status < 200 || result.status >= 300) {
1818
- return fail(io, loaded.key, json, errorMessage(result.status, result.body), undefined, 2);
1837
+ return fail(io, loaded.key, json, errorMessage(result.status, result.body, result.retryAfterSeconds), undefined, 2);
1819
1838
  }
1820
1839
 
1821
1840
  if (json) {
@@ -1826,3 +1845,201 @@ export async function accountExport(args, io) {
1826
1845
  writeStdout(io, loaded.key, `${SENTENCE.exportAccepted}\n`);
1827
1846
  return { exitCode: 0 };
1828
1847
  }
1848
+
1849
+ const NEXT_OPTIONS = {
1850
+ json: { type: "boolean" },
1851
+ };
1852
+
1853
+ export async function next(args, io) {
1854
+ const loaded = loadKey(io);
1855
+ if (loaded.exit) return loaded.exit;
1856
+ const parsed = parseStrict(args, NEXT_OPTIONS, false);
1857
+ if (parsed === null || parsed.positionals.length > 0) {
1858
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
1859
+ }
1860
+ const json = parsed.values.json === true;
1861
+ const result = await call(io, loaded.key, { path: "/v1/me/next-step", method: "GET" });
1862
+ if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
1863
+ if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status, result.retryAfterSeconds));
1864
+ const body = isRecord(result.body) ? result.body : null;
1865
+ const step = body?.step;
1866
+ const sentence = body?.sentence;
1867
+ if (typeof step !== "string" || typeof sentence !== "string") {
1868
+ return fail(io, loaded.key, json, SENTENCE.failed);
1869
+ }
1870
+ if (json) return succeed(io, loaded.key, json, { step, sentence }, "");
1871
+ return succeed(io, loaded.key, json, { step, sentence }, `${sentence}\n`);
1872
+ }
1873
+
1874
+ const COMPOSE_LIST_OPTIONS = {
1875
+ json: { type: "boolean" },
1876
+ };
1877
+
1878
+ const COMPOSE_ACTION_OPTIONS = {
1879
+ json: { type: "boolean" },
1880
+ id: { type: "string", multiple: true },
1881
+ };
1882
+
1883
+ const COMPOSE_UNION = {
1884
+ json: { type: "boolean" },
1885
+ id: { type: "string" },
1886
+ };
1887
+
1888
+ function composePositionals(args) {
1889
+ try {
1890
+ return parseArgs({
1891
+ args: Array.isArray(args) ? args : [],
1892
+ strict: false,
1893
+ allowPositionals: true,
1894
+ options: COMPOSE_UNION,
1895
+ }).positionals;
1896
+ } catch {
1897
+ return null;
1898
+ }
1899
+ }
1900
+
1901
+ function composeFields(value) {
1902
+ if (!isRecord(value)) return null;
1903
+ const id = value.id;
1904
+ const projectId = value.project_id;
1905
+ const name = value.name;
1906
+ const gitUrl = value.git_url;
1907
+ const branch = value.branch;
1908
+ const composePath = value.compose_path;
1909
+ const memoryLimit = value.memory_limit;
1910
+ const createdAt = value.created_at;
1911
+ const updatedAt = value.updated_at;
1912
+ if (typeof id !== "string" || typeof projectId !== "string" || typeof name !== "string") return null;
1913
+ if (typeof gitUrl !== "string" || typeof branch !== "string" || typeof composePath !== "string") return null;
1914
+ if (typeof createdAt !== "string" || typeof updatedAt !== "string") return null;
1915
+ if (typeof memoryLimit !== "number" || !Number.isInteger(memoryLimit)) return null;
1916
+ const view = {
1917
+ id,
1918
+ project_id: projectId,
1919
+ name,
1920
+ git_url: gitUrl,
1921
+ branch,
1922
+ compose_path: composePath,
1923
+ memory_limit: memoryLimit,
1924
+ created_at: createdAt,
1925
+ updated_at: updatedAt,
1926
+ };
1927
+ const projectName = value.project_name;
1928
+ if (typeof projectName === "string") {
1929
+ return { ...view, project_name: projectName };
1930
+ }
1931
+ return view;
1932
+ }
1933
+
1934
+ function composeLines(view) {
1935
+ return [
1936
+ view.id,
1937
+ view.project_id,
1938
+ view.name,
1939
+ view.git_url,
1940
+ view.branch,
1941
+ view.compose_path,
1942
+ String(view.memory_limit),
1943
+ view.created_at,
1944
+ view.updated_at,
1945
+ ];
1946
+ }
1947
+
1948
+ function composeListLine(view) {
1949
+ const base = `${view.id} ${view.name}`;
1950
+ return typeof view.project_name === "string" ? `${base} ${view.project_name}` : base;
1951
+ }
1952
+
1953
+ function requireComposeId(io, key, json, rawId) {
1954
+ const ids = Array.isArray(rawId) ? rawId : rawId === undefined ? [] : [rawId];
1955
+ if (ids.length > 1) return { exit: fail(io, key, json, SENTENCE.refused) };
1956
+ const id = appUuid(ids[0]);
1957
+ if (id === null) return { exit: fail(io, key, json, SENTENCE.composeId) };
1958
+ return { id };
1959
+ }
1960
+
1961
+ async function composeList(args, io, key) {
1962
+ const parsed = parseStrict(args, COMPOSE_LIST_OPTIONS, true);
1963
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "list") {
1964
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1965
+ }
1966
+ if (parsed.values.id !== undefined) return fail(io, key, wantsJson(args), SENTENCE.refused);
1967
+ const json = parsed.values.json === true;
1968
+ const result = await call(io, key, { path: "/v1/compose", method: "GET" });
1969
+ if (result.network) return fail(io, key, json, SENTENCE.platform);
1970
+ if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status, result.retryAfterSeconds));
1971
+ const items = isRecord(result.body) && Array.isArray(result.body.compose) ? result.body.compose : null;
1972
+ if (items === null) return fail(io, key, json, SENTENCE.failed);
1973
+ const views = [];
1974
+ for (const item of items) {
1975
+ const row = composeFields(item);
1976
+ if (row === null) return fail(io, key, json, SENTENCE.failed);
1977
+ views.push(row);
1978
+ }
1979
+ const human = views.length === 0 ? "" : `${views.map((row) => composeListLine(row)).join("\n")}\n`;
1980
+ return succeed(io, key, json, { compose: views }, human);
1981
+ }
1982
+
1983
+ async function composeDeploy(args, io, key) {
1984
+ const parsed = parseStrict(args, COMPOSE_ACTION_OPTIONS, true);
1985
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "deploy") {
1986
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1987
+ }
1988
+ const json = parsed.values.json === true;
1989
+ const required = requireComposeId(io, key, json, parsed.values.id);
1990
+ if (required.exit) return required.exit;
1991
+ const result = await call(io, key, {
1992
+ path: `/v1/compose/${encodeURIComponent(required.id)}/deploy`,
1993
+ method: "POST",
1994
+ });
1995
+ if (result.network) return fail(io, key, json, SENTENCE.platform);
1996
+ if (result.status !== 202) {
1997
+ return fail(
1998
+ io,
1999
+ key,
2000
+ json,
2001
+ upgradeMessage(result.status, result.body) ?? composeUnavailableMessage(result.status, result.body) ?? sentenceFor(result.status, result.retryAfterSeconds),
2002
+ );
2003
+ }
2004
+ const view = composeFields(isRecord(result.body) ? result.body.compose : null);
2005
+ if (view === null) return fail(io, key, json, SENTENCE.failed);
2006
+ const payload = { compose: view };
2007
+ return succeed(io, key, json, payload, `${composeLines(view).join("\n")}\n`);
2008
+ }
2009
+
2010
+ async function composeStartStop(args, io, key, action) {
2011
+ const parsed = parseStrict(args, COMPOSE_ACTION_OPTIONS, true);
2012
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== action) {
2013
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
2014
+ }
2015
+ const json = parsed.values.json === true;
2016
+ const required = requireComposeId(io, key, json, parsed.values.id);
2017
+ if (required.exit) return required.exit;
2018
+ const result = await call(io, key, {
2019
+ path: `/v1/compose/${encodeURIComponent(required.id)}/${action}`,
2020
+ method: "POST",
2021
+ });
2022
+ if (result.network) return fail(io, key, json, SENTENCE.platform);
2023
+ if (result.status !== 200) {
2024
+ return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status, result.retryAfterSeconds));
2025
+ }
2026
+ const body = isRecord(result.body) ? result.body : null;
2027
+ const view = body === null ? null : composeFields(body.compose);
2028
+ const statusText = body?.status;
2029
+ if (view === null || typeof statusText !== "string") return fail(io, key, json, SENTENCE.failed);
2030
+ const payload = { compose: view, status: statusText };
2031
+ const lines = [statusText, ...composeLines(view)];
2032
+ return succeed(io, key, json, payload, `${lines.join("\n")}\n`);
2033
+ }
2034
+
2035
+ export async function compose(args, io) {
2036
+ const loaded = loadKey(io);
2037
+ if (loaded.exit) return loaded.exit;
2038
+ const words = composePositionals(args);
2039
+ if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
2040
+ if (words[0] === "list") return composeList(args, io, loaded.key);
2041
+ 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");
2044
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
2045
+ }
package/src/main.mjs CHANGED
@@ -4,7 +4,7 @@ import { createInterface } from "node:readline";
4
4
  import { Writable } from "node:stream";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { DEFAULT_BASE_URL } from "./client.mjs";
7
- import { accountExport, app, database, deploy, diagnose, domains, env, list, login, logout, logs, plans, preflight, restart, rollback, status, storage, usage, whoami } from "./commands.mjs";
7
+ import { accountExport, app, compose, database, deploy, diagnose, domains, env, list, login, logout, logs, next, plans, preflight, restart, rollback, status, storage, usage, whoami } from "./commands.mjs";
8
8
 
9
9
  const PROMPT = "Paste your Astris API key: ";
10
10
 
@@ -48,6 +48,7 @@ const handlers = new Map([
48
48
  ["diagnose", diagnose],
49
49
  ["database", database],
50
50
  ["storage", storage],
51
+ ["compose", compose],
51
52
  ["env", env],
52
53
  ["domains", domains],
53
54
  ["restart", restart],
@@ -56,6 +57,7 @@ const handlers = new Map([
56
57
  ["usage", usage],
57
58
  ["plans", plans],
58
59
  ["whoami", whoami],
60
+ ["next", next],
59
61
  ["rollback", rollback],
60
62
  ["login", login],
61
63
  ["logout", logout],