@astris-labs/cli 0.1.0 → 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 +30 -9
- 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
|
|
|
@@ -38,15 +38,21 @@ v0 commands are `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `da
|
|
|
38
38
|
|
|
39
39
|
`--app <id>` redeploys that application. The value is the application id.
|
|
40
40
|
|
|
41
|
-
To create a project and an application, then queue a deploy, pass these flags. The names are kebab-case.
|
|
41
|
+
To create a project and an application, then queue a deploy, pass these flags. A private repository over SSH is the exception below. The names are kebab-case.
|
|
42
42
|
|
|
43
43
|
- `--project`
|
|
44
44
|
- `--name`
|
|
45
45
|
- `--git-url`
|
|
46
46
|
- `--branch`
|
|
47
47
|
|
|
48
|
+
`--git-url` is a public http or https URL, or an SSH URL for a private repository in the form `ssh://git@host/owner/repo.git`. In an `ssh://` URL the user name `git` is the only user name allowed; an http or https URL takes none. Any other user name, or any password, is refused with `The Git URL must not include a user name or password.`
|
|
49
|
+
|
|
50
|
+
When the API answers the create with a deploy key (an SSH URL), the CLI makes the project and the application and queues no deploy. It prints the project id, the application id, the name, the slug, the deploy key type, the public key, the fingerprint, and then the next step: `Add this public key to the repository as a read-only deploy key, then deploy again with this app_id. On GitHub the key goes in the repository Settings under Deploy keys, and write access stays off.`
|
|
51
|
+
|
|
48
52
|
Optional flags are `--build-path`, `--dockerfile`, `--port`, `--build`, `--publish-directory`, `--spa`, `--kind`, `--command`, and `--schedule`.
|
|
49
53
|
|
|
54
|
+
`--app <id> --hook` prints the push-to-deploy hook URL of that application, one line. The URL is a credential: keep it private. `--hook` needs `--app`; without it the sentence is `Send --app set to the application id.` `--hook` follows the same `--app` rules as a redeploy, so the flags that rule refuses are refused with it.
|
|
55
|
+
|
|
50
56
|
`--build nixpacks` builds with Nixpacks. `--build dockerfile` builds from the Dockerfile named by `--dockerfile`; without `--build`, a `--dockerfile` value alone means a Dockerfile build. `--build static` serves static files; with it, `--publish-directory <dir>` names the directory to publish, relative to the build, and `--spa` serves it as a single-page application. `--spa` takes no value, and `--spa=false` is refused.
|
|
51
57
|
|
|
52
58
|
`--kind web` is the default kind. `--kind worker` runs `--command` as the container's process. `--kind cron` runs `--command` on `--schedule`, five space-separated fields. A worker or a cron app cannot use `--build static`. `--app` with any of `--build`, `--publish-directory`, `--spa`, `--kind`, `--command`, or `--schedule` is refused and makes no request; the create flags `--project`, `--name`, `--git-url`, `--branch`, `--port`, `--dockerfile`, and `--build-path` are still ignored with `--app`.
|
|
@@ -59,7 +65,7 @@ Optional flags are `--build-path`, `--dockerfile`, `--port`, `--build`, `--publi
|
|
|
59
65
|
|
|
60
66
|
`astris env --app <id>` reads one JSON object from stdin and replaces the whole variable set.
|
|
61
67
|
|
|
62
|
-
`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.
|
|
63
69
|
|
|
64
70
|
### domains
|
|
65
71
|
|
|
@@ -121,16 +127,31 @@ When storage is not configured, all four commands print `Storage is not availabl
|
|
|
121
127
|
|
|
122
128
|
### login
|
|
123
129
|
|
|
124
|
-
`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.`
|
|
125
131
|
|
|
126
132
|
### logout
|
|
127
133
|
|
|
128
134
|
`astris logout` deletes the saved key file and writes `Logged out.` When there is no file, it writes `There was nothing to remove.`
|
|
129
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
|
+
|
|
130
149
|
## Exit codes
|
|
131
150
|
|
|
132
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.
|
|
133
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
|
+
|
|
134
155
|
## Not in this version
|
|
135
156
|
|
|
136
157
|
`rollback` is not in v0. The route it needs waits for T-042 item 3.
|
|
@@ -139,9 +160,9 @@ Exit code 0 means the command succeeded. Any error exits with a non-zero code. W
|
|
|
139
160
|
|
|
140
161
|
Some arguments are refused on this machine, before any request.
|
|
141
162
|
|
|
142
|
-
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.
|
|
143
164
|
|
|
144
|
-
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.
|
|
145
166
|
|
|
146
167
|
`login` and `logout` take no arguments. An argument is refused, and `logout` does not delete the file.
|
|
147
168
|
|
|
@@ -153,9 +174,9 @@ After the key, `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `dat
|
|
|
153
174
|
|
|
154
175
|
`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.
|
|
155
176
|
|
|
156
|
-
`--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, and then refuses `--build`, `--publish-directory`, `--spa`, `--kind`, `--command`, and `--schedule`; the other create flags are ignored.
|
|
177
|
+
`--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.
|
|
157
178
|
|
|
158
|
-
`deploy` without `--app` refuses the call unless `--project`, `--name`, `--git-url`, and `--branch` each have a non-empty value after trim. The sentence names those four flags. On that same path, a `--git-url` whose
|
|
179
|
+
`deploy` without `--app` refuses the call unless `--project`, `--name`, `--git-url`, and `--branch` each have a non-empty value after trim. The sentence names those four flags. On that same path, a `--git-url` whose password is set, or whose user name is set and is not the `git` of an `ssh://` URL, is refused. The sentence is `The Git URL must not include a user name or password.` A `--port` on that path is refused unless it is a whole number from 1 to 65535 with no leading zero. Other Git URL rules are left to the API. On that path, `--build` is exactly `nixpacks`, `dockerfile`, or `static`. `nixpacks` refuses `--dockerfile`, `--publish-directory`, and `--spa`. `dockerfile` needs `--dockerfile`. `static` takes an optional `--publish-directory` and an optional `--spa`, and refuses `--dockerfile`. `--publish-directory` or `--spa` without `--build` is refused. `--kind` is exactly `web`, `worker`, or `cron`. A web app refuses `--command` and `--schedule`. A worker needs `--command` and refuses `--schedule`. A cron needs `--command` and a `--schedule` of five space-separated fields of digits, `*`, `,`, `-`, or `/`, at most 100 characters, each field non-empty. A command is 1 to 512 characters, with no line break and no NUL character. A worker or cron app with `--build static` is refused. Any refusal on this path makes no request.
|
|
159
180
|
|
|
160
181
|
`env` refuses stdin that is not one JSON object. An array, a bare value, and text that is not JSON are refused, before any request. `env --names` sends no replace: it prints the names of the application's variables, one per line, and never a value, and it reads no stdin.
|
|
161
182
|
|
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
|
}
|