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/lib/login.mjs ADDED
@@ -0,0 +1,135 @@
1
+ /* Signing a terminal in.
2
+ *
3
+ * THE DEVICE AUTHORIZATION SHAPE, and it is the only one that fits. A terminal
4
+ * cannot receive an email, and asking somebody to paste an account password
5
+ * into a command is how passwords end up in shell history. So the tool makes a
6
+ * request, opens a browser at it, and waits; the browser is already signed in,
7
+ * shows what is being asked for in words, and approves.
8
+ *
9
+ * THE TERMINAL HOLDS A SECRET THE BROWSER NEVER SEES. The request carries only
10
+ * the hash of it. That is what stops somebody who reads the link over your
11
+ * shoulder from collecting the key: they can approve the request, and the key
12
+ * still goes only to the process that can prove it made it.
13
+ *
14
+ * NO LOCAL WEB SERVER, and that is worth saying because it is the usual way to
15
+ * do this. A callback server means binding a port, which fails on locked down
16
+ * machines, breaks over SSH, and puts the tool's security on a socket anyone
17
+ * on the box can talk to. Polling an endpoint has none of those problems and
18
+ * works identically over SSH, in a container, and on a laptop. */
19
+
20
+ import { randomBytes, createHash } from "node:crypto";
21
+ import { spawn } from "node:child_process";
22
+ import { API, PROJECT } from "./api.mjs";
23
+ import { deviceName } from "./config.mjs";
24
+
25
+ const FS = `https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/(default)/documents`;
26
+
27
+ const rand = n => randomBytes(n).toString("base64url").slice(0, n);
28
+ const sha256 = s => createHash("sha256").update(s).digest("hex");
29
+
30
+ /* The browser that will do the approving. Opened rather than printed when
31
+ there is one, printed as well as opened always: a link that only exists
32
+ inside a window that failed to open is a dead end, and this runs over SSH
33
+ often enough for that to matter. */
34
+ export function openBrowser(url) {
35
+ const cmd = process.platform === "darwin" ? "open"
36
+ : process.platform === "win32" ? "cmd" : "xdg-open";
37
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
38
+ try {
39
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
40
+ child.on("error", () => {});
41
+ child.unref();
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export async function startLogin(web) {
49
+ const rid = rand(24);
50
+ const token = rand(32);
51
+ const expiresAt = Date.now() + 10 * 60_000;
52
+
53
+ /* Created unauthenticated, which the rules allow because the document is
54
+ worth nothing on its own: a device name, the hash of a secret, and an
55
+ expiry. See cliRequests in firestore.rules. */
56
+ const r = await fetch(`${FS}/cliRequests?documentId=${rid}`, {
57
+ method: "POST",
58
+ headers: { "content-type": "application/json" },
59
+ body: JSON.stringify({
60
+ fields: {
61
+ tokenHash: { stringValue: sha256(token) },
62
+ device: { stringValue: deviceName() },
63
+ createdAt: { integerValue: String(Date.now()) },
64
+ expiresAt: { integerValue: String(expiresAt) }
65
+ }
66
+ })
67
+ });
68
+ if (!r.ok) {
69
+ const body = await r.text();
70
+ throw new Error(`Could not start sign in. ${body.slice(0, 200)}`);
71
+ }
72
+
73
+ /* THROUGH THE CONSOLE, NOT STRAIGHT AT THE APPROVAL PAGE.
74
+ Setting a terminal up is an authenticated act, and /dashboard is the door
75
+ that establishes a real session: arriving there signed out runs the
76
+ ordinary sign-in and comes back here, so by the time the approval screen
77
+ appears the browser is signed in exactly as it is for everything else.
78
+ Pointing at /auth directly skipped that and made this the one flow in the
79
+ product with its own idea of what being signed in means. */
80
+ return { rid, token, expiresAt, url: `${web}/dashboard/cli?code=${rid}` };
81
+ }
82
+
83
+ /* Asked for repeatedly until somebody approves it, which is what the 202 is
84
+ for: waiting is the expected answer, not a failure, and it must not read
85
+ like one in a log. */
86
+ export async function awaitApproval({ rid, token, expiresAt }, onTick) {
87
+ for (;;) {
88
+ if (Date.now() > expiresAt) throw new Error("That sign in expired. Run it again.");
89
+ const r = await fetch(`${API}/api/signin/token`, {
90
+ method: "POST",
91
+ headers: { "content-type": "application/json" },
92
+ body: JSON.stringify({ cli: rid, token })
93
+ });
94
+ if (r.status === 200) {
95
+ const got = await r.json();
96
+ /* Refused in the browser. Thrown with a name the caller can recognise,
97
+ because "cancelled" deserves different words and different choices
98
+ from "the network failed". */
99
+ if (got.status === "denied") {
100
+ /* CLOSING THE TAB IS NOT THE SAME AS PRESSING CANCEL. One is a
101
+ decision and the other is usually an accident, and the way back
102
+ from them is different: a decision deserves a menu, a closed tab
103
+ deserves the setup opening again. */
104
+ const no = new Error(got.closed
105
+ ? "The setup tab was closed."
106
+ : "Authentication cancelled.");
107
+ no.cancelled = true;
108
+ no.closed = got.closed === true;
109
+ throw no;
110
+ }
111
+ return got;
112
+ }
113
+ if (r.status === 202) {
114
+ /* WHICH KIND OF WAITING. "waiting" is nobody has looked at the browser
115
+ yet; "setup" is they approved it and are choosing how the tool should
116
+ work. The caller shows a different line for each, because a spinner
117
+ that says the same thing through both is a spinner that never
118
+ acknowledged the thing the person just did. */
119
+ const st = await r.json().then(b => b?.status, () => null);
120
+ onTick?.(st || "waiting");
121
+ /* Two seconds. Fast enough that approving feels immediate, slow enough
122
+ that a browser left open for ten minutes is three hundred requests
123
+ rather than thirty thousand. */
124
+ await new Promise(s => setTimeout(s, 2000));
125
+ continue;
126
+ }
127
+ const body = await r.json().catch(() => ({}));
128
+ const why = {
129
+ expired: "That sign in expired. Run it again.",
130
+ "not-yours": "That request belongs to another terminal.",
131
+ unknown: "That request no longer exists. Run it again."
132
+ }[body.error] || `Sign in failed (${r.status}).`;
133
+ throw new Error(why);
134
+ }
135
+ }
@@ -0,0 +1,136 @@
1
+ /* Replacing this tool with a newer copy of itself.
2
+ *
3
+ * WHY THIS IS ALLOWED TO EXIST NOW. The note next door in update.mjs says a
4
+ * deploy tool that changes its own version by itself cannot be reasoned about,
5
+ * and that is still true of a tool that does it without being asked. What
6
+ * changed is that it is a setting somebody chose during setup, and it is only
7
+ * ever honoured where the answer is obviously safe:
8
+ *
9
+ * NEVER IN A PIPELINE. No terminal means a build machine, and a build
10
+ * machine that quietly moves to a version nobody pinned is the exact failure
11
+ * the old note was written about.
12
+ *
13
+ * NEVER MID-COMMAND. It runs after the work is finished, so the deploy that
14
+ * just happened was done by the version that was asked for, and the new one
15
+ * takes over on the next run.
16
+ *
17
+ * NEVER OVER SOMEBODY ELSE'S INSTALL. If these files are not under
18
+ * ~/.aloic/versions then something else owns them, npm or a checkout, and
19
+ * quietly writing into it would be a worse bug than being out of date.
20
+ *
21
+ * The mechanics are exactly what install.sh does, in Node: read the manifest,
22
+ * fetch each file, check it against its published hash, and only once every
23
+ * one of them is verified move the whole directory into place and swing the
24
+ * `current` link at it. A failure at any point leaves the version that is
25
+ * running exactly where it was. */
26
+
27
+ import { createHash } from "node:crypto";
28
+ import { mkdir, rm, writeFile, symlink, unlink, stat } from "node:fs/promises";
29
+ import { homedir } from "node:os";
30
+ import { dirname, join, resolve, sep } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+
33
+ export const GET = process.env.ALOIC_GET || "https://get.aloic.ai";
34
+
35
+ const dir = () => process.env.ALOIC_HOME || join(homedir(), ".aloic");
36
+
37
+ /* Whether this copy is one the installer put here, which is the only kind we
38
+ are entitled to replace. */
39
+ export function installed() {
40
+ try {
41
+ const here = resolve(fileURLToPath(import.meta.url), "..", "..");
42
+ return here.startsWith(resolve(dir(), "versions") + sep);
43
+ } catch { return false; }
44
+ }
45
+
46
+ const sha256 = buf => createHash("sha256").update(buf).digest("hex");
47
+
48
+ /* Fetch, verify, stage, swap. Returns the version installed. */
49
+ export async function installVersion(version) {
50
+ const r = await fetch(`${GET}/manifest.json`, { signal: AbortSignal.timeout(10_000) });
51
+ if (!r.ok) throw new Error("Could not read the release manifest.");
52
+ const man = await r.json();
53
+ if (man?.version !== version) {
54
+ throw new Error(`${version} is not the published release (${man?.version}).`);
55
+ }
56
+ if (!Array.isArray(man.files) || !man.files.length) {
57
+ throw new Error("The release manifest is empty.");
58
+ }
59
+
60
+ const stage = join(dir(), `.staging-${process.pid}`);
61
+ await rm(stage, { recursive: true, force: true });
62
+ await mkdir(stage, { recursive: true });
63
+
64
+ try {
65
+ for (const f of man.files) {
66
+ if (typeof f?.path !== "string" || !/^[a-zA-Z0-9._/-]+$/.test(f.path)
67
+ || f.path.includes("..")) {
68
+ throw new Error("The release manifest names a file it should not.");
69
+ }
70
+ const hit = await fetch(`${GET}/${version}/${f.path}`,
71
+ { signal: AbortSignal.timeout(20_000) });
72
+ if (!hit.ok) throw new Error(`Could not download ${f.path}.`);
73
+ const body = Buffer.from(await hit.arrayBuffer());
74
+ if (sha256(body) !== f.sha256) {
75
+ throw new Error(`${f.path} did not match its published hash.`);
76
+ }
77
+ const at = join(stage, f.path);
78
+ await mkdir(dirname(at), { recursive: true });
79
+ await writeFile(at, body);
80
+ }
81
+
82
+ const target = join(dir(), "versions", version);
83
+ await mkdir(join(dir(), "versions"), { recursive: true });
84
+ await rm(target, { recursive: true, force: true });
85
+ /* The move is the moment it becomes real, and it happens only after every
86
+ byte has been checked. */
87
+ const { rename } = await import("node:fs/promises");
88
+ await rename(stage, target);
89
+
90
+ /* THE LINK IS SWUNG LAST. The launcher runs whatever `current` points at,
91
+ so until this line the running version is still the one on disk and a
92
+ failure above changes nothing at all. */
93
+ const link = join(dir(), "current");
94
+ await unlink(link).catch(() => {});
95
+ await symlink(target, link);
96
+ return version;
97
+ } finally {
98
+ await rm(stage, { recursive: true, force: true }).catch(() => {});
99
+ }
100
+ }
101
+
102
+ /* What the installer published, or null if it cannot be reached. */
103
+ export async function latest() {
104
+ try {
105
+ const r = await fetch(`${GET}/latest`, { signal: AbortSignal.timeout(4000) });
106
+ if (!r.ok) return null;
107
+ const v = (await r.text()).trim();
108
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
109
+ } catch { return null; }
110
+ }
111
+
112
+ /* Whether the files sitting in `current` are byte for byte the ones the
113
+ manifest publishes. Cheap: a dozen small files hashed locally, one request.
114
+ Any missing or altered file answers false, which is the honest answer for
115
+ "is this install the release it claims to be". */
116
+ export async function matches(version) {
117
+ try {
118
+ const r = await fetch(`${GET}/manifest.json`, { signal: AbortSignal.timeout(8000) });
119
+ if (!r.ok) return true; /* cannot tell, so do not churn */
120
+ const man = await r.json();
121
+ if (man?.version !== version || !Array.isArray(man.files)) return true;
122
+ const at = join(dir(), "current");
123
+ const { readFile } = await import("node:fs/promises");
124
+ for (const f of man.files) {
125
+ const body = await readFile(join(at, f.path)).catch(() => null);
126
+ if (!body || sha256(body) !== f.sha256) return false;
127
+ }
128
+ return true;
129
+ } catch { return true; }
130
+ }
131
+
132
+ /* Whether the directory we would write into is actually ours to write into. */
133
+ export async function writable() {
134
+ try { await stat(join(dir(), "versions")); return true; }
135
+ catch { return false; }
136
+ }
package/lib/ui.mjs ADDED
@@ -0,0 +1,308 @@
1
+ /* The parts of a terminal that make a tool feel like a tool.
2
+ *
3
+ * NO DEPENDENCIES HERE EITHER. Every one of these is thirty lines of escape
4
+ * codes that have worked since the 1970s, and taking a package for them means
5
+ * a deploy tool that stops working when that package does.
6
+ *
7
+ * EVERYTHING DEGRADES. A pipe is not a terminal: there is no cursor to move,
8
+ * no colour worth printing and nobody to press a key. Each of these checks and
9
+ * falls back to plain lines, because the same command runs on a laptop and
10
+ * inside a build machine and only one of those has a person watching. */
11
+
12
+ import { stdin, stdout } from "node:process";
13
+
14
+ const ESC = "";
15
+ export const tty = () => !!stdout.isTTY && !!stdin.isTTY;
16
+
17
+ /* Colour, unless something says otherwise. NO_COLOR is honoured because it is
18
+ the one convention every tool agrees on, and FORCE_COLOR because CI logs are
19
+ often colour capable while failing every other test for it. */
20
+ const plain = !!process.env.NO_COLOR
21
+ || (!stdout.isTTY && process.env.FORCE_COLOR !== "1");
22
+ const wrap = (a, b) => s => (plain ? String(s) : `${ESC}[${a}m${s}${ESC}[${b}m`);
23
+ export const c = {
24
+ bold: wrap(1, 22), dim: wrap(2, 22), under: wrap(4, 24),
25
+ red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39),
26
+ blue: wrap(34, 39), grey: wrap(90, 39), cyan: wrap(36, 39)
27
+ };
28
+
29
+ export const out = s => stdout.write(s + "\n");
30
+
31
+ /* A step with a mark in front of it, so a transcript can be skimmed for the
32
+ line that went wrong rather than read. */
33
+ export const ok = s => out(`${c.green("✓")} ${s}`);
34
+ export const bad = s => out(`${c.red("✗")} ${s}`);
35
+ export const info = s => out(`${c.grey("·")} ${s}`);
36
+
37
+ /* A spinner that knows it might not be watched. In a pipe it prints the label
38
+ once and returns a no-op, so a build log gets one line instead of two
39
+ hundred frames of animation. */
40
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼",
41
+ "⠴", "⠦", "⠧", "⠇", "⠏"];
42
+ export function spin(label, note = "") {
43
+ if (!tty()) {
44
+ info(label);
45
+ if (note) info(note);
46
+ return { stop(final) { if (final) out(final); }, set() {}, say(n) { if (n) info(n); } };
47
+ }
48
+ let i = 0, text = label, under = note;
49
+
50
+ /* A SECOND LINE UNDER THE RING, WITHOUT LOSING THE RING'S LINE.
51
+ *
52
+ * The spinner redraws itself with a carriage return, which only ever reaches
53
+ * the line the cursor is on, so anything printed after it would be the line
54
+ * the next frame overwrites. Drawing the note and then stepping back up one
55
+ * row leaves the cursor where the ring lives: the note is written once per
56
+ * frame and never moves, and the ring goes on turning above it.
57
+ *
58
+ * This exists because a spinner alone cannot say why it is spinning. Waiting
59
+ * on somebody in a browser is a wait with an instruction attached, and the
60
+ * instruction has to be visible for the whole of it. */
61
+ const clear = () => {
62
+ stdout.write(`\r${ESC}[2K`);
63
+ if (under) stdout.write(`\n${ESC}[2K${ESC}[1A`);
64
+ };
65
+ const draw = () => {
66
+ stdout.write(`\r${ESC}[2K${c.cyan(FRAMES[i++ % FRAMES.length])} ${text}`);
67
+ if (under) stdout.write(`\n${ESC}[2K${c.grey(under)}${ESC}[1A`);
68
+ };
69
+ stdout.write(`${ESC}[?25l`);
70
+ draw();
71
+ const t = setInterval(draw, 80);
72
+ return {
73
+ set(s) { text = s; },
74
+ /* Added or changed while it is running: the wait changes character when
75
+ the browser hands over, and so does what somebody should be doing. */
76
+ say(n) {
77
+ /* Taking a note away has to erase the row it was on before the cursor
78
+ stops visiting it. */
79
+ if (under && !n) { stdout.write(`\n${ESC}[2K${ESC}[1A`); }
80
+ under = n || "";
81
+ },
82
+ stop(final) {
83
+ clearInterval(t);
84
+ clear();
85
+ stdout.write(`${ESC}[?25h`);
86
+ if (final) out(final);
87
+ }
88
+ };
89
+ }
90
+
91
+ /* A progress bar for the one part of a deploy that takes real time. */
92
+ export function bar(done, total, width = 22) {
93
+ const filled = total ? Math.round((done / total) * width) : width;
94
+ return c.grey("[") + c.cyan("█".repeat(filled))
95
+ + c.grey("░".repeat(Math.max(0, width - filled))) + c.grey("]");
96
+ }
97
+
98
+ /* ---------- a box ----------
99
+ *
100
+ * WHY A BOX AT ALL, when every other line this tool prints is a mark and a
101
+ * sentence. Because this one is not about what the command is doing. Every
102
+ * other line belongs to the deploy somebody asked for; the update notice
103
+ * interrupts it to talk about the tool itself, and a line that reads like
104
+ * output but is not output is the kind of thing people learn to skim past.
105
+ * A frame says "this is an aside" before a word of it has been read, and it
106
+ * says so in one glance rather than one sentence.
107
+ *
108
+ * MEASURED WITHOUT THE COLOUR. The whole thing is alignment, and a colour code
109
+ * is several invisible bytes that String.length counts anyway: measure the
110
+ * painted string and every right hand edge lands short by however much colour
111
+ * that line happened to use.
112
+ *
113
+ * IT GIVES UP RATHER THAN WRAP. A frame narrower than the thing inside it is
114
+ * worse than no frame, so in a small terminal the lines are simply printed. */
115
+
116
+ const CODES = new RegExp(ESC + "\\[[0-9;]*m", "g");
117
+ export const wide = s => String(s).replace(CODES, "").length;
118
+
119
+ export function box(lines, { title = "", tone = "yellow", pad = 2 } = {}) {
120
+ const paint = c[tone] || (s => s);
121
+ const rule = n => "─".repeat(Math.max(0, n));
122
+ const inner = Math.max(wide(title) + 6, ...lines.map(l => wide(l) + pad * 2));
123
+
124
+ /* Not enough room to draw one honestly, so do not draw one. */
125
+ if ((stdout.columns || 80) < inner + 3) {
126
+ if (title) out(c.bold(paint(title)));
127
+ lines.filter(l => wide(l)).forEach(l => out(` ${l}`));
128
+ return;
129
+ }
130
+
131
+ /* The title sits in the top edge rather than on a line of its own: it is two
132
+ words, and a whole row for two words is a taller box saying no more. */
133
+ out(title
134
+ ? paint("╭─ ") + c.bold(paint(title)) + " "
135
+ + paint(rule(inner - 3 - wide(title)) + "╮")
136
+ : paint("╭" + rule(inner) + "╮"));
137
+
138
+ for (const line of lines) {
139
+ out(paint("│") + " ".repeat(pad) + line
140
+ + " ".repeat(inner - pad - wide(line)) + paint("│"));
141
+ }
142
+
143
+ out(paint("╰" + rule(inner) + "╯"));
144
+ }
145
+
146
+ /* ---------- asking ---------- */
147
+
148
+ const readKey = () => new Promise(resolve => {
149
+ const onData = buf => {
150
+ stdin.removeListener("data", onData);
151
+ stdin.setRawMode(false);
152
+ stdin.pause();
153
+ resolve(buf.toString());
154
+ };
155
+ stdin.resume();
156
+ stdin.setRawMode(true);
157
+ stdin.once("data", onData);
158
+ });
159
+
160
+ /* PICK ONE, WITH ARROW KEYS, which is what makes a setup feel like a program
161
+ rather than a form. It falls back to a numbered list when there is no
162
+ terminal to drive, because the same command runs unattended.
163
+ *
164
+ * The list is redrawn in place rather than reprinted, and only a window of it
165
+ * is drawn at all, so somebody with forty projects does not lose their shell
166
+ * history to a menu. */
167
+ export async function pick(title, items, render = String, nudge = null) {
168
+ if (!items.length) throw new Error("There is nothing to choose from.");
169
+ if (items.length === 1) {
170
+ out(`${title} ${c.cyan(render(items[0]))}`);
171
+ return items[0];
172
+ }
173
+
174
+ if (!tty()) {
175
+ /* THE ONE MESSAGE THAT FIRES EXACTLY WHEN SOMEBODY IS STUCK, so it names
176
+ the flag and shows it being used with a real value from the list above.
177
+ It used to say "name the project as an argument", which reads as a
178
+ positional and is not: following it literally fails with this same
179
+ message and no new information, which is a dead end with a signpost. */
180
+ out(title);
181
+ items.forEach(it => out(` ${render(it)}`));
182
+ throw new Error(nudge ? nudge(items) : "No terminal to choose from.");
183
+ }
184
+
185
+ const WINDOW = Math.min(items.length, 8);
186
+ let at = 0, top = 0;
187
+
188
+ const draw = first => {
189
+ if (!first) stdout.write(`${ESC}[${WINDOW + 1}A`);
190
+ stdout.write(`${ESC}[2K${title}\n`);
191
+ for (let r = 0; r < WINDOW; r++) {
192
+ const i = top + r;
193
+ const on = i === at;
194
+ const line = i < items.length
195
+ ? `${on ? c.cyan("❯") : " "} ${on ? c.bold(render(items[i])) : render(items[i])}`
196
+ : "";
197
+ stdout.write(`${ESC}[2K${line}\n`);
198
+ }
199
+ };
200
+
201
+ stdout.write(`${ESC}[?25l`);
202
+ draw(true);
203
+ for (;;) {
204
+ const k = await readKey();
205
+ /* Ctrl-C, by hand: raw mode swallows the signal, so a tool that reads keys
206
+ has to honour it itself or it cannot be quit. */
207
+ if (k === "") { stdout.write(`${ESC}[?25h`); out(""); process.exit(130); }
208
+ if (k === "\r" || k === "\n") break;
209
+ if (k === `${ESC}[A` || k === "k") at = (at - 1 + items.length) % items.length;
210
+ else if (k === `${ESC}[B` || k === "j") at = (at + 1) % items.length;
211
+ else continue;
212
+ if (at < top) top = at;
213
+ if (at >= top + WINDOW) top = at - WINDOW + 1;
214
+ if (at === 0) top = 0;
215
+ if (at === items.length - 1) top = Math.max(0, items.length - WINDOW);
216
+ draw(false);
217
+ }
218
+ stdout.write(`${ESC}[?25h`);
219
+
220
+ /* Leave the ANSWER on screen and take the menu away. A finished prompt that
221
+ is still a menu is a transcript nobody can read afterwards. */
222
+ stdout.write(`${ESC}[${WINDOW + 1}A${ESC}[2K${title} ${c.cyan(render(items[at]))}\n`);
223
+ for (let r = 0; r < WINDOW; r++) stdout.write(`${ESC}[2K\n`);
224
+ stdout.write(`${ESC}[${WINDOW}A`);
225
+ return items[at];
226
+ }
227
+
228
+ export async function confirm(question, yes = true) {
229
+ if (!tty()) return yes;
230
+ stdout.write(`${question} ${c.grey(yes ? "[Y/n]" : "[y/N]")} `);
231
+ for (;;) {
232
+ const k = await readKey();
233
+
234
+ /* THE FIRST CHARACTER THAT MEANS ANYTHING, not the first byte.
235
+ *
236
+ * This tested `k.toLowerCase().startsWith("y")`, and a keypress does not
237
+ * reliably begin with the letter: a terminal can put an escape sequence in
238
+ * front of it, a paste arrives wrapped in bracketed-paste markers, and a
239
+ * pty can deliver a control byte glued to the character behind it. Every
240
+ * one of those read as "neither yes nor no" and fell through to the
241
+ * default, so on a question about deleting things somebody could type y,
242
+ * watch it print "no", and be told nothing was removed. It is worth being
243
+ * careful here in both directions: the same fall-through on a [Y/n]
244
+ * question would have taken yes from noise.
245
+ *
246
+ * Control characters out, whitespace off, then look. */
247
+ const said = k.replace(/[\u0000-\u001f\u007f]/g, "").trim().toLowerCase();
248
+
249
+ if (said.startsWith("y")) { out("yes"); return true; }
250
+ if (said.startsWith("n")) { out("no"); return false; }
251
+
252
+ /* Ctrl-C and Ctrl-D both mean stop, and raw mode swallows both, so a tool
253
+ that reads keys has to honour them itself or it cannot be quit. */
254
+ if (!said && /[\u0003\u0004]/.test(k)) { out(""); process.exit(130); }
255
+ /* Return on its own takes the default, which is exactly what the capital
256
+ letter in the prompt promises. */
257
+ if (!said && /[\r\n]/.test(k)) { out(yes ? "yes" : "no"); return yes; }
258
+ /* Anything else is asked again rather than guessed at. */
259
+ }
260
+ }
261
+
262
+ /* A line typed in, for the one question that cannot be a menu. Hidden while
263
+ typing, because the answer is a secret and a key echoed into a terminal is a
264
+ key in a screen recording. */
265
+ export async function secret(question) {
266
+ if (!tty()) throw new Error("No terminal to type into.");
267
+ stdout.write(question + " ");
268
+ stdin.resume();
269
+ stdin.setRawMode(true);
270
+ let buf = "";
271
+ for (;;) {
272
+ const k = await new Promise(r => stdin.once("data", d => r(d.toString())));
273
+ if (k === "\u0003") { stdin.setRawMode(false); stdin.pause(); out(""); process.exit(130); }
274
+ if (k === "\r" || k === "\n") break;
275
+ if (k === "\u007f" || k === "\b") { buf = buf.slice(0, -1); continue; }
276
+ /* Anything that is not a printable run is an arrow key or worse. */
277
+ if (/^[\x20-\x7e]+$/.test(k)) buf += k;
278
+ }
279
+ stdin.setRawMode(false);
280
+ stdin.pause();
281
+ out("");
282
+ return buf.trim();
283
+ }
284
+
285
+ /* A line of ordinary text, echoed as it is typed. The counterpart to secret()
286
+ for answers that are not secrets. */
287
+ export async function ask(question, hint) {
288
+ if (!tty()) return "";
289
+ if (hint) out(` ${c.grey(hint)}`);
290
+ stdout.write(question + " ");
291
+ stdin.resume();
292
+ stdin.setRawMode(true);
293
+ let buf = "";
294
+ for (;;) {
295
+ const k = await new Promise(r => stdin.once("data", d => r(d.toString())));
296
+ if (k === "\u0003") { stdin.setRawMode(false); stdin.pause(); out(""); process.exit(130); }
297
+ if (k === "\r" || k === "\n") break;
298
+ if (k === "\u007f" || k === "\b") {
299
+ if (buf) { buf = buf.slice(0, -1); stdout.write("\b \b"); }
300
+ continue;
301
+ }
302
+ if (/^[\x20-\x7e]+$/.test(k)) { buf += k; stdout.write(k); }
303
+ }
304
+ stdin.setRawMode(false);
305
+ stdin.pause();
306
+ out("");
307
+ return buf.trim();
308
+ }
package/lib/update.mjs ADDED
@@ -0,0 +1,101 @@
1
+ /* Telling somebody a newer version exists.
2
+ *
3
+ * QUIETLY, ONCE A DAY, AND NEVER IN THE WAY. An update notice is the smallest
4
+ * feature in a tool and the easiest one to make hateful: check on every run and
5
+ * you have added a network round trip to a deploy; check loudly and you have
6
+ * put a banner between somebody and the output they ran the command for; check
7
+ * blocking and one slow registry response makes the whole tool feel broken.
8
+ *
9
+ * So: at most once a day, after the work is done, on a socket that is allowed
10
+ * to fail, and never at all when nobody is watching. A build machine has no use
11
+ * for this and its logs should not carry it.
12
+ *
13
+ * IT NEVER UPDATES ANYTHING BY ITSELF. A deploy tool that changes its own
14
+ * version mid-pipeline is a deploy tool that cannot be reasoned about. It says
15
+ * what to run and leaves the decision where it belongs. */
16
+
17
+ import { homedir } from "node:os";
18
+ import { resolve, sep } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { saveConfig } from "./config.mjs";
21
+ import { box, c, out, tty } from "./ui.mjs";
22
+
23
+ /* WHERE THE RELEASES ACTUALLY ARE. This asked the npm registry, and there is
24
+ no `aloic` package on it: the check ran, quietly found nothing, and reported
25
+ nothing, forever. The releases are hosted by us at get.aloic.ai, which is
26
+ also what the one line installer reads, so the tool and the installer agree
27
+ about what the current version is by reading the same file. */
28
+ const GET = process.env.ALOIC_GET || "https://get.aloic.ai";
29
+
30
+ /* HOW THIS COPY GOT HERE, so the suggestion is the one that will work.
31
+ Installed by the script, the files live under ~/.aloic/versions and the way
32
+ to upgrade is to run it again. Anywhere else it came through npm. */
33
+ function viaInstaller() {
34
+ try {
35
+ const here = resolve(fileURLToPath(import.meta.url), "..", "..");
36
+ return here.startsWith(resolve(homedir(), ".aloic") + sep);
37
+ } catch { return false; }
38
+ }
39
+
40
+ /* Newer, by the ordinary three number comparison, ignoring any prerelease
41
+ suffix: somebody on a release candidate asked for it and does not need to be
42
+ told about the stable one. */
43
+ function newer(a, b) {
44
+ const nums = v => String(v).split("-")[0].split(".").map(n => parseInt(n, 10) || 0);
45
+ const [x, y] = [nums(a), nums(b)];
46
+ for (let i = 0; i < 3; i++) {
47
+ if ((x[i] || 0) > (y[i] || 0)) return true;
48
+ if ((x[i] || 0) < (y[i] || 0)) return false;
49
+ }
50
+ return false;
51
+ }
52
+
53
+ export async function checkForUpdate(version) {
54
+ if (!tty() || process.env.ALOIC_NO_UPDATE_CHECK) return null;
55
+ try {
56
+ /* EVERY TIME, NOT ONCE A DAY.
57
+ *
58
+ * This was cached for twenty-four hours, which is the right shape for a
59
+ * notice printed after the work and the wrong one for a notice printed
60
+ * before it: somebody who updates and then runs a command should not be
61
+ * told for the rest of the day that they are out of date, and somebody on
62
+ * a build from last week should be told on the first command rather than
63
+ * whenever the cache happens to lapse.
64
+ *
65
+ * It costs one request for a file of eight bytes, and the whole thing is
66
+ * abandoned after two seconds. `checkedAt` and `latest` are still written
67
+ * so anything reading them keeps working. */
68
+ const r = await fetch(`${GET}/latest`, { signal: AbortSignal.timeout(2000) });
69
+ if (!r.ok) return null;
70
+ const latest = (await r.text()).trim();
71
+ if (!/^\d+\.\d+\.\d+/.test(latest)) return null;
72
+ void saveConfig({ checkedAt: Date.now(), latest }).catch(() => {});
73
+ return newer(latest, version) ? latest : null;
74
+ } catch {
75
+ /* Never a reason to say anything. */
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /* THE NOTICE ITSELF, IN A FRAME.
81
+ *
82
+ * It is the one thing this tool prints that is not about the command somebody
83
+ * ran, and it used to be a grey line among the grey lines of a deploy, which
84
+ * is where a notice goes to be scrolled past. See the note on box() for why a
85
+ * border does the work here that another sentence could not.
86
+ *
87
+ * WHAT IT TELLS SOMEBODY TO RUN DEPENDS ON HOW THEY GOT IT. `aloic update`
88
+ * only replaces an install this tool made; over an npm copy or a checkout it
89
+ * has nothing to write to, so those are told the command that will actually
90
+ * work rather than the one that reads best. */
91
+ export function tellAboutUpdate(latest, version, how = null) {
92
+ if (!latest) return;
93
+ const run = how || (viaInstaller()
94
+ ? "curl -fsSL https://get.aloic.ai | sh"
95
+ : "npm i -g aloic");
96
+ out("");
97
+ box([
98
+ `${c.grey(version)} ${c.grey("\u2192")} ${c.bold(c.yellow(latest))}`,
99
+ `Run ${c.bold(c.cyan(run))} to install it.`
100
+ ], { title: "Update available", tone: "yellow" });
101
+ }