@aloud/runner 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@aloud/runner",
3
+ "version": "0.2.0",
4
+ "description": "Run Aloud usability studies in a real browser on your own machine, so a study can reach localhost and anything else behind your network.",
5
+ "license": "ISC",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dfala/user-testing-ai.git",
9
+ "directory": "packages/runner"
10
+ },
11
+ "homepage": "https://usealoud.com",
12
+ "keywords": ["usability", "testing", "playwright", "aloud"],
13
+ "type": "module",
14
+ "main": "./src/index.ts",
15
+ "types": "./src/index.ts",
16
+ "exports": {
17
+ ".": "./src/index.ts",
18
+ "./*": "./src/*.ts"
19
+ },
20
+ "bin": { "aloud": "./dist/cli.js" },
21
+ "files": ["dist/cli.js", "src", "README.md"],
22
+ "engines": { "node": ">=20" },
23
+ "scripts": {
24
+ "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --external:playwright --external:sharp --outfile=dist/cli.js",
25
+ "prepack": "npm run build"
26
+ },
27
+ "dependencies": {
28
+ "playwright": "^1.62.1",
29
+ "sharp": "^0.35.3"
30
+ },
31
+ "devDependencies": {
32
+ "@aloud/core": "*",
33
+ "@aloud/engine": "*",
34
+ "@aloud/eval": "*",
35
+ "esbuild": "^0.28.2"
36
+ }
37
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `aloud` - the local runner.
4
+ *
5
+ * The browsers run on this machine, which is what makes testing localhost and anything behind a
6
+ * firewall possible. The connection is outbound only: nothing here listens on a port, and the
7
+ * server holds no address for this machine.
8
+ */
9
+ import { createInterface } from "node:readline/promises";
10
+ import { readFileSync, unlinkSync } from "node:fs";
11
+ import { hostname } from "node:os";
12
+ import { normaliseHosts } from "@aloud/core";
13
+ import {
14
+ CredentialsError,
15
+ clearCredentials,
16
+ credentialsFromEnv,
17
+ credentialsPath,
18
+ readCredentials,
19
+ writeCredentials,
20
+ type Credentials,
21
+ } from "./config/credentials";
22
+ import { policyFrom, type LocalPolicy } from "./config/policy";
23
+ import { clearRunning, readRunning, runningPath, writeRunning } from "./config/running";
24
+ import { RunnerClient } from "./protocol/client";
25
+ import { installChromium, preflight } from "./preflight";
26
+ import { TerminalReporter } from "./ui/output";
27
+ import { runLoop } from "./loop";
28
+
29
+ /** Where a runner reaches the hosted control plane. Overridable for self-hosting and for tests. */
30
+ const DEFAULT_SERVER = process.env.ALOUD_SERVER?.replace(/\/+$/, "") || "https://usealoud.com";
31
+
32
+ export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> {
33
+ const [command = "help", ...rest] = argv;
34
+
35
+ switch (command) {
36
+ case "login":
37
+ return login(rest);
38
+ case "logout":
39
+ return logout();
40
+ case "start":
41
+ return start(rest);
42
+ case "status":
43
+ return status();
44
+ case "allow":
45
+ return allow(rest);
46
+ case "help":
47
+ case "--help":
48
+ case "-h":
49
+ printHelp();
50
+ return 0;
51
+ default:
52
+ // Refuse rather than guess. An unrecognised command that might spend money is not a thing to
53
+ // be forgiving about.
54
+ process.stderr.write(`I do not know the command "${command}".\n\n`);
55
+ printHelp();
56
+ return 1;
57
+ }
58
+ }
59
+
60
+ function printHelp(): void {
61
+ process.stdout.write(
62
+ [
63
+ "",
64
+ "aloud - run usability studies on this machine",
65
+ "",
66
+ " aloud login [--token <token>] Connect this machine to your workspace",
67
+ " aloud start [--once] [--quiet] Wait for studies and run them here",
68
+ " aloud status What is set up, and whether it is running",
69
+ " aloud allow <host> Let studies open this host from this machine",
70
+ " aloud logout Forget the token on this machine",
71
+ "",
72
+ `Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
73
+ "",
74
+ ].join("\n"),
75
+ );
76
+ }
77
+
78
+ async function loadCredentials(): Promise<Credentials | null> {
79
+ return credentialsFromEnv() ?? (await readCredentials());
80
+ }
81
+
82
+ function policyOf(credentials: Credentials, argv: readonly string[]): LocalPolicy {
83
+ const concurrency = numberOption(argv, "--concurrency");
84
+ return policyFrom({
85
+ allowedHosts: credentials.allowedHosts,
86
+ allowPrivateNetwork: credentials.allowPrivateNetwork,
87
+ ...(concurrency !== null ? { maxConcurrentSessions: concurrency } : {}),
88
+ });
89
+ }
90
+
91
+ /* --------------------------------- login --------------------------------- */
92
+
93
+ async function login(argv: readonly string[]): Promise<number> {
94
+ const server = stringOption(argv, "--server") ?? DEFAULT_SERVER;
95
+ let token = stringOption(argv, "--token");
96
+
97
+ if (!token) {
98
+ process.stdout.write(`\nOpen ${server}/app/settings/runners and create a runner.\n`);
99
+ process.stdout.write("It shows you a token once. Paste it here.\n\n");
100
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
101
+ try {
102
+ token = (await rl.question("Token: ")).trim();
103
+ } finally {
104
+ rl.close();
105
+ }
106
+ }
107
+
108
+ if (!token?.startsWith("utar_")) {
109
+ process.stderr.write("That does not look like a runner token. They start with utar_.\n");
110
+ return 1;
111
+ }
112
+
113
+ interface Profile {
114
+ runnerId?: string;
115
+ runnerName?: string;
116
+ workspaceId?: string;
117
+ allowedHosts?: string[];
118
+ }
119
+
120
+ const client = new RunnerClient({ server, token });
121
+ let profile: Profile;
122
+ try {
123
+ const response = await client.request<Profile>("api/runner/me", { retry: false });
124
+ profile = response.body ?? {};
125
+ } catch (error) {
126
+ process.stderr.write(`That token did not work: ${(error as Error).message}\n`);
127
+ return 1;
128
+ }
129
+
130
+ const credentials: Credentials = {
131
+ server,
132
+ token,
133
+ runnerId: profile.runnerId ?? "",
134
+ runnerName: profile.runnerName || hostname(),
135
+ workspaceId: profile.workspaceId ?? "",
136
+ // Written from what the server says this runner was registered for, once, at login. Nothing
137
+ // the server sends afterwards edits this file.
138
+ allowedHosts: normaliseHosts(profile.allowedHosts ?? ["localhost"]),
139
+ allowPrivateNetwork: true,
140
+ };
141
+ await writeCredentials(credentials);
142
+
143
+ const reporter = new TerminalReporter(process.stdout, token);
144
+ process.stdout.write(`\nConnected as ${credentials.runnerName}.\n`);
145
+ process.stdout.write(`Allowed here: ${credentials.allowedHosts.join(", ")}\n`);
146
+ reporter.privacyNote();
147
+ process.stdout.write("Run `aloud start` and leave it running.\n\n");
148
+ return 0;
149
+ }
150
+
151
+ async function logout(): Promise<number> {
152
+ const removed = await clearCredentials();
153
+ process.stdout.write(removed ? `Forgot the token at ${credentialsPath()}.\n` : "There was nothing to forget.\n");
154
+ return 0;
155
+ }
156
+
157
+ /* --------------------------------- status --------------------------------- */
158
+
159
+ async function status(): Promise<number> {
160
+ const credentials = await loadCredentials().catch((error: Error) => {
161
+ process.stderr.write(error.message + "\n");
162
+ return null;
163
+ });
164
+ const checks = await preflight();
165
+ const running = await readRunning();
166
+
167
+ process.stdout.write("\n");
168
+ if (!credentials) {
169
+ process.stdout.write("Signed in no. Run `aloud login`.\n");
170
+ } else {
171
+ process.stdout.write(`Signed in ${credentials.runnerName}\n`);
172
+ process.stdout.write(`Server ${credentials.server}\n`);
173
+ process.stdout.write(`Allowed here ${credentials.allowedHosts.join(", ") || "nothing yet"}\n`);
174
+ }
175
+ process.stdout.write(
176
+ `Chromium ${checks.chromiumInstalled ? `ready (${checks.chromiumPath})` : "not installed yet"}\n`,
177
+ );
178
+ process.stdout.write(
179
+ `Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}\n`,
180
+ );
181
+ process.stdout.write("\n");
182
+
183
+ // Zero means "a study started now would run here", which is the only question worth asking of
184
+ // this command. Signed in and installed but stopped is not ready, and used to exit 0 anyway.
185
+ return credentials && checks.chromiumInstalled && running ? 0 : 1;
186
+ }
187
+
188
+ /* --------------------------------- allow --------------------------------- */
189
+
190
+ async function allow(argv: readonly string[]): Promise<number> {
191
+ const host = argv.find((arg) => !arg.startsWith("-"));
192
+ if (!host) {
193
+ process.stderr.write("Which host? For example: aloud allow staging.acme.com\n");
194
+ return 1;
195
+ }
196
+
197
+ const credentials = await readCredentials();
198
+ if (!credentials) {
199
+ process.stderr.write("Not signed in. Run `aloud login` first.\n");
200
+ return 1;
201
+ }
202
+
203
+ const next = normaliseHosts([...credentials.allowedHosts, host]);
204
+ await writeCredentials({ ...credentials, allowedHosts: next });
205
+ process.stdout.write(`\nStudies on this machine may now open: ${next.join(", ")}\n`);
206
+ process.stdout.write("Nothing the server sends can change that list. Only this command can.\n\n");
207
+ return 0;
208
+ }
209
+
210
+ /* --------------------------------- start --------------------------------- */
211
+
212
+ async function start(argv: readonly string[]): Promise<number> {
213
+ const credentials = await loadCredentials().catch((error: Error) => {
214
+ process.stderr.write(error.message + "\n");
215
+ return null;
216
+ });
217
+ if (!credentials) {
218
+ process.stderr.write("Not signed in. Run `aloud login` first.\n");
219
+ return 1;
220
+ }
221
+
222
+ const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
223
+
224
+ // Preflight runs before the first claim, deliberately. A missing Chromium makes
225
+ // `chromium.launch()` throw outside the session's own error handling, which takes the whole run
226
+ // down with a generic failure. That must not be anybody's first experience of the product.
227
+ const checks = await preflight();
228
+ if (!checks.chromiumInstalled) {
229
+ const installed = await installChromium((line) => process.stdout.write(line + "\n"));
230
+ if (!installed) {
231
+ process.stderr.write("\nCannot run studies without Chromium. Nothing was claimed.\n");
232
+ return 1;
233
+ }
234
+ }
235
+
236
+ const local = policyOf(credentials, argv);
237
+ if (local.allowedHosts.length === 0) {
238
+ process.stderr.write("This machine is not allowed to open anything. Try `aloud allow localhost`.\n");
239
+ return 1;
240
+ }
241
+
242
+ const client = new RunnerClient({
243
+ server: credentials.server,
244
+ token: credentials.token,
245
+ onConnectionChange: (state, detail) => reporter.connection(state, detail),
246
+ });
247
+
248
+ reporter.header({
249
+ runnerName: credentials.runnerName,
250
+ workspace: credentials.workspaceId || "your workspace",
251
+ server: credentials.server,
252
+ allowedHosts: local.allowedHosts,
253
+ chromium: true,
254
+ });
255
+
256
+ const controller = new AbortController();
257
+ installSignalHandlers(controller, reporter);
258
+
259
+ await writeRunning({ pid: process.pid, startedAt: new Date().toISOString(), server: credentials.server });
260
+ // The `finally` below covers every ordinary ending. This covers the second Ctrl-C, which calls
261
+ // `process.exit` and unwinds nothing. Synchronous, because an exit handler cannot await.
262
+ process.on("exit", () => clearRunningSync());
263
+
264
+ try {
265
+ await runLoop({
266
+ client,
267
+ local,
268
+ ui: reporter,
269
+ webUrl: `${credentials.server}/app`,
270
+ once: argv.includes("--once"),
271
+ signal: controller.signal,
272
+ });
273
+ return 0;
274
+ } catch (error) {
275
+ process.stderr.write(`\n${(error as Error).message}\n`);
276
+ return 1;
277
+ } finally {
278
+ await clearRunning();
279
+ }
280
+ }
281
+
282
+ /** The exit-handler twin of `clearRunning`, which cannot await anything. */
283
+ function clearRunningSync(): void {
284
+ try {
285
+ const path = runningPath();
286
+ const state = JSON.parse(readFileSync(path, "utf8")) as { pid?: number };
287
+ if (state.pid === process.pid) unlinkSync(path);
288
+ } catch {
289
+ // Nothing to clean up, or nothing we are allowed to clean up. Either way `status` checks the
290
+ // pid rather than trusting the file, so a leftover record is not a wrong answer.
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Ctrl-C, twice.
296
+ *
297
+ * The first asks the run to stop, so the lease is released and nobody is left waiting ninety
298
+ * seconds for a runner that is already gone. The second gives up on being polite. Playwright's own
299
+ * SIGINT handler is disabled at launch precisely so this one gets a chance to run at all.
300
+ */
301
+ function installSignalHandlers(controller: AbortController, reporter: TerminalReporter): void {
302
+ let asked = false;
303
+ const onSignal = () => {
304
+ if (asked) {
305
+ reporter.note("Stopping now. Some browser windows may need closing by hand.");
306
+ process.exit(130);
307
+ }
308
+ asked = true;
309
+ reporter.note("Stopping. Closing browsers and telling the server this study did not finish.");
310
+ controller.abort();
311
+ };
312
+ process.on("SIGINT", onSignal);
313
+ process.on("SIGTERM", onSignal);
314
+ }
315
+
316
+ /** A start time someone can compare against their own clock, not an ISO string. */
317
+ function when(iso: string): string {
318
+ const at = new Date(iso);
319
+ return Number.isNaN(at.getTime()) ? "an unknown time" : at.toLocaleTimeString();
320
+ }
321
+
322
+ function stringOption(argv: readonly string[], name: string): string | undefined {
323
+ const index = argv.indexOf(name);
324
+ if (index === -1) return undefined;
325
+ const value = argv[index + 1];
326
+ return value && !value.startsWith("-") ? value : undefined;
327
+ }
328
+
329
+ function numberOption(argv: readonly string[], name: string): number | null {
330
+ const raw = stringOption(argv, name);
331
+ if (raw === undefined) return null;
332
+ const value = Number(raw);
333
+ return Number.isFinite(value) ? value : null;
334
+ }
335
+
336
+ export { CredentialsError };
337
+
338
+ // Only when run directly, so importing this module from a test does not start a runner.
339
+ const entry = process.argv[1] ?? "";
340
+ if (entry.endsWith("cli.ts") || entry.endsWith("cli.js") || entry.endsWith("/aloud")) {
341
+ main().then(
342
+ (code) => process.exit(code),
343
+ (error: Error) => {
344
+ process.stderr.write("\n" + error.message + "\n");
345
+ process.exit(1);
346
+ },
347
+ );
348
+ }
@@ -0,0 +1,146 @@
1
+ import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ /**
7
+ * The runner's credentials, at ~/.aloud/credentials.json, mode 0600.
8
+ *
9
+ * One path on all three platforms rather than XDG on Linux, Application Support on macOS, and
10
+ * %APPDATA% on Windows. It is easier to explain, easier to tell someone to delete, and it is what
11
+ * most developer CLIs already do.
12
+ */
13
+ export interface Credentials {
14
+ /** Pinned. The token is never sent anywhere else, whatever a redirect suggests. */
15
+ server: string;
16
+ token: string;
17
+ runnerId: string;
18
+ runnerName: string;
19
+ workspaceId: string;
20
+ /** The local allowlist. Written at login from what the person chose; the server cannot edit it. */
21
+ allowedHosts: string[];
22
+ allowPrivateNetwork: boolean;
23
+ }
24
+
25
+ export class CredentialsError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "CredentialsError";
29
+ }
30
+ }
31
+
32
+ export function credentialsPath(home = homedir()): string {
33
+ return join(home, ".aloud", "credentials.json");
34
+ }
35
+
36
+ /**
37
+ * Reads the credentials, or returns null if there are none.
38
+ *
39
+ * Refuses a file other users can read. A token that grants a stranger the ability to run browsers
40
+ * on this machine should not be sitting at 0644 because an editor rewrote it.
41
+ */
42
+ export async function readCredentials(path = credentialsPath()): Promise<Credentials | null> {
43
+ let raw: string;
44
+ try {
45
+ const info = await stat(path);
46
+ // eslint-disable-next-line no-bitwise
47
+ if ((info.mode & 0o077) !== 0) {
48
+ throw new CredentialsError(
49
+ `${path} can be read by other users on this machine. Fix it with:\n chmod 600 ${path}`,
50
+ );
51
+ }
52
+ raw = await readFile(path, "utf8");
53
+ } catch (error) {
54
+ if (error instanceof CredentialsError) throw error;
55
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
56
+ throw error;
57
+ }
58
+
59
+ let parsed: Partial<Credentials>;
60
+ try {
61
+ parsed = JSON.parse(raw) as Partial<Credentials>;
62
+ } catch {
63
+ throw new CredentialsError(`${path} is not valid JSON. Delete it and run \`aloud login\` again.`);
64
+ }
65
+
66
+ if (!parsed.token || !parsed.server || !parsed.workspaceId) {
67
+ throw new CredentialsError(`${path} is missing fields. Delete it and run \`aloud login\` again.`);
68
+ }
69
+
70
+ return {
71
+ server: stripTrailingSlash(parsed.server),
72
+ token: parsed.token,
73
+ runnerId: parsed.runnerId ?? "",
74
+ runnerName: parsed.runnerName ?? "this machine",
75
+ workspaceId: parsed.workspaceId,
76
+ allowedHosts: parsed.allowedHosts ?? [],
77
+ allowPrivateNetwork: parsed.allowPrivateNetwork ?? true,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Writes the credentials at 0600.
83
+ *
84
+ * `mode` on writeFile applies only when the file is *created*, so an existing 0644 file would stay
85
+ * 0644 and the mode argument would be silently ignored. The chmod afterwards is the part that
86
+ * actually holds.
87
+ */
88
+ export async function writeCredentials(credentials: Credentials, path = credentialsPath()): Promise<void> {
89
+ const directory = dirname(path);
90
+ await mkdir(directory, { recursive: true, mode: 0o700 });
91
+ await writeFile(path, JSON.stringify(credentials, null, 2) + "\n", { mode: 0o600 });
92
+ await chmod(path, 0o600);
93
+ }
94
+
95
+ export async function clearCredentials(path = credentialsPath()): Promise<boolean> {
96
+ try {
97
+ await writeFile(path, "", { mode: 0o600, flag: constants.O_WRONLY | constants.O_TRUNC });
98
+ const { unlink } = await import("node:fs/promises");
99
+ await unlink(path);
100
+ return true;
101
+ } catch (error) {
102
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Credentials from the environment, for CI, where writing a token to disk is the wrong shape.
109
+ *
110
+ * Requires the server too: a token with no pinned origin is a token that will go wherever it is
111
+ * pointed.
112
+ */
113
+ export function credentialsFromEnv(env: NodeJS.ProcessEnv = process.env): Credentials | null {
114
+ const token = env.ALOUD_RUNNER_TOKEN?.trim();
115
+ if (!token) return null;
116
+ const server = env.ALOUD_SERVER?.trim();
117
+ if (!server) {
118
+ throw new CredentialsError("ALOUD_RUNNER_TOKEN is set but ALOUD_SERVER is not. Set both, or neither.");
119
+ }
120
+ return {
121
+ server: stripTrailingSlash(server),
122
+ token,
123
+ runnerId: "",
124
+ runnerName: env.ALOUD_RUNNER_NAME?.trim() || "ci",
125
+ workspaceId: env.ALOUD_WORKSPACE_ID?.trim() || "",
126
+ allowedHosts: (env.ALOUD_ALLOWED_HOSTS ?? "").split(",").map((h) => h.trim()).filter(Boolean),
127
+ allowPrivateNetwork: env.ALOUD_ALLOW_PRIVATE_NETWORK !== "false",
128
+ };
129
+ }
130
+
131
+ /**
132
+ * SPEC 20.4, extended to the runner's own credential.
133
+ *
134
+ * Everything the runner prints or logs goes through here, so a token cannot reach a terminal
135
+ * transcript that someone pastes into a bug report.
136
+ */
137
+ export function scrubToken(text: string, token?: string | null): string {
138
+ let out = token && token.length >= 8 ? text.split(token).join("[redacted]") : text;
139
+ // Also catches a token from some other session that happens to be in the text.
140
+ out = out.replace(/utar_[A-Za-z0-9_-]{8,}/g, "[redacted]");
141
+ return out;
142
+ }
143
+
144
+ function stripTrailingSlash(value: string): string {
145
+ return value.replace(/\/+$/, "");
146
+ }
@@ -0,0 +1,49 @@
1
+ import { normaliseHosts } from "@aloud/core";
2
+
3
+ /**
4
+ * The allowlist that lives on this machine, and that the server cannot change.
5
+ *
6
+ * This is the thing that makes the Phase 5 exit criterion true rather than merely intended. The
7
+ * control plane already intersects the study's hosts with the runner's registered hosts before
8
+ * issuing a lease, which is a good invariant against a *confused* server. It is no defence against
9
+ * a *compromised* one: a compromised server simply returns a lease saying `allowedHosts:
10
+ * ["192.168.1.1"]` and asserts it did the intersection.
11
+ *
12
+ * So the runner keeps its own copy on disk and intersects again locally. Nothing the server sends
13
+ * ever edits this file.
14
+ */
15
+ export interface LocalPolicy {
16
+ /** Hosts this machine will open. Written at login, changed only by `aloud allow`. */
17
+ allowedHosts: string[];
18
+ /**
19
+ * Whether private and loopback addresses may be opened at all.
20
+ *
21
+ * Testing localhost is the entire point of running here, so this defaults to true. It is a
22
+ * separate switch from the allowlist because turning it off should be one decision, not a
23
+ * per-host audit.
24
+ */
25
+ allowPrivateNetwork: boolean;
26
+ /** The server does not know how much memory this machine has. This is a local decision. */
27
+ maxConcurrentSessions: number;
28
+ }
29
+
30
+ export const DEFAULT_MAX_CONCURRENT_SESSIONS = 3;
31
+
32
+ export function policyFrom(input: {
33
+ allowedHosts?: readonly string[];
34
+ allowPrivateNetwork?: boolean;
35
+ maxConcurrentSessions?: number;
36
+ }): LocalPolicy {
37
+ return {
38
+ allowedHosts: normaliseHosts(input.allowedHosts ?? []),
39
+ allowPrivateNetwork: input.allowPrivateNetwork ?? true,
40
+ // Clamped rather than trusted: three browsers is already a lot on a laptop, and a typo of 300
41
+ // should not take the machine down.
42
+ maxConcurrentSessions: clamp(input.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS, 1, 8),
43
+ };
44
+ }
45
+
46
+ function clamp(value: number, low: number, high: number): number {
47
+ if (!Number.isFinite(value)) return low;
48
+ return Math.min(high, Math.max(low, Math.floor(value)));
49
+ }
@@ -0,0 +1,95 @@
1
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ /**
6
+ * Whether a runner is running on this machine, recorded where `aloud status` can read it.
7
+ *
8
+ * `status` used to end with a flat "Not running." on every invocation, because it never looked:
9
+ * it checked the credentials file and the Chromium binary, both of which say what is *installed*,
10
+ * and then guessed at the one thing it was actually asked. That line had to be explained away
11
+ * everywhere status was recommended, which is the tell that the command was wrong rather than the
12
+ * documentation.
13
+ *
14
+ * A file next to the credentials is enough. The runner is a foreground process on the same machine
15
+ * as the person asking, so there is nothing to discover over a network: `start` writes its pid here
16
+ * and removes it on the way out, and `status` asks the operating system whether that pid is still
17
+ * alive.
18
+ */
19
+ export interface RunningState {
20
+ pid: number;
21
+ startedAt: string;
22
+ server: string;
23
+ }
24
+
25
+ export function runningPath(home = homedir()): string {
26
+ return join(home, ".aloud", "running.json");
27
+ }
28
+
29
+ export async function writeRunning(state: RunningState, path = runningPath()): Promise<void> {
30
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
31
+ await writeFile(path, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
32
+ }
33
+
34
+ /**
35
+ * What is running here, or null.
36
+ *
37
+ * A crash leaves the file behind, so the file alone is not the answer: the pid is checked with
38
+ * signal 0, which tests for the process without touching it. A stale record is deleted on the way
39
+ * past, so this heals rather than accumulating.
40
+ *
41
+ * The one thing this cannot rule out is the operating system having reused that pid for something
42
+ * unrelated, which would read as a runner that is not there. It is a narrow window on a number that
43
+ * counts to 99998 before it wraps, and the cost of being wrong is a status line, so it is not worth
44
+ * platform-specific process inspection to close.
45
+ */
46
+ export async function readRunning(path = runningPath()): Promise<RunningState | null> {
47
+ let raw: string;
48
+ try {
49
+ raw = await readFile(path, "utf8");
50
+ } catch {
51
+ return null;
52
+ }
53
+
54
+ let state: Partial<RunningState>;
55
+ try {
56
+ state = JSON.parse(raw) as Partial<RunningState>;
57
+ } catch {
58
+ await unlink(path).catch(() => {});
59
+ return null;
60
+ }
61
+
62
+ if (typeof state.pid !== "number" || !alive(state.pid)) {
63
+ await unlink(path).catch(() => {});
64
+ return null;
65
+ }
66
+
67
+ return {
68
+ pid: state.pid,
69
+ startedAt: state.startedAt ?? "",
70
+ server: state.server ?? "",
71
+ };
72
+ }
73
+
74
+ /** Removes the record, but only if it is ours. A second runner's pid is not ours to forget. */
75
+ export async function clearRunning(pid = process.pid, path = runningPath()): Promise<void> {
76
+ try {
77
+ const raw = await readFile(path, "utf8");
78
+ const state = JSON.parse(raw) as Partial<RunningState>;
79
+ if (state.pid !== pid) return;
80
+ } catch {
81
+ return;
82
+ }
83
+ await unlink(path).catch(() => {});
84
+ }
85
+
86
+ function alive(pid: number): boolean {
87
+ try {
88
+ // Signal 0 performs the permission and existence checks without delivering anything.
89
+ process.kill(pid, 0);
90
+ return true;
91
+ } catch (error) {
92
+ // EPERM means it exists and belongs to someone else, which still counts as alive.
93
+ return (error as NodeJS.ErrnoException).code === "EPERM";
94
+ }
95
+ }