@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 +42 -7
- package/package.json +1 -1
- package/src/commands.mjs +513 -5
- package/src/main.mjs +77 -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`, `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
|
|
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`
|
|
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 `
|
|
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
|
|
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 `
|
|
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
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
|
|
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
|
|
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
|
-
|
|
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 === "
|
|
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;
|
|
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
|
|
41
|
-
const
|
|
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
|
}
|