aloic 0.1.7

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/aloic.mjs ADDED
@@ -0,0 +1,1133 @@
1
+ #!/usr/bin/env node
2
+ /* aloic — publish a folder to Aloic.
3
+ *
4
+ * aloic login sign this machine in through a browser
5
+ * aloic init choose which project this folder publishes to
6
+ * aloic deploy ./dist publish it
7
+ *
8
+ * THE THREE COMMANDS ARE ONE COMMAND. Running `aloic deploy` with nothing set
9
+ * up does the login and the project choice on the way past, because a tool
10
+ * that stops to tell you to run two other commands first has simply moved the
11
+ * work onto the person. Each step is still available on its own for the cases
12
+ * where somebody wants to do them deliberately.
13
+ *
14
+ * EXIT CODES MATTER HERE more than in most tools: this runs unattended, and a
15
+ * pipeline decides whether a deploy worked by asking this process. Zero means
16
+ * the site is live. Anything else means it is not, and the reason is on stderr
17
+ * in one line that names what to do about it. */
18
+
19
+ import { readFile, writeFile, rm, rmdir, stat } from "node:fs/promises";
20
+ import { randomBytes } from "node:crypto";
21
+ import { homedir } from "node:os";
22
+ import { dirname, join, resolve, sep } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import { signIn, have, putBlob, putMap, publish, revoke, hashOf, keyOf } from "./lib/api.mjs";
26
+ import { walk, read, typeOf, tooMuch } from "./lib/files.mjs";
27
+ import {
28
+ findKey, findProject, saveProject, loadConfig, saveConfig, clearConfig,
29
+ configPath, deviceName
30
+ } from "./lib/config.mjs";
31
+ import { startLogin, awaitApproval, openBrowser } from "./lib/login.mjs";
32
+ import { checkForUpdate, tellAboutUpdate } from "./lib/update.mjs";
33
+ import { c, out, ok, info, bad, bar, pick, ask, secret, spin, tty, confirm } from "./lib/ui.mjs";
34
+
35
+ const here = dirname(fileURLToPath(import.meta.url));
36
+ const VERSION = JSON.parse(
37
+ await readFile(resolve(here, "package.json"), "utf8")
38
+ ).version;
39
+
40
+ const WEB = process.env.ALOIC_WEB || "https://aloic.ai";
41
+
42
+ /* ---------- how this copy of the tool behaves ----------
43
+ *
44
+ * CHOSEN IN THE BROWSER DURING SETUP, kept in the same file as the key, and
45
+ * changeable afterwards with `aloic settings`. Three, and every one of them is
46
+ * honoured somewhere below: a setting that does nothing is worse than no
47
+ * setting, because it teaches people the screen is decoration.
48
+ *
49
+ * The defaults are what the tool did before any of this existed, so an
50
+ * existing install that has never seen the setup screen behaves identically. */
51
+ const DEFAULTS = {
52
+ /* Whether `aloic deploy` points the project at what it just uploaded, or
53
+ keeps it as a draft for somebody to publish deliberately. */
54
+ publish: true,
55
+ /* Whether it asks first. Off, because a deploy tool that stops to ask on
56
+ every run is a deploy tool people wrap in `yes |`. */
57
+ confirm: false,
58
+ /* What to do about a newer version: install it after the command finishes,
59
+ or say nothing until `aloic update` is run. Never automatic without a
60
+ terminal, whatever this says: see lib/selfupdate.mjs. */
61
+ updates: "auto"
62
+ };
63
+
64
+ /* The three questions, in the words the setup screen in the browser uses. Both
65
+ places ask the same thing; a tool and its setup page disagreeing about what
66
+ a setting is called is how somebody ends up with two mental models of one
67
+ switch. */
68
+ const SETTINGS = [
69
+ {
70
+ key: "publish",
71
+ q: "When you run aloic deploy",
72
+ on: "Publish it",
73
+ off: "Save it as a draft",
74
+ /* Shown under the answer, because the thing worth knowing about choosing
75
+ to publish is that the other behaviour is still one flag away. */
76
+ note: "You can still run aloic deploy --draft any time to save the deployment as a draft."
77
+ },
78
+ {
79
+ key: "confirm",
80
+ q: "Before publishing",
81
+ on: "Ask me first",
82
+ off: "Publish immediately"
83
+ },
84
+ {
85
+ key: "updates",
86
+ q: "New versions",
87
+ on: "Update automatically",
88
+ off: "Only notify me",
89
+ /* These two are words rather than true and false, because "false" for an
90
+ update setting reads as "never" and this one means "when you say so". */
91
+ values: ["auto", "ask"]
92
+ }
93
+ ];
94
+
95
+ async function settings() {
96
+ const cfg = await loadConfig();
97
+ return { ...DEFAULTS, ...(cfg.settings || {}) };
98
+ }
99
+
100
+ const args = process.argv.slice(2);
101
+ const cmd = (args[0] || "").replace(/^-+/, "") || "";
102
+ const flag = (name, fallback = null) => {
103
+ const i = args.indexOf(`--${name}`);
104
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : fallback;
105
+ };
106
+ const has = name => args.includes(`--${name}`);
107
+
108
+ const die = (s, code = 1) => { process.stderr.write(`${c.red("✗")} ${s}\n`); process.exit(code); };
109
+
110
+ const HELP = `${c.bold("aloic")} ${c.grey(VERSION)} publish a folder to Aloic
111
+
112
+ ${c.bold("Commands")}
113
+ login sign this machine in through your browser
114
+ logout revoke this machine's key and forget it
115
+ settings change how this machine's CLI behaves
116
+ update move to the newest version
117
+ uninstall remove the CLI and everything it wrote
118
+ init choose which project this folder publishes to
119
+ deploy [folder] publish a folder and point the project at it
120
+ deploy draft publish the draft this project is holding
121
+ test draft open the draft at a temporary address
122
+ projects list the projects this machine can publish to
123
+ whoami show who this machine is signed in as
124
+
125
+ ${c.bold("Options")}
126
+ --title <text> names the deploy, shown as its heading
127
+ --description <text> what changed, shown under the name
128
+ --project <slug> publish to this project, ignoring .aloic
129
+ --draft upload without pointing the project at it
130
+ --publish publish it, whatever the saved setting says
131
+ --keep-key uninstall without removing the saved key
132
+ --quiet print only the address at the end
133
+ --version print the version and exit
134
+
135
+ ${c.grey("Unattended, set ALOIC_KEY and skip login entirely.")}`;
136
+
137
+ /* ---------- signing in ---------- */
138
+
139
+ async function sessionOrLogin({ quiet } = {}) {
140
+ const found = await findKey();
141
+ if (found) return await signIn(found.key);
142
+
143
+ if (!tty()) {
144
+ die("Not signed in. Set ALOIC_KEY, or run `aloic login` on a machine with a browser.");
145
+ }
146
+ if (!quiet) {
147
+ out("");
148
+ info("This machine is not signed in yet.");
149
+ }
150
+ /* This one goes on to publish, so it needs the session and not just the
151
+ key. See the note on `session` in login. */
152
+ return await login({ silent: true, session: true });
153
+ }
154
+
155
+ /* `session` says whether the caller needs a signed-in session back, or only
156
+ needs the machine set up. Setting up and having a session are not the same
157
+ thing: `aloic login` finishes the moment the key is saved, while `deploy`
158
+ needs to go on and use it. Exchanging the key for a session regardless meant
159
+ a round trip after the success line was already printed, so a bad minute on
160
+ the network produced a green tick followed by a crash. */
161
+ async function login({ silent, bare, session } = {}) {
162
+ /* IS THERE ANYTHING AT THE OTHER END. Signing in means opening a page and
163
+ waiting for somebody to press a button on it, so a web app that is not
164
+ answering produces the worst possible failure: a browser tab showing an
165
+ error, a spinner in the terminal, and ten minutes until the request
166
+ expires. One HEAD request before any of that turns it into a sentence.
167
+
168
+ Not fatal on its own: a machine behind a proxy that blocks HEAD, or with
169
+ no route out at all while the browser has one, should not be stopped by
170
+ our own reachability check. It says what it saw and carries on. */
171
+ const reachable = await fetch(WEB, {
172
+ method: "HEAD",
173
+ redirect: "follow",
174
+ signal: AbortSignal.timeout(4000)
175
+ }).then(r => r.ok, () => false);
176
+ if (!reachable) {
177
+ out("");
178
+ bad(`${WEB} is not answering.`);
179
+ info("Signing in needs the Aloic web app, which approves the request.");
180
+ info("Set ALOIC_WEB if you are running it somewhere else, or use a key:");
181
+ out(` ${c.grey("ALOIC_KEY=alo_... aloic deploy ./dist")}`);
182
+ out("");
183
+ }
184
+
185
+ const ask = await startLogin(WEB);
186
+
187
+ out("");
188
+ out(` ${c.bold("Sign in to Aloic")}`);
189
+ out("");
190
+ out(` ${c.grey("Opening")} ${c.cyan(ask.url)}`);
191
+ out("");
192
+ out(` ${c.grey("Approve the request for")} ${c.bold(deviceName())} ${c.grey("in your browser.")}`);
193
+ out("");
194
+ openBrowser(ask.url);
195
+
196
+ /* THE WAIT CHANGES CHARACTER WHEN THEY PRESS ALLOW, and the line has to say
197
+ so. Until then this is waiting on a decision; after it, the decision is
198
+ made and the browser is asking how the tool should behave, which is a
199
+ different thing to be told to go and do. */
200
+ const s = spin("Waiting for you to approve it");
201
+ let phase = "waiting";
202
+ let got;
203
+ try {
204
+ got = await awaitApproval(ask, st => {
205
+ if (st === "setup" && phase !== "setup") {
206
+ phase = "setup";
207
+ s.set("Setting up Aloic");
208
+ s.say("Continue in your browser to finish setting up.");
209
+ }
210
+ });
211
+ s.stop();
212
+ } catch (e) {
213
+ s.stop();
214
+ /* CANCELLED IS NOT AN ERROR, it is a decision, and the two deserve
215
+ different words and different exits. Somebody who meant to cancel does
216
+ not need a red cross and a stack of advice; somebody who cancelled by
217
+ accident needs the way back to be one keypress rather than remembering
218
+ the command. */
219
+ /* A CLOSED TAB IS ONE KEYPRESS FROM BEING OPEN AGAIN. Everything needed
220
+ to try is still true: the account, the machine, the intent. A menu here
221
+ would be three choices where there is one obvious one. */
222
+ if (e.closed) return await afterClosed({ silent, bare, session });
223
+ if (e.cancelled) return await afterCancel({ silent, bare, session });
224
+ die(e.message);
225
+ }
226
+
227
+ /* The choices made in the browser arrive with the key and are written down
228
+ beside it, so the first command after this already behaves the way the
229
+ setup screen said it would. Defaults for anything that flow did not
230
+ answer, which is every sign in from before it existed. */
231
+ await saveConfig({
232
+ key: got.key,
233
+ email: got.email || "",
234
+ savedAt: Date.now(),
235
+ settings: { ...DEFAULTS, ...(got.settings || {}) }
236
+ });
237
+
238
+ if (phase === "setup") {
239
+ ok("Aloic CLI was successfully set up.");
240
+ /* THE COMMAND THAT WORKS FROM WHERE THEY ARE. Somebody who came here
241
+ straight from the installer is standing in a shell whose PATH has not
242
+ caught up, and telling them to run `aloic` is how the installer used to
243
+ send people to "no such file or directory".
244
+
245
+ Not said at all when this WAS `aloic`: the list of commands is printed
246
+ underneath a moment later, so telling them to run the thing they just
247
+ ran to see the thing they are about to see is noise. */
248
+ if (!bare) {
249
+ info(onPath()
250
+ ? "Run `aloic` to see what it can do."
251
+ : "Open a new terminal and run `aloic` to see what it can do.");
252
+ }
253
+ } else {
254
+ ok(`Signed in as ${c.bold(got.email || got.name || "your account")}`);
255
+ info(`Key saved to ${configPath()}, named ${deviceName()}`);
256
+ }
257
+ if (!silent) out("");
258
+ return session ? await signIn(got.key) : null;
259
+ }
260
+
261
+ /* Whether the short name resolves in the shell this is running in. Used only
262
+ to choose between two sentences. */
263
+ function onPath() {
264
+ const dirs = String(process.env.PATH || "").split(":");
265
+ return dirs.includes(join(homedir(), ".local", "bin"));
266
+ }
267
+
268
+ /* What happens when the setup tab goes away without an answer.
269
+ *
270
+ * Not the cancel menu: closing a window is rarely a decision about signing in,
271
+ * and offering to paste a key by hand as one of three equal options is a menu
272
+ * about the wrong thing. One line, one key, and the browser opens again. */
273
+ async function afterClosed(opts = {}) {
274
+ out("");
275
+ bad("The setup tab was closed.");
276
+ if (!tty()) process.exit(1);
277
+ out("");
278
+ await ask(` ${c.grey("Press")} ${c.bold("Enter")} ${c.grey("to reopen setup")}`);
279
+ return await login({ ...opts, silent: true });
280
+ }
281
+
282
+ /* What happens after somebody presses Cancel in the browser. */
283
+ async function afterCancel(opts = {}) {
284
+ out("");
285
+ bad("Authentication cancelled.");
286
+ out("");
287
+ if (!tty()) process.exit(1);
288
+
289
+ const choice = await pick(` ${c.grey("What now?")}`, [
290
+ { k: "retry", t: "Try signing in again" },
291
+ { k: "paste", t: "Paste a terminal key instead" },
292
+ { k: "quit", t: "Quit" }
293
+ ], x => x.t);
294
+
295
+ if (choice.k === "quit") process.exit(1);
296
+ if (choice.k === "retry") return await login({ ...opts, silent: true });
297
+
298
+ out("");
299
+ info(`Make one in Settings on ${WEB}, then paste it here.`);
300
+ const key = await secret(` ${c.bold("Key")}`);
301
+ if (!/^alo_[A-Za-z0-9_-]{32,64}$/.test(key)) {
302
+ die("That does not look like a terminal key. They start with alo_.");
303
+ }
304
+ const who = await signIn(key).catch(() => die("That key was not accepted."));
305
+ await saveConfig({ key, email: who.email || "", savedAt: Date.now() });
306
+ ok(`Signed in as ${c.bold(who.email || "your account")}`);
307
+ return who;
308
+ }
309
+
310
+ /* ---------- choosing a project ---------- */
311
+
312
+ const label = s => {
313
+ const live = s.status === "live" ? c.green("live") : c.grey(s.status || "draft");
314
+ return `${s.name || s.slug} ${c.grey(s.primaryDomain || `${s.slug}.aloic.ai`)} ${live}`;
315
+ };
316
+
317
+ async function chooseProject(who, { save = true, dir = process.cwd() } = {}) {
318
+ if (!who.sites?.length) {
319
+ die(`No projects on this account yet. Make one at ${WEB}/dashboard/projects/new`);
320
+ }
321
+
322
+ const named = flag("project");
323
+ if (named) {
324
+ /* THE NAME THIS TOOL ITSELF PRINTS HAS TO WORK. It listed "Test Project",
325
+ asked for a slug, and then refused "Test Project": three behaviours that
326
+ only make sense if you already know the two are different things.
327
+ Matched case insensitively on either, and the error lists what was
328
+ actually on offer rather than leaving somebody to remember a command. */
329
+ const want = named.trim().toLowerCase();
330
+ const hit = who.sites.find(s =>
331
+ s.slug.toLowerCase() === want
332
+ || s.id === named
333
+ || (s.name || "").trim().toLowerCase() === want);
334
+ if (!hit) {
335
+ die(`No project called "${named}" on this account. Yours are:\n`
336
+ + who.sites.map(s => ` ${s.slug}${s.name && s.name !== s.slug ? ` (${s.name})` : ""}`).join("\n"));
337
+ }
338
+ return hit;
339
+ }
340
+
341
+ const saved = await findProject(dir);
342
+ if (saved) {
343
+ const hit = who.sites.find(s => s.id === saved.project);
344
+ if (hit) return hit;
345
+ info(`${saved.file} names a project this key cannot reach. Choose again.`);
346
+ }
347
+
348
+ const chosen = await pick(
349
+ ` ${c.bold("Which project?")}`, who.sites, label,
350
+ list => `No terminal to choose with. Pass --project, for example:\n`
351
+ + ` aloic deploy ${args[1] && !args[1].startsWith("--") ? args[1] : "."}`
352
+ + ` --project ${list[0].slug}`
353
+ );
354
+ if (save) {
355
+ const file = await saveProject(dir, chosen.id, chosen.slug);
356
+ info(`Remembered in ${file}. Commit it and the whole team deploys the same project.`);
357
+ }
358
+ return chosen;
359
+ }
360
+
361
+ /* ---------- commands ---------- */
362
+
363
+ async function cmdLogin() {
364
+ const found = await findKey();
365
+ if (found && !has("force")) {
366
+ const who = await signIn(found.key).catch(() => null);
367
+ if (who) {
368
+ ok(`Already signed in${who.sites?.length ? ` with ${who.sites.length} project${who.sites.length === 1 ? "" : "s"}` : ""}.`);
369
+ info("Run `aloic login --force` to sign in again.");
370
+ return;
371
+ }
372
+ }
373
+ await login({});
374
+ }
375
+
376
+ async function cmdLogout() {
377
+ const found = await findKey();
378
+ if (!found) { ok("Not signed in on this machine."); return; }
379
+
380
+ /* A KEY FROM THE ENVIRONMENT IS NOT THIS MACHINE'S TO REVOKE. It was put
381
+ there by a pipeline or a shell profile, other machines may be using the
382
+ same one, and deleting it on the way out of an unrelated command would
383
+ take a deployment pipeline down. Only the one `login` saved is revoked. */
384
+ if (found.from !== configPath()) {
385
+ await clearConfig();
386
+ ok("Signed out on this machine.");
387
+ info(`The key in ${found.from} is left alone. Revoke it in Settings on aloic.ai.`);
388
+ return;
389
+ }
390
+
391
+ const who = await signIn(found.key).catch(() => null);
392
+ const gone = who ? await revoke(found.key, who.idToken).catch(() => false) : false;
393
+ await clearConfig();
394
+
395
+ if (gone) {
396
+ ok("Terminal key revoked.");
397
+ info("Run `aloic login` to sign in again.");
398
+ } else {
399
+ ok("Signed out on this machine.");
400
+ info("The key could not be revoked from here. Remove it in Settings on aloic.ai.");
401
+ }
402
+ }
403
+
404
+ /* ---------- taking it back off ----------
405
+ *
406
+ * EVERY INSTALLER OWES YOU ONE. A tool that writes to three places in your
407
+ * home directory and a line into your shell profile, and then has no way to
408
+ * undo any of it, is a tool you have to clean up by hand from a blog post. It
409
+ * is also the thing that makes the install worth trusting: an install you can
410
+ * reverse in one command is an install you can try.
411
+ *
412
+ * There is a shell version of this in the installer as well, for the case this
413
+ * one cannot help with, which is a copy too broken to run. */
414
+ async function cmdUninstall() {
415
+ const dir = process.env.ALOIC_HOME || join(homedir(), ".aloic");
416
+ const launcher = join(homedir(), ".local", "bin", "aloic");
417
+ const keepKey = has("keep-key");
418
+
419
+ /* Was this copy put here by the installer, or is it npx running out of a
420
+ cache somewhere? Only the first case owns the directories below. */
421
+ const mine = here.startsWith(resolve(dir) + sep);
422
+
423
+ const targets = [];
424
+ const seen = async p => { try { await stat(p); return true; } catch { return false; } };
425
+ if (await seen(join(dir, "versions"))) targets.push(join(dir, "versions"));
426
+ if (await seen(join(dir, "current"))) targets.push(join(dir, "current"));
427
+ /* The file the installer writes for `source ~/.aloic/env`. Left behind, it
428
+ is one stale line pointing at a directory that no longer has anything in
429
+ it, and it keeps the directory from being tidied away. */
430
+ if (await seen(join(dir, "env"))) targets.push(join(dir, "env"));
431
+ if (await seen(launcher)) targets.push(launcher);
432
+
433
+ if (!targets.length && !mine) {
434
+ out("");
435
+ info("This copy was not installed by the Aloic installer, so there is nothing here to remove.");
436
+ info("If you installed it with npm, remove it with `npm rm -g aloic`.");
437
+ if (await seen(configPath())) {
438
+ out("");
439
+ info(`Your saved key is still at ${configPath()}. Remove it with \`aloic logout\`.`);
440
+ }
441
+ return;
442
+ }
443
+
444
+ out("");
445
+ out(` ${c.bold("Uninstall Aloic")}`);
446
+ out("");
447
+ for (const t of targets) out(` ${c.grey("remove")} ${t}`);
448
+ if (!keepKey && await seen(configPath())) {
449
+ out(` ${c.grey("remove")} ${configPath()} ${c.grey("(your saved key)")}`);
450
+ }
451
+ out(` ${c.grey("remove")} the PATH line from your shell profile ${c.grey("(if it is there)")}`);
452
+ out("");
453
+
454
+ /* IT ASKS, AND WHERE IT CANNOT ASK IT REFUSES.
455
+
456
+ Every other command in this tool treats "no terminal" as "get on with it",
457
+ which is right for publishing and wrong for deleting: a command that
458
+ removes the tool, the key and a line from a shell profile must not do that
459
+ because it happened to be run from a script. With nobody to answer, the
460
+ answer has to be on the command line. */
461
+ if (!has("yes")) {
462
+ if (!tty()) {
463
+ die("Uninstalling needs a terminal to confirm from. Pass --yes to skip the question.");
464
+ }
465
+ if (!await confirm(" Are you sure you want to uninstall the Aloic CLI?", false)) {
466
+ out("");
467
+ info("Nothing was removed.");
468
+ return;
469
+ }
470
+ }
471
+
472
+ /* TWO STEPS, NAMED, IN THE ORDER THEY HAPPEN.
473
+
474
+ Signing out is a network round trip and deleting files is not, so without
475
+ saying which one is running the whole thing is a pause followed by a tick.
476
+ They are also genuinely different acts: the first gives a credential back
477
+ to the server, the second takes files off this machine, and somebody
478
+ watching should be able to tell which one failed. */
479
+ out("");
480
+
481
+ /* THE KEY GOES BACK FIRST, while there is still a tool to do it with. A
482
+ local file deleted is a key that goes on working on the server, listed in
483
+ Settings, until somebody notices it. Best effort: being offline is not a
484
+ reason to refuse to uninstall. */
485
+ if (!keepKey) {
486
+ const found = await findKey();
487
+ if (found && found.from === configPath()) {
488
+ const s1 = tty() ? spin("Logging out") : null;
489
+ const who = await signIn(found.key).catch(() => null);
490
+ const gone = who ? await revoke(found.key, who.idToken).catch(() => false) : false;
491
+ s1?.stop();
492
+ if (gone) ok("Terminal key revoked.");
493
+ else info("The key could not be revoked from here. Remove it in Settings on aloic.ai.");
494
+ } else if (found) {
495
+ info(`The key in ${found.from} is left alone. Revoke it in Settings on aloic.ai.`);
496
+ }
497
+ await clearConfig();
498
+ }
499
+
500
+ const s2 = tty() ? spin("Uninstalling") : null;
501
+ for (const t of targets) await rm(t, { recursive: true, force: true }).catch(() => {});
502
+ /* Only if it is empty. Somebody may keep other things in here, and an
503
+ uninstaller that takes a directory it did not create is a bug. */
504
+ await rmdir(dir).catch(() => {});
505
+
506
+ const rc = await stripPath();
507
+ s2?.stop();
508
+
509
+ ok("Aloic removed.");
510
+ if (rc) info(`The PATH line was taken out of ${rc}. Open a new terminal.`);
511
+ else info("Nothing was left in your shell profile.");
512
+ out("");
513
+ out(` ${c.grey("Install it again with")} ${c.cyan("curl -fsSL https://get.aloic.ai | sh")}`);
514
+ out("");
515
+ }
516
+
517
+ /* The two lines the installer appended, and only those two. Rewritten rather
518
+ than truncated, so anything somebody added afterwards survives. */
519
+ async function stripPath() {
520
+ const files = [".zshrc", ".bashrc", ".bash_profile", ".profile",
521
+ join(".config", "fish", "config.fish")];
522
+ for (const name of files) {
523
+ const at = join(homedir(), name);
524
+ let text;
525
+ try { text = await readFile(at, "utf8"); } catch { continue; }
526
+ if (!text.includes("# aloic installer")) continue;
527
+ const next = text.replace(
528
+ /\n*# aloic installer\n(?:export PATH=[^\n]*\n|fish_add_path[^\n]*\n)?/g,
529
+ "\n"
530
+ );
531
+ if (next === text) continue;
532
+ await writeFile(at, next);
533
+ return at;
534
+ }
535
+ return null;
536
+ }
537
+
538
+ /* ---------- changing them later ----------
539
+ *
540
+ * The setup screen in the browser is where these are first chosen, and a
541
+ * choice you can only revisit by signing in again is a choice you will live
542
+ * with instead. Same three questions, asked the way a terminal asks them. */
543
+ async function cmdSettings() {
544
+ const now = await settings();
545
+ const vals = s => s.values || [true, false];
546
+ const label = (s, v) => (v === vals(s)[0] ? s.on : s.off);
547
+
548
+ if (!tty()) {
549
+ out("");
550
+ for (const s of SETTINGS) {
551
+ out(` ${c.grey(s.q.padEnd(28))}${label(s, now[s.key])}`);
552
+ }
553
+ out("");
554
+ info("Run this in a terminal to change them.");
555
+ return;
556
+ }
557
+
558
+ out("");
559
+ const next = { ...now };
560
+ for (const s of SETTINGS) {
561
+ const chosen = await pick(
562
+ ` ${c.bold(s.q)}`,
563
+ vals(s).map(v => ({ v, t: label(s, v) })),
564
+ it => (it.v === now[s.key] ? `${it.t} ${c.grey("(current)")}` : it.t)
565
+ );
566
+ next[s.key] = chosen.v;
567
+ /* The footnote belongs to an ANSWER, not to the question, so it appears
568
+ only for the person it is about. */
569
+ if (s.note && chosen.v === vals(s)[0]) info(c.grey(s.note));
570
+ }
571
+ await saveConfig({ settings: next });
572
+ out("");
573
+ ok("Saved.");
574
+ info(`Stored in ${configPath()}`);
575
+ }
576
+
577
+ /* ---------- moving to a newer version ----------
578
+ *
579
+ * The manual half of the update setting. Somebody who chose "update when I
580
+ * ask" needs something to ask with, and somebody on automatic still wants a
581
+ * way to do it now rather than after the next command. */
582
+ async function cmdUpdate() {
583
+ const { installed, installVersion, latest, writable, matches } =
584
+ await import("./lib/selfupdate.mjs");
585
+
586
+ if (!installed() || !await writable()) {
587
+ out("");
588
+ info("This copy was not installed by the Aloic installer, so it cannot update itself.");
589
+ info("Reinstall with: curl -fsSL https://get.aloic.ai | sh");
590
+ return;
591
+ }
592
+
593
+ const s = tty() ? spin("Checking for a newer version") : null;
594
+ const v = await latest();
595
+ s?.stop();
596
+
597
+ if (!v) { bad("Could not reach get.aloic.ai."); process.exit(1); }
598
+
599
+ /* "ALREADY ON IT" HAS TO MEAN THE FILES ARE THE ONES PUBLISHED, not that a
600
+ number matches. A release that was overwritten in place, which happened
601
+ once and is now refused at the packing end, leaves an install with the
602
+ right version number and the wrong contents, and a check that compares
603
+ only the number can never get it out of that. This compares what is on
604
+ disk against the manifest and repairs it when they differ. */
605
+ if (v === VERSION && await matches(v)) {
606
+ ok(`Already on ${c.bold(VERSION)}.`);
607
+ return;
608
+ }
609
+ if (v === VERSION) info("This copy does not match the published release. Repairing.");
610
+
611
+ const s2 = tty() ? spin(`Updating to ${v}`) : null;
612
+ try {
613
+ await installVersion(v);
614
+ s2?.stop();
615
+ ok(`Updated ${c.grey(VERSION)} ${c.grey("\u2192")} ${c.bold(v)}`);
616
+ } catch (e) {
617
+ s2?.stop();
618
+ bad(e?.message || "That update did not go through.");
619
+ info(`Still on ${VERSION}. Nothing was changed.`);
620
+ process.exit(1);
621
+ }
622
+ }
623
+
624
+ async function cmdWhoami() {
625
+ const who = await sessionOrLogin({ quiet: true });
626
+ const from = (await findKey())?.from;
627
+ out(`${c.bold(who.email || "signed in")}`);
628
+ out(`${c.grey("key from")} ${from}`);
629
+ out(`${c.grey("projects")} ${who.sites?.length || 0}`);
630
+ }
631
+
632
+ async function cmdProjects() {
633
+ const who = await sessionOrLogin({ quiet: true });
634
+ if (!who.sites?.length) return info("No projects on this account yet.");
635
+ /* The slug is what --project wants, and three unlabelled columns left
636
+ somebody guessing which of them that was. */
637
+ const wide = Math.max(...who.sites.map(s => (s.name || s.slug).length));
638
+ out(` ${c.grey("NAME".padEnd(wide))} ${c.grey("--project")}`);
639
+ for (const s of who.sites) {
640
+ const live = s.status === "live" ? c.green("live") : c.grey(s.status || "draft");
641
+ out(` ${(s.name || s.slug).padEnd(wide)} ${c.bold(s.slug)} ${live}`);
642
+ }
643
+ }
644
+
645
+ async function cmdInit() {
646
+ const who = await sessionOrLogin({});
647
+ const chosen = await chooseProject(who, { save: true });
648
+ /* WRITTEN HERE TOO, and this is the fix for a real lie. chooseProject only
649
+ saves the choice it had to ASK for, so `aloic init --project x` returned a
650
+ green tick, wrote nothing, and the very next deploy prompted again. An
651
+ init that does not persist is worse than one that fails, because it fails
652
+ one command later and somewhere else. */
653
+ const file = await saveProject(process.cwd(), chosen.id, chosen.slug);
654
+ ok(`This folder publishes to ${c.bold(chosen.name || chosen.slug)}.`);
655
+ info(`Written to ${file}. Commit it and the whole team deploys the same project.`);
656
+ }
657
+
658
+ async function cmdDeploy() {
659
+ /* `aloic deploy draft` is the finish of the sentence `aloic deploy --draft`
660
+ started, so it is spelled the way the earlier command printed it. The
661
+ keyword wins over a folder of the same name: deploying a directory called
662
+ exactly "draft" is vanishingly rare and still one character away as
663
+ `aloic deploy ./draft`, whereas the keyword silently deploying a folder
664
+ would publish the wrong thing without saying so. */
665
+ if (args[1] === "draft") return await cmdDraft("publish");
666
+
667
+ const quiet = has("quiet");
668
+ /* THE CLOCK STARTS WHEN THE WORK DOES, not when the command was typed.
669
+
670
+ This was set here, above the sign in, the project picker and the prompt
671
+ for a title, so every second somebody spent reading a list or thinking of
672
+ a name was billed to the deploy: a build that took four seconds reported
673
+ forty because the person answering the prompt was slow, and that number is
674
+ stored on the record and shown in the deploy list forever.
675
+
676
+ Nothing above the first note is work. It is a conversation. */
677
+ let began = Date.now();
678
+ const log = [];
679
+ const note = (m, k) => {
680
+ log.push({ at: Date.now() - began, m, ...(k ? { k } : {}) });
681
+ if (!quiet) out(k === "step" ? `\n${c.bold(m)}` : ` ${c.grey(m)}`);
682
+ };
683
+
684
+ const where = resolve(args[1] && !args[1].startsWith("--") ? args[1] : ".");
685
+
686
+ const who = await sessionOrLogin({ quiet });
687
+
688
+ /* WHAT THIS MACHINE WAS TOLD TO DO, and the flags still win.
689
+ --draft and --publish are one command saying what it wants; the setting is
690
+ what every command means when it says nothing. */
691
+ const prefs = await settings();
692
+ const live = has("publish") ? true : has("draft") ? false : prefs.publish !== false;
693
+
694
+ const site = await chooseProject(who, { save: live });
695
+
696
+ /* REQUIRED. A deploy with no name is a row in a list that says "Upload", and
697
+ a list of those is a history nobody can read, which is most of the reason
698
+ to keep a history. Asked for until it is answered when somebody is there;
699
+ refused outright when nobody is, because a pipeline that publishes
700
+ anonymous builds is the case this is for. */
701
+ /* `--message` still answers. It was the name first, and the GitHub Action
702
+ people have already copied into their repositories passes it: a rename
703
+ that breaks somebody's pipeline is not a rename, it is an outage. */
704
+ let title = (flag("title") || flag("message") || "").trim();
705
+ if (!title) {
706
+ if (!tty()) {
707
+ die("This deploy needs a title. Pass --title \"what this is\".");
708
+ }
709
+ out("");
710
+ while (!title) {
711
+ title = await ask(` ${c.bold("Title")}`, "Name this deployment.");
712
+ if (!title) info("A title is needed.");
713
+ }
714
+ }
715
+
716
+ /* ASKED BEFORE ANYTHING MOVES, for whoever asked to be asked. After the
717
+ title, so the question names the thing it is about, and before the upload,
718
+ because stopping half way through one is not a decision anybody wanted to
719
+ have offered to them. */
720
+ /* WHERE THERE IS NOBODY TO ASK, IT DOES NOT ASK.
721
+ This used to refuse and name a flag. Confirming before publishing is a
722
+ personal preference somebody set on their own machine, not a safety rail
723
+ the product depends on, and a preference that stops a build machine is a
724
+ preference that has escaped its scope. Deleting things still asks: see
725
+ cmdUninstall, which refuses precisely because it is not a preference. */
726
+ if (live && prefs.confirm && tty()) {
727
+ const where = site.primaryDomain || `${site.slug}.aloic.ai`;
728
+ out("");
729
+ if (!await confirm(` Publish to ${c.bold(where)}?`)) {
730
+ out("");
731
+ info("Nothing was uploaded.");
732
+ return;
733
+ }
734
+ }
735
+
736
+ /* Everything above was somebody answering questions. Time starts here. */
737
+ began = Date.now();
738
+
739
+ note("Reading files", "step");
740
+ const { files, skipped } = await walk(where);
741
+ const wrong = tooMuch(files);
742
+ if (wrong) die(wrong);
743
+ const bytes = files.reduce((n, f) => n + f.size, 0);
744
+ note(`${files.length} ${files.length === 1 ? "file" : "files"}, ${(bytes / 1024).toFixed(0)}KB`);
745
+ if (skipped.length) {
746
+ note(`${skipped.length} not included: ${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? "…" : ""}`);
747
+ }
748
+
749
+ /* EVERYTHING IS HASHED FIRST, then compared against what is already stored,
750
+ and only what is missing goes up. On a second deploy of a site whose
751
+ images did not change, that is the difference between sending the whole
752
+ build and sending the one file that was edited. */
753
+ const blobs = new Map();
754
+ const map = {};
755
+ for (const f of files) {
756
+ const buf = await read(f);
757
+ f.hash = hashOf(buf);
758
+ map[keyOf(f.path)] = f.hash;
759
+ if (!blobs.has(f.hash)) blobs.set(f.hash, { buf, type: typeOf(f.path) });
760
+ }
761
+
762
+ const already = await have(who.uid, site.id, who.idToken);
763
+ const todo = [...blobs.entries()].filter(([h]) => !already.has(h));
764
+ const reused = blobs.size - todo.length;
765
+
766
+ note("Uploading", "step");
767
+ if (reused) note(`${reused} already stored, not sent again`);
768
+
769
+ let sent = 0, failed = null, at = 0;
770
+ const s = todo.length && !quiet && tty() ? spin("") : null;
771
+ const paint = () => s?.set(`${bar(sent, todo.length)} ${sent}/${todo.length}`);
772
+ paint();
773
+
774
+ /* Four at a time. Enough to keep a connection busy, few enough that a
775
+ failure is reported before much more has been spent on it. */
776
+ await Promise.all(Array.from({ length: Math.min(4, todo.length) }, async () => {
777
+ for (;;) {
778
+ if (failed) return;
779
+ const item = todo[at++];
780
+ if (!item) return;
781
+ const [hash, { buf, type }] = item;
782
+ try { await putBlob(who.uid, site.id, who.idToken, hash, type, buf); }
783
+ catch (e) { failed = e; return; }
784
+ sent++; paint();
785
+ }
786
+ }));
787
+ s?.stop();
788
+ if (failed) die(`Upload failed. ${failed.message}`);
789
+ note(`Sent ${todo.length} ${todo.length === 1 ? "file" : "files"} in ${((Date.now() - began) / 1000).toFixed(1)}s`);
790
+
791
+ /* THE ID ALOIC ALREADY PICKED, when there is one.
792
+ *
793
+ * A build started from the dashboard creates the deploy record first, so
794
+ * there is a page to watch from the moment the button is pressed, and passes
795
+ * its id to the workflow. Filling that record in is the difference between
796
+ * one deploy that goes from building to ready and two rows where one of them
797
+ * says "building" forever.
798
+ *
799
+ * Only ever from the environment, and only a shape we would have generated
800
+ * ourselves: this decides which document gets written, and the write is
801
+ * scoped to this project by the key either way. A push-triggered build sends
802
+ * nothing and makes its own, exactly as before. */
803
+ const given = String(process.env.ALOIC_DEPLOYMENT || "").trim();
804
+ const deployId = /^[A-Za-z0-9_-]{16,32}$/.test(given)
805
+ ? given
806
+ : randomBytes(15).toString("base64url");
807
+ await putMap(who.uid, site.id, who.idToken, deployId, map);
808
+
809
+ /* SAID OUT LOUD, because it is a change to the files somebody just gave us
810
+ and finding it by curling your own site is the wrong way to learn it. */
811
+ note("Aloic adds a small analytics script to served HTML pages", "info");
812
+
813
+ if (live) {
814
+ note("Publishing", "step");
815
+ note(`Pointing ${site.primaryDomain || `${site.slug}.aloic.ai`} at this deploy`);
816
+ } else {
817
+ note("Uploaded", "step");
818
+ note("Not published. The project keeps serving what it was.");
819
+ }
820
+
821
+ await publish(who.idToken, site.id, deployId, {
822
+ id: deployId,
823
+ siteId: site.id,
824
+ uid: who.uid,
825
+ status: "ready",
826
+ files: files.length,
827
+ bytes,
828
+ createdAt: began,
829
+ ms: Date.now() - began,
830
+ root: "",
831
+ skipped: skipped.length,
832
+ /* THE MESSAGE NAMES THE DEPLOY. It is the commit subject in a pipeline and
833
+ the one line somebody writes by hand otherwise, which is a name rather
834
+ than a description, and it is what the deploy page shows as its
835
+ heading. --description is the longer form underneath. */
836
+ title: title.slice(0, 120),
837
+ note: (flag("description") || "").trim().slice(0, 500),
838
+ /* Says it was never published, which is not the same as having been
839
+ replaced. See the note on `draft` in lib/creators.ts. */
840
+ ...(live ? {} : { draft: true }),
841
+ expiresAt: null,
842
+ log,
843
+ /* Says where it came from, so a deploy page can tell a push from a drop. */
844
+ via: "cli"
845
+ }, live);
846
+
847
+ const url = `https://${site.primaryDomain || `${site.slug}.aloic.ai`}`;
848
+ const took = c.grey(`${((Date.now() - began) / 1000).toFixed(1)}s`);
849
+ if (quiet) out(live ? url : deployId);
850
+ else {
851
+ out("");
852
+ /* A DRAFT IS NOT LIVE, and saying so would be the one lie this tool tells.
853
+ It printed the address either way, which read as a successful publish of
854
+ something that was deliberately not published. */
855
+ if (live) ok(`Live at ${c.cyan(url)} ${took}`);
856
+ else {
857
+ ok(`Draft created ${took}`);
858
+ info(`Your previous deployment is still published at ${c.cyan(url)}.`);
859
+ /* WHAT TO DO WITH IT, NAMED. A draft is the one deploy that is not
860
+ finished with, and a tool that makes one and then says nothing about
861
+ how to look at it or send it leaves somebody in the dashboard hunting
862
+ for a button. Two commands, both of which act on this draft. */
863
+ out("");
864
+ out(` ${c.grey("Preview it in a browser")}`);
865
+ out(` ${c.bold("aloic test draft")}`);
866
+ out("");
867
+ out(` ${c.grey(`Publish it to ${url}`)}`);
868
+ out(` ${c.bold("aloic deploy draft")}`);
869
+ }
870
+ }
871
+ }
872
+
873
+ /* ---------- finishing with a draft ----------
874
+ *
875
+ * TWO ENDINGS FOR ONE THING, and they are the two sentences the deploy that
876
+ * made it printed: look at it, or send it. Both act on the newest draft of
877
+ * whichever project this folder publishes to, because that is what somebody
878
+ * means by "the draft" one command after making one.
879
+ */
880
+ async function cmdDraft(what) {
881
+ const who = await sessionOrLogin({});
882
+ const site = await chooseProject(who, { save: false });
883
+
884
+ const { newestDraft, publishDraft, setPreview, PREVIEW_FOR } =
885
+ await import("./lib/api.mjs");
886
+
887
+ /* NOT CAUGHT AND FLATTENED TO null. A failed lookup and an empty project are
888
+ different facts, and reporting the first as the second is how a broken
889
+ query spent a release telling people they had no draft when they were
890
+ looking at one. */
891
+ const s0 = tty() ? spin("Looking for a draft") : null;
892
+ let d = null;
893
+ try {
894
+ d = await newestDraft(who.idToken, site.id);
895
+ s0?.stop();
896
+ } catch (e) {
897
+ s0?.stop();
898
+ die(e?.message || "Could not look for a draft.");
899
+ }
900
+
901
+ if (!d) {
902
+ out("");
903
+ bad(`${site.name || site.slug} has no draft waiting.`);
904
+ info("Make one with `aloic deploy --draft`.");
905
+ process.exit(1);
906
+ }
907
+
908
+ const name = (d.title || d.note || "").trim() || d.id.slice(0, 8);
909
+ const url = `https://${site.primaryDomain || `${site.slug}.aloic.ai`}`;
910
+
911
+ if (what === "publish") {
912
+ out("");
913
+ out(` ${c.bold(name)} ${c.grey(`${d.files} files, ${(d.bytes / 1024).toFixed(0)}KB`)}`);
914
+ out("");
915
+
916
+ /* THE SAME SETTING THE UPLOAD PATH READS. Somebody who asked to be asked
917
+ before publishing meant every publish, not only the ones that happen to
918
+ come with an upload attached. */
919
+ const prefs = await settings();
920
+ if (prefs.confirm && tty()) {
921
+ if (!await confirm(` Publish to ${c.bold(url.replace(/^https:\/\//, ""))}?`)) {
922
+ out("");
923
+ info("Nothing was published.");
924
+ return;
925
+ }
926
+ out("");
927
+ }
928
+
929
+ const s = tty() ? spin("Publishing") : null;
930
+ try {
931
+ await publishDraft(who.idToken, site.id, d.id);
932
+ s?.stop();
933
+ } catch (e) {
934
+ s?.stop();
935
+ die(e?.message || "That draft could not be published.");
936
+ }
937
+ ok(`Live at ${c.cyan(url)}`);
938
+ return;
939
+ }
940
+
941
+ /* ---------- test ----------
942
+ *
943
+ * A preview is a whole host of its own rather than a path, so an absolute
944
+ * link inside the build resolves inside the preview: see previewUrl in
945
+ * src/lib/creators.ts. It is minted here and stopped when this command
946
+ * ends, so the address lives exactly as long as somebody is looking at it. */
947
+ const key = previewName();
948
+ const until = Date.now() + PREVIEW_FOR;
949
+ const s = tty() ? spin("Making a preview") : null;
950
+ try {
951
+ await setPreview(who.idToken, site.id, d.id, key, until);
952
+ s?.stop();
953
+ } catch (e) {
954
+ s?.stop();
955
+ die(e?.message || "That preview could not be made.");
956
+ }
957
+
958
+ const at = `https://${key}--${site.slug}.aloic.ai`;
959
+ out("");
960
+ ok(`Previewing ${c.bold(name)}`);
961
+ out(` ${c.cyan(at)}`);
962
+ out("");
963
+ openBrowser(at);
964
+
965
+ if (!tty()) {
966
+ info(`This address stops answering in ${PREVIEW_FOR / 60000} minutes.`);
967
+ return;
968
+ }
969
+
970
+ /* THE CLOCK IS THE POINT. A preview that quietly expires is a link somebody
971
+ sends and then has to explain; one that counts down in front of you is a
972
+ thing you know the shape of. Enter ends it early. */
973
+ const ring = spin("", "");
974
+ const tick = () => {
975
+ const left = Math.max(0, until - Date.now());
976
+ const m = Math.floor(left / 60000), sec = Math.floor((left % 60000) / 1000);
977
+ ring.set(`Preview open for ${c.bold(`${m}:${String(sec).padStart(2, "0")}`)}`);
978
+ ring.say("Press Enter to stop the preview.");
979
+ };
980
+ tick();
981
+ const beat = setInterval(tick, 1000);
982
+
983
+ await Promise.race([
984
+ ask(""),
985
+ new Promise(r => setTimeout(r, Math.max(0, until - Date.now())))
986
+ ]);
987
+ clearInterval(beat);
988
+ ring.stop();
989
+
990
+ /* Ended deliberately rather than left to run out, which is the difference
991
+ between a link that is dead and a link that is dead in half an hour. */
992
+ await setPreview(who.idToken, site.id, d.id, key, 0).catch(() => {});
993
+ ok("Preview stopped.");
994
+ }
995
+
996
+ /* Lowercase and digits, because a host is case insensitive and a deploy id is
997
+ not. Random, so a preview cannot be guessed from an id anybody can list. */
998
+ function previewName() {
999
+ return randomBytes(9).toString("hex").slice(0, 12);
1000
+ }
1001
+
1002
+ /* ---------- running ---------- */
1003
+
1004
+ if (has("version") || cmd === "version") { out(VERSION); process.exit(0); }
1005
+
1006
+ const commands = {
1007
+ login: cmdLogin, logout: cmdLogout, whoami: cmdWhoami,
1008
+ projects: cmdProjects, init: cmdInit, deploy: cmdDeploy,
1009
+ settings: cmdSettings, update: cmdUpdate, uninstall: cmdUninstall,
1010
+ test: () => cmdDraft("test")
1011
+ };
1012
+ const run = commands[cmd];
1013
+
1014
+ /* ---------- `aloic`, on its own ----------
1015
+ *
1016
+ * IT CHECKS WHETHER IT IS SET UP, which is what makes it the one thing the
1017
+ * installer has to tell anybody to run. Bare, with no key on this machine, the
1018
+ * useful thing is not a list of commands that will all refuse: it is the setup
1019
+ * that makes them work. With a key, it is the list.
1020
+ *
1021
+ * Only ever with no arguments at all. `aloic deploy` on an unconfigured
1022
+ * machine already asks for a sign-in on its way past, and `aloic --help` is
1023
+ * somebody asking for the list rather than for anything to happen. */
1024
+ if (!cmd && !has("help")) {
1025
+ const already = await findKey();
1026
+ if (!already) {
1027
+ await login({ silent: true, bare: true });
1028
+ out("");
1029
+ out(HELP);
1030
+ process.exit(0);
1031
+ }
1032
+ }
1033
+
1034
+ /* A COMMAND NOBODY HAS IS NOT A REQUEST FOR THE MANUAL.
1035
+ *
1036
+ * Every unrecognised word printed the whole help page, which reads as though
1037
+ * the command worked and this is its output: the one line saying it was not
1038
+ * understood is the first thing scrolled off the top by the forty lines
1039
+ * underneath it. Say the one thing that is true and point at the list. */
1040
+ if (cmd && !run && cmd !== "help") {
1041
+ out("");
1042
+ bad(`We didn't recognize that command.`);
1043
+ info("Run `aloic` to see all available commands.");
1044
+ process.exit(1);
1045
+ }
1046
+
1047
+ if (!run || has("help") || cmd === "help") {
1048
+ out(HELP);
1049
+ process.exit(0);
1050
+ }
1051
+
1052
+ /* The update check runs beside the command rather than before it, so a slow
1053
+ lookup cannot delay a deploy, and is only ever reported at the end.
1054
+
1055
+ NOT WHILE UNINSTALLING, and this was a real leak rather than an ordering
1056
+ nicety. The check records when it last ran by writing to the same config
1057
+ file the uninstaller has just deleted, so the two raced and the write
1058
+ usually landed second: every uninstall left a config.json behind in a
1059
+ directory it had otherwise emptied, and the next install found a stale
1060
+ record of a check that happened before the tool was removed. Telling
1061
+ somebody about a new version of the thing they are in the middle of removing
1062
+ would be beside the point anyway. */
1063
+ /* ---------- new versions ----------
1064
+ *
1065
+ * CHECKED BEFORE THE COMMAND RUNS, not after it.
1066
+ *
1067
+ * It used to be a line printed once the work was done, once a day, which is
1068
+ * the polite version and the wrong one: the moment somebody most wants to know
1069
+ * they are on an old build is before it does anything, not underneath the
1070
+ * output of something that has already happened. So it is asked every time and
1071
+ * said first.
1072
+ *
1073
+ * WHAT KEEPS THAT FROM BEING A TAX ON EVERY DEPLOY. It is one request for a
1074
+ * file of eight bytes, it is given two seconds and then abandoned, and it does
1075
+ * not happen at all without a terminal: a build machine gets no notice, no
1076
+ * delay and no behaviour that depends on the network being reachable.
1077
+ *
1078
+ * The commands about the tool itself are excluded, because being told an
1079
+ * update exists while running the thing that installs updates is the tool
1080
+ * talking to itself. */
1081
+ const quiet0 = cmd === "uninstall" || cmd === "settings" || cmd === "update";
1082
+ let newer = null;
1083
+ if (!quiet0 && tty()) {
1084
+ const prefs0 = await settings().catch(() => DEFAULTS);
1085
+ /* `false` is what the first version of this setting stored for "do not
1086
+ check". Nothing writes it any more and honouring it is one comparison:
1087
+ silently starting to check for somebody who once said not to would be
1088
+ changing a decision on their behalf. */
1089
+ if (prefs0.updates !== "off" && prefs0.updates !== false) {
1090
+ newer = await checkForUpdate(VERSION).catch(() => null);
1091
+ /* Said before anything else, and only for the mode that asked to be told.
1092
+ On automatic there is nothing to act on: it installs itself below. */
1093
+ if (newer && prefs0.updates !== "auto") {
1094
+ const { installed } = await import("./lib/selfupdate.mjs");
1095
+ tellAboutUpdate(newer, VERSION, installed() ? "aloic update" : null);
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ try {
1101
+ await run();
1102
+ await afterwards(newer);
1103
+ } catch (e) {
1104
+ die(e?.message || String(e));
1105
+ }
1106
+
1107
+ /* WHAT TO DO ABOUT A NEWER VERSION, once the work is done.
1108
+ *
1109
+ * On automatic it installs it here rather than before the command, so the
1110
+ * thing that just ran is the version that was asked for and the new one takes
1111
+ * over next time. Failing is not worth a red cross on an otherwise successful
1112
+ * deploy: it says so quietly and the tool goes on working. */
1113
+ async function afterwards(newer) {
1114
+ if (!newer) return;
1115
+ const prefs = await settings().catch(() => DEFAULTS);
1116
+ /* Only automatic acts here. Notifying already happened, before the command,
1117
+ which is where it is worth reading. */
1118
+ if (prefs.updates !== "auto") return;
1119
+
1120
+ const { installed, installVersion, writable } = await import("./lib/selfupdate.mjs");
1121
+ /* Never over an install we did not make, and never without somebody there:
1122
+ a pipeline moving itself to a version nobody pinned is the failure the
1123
+ note in lib/update.mjs is about. */
1124
+ if (!installed() || !await writable()) return tellAboutUpdate(newer, VERSION);
1125
+
1126
+ try {
1127
+ await installVersion(newer);
1128
+ out("");
1129
+ info(`Updated to ${c.bold(newer)}. It takes effect on the next command.`);
1130
+ } catch {
1131
+ tellAboutUpdate(newer, VERSION);
1132
+ }
1133
+ }