@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 +21 -6
- package/package.json +1 -1
- package/src/commands.mjs +318 -5
- package/src/main.mjs +75 -20
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
|
|
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 `
|
|
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
|
|
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`
|
|
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 `
|
|
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
|
|
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
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
|
|
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
|
|
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
|
-
|
|
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 === "
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (
|
|
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
|
|
41
|
-
const
|
|
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
|
}
|