@astris-labs/cli 0.1.1 → 0.1.3

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
@@ -12,7 +12,7 @@ From this repository, run `pnpm install` at the root. Then run the client with `
12
12
 
13
13
  ## API key
14
14
 
15
- `astris login` reads the key from stdin and saves it only after the API accepts it. `astris logout` deletes that one file.
15
+ `astris login` asks for the key. On a terminal the question is `Paste your Astris API key:` and the typed key is not shown. With a pipe, `astris login` reads the key from stdin. The key is saved only after the API accepts it. `astris logout` deletes that one file.
16
16
 
17
17
  The file name is `key`, in a directory named `astris`.
18
18
 
@@ -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`, and `logout`.
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
 
@@ -65,7 +65,7 @@ Optional flags are `--build-path`, `--dockerfile`, `--port`, `--build`, `--publi
65
65
 
66
66
  `astris env --app <id>` reads one JSON object from stdin and replaces the whole variable set.
67
67
 
68
- `astris env --app <id> --names` prints the names of the application's variables, one per line, and never a value. `--json` prints `{application_id, names}`. It reads no stdin. The runner still waits for stdin to close when stdin is not a terminal, so close it when nothing is piped in, for example with `< /dev/null`.
68
+ `astris env --app <id> --names` prints the names of the application's variables, one per line, and never a value. `--json` prints `{application_id, names}`. It reads no stdin and does not wait for stdin to close.
69
69
 
70
70
  ### domains
71
71
 
@@ -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. 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,18 +137,37 @@ 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
- `astris login` reads the key from stdin. It does not take the key as an argument. When the API accepts the key, it writes `Logged in.`
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.`
131
147
 
132
148
  ### logout
133
149
 
134
150
  `astris logout` deletes the saved key file and writes `Logged out.` When there is no file, it writes `There was nothing to remove.`
135
151
 
152
+ ### app
153
+
154
+ `astris app remove --app <id> --confirm <name>` calls `GET /v1/apps/<id>` and then `DELETE /v1/apps/<id>`. `--confirm` must equal the application name exactly; without `--confirm` the sentence is `Send --confirm set to the application name.` A different name is refused with `The name does not match the application.` and nothing is deleted. Human output is `App removed.` `--json` prints one JSON object `{"app_id":"<id>","removed":true}`.
155
+
156
+ ### preflight
157
+
158
+ `astris preflight [dir]` calls `POST /v1/preflight` with the files it finds under `dir` (the current directory when `dir` is omitted) and prints the verdict and one line per check with its reason. It sends no file or folder whose name starts with a dot (so no .env file and nothing under .git), nothing under node_modules, dist, build, out or coverage, no binary file, and nothing outside dir. Files the request cannot hold are left out and counted on stderr. `--json` prints one JSON object of the response body.
159
+
160
+ ### export
161
+
162
+ `astris export --email <address>` calls `POST /v1/account-export` and prints the answer. If the address is on an Astris account, check its inbox for the download link. The link works for one hour. Nothing is written to disk. `--json` prints one JSON object of the response body.
163
+
164
+
136
165
  ## Exit codes
137
166
 
138
167
  Exit code 0 means the command succeeded. Any error exits with a non-zero code. With `--json`, an error that happens after the key check is one object `{"error":"<sentence>"}` on stdout. A missing key and an unknown command write their sentence to stderr.
139
168
 
169
+ `astris preflight` exits 0 when the verdict is `pass` or `fixable`, 1 when it is `refuse`, and 2 when it could not run. `astris export` exits 0 when the server accepts the request and 2 when it could not run.
170
+
140
171
  ## Not in this version
141
172
 
142
173
  `rollback` is not in v0. The route it needs waits for T-042 item 3.
@@ -145,18 +176,22 @@ Exit code 0 means the command succeeded. Any error exits with a non-zero code. W
145
176
 
146
177
  Some arguments are refused on this machine, before any request.
147
178
 
148
- The command name is checked first. `deploy`, `status`, `env`, `domains`, `restart`, `logs`, `rollback`, `diagnose`, `database`, `storage`, `list`, `usage`, `plans`, `whoami`, `login`, and `logout` 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.
149
180
 
150
- Login reads the key from stdin. 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.
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.
151
182
 
152
183
  `login` and `logout` take no arguments. An argument is refused, and `logout` does not delete the file.
153
184
 
154
- 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.`
155
186
 
156
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.
157
188
 
158
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.
159
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
+
160
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.
161
196
 
162
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.1",
3
+ "version": "0.1.3",
4
4
  "description": "The Astris CLI, a command-line client of the Astris API.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/commands.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { lstatSync, readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
1
3
  import { parseArgs } from "node:util";
2
4
 
3
5
  import { astrisFetch, DEFAULT_BASE_URL, redact } from "./client.mjs";
@@ -14,6 +16,7 @@ const SENTENCE = {
14
16
  projectId: "Send --project set to the project id.",
15
17
  databaseFlags: "Those arguments were refused. Send --project, --kind, and --name.",
16
18
  databaseId: "Send --id set to the database id.",
19
+ composeId: "Send --id set to the compose stack id.",
17
20
  domainId: "Send --id set to the domain id.",
18
21
  git: "The Git URL must not include a user name or password.",
19
22
  // T-169: reused word for word from the MCP's SENTENCE.deployKey (T-139).
@@ -41,6 +44,13 @@ const SENTENCE = {
41
44
  loggedIn: "Logged in.",
42
45
  loggedOut: "Logged out.",
43
46
  nothingToRemove: "There was nothing to remove.",
47
+ appRemoved: "App removed.",
48
+ confirmRequired: "Send --confirm set to the application name.",
49
+ nameMismatch: "The name does not match the application.",
50
+ unreadableDir: "That directory cannot be read.",
51
+ exportEmail: "Send --email set to the account email address.",
52
+ exportAccepted:
53
+ "Accepted. If that address is on an Astris account, check its inbox for the download link. The link works for one hour.",
44
54
  };
45
55
 
46
56
  const HTTP_SENTENCE = new Map([
@@ -48,6 +58,7 @@ const HTTP_SENTENCE = new Map([
48
58
  [401, SENTENCE.unauthorized],
49
59
  [404, SENTENCE.notFound],
50
60
  [409, SENTENCE.taken],
61
+ [429, "This account asked for too many changes in a short time. Wait a little, then try again."],
51
62
  [502, SENTENCE.platform],
52
63
  ]);
53
64
 
@@ -55,6 +66,11 @@ function sentenceFor(status) {
55
66
  return HTTP_SENTENCE.get(status) ?? SENTENCE.failed;
56
67
  }
57
68
 
69
+ function errorMessage(status, body) {
70
+ if (isRecord(body) && typeof body.message === "string") return body.message;
71
+ return sentenceFor(status);
72
+ }
73
+
58
74
  function upgradeMessage(status, body) {
59
75
  if (status !== 403 || !isRecord(body) || body.error !== "upgrade_required" || typeof body.message !== "string") {
60
76
  return null;
@@ -94,12 +110,12 @@ function withCreated(sentence, created) {
94
110
  return text;
95
111
  }
96
112
 
97
- function fail(io, key, json, sentence, created) {
113
+ function fail(io, key, json, sentence, created, exitCode = 1) {
98
114
  const text = withCreated(sentence, created);
99
115
  const safe = redact(text, key);
100
116
  if (json) writeStdout(io, key, `${JSON.stringify({ error: safe })}\n`);
101
117
  else writeStderr(io, key, `${safe}\n`);
102
- return { exitCode: 1 };
118
+ return { exitCode };
103
119
  }
104
120
 
105
121
  function succeed(io, key, json, payload, human) {
@@ -108,13 +124,13 @@ function succeed(io, key, json, payload, human) {
108
124
  return { exitCode: 0 };
109
125
  }
110
126
 
111
- function loadKey(io) {
127
+ function loadKey(io, exitCode = 1) {
112
128
  try {
113
129
  return { key: readApiKey(io.env) };
114
130
  } catch (error) {
115
131
  if (error instanceof NoApiKeyError) {
116
132
  io.stderr.write(`${SENTENCE.missingKey}\n`);
117
- return { exit: { exitCode: 1 } };
133
+ return { exit: { exitCode } };
118
134
  }
119
135
  throw error;
120
136
  }
@@ -1497,7 +1513,15 @@ function stdinKey(io) {
1497
1513
 
1498
1514
  export async function login(args, io) {
1499
1515
  if (Array.isArray(args) && args.length > 0) return fail(io, "", false, SENTENCE.refused);
1500
- const key = stdinKey(io);
1516
+ let key = stdinKey(io);
1517
+ if (key === "" && typeof io.readSecret === "function") {
1518
+ try {
1519
+ key = (await io.readSecret()).trim();
1520
+ } catch {
1521
+ // Ctrl+C at the prompt: leave with no output and nothing saved.
1522
+ return { exitCode: 1 };
1523
+ }
1524
+ }
1501
1525
  if (key === "") return fail(io, "", false, SENTENCE.emptyKey);
1502
1526
  const result = await call(io, key, { path: "/v1/me", method: "GET" });
1503
1527
  if (result.network) return fail(io, key, false, SENTENCE.platform);
@@ -1513,3 +1537,487 @@ export async function logout(args, io) {
1513
1537
  io.stdout.write(`${removed ? SENTENCE.loggedOut : SENTENCE.nothingToRemove}\n`);
1514
1538
  return { exitCode: 0 };
1515
1539
  }
1540
+
1541
+ const APP_REMOVE_OPTIONS = {
1542
+ json: { type: "boolean" },
1543
+ app: { type: "string" },
1544
+ confirm: { type: "string" },
1545
+ };
1546
+
1547
+ function appPositionals(args) {
1548
+ try {
1549
+ return parseArgs({
1550
+ args: Array.isArray(args) ? args : [],
1551
+ strict: false,
1552
+ allowPositionals: true,
1553
+ options: APP_REMOVE_OPTIONS,
1554
+ }).positionals;
1555
+ } catch {
1556
+ return null;
1557
+ }
1558
+ }
1559
+
1560
+ export async function app(args, io) {
1561
+ const loaded = loadKey(io);
1562
+ if (loaded.exit) return loaded.exit;
1563
+ const words = appPositionals(args);
1564
+ if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
1565
+ if (words[0] === "remove") return appRemove(args, io, loaded.key);
1566
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
1567
+ }
1568
+
1569
+ async function appRemove(args, io, key) {
1570
+ const parsed = parseStrict(args, APP_REMOVE_OPTIONS, true);
1571
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "remove") {
1572
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1573
+ }
1574
+ const json = parsed.values.json === true;
1575
+ const appId = appUuid(parsed.values.app);
1576
+ if (appId === null) return fail(io, key, json, SENTENCE.app);
1577
+ const confirm = typeof parsed.values.confirm === "string" ? parsed.values.confirm : "";
1578
+ if (confirm === "") return fail(io, key, json, SENTENCE.confirmRequired);
1579
+
1580
+ const got = await call(io, key, { path: `/v1/apps/${encodeURIComponent(appId)}`, method: "GET" });
1581
+ if (got.network) return fail(io, key, json, SENTENCE.platform);
1582
+ if (got.status !== 200) return fail(io, key, json, sentenceFor(got.status));
1583
+ if (!isRecord(got.body)) return fail(io, key, json, SENTENCE.failed);
1584
+ const application = isRecord(got.body.application) ? got.body.application : null;
1585
+ if (application === null) return fail(io, key, json, SENTENCE.failed);
1586
+ const name = typeof application.name === "string" ? application.name : null;
1587
+ if (name === null) return fail(io, key, json, SENTENCE.failed);
1588
+
1589
+ if (name !== confirm) {
1590
+ io.stderr.write(`${SENTENCE.nameMismatch}\n`);
1591
+ return { exitCode: 1 };
1592
+ }
1593
+
1594
+ const deleted = await call(io, key, { path: `/v1/apps/${encodeURIComponent(appId)}`, method: "DELETE" });
1595
+ if (deleted.network) return fail(io, key, json, SENTENCE.platform);
1596
+ if (deleted.status !== 200) return fail(io, key, json, sentenceFor(deleted.status));
1597
+ return succeed(io, key, json, { app_id: appId, removed: true }, `${SENTENCE.appRemoved}\n`);
1598
+ }
1599
+
1600
+ export const PREFLIGHT_MAX_FILES = 64; // the server's, src/app/v1/preflight/route.ts:24
1601
+ export const PREFLIGHT_MAX_FILE_BYTES = 32 * 1024; // the server's, src/app/v1/preflight/route.ts:25
1602
+ export const PREFLIGHT_MAX_LOCKFILE_BYTES = 320 * 1024; // the server's, src/app/v1/preflight/route.ts:26
1603
+ export const PREFLIGHT_MAX_BODY_BYTES = 512 * 1024; // the server's, src/app/v1/preflight/route.ts:27
1604
+
1605
+ const ROOT_LOCKFILES = new Set([
1606
+ "package-lock.json",
1607
+ "pnpm-lock.yaml",
1608
+ "yarn.lock",
1609
+ "bun.lock",
1610
+ ]);
1611
+
1612
+ const SKIP_DIRS = new Set([
1613
+ "node_modules",
1614
+ "dist",
1615
+ "build",
1616
+ "out",
1617
+ "coverage",
1618
+ ]);
1619
+
1620
+ function validRelPath(p) {
1621
+ if (p.length === 0 || p.length > 256) return false;
1622
+ if (p.startsWith("/") || p.includes("\\") || /^[A-Za-z]:/.test(p)) return false;
1623
+ return p.split("/").every((segment) => segment.length > 0 && segment !== "..");
1624
+ }
1625
+
1626
+ export function preflightFiles(root) {
1627
+ let rootStat;
1628
+ try {
1629
+ rootStat = lstatSync(root);
1630
+ } catch {
1631
+ return null;
1632
+ }
1633
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return null;
1634
+
1635
+ const candidates = [];
1636
+ function walk(dir) {
1637
+ let entries;
1638
+ try {
1639
+ entries = readdirSync(dir);
1640
+ } catch {
1641
+ return false;
1642
+ }
1643
+ for (const name of entries) {
1644
+ if (name.startsWith(".")) continue;
1645
+ const fullPath = path.join(dir, name);
1646
+ let stat;
1647
+ try {
1648
+ stat = lstatSync(fullPath);
1649
+ } catch {
1650
+ continue;
1651
+ }
1652
+ if (stat.isSymbolicLink()) continue;
1653
+ if (stat.isDirectory()) {
1654
+ if (SKIP_DIRS.has(name)) continue;
1655
+ const ok = walk(fullPath);
1656
+ if (!ok) return false;
1657
+ } else if (stat.isFile()) {
1658
+ const rel = path.relative(root, fullPath).split(path.sep).join("/");
1659
+ const depth = rel.split("/").length - 1;
1660
+ candidates.push({ rel, fullPath, depth, stat });
1661
+ }
1662
+ }
1663
+ return true;
1664
+ }
1665
+
1666
+ if (!walk(root)) return null;
1667
+
1668
+ candidates.sort((a, b) => {
1669
+ if (a.depth !== b.depth) return a.depth - b.depth;
1670
+ return a.rel.localeCompare(b.rel);
1671
+ });
1672
+
1673
+ const files = [];
1674
+ let leftOut = 0;
1675
+
1676
+ for (const candidate of candidates) {
1677
+ if (!validRelPath(candidate.rel)) {
1678
+ leftOut++;
1679
+ continue;
1680
+ }
1681
+
1682
+ if (files.length >= PREFLIGHT_MAX_FILES) {
1683
+ leftOut++;
1684
+ continue;
1685
+ }
1686
+
1687
+ const isRootLock = candidate.depth === 0 && ROOT_LOCKFILES.has(candidate.rel);
1688
+ const maxBytes = isRootLock ? PREFLIGHT_MAX_LOCKFILE_BYTES : PREFLIGHT_MAX_FILE_BYTES;
1689
+ if (candidate.stat.size > maxBytes) {
1690
+ leftOut++;
1691
+ continue;
1692
+ }
1693
+
1694
+ let buf;
1695
+ try {
1696
+ buf = readFileSync(candidate.fullPath);
1697
+ } catch {
1698
+ leftOut++;
1699
+ continue;
1700
+ }
1701
+
1702
+ if (buf.includes(0x00)) {
1703
+ leftOut++;
1704
+ continue;
1705
+ }
1706
+
1707
+ const text = buf.toString("utf8");
1708
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
1709
+ leftOut++;
1710
+ continue;
1711
+ }
1712
+
1713
+ const candidateFile = { path: candidate.rel, text };
1714
+ const wouldBeBytes = Buffer.byteLength(
1715
+ JSON.stringify({ files: [...files, candidateFile] }),
1716
+ "utf8",
1717
+ );
1718
+ if (wouldBeBytes > PREFLIGHT_MAX_BODY_BYTES) {
1719
+ leftOut++;
1720
+ continue;
1721
+ }
1722
+
1723
+ files.push(candidateFile);
1724
+ }
1725
+
1726
+ return { files, leftOut };
1727
+ }
1728
+
1729
+ const PREFLIGHT_OPTIONS = {
1730
+ json: { type: "boolean" },
1731
+ };
1732
+
1733
+ export async function preflight(args, io) {
1734
+ const loaded = loadKey(io, 2);
1735
+ if (loaded.exit) return loaded.exit;
1736
+ const parsed = parseStrict(args, PREFLIGHT_OPTIONS, true);
1737
+ if (parsed === null || parsed.positionals.length > 1) {
1738
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused, undefined, 2);
1739
+ }
1740
+ const json = parsed.values.json === true;
1741
+ const dir = parsed.positionals.length === 1 ? parsed.positionals[0] : ".";
1742
+ const walked = preflightFiles(dir);
1743
+ if (walked === null) {
1744
+ return fail(io, loaded.key, json, SENTENCE.unreadableDir, undefined, 2);
1745
+ }
1746
+
1747
+ const result = await call(io, loaded.key, {
1748
+ path: "/v1/preflight",
1749
+ method: "POST",
1750
+ body: { files: walked.files },
1751
+ });
1752
+ if (result.network) return fail(io, loaded.key, json, SENTENCE.platform, undefined, 2);
1753
+ if (result.status < 200 || result.status >= 300) {
1754
+ return fail(io, loaded.key, json, errorMessage(result.status, result.body), undefined, 2);
1755
+ }
1756
+
1757
+ if (
1758
+ !isRecord(result.body) ||
1759
+ typeof result.body.verdict !== "string" ||
1760
+ !Array.isArray(result.body.checks) ||
1761
+ (result.body.verdict !== "pass" && result.body.verdict !== "fixable" && result.body.verdict !== "refuse")
1762
+ ) {
1763
+ return fail(io, loaded.key, json, SENTENCE.failed, undefined, 2);
1764
+ }
1765
+
1766
+ if (walked.leftOut > 0) {
1767
+ const leftOutMsg =
1768
+ walked.leftOut === 1
1769
+ ? "Left out 1 file. The checks did not read it.\n"
1770
+ : `Left out ${walked.leftOut} files. The checks did not read them.\n`;
1771
+ io.stderr.write(leftOutMsg);
1772
+ }
1773
+
1774
+ const exitCode = result.body.verdict === "refuse" ? 1 : 0;
1775
+ if (json) {
1776
+ writeStdout(io, loaded.key, `${JSON.stringify(result.body)}\n`);
1777
+ return { exitCode };
1778
+ }
1779
+
1780
+ let human = `Verdict: ${result.body.verdict}\n`;
1781
+ for (const check of result.body.checks) {
1782
+ if (
1783
+ isRecord(check) &&
1784
+ typeof check.name === "string" &&
1785
+ typeof check.result === "string" &&
1786
+ typeof check.reason === "string"
1787
+ ) {
1788
+ human += `${check.name}: ${check.result} - ${check.reason}\n`;
1789
+ }
1790
+ }
1791
+ writeStdout(io, loaded.key, human);
1792
+ return { exitCode };
1793
+ }
1794
+
1795
+ const EXPORT_OPTIONS = {
1796
+ json: { type: "boolean" },
1797
+ email: { type: "string" },
1798
+ };
1799
+
1800
+ export async function accountExport(args, io) {
1801
+ const loaded = loadKey(io, 2);
1802
+ if (loaded.exit) return loaded.exit;
1803
+ const parsed = parseStrict(args, EXPORT_OPTIONS, false);
1804
+ if (parsed === null) {
1805
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused, undefined, 2);
1806
+ }
1807
+ const json = parsed.values.json === true;
1808
+ const email = typeof parsed.values.email === "string" ? parsed.values.email.trim() : "";
1809
+ if (email === "") {
1810
+ return fail(io, loaded.key, json, SENTENCE.exportEmail, undefined, 2);
1811
+ }
1812
+
1813
+ const result = await call(io, loaded.key, {
1814
+ path: "/v1/account-export",
1815
+ method: "POST",
1816
+ body: { email },
1817
+ });
1818
+ if (result.network) return fail(io, loaded.key, json, SENTENCE.platform, undefined, 2);
1819
+ if (result.status < 200 || result.status >= 300) {
1820
+ return fail(io, loaded.key, json, errorMessage(result.status, result.body), undefined, 2);
1821
+ }
1822
+
1823
+ if (json) {
1824
+ writeStdout(io, loaded.key, `${JSON.stringify(result.body ?? { status: "accepted" })}\n`);
1825
+ return { exitCode: 0 };
1826
+ }
1827
+
1828
+ writeStdout(io, loaded.key, `${SENTENCE.exportAccepted}\n`);
1829
+ return { exitCode: 0 };
1830
+ }
1831
+
1832
+ const NEXT_OPTIONS = {
1833
+ json: { type: "boolean" },
1834
+ };
1835
+
1836
+ export async function next(args, io) {
1837
+ const loaded = loadKey(io);
1838
+ if (loaded.exit) return loaded.exit;
1839
+ const parsed = parseStrict(args, NEXT_OPTIONS, false);
1840
+ if (parsed === null || parsed.positionals.length > 0) {
1841
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
1842
+ }
1843
+ const json = parsed.values.json === true;
1844
+ const result = await call(io, loaded.key, { path: "/v1/me/next-step", method: "GET" });
1845
+ 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));
1847
+ const body = isRecord(result.body) ? result.body : null;
1848
+ const step = body?.step;
1849
+ const sentence = body?.sentence;
1850
+ if (typeof step !== "string" || typeof sentence !== "string") {
1851
+ return fail(io, loaded.key, json, SENTENCE.failed);
1852
+ }
1853
+ if (json) return succeed(io, loaded.key, json, { step, sentence }, "");
1854
+ return succeed(io, loaded.key, json, { step, sentence }, `${sentence}\n`);
1855
+ }
1856
+
1857
+ const COMPOSE_LIST_OPTIONS = {
1858
+ json: { type: "boolean" },
1859
+ };
1860
+
1861
+ const COMPOSE_ACTION_OPTIONS = {
1862
+ json: { type: "boolean" },
1863
+ id: { type: "string", multiple: true },
1864
+ };
1865
+
1866
+ const COMPOSE_UNION = {
1867
+ json: { type: "boolean" },
1868
+ id: { type: "string" },
1869
+ };
1870
+
1871
+ function composePositionals(args) {
1872
+ try {
1873
+ return parseArgs({
1874
+ args: Array.isArray(args) ? args : [],
1875
+ strict: false,
1876
+ allowPositionals: true,
1877
+ options: COMPOSE_UNION,
1878
+ }).positionals;
1879
+ } catch {
1880
+ return null;
1881
+ }
1882
+ }
1883
+
1884
+ function composeFields(value) {
1885
+ if (!isRecord(value)) return null;
1886
+ const id = value.id;
1887
+ const projectId = value.project_id;
1888
+ const name = value.name;
1889
+ const gitUrl = value.git_url;
1890
+ const branch = value.branch;
1891
+ const composePath = value.compose_path;
1892
+ const memoryLimit = value.memory_limit;
1893
+ const createdAt = value.created_at;
1894
+ const updatedAt = value.updated_at;
1895
+ if (typeof id !== "string" || typeof projectId !== "string" || typeof name !== "string") return null;
1896
+ if (typeof gitUrl !== "string" || typeof branch !== "string" || typeof composePath !== "string") return null;
1897
+ if (typeof createdAt !== "string" || typeof updatedAt !== "string") return null;
1898
+ if (typeof memoryLimit !== "number" || !Number.isInteger(memoryLimit)) return null;
1899
+ const view = {
1900
+ id,
1901
+ project_id: projectId,
1902
+ name,
1903
+ git_url: gitUrl,
1904
+ branch,
1905
+ compose_path: composePath,
1906
+ memory_limit: memoryLimit,
1907
+ created_at: createdAt,
1908
+ updated_at: updatedAt,
1909
+ };
1910
+ const projectName = value.project_name;
1911
+ if (typeof projectName === "string") {
1912
+ return { ...view, project_name: projectName };
1913
+ }
1914
+ return view;
1915
+ }
1916
+
1917
+ function composeLines(view) {
1918
+ return [
1919
+ view.id,
1920
+ view.project_id,
1921
+ view.name,
1922
+ view.git_url,
1923
+ view.branch,
1924
+ view.compose_path,
1925
+ String(view.memory_limit),
1926
+ view.created_at,
1927
+ view.updated_at,
1928
+ ];
1929
+ }
1930
+
1931
+ function composeListLine(view) {
1932
+ const base = `${view.id} ${view.name}`;
1933
+ return typeof view.project_name === "string" ? `${base} ${view.project_name}` : base;
1934
+ }
1935
+
1936
+ function requireComposeId(io, key, json, rawId) {
1937
+ const ids = Array.isArray(rawId) ? rawId : rawId === undefined ? [] : [rawId];
1938
+ if (ids.length > 1) return { exit: fail(io, key, json, SENTENCE.refused) };
1939
+ const id = appUuid(ids[0]);
1940
+ if (id === null) return { exit: fail(io, key, json, SENTENCE.composeId) };
1941
+ return { id };
1942
+ }
1943
+
1944
+ async function composeList(args, io, key) {
1945
+ const parsed = parseStrict(args, COMPOSE_LIST_OPTIONS, true);
1946
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "list") {
1947
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1948
+ }
1949
+ if (parsed.values.id !== undefined) return fail(io, key, wantsJson(args), SENTENCE.refused);
1950
+ const json = parsed.values.json === true;
1951
+ const result = await call(io, key, { path: "/v1/compose", method: "GET" });
1952
+ if (result.network) return fail(io, key, json, SENTENCE.platform);
1953
+ if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
1954
+ const items = isRecord(result.body) && Array.isArray(result.body.compose) ? result.body.compose : null;
1955
+ if (items === null) return fail(io, key, json, SENTENCE.failed);
1956
+ const views = [];
1957
+ for (const item of items) {
1958
+ const row = composeFields(item);
1959
+ if (row === null) return fail(io, key, json, SENTENCE.failed);
1960
+ views.push(row);
1961
+ }
1962
+ const human = views.length === 0 ? "" : `${views.map((row) => composeListLine(row)).join("\n")}\n`;
1963
+ return succeed(io, key, json, { compose: views }, human);
1964
+ }
1965
+
1966
+ async function composeDeploy(args, io, key) {
1967
+ const parsed = parseStrict(args, COMPOSE_ACTION_OPTIONS, true);
1968
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "deploy") {
1969
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1970
+ }
1971
+ const json = parsed.values.json === true;
1972
+ const required = requireComposeId(io, key, json, parsed.values.id);
1973
+ if (required.exit) return required.exit;
1974
+ const result = await call(io, key, {
1975
+ path: `/v1/compose/${encodeURIComponent(required.id)}/deploy`,
1976
+ method: "POST",
1977
+ });
1978
+ if (result.network) return fail(io, key, json, SENTENCE.platform);
1979
+ if (result.status !== 202) {
1980
+ return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
1981
+ }
1982
+ const view = composeFields(isRecord(result.body) ? result.body.compose : null);
1983
+ if (view === null) return fail(io, key, json, SENTENCE.failed);
1984
+ const payload = { compose: view };
1985
+ return succeed(io, key, json, payload, `${composeLines(view).join("\n")}\n`);
1986
+ }
1987
+
1988
+ async function composeStartStop(args, io, key, action) {
1989
+ const parsed = parseStrict(args, COMPOSE_ACTION_OPTIONS, true);
1990
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== action) {
1991
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1992
+ }
1993
+ const json = parsed.values.json === true;
1994
+ const required = requireComposeId(io, key, json, parsed.values.id);
1995
+ if (required.exit) return required.exit;
1996
+ const result = await call(io, key, {
1997
+ path: `/v1/compose/${encodeURIComponent(required.id)}/${action}`,
1998
+ method: "POST",
1999
+ });
2000
+ if (result.network) return fail(io, key, json, SENTENCE.platform);
2001
+ if (result.status !== 200) {
2002
+ return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
2003
+ }
2004
+ const body = isRecord(result.body) ? result.body : null;
2005
+ const view = body === null ? null : composeFields(body.compose);
2006
+ const statusText = body?.status;
2007
+ if (view === null || typeof statusText !== "string") return fail(io, key, json, SENTENCE.failed);
2008
+ const payload = { compose: view, status: statusText };
2009
+ const lines = [statusText, ...composeLines(view)];
2010
+ return succeed(io, key, json, payload, `${lines.join("\n")}\n`);
2011
+ }
2012
+
2013
+ export async function compose(args, io) {
2014
+ const loaded = loadKey(io);
2015
+ if (loaded.exit) return loaded.exit;
2016
+ const words = composePositionals(args);
2017
+ if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
2018
+ if (words[0] === "list") return composeList(args, io, loaded.key);
2019
+ 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");
2022
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
2023
+ }
package/src/main.mjs CHANGED
@@ -1,28 +1,82 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
+ import { createInterface } from "node:readline";
4
+ import { Writable } from "node:stream";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { DEFAULT_BASE_URL } from "./client.mjs";
5
- import { database, deploy, diagnose, domains, env, list, login, logout, logs, plans, 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
+
9
+ const PROMPT = "Paste your Astris API key: ";
10
+
11
+ // Hides what is typed: the interface writes only to a muted stream, the prompt
12
+ // and its closing newline go to stderr. Resolves the typed line, resolves ""
13
+ // when stdin ends (Ctrl+D), and rejects on Ctrl+C.
14
+ function readSecret() {
15
+ return new Promise((resolve, reject) => {
16
+ process.stderr.write(PROMPT);
17
+ const muted = new Writable({
18
+ write(_chunk, _encoding, done) {
19
+ done();
20
+ },
21
+ });
22
+ const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
23
+ let settled = false;
24
+ const finish = (settle, value) => {
25
+ if (settled) return;
26
+ settled = true;
27
+ rl.removeListener("line", onLine);
28
+ rl.removeListener("SIGINT", onSigint);
29
+ rl.removeListener("close", onClose);
30
+ rl.close();
31
+ process.stderr.write("\n");
32
+ settle(value);
33
+ };
34
+ const onLine = (line) => finish(resolve, line);
35
+ const onSigint = () => finish(reject, new Error("interrupted"));
36
+ const onClose = () => finish(resolve, "");
37
+ rl.on("line", onLine);
38
+ rl.on("SIGINT", onSigint);
39
+ rl.on("close", onClose);
40
+ });
41
+ }
42
+
43
+ // One table: the usage text and the dispatch both read it. A Map, so a word
44
+ // such as "constructor" or "__proto__" is not found on Object.prototype.
45
+ const handlers = new Map([
46
+ ["deploy", deploy],
47
+ ["status", status],
48
+ ["diagnose", diagnose],
49
+ ["database", database],
50
+ ["storage", storage],
51
+ ["compose", compose],
52
+ ["env", env],
53
+ ["domains", domains],
54
+ ["restart", restart],
55
+ ["logs", logs],
56
+ ["list", list],
57
+ ["usage", usage],
58
+ ["plans", plans],
59
+ ["whoami", whoami],
60
+ ["next", next],
61
+ ["rollback", rollback],
62
+ ["login", login],
63
+ ["logout", logout],
64
+ ["app", app],
65
+ ["preflight", preflight],
66
+ ["export", accountExport],
67
+ ]);
68
+
69
+ export const COMMANDS = [...handlers.keys()];
6
70
 
7
71
  export async function main(argv, io) {
8
72
  const command = argv?.[2];
9
73
  const args = Array.isArray(argv) ? argv.slice(3) : [];
10
- if (command === "deploy") return (await deploy(args, io)).exitCode;
11
- if (command === "status") return (await status(args, io)).exitCode;
12
- if (command === "diagnose") return (await diagnose(args, io)).exitCode;
13
- if (command === "database") return (await database(args, io)).exitCode;
14
- if (command === "storage") return (await storage(args, io)).exitCode;
15
- if (command === "env") return (await env(args, io)).exitCode;
16
- if (command === "domains") return (await domains(args, io)).exitCode;
17
- if (command === "restart") return (await restart(args, io)).exitCode;
18
- if (command === "logs") return (await logs(args, io)).exitCode;
19
- if (command === "list") return (await list(args, io)).exitCode;
20
- if (command === "usage") return (await usage(args, io)).exitCode;
21
- if (command === "plans") return (await plans(args, io)).exitCode;
22
- if (command === "whoami") return (await whoami(args, io)).exitCode;
23
- if (command === "rollback") return rollback(args, io).exitCode;
24
- if (command === "login") return (await login(args, io)).exitCode;
25
- if (command === "logout") return (await logout(args, io)).exitCode;
74
+ if (command === "--help" || command === "-h" || command === "help") {
75
+ io.stdout.write(`Usage: astris <command> [flags]\n\nCommands:\n ${COMMANDS.join("\n ")}\n`);
76
+ return 0;
77
+ }
78
+ const handler = handlers.get(command);
79
+ if (handler) return (await handler(args, io)).exitCode;
26
80
  io.stderr.write("Unknown command.\n");
27
81
  return 1;
28
82
  }
@@ -37,14 +91,17 @@ async function readStdin() {
37
91
  }
38
92
 
39
93
  if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
40
- const stdin = (process.argv[2] === "env" || process.argv[2] === "login") && !process.stdin.isTTY ? await readStdin() : "";
41
- const code = await main(process.argv, {
94
+ const isEnvWithoutNames = process.argv[2] === "env" && !process.argv.slice(3).includes("--names");
95
+ const stdin = (isEnvWithoutNames || process.argv[2] === "login") && !process.stdin.isTTY ? await readStdin() : "";
96
+ const io = {
42
97
  env: process.env,
43
98
  fetchImpl: globalThis.fetch,
44
99
  baseUrl: DEFAULT_BASE_URL,
45
100
  stdin,
46
101
  stdout: process.stdout,
47
102
  stderr: process.stderr,
48
- });
103
+ };
104
+ if (process.argv[2] === "login" && process.stdin.isTTY) io.readSecret = readSecret;
105
+ const code = await main(process.argv, io);
49
106
  process.exit(code);
50
107
  }