@astris-labs/cli 0.1.1 → 0.1.2

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`, `list`, `logs`, `usage`, `plans`, `whoami`, `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
 
@@ -127,16 +127,31 @@ When storage is not configured, all four commands print `Storage is not availabl
127
127
 
128
128
  ### login
129
129
 
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.`
130
+ `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
131
 
132
132
  ### logout
133
133
 
134
134
  `astris logout` deletes the saved key file and writes `Logged out.` When there is no file, it writes `There was nothing to remove.`
135
135
 
136
+ ### app
137
+
138
+ `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}`.
139
+
140
+ ### preflight
141
+
142
+ `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.
143
+
144
+ ### export
145
+
146
+ `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.
147
+
148
+
136
149
  ## Exit codes
137
150
 
138
151
  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
152
 
153
+ `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.
154
+
140
155
  ## Not in this version
141
156
 
142
157
  `rollback` is not in v0. The route it needs waits for T-042 item 3.
@@ -145,9 +160,9 @@ Exit code 0 means the command succeeded. Any error exits with a non-zero code. W
145
160
 
146
161
  Some arguments are refused on this machine, before any request.
147
162
 
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.
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.
149
164
 
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.
165
+ 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
166
 
152
167
  `login` and `logout` take no arguments. An argument is refused, and `logout` does not delete the file.
153
168
 
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.2",
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";
@@ -41,6 +43,13 @@ const SENTENCE = {
41
43
  loggedIn: "Logged in.",
42
44
  loggedOut: "Logged out.",
43
45
  nothingToRemove: "There was nothing to remove.",
46
+ appRemoved: "App removed.",
47
+ confirmRequired: "Send --confirm set to the application name.",
48
+ nameMismatch: "The name does not match the application.",
49
+ unreadableDir: "That directory cannot be read.",
50
+ exportEmail: "Send --email set to the account email address.",
51
+ exportAccepted:
52
+ "Accepted. If that address is on an Astris account, check its inbox for the download link. The link works for one hour.",
44
53
  };
45
54
 
46
55
  const HTTP_SENTENCE = new Map([
@@ -55,6 +64,11 @@ function sentenceFor(status) {
55
64
  return HTTP_SENTENCE.get(status) ?? SENTENCE.failed;
56
65
  }
57
66
 
67
+ function errorMessage(status, body) {
68
+ if (isRecord(body) && typeof body.message === "string") return body.message;
69
+ return sentenceFor(status);
70
+ }
71
+
58
72
  function upgradeMessage(status, body) {
59
73
  if (status !== 403 || !isRecord(body) || body.error !== "upgrade_required" || typeof body.message !== "string") {
60
74
  return null;
@@ -94,12 +108,12 @@ function withCreated(sentence, created) {
94
108
  return text;
95
109
  }
96
110
 
97
- function fail(io, key, json, sentence, created) {
111
+ function fail(io, key, json, sentence, created, exitCode = 1) {
98
112
  const text = withCreated(sentence, created);
99
113
  const safe = redact(text, key);
100
114
  if (json) writeStdout(io, key, `${JSON.stringify({ error: safe })}\n`);
101
115
  else writeStderr(io, key, `${safe}\n`);
102
- return { exitCode: 1 };
116
+ return { exitCode };
103
117
  }
104
118
 
105
119
  function succeed(io, key, json, payload, human) {
@@ -108,13 +122,13 @@ function succeed(io, key, json, payload, human) {
108
122
  return { exitCode: 0 };
109
123
  }
110
124
 
111
- function loadKey(io) {
125
+ function loadKey(io, exitCode = 1) {
112
126
  try {
113
127
  return { key: readApiKey(io.env) };
114
128
  } catch (error) {
115
129
  if (error instanceof NoApiKeyError) {
116
130
  io.stderr.write(`${SENTENCE.missingKey}\n`);
117
- return { exit: { exitCode: 1 } };
131
+ return { exit: { exitCode } };
118
132
  }
119
133
  throw error;
120
134
  }
@@ -1497,7 +1511,15 @@ function stdinKey(io) {
1497
1511
 
1498
1512
  export async function login(args, io) {
1499
1513
  if (Array.isArray(args) && args.length > 0) return fail(io, "", false, SENTENCE.refused);
1500
- const key = stdinKey(io);
1514
+ let key = stdinKey(io);
1515
+ if (key === "" && typeof io.readSecret === "function") {
1516
+ try {
1517
+ key = (await io.readSecret()).trim();
1518
+ } catch {
1519
+ // Ctrl+C at the prompt: leave with no output and nothing saved.
1520
+ return { exitCode: 1 };
1521
+ }
1522
+ }
1501
1523
  if (key === "") return fail(io, "", false, SENTENCE.emptyKey);
1502
1524
  const result = await call(io, key, { path: "/v1/me", method: "GET" });
1503
1525
  if (result.network) return fail(io, key, false, SENTENCE.platform);
@@ -1513,3 +1535,294 @@ export async function logout(args, io) {
1513
1535
  io.stdout.write(`${removed ? SENTENCE.loggedOut : SENTENCE.nothingToRemove}\n`);
1514
1536
  return { exitCode: 0 };
1515
1537
  }
1538
+
1539
+ const APP_REMOVE_OPTIONS = {
1540
+ json: { type: "boolean" },
1541
+ app: { type: "string" },
1542
+ confirm: { type: "string" },
1543
+ };
1544
+
1545
+ function appPositionals(args) {
1546
+ try {
1547
+ return parseArgs({
1548
+ args: Array.isArray(args) ? args : [],
1549
+ strict: false,
1550
+ allowPositionals: true,
1551
+ options: APP_REMOVE_OPTIONS,
1552
+ }).positionals;
1553
+ } catch {
1554
+ return null;
1555
+ }
1556
+ }
1557
+
1558
+ export async function app(args, io) {
1559
+ const loaded = loadKey(io);
1560
+ if (loaded.exit) return loaded.exit;
1561
+ const words = appPositionals(args);
1562
+ if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
1563
+ if (words[0] === "remove") return appRemove(args, io, loaded.key);
1564
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
1565
+ }
1566
+
1567
+ async function appRemove(args, io, key) {
1568
+ const parsed = parseStrict(args, APP_REMOVE_OPTIONS, true);
1569
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "remove") {
1570
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1571
+ }
1572
+ const json = parsed.values.json === true;
1573
+ const appId = appUuid(parsed.values.app);
1574
+ if (appId === null) return fail(io, key, json, SENTENCE.app);
1575
+ const confirm = typeof parsed.values.confirm === "string" ? parsed.values.confirm : "";
1576
+ if (confirm === "") return fail(io, key, json, SENTENCE.confirmRequired);
1577
+
1578
+ const got = await call(io, key, { path: `/v1/apps/${encodeURIComponent(appId)}`, method: "GET" });
1579
+ if (got.network) return fail(io, key, json, SENTENCE.platform);
1580
+ if (got.status !== 200) return fail(io, key, json, sentenceFor(got.status));
1581
+ if (!isRecord(got.body)) return fail(io, key, json, SENTENCE.failed);
1582
+ const application = isRecord(got.body.application) ? got.body.application : null;
1583
+ if (application === null) return fail(io, key, json, SENTENCE.failed);
1584
+ const name = typeof application.name === "string" ? application.name : null;
1585
+ if (name === null) return fail(io, key, json, SENTENCE.failed);
1586
+
1587
+ if (name !== confirm) {
1588
+ io.stderr.write(`${SENTENCE.nameMismatch}\n`);
1589
+ return { exitCode: 1 };
1590
+ }
1591
+
1592
+ const deleted = await call(io, key, { path: `/v1/apps/${encodeURIComponent(appId)}`, method: "DELETE" });
1593
+ if (deleted.network) return fail(io, key, json, SENTENCE.platform);
1594
+ if (deleted.status !== 200) return fail(io, key, json, sentenceFor(deleted.status));
1595
+ return succeed(io, key, json, { app_id: appId, removed: true }, `${SENTENCE.appRemoved}\n`);
1596
+ }
1597
+
1598
+ export const PREFLIGHT_MAX_FILES = 64; // the server's, src/app/v1/preflight/route.ts:24
1599
+ export const PREFLIGHT_MAX_FILE_BYTES = 32 * 1024; // the server's, src/app/v1/preflight/route.ts:25
1600
+ export const PREFLIGHT_MAX_LOCKFILE_BYTES = 320 * 1024; // the server's, src/app/v1/preflight/route.ts:26
1601
+ export const PREFLIGHT_MAX_BODY_BYTES = 512 * 1024; // the server's, src/app/v1/preflight/route.ts:27
1602
+
1603
+ const ROOT_LOCKFILES = new Set([
1604
+ "package-lock.json",
1605
+ "pnpm-lock.yaml",
1606
+ "yarn.lock",
1607
+ "bun.lock",
1608
+ ]);
1609
+
1610
+ const SKIP_DIRS = new Set([
1611
+ "node_modules",
1612
+ "dist",
1613
+ "build",
1614
+ "out",
1615
+ "coverage",
1616
+ ]);
1617
+
1618
+ function validRelPath(p) {
1619
+ if (p.length === 0 || p.length > 256) return false;
1620
+ if (p.startsWith("/") || p.includes("\\") || /^[A-Za-z]:/.test(p)) return false;
1621
+ return p.split("/").every((segment) => segment.length > 0 && segment !== "..");
1622
+ }
1623
+
1624
+ export function preflightFiles(root) {
1625
+ let rootStat;
1626
+ try {
1627
+ rootStat = lstatSync(root);
1628
+ } catch {
1629
+ return null;
1630
+ }
1631
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return null;
1632
+
1633
+ const candidates = [];
1634
+ function walk(dir) {
1635
+ let entries;
1636
+ try {
1637
+ entries = readdirSync(dir);
1638
+ } catch {
1639
+ return false;
1640
+ }
1641
+ for (const name of entries) {
1642
+ if (name.startsWith(".")) continue;
1643
+ const fullPath = path.join(dir, name);
1644
+ let stat;
1645
+ try {
1646
+ stat = lstatSync(fullPath);
1647
+ } catch {
1648
+ continue;
1649
+ }
1650
+ if (stat.isSymbolicLink()) continue;
1651
+ if (stat.isDirectory()) {
1652
+ if (SKIP_DIRS.has(name)) continue;
1653
+ const ok = walk(fullPath);
1654
+ if (!ok) return false;
1655
+ } else if (stat.isFile()) {
1656
+ const rel = path.relative(root, fullPath).split(path.sep).join("/");
1657
+ const depth = rel.split("/").length - 1;
1658
+ candidates.push({ rel, fullPath, depth, stat });
1659
+ }
1660
+ }
1661
+ return true;
1662
+ }
1663
+
1664
+ if (!walk(root)) return null;
1665
+
1666
+ candidates.sort((a, b) => {
1667
+ if (a.depth !== b.depth) return a.depth - b.depth;
1668
+ return a.rel.localeCompare(b.rel);
1669
+ });
1670
+
1671
+ const files = [];
1672
+ let leftOut = 0;
1673
+
1674
+ for (const candidate of candidates) {
1675
+ if (!validRelPath(candidate.rel)) {
1676
+ leftOut++;
1677
+ continue;
1678
+ }
1679
+
1680
+ if (files.length >= PREFLIGHT_MAX_FILES) {
1681
+ leftOut++;
1682
+ continue;
1683
+ }
1684
+
1685
+ const isRootLock = candidate.depth === 0 && ROOT_LOCKFILES.has(candidate.rel);
1686
+ const maxBytes = isRootLock ? PREFLIGHT_MAX_LOCKFILE_BYTES : PREFLIGHT_MAX_FILE_BYTES;
1687
+ if (candidate.stat.size > maxBytes) {
1688
+ leftOut++;
1689
+ continue;
1690
+ }
1691
+
1692
+ let buf;
1693
+ try {
1694
+ buf = readFileSync(candidate.fullPath);
1695
+ } catch {
1696
+ leftOut++;
1697
+ continue;
1698
+ }
1699
+
1700
+ if (buf.includes(0x00)) {
1701
+ leftOut++;
1702
+ continue;
1703
+ }
1704
+
1705
+ const text = buf.toString("utf8");
1706
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
1707
+ leftOut++;
1708
+ continue;
1709
+ }
1710
+
1711
+ const candidateFile = { path: candidate.rel, text };
1712
+ const wouldBeBytes = Buffer.byteLength(
1713
+ JSON.stringify({ files: [...files, candidateFile] }),
1714
+ "utf8",
1715
+ );
1716
+ if (wouldBeBytes > PREFLIGHT_MAX_BODY_BYTES) {
1717
+ leftOut++;
1718
+ continue;
1719
+ }
1720
+
1721
+ files.push(candidateFile);
1722
+ }
1723
+
1724
+ return { files, leftOut };
1725
+ }
1726
+
1727
+ const PREFLIGHT_OPTIONS = {
1728
+ json: { type: "boolean" },
1729
+ };
1730
+
1731
+ export async function preflight(args, io) {
1732
+ const loaded = loadKey(io, 2);
1733
+ if (loaded.exit) return loaded.exit;
1734
+ const parsed = parseStrict(args, PREFLIGHT_OPTIONS, true);
1735
+ if (parsed === null || parsed.positionals.length > 1) {
1736
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused, undefined, 2);
1737
+ }
1738
+ const json = parsed.values.json === true;
1739
+ const dir = parsed.positionals.length === 1 ? parsed.positionals[0] : ".";
1740
+ const walked = preflightFiles(dir);
1741
+ if (walked === null) {
1742
+ return fail(io, loaded.key, json, SENTENCE.unreadableDir, undefined, 2);
1743
+ }
1744
+
1745
+ const result = await call(io, loaded.key, {
1746
+ path: "/v1/preflight",
1747
+ method: "POST",
1748
+ body: { files: walked.files },
1749
+ });
1750
+ if (result.network) return fail(io, loaded.key, json, SENTENCE.platform, undefined, 2);
1751
+ if (result.status < 200 || result.status >= 300) {
1752
+ return fail(io, loaded.key, json, errorMessage(result.status, result.body), undefined, 2);
1753
+ }
1754
+
1755
+ if (
1756
+ !isRecord(result.body) ||
1757
+ typeof result.body.verdict !== "string" ||
1758
+ !Array.isArray(result.body.checks) ||
1759
+ (result.body.verdict !== "pass" && result.body.verdict !== "fixable" && result.body.verdict !== "refuse")
1760
+ ) {
1761
+ return fail(io, loaded.key, json, SENTENCE.failed, undefined, 2);
1762
+ }
1763
+
1764
+ if (walked.leftOut > 0) {
1765
+ const leftOutMsg =
1766
+ walked.leftOut === 1
1767
+ ? "Left out 1 file. The checks did not read it.\n"
1768
+ : `Left out ${walked.leftOut} files. The checks did not read them.\n`;
1769
+ io.stderr.write(leftOutMsg);
1770
+ }
1771
+
1772
+ const exitCode = result.body.verdict === "refuse" ? 1 : 0;
1773
+ if (json) {
1774
+ writeStdout(io, loaded.key, `${JSON.stringify(result.body)}\n`);
1775
+ return { exitCode };
1776
+ }
1777
+
1778
+ let human = `Verdict: ${result.body.verdict}\n`;
1779
+ for (const check of result.body.checks) {
1780
+ if (
1781
+ isRecord(check) &&
1782
+ typeof check.name === "string" &&
1783
+ typeof check.result === "string" &&
1784
+ typeof check.reason === "string"
1785
+ ) {
1786
+ human += `${check.name}: ${check.result} - ${check.reason}\n`;
1787
+ }
1788
+ }
1789
+ writeStdout(io, loaded.key, human);
1790
+ return { exitCode };
1791
+ }
1792
+
1793
+ const EXPORT_OPTIONS = {
1794
+ json: { type: "boolean" },
1795
+ email: { type: "string" },
1796
+ };
1797
+
1798
+ export async function accountExport(args, io) {
1799
+ const loaded = loadKey(io, 2);
1800
+ if (loaded.exit) return loaded.exit;
1801
+ const parsed = parseStrict(args, EXPORT_OPTIONS, false);
1802
+ if (parsed === null) {
1803
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused, undefined, 2);
1804
+ }
1805
+ const json = parsed.values.json === true;
1806
+ const email = typeof parsed.values.email === "string" ? parsed.values.email.trim() : "";
1807
+ if (email === "") {
1808
+ return fail(io, loaded.key, json, SENTENCE.exportEmail, undefined, 2);
1809
+ }
1810
+
1811
+ const result = await call(io, loaded.key, {
1812
+ path: "/v1/account-export",
1813
+ method: "POST",
1814
+ body: { email },
1815
+ });
1816
+ if (result.network) return fail(io, loaded.key, json, SENTENCE.platform, undefined, 2);
1817
+ if (result.status < 200 || result.status >= 300) {
1818
+ return fail(io, loaded.key, json, errorMessage(result.status, result.body), undefined, 2);
1819
+ }
1820
+
1821
+ if (json) {
1822
+ writeStdout(io, loaded.key, `${JSON.stringify(result.body ?? { status: "accepted" })}\n`);
1823
+ return { exitCode: 0 };
1824
+ }
1825
+
1826
+ writeStdout(io, loaded.key, `${SENTENCE.exportAccepted}\n`);
1827
+ return { exitCode: 0 };
1828
+ }
package/src/main.mjs CHANGED
@@ -1,28 +1,80 @@
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, database, deploy, diagnose, domains, env, list, login, logout, logs, 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
+ ["env", env],
52
+ ["domains", domains],
53
+ ["restart", restart],
54
+ ["logs", logs],
55
+ ["list", list],
56
+ ["usage", usage],
57
+ ["plans", plans],
58
+ ["whoami", whoami],
59
+ ["rollback", rollback],
60
+ ["login", login],
61
+ ["logout", logout],
62
+ ["app", app],
63
+ ["preflight", preflight],
64
+ ["export", accountExport],
65
+ ]);
66
+
67
+ export const COMMANDS = [...handlers.keys()];
6
68
 
7
69
  export async function main(argv, io) {
8
70
  const command = argv?.[2];
9
71
  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;
72
+ if (command === "--help" || command === "-h" || command === "help") {
73
+ io.stdout.write(`Usage: astris <command> [flags]\n\nCommands:\n ${COMMANDS.join("\n ")}\n`);
74
+ return 0;
75
+ }
76
+ const handler = handlers.get(command);
77
+ if (handler) return (await handler(args, io)).exitCode;
26
78
  io.stderr.write("Unknown command.\n");
27
79
  return 1;
28
80
  }
@@ -37,14 +89,17 @@ async function readStdin() {
37
89
  }
38
90
 
39
91
  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, {
92
+ const isEnvWithoutNames = process.argv[2] === "env" && !process.argv.slice(3).includes("--names");
93
+ const stdin = (isEnvWithoutNames || process.argv[2] === "login") && !process.stdin.isTTY ? await readStdin() : "";
94
+ const io = {
42
95
  env: process.env,
43
96
  fetchImpl: globalThis.fetch,
44
97
  baseUrl: DEFAULT_BASE_URL,
45
98
  stdin,
46
99
  stdout: process.stdout,
47
100
  stderr: process.stderr,
48
- });
101
+ };
102
+ if (process.argv[2] === "login" && process.stdin.isTTY) io.readSecret = readSecret;
103
+ const code = await main(process.argv, io);
49
104
  process.exit(code);
50
105
  }