@astris-labs/cli 0.0.0-stage → 0.1.0
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 +165 -2
- package/package.json +24 -4
- package/src/client.mjs +71 -0
- package/src/commands.mjs +1515 -0
- package/src/key.mjs +126 -0
- package/src/main.mjs +50 -0
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,1515 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
|
|
3
|
+
import { astrisFetch, DEFAULT_BASE_URL, redact } from "./client.mjs";
|
|
4
|
+
import { NoApiKeyError, readApiKey, removeApiKey, saveApiKey } from "./key.mjs";
|
|
5
|
+
|
|
6
|
+
const UUID =
|
|
7
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
|
|
9
|
+
const SENTENCE = {
|
|
10
|
+
missingKey: "ASTRIS_API_KEY is not set",
|
|
11
|
+
incomplete:
|
|
12
|
+
"Those arguments were refused. Send --project, --name, --git-url, and --branch, or send --app to redeploy.",
|
|
13
|
+
app: "Send --app set to the application id.",
|
|
14
|
+
projectId: "Send --project set to the project id.",
|
|
15
|
+
databaseFlags: "Those arguments were refused. Send --project, --kind, and --name.",
|
|
16
|
+
databaseId: "Send --id set to the database id.",
|
|
17
|
+
domainId: "Send --id set to the domain id.",
|
|
18
|
+
git: "The Git URL must not include a user name or password.",
|
|
19
|
+
// T-169: reused word for word from the MCP's SENTENCE.deployKey (T-139).
|
|
20
|
+
deployKey:
|
|
21
|
+
"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.",
|
|
22
|
+
unauthorized:
|
|
23
|
+
"This key was refused. Put a current Astris API key in the environment and try again.",
|
|
24
|
+
notFound: "Nothing with that id is on this account. Use the id from an earlier command.",
|
|
25
|
+
refused: "Those arguments were refused. Check the flags and send them again.",
|
|
26
|
+
taken: "That name or hostname is already taken. Choose another and try again.",
|
|
27
|
+
noBackup: "This database has no backup job. Only Postgres databases are backed up.",
|
|
28
|
+
deleteConfirm: "This removes the database and its data. Add --yes to confirm.",
|
|
29
|
+
bucketConfirm: "This removes the bucket and its objects. Add --yes to confirm.",
|
|
30
|
+
bucketRemoved: "Bucket removed.",
|
|
31
|
+
bucketNotEmpty: "That bucket is not empty. Remove its objects, then try again.",
|
|
32
|
+
bucketLimit: "This account has reached its bucket safety limit. Contact support.",
|
|
33
|
+
storageOff: "Storage is not available right now. Try again later.",
|
|
34
|
+
platform: "The platform did not accept the change. Check status, then try again.",
|
|
35
|
+
failed: "Astris failed on its side. Try again.",
|
|
36
|
+
logsApp: "Send the application id.",
|
|
37
|
+
rollback: "rollback is not available in this version.",
|
|
38
|
+
replaced: "Replaced the whole set.",
|
|
39
|
+
restarted: "Restarted.",
|
|
40
|
+
emptyKey: "The key was empty.",
|
|
41
|
+
loggedIn: "Logged in.",
|
|
42
|
+
loggedOut: "Logged out.",
|
|
43
|
+
nothingToRemove: "There was nothing to remove.",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const HTTP_SENTENCE = new Map([
|
|
47
|
+
[400, SENTENCE.refused],
|
|
48
|
+
[401, SENTENCE.unauthorized],
|
|
49
|
+
[404, SENTENCE.notFound],
|
|
50
|
+
[409, SENTENCE.taken],
|
|
51
|
+
[502, SENTENCE.platform],
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
function sentenceFor(status) {
|
|
55
|
+
return HTTP_SENTENCE.get(status) ?? SENTENCE.failed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function upgradeMessage(status, body) {
|
|
59
|
+
if (status !== 403 || !isRecord(body) || body.error !== "upgrade_required" || typeof body.message !== "string") {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return body.message;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function storageCreateMessage(status, body) {
|
|
66
|
+
if (!isRecord(body)) return null;
|
|
67
|
+
if (status === 403 && body.error === "bucket_limit") {
|
|
68
|
+
if (typeof body.message === "string" && body.message.length > 0) {
|
|
69
|
+
return body.message;
|
|
70
|
+
}
|
|
71
|
+
return SENTENCE.bucketLimit;
|
|
72
|
+
}
|
|
73
|
+
if (status === 503 && body.error === "storage_unavailable") {
|
|
74
|
+
if (typeof body.message === "string" && body.message.length > 0) {
|
|
75
|
+
return body.message;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeStdout(io, key, text) {
|
|
83
|
+
io.stdout.write(redact(text, key));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeStderr(io, key, text) {
|
|
87
|
+
io.stderr.write(redact(text, key));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function withCreated(sentence, created) {
|
|
91
|
+
let text = sentence;
|
|
92
|
+
if (created?.projectId) text += ` Created project ${created.projectId}.`;
|
|
93
|
+
if (created?.applicationId) text += ` Created application ${created.applicationId}.`;
|
|
94
|
+
return text;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fail(io, key, json, sentence, created) {
|
|
98
|
+
const text = withCreated(sentence, created);
|
|
99
|
+
const safe = redact(text, key);
|
|
100
|
+
if (json) writeStdout(io, key, `${JSON.stringify({ error: safe })}\n`);
|
|
101
|
+
else writeStderr(io, key, `${safe}\n`);
|
|
102
|
+
return { exitCode: 1 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function succeed(io, key, json, payload, human) {
|
|
106
|
+
if (json) writeStdout(io, key, `${JSON.stringify(payload)}\n`);
|
|
107
|
+
else writeStdout(io, key, human);
|
|
108
|
+
return { exitCode: 0 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loadKey(io) {
|
|
112
|
+
try {
|
|
113
|
+
return { key: readApiKey(io.env) };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof NoApiKeyError) {
|
|
116
|
+
io.stderr.write(`${SENTENCE.missingKey}\n`);
|
|
117
|
+
return { exit: { exitCode: 1 } };
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function wantsJson(args) {
|
|
124
|
+
return Array.isArray(args) && args.includes("--json");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const BASE_OPTIONS = {
|
|
128
|
+
json: { type: "boolean" },
|
|
129
|
+
app: { type: "string" },
|
|
130
|
+
project: { type: "string" },
|
|
131
|
+
name: { type: "string" },
|
|
132
|
+
"git-url": { type: "string" },
|
|
133
|
+
branch: { type: "string" },
|
|
134
|
+
"build-path": { type: "string" },
|
|
135
|
+
dockerfile: { type: "string" },
|
|
136
|
+
port: { type: "string" },
|
|
137
|
+
slug: { type: "string" },
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// T-146: deploy's build and kind flags, env's --names. Given per command, so
|
|
141
|
+
// status, domains and restart keep refusing them. T-169: hook on deploy only.
|
|
142
|
+
const DEPLOY_OPTIONS = {
|
|
143
|
+
build: { type: "string" },
|
|
144
|
+
"publish-directory": { type: "string" },
|
|
145
|
+
spa: { type: "boolean" },
|
|
146
|
+
kind: { type: "string" },
|
|
147
|
+
command: { type: "string" },
|
|
148
|
+
schedule: { type: "string" },
|
|
149
|
+
hook: { type: "boolean" },
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const ENV_OPTIONS = {
|
|
153
|
+
names: { type: "boolean" },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function parseFlags(args, extra) {
|
|
157
|
+
let tokens;
|
|
158
|
+
try {
|
|
159
|
+
tokens = parseArgs({
|
|
160
|
+
args: Array.isArray(args) ? args : [],
|
|
161
|
+
strict: true,
|
|
162
|
+
allowPositionals: false,
|
|
163
|
+
tokens: true,
|
|
164
|
+
options: extra === undefined ? BASE_OPTIONS : { ...BASE_OPTIONS, ...extra },
|
|
165
|
+
}).tokens;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const flags = {};
|
|
170
|
+
for (const token of tokens) {
|
|
171
|
+
if (token.kind !== "option") continue;
|
|
172
|
+
if (token.name === "json") flags.json = true;
|
|
173
|
+
else if (token.name === "app") flags.app = token.value;
|
|
174
|
+
else if (token.name === "project") flags.project = token.value;
|
|
175
|
+
else if (token.name === "name") flags.name = token.value;
|
|
176
|
+
else if (token.name === "git-url") flags.gitUrl = token.value;
|
|
177
|
+
else if (token.name === "branch") flags.branch = token.value;
|
|
178
|
+
else if (token.name === "build-path") flags.buildPath = token.value;
|
|
179
|
+
else if (token.name === "dockerfile") flags.dockerfile = token.value;
|
|
180
|
+
else if (token.name === "port") flags.port = token.value;
|
|
181
|
+
else if (token.name === "slug") flags.slug = token.value;
|
|
182
|
+
else if (token.name === "build") flags.build = token.value;
|
|
183
|
+
else if (token.name === "publish-directory") flags.publishDirectory = token.value;
|
|
184
|
+
else if (token.name === "spa") flags.spa = true;
|
|
185
|
+
else if (token.name === "kind") flags.kind = token.value;
|
|
186
|
+
else if (token.name === "command") flags.command = token.value;
|
|
187
|
+
else if (token.name === "schedule") flags.schedule = token.value;
|
|
188
|
+
else if (token.name === "names") flags.names = true;
|
|
189
|
+
else if (token.name === "hook") flags.hook = true;
|
|
190
|
+
}
|
|
191
|
+
return flags;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function open(args, io, extra) {
|
|
195
|
+
const loaded = loadKey(io);
|
|
196
|
+
if (loaded.exit) return loaded;
|
|
197
|
+
const flags = parseFlags(args, extra);
|
|
198
|
+
if (flags === null) return { exit: fail(io, loaded.key, wantsJson(args), SENTENCE.refused) };
|
|
199
|
+
return { key: loaded.key, json: flags.json === true, flags };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function appUuid(value) {
|
|
203
|
+
if (typeof value !== "string") return null;
|
|
204
|
+
const id = value.trim();
|
|
205
|
+
return UUID.test(id) ? id : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function requireApp(io, key, json, flags) {
|
|
209
|
+
const appId = appUuid(flags.app);
|
|
210
|
+
if (appId === null) return { exit: fail(io, key, json, SENTENCE.app) };
|
|
211
|
+
return { appId };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function trimmed(value) {
|
|
215
|
+
return typeof value === "string" ? value.trim() : "";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// T-169: the fixed SSH user "git" of an ssh:// URL is not user info; every
|
|
219
|
+
// other user or password is refused before any request, as before.
|
|
220
|
+
function gitUrlHasUserInfo(value) {
|
|
221
|
+
let url;
|
|
222
|
+
try {
|
|
223
|
+
url = new URL(value);
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
if (url.password !== "") return true;
|
|
228
|
+
if (url.username === "") return false;
|
|
229
|
+
return !(url.protocol === "ssh:" && url.username === "git");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// T-169: a well-formed deploy key, or null when any field is missing. The
|
|
233
|
+
// MCP's deployKeyOf (src/lib/mcp/tools.ts) is the model; nothing imports it.
|
|
234
|
+
function deployKeyOf(value) {
|
|
235
|
+
if (!isRecord(value)) return null;
|
|
236
|
+
const { type, public_key, fingerprint } = value;
|
|
237
|
+
if (typeof type !== "string" || typeof public_key !== "string" || typeof fingerprint !== "string") {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
return { type, public_key, fingerprint };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function portNumber(raw) {
|
|
244
|
+
if (typeof raw !== "string") return null;
|
|
245
|
+
const text = raw.trim();
|
|
246
|
+
if (!/^[1-9][0-9]{0,4}$/.test(text)) return null;
|
|
247
|
+
const port = Number(text);
|
|
248
|
+
if (port < 1 || port > 65535) return null;
|
|
249
|
+
return port;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function baseUrlOf(io) {
|
|
253
|
+
return typeof io.baseUrl === "string" && io.baseUrl !== "" ? io.baseUrl : DEFAULT_BASE_URL;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function call(io, key, request) {
|
|
257
|
+
try {
|
|
258
|
+
return await astrisFetch({
|
|
259
|
+
key,
|
|
260
|
+
baseUrl: baseUrlOf(io),
|
|
261
|
+
path: request.path,
|
|
262
|
+
method: request.method,
|
|
263
|
+
body: request.body,
|
|
264
|
+
fetchImpl: io.fetchImpl,
|
|
265
|
+
});
|
|
266
|
+
} catch {
|
|
267
|
+
return { network: true };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function text(value) {
|
|
272
|
+
return typeof value === "string" ? value : "";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function hostnamesOf(body) {
|
|
276
|
+
const hostnames = body?.application?.hostnames;
|
|
277
|
+
if (!Array.isArray(hostnames)) return [];
|
|
278
|
+
return hostnames.filter((item) => typeof item === "string");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function deploymentRows(body) {
|
|
282
|
+
const rows = Array.isArray(body?.deployments) ? body.deployments.slice(0, 5) : [];
|
|
283
|
+
return rows.map((row) => ({
|
|
284
|
+
status: typeof row?.status === "string" ? row.status : null,
|
|
285
|
+
created_at: typeof row?.created_at === "string" ? row.created_at : null,
|
|
286
|
+
finished_at:
|
|
287
|
+
row?.finished_at === null || typeof row?.finished_at === "string" ? row.finished_at : null,
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function envObject(stdin) {
|
|
292
|
+
if (typeof stdin !== "string") return null;
|
|
293
|
+
let value;
|
|
294
|
+
try {
|
|
295
|
+
value = JSON.parse(stdin);
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
300
|
+
return value;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function findProjectId(body, name) {
|
|
304
|
+
const projects = body?.projects;
|
|
305
|
+
if (!Array.isArray(projects)) return null;
|
|
306
|
+
for (const project of projects) {
|
|
307
|
+
if (project && project.name === name && typeof project.id === "string" && project.id !== "") {
|
|
308
|
+
return project.id;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function redeploy(io, key, json, appId) {
|
|
315
|
+
const result = await call(io, key, {
|
|
316
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/deploy`,
|
|
317
|
+
method: "POST",
|
|
318
|
+
});
|
|
319
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
320
|
+
if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status));
|
|
321
|
+
const application = result.body?.application ?? {};
|
|
322
|
+
const id = text(application.id) || appId;
|
|
323
|
+
const name = text(application.name);
|
|
324
|
+
return succeed(
|
|
325
|
+
io,
|
|
326
|
+
key,
|
|
327
|
+
json,
|
|
328
|
+
{ application_id: id, name, accepted: true },
|
|
329
|
+
`${id}\n${name}\n`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// T-146: the create-only body keys from --build and --kind, or null when the
|
|
334
|
+
// combination is refused. Only the flags the user passed are returned, under
|
|
335
|
+
// the API's field names, so a call with no new flag adds no key. The rules
|
|
336
|
+
// mirror the API's readers (src/lib/apps/input.ts readBuildKind, readKind,
|
|
337
|
+
// readCommand, readSchedule); what the tables do not check stays with the API.
|
|
338
|
+
const BUILD_RULES = new Map([
|
|
339
|
+
["nixpacks", { dockerfile: "no", publishDirectory: "no", spa: "no" }],
|
|
340
|
+
["dockerfile", { dockerfile: "required", publishDirectory: "no", spa: "no" }],
|
|
341
|
+
["static", { dockerfile: "no", publishDirectory: "may", spa: "may" }],
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
const KIND_RULES = new Map([
|
|
345
|
+
["web", { command: "no", schedule: "no" }],
|
|
346
|
+
["worker", { command: "required", schedule: "no" }],
|
|
347
|
+
["cron", { command: "required", schedule: "required" }],
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
const COMMAND_MAX = 512;
|
|
351
|
+
const SCHEDULE_MAX = 100;
|
|
352
|
+
const SCHEDULE_FIELD = /^[0-9*,/-]+$/;
|
|
353
|
+
|
|
354
|
+
function validCommand(value) {
|
|
355
|
+
return (
|
|
356
|
+
typeof value === "string" &&
|
|
357
|
+
value.length >= 1 &&
|
|
358
|
+
value.length <= COMMAND_MAX &&
|
|
359
|
+
!value.includes("\n") &&
|
|
360
|
+
!value.includes("\u0000")
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validSchedule(value) {
|
|
365
|
+
if (typeof value !== "string" || value.length < 1 || value.length > SCHEDULE_MAX) return false;
|
|
366
|
+
const parts = value.split(" ");
|
|
367
|
+
return parts.length === 5 && parts.every((part) => part.length > 0 && SCHEDULE_FIELD.test(part));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function createFields(flags) {
|
|
371
|
+
const fields = {};
|
|
372
|
+
const build = flags.build;
|
|
373
|
+
if (build !== undefined) {
|
|
374
|
+
const rule = BUILD_RULES.get(build);
|
|
375
|
+
if (rule === undefined) return null;
|
|
376
|
+
if (rule.dockerfile === "no" && flags.dockerfile !== undefined) return null;
|
|
377
|
+
if (rule.dockerfile === "required" && flags.dockerfile === undefined) return null;
|
|
378
|
+
if (rule.publishDirectory === "no" && flags.publishDirectory !== undefined) return null;
|
|
379
|
+
if (rule.publishDirectory === "may" && flags.publishDirectory !== undefined) {
|
|
380
|
+
fields.publish_directory = flags.publishDirectory;
|
|
381
|
+
}
|
|
382
|
+
if (rule.spa === "no" && flags.spa !== undefined) return null;
|
|
383
|
+
if (rule.spa === "may" && flags.spa !== undefined) fields.spa = flags.spa === true;
|
|
384
|
+
fields.build = build;
|
|
385
|
+
} else if (flags.publishDirectory !== undefined || flags.spa !== undefined) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
const kind = flags.kind;
|
|
389
|
+
if (kind !== undefined) {
|
|
390
|
+
const rule = KIND_RULES.get(kind);
|
|
391
|
+
if (rule === undefined) return null;
|
|
392
|
+
if (rule.command === "no" && flags.command !== undefined) return null;
|
|
393
|
+
if (rule.command === "required") {
|
|
394
|
+
if (!validCommand(flags.command)) return null;
|
|
395
|
+
fields.command = flags.command;
|
|
396
|
+
}
|
|
397
|
+
if (rule.schedule === "no" && flags.schedule !== undefined) return null;
|
|
398
|
+
if (rule.schedule === "required") {
|
|
399
|
+
if (!validSchedule(flags.schedule)) return null;
|
|
400
|
+
fields.schedule = flags.schedule;
|
|
401
|
+
}
|
|
402
|
+
fields.kind = kind;
|
|
403
|
+
if (kind !== "web" && fields.build === "static") return null;
|
|
404
|
+
} else if (flags.command !== undefined || flags.schedule !== undefined) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
return fields;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function createThenDeploy(io, key, json, flags) {
|
|
411
|
+
const project = trimmed(flags.project);
|
|
412
|
+
const name = trimmed(flags.name);
|
|
413
|
+
const gitUrl = trimmed(flags.gitUrl);
|
|
414
|
+
const branch = trimmed(flags.branch);
|
|
415
|
+
if (project === "" || name === "" || gitUrl === "" || branch === "") {
|
|
416
|
+
return fail(io, key, json, SENTENCE.incomplete);
|
|
417
|
+
}
|
|
418
|
+
if (gitUrlHasUserInfo(gitUrl)) return fail(io, key, json, SENTENCE.git);
|
|
419
|
+
let port = 3000;
|
|
420
|
+
if (flags.port !== undefined) {
|
|
421
|
+
const parsed = portNumber(flags.port);
|
|
422
|
+
if (parsed === null) return fail(io, key, json, SENTENCE.refused);
|
|
423
|
+
port = parsed;
|
|
424
|
+
}
|
|
425
|
+
const extraFields = createFields(flags);
|
|
426
|
+
if (extraFields === null) return fail(io, key, json, SENTENCE.refused);
|
|
427
|
+
const buildPath = flags.buildPath !== undefined ? flags.buildPath : "/";
|
|
428
|
+
const created = { projectId: null, applicationId: null };
|
|
429
|
+
try {
|
|
430
|
+
const listed = await call(io, key, { path: "/v1/projects", method: "GET" });
|
|
431
|
+
if (listed.network) return fail(io, key, json, SENTENCE.platform, created);
|
|
432
|
+
if (listed.status !== 200) return fail(io, key, json, sentenceFor(listed.status), created);
|
|
433
|
+
let projectId = findProjectId(listed.body, project);
|
|
434
|
+
if (projectId === null) {
|
|
435
|
+
const made = await call(io, key, {
|
|
436
|
+
path: "/v1/projects",
|
|
437
|
+
method: "POST",
|
|
438
|
+
body: { name: project },
|
|
439
|
+
});
|
|
440
|
+
if (made.network) return fail(io, key, json, SENTENCE.platform, created);
|
|
441
|
+
const madeId = made.body?.project?.id;
|
|
442
|
+
if (made.status !== 201 || typeof madeId !== "string" || madeId === "") {
|
|
443
|
+
return fail(io, key, json, made.status === 201 ? SENTENCE.failed : sentenceFor(made.status), created);
|
|
444
|
+
}
|
|
445
|
+
projectId = madeId;
|
|
446
|
+
created.projectId = madeId;
|
|
447
|
+
}
|
|
448
|
+
const appBody = {
|
|
449
|
+
name,
|
|
450
|
+
git_url: gitUrl,
|
|
451
|
+
branch,
|
|
452
|
+
build_path: buildPath,
|
|
453
|
+
port,
|
|
454
|
+
...extraFields,
|
|
455
|
+
};
|
|
456
|
+
if (flags.dockerfile !== undefined) appBody.dockerfile = flags.dockerfile;
|
|
457
|
+
const madeApp = await call(io, key, {
|
|
458
|
+
path: `/v1/projects/${encodeURIComponent(projectId)}/apps`,
|
|
459
|
+
method: "POST",
|
|
460
|
+
body: appBody,
|
|
461
|
+
});
|
|
462
|
+
if (madeApp.network) return fail(io, key, json, SENTENCE.platform, created);
|
|
463
|
+
const application = madeApp.body?.application;
|
|
464
|
+
const applicationId = application?.id;
|
|
465
|
+
if (madeApp.status !== 201 || typeof applicationId !== "string" || applicationId === "") {
|
|
466
|
+
const passed = upgradeMessage(madeApp.status, madeApp.body);
|
|
467
|
+
return fail(
|
|
468
|
+
io,
|
|
469
|
+
key,
|
|
470
|
+
json,
|
|
471
|
+
passed ?? (madeApp.status === 201 ? SENTENCE.failed : sentenceFor(madeApp.status)),
|
|
472
|
+
created,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
created.applicationId = applicationId;
|
|
476
|
+
// T-169: the create answer's deploy key decides, not the URL. A
|
|
477
|
+
// well-formed key prints the key and queues no deploy; a present but
|
|
478
|
+
// malformed key is a platform fault; an absent key is today's https flow.
|
|
479
|
+
const rawKey = application.deploy_key;
|
|
480
|
+
if (rawKey !== undefined && rawKey !== null) {
|
|
481
|
+
const deployKey = deployKeyOf(rawKey);
|
|
482
|
+
if (deployKey === null) return fail(io, key, json, SENTENCE.platform, created);
|
|
483
|
+
const keyName = text(application.name) || name;
|
|
484
|
+
const keySlug = text(application.slug);
|
|
485
|
+
return succeed(
|
|
486
|
+
io,
|
|
487
|
+
key,
|
|
488
|
+
json,
|
|
489
|
+
{
|
|
490
|
+
project_id: projectId,
|
|
491
|
+
application_id: applicationId,
|
|
492
|
+
name: keyName,
|
|
493
|
+
slug: keySlug,
|
|
494
|
+
deploy_key: deployKey,
|
|
495
|
+
next_step: SENTENCE.deployKey,
|
|
496
|
+
},
|
|
497
|
+
`${projectId}\n${applicationId}\n${keyName}\n${keySlug}\n${deployKey.type}\n${deployKey.public_key}\n${deployKey.fingerprint}\n${SENTENCE.deployKey}\n`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const deployed = await call(io, key, {
|
|
501
|
+
path: `/v1/apps/${encodeURIComponent(applicationId)}/deploy`,
|
|
502
|
+
method: "POST",
|
|
503
|
+
});
|
|
504
|
+
if (deployed.network) return fail(io, key, json, SENTENCE.platform, created);
|
|
505
|
+
if (deployed.status !== 202) return fail(io, key, json, sentenceFor(deployed.status), created);
|
|
506
|
+
const shown = deployed.body?.application ?? application;
|
|
507
|
+
const id = text(shown.id) || applicationId;
|
|
508
|
+
const shownName = text(shown.name) || name;
|
|
509
|
+
const slug = text(shown.slug);
|
|
510
|
+
return succeed(
|
|
511
|
+
io,
|
|
512
|
+
key,
|
|
513
|
+
json,
|
|
514
|
+
{
|
|
515
|
+
project_id: projectId,
|
|
516
|
+
application_id: id,
|
|
517
|
+
name: shownName,
|
|
518
|
+
slug,
|
|
519
|
+
accepted: true,
|
|
520
|
+
},
|
|
521
|
+
`${projectId}\n${id}\n${shownName}\n${slug}\n`,
|
|
522
|
+
);
|
|
523
|
+
} catch {
|
|
524
|
+
return fail(io, key, json, SENTENCE.platform, created);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export async function deploy(args, io) {
|
|
529
|
+
const opened = open(args, io, DEPLOY_OPTIONS);
|
|
530
|
+
if (opened.exit) return opened.exit;
|
|
531
|
+
// T-169: the hook belongs to an app id, with or without create flags.
|
|
532
|
+
if (opened.flags.hook === true && opened.flags.app === undefined) {
|
|
533
|
+
return fail(io, opened.key, opened.json, SENTENCE.app);
|
|
534
|
+
}
|
|
535
|
+
if (opened.flags.app !== undefined) {
|
|
536
|
+
const appId = appUuid(opened.flags.app);
|
|
537
|
+
if (appId === null) return fail(io, opened.key, opened.json, SENTENCE.app);
|
|
538
|
+
if (
|
|
539
|
+
opened.flags.build !== undefined ||
|
|
540
|
+
opened.flags.publishDirectory !== undefined ||
|
|
541
|
+
opened.flags.spa !== undefined ||
|
|
542
|
+
opened.flags.kind !== undefined ||
|
|
543
|
+
opened.flags.command !== undefined ||
|
|
544
|
+
opened.flags.schedule !== undefined
|
|
545
|
+
) {
|
|
546
|
+
return fail(io, opened.key, opened.json, SENTENCE.refused);
|
|
547
|
+
}
|
|
548
|
+
if (opened.flags.hook === true) return hookUrl(io, opened.key, opened.json, appId);
|
|
549
|
+
return redeploy(io, opened.key, opened.json, appId);
|
|
550
|
+
}
|
|
551
|
+
return createThenDeploy(io, opened.key, opened.json, opened.flags);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export async function status(args, io) {
|
|
555
|
+
const opened = open(args, io);
|
|
556
|
+
if (opened.exit) return opened.exit;
|
|
557
|
+
const required = requireApp(io, opened.key, opened.json, opened.flags);
|
|
558
|
+
if (required.exit) return required.exit;
|
|
559
|
+
const result = await call(io, opened.key, {
|
|
560
|
+
path: `/v1/apps/${encodeURIComponent(required.appId)}`,
|
|
561
|
+
method: "GET",
|
|
562
|
+
});
|
|
563
|
+
if (result.network) return fail(io, opened.key, opened.json, SENTENCE.platform);
|
|
564
|
+
if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status));
|
|
565
|
+
const statusText = text(result.body?.status);
|
|
566
|
+
const hostnames = hostnamesOf(result.body);
|
|
567
|
+
const id = text(result.body?.application?.id) || required.appId;
|
|
568
|
+
return succeed(
|
|
569
|
+
io,
|
|
570
|
+
opened.key,
|
|
571
|
+
opened.json,
|
|
572
|
+
{
|
|
573
|
+
application_id: id,
|
|
574
|
+
status: statusText,
|
|
575
|
+
hostnames,
|
|
576
|
+
deployments: deploymentRows(result.body),
|
|
577
|
+
},
|
|
578
|
+
`${statusText}\n${hostnames.join("\n")}${hostnames.length > 0 ? "\n" : ""}`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// T-169: GET /v1/apps/:id/hook answers {hook: {url}}. The URL is a
|
|
583
|
+
// credential: it prints only from deploy --hook, and no error path carries it.
|
|
584
|
+
async function hookUrl(io, key, json, appId) {
|
|
585
|
+
const result = await call(io, key, {
|
|
586
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/hook`,
|
|
587
|
+
method: "GET",
|
|
588
|
+
});
|
|
589
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
590
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
591
|
+
const url = result.body?.hook?.url;
|
|
592
|
+
if (typeof url !== "string") return fail(io, key, json, SENTENCE.failed);
|
|
593
|
+
return succeed(io, key, json, { application_id: appId, url }, `${url}\n`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// T-146: GET /v1/apps/:id/env answers {names: [...]} and no value. Nothing
|
|
597
|
+
// else of the body is read, so a value cannot reach the output.
|
|
598
|
+
async function envNames(io, key, json, appId) {
|
|
599
|
+
const result = await call(io, key, {
|
|
600
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/env`,
|
|
601
|
+
method: "GET",
|
|
602
|
+
});
|
|
603
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
604
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
605
|
+
const names = result.body?.names;
|
|
606
|
+
if (!Array.isArray(names) || !names.every((name) => typeof name === "string")) {
|
|
607
|
+
return fail(io, key, json, SENTENCE.failed);
|
|
608
|
+
}
|
|
609
|
+
return succeed(
|
|
610
|
+
io,
|
|
611
|
+
key,
|
|
612
|
+
json,
|
|
613
|
+
{ application_id: appId, names },
|
|
614
|
+
names.length === 0 ? "" : `${names.join("\n")}\n`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export async function env(args, io) {
|
|
619
|
+
const opened = open(args, io, ENV_OPTIONS);
|
|
620
|
+
if (opened.exit) return opened.exit;
|
|
621
|
+
const required = requireApp(io, opened.key, opened.json, opened.flags);
|
|
622
|
+
if (required.exit) return required.exit;
|
|
623
|
+
if (opened.flags.names === true) return envNames(io, opened.key, opened.json, required.appId);
|
|
624
|
+
const body = envObject(io.stdin);
|
|
625
|
+
if (body === null) return fail(io, opened.key, opened.json, SENTENCE.refused);
|
|
626
|
+
const result = await call(io, opened.key, {
|
|
627
|
+
path: `/v1/apps/${encodeURIComponent(required.appId)}/env`,
|
|
628
|
+
method: "PUT",
|
|
629
|
+
body,
|
|
630
|
+
});
|
|
631
|
+
if (result.network) return fail(io, opened.key, opened.json, SENTENCE.platform);
|
|
632
|
+
if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status));
|
|
633
|
+
return succeed(
|
|
634
|
+
io,
|
|
635
|
+
opened.key,
|
|
636
|
+
opened.json,
|
|
637
|
+
{ application_id: required.appId, replaced: true },
|
|
638
|
+
`${SENTENCE.replaced}\n`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const DOMAINS_BIND_OPTIONS = {
|
|
643
|
+
json: { type: "boolean" },
|
|
644
|
+
app: { type: "string" },
|
|
645
|
+
slug: { type: "string" },
|
|
646
|
+
hostname: { type: "string" },
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const DOMAINS_VERIFY_OPTIONS = {
|
|
650
|
+
json: { type: "boolean" },
|
|
651
|
+
app: { type: "string" },
|
|
652
|
+
id: { type: "string" },
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const DOMAINS_UNION = {
|
|
656
|
+
json: { type: "boolean" },
|
|
657
|
+
app: { type: "string" },
|
|
658
|
+
slug: { type: "string" },
|
|
659
|
+
hostname: { type: "string" },
|
|
660
|
+
id: { type: "string" },
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
function domainsPositionals(args) {
|
|
664
|
+
try {
|
|
665
|
+
return parseArgs({
|
|
666
|
+
args: Array.isArray(args) ? args : [],
|
|
667
|
+
strict: false,
|
|
668
|
+
allowPositionals: true,
|
|
669
|
+
options: DOMAINS_UNION,
|
|
670
|
+
}).positionals;
|
|
671
|
+
} catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function customDomainView(domain) {
|
|
677
|
+
if (!isRecord(domain)) return null;
|
|
678
|
+
const verification = isRecord(domain.verification) ? domain.verification : null;
|
|
679
|
+
const cname = isRecord(verification?.cname) ? verification.cname : null;
|
|
680
|
+
const txt = isRecord(verification?.txt) ? verification.txt : null;
|
|
681
|
+
if (
|
|
682
|
+
cname === null ||
|
|
683
|
+
txt === null ||
|
|
684
|
+
typeof cname.name !== "string" ||
|
|
685
|
+
typeof cname.target !== "string" ||
|
|
686
|
+
typeof txt.name !== "string" ||
|
|
687
|
+
typeof txt.value !== "string" ||
|
|
688
|
+
typeof domain.hostname !== "string" ||
|
|
689
|
+
typeof domain.kind !== "string" ||
|
|
690
|
+
typeof domain.status !== "string"
|
|
691
|
+
) {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
hostname: domain.hostname,
|
|
696
|
+
kind: domain.kind,
|
|
697
|
+
status: domain.status,
|
|
698
|
+
verification: {
|
|
699
|
+
cname: { name: cname.name, target: cname.target },
|
|
700
|
+
txt: { name: txt.name, value: txt.value },
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function domainsBind(args, io, key) {
|
|
706
|
+
const parsed = parseStrict(args, DOMAINS_BIND_OPTIONS, false);
|
|
707
|
+
if (parsed === null) return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
708
|
+
const json = parsed.values.json === true;
|
|
709
|
+
const appId = appUuid(parsed.values.app);
|
|
710
|
+
if (appId === null) return fail(io, key, json, SENTENCE.app);
|
|
711
|
+
const hostnameFlag = parsed.values.hostname;
|
|
712
|
+
if (hostnameFlag !== undefined && parsed.values.slug !== undefined) {
|
|
713
|
+
return fail(io, key, json, SENTENCE.refused);
|
|
714
|
+
}
|
|
715
|
+
const request = {
|
|
716
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/domains`,
|
|
717
|
+
method: "POST",
|
|
718
|
+
};
|
|
719
|
+
if (hostnameFlag !== undefined) {
|
|
720
|
+
const host = trimmed(hostnameFlag);
|
|
721
|
+
if (host === "" || /\s/.test(host)) return fail(io, key, json, SENTENCE.refused);
|
|
722
|
+
request.body = { kind: "custom", hostname: host };
|
|
723
|
+
} else if (parsed.values.slug !== undefined) {
|
|
724
|
+
request.body = { slug: parsed.values.slug };
|
|
725
|
+
}
|
|
726
|
+
const result = await call(io, key, request);
|
|
727
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
728
|
+
if (result.status !== 201) return fail(io, key, json, sentenceFor(result.status));
|
|
729
|
+
const domain = isRecord(result.body) ? result.body.domain : undefined;
|
|
730
|
+
const hostname = text(domain?.hostname);
|
|
731
|
+
if (hostnameFlag === undefined) {
|
|
732
|
+
return succeed(io, key, json, { application_id: appId, hostname }, `${hostname}\n`);
|
|
733
|
+
}
|
|
734
|
+
const view = customDomainView(domain);
|
|
735
|
+
if (view === null) return fail(io, key, json, SENTENCE.failed);
|
|
736
|
+
return succeed(
|
|
737
|
+
io,
|
|
738
|
+
key,
|
|
739
|
+
json,
|
|
740
|
+
{
|
|
741
|
+
application_id: appId,
|
|
742
|
+
hostname: view.hostname,
|
|
743
|
+
kind: view.kind,
|
|
744
|
+
status: view.status,
|
|
745
|
+
verification: view.verification,
|
|
746
|
+
},
|
|
747
|
+
`${view.hostname}\n${view.status}\n${view.verification.cname.name}\n${view.verification.cname.target}\n${view.verification.txt.name}\n${view.verification.txt.value}\n`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function domainsVerify(args, io, key) {
|
|
752
|
+
const parsed = parseStrict(args, DOMAINS_VERIFY_OPTIONS, true);
|
|
753
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "verify") {
|
|
754
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
755
|
+
}
|
|
756
|
+
const json = parsed.values.json === true;
|
|
757
|
+
const appId = appUuid(parsed.values.app);
|
|
758
|
+
if (appId === null) return fail(io, key, json, SENTENCE.app);
|
|
759
|
+
const domainId = appUuid(parsed.values.id);
|
|
760
|
+
if (domainId === null) return fail(io, key, json, SENTENCE.domainId);
|
|
761
|
+
const result = await call(io, key, {
|
|
762
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/domains/${encodeURIComponent(domainId)}/verify`,
|
|
763
|
+
method: "POST",
|
|
764
|
+
});
|
|
765
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
766
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
767
|
+
const body = isRecord(result.body) ? result.body : {};
|
|
768
|
+
const domain = isRecord(body.domain) ? body.domain : {};
|
|
769
|
+
const statusText = typeof domain.status === "string" ? domain.status : "";
|
|
770
|
+
const payload = { application_id: appId, domain_id: domainId, status: statusText };
|
|
771
|
+
let human = `${statusText}\n`;
|
|
772
|
+
if (typeof body.reason === "string" && body.reason !== "") {
|
|
773
|
+
payload.reason = body.reason;
|
|
774
|
+
human = `${statusText}\n${body.reason}\n`;
|
|
775
|
+
}
|
|
776
|
+
return succeed(io, key, json, payload, human);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export async function domains(args, io) {
|
|
780
|
+
const loaded = loadKey(io);
|
|
781
|
+
if (loaded.exit) return loaded.exit;
|
|
782
|
+
const words = domainsPositionals(args);
|
|
783
|
+
if (words === null) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
784
|
+
if (words.length === 1 && words[0] === "verify") return domainsVerify(args, io, loaded.key);
|
|
785
|
+
if (words.length !== 0) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
786
|
+
return domainsBind(args, io, loaded.key);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export async function restart(args, io) {
|
|
790
|
+
const opened = open(args, io);
|
|
791
|
+
if (opened.exit) return opened.exit;
|
|
792
|
+
const required = requireApp(io, opened.key, opened.json, opened.flags);
|
|
793
|
+
if (required.exit) return required.exit;
|
|
794
|
+
const result = await call(io, opened.key, {
|
|
795
|
+
path: `/v1/apps/${encodeURIComponent(required.appId)}/restart`,
|
|
796
|
+
method: "POST",
|
|
797
|
+
});
|
|
798
|
+
if (result.network) return fail(io, opened.key, opened.json, SENTENCE.platform);
|
|
799
|
+
if (result.status !== 200) return fail(io, opened.key, opened.json, sentenceFor(result.status));
|
|
800
|
+
return succeed(
|
|
801
|
+
io,
|
|
802
|
+
opened.key,
|
|
803
|
+
opened.json,
|
|
804
|
+
{ application_id: required.appId, restarted: true },
|
|
805
|
+
`${SENTENCE.restarted}\n`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const LOGS_OPTIONS = {
|
|
810
|
+
json: { type: "boolean" },
|
|
811
|
+
source: { type: "string" },
|
|
812
|
+
tail: { type: "string" },
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const LIST_OPTIONS = {
|
|
816
|
+
json: { type: "boolean" },
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
function logsSuffix(source, tail) {
|
|
820
|
+
const parts = [];
|
|
821
|
+
if (source !== undefined) {
|
|
822
|
+
if (source !== "runtime" && source !== "deploy") return null;
|
|
823
|
+
parts.push(`source=${source}`);
|
|
824
|
+
}
|
|
825
|
+
if (tail !== undefined) {
|
|
826
|
+
if (!/^[1-9][0-9]*$/.test(tail)) return null;
|
|
827
|
+
const parsed = Number(tail);
|
|
828
|
+
parts.push(`tail=${parsed > 500 ? 500 : parsed}`);
|
|
829
|
+
}
|
|
830
|
+
return parts.length === 0 ? "" : `?${parts.join("&")}`;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export async function logs(args, io) {
|
|
834
|
+
const loaded = loadKey(io);
|
|
835
|
+
if (loaded.exit) return loaded.exit;
|
|
836
|
+
const parsed = parseStrict(args, LOGS_OPTIONS, true);
|
|
837
|
+
if (parsed === null) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
838
|
+
const json = parsed.values.json === true;
|
|
839
|
+
if (parsed.positionals.length === 0) return fail(io, loaded.key, json, SENTENCE.logsApp);
|
|
840
|
+
if (parsed.positionals.length !== 1) return fail(io, loaded.key, json, SENTENCE.refused);
|
|
841
|
+
const appId = appUuid(parsed.positionals[0]);
|
|
842
|
+
if (appId === null) return fail(io, loaded.key, json, SENTENCE.logsApp);
|
|
843
|
+
const suffix = logsSuffix(parsed.values.source, parsed.values.tail);
|
|
844
|
+
if (suffix === null) return fail(io, loaded.key, json, SENTENCE.refused);
|
|
845
|
+
const result = await call(io, loaded.key, {
|
|
846
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/logs${suffix}`,
|
|
847
|
+
method: "GET",
|
|
848
|
+
});
|
|
849
|
+
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
850
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
851
|
+
if (!isRecord(result.body) || typeof result.body.lines !== "string") {
|
|
852
|
+
return fail(io, loaded.key, json, SENTENCE.failed);
|
|
853
|
+
}
|
|
854
|
+
return succeed(io, loaded.key, json, result.body, result.body.lines);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function listLine(resource, row) {
|
|
858
|
+
if (!isRecord(row)) return null;
|
|
859
|
+
const id = row.id;
|
|
860
|
+
const name = row.name;
|
|
861
|
+
const projectName = row.project_name;
|
|
862
|
+
if (typeof id !== "string" || typeof name !== "string" || typeof projectName !== "string") return null;
|
|
863
|
+
if (resource === "apps") return `${id} ${name} ${projectName}`;
|
|
864
|
+
if (typeof row.kind !== "string") return null;
|
|
865
|
+
return `${id} ${row.kind} ${name} ${projectName}`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function listText(resource, body) {
|
|
869
|
+
if (!isRecord(body)) return null;
|
|
870
|
+
const rows = resource === "apps" ? body.apps : body.databases;
|
|
871
|
+
if (!Array.isArray(rows)) return null;
|
|
872
|
+
const lines = [];
|
|
873
|
+
for (const row of rows) {
|
|
874
|
+
const line = listLine(resource, row);
|
|
875
|
+
if (line === null) return null;
|
|
876
|
+
lines.push(line);
|
|
877
|
+
}
|
|
878
|
+
if (lines.length === 0) return "";
|
|
879
|
+
return `${lines.join("\n")}\n`;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function projectRows(body) {
|
|
883
|
+
if (!isRecord(body) || !Array.isArray(body.projects)) return null;
|
|
884
|
+
const rows = [];
|
|
885
|
+
for (const item of body.projects) {
|
|
886
|
+
if (
|
|
887
|
+
!isRecord(item) ||
|
|
888
|
+
typeof item.id !== "string" ||
|
|
889
|
+
typeof item.name !== "string" ||
|
|
890
|
+
typeof item.created_at !== "string"
|
|
891
|
+
) {
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
rows.push({ id: item.id, name: item.name, created_at: item.created_at });
|
|
895
|
+
}
|
|
896
|
+
return rows;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export async function list(args, io) {
|
|
900
|
+
const loaded = loadKey(io);
|
|
901
|
+
if (loaded.exit) return loaded.exit;
|
|
902
|
+
const parsed = parseStrict(args, LIST_OPTIONS, true);
|
|
903
|
+
if (parsed === null) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
904
|
+
const json = parsed.values.json === true;
|
|
905
|
+
if (parsed.positionals.length !== 1) return fail(io, loaded.key, json, SENTENCE.refused);
|
|
906
|
+
const resource = parsed.positionals[0];
|
|
907
|
+
if (resource !== "apps" && resource !== "databases" && resource !== "projects") {
|
|
908
|
+
return fail(io, loaded.key, json, SENTENCE.refused);
|
|
909
|
+
}
|
|
910
|
+
const path =
|
|
911
|
+
resource === "apps" ? "/v1/apps" : resource === "databases" ? "/v1/databases" : "/v1/projects";
|
|
912
|
+
const result = await call(io, loaded.key, { path, method: "GET" });
|
|
913
|
+
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
914
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
915
|
+
if (resource !== "projects") {
|
|
916
|
+
const human = listText(resource, result.body);
|
|
917
|
+
if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
918
|
+
return succeed(io, loaded.key, json, result.body, human);
|
|
919
|
+
}
|
|
920
|
+
const rows = projectRows(result.body);
|
|
921
|
+
if (rows === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
922
|
+
const human = rows.length === 0 ? "" : `${rows.map((row) => `${row.id} ${row.name}`).join("\n")}\n`;
|
|
923
|
+
return succeed(io, loaded.key, json, { projects: rows }, human);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const USAGE_OPTIONS = {
|
|
927
|
+
json: { type: "boolean" },
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
function finiteNumber(value) {
|
|
931
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function usageText(body) {
|
|
935
|
+
if (!isRecord(body) || !isRecord(body.used) || !finiteNumber(body.used.ram_mb)) return null;
|
|
936
|
+
const used = body.used.ram_mb;
|
|
937
|
+
if (body.plan === null) return `Plan: none\nRAM used: ${used} MiB\n`;
|
|
938
|
+
if (typeof body.plan !== "string" || !isRecord(body.pools)) return null;
|
|
939
|
+
if (!finiteNumber(body.pools.ram_mb) || !finiteNumber(body.ram_remaining_mb)) return null;
|
|
940
|
+
return `Plan: ${body.plan}\nRAM used: ${used} MiB of ${body.pools.ram_mb} MiB\nRAM left: ${body.ram_remaining_mb} MiB\n`;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
export async function usage(args, io) {
|
|
944
|
+
const loaded = loadKey(io);
|
|
945
|
+
if (loaded.exit) return loaded.exit;
|
|
946
|
+
const parsed = parseStrict(args, USAGE_OPTIONS, false);
|
|
947
|
+
if (parsed === null) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
948
|
+
const json = parsed.values.json === true;
|
|
949
|
+
const result = await call(io, loaded.key, { path: "/v1/usage", method: "GET" });
|
|
950
|
+
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
951
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
952
|
+
const human = usageText(result.body);
|
|
953
|
+
if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
954
|
+
return succeed(io, loaded.key, json, result.body, human);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const PLANS_OPTIONS = {
|
|
958
|
+
json: { type: "boolean" },
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
function plansText(body) {
|
|
962
|
+
if (!isRecord(body) || !Array.isArray(body.plans)) return null;
|
|
963
|
+
const lines = [];
|
|
964
|
+
for (const row of body.plans) {
|
|
965
|
+
if (!isRecord(row) || typeof row.name !== "string") return null;
|
|
966
|
+
if (!finiteNumber(row.ram_mb) || !finiteNumber(row.cpu_milli) || !finiteNumber(row.disk_gb)) return null;
|
|
967
|
+
lines.push(`${row.name}: RAM ${row.ram_mb} MiB, CPU ${row.cpu_milli} mCPU, disk ${row.disk_gb} GB`);
|
|
968
|
+
}
|
|
969
|
+
if (lines.length === 0) return "";
|
|
970
|
+
return `${lines.join("\n")}\n`;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export async function plans(args, io) {
|
|
974
|
+
const loaded = loadKey(io);
|
|
975
|
+
if (loaded.exit) return loaded.exit;
|
|
976
|
+
const parsed = parseStrict(args, PLANS_OPTIONS, false);
|
|
977
|
+
if (parsed === null) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
978
|
+
const json = parsed.values.json === true;
|
|
979
|
+
const result = await call(io, loaded.key, { path: "/v1/plans", method: "GET" });
|
|
980
|
+
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
981
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
982
|
+
const human = plansText(result.body);
|
|
983
|
+
if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
984
|
+
return succeed(io, loaded.key, json, result.body, human);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const WHOAMI_OPTIONS = {
|
|
988
|
+
json: { type: "boolean" },
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
function whoamiView(body) {
|
|
992
|
+
if (!isRecord(body)) return null;
|
|
993
|
+
const account = body.account;
|
|
994
|
+
const apiKey = body.key;
|
|
995
|
+
if (!isRecord(account) || !isRecord(apiKey)) return null;
|
|
996
|
+
const id = account.id;
|
|
997
|
+
const email = account.email;
|
|
998
|
+
const accountStatus = account.status;
|
|
999
|
+
const plan = account.plan;
|
|
1000
|
+
const createdAt = account.created_at;
|
|
1001
|
+
const keyId = apiKey.id;
|
|
1002
|
+
const name = apiKey.name;
|
|
1003
|
+
const keyPrefix = apiKey.key_prefix;
|
|
1004
|
+
if (
|
|
1005
|
+
typeof id !== "string" ||
|
|
1006
|
+
typeof email !== "string" ||
|
|
1007
|
+
typeof accountStatus !== "string" ||
|
|
1008
|
+
typeof createdAt !== "string" ||
|
|
1009
|
+
(plan !== null && typeof plan !== "string") ||
|
|
1010
|
+
typeof keyId !== "string" ||
|
|
1011
|
+
typeof name !== "string" ||
|
|
1012
|
+
typeof keyPrefix !== "string"
|
|
1013
|
+
) {
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
account: { id, email, status: accountStatus, plan, created_at: createdAt },
|
|
1018
|
+
key: { id: keyId, name, key_prefix: keyPrefix },
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
export async function whoami(args, io) {
|
|
1023
|
+
const loaded = loadKey(io);
|
|
1024
|
+
if (loaded.exit) return loaded.exit;
|
|
1025
|
+
const parsed = parseStrict(args, WHOAMI_OPTIONS, false);
|
|
1026
|
+
if (parsed === null) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1027
|
+
const json = parsed.values.json === true;
|
|
1028
|
+
const result = await call(io, loaded.key, { path: "/v1/me", method: "GET" });
|
|
1029
|
+
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
1030
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
1031
|
+
const view = whoamiView(result.body);
|
|
1032
|
+
if (view === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
1033
|
+
const plan = view.account.plan === null ? "none" : view.account.plan;
|
|
1034
|
+
const human = `Account: ${view.account.email}\nPlan: ${plan}\nKey: ${view.key.name} (${view.key.key_prefix})\n`;
|
|
1035
|
+
return succeed(io, loaded.key, json, view, human);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export function rollback(args, io) {
|
|
1039
|
+
const loaded = loadKey(io);
|
|
1040
|
+
if (loaded.exit) return loaded.exit;
|
|
1041
|
+
return fail(io, loaded.key, wantsJson(args), SENTENCE.rollback);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const DIAGNOSE_OPTIONS = {
|
|
1045
|
+
json: { type: "boolean" },
|
|
1046
|
+
app: { type: "string" },
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
const DATABASE_CREATE_OPTIONS = {
|
|
1050
|
+
json: { type: "boolean" },
|
|
1051
|
+
project: { type: "string" },
|
|
1052
|
+
kind: { type: "string" },
|
|
1053
|
+
name: { type: "string" },
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const DATABASE_STATUS_OPTIONS = {
|
|
1057
|
+
json: { type: "boolean" },
|
|
1058
|
+
id: { type: "string" },
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
const DATABASE_DELETE_OPTIONS = {
|
|
1062
|
+
json: { type: "boolean" },
|
|
1063
|
+
id: { type: "string", multiple: true },
|
|
1064
|
+
yes: { type: "boolean" },
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
const DATABASE_UNION = {
|
|
1068
|
+
json: { type: "boolean" },
|
|
1069
|
+
project: { type: "string" },
|
|
1070
|
+
kind: { type: "string" },
|
|
1071
|
+
name: { type: "string" },
|
|
1072
|
+
id: { type: "string" },
|
|
1073
|
+
yes: { type: "boolean" },
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
function parseStrict(args, options, allowPositionals) {
|
|
1077
|
+
try {
|
|
1078
|
+
return parseArgs({
|
|
1079
|
+
args: Array.isArray(args) ? args : [],
|
|
1080
|
+
strict: true,
|
|
1081
|
+
allowPositionals,
|
|
1082
|
+
options,
|
|
1083
|
+
});
|
|
1084
|
+
} catch {
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function isRecord(value) {
|
|
1090
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function diagnoseSentences(body) {
|
|
1094
|
+
if (!isRecord(body)) return null;
|
|
1095
|
+
if (body.diagnosis === null) {
|
|
1096
|
+
return typeof body.note === "string" ? `${body.note}\n` : null;
|
|
1097
|
+
}
|
|
1098
|
+
if (!isRecord(body.diagnosis) || typeof body.diagnosis.reason !== "string") return null;
|
|
1099
|
+
if (body.diagnosis.next_step === undefined) return `${body.diagnosis.reason}\n`;
|
|
1100
|
+
if (typeof body.diagnosis.next_step !== "string") return null;
|
|
1101
|
+
return `${body.diagnosis.reason}\n${body.diagnosis.next_step}\n`;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function databaseFields(value) {
|
|
1105
|
+
if (!isRecord(value)) return null;
|
|
1106
|
+
const id = value.id;
|
|
1107
|
+
const projectId = value.project_id;
|
|
1108
|
+
const kind = value.kind;
|
|
1109
|
+
const name = value.name;
|
|
1110
|
+
const host = value.host;
|
|
1111
|
+
const port = value.port;
|
|
1112
|
+
const databaseName = value.database;
|
|
1113
|
+
const user = value.user;
|
|
1114
|
+
const memory = value.memory_limit;
|
|
1115
|
+
const createdAt = value.created_at;
|
|
1116
|
+
const updatedAt = value.updated_at;
|
|
1117
|
+
if (typeof id !== "string" || typeof projectId !== "string" || typeof kind !== "string") return null;
|
|
1118
|
+
if (typeof name !== "string" || typeof host !== "string" || typeof user !== "string") return null;
|
|
1119
|
+
if (typeof createdAt !== "string" || typeof updatedAt !== "string") return null;
|
|
1120
|
+
if (typeof port !== "number" || !Number.isInteger(port)) return null;
|
|
1121
|
+
if (typeof memory !== "number" || !Number.isInteger(memory)) return null;
|
|
1122
|
+
if (databaseName !== null && typeof databaseName !== "string") return null;
|
|
1123
|
+
return {
|
|
1124
|
+
id,
|
|
1125
|
+
project_id: projectId,
|
|
1126
|
+
kind,
|
|
1127
|
+
name,
|
|
1128
|
+
host,
|
|
1129
|
+
port,
|
|
1130
|
+
database: databaseName,
|
|
1131
|
+
user,
|
|
1132
|
+
memory_limit: memory,
|
|
1133
|
+
created_at: createdAt,
|
|
1134
|
+
updated_at: updatedAt,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function databaseLines(view) {
|
|
1139
|
+
const lines = [view.id, view.project_id, view.kind, view.name, view.host, String(view.port)];
|
|
1140
|
+
if (view.database !== null) lines.push(view.database);
|
|
1141
|
+
lines.push(view.user, String(view.memory_limit), view.created_at, view.updated_at);
|
|
1142
|
+
return lines;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function databasePositionals(args) {
|
|
1146
|
+
try {
|
|
1147
|
+
return parseArgs({
|
|
1148
|
+
args: Array.isArray(args) ? args : [],
|
|
1149
|
+
strict: false,
|
|
1150
|
+
allowPositionals: true,
|
|
1151
|
+
options: DATABASE_UNION,
|
|
1152
|
+
}).positionals;
|
|
1153
|
+
} catch {
|
|
1154
|
+
return null;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
export async function diagnose(args, io) {
|
|
1159
|
+
const loaded = loadKey(io);
|
|
1160
|
+
if (loaded.exit) return loaded.exit;
|
|
1161
|
+
const parsed = parseStrict(args, DIAGNOSE_OPTIONS, false);
|
|
1162
|
+
if (parsed === null) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1163
|
+
const json = parsed.values.json === true;
|
|
1164
|
+
const appId = appUuid(parsed.values.app);
|
|
1165
|
+
if (appId === null) return fail(io, loaded.key, json, SENTENCE.app);
|
|
1166
|
+
const result = await call(io, loaded.key, {
|
|
1167
|
+
path: `/v1/apps/${encodeURIComponent(appId)}/diagnostics`,
|
|
1168
|
+
method: "GET",
|
|
1169
|
+
});
|
|
1170
|
+
if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
|
|
1171
|
+
if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
|
|
1172
|
+
const human = diagnoseSentences(result.body);
|
|
1173
|
+
if (human === null) return fail(io, loaded.key, json, SENTENCE.failed);
|
|
1174
|
+
return succeed(io, loaded.key, json, result.body, human);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
async function databaseCreate(args, io, key) {
|
|
1178
|
+
const parsed = parseStrict(args, DATABASE_CREATE_OPTIONS, true);
|
|
1179
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "create") {
|
|
1180
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1181
|
+
}
|
|
1182
|
+
const json = parsed.values.json === true;
|
|
1183
|
+
const project = trimmed(parsed.values.project);
|
|
1184
|
+
const kind = trimmed(parsed.values.kind);
|
|
1185
|
+
const name = trimmed(parsed.values.name);
|
|
1186
|
+
if (project === "" || kind === "" || name === "") {
|
|
1187
|
+
return fail(io, key, json, SENTENCE.databaseFlags);
|
|
1188
|
+
}
|
|
1189
|
+
const projectId = appUuid(project);
|
|
1190
|
+
if (projectId === null) return fail(io, key, json, SENTENCE.projectId);
|
|
1191
|
+
if (kind !== "postgres" && kind !== "redis") return fail(io, key, json, SENTENCE.refused);
|
|
1192
|
+
const result = await call(io, key, {
|
|
1193
|
+
path: `/v1/projects/${encodeURIComponent(projectId)}/databases`,
|
|
1194
|
+
method: "POST",
|
|
1195
|
+
body: { kind, name },
|
|
1196
|
+
});
|
|
1197
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1198
|
+
if (result.status !== 201) {
|
|
1199
|
+
return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
|
|
1200
|
+
}
|
|
1201
|
+
const view = databaseFields(isRecord(result.body) ? result.body.database : null);
|
|
1202
|
+
const password = isRecord(result.body) ? result.body.password : undefined;
|
|
1203
|
+
if (view === null || typeof password !== "string") return fail(io, key, json, SENTENCE.failed);
|
|
1204
|
+
const lines = databaseLines(view);
|
|
1205
|
+
lines.push(password);
|
|
1206
|
+
return succeed(io, key, json, { database: view, password }, `${lines.join("\n")}\n`);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
async function databaseStatus(args, io, key) {
|
|
1210
|
+
const parsed = parseStrict(args, DATABASE_STATUS_OPTIONS, true);
|
|
1211
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "status") {
|
|
1212
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1213
|
+
}
|
|
1214
|
+
const json = parsed.values.json === true;
|
|
1215
|
+
const id = appUuid(parsed.values.id);
|
|
1216
|
+
if (id === null) return fail(io, key, json, SENTENCE.databaseId);
|
|
1217
|
+
const result = await call(io, key, {
|
|
1218
|
+
path: `/v1/databases/${encodeURIComponent(id)}`,
|
|
1219
|
+
method: "GET",
|
|
1220
|
+
});
|
|
1221
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1222
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
1223
|
+
const view = databaseFields(isRecord(result.body) ? result.body.database : null);
|
|
1224
|
+
const statusText = isRecord(result.body) ? result.body.status : undefined;
|
|
1225
|
+
if (view === null || typeof statusText !== "string") return fail(io, key, json, SENTENCE.failed);
|
|
1226
|
+
const payload = {
|
|
1227
|
+
id: view.id,
|
|
1228
|
+
project_id: view.project_id,
|
|
1229
|
+
kind: view.kind,
|
|
1230
|
+
name: view.name,
|
|
1231
|
+
host: view.host,
|
|
1232
|
+
port: view.port,
|
|
1233
|
+
database: view.database,
|
|
1234
|
+
user: view.user,
|
|
1235
|
+
memory_limit: view.memory_limit,
|
|
1236
|
+
created_at: view.created_at,
|
|
1237
|
+
updated_at: view.updated_at,
|
|
1238
|
+
status: statusText,
|
|
1239
|
+
};
|
|
1240
|
+
const lines = [statusText, ...databaseLines(view)];
|
|
1241
|
+
return succeed(io, key, json, payload, `${lines.join("\n")}\n`);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function databaseBackup(args, io, key) {
|
|
1245
|
+
const parsed = parseStrict(args, DATABASE_STATUS_OPTIONS, true);
|
|
1246
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "backup") {
|
|
1247
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1248
|
+
}
|
|
1249
|
+
const json = parsed.values.json === true;
|
|
1250
|
+
const id = appUuid(parsed.values.id);
|
|
1251
|
+
if (id === null) return fail(io, key, json, SENTENCE.databaseId);
|
|
1252
|
+
const result = await call(io, key, {
|
|
1253
|
+
path: `/v1/databases/${encodeURIComponent(id)}/backups`,
|
|
1254
|
+
method: "POST",
|
|
1255
|
+
});
|
|
1256
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1257
|
+
if (result.status === 409) return fail(io, key, json, SENTENCE.noBackup);
|
|
1258
|
+
if (result.status !== 202) return fail(io, key, json, sentenceFor(result.status));
|
|
1259
|
+
return succeed(io, key, json, { database_id: id, backup: "run" }, "Backup run.\n");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
async function databaseDelete(args, io, key) {
|
|
1263
|
+
const parsed = parseStrict(args, DATABASE_DELETE_OPTIONS, true);
|
|
1264
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "delete") {
|
|
1265
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1266
|
+
}
|
|
1267
|
+
const json = parsed.values.json === true;
|
|
1268
|
+
const ids = Array.isArray(parsed.values.id) ? parsed.values.id : [];
|
|
1269
|
+
if (ids.length > 1) return fail(io, key, json, SENTENCE.refused);
|
|
1270
|
+
const id = appUuid(ids[0]);
|
|
1271
|
+
if (id === null) return fail(io, key, json, SENTENCE.databaseId);
|
|
1272
|
+
if (parsed.values.yes !== true) return fail(io, key, json, SENTENCE.deleteConfirm);
|
|
1273
|
+
const result = await call(io, key, {
|
|
1274
|
+
path: `/v1/databases/${encodeURIComponent(id)}`,
|
|
1275
|
+
method: "DELETE",
|
|
1276
|
+
});
|
|
1277
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1278
|
+
if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status));
|
|
1279
|
+
return succeed(io, key, json, { database_id: id, removed: true }, "Database removed.\n");
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
export async function database(args, io) {
|
|
1283
|
+
const loaded = loadKey(io);
|
|
1284
|
+
if (loaded.exit) return loaded.exit;
|
|
1285
|
+
const words = databasePositionals(args);
|
|
1286
|
+
if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1287
|
+
if (words[0] === "create") return databaseCreate(args, io, loaded.key);
|
|
1288
|
+
if (words[0] === "status") return databaseStatus(args, io, loaded.key);
|
|
1289
|
+
if (words[0] === "backup") return databaseBackup(args, io, loaded.key);
|
|
1290
|
+
if (words[0] === "delete") return databaseDelete(args, io, loaded.key);
|
|
1291
|
+
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const STORAGE_CREATE_OPTIONS = {
|
|
1295
|
+
json: { type: "boolean" },
|
|
1296
|
+
name: { type: "string" },
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
const STORAGE_LIST_OPTIONS = {
|
|
1300
|
+
json: { type: "boolean" },
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
const STORAGE_SHOW_OPTIONS = {
|
|
1304
|
+
json: { type: "boolean" },
|
|
1305
|
+
id: { type: "string" },
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
const STORAGE_DELETE_OPTIONS = {
|
|
1309
|
+
json: { type: "boolean" },
|
|
1310
|
+
id: { type: "string", multiple: true },
|
|
1311
|
+
yes: { type: "boolean" },
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
const STORAGE_UNION = {
|
|
1315
|
+
json: { type: "boolean" },
|
|
1316
|
+
name: { type: "string" },
|
|
1317
|
+
id: { type: "string" },
|
|
1318
|
+
yes: { type: "boolean" },
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
function storagePositionals(args) {
|
|
1322
|
+
try {
|
|
1323
|
+
return parseArgs({
|
|
1324
|
+
args: Array.isArray(args) ? args : [],
|
|
1325
|
+
strict: false,
|
|
1326
|
+
allowPositionals: true,
|
|
1327
|
+
options: STORAGE_UNION,
|
|
1328
|
+
}).positionals;
|
|
1329
|
+
} catch {
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function storageFields(value) {
|
|
1335
|
+
if (!isRecord(value)) return null;
|
|
1336
|
+
const id = value.id;
|
|
1337
|
+
const name = value.name;
|
|
1338
|
+
const endpoint = value.endpoint;
|
|
1339
|
+
const bucket = value.bucket;
|
|
1340
|
+
const accessKeyId = value.access_key_id;
|
|
1341
|
+
const status = value.status;
|
|
1342
|
+
const createdAt = value.created_at;
|
|
1343
|
+
const updatedAt = value.updated_at;
|
|
1344
|
+
if (typeof id !== "string" || typeof name !== "string" || typeof endpoint !== "string") return null;
|
|
1345
|
+
if (typeof bucket !== "string" || typeof status !== "string") return null;
|
|
1346
|
+
if (typeof createdAt !== "string" || typeof updatedAt !== "string") return null;
|
|
1347
|
+
if (accessKeyId !== null && typeof accessKeyId !== "string") return null;
|
|
1348
|
+
return {
|
|
1349
|
+
id,
|
|
1350
|
+
name,
|
|
1351
|
+
endpoint,
|
|
1352
|
+
bucket,
|
|
1353
|
+
access_key_id: accessKeyId,
|
|
1354
|
+
status,
|
|
1355
|
+
created_at: createdAt,
|
|
1356
|
+
updated_at: updatedAt,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function storageLines(view) {
|
|
1361
|
+
const lines = [view.id, view.name, view.endpoint, view.bucket];
|
|
1362
|
+
if (view.access_key_id !== null) lines.push(view.access_key_id);
|
|
1363
|
+
lines.push(view.status, view.created_at, view.updated_at);
|
|
1364
|
+
return lines;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
async function storageCreate(args, io, key) {
|
|
1368
|
+
const parsed = parseStrict(args, STORAGE_CREATE_OPTIONS, true);
|
|
1369
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "create") {
|
|
1370
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1371
|
+
}
|
|
1372
|
+
const json = parsed.values.json === true;
|
|
1373
|
+
const name = trimmed(parsed.values.name);
|
|
1374
|
+
if (name === "") return fail(io, key, json, SENTENCE.refused);
|
|
1375
|
+
const result = await call(io, key, {
|
|
1376
|
+
path: "/v1/buckets",
|
|
1377
|
+
method: "POST",
|
|
1378
|
+
body: { name },
|
|
1379
|
+
});
|
|
1380
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1381
|
+
const refusal = storageCreateMessage(result.status, result.body);
|
|
1382
|
+
if (refusal !== null) return fail(io, key, json, refusal);
|
|
1383
|
+
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1384
|
+
if (result.status !== 201) {
|
|
1385
|
+
return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
|
|
1386
|
+
}
|
|
1387
|
+
const body = isRecord(result.body) ? result.body : null;
|
|
1388
|
+
const view = body === null ? null : storageFields(body.bucket);
|
|
1389
|
+
const credentials = body === null ? null : body.credentials;
|
|
1390
|
+
if (
|
|
1391
|
+
view === null ||
|
|
1392
|
+
!isRecord(credentials) ||
|
|
1393
|
+
typeof credentials.endpoint !== "string" ||
|
|
1394
|
+
typeof credentials.bucket !== "string" ||
|
|
1395
|
+
typeof credentials.access_key_id !== "string" ||
|
|
1396
|
+
typeof credentials.secret_access_key !== "string"
|
|
1397
|
+
) {
|
|
1398
|
+
return fail(io, key, json, SENTENCE.failed);
|
|
1399
|
+
}
|
|
1400
|
+
const payloadCredentials = {
|
|
1401
|
+
endpoint: credentials.endpoint,
|
|
1402
|
+
bucket: credentials.bucket,
|
|
1403
|
+
access_key_id: credentials.access_key_id,
|
|
1404
|
+
secret_access_key: credentials.secret_access_key,
|
|
1405
|
+
...(typeof credentials.region === "string" ? { region: credentials.region } : {}),
|
|
1406
|
+
};
|
|
1407
|
+
const lines = [
|
|
1408
|
+
view.id,
|
|
1409
|
+
view.name,
|
|
1410
|
+
payloadCredentials.endpoint,
|
|
1411
|
+
payloadCredentials.bucket,
|
|
1412
|
+
payloadCredentials.access_key_id,
|
|
1413
|
+
payloadCredentials.secret_access_key,
|
|
1414
|
+
];
|
|
1415
|
+
return succeed(io, key, json, { bucket: view, credentials: payloadCredentials }, `${lines.join("\n")}\n`);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async function storageList(args, io, key) {
|
|
1419
|
+
const parsed = parseStrict(args, STORAGE_LIST_OPTIONS, true);
|
|
1420
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "list") {
|
|
1421
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1422
|
+
}
|
|
1423
|
+
const json = parsed.values.json === true;
|
|
1424
|
+
const result = await call(io, key, { path: "/v1/buckets", method: "GET" });
|
|
1425
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1426
|
+
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1427
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
1428
|
+
const items = isRecord(result.body) && Array.isArray(result.body.buckets) ? result.body.buckets : null;
|
|
1429
|
+
if (items === null) return fail(io, key, json, SENTENCE.failed);
|
|
1430
|
+
const views = [];
|
|
1431
|
+
for (const item of items) {
|
|
1432
|
+
const row = storageFields(item);
|
|
1433
|
+
if (row === null) return fail(io, key, json, SENTENCE.failed);
|
|
1434
|
+
views.push(row);
|
|
1435
|
+
}
|
|
1436
|
+
const human = views.length === 0 ? "" : `${views.map((row) => `${row.id} ${row.name} ${row.status}`).join("\n")}\n`;
|
|
1437
|
+
return succeed(io, key, json, { buckets: views }, human);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
async function storageShow(args, io, key) {
|
|
1441
|
+
const parsed = parseStrict(args, STORAGE_SHOW_OPTIONS, true);
|
|
1442
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "show") {
|
|
1443
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1444
|
+
}
|
|
1445
|
+
const json = parsed.values.json === true;
|
|
1446
|
+
const id = appUuid(parsed.values.id);
|
|
1447
|
+
if (id === null) return fail(io, key, json, SENTENCE.refused);
|
|
1448
|
+
const result = await call(io, key, {
|
|
1449
|
+
path: `/v1/buckets/${encodeURIComponent(id)}`,
|
|
1450
|
+
method: "GET",
|
|
1451
|
+
});
|
|
1452
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1453
|
+
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1454
|
+
if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
|
|
1455
|
+
const view = storageFields(isRecord(result.body) ? result.body.bucket : null);
|
|
1456
|
+
if (view === null) return fail(io, key, json, SENTENCE.failed);
|
|
1457
|
+
return succeed(io, key, json, { bucket: view }, `${storageLines(view).join("\n")}\n`);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
async function storageDelete(args, io, key) {
|
|
1461
|
+
const parsed = parseStrict(args, STORAGE_DELETE_OPTIONS, true);
|
|
1462
|
+
if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "delete") {
|
|
1463
|
+
return fail(io, key, wantsJson(args), SENTENCE.refused);
|
|
1464
|
+
}
|
|
1465
|
+
const json = parsed.values.json === true;
|
|
1466
|
+
const ids = Array.isArray(parsed.values.id) ? parsed.values.id : [];
|
|
1467
|
+
if (ids.length > 1) return fail(io, key, json, SENTENCE.refused);
|
|
1468
|
+
const id = appUuid(ids[0]);
|
|
1469
|
+
if (id === null) return fail(io, key, json, SENTENCE.refused);
|
|
1470
|
+
if (parsed.values.yes !== true) return fail(io, key, json, SENTENCE.bucketConfirm);
|
|
1471
|
+
const result = await call(io, key, {
|
|
1472
|
+
path: `/v1/buckets/${encodeURIComponent(id)}`,
|
|
1473
|
+
method: "DELETE",
|
|
1474
|
+
});
|
|
1475
|
+
if (result.network) return fail(io, key, json, SENTENCE.platform);
|
|
1476
|
+
if (result.status === 409) return fail(io, key, json, SENTENCE.bucketNotEmpty);
|
|
1477
|
+
if (result.status === 503) return fail(io, key, json, SENTENCE.storageOff);
|
|
1478
|
+
if (result.status !== 204) return fail(io, key, json, sentenceFor(result.status));
|
|
1479
|
+
return succeed(io, key, json, { bucket_id: id, removed: true }, `${SENTENCE.bucketRemoved}\n`);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
export async function storage(args, io) {
|
|
1483
|
+
const loaded = loadKey(io);
|
|
1484
|
+
if (loaded.exit) return loaded.exit;
|
|
1485
|
+
const words = storagePositionals(args);
|
|
1486
|
+
if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1487
|
+
if (words[0] === "create") return storageCreate(args, io, loaded.key);
|
|
1488
|
+
if (words[0] === "list") return storageList(args, io, loaded.key);
|
|
1489
|
+
if (words[0] === "show") return storageShow(args, io, loaded.key);
|
|
1490
|
+
if (words[0] === "delete") return storageDelete(args, io, loaded.key);
|
|
1491
|
+
return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function stdinKey(io) {
|
|
1495
|
+
return typeof io.stdin === "string" ? io.stdin.trim() : "";
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
export async function login(args, io) {
|
|
1499
|
+
if (Array.isArray(args) && args.length > 0) return fail(io, "", false, SENTENCE.refused);
|
|
1500
|
+
const key = stdinKey(io);
|
|
1501
|
+
if (key === "") return fail(io, "", false, SENTENCE.emptyKey);
|
|
1502
|
+
const result = await call(io, key, { path: "/v1/me", method: "GET" });
|
|
1503
|
+
if (result.network) return fail(io, key, false, SENTENCE.platform);
|
|
1504
|
+
if (result.status !== 200) return fail(io, key, false, sentenceFor(result.status));
|
|
1505
|
+
saveApiKey(io.env, key);
|
|
1506
|
+
io.stdout.write(`${SENTENCE.loggedIn}\n`);
|
|
1507
|
+
return { exitCode: 0 };
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
export async function logout(args, io) {
|
|
1511
|
+
if (Array.isArray(args) && args.length > 0) return fail(io, "", false, SENTENCE.refused);
|
|
1512
|
+
const removed = removeApiKey(io.env).removed === true;
|
|
1513
|
+
io.stdout.write(`${removed ? SENTENCE.loggedOut : SENTENCE.nothingToRemove}\n`);
|
|
1514
|
+
return { exitCode: 0 };
|
|
1515
|
+
}
|