@aloud/runner 0.3.0 → 0.3.1
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 +15 -2
- package/dist/cli.js +226 -20
- package/package.json +2 -2
- package/src/cli.ts +212 -7
- package/src/version.ts +56 -1
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 #
|
|
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]` |
|
|
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.
|
|
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.
|
|
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}}
|
|
@@ -3381,8 +3385,39 @@ function alive(pid) {
|
|
|
3381
3385
|
}
|
|
3382
3386
|
|
|
3383
3387
|
// src/version.ts
|
|
3384
|
-
var RUNNER_VERSION = "0.3.
|
|
3388
|
+
var RUNNER_VERSION = "0.3.1";
|
|
3385
3389
|
var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
|
|
3390
|
+
function versionParts(value) {
|
|
3391
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
|
3392
|
+
if (!match) return null;
|
|
3393
|
+
const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
3394
|
+
return parts.every(Number.isSafeInteger) ? parts : null;
|
|
3395
|
+
}
|
|
3396
|
+
function compareRunnerVersions(left, right) {
|
|
3397
|
+
const a = versionParts(left);
|
|
3398
|
+
const b = versionParts(right);
|
|
3399
|
+
if (!a || !b) return null;
|
|
3400
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
3401
|
+
if (a[index] > b[index]) return 1;
|
|
3402
|
+
if (a[index] < b[index]) return -1;
|
|
3403
|
+
}
|
|
3404
|
+
return 0;
|
|
3405
|
+
}
|
|
3406
|
+
function runnerVersionPolicyFrom(value) {
|
|
3407
|
+
if (!value || typeof value !== "object") return null;
|
|
3408
|
+
const policy = value;
|
|
3409
|
+
if (typeof policy.minimum !== "string" || typeof policy.recommended !== "string") return null;
|
|
3410
|
+
const order = compareRunnerVersions(policy.recommended, policy.minimum);
|
|
3411
|
+
if (order === null || order < 0) return null;
|
|
3412
|
+
return { minimum: policy.minimum, recommended: policy.recommended };
|
|
3413
|
+
}
|
|
3414
|
+
function runnerUpdateFor(policy, current = RUNNER_VERSION) {
|
|
3415
|
+
const recommendedOrder = compareRunnerVersions(policy.recommended, current);
|
|
3416
|
+
const minimumOrder = compareRunnerVersions(policy.minimum, current);
|
|
3417
|
+
if (recommendedOrder === null || minimumOrder === null) return null;
|
|
3418
|
+
if (recommendedOrder <= 0) return null;
|
|
3419
|
+
return { target: policy.recommended, required: minimumOrder > 0 };
|
|
3420
|
+
}
|
|
3386
3421
|
|
|
3387
3422
|
// src/protocol/client.ts
|
|
3388
3423
|
var LeaseLostError = class extends Error {
|
|
@@ -7485,7 +7520,7 @@ function buildRunLimits(input) {
|
|
|
7485
7520
|
const { synthesis, snapshot } = input;
|
|
7486
7521
|
if (snapshot.cast.some((persona) => persona.completionOriented === true)) {
|
|
7487
7522
|
limits.push(
|
|
7488
|
-
"One participant was deliberately
|
|
7523
|
+
"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
7524
|
);
|
|
7490
7525
|
}
|
|
7491
7526
|
if (synthesis.usableSessions.length < snapshot.cast.length) {
|
|
@@ -9378,7 +9413,6 @@ var PersonaProposalResponse = z24.object({
|
|
|
9378
9413
|
patience: Level,
|
|
9379
9414
|
urgency: Level,
|
|
9380
9415
|
emotionalStance: z24.enum(["curious", "neutral", "sceptical", "hurried", "anxious", "enthusiastic"]),
|
|
9381
|
-
device: DeviceContext,
|
|
9382
9416
|
background: z24.string().max(1200).default(""),
|
|
9383
9417
|
attentionBias: z24.string().max(400).default(""),
|
|
9384
9418
|
completionOriented: z24.boolean().default(false),
|
|
@@ -9446,6 +9480,15 @@ init_src();
|
|
|
9446
9480
|
init_src();
|
|
9447
9481
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
9448
9482
|
|
|
9483
|
+
// ../app/src/services/runner-lease.ts
|
|
9484
|
+
init_src();
|
|
9485
|
+
|
|
9486
|
+
// ../app/src/services/run-finalize.ts
|
|
9487
|
+
init_src();
|
|
9488
|
+
|
|
9489
|
+
// ../app/src/services/runner-lease.ts
|
|
9490
|
+
var DEFAULT_LEASE_SECONDS = 20 * 60;
|
|
9491
|
+
|
|
9449
9492
|
// ../app/src/application.ts
|
|
9450
9493
|
var PreflightFailedError = class extends Error {
|
|
9451
9494
|
constructor(preflight2) {
|
|
@@ -9661,10 +9704,6 @@ init_src();
|
|
|
9661
9704
|
// ../app/src/services/recommendations.ts
|
|
9662
9705
|
init_src();
|
|
9663
9706
|
|
|
9664
|
-
// ../app/src/services/runner-lease.ts
|
|
9665
|
-
init_src();
|
|
9666
|
-
var DEFAULT_LEASE_SECONDS = 20 * 60;
|
|
9667
|
-
|
|
9668
9707
|
// ../app/src/services/scheduling.ts
|
|
9669
9708
|
init_src();
|
|
9670
9709
|
|
|
@@ -9832,7 +9871,7 @@ function text(value) {
|
|
|
9832
9871
|
function json(value) {
|
|
9833
9872
|
return text(JSON.stringify(value, null, 2));
|
|
9834
9873
|
}
|
|
9835
|
-
async function handleTool(context, name, input, execute) {
|
|
9874
|
+
async function handleTool(context, name, input, execute, prepare) {
|
|
9836
9875
|
const { app, actor, workspaceId } = context;
|
|
9837
9876
|
switch (name) {
|
|
9838
9877
|
case "create_study": {
|
|
@@ -9965,6 +10004,21 @@ async function handleTool(context, name, input, execute) {
|
|
|
9965
10004
|
}
|
|
9966
10005
|
case "start_study": {
|
|
9967
10006
|
const parsed = StartStudyInput.parse(input);
|
|
10007
|
+
const blocked = await prepare?.(parsed.studyId, parsed.idempotencyKey);
|
|
10008
|
+
if (blocked) {
|
|
10009
|
+
return {
|
|
10010
|
+
...json({
|
|
10011
|
+
started: false,
|
|
10012
|
+
reason: "runner_not_ready",
|
|
10013
|
+
code: blocked.code,
|
|
10014
|
+
blockedHost: blocked.blockedHost,
|
|
10015
|
+
message: blocked.message,
|
|
10016
|
+
runId: blocked.existingRunId ?? null,
|
|
10017
|
+
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."
|
|
10018
|
+
}),
|
|
10019
|
+
isError: true
|
|
10020
|
+
};
|
|
10021
|
+
}
|
|
9968
10022
|
let outcome;
|
|
9969
10023
|
try {
|
|
9970
10024
|
outcome = await app.startRun(actor, {
|
|
@@ -10308,7 +10362,7 @@ function createMcpServer(options) {
|
|
|
10308
10362
|
function backendFor(options) {
|
|
10309
10363
|
if ("backend" in options) return options.backend;
|
|
10310
10364
|
return {
|
|
10311
|
-
callTool: (name, input) => handleTool(options.context, name, input, options.execute),
|
|
10365
|
+
callTool: (name, input) => handleTool(options.context, name, input, options.execute, options.prepare),
|
|
10312
10366
|
readResource: (parsed) => readResource(options.context, parsed)
|
|
10313
10367
|
};
|
|
10314
10368
|
}
|
|
@@ -10512,6 +10566,11 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
10512
10566
|
return allow(rest);
|
|
10513
10567
|
case "setup":
|
|
10514
10568
|
return setup();
|
|
10569
|
+
case "version":
|
|
10570
|
+
case "--version":
|
|
10571
|
+
case "-v":
|
|
10572
|
+
process.stdout.write(RUNNER_VERSION + "\n");
|
|
10573
|
+
return 0;
|
|
10515
10574
|
case "mcp":
|
|
10516
10575
|
if (rest[0] === "connect") return connectMcp(rest.slice(1));
|
|
10517
10576
|
if (rest[0] === "disconnect") return disconnectMcp();
|
|
@@ -10538,12 +10597,14 @@ function printHelp() {
|
|
|
10538
10597
|
"",
|
|
10539
10598
|
" aloud setup What to do next, for a person or an agent",
|
|
10540
10599
|
" aloud login [--token <token>] Connect this machine, approving it in your browser",
|
|
10541
|
-
" aloud start [--once] [--quiet]
|
|
10600
|
+
" aloud start [--once] [--quiet] [--no-update]",
|
|
10601
|
+
" Update, then wait for studies and run them here",
|
|
10542
10602
|
" aloud status What is set up, and whether it is running",
|
|
10543
10603
|
" aloud allow <host> Let studies open this host from this machine",
|
|
10544
10604
|
" aloud mcp Serve MCP to an editor, using the saved credential",
|
|
10545
10605
|
" aloud mcp connect Connect an editor, approving it in your browser",
|
|
10546
10606
|
" aloud logout Forget the token on this machine",
|
|
10607
|
+
" aloud --version Print the installed runner version",
|
|
10547
10608
|
"",
|
|
10548
10609
|
`Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
|
|
10549
10610
|
""
|
|
@@ -10728,7 +10789,7 @@ async function setup() {
|
|
|
10728
10789
|
const running = await readRunning();
|
|
10729
10790
|
const installed = onPath("aloud");
|
|
10730
10791
|
const latest = await latestVersion();
|
|
10731
|
-
const stale = latest !== null && latest
|
|
10792
|
+
const stale = latest !== null && compareRunnerVersions(latest, RUNNER_VERSION) === 1;
|
|
10732
10793
|
const signedIn = await signedInState(credentials);
|
|
10733
10794
|
const out = (line = "") => process.stdout.write(line + "\n");
|
|
10734
10795
|
out();
|
|
@@ -10751,11 +10812,11 @@ async function setup() {
|
|
|
10751
10812
|
}
|
|
10752
10813
|
if (signedIn.state === "revoked") {
|
|
10753
10814
|
steps.push([
|
|
10754
|
-
|
|
10755
|
-
"The saved
|
|
10815
|
+
"aloud login",
|
|
10816
|
+
"The saved connection was revoked. This prints a new browser-approval link and code."
|
|
10756
10817
|
]);
|
|
10757
10818
|
}
|
|
10758
|
-
if (signedIn.state === "none"
|
|
10819
|
+
if (signedIn.state === "none") {
|
|
10759
10820
|
steps.push([
|
|
10760
10821
|
"aloud login",
|
|
10761
10822
|
"Prints a link and a short code, then waits. Give both to the person; they approve in",
|
|
@@ -10795,7 +10856,7 @@ async function setup() {
|
|
|
10795
10856
|
);
|
|
10796
10857
|
}
|
|
10797
10858
|
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
|
|
10859
|
+
out(" Do not install a similarly named package.");
|
|
10799
10860
|
out(" - There is nothing secret for you to handle. `aloud login` prints a link and a code,");
|
|
10800
10861
|
out(" and the credential goes straight to this machine. Never ask anyone for a token.");
|
|
10801
10862
|
out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
|
|
@@ -10935,6 +10996,139 @@ async function latestVersion() {
|
|
|
10935
10996
|
return null;
|
|
10936
10997
|
}
|
|
10937
10998
|
}
|
|
10999
|
+
async function serverVersionPolicy(credentials) {
|
|
11000
|
+
try {
|
|
11001
|
+
const client = new RunnerClient({ server: credentials.server, token: credentials.token });
|
|
11002
|
+
const response = await client.request("api/runner/me", {
|
|
11003
|
+
retry: false
|
|
11004
|
+
});
|
|
11005
|
+
return runnerVersionPolicyFrom(response.body?.runnerVersionPolicy);
|
|
11006
|
+
} catch {
|
|
11007
|
+
return null;
|
|
11008
|
+
}
|
|
11009
|
+
}
|
|
11010
|
+
async function updateBeforeStart(credentials, argv, overrides = {}) {
|
|
11011
|
+
const stdout = overrides.stdout ?? ((text2) => process.stdout.write(text2));
|
|
11012
|
+
const stderr = overrides.stderr ?? ((text2) => process.stderr.write(text2));
|
|
11013
|
+
const policy = await (overrides.versionPolicy ?? serverVersionPolicy)(credentials);
|
|
11014
|
+
if (!policy) return null;
|
|
11015
|
+
const update = runnerUpdateFor(policy);
|
|
11016
|
+
if (!update) return null;
|
|
11017
|
+
if (argv.includes("--no-update")) {
|
|
11018
|
+
if (!update.required) return null;
|
|
11019
|
+
stderr(
|
|
11020
|
+
`Runner ${RUNNER_VERSION} cannot start studies on this server; ${policy.minimum} or newer is required.
|
|
11021
|
+
Automatic updates were disabled. Remove --no-update or run npm install -g @aloud/runner@${update.target}.
|
|
11022
|
+
`
|
|
11023
|
+
);
|
|
11024
|
+
return 1;
|
|
11025
|
+
}
|
|
11026
|
+
stdout(`
|
|
11027
|
+
Updating Aloud runner ${RUNNER_VERSION} \u2192 ${update.target} before it starts.
|
|
11028
|
+
`);
|
|
11029
|
+
const prefix = await (overrides.npmPrefix ?? npmPrefix)();
|
|
11030
|
+
if (prefix?.writable === false) {
|
|
11031
|
+
return failedAutomaticUpdate(
|
|
11032
|
+
update,
|
|
11033
|
+
policy,
|
|
11034
|
+
`npm's global install directory (${prefix.path}) is not writable by this user.`,
|
|
11035
|
+
stderr
|
|
11036
|
+
);
|
|
11037
|
+
}
|
|
11038
|
+
const installed = overrides.install ? await overrides.install(update.target, stdout) : await run(
|
|
11039
|
+
"npm",
|
|
11040
|
+
["install", "-g", `@aloud/runner@${update.target}`],
|
|
11041
|
+
(line = "") => stdout(line + "\n")
|
|
11042
|
+
);
|
|
11043
|
+
if (!installed) {
|
|
11044
|
+
return failedAutomaticUpdate(update, policy, "npm did not complete the update.", stderr);
|
|
11045
|
+
}
|
|
11046
|
+
const entry2 = await (overrides.installedEntry ?? installedRunnerEntry)(update.target);
|
|
11047
|
+
if (!entry2) {
|
|
11048
|
+
return failedAutomaticUpdate(
|
|
11049
|
+
update,
|
|
11050
|
+
policy,
|
|
11051
|
+
`npm completed, but the installed @aloud/runner@${update.target} could not be verified.`,
|
|
11052
|
+
stderr
|
|
11053
|
+
);
|
|
11054
|
+
}
|
|
11055
|
+
stdout(`
|
|
11056
|
+
Updated to ${update.target}. Starting it now.
|
|
11057
|
+
|
|
11058
|
+
`);
|
|
11059
|
+
return (overrides.relaunch ?? relaunchUpdatedRunner)(entry2, argv);
|
|
11060
|
+
}
|
|
11061
|
+
function failedAutomaticUpdate(update, policy, reason, stderr) {
|
|
11062
|
+
const command = `npm install -g @aloud/runner@${update.target}`;
|
|
11063
|
+
if (update.required) {
|
|
11064
|
+
stderr(
|
|
11065
|
+
`
|
|
11066
|
+
${reason}
|
|
11067
|
+
Runner ${RUNNER_VERSION} is below this server's minimum ${policy.minimum}, so nothing was started.
|
|
11068
|
+
Do not use sudo. Fix npm's global prefix, then run: ${command}
|
|
11069
|
+
`
|
|
11070
|
+
);
|
|
11071
|
+
return 1;
|
|
11072
|
+
}
|
|
11073
|
+
stderr(
|
|
11074
|
+
`
|
|
11075
|
+
${reason}
|
|
11076
|
+
Runner ${RUNNER_VERSION} is still compatible, so it will start without updating.
|
|
11077
|
+
To update it later, run: ${command}
|
|
11078
|
+
|
|
11079
|
+
`
|
|
11080
|
+
);
|
|
11081
|
+
return null;
|
|
11082
|
+
}
|
|
11083
|
+
async function installedRunnerEntry(target) {
|
|
11084
|
+
const root = await commandOutput("npm", ["root", "--global"]);
|
|
11085
|
+
if (!root) return null;
|
|
11086
|
+
const directory = join6(root, "@aloud", "runner");
|
|
11087
|
+
const entry2 = join6(directory, "dist", "cli.js");
|
|
11088
|
+
try {
|
|
11089
|
+
const manifest = JSON.parse(readFileSync(join6(directory, "package.json"), "utf8"));
|
|
11090
|
+
return manifest.version === target && existsSync2(entry2) ? entry2 : null;
|
|
11091
|
+
} catch {
|
|
11092
|
+
return null;
|
|
11093
|
+
}
|
|
11094
|
+
}
|
|
11095
|
+
async function commandOutput(command, args) {
|
|
11096
|
+
return new Promise((resolve) => {
|
|
11097
|
+
const child = spawn2(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
|
|
11098
|
+
let output = "";
|
|
11099
|
+
let settled = false;
|
|
11100
|
+
const finish = (value) => {
|
|
11101
|
+
if (settled) return;
|
|
11102
|
+
settled = true;
|
|
11103
|
+
resolve(value);
|
|
11104
|
+
};
|
|
11105
|
+
child.stdout?.on("data", (chunk) => output += chunk.toString("utf8"));
|
|
11106
|
+
child.on("error", () => finish(null));
|
|
11107
|
+
child.on("close", (code) => finish(code === 0 && output.trim() ? output.trim() : null));
|
|
11108
|
+
});
|
|
11109
|
+
}
|
|
11110
|
+
async function relaunchUpdatedRunner(entry2, argv) {
|
|
11111
|
+
return new Promise((resolve) => {
|
|
11112
|
+
let settled = false;
|
|
11113
|
+
const finish = (code) => {
|
|
11114
|
+
if (settled) return;
|
|
11115
|
+
settled = true;
|
|
11116
|
+
resolve(code);
|
|
11117
|
+
};
|
|
11118
|
+
const child = spawn2(process.execPath, [entry2, "start", ...argv, "--no-update"], {
|
|
11119
|
+
stdio: "inherit"
|
|
11120
|
+
});
|
|
11121
|
+
child.on("error", (error) => {
|
|
11122
|
+
process.stderr.write(`The runner updated but could not relaunch: ${error.message}
|
|
11123
|
+
Run \`aloud start\` again.
|
|
11124
|
+
`);
|
|
11125
|
+
finish(1);
|
|
11126
|
+
});
|
|
11127
|
+
child.on("close", (code, signal) => {
|
|
11128
|
+
finish(code ?? (signal ? 130 : 1));
|
|
11129
|
+
});
|
|
11130
|
+
});
|
|
11131
|
+
}
|
|
10938
11132
|
async function status() {
|
|
10939
11133
|
const credentials = await loadCredentials().catch((error) => {
|
|
10940
11134
|
process.stderr.write(error.message + "\n");
|
|
@@ -10992,6 +11186,17 @@ async function start(argv) {
|
|
|
10992
11186
|
process.stderr.write("Not signed in. Run `aloud login` first.\n");
|
|
10993
11187
|
return 1;
|
|
10994
11188
|
}
|
|
11189
|
+
const existing = await readRunning();
|
|
11190
|
+
if (existing && existing.pid !== process.pid) {
|
|
11191
|
+
process.stderr.write(
|
|
11192
|
+
`Aloud runner is already running as pid ${existing.pid}. Stop that process before starting another.
|
|
11193
|
+
To restart it: kill ${existing.pid} && aloud start
|
|
11194
|
+
`
|
|
11195
|
+
);
|
|
11196
|
+
return 1;
|
|
11197
|
+
}
|
|
11198
|
+
const updated = await updateBeforeStart(credentials, argv);
|
|
11199
|
+
if (updated !== null) return updated;
|
|
10995
11200
|
const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
|
|
10996
11201
|
const checks = await preflight();
|
|
10997
11202
|
if (!checks.chromiumInstalled) {
|
|
@@ -11091,5 +11296,6 @@ if (entry.endsWith("cli.ts") || entry.endsWith("cli.js") || entry.endsWith("/alo
|
|
|
11091
11296
|
}
|
|
11092
11297
|
export {
|
|
11093
11298
|
CredentialsError,
|
|
11094
|
-
main
|
|
11299
|
+
main,
|
|
11300
|
+
updateBeforeStart
|
|
11095
11301
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aloud/runner",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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": "
|
|
20
|
+
"bin": { "aloud": "dist/cli.js" },
|
|
21
21
|
"files": ["dist/cli.js", "src", "README.md"],
|
|
22
22
|
"engines": { "node": ">=20" },
|
|
23
23
|
"scripts": {
|
package/src/cli.ts
CHANGED
|
@@ -31,7 +31,14 @@ import {
|
|
|
31
31
|
} from "./config/credentials";
|
|
32
32
|
import { policyFrom, type LocalPolicy } from "./config/policy";
|
|
33
33
|
import { clearRunning, readRunning, runningPath, writeRunning, type RunningState } from "./config/running";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
RUNNER_VERSION,
|
|
36
|
+
compareRunnerVersions,
|
|
37
|
+
runnerUpdateFor,
|
|
38
|
+
runnerVersionPolicyFrom,
|
|
39
|
+
type RunnerUpdate,
|
|
40
|
+
type RunnerVersionPolicy,
|
|
41
|
+
} from "./version";
|
|
35
42
|
import { RunnerClient } from "./protocol/client";
|
|
36
43
|
import { installChromium, preflight } from "./preflight";
|
|
37
44
|
import { TerminalReporter } from "./ui/output";
|
|
@@ -57,6 +64,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
57
64
|
return allow(rest);
|
|
58
65
|
case "setup":
|
|
59
66
|
return setup();
|
|
67
|
+
case "version":
|
|
68
|
+
case "--version":
|
|
69
|
+
case "-v":
|
|
70
|
+
process.stdout.write(RUNNER_VERSION + "\n");
|
|
71
|
+
return 0;
|
|
60
72
|
case "mcp":
|
|
61
73
|
// `aloud mcp` is the server an MCP host launches; `aloud mcp connect` is how it gets a
|
|
62
74
|
// credential in the first place. Same word, because from the outside they are one feature.
|
|
@@ -86,12 +98,14 @@ function printHelp(): void {
|
|
|
86
98
|
"",
|
|
87
99
|
" aloud setup What to do next, for a person or an agent",
|
|
88
100
|
" aloud login [--token <token>] Connect this machine, approving it in your browser",
|
|
89
|
-
" aloud start [--once] [--quiet]
|
|
101
|
+
" aloud start [--once] [--quiet] [--no-update]",
|
|
102
|
+
" Update, then wait for studies and run them here",
|
|
90
103
|
" aloud status What is set up, and whether it is running",
|
|
91
104
|
" aloud allow <host> Let studies open this host from this machine",
|
|
92
105
|
" aloud mcp Serve MCP to an editor, using the saved credential",
|
|
93
106
|
" aloud mcp connect Connect an editor, approving it in your browser",
|
|
94
107
|
" aloud logout Forget the token on this machine",
|
|
108
|
+
" aloud --version Print the installed runner version",
|
|
95
109
|
"",
|
|
96
110
|
`Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
|
|
97
111
|
"",
|
|
@@ -329,7 +343,7 @@ async function setup(): Promise<number> {
|
|
|
329
343
|
const running = await readRunning();
|
|
330
344
|
const installed = onPath("aloud");
|
|
331
345
|
const latest = await latestVersion();
|
|
332
|
-
const stale = latest !== null && latest
|
|
346
|
+
const stale = latest !== null && compareRunnerVersions(latest, RUNNER_VERSION) === 1;
|
|
333
347
|
const signedIn = await signedInState(credentials);
|
|
334
348
|
|
|
335
349
|
const out = (line = "") => process.stdout.write(line + "\n");
|
|
@@ -369,11 +383,11 @@ async function setup(): Promise<number> {
|
|
|
369
383
|
}
|
|
370
384
|
if (signedIn.state === "revoked") {
|
|
371
385
|
steps.push([
|
|
372
|
-
|
|
373
|
-
"The saved
|
|
386
|
+
"aloud login",
|
|
387
|
+
"The saved connection was revoked. This prints a new browser-approval link and code.",
|
|
374
388
|
]);
|
|
375
389
|
}
|
|
376
|
-
if (signedIn.state === "none"
|
|
390
|
+
if (signedIn.state === "none") {
|
|
377
391
|
steps.push([
|
|
378
392
|
"aloud login",
|
|
379
393
|
"Prints a link and a short code, then waits. Give both to the person; they approve in",
|
|
@@ -418,7 +432,7 @@ async function setup(): Promise<number> {
|
|
|
418
432
|
);
|
|
419
433
|
}
|
|
420
434
|
out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
|
|
421
|
-
out(" Do not install a similarly named package
|
|
435
|
+
out(" Do not install a similarly named package.");
|
|
422
436
|
out(" - There is nothing secret for you to handle. `aloud login` prints a link and a code,");
|
|
423
437
|
out(" and the credential goes straight to this machine. Never ask anyone for a token.");
|
|
424
438
|
out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
|
|
@@ -624,6 +638,182 @@ async function latestVersion(): Promise<string | null> {
|
|
|
624
638
|
}
|
|
625
639
|
}
|
|
626
640
|
|
|
641
|
+
/**
|
|
642
|
+
* The release policy belongs to the control plane this machine is connected to, not to npm.
|
|
643
|
+
*
|
|
644
|
+
* That distinction lets a self-hosted deployment deliberately lag the hosted service and lets the
|
|
645
|
+
* hosted service publish a runner before recommending it. The response is validated before any
|
|
646
|
+
* part of it reaches an install command; even a compromised control plane can select only an exact
|
|
647
|
+
* numeric release of the official scoped package.
|
|
648
|
+
*/
|
|
649
|
+
async function serverVersionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null> {
|
|
650
|
+
try {
|
|
651
|
+
const client = new RunnerClient({ server: credentials.server, token: credentials.token });
|
|
652
|
+
const response = await client.request<{ runnerVersionPolicy?: unknown }>("api/runner/me", {
|
|
653
|
+
retry: false,
|
|
654
|
+
});
|
|
655
|
+
return runnerVersionPolicyFrom(response.body?.runnerVersionPolicy);
|
|
656
|
+
} catch {
|
|
657
|
+
// Startup still reaches the ordinary authenticated claim below. A revoked token, unreachable
|
|
658
|
+
// server, or old self-hosted control plane will be explained there; an update convenience must
|
|
659
|
+
// not replace the runner's established connection behavior.
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Updates before any browser or lease exists, then starts the newly installed bundle.
|
|
666
|
+
*
|
|
667
|
+
* `null` means the current process should continue. A number means startup has been handed to the
|
|
668
|
+
* replacement process, or could not safely continue because the server requires that replacement.
|
|
669
|
+
*/
|
|
670
|
+
export interface StartUpdateDependencies {
|
|
671
|
+
versionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null>;
|
|
672
|
+
npmPrefix(): Promise<{ path: string; writable: boolean | null } | null>;
|
|
673
|
+
install(target: string, write: (text: string) => void): Promise<boolean>;
|
|
674
|
+
installedEntry(target: string): Promise<string | null>;
|
|
675
|
+
relaunch(entry: string, argv: readonly string[]): Promise<number>;
|
|
676
|
+
stdout(text: string): void;
|
|
677
|
+
stderr(text: string): void;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function updateBeforeStart(
|
|
681
|
+
credentials: Credentials,
|
|
682
|
+
argv: readonly string[],
|
|
683
|
+
overrides: Partial<StartUpdateDependencies> = {},
|
|
684
|
+
): Promise<number | null> {
|
|
685
|
+
const stdout = overrides.stdout ?? ((text: string) => process.stdout.write(text));
|
|
686
|
+
const stderr = overrides.stderr ?? ((text: string) => process.stderr.write(text));
|
|
687
|
+
const policy = await (overrides.versionPolicy ?? serverVersionPolicy)(credentials);
|
|
688
|
+
if (!policy) return null;
|
|
689
|
+
const update = runnerUpdateFor(policy);
|
|
690
|
+
if (!update) return null;
|
|
691
|
+
|
|
692
|
+
if (argv.includes("--no-update")) {
|
|
693
|
+
if (!update.required) return null;
|
|
694
|
+
stderr(
|
|
695
|
+
`Runner ${RUNNER_VERSION} cannot start studies on this server; ${policy.minimum} or newer is required.\n` +
|
|
696
|
+
`Automatic updates were disabled. Remove --no-update or run npm install -g @aloud/runner@${update.target}.\n`,
|
|
697
|
+
);
|
|
698
|
+
return 1;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
stdout(`\nUpdating Aloud runner ${RUNNER_VERSION} → ${update.target} before it starts.\n`);
|
|
702
|
+
const prefix = await (overrides.npmPrefix ?? npmPrefix)();
|
|
703
|
+
if (prefix?.writable === false) {
|
|
704
|
+
return failedAutomaticUpdate(
|
|
705
|
+
update,
|
|
706
|
+
policy,
|
|
707
|
+
`npm's global install directory (${prefix.path}) is not writable by this user.`,
|
|
708
|
+
stderr,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const installed = overrides.install
|
|
713
|
+
? await overrides.install(update.target, stdout)
|
|
714
|
+
: await run(
|
|
715
|
+
"npm",
|
|
716
|
+
["install", "-g", `@aloud/runner@${update.target}`],
|
|
717
|
+
(line = "") => stdout(line + "\n"),
|
|
718
|
+
);
|
|
719
|
+
if (!installed) {
|
|
720
|
+
return failedAutomaticUpdate(update, policy, "npm did not complete the update.", stderr);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Do not relaunch `process.argv[1]`: a runner started through npx or a project-local shim can
|
|
724
|
+
// live somewhere entirely different from the global package npm just replaced. Resolve npm's
|
|
725
|
+
// canonical global root and verify the exact installed version before handing it any work.
|
|
726
|
+
const entry = await (overrides.installedEntry ?? installedRunnerEntry)(update.target);
|
|
727
|
+
if (!entry) {
|
|
728
|
+
return failedAutomaticUpdate(
|
|
729
|
+
update,
|
|
730
|
+
policy,
|
|
731
|
+
`npm completed, but the installed @aloud/runner@${update.target} could not be verified.`,
|
|
732
|
+
stderr,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
stdout(`\nUpdated to ${update.target}. Starting it now.\n\n`);
|
|
737
|
+
return (overrides.relaunch ?? relaunchUpdatedRunner)(entry, argv);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function failedAutomaticUpdate(
|
|
741
|
+
update: RunnerUpdate,
|
|
742
|
+
policy: RunnerVersionPolicy,
|
|
743
|
+
reason: string,
|
|
744
|
+
stderr: (text: string) => void,
|
|
745
|
+
): number | null {
|
|
746
|
+
const command = `npm install -g @aloud/runner@${update.target}`;
|
|
747
|
+
if (update.required) {
|
|
748
|
+
stderr(
|
|
749
|
+
`\n${reason}\nRunner ${RUNNER_VERSION} is below this server's minimum ${policy.minimum}, so nothing was started.\n` +
|
|
750
|
+
`Do not use sudo. Fix npm's global prefix, then run: ${command}\n`,
|
|
751
|
+
);
|
|
752
|
+
return 1;
|
|
753
|
+
}
|
|
754
|
+
stderr(
|
|
755
|
+
`\n${reason}\nRunner ${RUNNER_VERSION} is still compatible, so it will start without updating.\n` +
|
|
756
|
+
`To update it later, run: ${command}\n\n`,
|
|
757
|
+
);
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Finds the exact global bundle npm just installed, independently of how this process was run. */
|
|
762
|
+
async function installedRunnerEntry(target: string): Promise<string | null> {
|
|
763
|
+
const root = await commandOutput("npm", ["root", "--global"]);
|
|
764
|
+
if (!root) return null;
|
|
765
|
+
|
|
766
|
+
const directory = join(root, "@aloud", "runner");
|
|
767
|
+
const entry = join(directory, "dist", "cli.js");
|
|
768
|
+
try {
|
|
769
|
+
const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")) as {
|
|
770
|
+
version?: unknown;
|
|
771
|
+
};
|
|
772
|
+
return manifest.version === target && existsSync(entry) ? entry : null;
|
|
773
|
+
} catch {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Captures one short command result without involving a shell. */
|
|
779
|
+
async function commandOutput(command: string, args: readonly string[]): Promise<string | null> {
|
|
780
|
+
return new Promise((resolve) => {
|
|
781
|
+
const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
|
|
782
|
+
let output = "";
|
|
783
|
+
let settled = false;
|
|
784
|
+
const finish = (value: string | null) => {
|
|
785
|
+
if (settled) return;
|
|
786
|
+
settled = true;
|
|
787
|
+
resolve(value);
|
|
788
|
+
};
|
|
789
|
+
child.stdout?.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
|
|
790
|
+
child.on("error", () => finish(null));
|
|
791
|
+
child.on("close", (code: number | null) => finish(code === 0 && output.trim() ? output.trim() : null));
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** The old bundle stays only as a transparent parent while the verified new bundle runs. */
|
|
796
|
+
async function relaunchUpdatedRunner(entry: string, argv: readonly string[]): Promise<number> {
|
|
797
|
+
return new Promise((resolve) => {
|
|
798
|
+
let settled = false;
|
|
799
|
+
const finish = (code: number) => {
|
|
800
|
+
if (settled) return;
|
|
801
|
+
settled = true;
|
|
802
|
+
resolve(code);
|
|
803
|
+
};
|
|
804
|
+
const child = spawn(process.execPath, [entry, "start", ...argv, "--no-update"], {
|
|
805
|
+
stdio: "inherit",
|
|
806
|
+
});
|
|
807
|
+
child.on("error", (error: Error) => {
|
|
808
|
+
process.stderr.write(`The runner updated but could not relaunch: ${error.message}\nRun \`aloud start\` again.\n`);
|
|
809
|
+
finish(1);
|
|
810
|
+
});
|
|
811
|
+
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
812
|
+
finish(code ?? (signal ? 130 : 1));
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
627
817
|
/* --------------------------------- status --------------------------------- */
|
|
628
818
|
|
|
629
819
|
async function status(): Promise<number> {
|
|
@@ -689,6 +879,21 @@ async function start(argv: readonly string[]): Promise<number> {
|
|
|
689
879
|
return 1;
|
|
690
880
|
}
|
|
691
881
|
|
|
882
|
+
// Two starts using one runner identity can claim two studies while status reports only the last
|
|
883
|
+
// pid written. Refuse before updating or polling; a restart has to stop the existing process
|
|
884
|
+
// first, which also guarantees we never replace its package while it is inside an active study.
|
|
885
|
+
const existing = await readRunning();
|
|
886
|
+
if (existing && existing.pid !== process.pid) {
|
|
887
|
+
process.stderr.write(
|
|
888
|
+
`Aloud runner is already running as pid ${existing.pid}. Stop that process before starting another.\n` +
|
|
889
|
+
`To restart it: kill ${existing.pid} && aloud start\n`,
|
|
890
|
+
);
|
|
891
|
+
return 1;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const updated = await updateBeforeStart(credentials, argv);
|
|
895
|
+
if (updated !== null) return updated;
|
|
896
|
+
|
|
692
897
|
const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
|
|
693
898
|
|
|
694
899
|
// Preflight runs before the first claim, deliberately. A missing Chromium makes
|
package/src/version.ts
CHANGED
|
@@ -10,7 +10,62 @@
|
|
|
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.3.
|
|
13
|
+
export const RUNNER_VERSION = "0.3.1";
|
|
14
14
|
|
|
15
15
|
/** The header the server reads it from. */
|
|
16
16
|
export const RUNNER_VERSION_HEADER = "x-aloud-runner-version";
|
|
17
|
+
|
|
18
|
+
export interface RunnerVersionPolicy {
|
|
19
|
+
/** The oldest release that may claim new work. */
|
|
20
|
+
minimum: string;
|
|
21
|
+
/** The exact official release a start should move to when it can. */
|
|
22
|
+
recommended: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Strict because a server response eventually becomes part of an npm package specifier. */
|
|
26
|
+
function versionParts(value: string): [number, number, number] | null {
|
|
27
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as [number, number, number];
|
|
30
|
+
return parts.every(Number.isSafeInteger) ? parts : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Numeric comparison, so 0.10.0 is newer than 0.9.9 rather than lexicographically smaller. */
|
|
34
|
+
export function compareRunnerVersions(left: string, right: string): number | null {
|
|
35
|
+
const a = versionParts(left);
|
|
36
|
+
const b = versionParts(right);
|
|
37
|
+
if (!a || !b) return null;
|
|
38
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
39
|
+
if (a[index]! > b[index]!) return 1;
|
|
40
|
+
if (a[index]! < b[index]!) return -1;
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parses only a coherent policy. A bad control-plane response never becomes an install command. */
|
|
46
|
+
export function runnerVersionPolicyFrom(value: unknown): RunnerVersionPolicy | null {
|
|
47
|
+
if (!value || typeof value !== "object") return null;
|
|
48
|
+
const policy = value as { minimum?: unknown; recommended?: unknown };
|
|
49
|
+
if (typeof policy.minimum !== "string" || typeof policy.recommended !== "string") return null;
|
|
50
|
+
const order = compareRunnerVersions(policy.recommended, policy.minimum);
|
|
51
|
+
if (order === null || order < 0) return null;
|
|
52
|
+
return { minimum: policy.minimum, recommended: policy.recommended };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RunnerUpdate {
|
|
56
|
+
target: string;
|
|
57
|
+
/** Required means the installed release cannot claim work if updating fails. */
|
|
58
|
+
required: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What this installed runner should do with one valid server policy. Never chooses a downgrade. */
|
|
62
|
+
export function runnerUpdateFor(
|
|
63
|
+
policy: RunnerVersionPolicy,
|
|
64
|
+
current: string = RUNNER_VERSION,
|
|
65
|
+
): RunnerUpdate | null {
|
|
66
|
+
const recommendedOrder = compareRunnerVersions(policy.recommended, current);
|
|
67
|
+
const minimumOrder = compareRunnerVersions(policy.minimum, current);
|
|
68
|
+
if (recommendedOrder === null || minimumOrder === null) return null;
|
|
69
|
+
if (recommendedOrder <= 0) return null;
|
|
70
|
+
return { target: policy.recommended, required: minimumOrder > 0 };
|
|
71
|
+
}
|