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/README.md +88 -0
- package/aloic.mjs +1133 -0
- package/github-action.yml +62 -0
- package/lib/api.mjs +308 -0
- package/lib/config.mjs +97 -0
- package/lib/files.mjs +92 -0
- package/lib/login.mjs +135 -0
- package/lib/selfupdate.mjs +136 -0
- package/lib/ui.mjs +308 -0
- package/lib/update.mjs +101 -0
- package/package.json +34 -0
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
|
+
}
|