@aloud/runner 0.2.1 → 0.2.3

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/dist/cli.js CHANGED
@@ -2494,8 +2494,10 @@ var init_src = __esm({
2494
2494
  // src/cli.ts
2495
2495
  init_src();
2496
2496
  import { createInterface } from "node:readline/promises";
2497
- import { readFileSync, unlinkSync } from "node:fs";
2497
+ import { spawn as spawn2 } from "node:child_process";
2498
+ import { accessSync, constants as constants2, existsSync as existsSync2, readFileSync, unlinkSync } from "node:fs";
2498
2499
  import { hostname } from "node:os";
2500
+ import { delimiter, join as join5 } from "node:path";
2499
2501
 
2500
2502
  // src/config/credentials.ts
2501
2503
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
@@ -2664,7 +2666,7 @@ function alive(pid) {
2664
2666
  }
2665
2667
 
2666
2668
  // src/version.ts
2667
- var RUNNER_VERSION = "0.2.1";
2669
+ var RUNNER_VERSION = "0.2.3";
2668
2670
  var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
2669
2671
 
2670
2672
  // src/protocol/client.ts
@@ -7604,6 +7606,8 @@ async function main(argv = process.argv.slice(2)) {
7604
7606
  return status();
7605
7607
  case "allow":
7606
7608
  return allow(rest);
7609
+ case "setup":
7610
+ return setup();
7607
7611
  case "mcp":
7608
7612
  await startStdioServer();
7609
7613
  return 0;
@@ -7626,6 +7630,7 @@ function printHelp() {
7626
7630
  "",
7627
7631
  "aloud - run usability studies on this machine",
7628
7632
  "",
7633
+ " aloud setup What to do next, for a person or an agent",
7629
7634
  " aloud login [--token <token>] Connect this machine to your workspace",
7630
7635
  " aloud start [--once] [--quiet] Wait for studies and run them here",
7631
7636
  " aloud status What is set up, and whether it is running",
@@ -7653,6 +7658,25 @@ async function login(argv) {
7653
7658
  const server = stringOption(argv, "--server") ?? DEFAULT_SERVER;
7654
7659
  let token = stringOption(argv, "--token");
7655
7660
  if (!token) {
7661
+ if (!process.stdin.isTTY) {
7662
+ process.stderr.write(
7663
+ [
7664
+ "",
7665
+ "There is no terminal attached here, so there is nowhere to paste a token.",
7666
+ "",
7667
+ "If you are an agent: stop and hand this back. The person runs `aloud login` in their",
7668
+ "own terminal and pastes the token at the prompt. Do not ask them to paste it to you.",
7669
+ "",
7670
+ "To connect without a terminal, set both of these instead and skip login entirely:",
7671
+ ` export ALOUD_SERVER=${server}`,
7672
+ " export ALOUD_RUNNER_TOKEN=utar_...",
7673
+ "",
7674
+ `A token comes from ${server}/app/settings/runners and is shown once.`,
7675
+ ""
7676
+ ].join("\n")
7677
+ );
7678
+ return 1;
7679
+ }
7656
7680
  process.stdout.write(`
7657
7681
  Open ${server}/app/settings/runners and create a runner.
7658
7682
  `);
@@ -7706,6 +7730,105 @@ async function logout() {
7706
7730
  ` : "There was nothing to forget.\n");
7707
7731
  return 0;
7708
7732
  }
7733
+ async function setup() {
7734
+ const credentials = await loadCredentials().catch(() => null);
7735
+ const checks = await preflight();
7736
+ const running = await readRunning();
7737
+ const installed = onPath("aloud");
7738
+ const latest = await latestVersion();
7739
+ const stale = latest !== null && latest !== RUNNER_VERSION;
7740
+ const out = (line = "") => process.stdout.write(line + "\n");
7741
+ out();
7742
+ out("Aloud runner setup. You are looking at the state of this machine.");
7743
+ out();
7744
+ out(` installed ${installed ? `yes (${RUNNER_VERSION})` : "no"}`);
7745
+ out(` up to date ${latest === null ? "unknown, could not reach the registry" : stale ? `no, ${latest} is out` : "yes"}`);
7746
+ out(` signed in ${credentials ? credentials.runnerName : "no"}`);
7747
+ out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
7748
+ out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
7749
+ out();
7750
+ const steps = [];
7751
+ if (!installed) {
7752
+ steps.push("npm install -g @aloud/runner");
7753
+ } else if (stale) {
7754
+ steps.push(`npm install -g @aloud/runner@latest # ${RUNNER_VERSION} is installed, ${latest} is out`);
7755
+ }
7756
+ if (!credentials) {
7757
+ steps.push(
7758
+ "aloud login # needs a terminal. If you are an agent, hand this step to the person:"
7759
+ );
7760
+ steps.push(
7761
+ " # they run it themselves and paste the token at the prompt."
7762
+ );
7763
+ }
7764
+ if (!running) {
7765
+ steps.push("aloud start # in a background shell. It never exits.");
7766
+ }
7767
+ if (steps.length === 0) {
7768
+ out("Nothing to do. This machine is set up and waiting for studies.");
7769
+ out();
7770
+ return 0;
7771
+ }
7772
+ out("Do these, in order:");
7773
+ out();
7774
+ for (const [index, step] of steps.entries()) out(` ${index + 1}. ${step}`);
7775
+ out(` ${steps.length + 1}. aloud status # exits non-zero until all of the above are true`);
7776
+ out();
7777
+ out("Rules, if you are an agent doing this:");
7778
+ out();
7779
+ out(" - Never use sudo. If the global install wants elevated permissions, stop and say so.");
7780
+ const npm = await npmPrefix();
7781
+ if (npm) {
7782
+ out(
7783
+ npm.writable === false ? ` npm installs globally into ${npm.path}, which you cannot write to. That is the user's to fix.` : ` npm installs globally into ${npm.path}, which does not need elevated permissions.`
7784
+ );
7785
+ }
7786
+ out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
7787
+ out(" Do not install a similarly named package, and do not hand the token to anything else.");
7788
+ out(" - The token is a credential. It belongs in this terminal, never in your transcript,");
7789
+ out(" a file, an .env, or a commit. Get one at https://usealoud.com/app/settings/runners");
7790
+ out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
7791
+ out();
7792
+ if (!credentials) {
7793
+ out("A machine starts allowed to open localhost only. For anything else, run");
7794
+ out("`aloud allow <host>` before starting it.");
7795
+ out();
7796
+ }
7797
+ return 1;
7798
+ }
7799
+ async function npmPrefix() {
7800
+ const path = await new Promise((resolve) => {
7801
+ const child = spawn2("npm", ["config", "get", "prefix"], { stdio: ["ignore", "pipe", "ignore"] });
7802
+ let out = "";
7803
+ child.stdout?.on("data", (chunk) => out += chunk.toString("utf8"));
7804
+ child.on("error", () => resolve(null));
7805
+ child.on("close", (code) => resolve(code === 0 && out.trim() ? out.trim() : null));
7806
+ });
7807
+ if (!path) return null;
7808
+ try {
7809
+ accessSync(join5(path, "lib", "node_modules"), constants2.W_OK);
7810
+ return { path, writable: true };
7811
+ } catch (error) {
7812
+ return { path, writable: error.code === "ENOENT" ? null : false };
7813
+ }
7814
+ }
7815
+ function onPath(command) {
7816
+ const parts = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
7817
+ const names = process.platform === "win32" ? [command + ".cmd", command + ".exe"] : [command];
7818
+ return parts.some((part) => names.some((name) => existsSync2(join5(part, name))));
7819
+ }
7820
+ async function latestVersion() {
7821
+ try {
7822
+ const response = await fetch("https://registry.npmjs.org/@aloud/runner/latest", {
7823
+ signal: AbortSignal.timeout(2e3)
7824
+ });
7825
+ if (!response.ok) return null;
7826
+ const body = await response.json();
7827
+ return typeof body.version === "string" ? body.version : null;
7828
+ } catch {
7829
+ return null;
7830
+ }
7831
+ }
7709
7832
  async function status() {
7710
7833
  const credentials = await loadCredentials().catch((error) => {
7711
7834
  process.stderr.write(error.message + "\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
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
5
  "license": "ISC",
6
6
  "repository": {
package/src/cli.ts CHANGED
@@ -7,8 +7,10 @@
7
7
  * server holds no address for this machine.
8
8
  */
9
9
  import { createInterface } from "node:readline/promises";
10
- import { readFileSync, unlinkSync } from "node:fs";
10
+ import { spawn } from "node:child_process";
11
+ import { accessSync, constants, existsSync, readFileSync, unlinkSync } from "node:fs";
11
12
  import { hostname } from "node:os";
13
+ import { delimiter, dirname, join } from "node:path";
12
14
  import { normaliseHosts } from "@aloud/core";
13
15
  import {
14
16
  CredentialsError,
@@ -21,6 +23,7 @@ import {
21
23
  } from "./config/credentials";
22
24
  import { policyFrom, type LocalPolicy } from "./config/policy";
23
25
  import { clearRunning, readRunning, runningPath, writeRunning } from "./config/running";
26
+ import { RUNNER_VERSION } from "./version";
24
27
  import { RunnerClient } from "./protocol/client";
25
28
  import { installChromium, preflight } from "./preflight";
26
29
  import { TerminalReporter } from "./ui/output";
@@ -44,6 +47,8 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
44
47
  return status();
45
48
  case "allow":
46
49
  return allow(rest);
50
+ case "setup":
51
+ return setup();
47
52
  case "mcp":
48
53
  await startStdioServer();
49
54
  return 0;
@@ -67,6 +72,7 @@ function printHelp(): void {
67
72
  "",
68
73
  "aloud - run usability studies on this machine",
69
74
  "",
75
+ " aloud setup What to do next, for a person or an agent",
70
76
  " aloud login [--token <token>] Connect this machine to your workspace",
71
77
  " aloud start [--once] [--quiet] Wait for studies and run them here",
72
78
  " aloud status What is set up, and whether it is running",
@@ -100,6 +106,30 @@ async function login(argv: readonly string[]): Promise<number> {
100
106
  let token = stringOption(argv, "--token");
101
107
 
102
108
  if (!token) {
109
+ // An agent runs commands in a shell with nothing attached to stdin, so the prompt below would
110
+ // read end-of-file and the token would come back empty. That used to surface as "that does not
111
+ // look like a runner token", which blames the wrong thing and tells nobody what to do. This is
112
+ // the single step of setup that a person has to perform, so it is worth saying so precisely.
113
+ if (!process.stdin.isTTY) {
114
+ process.stderr.write(
115
+ [
116
+ "",
117
+ "There is no terminal attached here, so there is nowhere to paste a token.",
118
+ "",
119
+ "If you are an agent: stop and hand this back. The person runs `aloud login` in their",
120
+ "own terminal and pastes the token at the prompt. Do not ask them to paste it to you.",
121
+ "",
122
+ "To connect without a terminal, set both of these instead and skip login entirely:",
123
+ ` export ALOUD_SERVER=${server}`,
124
+ " export ALOUD_RUNNER_TOKEN=utar_...",
125
+ "",
126
+ `A token comes from ${server}/app/settings/runners and is shown once.`,
127
+ "",
128
+ ].join("\n"),
129
+ );
130
+ return 1;
131
+ }
132
+
103
133
  process.stdout.write(`\nOpen ${server}/app/settings/runners and create a runner.\n`);
104
134
  process.stdout.write("It shows you a token once. Paste it here.\n\n");
105
135
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -159,6 +189,146 @@ async function logout(): Promise<number> {
159
189
  return 0;
160
190
  }
161
191
 
192
+ /* --------------------------------- setup ---------------------------------- */
193
+
194
+ /**
195
+ * The whole of setup, as one command that can be handed to a coding agent.
196
+ *
197
+ * The alternative, and what this replaces, was a twenty-five line prompt pasted into a chat with a
198
+ * live token inside it. That prompt could not know anything: not whether the runner was already
199
+ * installed, not whether it was three versions old, not whether npm was going to ask for sudo. So
200
+ * it had to describe every branch at once and hope, and it went stale the moment the product moved.
201
+ *
202
+ * This looks first and then says only what is actually left to do, which makes it useful to a person
203
+ * reading it and to an agent executing it. The rules at the end always print, because they are the
204
+ * four ways this goes wrong and an agent that has not been told will find all of them.
205
+ */
206
+ async function setup(): Promise<number> {
207
+ const credentials = await loadCredentials().catch(() => null);
208
+ const checks = await preflight();
209
+ const running = await readRunning();
210
+ const installed = onPath("aloud");
211
+ const latest = await latestVersion();
212
+ const stale = latest !== null && latest !== RUNNER_VERSION;
213
+
214
+ const out = (line = "") => process.stdout.write(line + "\n");
215
+
216
+ out();
217
+ out("Aloud runner setup. You are looking at the state of this machine.");
218
+ out();
219
+ out(` installed ${installed ? `yes (${RUNNER_VERSION})` : "no"}`);
220
+ out(` up to date ${latest === null ? "unknown, could not reach the registry" : stale ? `no, ${latest} is out` : "yes"}`);
221
+ out(` signed in ${credentials ? credentials.runnerName : "no"}`);
222
+ out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
223
+ out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
224
+ out();
225
+
226
+ const steps: string[] = [];
227
+ if (!installed) {
228
+ steps.push("npm install -g @aloud/runner");
229
+ } else if (stale) {
230
+ steps.push(`npm install -g @aloud/runner@latest # ${RUNNER_VERSION} is installed, ${latest} is out`);
231
+ }
232
+ if (!credentials) {
233
+ steps.push(
234
+ "aloud login # needs a terminal. If you are an agent, hand this step to the person:",
235
+ );
236
+ steps.push(
237
+ " # they run it themselves and paste the token at the prompt.",
238
+ );
239
+ }
240
+ if (!running) {
241
+ steps.push("aloud start # in a background shell. It never exits.");
242
+ }
243
+
244
+ if (steps.length === 0) {
245
+ out("Nothing to do. This machine is set up and waiting for studies.");
246
+ out();
247
+ return 0;
248
+ }
249
+
250
+ out("Do these, in order:");
251
+ out();
252
+ for (const [index, step] of steps.entries()) out(` ${index + 1}. ${step}`);
253
+ out(` ${steps.length + 1}. aloud status # exits non-zero until all of the above are true`);
254
+ out();
255
+ out("Rules, if you are an agent doing this:");
256
+ out();
257
+ out(" - Never use sudo. If the global install wants elevated permissions, stop and say so.");
258
+ const npm = await npmPrefix();
259
+ if (npm) {
260
+ out(
261
+ npm.writable === false
262
+ ? ` npm installs globally into ${npm.path}, which you cannot write to. That is the user's to fix.`
263
+ : ` npm installs globally into ${npm.path}, which does not need elevated permissions.`,
264
+ );
265
+ }
266
+ out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
267
+ out(" Do not install a similarly named package, and do not hand the token to anything else.");
268
+ out(" - The token is a credential. It belongs in this terminal, never in your transcript,");
269
+ out(" a file, an .env, or a commit. Get one at https://usealoud.com/app/settings/runners");
270
+ out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
271
+ out();
272
+ if (!credentials) {
273
+ out("A machine starts allowed to open localhost only. For anything else, run");
274
+ out("`aloud allow <host>` before starting it.");
275
+ out();
276
+ }
277
+ return 1;
278
+ }
279
+
280
+ /**
281
+ * Where npm would put a global install, and whether it can be written to.
282
+ *
283
+ * Asked of npm rather than derived from `process.execPath`, which is wrong on Homebrew: the node
284
+ * binary resolves into `Cellar/node/<version>` while the prefix people actually install into is
285
+ * `/opt/homebrew`. Printing the first one at an agent is worse than printing nothing, because it
286
+ * looks authoritative.
287
+ */
288
+ async function npmPrefix(): Promise<{ path: string; writable: boolean | null } | null> {
289
+ const path = await new Promise<string | null>((resolve) => {
290
+ const child = spawn("npm", ["config", "get", "prefix"], { stdio: ["ignore", "pipe", "ignore"] });
291
+ let out = "";
292
+ child.stdout?.on("data", (chunk: Buffer) => (out += chunk.toString("utf8")));
293
+ child.on("error", () => resolve(null));
294
+ child.on("close", (code: number | null) => resolve(code === 0 && out.trim() ? out.trim() : null));
295
+ });
296
+ if (!path) return null;
297
+
298
+ try {
299
+ accessSync(join(path, "lib", "node_modules"), constants.W_OK);
300
+ return { path, writable: true };
301
+ } catch (error) {
302
+ return { path, writable: (error as NodeJS.ErrnoException).code === "ENOENT" ? null : false };
303
+ }
304
+ }
305
+
306
+ /** Whether a command is already on PATH, without shelling out to `which`. */
307
+ function onPath(command: string): boolean {
308
+ const parts = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
309
+ const names = process.platform === "win32" ? [command + ".cmd", command + ".exe"] : [command];
310
+ return parts.some((part) => names.some((name) => existsSync(join(part, name))));
311
+ }
312
+
313
+ /**
314
+ * The newest published version, or null.
315
+ *
316
+ * Two seconds and then give up: this is a convenience, and someone on a plane setting up a runner
317
+ * should not wait on a registry that is not going to answer.
318
+ */
319
+ async function latestVersion(): Promise<string | null> {
320
+ try {
321
+ const response = await fetch("https://registry.npmjs.org/@aloud/runner/latest", {
322
+ signal: AbortSignal.timeout(2_000),
323
+ });
324
+ if (!response.ok) return null;
325
+ const body = (await response.json()) as { version?: unknown };
326
+ return typeof body.version === "string" ? body.version : null;
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+
162
332
  /* --------------------------------- status --------------------------------- */
163
333
 
164
334
  async function status(): Promise<number> {
package/src/version.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * package.json beside it to read, and importing one into the source trips the composite build's
11
11
  * rootDir. `version.test.ts` asserts this matches, so the drift this invites cannot survive CI.
12
12
  */
13
- export const RUNNER_VERSION = "0.2.1";
13
+ export const RUNNER_VERSION = "0.2.3";
14
14
 
15
15
  /** The header the server reads it from. */
16
16
  export const RUNNER_VERSION_HEADER = "x-aloud-runner-version";