@aloud/runner 0.3.0 → 0.3.2

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 CHANGED
@@ -9,7 +9,7 @@ outbound only: nothing listens on a port, and the server holds no address for th
9
9
  ```sh
10
10
  npm install -g @aloud/runner
11
11
 
12
- aloud login # paste the token from usealoud.com/app/settings/runners
12
+ aloud login # approve this machine in your browser
13
13
  aloud start # waits for studies and runs them here
14
14
  ```
15
15
 
@@ -19,12 +19,25 @@ The first `aloud start` downloads Chromium, about 350 MB, once.
19
19
 
20
20
  | Command | What it does |
21
21
  | --- | --- |
22
+ | `aloud setup` | Inspect this machine and complete whatever setup remains |
22
23
  | `aloud login [--token <token>]` | Connect this machine to your workspace |
23
- | `aloud start [--once] [--quiet]` | Wait for studies and run them here |
24
+ | `aloud start [--once] [--quiet] [--no-update]` | Update, then wait for studies and run them here |
24
25
  | `aloud status` | What is set up, what is not, and whether it is running |
25
26
  | `aloud allow <host>` | Let studies open this host from this machine |
26
27
  | `aloud mcp` | Connect an MCP host to the web workspace selected by its MCP access token |
27
28
  | `aloud logout` | Forget the token on this machine |
29
+ | `aloud --version` | Print the installed runner version |
30
+
31
+ ## Updates
32
+
33
+ Starting with 0.3.1, `aloud start` asks the connected Aloud server which official runner release
34
+ it recommends. If a newer one is available, it installs that exact `@aloud/runner` version and
35
+ relaunches it before claiming work. It never replaces code during an active study.
36
+
37
+ If an optional update cannot be installed, the terminal says why and a still-compatible runner may
38
+ continue. If the server requires the update, nothing starts and the command exits non-zero with the
39
+ manual repair command. `--no-update` is available for managed environments, but it cannot make an
40
+ incompatible runner claim work.
28
41
 
29
42
  ## MCP access
30
43
 
package/dist/cli.js CHANGED
@@ -2286,7 +2286,7 @@ var init_registry = __esm({
2286
2286
  PinnedPromptVersions = {
2287
2287
  product_discovery: "1.0.0",
2288
2288
  study_design: "1.0.0",
2289
- persona_derivation: "2.1.0",
2289
+ persona_derivation: "2.2.0",
2290
2290
  success_criteria: "2.0.0",
2291
2291
  participant_decision: "2.2.0",
2292
2292
  participant_reaction: "1.2.0",
@@ -2685,6 +2685,11 @@ What the site appears to be, from its public pages:
2685
2685
  The flow being tested: {{targetFlow}}
2686
2686
  The goal each participant will be given: {{goal}}
2687
2687
 
2688
+ The device for this study is fixed by the operator: {{deviceDescription}} ({{device}}).
2689
+ Every person will use that device for this study. Do not put anyone on another device in their role,
2690
+ jobToBeDone, background, attentionBias, derivedFrom, rationale, or any other prose. Device is not a
2691
+ persona choice and must not appear as a field in your reply.
2692
+
2688
2693
  Audience segments relevant to this particular goal:
2689
2694
  {{relevantAudiences}}
2690
2695
 
@@ -2707,7 +2712,6 @@ For each person give:
2707
2712
  - "familiarityWithProduct", "familiarityWithDomain", "technicalConfidence", "patience", "urgency":
2708
2713
  each one of very_low, low, moderate, high, very_high
2709
2714
  - "emotionalStance": curious, neutral, sceptical, hurried, anxious, or enthusiastic
2710
- - "device": desktop or mobile_web
2711
2715
  - "background": two sentences of context that would change their behaviour
2712
2716
  - "attentionBias": what this person notices or cares about that the others would not
2713
2717
  - "completionOriented": true for exactly the one completion-oriented person, false for everyone else
@@ -2724,9 +2728,9 @@ Return JSON: {"personas": [ ... ]}
2724
2728
  `.trim();
2725
2729
  personaDerivationPrompt = definePrompt(
2726
2730
  "persona_derivation",
2727
- "2.1.0",
2731
+ "2.2.0",
2728
2732
  personaSource,
2729
- (vars) => personaSource.replace("{{productSignals}}", s(vars.productSignals)).replace("{{targetFlow}}", s(vars.targetFlow)).replace("{{goal}}", s(vars.goal)).replace("{{relevantAudiences}}", s(vars.relevantAudiences) || "The intended audience is not yet known.").replace("{{count}}", s(vars.count))
2733
+ (vars) => personaSource.replace("{{productSignals}}", s(vars.productSignals)).replace("{{targetFlow}}", s(vars.targetFlow)).replace("{{goal}}", s(vars.goal)).replace("{{deviceDescription}}", s(vars.deviceDescription)).replace("{{device}}", s(vars.device)).replace("{{relevantAudiences}}", s(vars.relevantAudiences) || "The intended audience is not yet known.").replace("{{count}}", s(vars.count))
2730
2734
  );
2731
2735
  criteriaSource = `
2732
2736
  A team wants to test this goal on their website: {{goal}}
@@ -3148,6 +3152,83 @@ async function waitForApproval(start2, deps) {
3148
3152
  }
3149
3153
  }
3150
3154
 
3155
+ // src/version.ts
3156
+ var RUNNER_VERSION = "0.3.2";
3157
+ var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
3158
+ function versionParts(value) {
3159
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
3160
+ if (!match) return null;
3161
+ const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
3162
+ return parts.every(Number.isSafeInteger) ? parts : null;
3163
+ }
3164
+ function compareRunnerVersions(left, right) {
3165
+ const a = versionParts(left);
3166
+ const b = versionParts(right);
3167
+ if (!a || !b) return null;
3168
+ for (let index = 0; index < a.length; index += 1) {
3169
+ if (a[index] > b[index]) return 1;
3170
+ if (a[index] < b[index]) return -1;
3171
+ }
3172
+ return 0;
3173
+ }
3174
+ function runnerVersionPolicyFrom(value) {
3175
+ if (!value || typeof value !== "object") return null;
3176
+ const policy = value;
3177
+ if (typeof policy.minimum !== "string" || typeof policy.recommended !== "string") return null;
3178
+ const order = compareRunnerVersions(policy.recommended, policy.minimum);
3179
+ if (order === null || order < 0) return null;
3180
+ return { minimum: policy.minimum, recommended: policy.recommended };
3181
+ }
3182
+ function runnerUpdateFor(policy, current = RUNNER_VERSION) {
3183
+ const recommendedOrder = compareRunnerVersions(policy.recommended, current);
3184
+ const minimumOrder = compareRunnerVersions(policy.minimum, current);
3185
+ if (recommendedOrder === null || minimumOrder === null) return null;
3186
+ if (recommendedOrder <= 0) return null;
3187
+ return { target: policy.recommended, required: minimumOrder > 0 };
3188
+ }
3189
+
3190
+ // src/protocol/presence.ts
3191
+ async function readRunnerPresence(input) {
3192
+ const fetchImpl = input.fetchImpl ?? fetch;
3193
+ try {
3194
+ const response = await fetchImpl(new URL("api/runner/me", input.server + "/"), {
3195
+ headers: {
3196
+ authorization: `Bearer ${input.token}`,
3197
+ accept: "application/json",
3198
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
3199
+ },
3200
+ signal: AbortSignal.timeout(input.timeoutMs ?? 5e3)
3201
+ });
3202
+ if (response.status === 401 || response.status === 403) return { state: "revoked" };
3203
+ if (!response.ok) return { state: "unreachable" };
3204
+ const body = await response.json().catch(() => ({}));
3205
+ return {
3206
+ state: "ok",
3207
+ presence: {
3208
+ online: body.online === true,
3209
+ lastSeenAt: typeof body.lastSeenAt === "string" ? body.lastSeenAt : null,
3210
+ lastVersion: typeof body.lastVersion === "string" ? body.lastVersion : null
3211
+ }
3212
+ };
3213
+ } catch {
3214
+ return { state: "unreachable" };
3215
+ }
3216
+ }
3217
+ async function waitForRunnerCheckIn(input) {
3218
+ const now = input.now ?? Date.now;
3219
+ const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
3220
+ const deadline = now() + (input.timeoutMs ?? 15e3);
3221
+ for (; ; ) {
3222
+ const result = await readRunnerPresence(input);
3223
+ if (result.state === "revoked") return "revoked";
3224
+ if (result.state === "ok" && result.presence.online && result.presence.lastSeenAt !== null && result.presence.lastSeenAt !== input.previousLastSeenAt && result.presence.lastVersion === (input.expectedVersion ?? RUNNER_VERSION)) {
3225
+ return "confirmed";
3226
+ }
3227
+ if (now() >= deadline) return "timeout";
3228
+ await sleep(Math.min(input.pollMs ?? 750, Math.max(0, deadline - now())));
3229
+ }
3230
+ }
3231
+
3151
3232
  // src/config/mcp-credentials.ts
3152
3233
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3153
3234
  import { constants } from "node:fs";
@@ -3380,10 +3461,6 @@ function alive(pid) {
3380
3461
  }
3381
3462
  }
3382
3463
 
3383
- // src/version.ts
3384
- var RUNNER_VERSION = "0.3.0";
3385
- var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
3386
-
3387
3464
  // src/protocol/client.ts
3388
3465
  var LeaseLostError = class extends Error {
3389
3466
  constructor(message) {
@@ -7485,7 +7562,7 @@ function buildRunLimits(input) {
7485
7562
  const { synthesis, snapshot } = input;
7486
7563
  if (snapshot.cast.some((persona) => persona.completionOriented === true)) {
7487
7564
  limits.push(
7488
- "One participant was deliberately completion-oriented to exercise more of the task path. They still used only visible controls, documented friction, and could stop at a genuine blocker; their persistence is a study-design choice, not a population estimate."
7565
+ "One participant was deliberately assigned the resilient goal-finisher role to exercise more of the task path. They were highly motivated to finish, but still used only visible controls, documented friction, and could stop at a genuine blocker; their persistence is a study-design choice, not a population estimate."
7489
7566
  );
7490
7567
  }
7491
7568
  if (synthesis.usableSessions.length < snapshot.cast.length) {
@@ -9378,7 +9455,6 @@ var PersonaProposalResponse = z24.object({
9378
9455
  patience: Level,
9379
9456
  urgency: Level,
9380
9457
  emotionalStance: z24.enum(["curious", "neutral", "sceptical", "hurried", "anxious", "enthusiastic"]),
9381
- device: DeviceContext,
9382
9458
  background: z24.string().max(1200).default(""),
9383
9459
  attentionBias: z24.string().max(400).default(""),
9384
9460
  completionOriented: z24.boolean().default(false),
@@ -9446,6 +9522,15 @@ init_src();
9446
9522
  init_src();
9447
9523
  var DAY_MS = 24 * 60 * 60 * 1e3;
9448
9524
 
9525
+ // ../app/src/services/runner-lease.ts
9526
+ init_src();
9527
+
9528
+ // ../app/src/services/run-finalize.ts
9529
+ init_src();
9530
+
9531
+ // ../app/src/services/runner-lease.ts
9532
+ var DEFAULT_LEASE_SECONDS = 20 * 60;
9533
+
9449
9534
  // ../app/src/application.ts
9450
9535
  var PreflightFailedError = class extends Error {
9451
9536
  constructor(preflight2) {
@@ -9661,10 +9746,6 @@ init_src();
9661
9746
  // ../app/src/services/recommendations.ts
9662
9747
  init_src();
9663
9748
 
9664
- // ../app/src/services/runner-lease.ts
9665
- init_src();
9666
- var DEFAULT_LEASE_SECONDS = 20 * 60;
9667
-
9668
9749
  // ../app/src/services/scheduling.ts
9669
9750
  init_src();
9670
9751
 
@@ -9832,7 +9913,7 @@ function text(value) {
9832
9913
  function json(value) {
9833
9914
  return text(JSON.stringify(value, null, 2));
9834
9915
  }
9835
- async function handleTool(context, name, input, execute) {
9916
+ async function handleTool(context, name, input, execute, prepare) {
9836
9917
  const { app, actor, workspaceId } = context;
9837
9918
  switch (name) {
9838
9919
  case "create_study": {
@@ -9965,6 +10046,21 @@ async function handleTool(context, name, input, execute) {
9965
10046
  }
9966
10047
  case "start_study": {
9967
10048
  const parsed = StartStudyInput.parse(input);
10049
+ const blocked = await prepare?.(parsed.studyId, parsed.idempotencyKey);
10050
+ if (blocked) {
10051
+ return {
10052
+ ...json({
10053
+ started: false,
10054
+ reason: "runner_not_ready",
10055
+ code: blocked.code,
10056
+ blockedHost: blocked.blockedHost,
10057
+ message: blocked.message,
10058
+ runId: blocked.existingRunId ?? null,
10059
+ note: blocked.existingRunId ? "The existing run was not duplicated. Fix the named machine condition and call start_study again with the same runId and idempotencyKey." : "No run was created. Fix the named machine condition and call start_study again with the same runId and idempotencyKey."
10060
+ }),
10061
+ isError: true
10062
+ };
10063
+ }
9968
10064
  let outcome;
9969
10065
  try {
9970
10066
  outcome = await app.startRun(actor, {
@@ -10308,7 +10404,7 @@ function createMcpServer(options) {
10308
10404
  function backendFor(options) {
10309
10405
  if ("backend" in options) return options.backend;
10310
10406
  return {
10311
- callTool: (name, input) => handleTool(options.context, name, input, options.execute),
10407
+ callTool: (name, input) => handleTool(options.context, name, input, options.execute, options.prepare),
10312
10408
  readResource: (parsed) => readResource(options.context, parsed)
10313
10409
  };
10314
10410
  }
@@ -10512,6 +10608,11 @@ async function main(argv = process.argv.slice(2)) {
10512
10608
  return allow(rest);
10513
10609
  case "setup":
10514
10610
  return setup();
10611
+ case "version":
10612
+ case "--version":
10613
+ case "-v":
10614
+ process.stdout.write(RUNNER_VERSION + "\n");
10615
+ return 0;
10515
10616
  case "mcp":
10516
10617
  if (rest[0] === "connect") return connectMcp(rest.slice(1));
10517
10618
  if (rest[0] === "disconnect") return disconnectMcp();
@@ -10538,12 +10639,14 @@ function printHelp() {
10538
10639
  "",
10539
10640
  " aloud setup What to do next, for a person or an agent",
10540
10641
  " aloud login [--token <token>] Connect this machine, approving it in your browser",
10541
- " aloud start [--once] [--quiet] Wait for studies and run them here",
10642
+ " aloud start [--once] [--quiet] [--no-update]",
10643
+ " Update, then wait for studies and run them here",
10542
10644
  " aloud status What is set up, and whether it is running",
10543
10645
  " aloud allow <host> Let studies open this host from this machine",
10544
10646
  " aloud mcp Serve MCP to an editor, using the saved credential",
10545
10647
  " aloud mcp connect Connect an editor, approving it in your browser",
10546
10648
  " aloud logout Forget the token on this machine",
10649
+ " aloud --version Print the installed runner version",
10547
10650
  "",
10548
10651
  `Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
10549
10652
  ""
@@ -10728,7 +10831,7 @@ async function setup() {
10728
10831
  const running = await readRunning();
10729
10832
  const installed = onPath("aloud");
10730
10833
  const latest = await latestVersion();
10731
- const stale = latest !== null && latest !== RUNNER_VERSION;
10834
+ const stale = latest !== null && compareRunnerVersions(latest, RUNNER_VERSION) === 1;
10732
10835
  const signedIn = await signedInState(credentials);
10733
10836
  const out = (line = "") => process.stdout.write(line + "\n");
10734
10837
  out();
@@ -10741,8 +10844,21 @@ async function setup() {
10741
10844
  );
10742
10845
  out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
10743
10846
  out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
10847
+ out(
10848
+ ` Aloud sees ${signedIn.state === "ok" ? signedIn.online ? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""}` : running ? "not yet. The local process exists, but no current check-in is confirmed" : "no" : signedIn.state === "unreachable" ? "unknown, the server did not answer" : "no"}`
10849
+ );
10744
10850
  out();
10745
- if (process.stdin.isTTY) return interactiveSetup({ installed, stale, latest, signedIn, running, credentials });
10851
+ if (process.stdin.isTTY) {
10852
+ return interactiveSetup({
10853
+ installed,
10854
+ stale,
10855
+ latest,
10856
+ signedIn,
10857
+ running,
10858
+ credentials,
10859
+ chromiumInstalled: checks.chromiumInstalled
10860
+ });
10861
+ }
10746
10862
  const steps = [];
10747
10863
  if (!installed) {
10748
10864
  steps.push(["npm install -g @aloud/runner"]);
@@ -10751,11 +10867,11 @@ async function setup() {
10751
10867
  }
10752
10868
  if (signedIn.state === "revoked") {
10753
10869
  steps.push([
10754
- `Create a new token at ${credentials?.server ?? DEFAULT_SERVER}/app/settings/runners`,
10755
- "The saved one was revoked and cannot be reused."
10870
+ "aloud login",
10871
+ "The saved connection was revoked. This prints a new browser-approval link and code."
10756
10872
  ]);
10757
10873
  }
10758
- if (signedIn.state === "none" || signedIn.state === "revoked") {
10874
+ if (signedIn.state === "none") {
10759
10875
  steps.push([
10760
10876
  "aloud login",
10761
10877
  "Prints a link and a short code, then waits. Give both to the person; they approve in",
@@ -10771,6 +10887,20 @@ async function setup() {
10771
10887
  out();
10772
10888
  return 1;
10773
10889
  }
10890
+ if (steps.length === 0 && running && signedIn.state === "ok" && !signedIn.online) {
10891
+ out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
10892
+ out(`Read ${join6(dirname5(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
10893
+ out("Do not report this machine ready until `Aloud sees` says yes.");
10894
+ out();
10895
+ return 1;
10896
+ }
10897
+ if (steps.length === 0 && running && signedIn.state === "ok" && signedIn.online && signedIn.lastVersion !== RUNNER_VERSION) {
10898
+ out(`The live process is checking in as v${signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`);
10899
+ out(`Restart it with: kill ${running.pid} && aloud start`);
10900
+ out("Do not report the update complete until `aloud status` shows the expected version.");
10901
+ out();
10902
+ return 1;
10903
+ }
10774
10904
  if (steps.length === 0) {
10775
10905
  out("Nothing to do. This machine is set up and waiting for studies.");
10776
10906
  out();
@@ -10795,7 +10925,7 @@ async function setup() {
10795
10925
  );
10796
10926
  }
10797
10927
  out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
10798
- out(" Do not install a similarly named package, and do not hand the token to anything else.");
10928
+ out(" Do not install a similarly named package.");
10799
10929
  out(" - There is nothing secret for you to handle. `aloud login` prints a link and a code,");
10800
10930
  out(" and the credential goes straight to this machine. Never ask anyone for a token.");
10801
10931
  out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
@@ -10844,12 +10974,28 @@ async function interactiveSetup(state) {
10844
10974
  out("Start it when you are ready, and leave it running: aloud start");
10845
10975
  return 1;
10846
10976
  }
10847
- return startDetached(out);
10977
+ return startDetached(out, { waitForConfirmation: state.chromiumInstalled });
10978
+ }
10979
+ if (state.signedIn.state === "ok" && state.signedIn.online && state.signedIn.lastVersion === RUNNER_VERSION) {
10980
+ out("");
10981
+ out("Set up and confirmed by Aloud. This machine is waiting for eligible studies.");
10982
+ out("");
10983
+ return 0;
10984
+ }
10985
+ if (state.signedIn.state === "ok" && state.signedIn.online) {
10986
+ out("");
10987
+ out(
10988
+ `The live process is checking in as v${state.signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`
10989
+ );
10990
+ out(`Restart it with: kill ${state.running.pid} && aloud start`);
10991
+ out("");
10992
+ return 1;
10848
10993
  }
10849
10994
  out("");
10850
- out("Set up. This machine is waiting for studies.");
10995
+ out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
10996
+ out(`Read ${join6(dirname5(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
10851
10997
  out("");
10852
- return 0;
10998
+ return 1;
10853
10999
  } finally {
10854
11000
  rl.close();
10855
11001
  }
@@ -10869,9 +11015,11 @@ async function run(command, args, out) {
10869
11015
  child.on("close", (code) => resolve(code === 0));
10870
11016
  });
10871
11017
  }
10872
- async function startDetached(out) {
11018
+ async function startDetached(out, options) {
10873
11019
  const log = join6(dirname5(credentialsPath()), "runner.log");
10874
11020
  await mkdir5(dirname5(log), { recursive: true, mode: 448 });
11021
+ const credentials = await readCredentials();
11022
+ const before = credentials ? await readRunnerPresence({ server: credentials.server, token: credentials.token }) : null;
10875
11023
  const handle = openSync(log, "a");
10876
11024
  const child = spawn2(process.execPath, [process.argv[1] ?? "", "start"], {
10877
11025
  detached: true,
@@ -10884,23 +11032,50 @@ async function startDetached(out) {
10884
11032
  out(" Check it aloud status");
10885
11033
  out(` Stop it kill ${child.pid}`);
10886
11034
  out("");
10887
- out("The first start downloads Chromium, about 350 MB, once. Studies will wait until it is done.");
11035
+ if (!options.waitForConfirmation) {
11036
+ out("The runner is preparing Chromium in the background. It is started locally, but not yet");
11037
+ out("confirmed by Aloud. The Machines page updates automatically after its first check-in.");
11038
+ out("");
11039
+ return 0;
11040
+ }
11041
+ if (!credentials || before?.state !== "ok") {
11042
+ out("Started locally, but Aloud could not establish a before-start status to confirm this launch.");
11043
+ out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
11044
+ out("");
11045
+ return 1;
11046
+ }
11047
+ out("Waiting for Aloud to confirm the first check-in\u2026");
11048
+ const confirmation = await waitForRunnerCheckIn({
11049
+ server: credentials.server,
11050
+ token: credentials.token,
11051
+ previousLastSeenAt: before.presence.lastSeenAt
11052
+ });
11053
+ if (confirmation === "confirmed") {
11054
+ out("Confirmed by Aloud. This machine is online and waiting for eligible studies.");
11055
+ out("");
11056
+ return 0;
11057
+ }
11058
+ if (confirmation === "revoked") {
11059
+ out("Aloud refused the saved connection. Run `aloud login` to approve this machine again.");
11060
+ } else {
11061
+ out("The process started, but Aloud did not confirm a check-in within 15 seconds.");
11062
+ out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
11063
+ }
10888
11064
  out("");
10889
- return 0;
11065
+ return 1;
10890
11066
  }
10891
11067
  async function signedInState(credentials) {
10892
11068
  if (!credentials) return { state: "none" };
10893
- try {
10894
- const response = await fetch(new URL("api/runner/me", credentials.server + "/"), {
10895
- headers: { authorization: `Bearer ${credentials.token}` },
10896
- signal: AbortSignal.timeout(5e3)
10897
- });
10898
- if (response.status === 401 || response.status === 403) return { state: "revoked" };
10899
- if (!response.ok) return { state: "unreachable", server: credentials.server };
10900
- return { state: "ok", name: credentials.runnerName };
10901
- } catch {
10902
- return { state: "unreachable", server: credentials.server };
10903
- }
11069
+ const result = await readRunnerPresence({ server: credentials.server, token: credentials.token });
11070
+ if (result.state === "revoked") return { state: "revoked" };
11071
+ if (result.state === "unreachable") return { state: "unreachable", server: credentials.server };
11072
+ return {
11073
+ state: "ok",
11074
+ name: credentials.runnerName,
11075
+ online: result.presence.online,
11076
+ lastSeenAt: result.presence.lastSeenAt,
11077
+ lastVersion: result.presence.lastVersion
11078
+ };
10904
11079
  }
10905
11080
  async function npmPrefix() {
10906
11081
  const path = await new Promise((resolve) => {
@@ -10935,6 +11110,139 @@ async function latestVersion() {
10935
11110
  return null;
10936
11111
  }
10937
11112
  }
11113
+ async function serverVersionPolicy(credentials) {
11114
+ try {
11115
+ const client = new RunnerClient({ server: credentials.server, token: credentials.token });
11116
+ const response = await client.request("api/runner/me", {
11117
+ retry: false
11118
+ });
11119
+ return runnerVersionPolicyFrom(response.body?.runnerVersionPolicy);
11120
+ } catch {
11121
+ return null;
11122
+ }
11123
+ }
11124
+ async function updateBeforeStart(credentials, argv, overrides = {}) {
11125
+ const stdout = overrides.stdout ?? ((text2) => process.stdout.write(text2));
11126
+ const stderr = overrides.stderr ?? ((text2) => process.stderr.write(text2));
11127
+ const policy = await (overrides.versionPolicy ?? serverVersionPolicy)(credentials);
11128
+ if (!policy) return null;
11129
+ const update = runnerUpdateFor(policy);
11130
+ if (!update) return null;
11131
+ if (argv.includes("--no-update")) {
11132
+ if (!update.required) return null;
11133
+ stderr(
11134
+ `Runner ${RUNNER_VERSION} cannot start studies on this server; ${policy.minimum} or newer is required.
11135
+ Automatic updates were disabled. Remove --no-update or run npm install -g @aloud/runner@${update.target}.
11136
+ `
11137
+ );
11138
+ return 1;
11139
+ }
11140
+ stdout(`
11141
+ Updating Aloud runner ${RUNNER_VERSION} \u2192 ${update.target} before it starts.
11142
+ `);
11143
+ const prefix = await (overrides.npmPrefix ?? npmPrefix)();
11144
+ if (prefix?.writable === false) {
11145
+ return failedAutomaticUpdate(
11146
+ update,
11147
+ policy,
11148
+ `npm's global install directory (${prefix.path}) is not writable by this user.`,
11149
+ stderr
11150
+ );
11151
+ }
11152
+ const installed = overrides.install ? await overrides.install(update.target, stdout) : await run(
11153
+ "npm",
11154
+ ["install", "-g", `@aloud/runner@${update.target}`],
11155
+ (line = "") => stdout(line + "\n")
11156
+ );
11157
+ if (!installed) {
11158
+ return failedAutomaticUpdate(update, policy, "npm did not complete the update.", stderr);
11159
+ }
11160
+ const entry2 = await (overrides.installedEntry ?? installedRunnerEntry)(update.target);
11161
+ if (!entry2) {
11162
+ return failedAutomaticUpdate(
11163
+ update,
11164
+ policy,
11165
+ `npm completed, but the installed @aloud/runner@${update.target} could not be verified.`,
11166
+ stderr
11167
+ );
11168
+ }
11169
+ stdout(`
11170
+ Updated to ${update.target}. Starting it now.
11171
+
11172
+ `);
11173
+ return (overrides.relaunch ?? relaunchUpdatedRunner)(entry2, argv);
11174
+ }
11175
+ function failedAutomaticUpdate(update, policy, reason, stderr) {
11176
+ const command = `npm install -g @aloud/runner@${update.target}`;
11177
+ if (update.required) {
11178
+ stderr(
11179
+ `
11180
+ ${reason}
11181
+ Runner ${RUNNER_VERSION} is below this server's minimum ${policy.minimum}, so nothing was started.
11182
+ Do not use sudo. Fix npm's global prefix, then run: ${command}
11183
+ `
11184
+ );
11185
+ return 1;
11186
+ }
11187
+ stderr(
11188
+ `
11189
+ ${reason}
11190
+ Runner ${RUNNER_VERSION} is still compatible, so it will start without updating.
11191
+ To update it later, run: ${command}
11192
+
11193
+ `
11194
+ );
11195
+ return null;
11196
+ }
11197
+ async function installedRunnerEntry(target) {
11198
+ const root = await commandOutput("npm", ["root", "--global"]);
11199
+ if (!root) return null;
11200
+ const directory = join6(root, "@aloud", "runner");
11201
+ const entry2 = join6(directory, "dist", "cli.js");
11202
+ try {
11203
+ const manifest = JSON.parse(readFileSync(join6(directory, "package.json"), "utf8"));
11204
+ return manifest.version === target && existsSync2(entry2) ? entry2 : null;
11205
+ } catch {
11206
+ return null;
11207
+ }
11208
+ }
11209
+ async function commandOutput(command, args) {
11210
+ return new Promise((resolve) => {
11211
+ const child = spawn2(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
11212
+ let output = "";
11213
+ let settled = false;
11214
+ const finish = (value) => {
11215
+ if (settled) return;
11216
+ settled = true;
11217
+ resolve(value);
11218
+ };
11219
+ child.stdout?.on("data", (chunk) => output += chunk.toString("utf8"));
11220
+ child.on("error", () => finish(null));
11221
+ child.on("close", (code) => finish(code === 0 && output.trim() ? output.trim() : null));
11222
+ });
11223
+ }
11224
+ async function relaunchUpdatedRunner(entry2, argv) {
11225
+ return new Promise((resolve) => {
11226
+ let settled = false;
11227
+ const finish = (code) => {
11228
+ if (settled) return;
11229
+ settled = true;
11230
+ resolve(code);
11231
+ };
11232
+ const child = spawn2(process.execPath, [entry2, "start", ...argv, "--no-update"], {
11233
+ stdio: "inherit"
11234
+ });
11235
+ child.on("error", (error) => {
11236
+ process.stderr.write(`The runner updated but could not relaunch: ${error.message}
11237
+ Run \`aloud start\` again.
11238
+ `);
11239
+ finish(1);
11240
+ });
11241
+ child.on("close", (code, signal) => {
11242
+ finish(code ?? (signal ? 130 : 1));
11243
+ });
11244
+ });
11245
+ }
10938
11246
  async function status() {
10939
11247
  const credentials = await loadCredentials().catch((error) => {
10940
11248
  process.stderr.write(error.message + "\n");
@@ -10942,6 +11250,7 @@ async function status() {
10942
11250
  });
10943
11251
  const checks = await preflight();
10944
11252
  const running = await readRunning();
11253
+ const signedIn = await signedInState(credentials);
10945
11254
  process.stdout.write("\n");
10946
11255
  if (!credentials) {
10947
11256
  process.stdout.write("Signed in no. Run `aloud login`.\n");
@@ -10959,10 +11268,14 @@ async function status() {
10959
11268
  );
10960
11269
  process.stdout.write(
10961
11270
  `Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}
11271
+ `
11272
+ );
11273
+ process.stdout.write(
11274
+ `Aloud sees ${signedIn.state === "ok" ? signedIn.online ? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""}` : "no current check-in" : signedIn.state === "unreachable" ? "unknown, server did not answer" : "no"}
10962
11275
  `
10963
11276
  );
10964
11277
  process.stdout.write("\n");
10965
- return credentials && checks.chromiumInstalled && running ? 0 : 1;
11278
+ return credentials && checks.chromiumInstalled && running && signedIn.state === "ok" && signedIn.online ? 0 : 1;
10966
11279
  }
10967
11280
  async function allow(argv) {
10968
11281
  const host = argv.find((arg) => !arg.startsWith("-"));
@@ -10992,6 +11305,17 @@ async function start(argv) {
10992
11305
  process.stderr.write("Not signed in. Run `aloud login` first.\n");
10993
11306
  return 1;
10994
11307
  }
11308
+ const existing = await readRunning();
11309
+ if (existing && existing.pid !== process.pid) {
11310
+ process.stderr.write(
11311
+ `Aloud runner is already running as pid ${existing.pid}. Stop that process before starting another.
11312
+ To restart it: kill ${existing.pid} && aloud start
11313
+ `
11314
+ );
11315
+ return 1;
11316
+ }
11317
+ const updated = await updateBeforeStart(credentials, argv);
11318
+ if (updated !== null) return updated;
10995
11319
  const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
10996
11320
  const checks = await preflight();
10997
11321
  if (!checks.chromiumInstalled) {
@@ -11091,5 +11415,6 @@ if (entry.endsWith("cli.ts") || entry.endsWith("cli.js") || entry.endsWith("/alo
11091
11415
  }
11092
11416
  export {
11093
11417
  CredentialsError,
11094
- main
11418
+ main,
11419
+ updateBeforeStart
11095
11420
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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": {
@@ -17,7 +17,7 @@
17
17
  ".": "./src/index.ts",
18
18
  "./*": "./src/*.ts"
19
19
  },
20
- "bin": { "aloud": "./dist/cli.js" },
20
+ "bin": { "aloud": "dist/cli.js" },
21
21
  "files": ["dist/cli.js", "src", "README.md"],
22
22
  "engines": { "node": ">=20" },
23
23
  "scripts": {