scenescout 3.14.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +27 -4
- package/dist/check-run.js +22 -11
- package/dist/ci-run.js +113 -17
- package/dist/cli.js +150 -75
- package/dist/commands.js +141 -0
- package/dist/engine/browser.js +546 -133
- package/dist/engine/check.js +77 -30
- package/dist/engine/ci.js +97 -0
- package/dist/engine/claims.js +81 -15
- package/dist/engine/collector.js +307 -65
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +81 -12
- package/dist/engine/forms.js +17 -0
- package/dist/engine/memory.js +180 -36
- package/dist/engine/oracles.js +126 -1
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +270 -22
- package/dist/engine/report.js +8 -1
- package/dist/engine/scripted-login.js +342 -62
- package/dist/first-run.js +577 -0
- package/dist/login-run.js +136 -35
- package/dist/mcp-server.js +75 -16
- package/package.json +3 -2
- package/skills/scenescout/SKILL.md +3 -3
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* SceneScout CLI.
|
|
4
4
|
*
|
|
5
|
+
* scenescout <url> A first look with no setup: observe mode, capped, never a gate
|
|
5
6
|
* scenescout scan <projectPath> Print project discovery results
|
|
6
7
|
* scenescout serve Run the MCP server on stdio
|
|
7
8
|
* scenescout install Install the skill, download the browser, register the MCP server
|
|
@@ -20,15 +21,17 @@ import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, de
|
|
|
20
21
|
import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
|
|
21
22
|
import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
22
23
|
import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
23
|
-
import { httpClient, runCi } from "./ci-run.js";
|
|
24
|
+
import { httpClient, httpJudgeAsk, runCi } from "./ci-run.js";
|
|
24
25
|
import { runLogin, runScriptedLogin, savedLine } from "./login-run.js";
|
|
25
26
|
import { credentialRedactor, LOGIN_ENV, readScriptedLogin } from "./engine/scripted-login.js";
|
|
26
27
|
import { parseLoginArgs } from "./engine/profiles.js";
|
|
27
|
-
import { detectProvider, EXIT_CI, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
|
|
28
|
-
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
|
|
28
|
+
import { detectProvider, EXIT_CI, judgeEffort, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
|
|
29
|
+
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason, } from "./engine/check.js";
|
|
30
|
+
import { downloadLine, EXIT_FIRST_RUN, FIRST_RUN_DIRNAME, firstRunCheckOptions, firstRunDownloads, firstRunSummary, formatFirstRun, modeSentence, parseFirstRunArgs, reportFolderProblem, unreachableReason, writeFirstRunReport, } from "./first-run.js";
|
|
29
31
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
30
32
|
import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
|
|
31
33
|
import { formatScan, scanProject } from "./scan.js";
|
|
34
|
+
import { dispatch } from "./commands.js";
|
|
32
35
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
33
36
|
const packageRoot = path.resolve(here, "..");
|
|
34
37
|
/** This package's version, as published. */
|
|
@@ -40,6 +43,18 @@ function usage(exitCode = 1) {
|
|
|
40
43
|
console.log(`SceneScout ${packageVersion()} — AI exploratory UI testing engine (MCP)
|
|
41
44
|
|
|
42
45
|
Usage:
|
|
46
|
+
scenescout <url> [options] A first look, with no setup: visits the app's pages and measures them (no
|
|
47
|
+
model, no API key), writes ${FIRST_RUN_DIRNAME}/ in this folder and prints
|
|
48
|
+
the three issues to look at first. Downloads Chromium if it is missing and
|
|
49
|
+
changes nothing else: no skill, no MCP registration, nothing on PATH.
|
|
50
|
+
The address comes first, its options after it.
|
|
51
|
+
(--max-routes N (default 20), --max-minutes N (default 3): no page is started
|
|
52
|
+
past either; --mode observe|read-only (default observe: nothing but reads
|
|
53
|
+
leaves the page, sign-in and token refresh apart; read-only lets a plain POST
|
|
54
|
+
through); --out dir: where the report goes. ${FIRST_RUN_DIRNAME}/ is written
|
|
55
|
+
only when it is new, empty or an earlier first look's)
|
|
56
|
+
Exit code: 0 it looked, whatever it found; 2 could not run (the URL could
|
|
57
|
+
not be reached, a bad argument, no browser) or could not write the report.
|
|
43
58
|
scenescout scan <projectPath> Discover framework, routes, auth states
|
|
44
59
|
scenescout serve Run the MCP server (stdio)
|
|
45
60
|
scenescout install One-step setup: skill + Chromium + MCP registration
|
|
@@ -95,7 +110,11 @@ Usage:
|
|
|
95
110
|
--project dir (default: here); --out dir (default: .scenescout/ci);
|
|
96
111
|
--show "the Save button": instead of exploring, capture that element as a PNG
|
|
97
112
|
under shots/; --compare-url https://…: with --show, capture it there too and
|
|
98
|
-
write a diff picture
|
|
113
|
+
write a diff picture;
|
|
114
|
+
--dedup judge|rule: judge (default) also asks the run's model, at its lowest
|
|
115
|
+
effort, whether a filed finding the rule keeps apart is one already on its
|
|
116
|
+
page (titles, categories, evidence and the page's path are sent);
|
|
117
|
+
rule asks nothing)
|
|
99
118
|
Exit code: 0 the run ran (findings never change it), 2 could not run.
|
|
100
119
|
scenescout login <url> --role <name>
|
|
101
120
|
Open a visible browser at the URL, sign in there (SSO, MFA, anything), then
|
|
@@ -107,7 +126,9 @@ Usage:
|
|
|
107
126
|
scenescout login <url> --role <name> --script
|
|
108
127
|
For CI: sign in headless from SCENESCOUT_LOGIN_USERNAME, SCENESCOUT_LOGIN_PASSWORD
|
|
109
128
|
and, if the form asks for a code, SCENESCOUT_LOGIN_TOTP_SECRET (base32 or an
|
|
110
|
-
otpauth:// URI)
|
|
129
|
+
otpauth:// URI) or SCENESCOUT_LOGIN_OTP_CODE (a fixed code a test environment
|
|
130
|
+
accepts), then save the profile as above. With a code and no password, a
|
|
131
|
+
passwordless sign-in (the username, then the code). A test user only. No value
|
|
111
132
|
is ever printed. Exit 0 signed in and saved, 1 not.
|
|
112
133
|
(--success-url text|url; --success-selector css; --username-selector,
|
|
113
134
|
--password-selector, --otp-selector, --submit-selector css; each of these also
|
|
@@ -295,13 +316,6 @@ function flagValue(flags, name) {
|
|
|
295
316
|
const next = flags[at + 1];
|
|
296
317
|
return next === undefined || next.startsWith("--") ? "" : next;
|
|
297
318
|
}
|
|
298
|
-
function browsersFlag(flags) {
|
|
299
|
-
// `--browser-only` is a different flag; a bare `--browser` is a slip that would otherwise be ignored and download Chromium.
|
|
300
|
-
const slip = flags.find((f) => f === "--browser" || f.startsWith("--browser="));
|
|
301
|
-
if (slip)
|
|
302
|
-
throw new Error(`unknown flag ${slip.split("=")[0]} — did you mean --browsers?`);
|
|
303
|
-
return flagValue(flags, "--browsers");
|
|
304
|
-
}
|
|
305
319
|
/** The `code` command on PATH, with its real path: the real path is what tells VS Code from a fork. */
|
|
306
320
|
function codeOnPath() {
|
|
307
321
|
const names = process.platform === "win32" ? ["code.cmd", "code.exe"] : ["code"];
|
|
@@ -349,7 +363,7 @@ async function install(flags) {
|
|
|
349
363
|
// only thing it cannot bring is the browser download.
|
|
350
364
|
const browserOnly = flags.includes("--browser-only");
|
|
351
365
|
// Read the choice before doing anything, so a typo costs nothing.
|
|
352
|
-
const selection = parseBrowserSelection(
|
|
366
|
+
const selection = parseBrowserSelection(flagValue(flags, "--browsers"));
|
|
353
367
|
if ("error" in selection)
|
|
354
368
|
throw new Error(selection.error);
|
|
355
369
|
const chosen = parseClients(flagValues(flags, ["--client", "--clients"]));
|
|
@@ -520,8 +534,6 @@ async function doctor(flags) {
|
|
|
520
534
|
}
|
|
521
535
|
/** `scenescout check`: exit 0 passed, 1 failed the gate, 2 could not run. */
|
|
522
536
|
async function check(args) {
|
|
523
|
-
if (args.includes("--help") || args.includes("-h"))
|
|
524
|
-
usage(0);
|
|
525
537
|
const parsed = parseCheckArgs(args, process.cwd());
|
|
526
538
|
if (!parsed.ok) {
|
|
527
539
|
console.error(`scenescout check: ${parsed.error}`);
|
|
@@ -582,10 +594,92 @@ async function check(args) {
|
|
|
582
594
|
console.error(`scenescout check: could not run a saved flow: ${refused}`);
|
|
583
595
|
process.exit(exitCodeOf(result));
|
|
584
596
|
}
|
|
597
|
+
/** `scenescout <url>`: a first look. Exit 0 once it has looked, whatever it found; 2 when it could not look or could not write its report. */
|
|
598
|
+
async function firstRun(args) {
|
|
599
|
+
const fail = (message) => {
|
|
600
|
+
console.error(`scenescout: ${message}`);
|
|
601
|
+
process.exit(EXIT_FIRST_RUN.couldNotRun);
|
|
602
|
+
};
|
|
603
|
+
const parsed = parseFirstRunArgs(args, process.cwd());
|
|
604
|
+
if (!parsed.ok)
|
|
605
|
+
return fail(parsed.error);
|
|
606
|
+
const options = parsed.options;
|
|
607
|
+
// Found out now, not after the look; and nothing is created until the look has something to write.
|
|
608
|
+
const folderProblem = reportFolderProblem(options.outDir ?? path.join(process.cwd(), FIRST_RUN_DIRNAME), options.outDir !== undefined);
|
|
609
|
+
if (folderProblem)
|
|
610
|
+
return fail(folderProblem);
|
|
611
|
+
console.log(`SceneScout ${packageVersion()} — a first look at ${options.url}`);
|
|
612
|
+
console.log(`It opens pages and measures them and submits no form: up to ${options.maxRoutes} pages, starting none after ${options.maxMinutes} minute(s). No model, no API key.`);
|
|
613
|
+
console.log(modeSentence(options.mode));
|
|
614
|
+
// Only the build a headless Chromium launch needs. Nothing else install does happens here: no skill, no registration, nothing on PATH.
|
|
615
|
+
const downloads = firstRunDownloads(await presentBrowsers());
|
|
616
|
+
if (downloads.length > 0) {
|
|
617
|
+
console.log(downloadLine(downloads));
|
|
618
|
+
const began = Date.now();
|
|
619
|
+
if (!downloadBrowsers(downloads)) {
|
|
620
|
+
return fail(`Chromium could not be downloaded. Check the network or proxy and run this again, or download it by hand: npx playwright install ${downloads.join(" ")}`);
|
|
621
|
+
}
|
|
622
|
+
// The installer's exit code is not the build: look again before saying it is there.
|
|
623
|
+
const still = firstRunDownloads(await presentBrowsers());
|
|
624
|
+
if (still.length > 0)
|
|
625
|
+
return fail(`the download finished, but ${still.join(", ")} is still not where Playwright looks for it. Run: npx playwright install ${still.join(" ")}`);
|
|
626
|
+
console.log(`✓ Chromium downloaded in ${Math.round((Date.now() - began) / 1000)} s.`);
|
|
627
|
+
}
|
|
628
|
+
// An empty project of its own: nothing is read from, or written to, the folder this runs in except the report.
|
|
629
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "scenescout-first-run-"));
|
|
630
|
+
// Also on an exit the finally below never reaches, such as Ctrl+C, which the browser's driver answers with process.exit.
|
|
631
|
+
const removeProject = () => fs.rmSync(projectDir, { recursive: true, force: true });
|
|
632
|
+
process.once("exit", removeProject);
|
|
633
|
+
const began = Date.now();
|
|
634
|
+
let result;
|
|
635
|
+
let error = "";
|
|
636
|
+
try {
|
|
637
|
+
console.log(`\nLooking at ${options.url} …`);
|
|
638
|
+
result = await runCheck(firstRunCheckOptions(options, projectDir), (line) => console.log(line));
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
error = err instanceof Error ? err.message : String(err);
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
process.off("exit", removeProject);
|
|
645
|
+
removeProject();
|
|
646
|
+
}
|
|
647
|
+
if (!result)
|
|
648
|
+
return fail(`could not run: ${error}`);
|
|
649
|
+
const unreachable = unreachableReason(result.routes);
|
|
650
|
+
if (unreachable)
|
|
651
|
+
return fail(`could not reach ${options.url}: ${unreachable}`);
|
|
652
|
+
const facts = { result, options, elapsedMs: Date.now() - began };
|
|
653
|
+
const files = {
|
|
654
|
+
"report.md": formatFirstRun(facts),
|
|
655
|
+
"check.json": JSON.stringify(toSummaryJson(result, packageVersion()), null, 2) + "\n",
|
|
656
|
+
};
|
|
657
|
+
// The look is done whatever happens to the files: its summary is printed either way.
|
|
658
|
+
let written;
|
|
659
|
+
let notWritten = "";
|
|
660
|
+
try {
|
|
661
|
+
written = writeFirstRunReport(files, { cwd: process.cwd(), tmpdir: os.tmpdir(), outDir: options.outDir });
|
|
662
|
+
}
|
|
663
|
+
catch (err) {
|
|
664
|
+
notWritten = err instanceof Error ? err.message : String(err);
|
|
665
|
+
}
|
|
666
|
+
if (written?.note)
|
|
667
|
+
console.log(written.note);
|
|
668
|
+
let where = `not written: ${notWritten}`;
|
|
669
|
+
if (written) {
|
|
670
|
+
const report = path.join(written.dir, "report.md");
|
|
671
|
+
const relative = path.relative(process.cwd(), report);
|
|
672
|
+
where = relative.startsWith("..") || path.isAbsolute(relative) ? report : relative;
|
|
673
|
+
}
|
|
674
|
+
console.log("");
|
|
675
|
+
for (const line of firstRunSummary(facts, where))
|
|
676
|
+
console.log(line);
|
|
677
|
+
if (!written)
|
|
678
|
+
return fail(`could not write the report: ${notWritten}`);
|
|
679
|
+
process.exit(EXIT_FIRST_RUN.ran);
|
|
680
|
+
}
|
|
585
681
|
/** `scenescout ci`: exit 0 when the run ran, 2 when it could not. Findings never change the exit code. */
|
|
586
682
|
async function ci(args) {
|
|
587
|
-
if (args.includes("--help") || args.includes("-h"))
|
|
588
|
-
usage(0);
|
|
589
683
|
const secrets = secretValues(process.env);
|
|
590
684
|
const say = (line) => console.log(redactKeys(line, secrets));
|
|
591
685
|
const fail = (message) => {
|
|
@@ -601,11 +695,15 @@ async function ci(args) {
|
|
|
601
695
|
return fail(provider.error);
|
|
602
696
|
const resolved = provider.resolved;
|
|
603
697
|
const key = (process.env[KEY_ENV[resolved.provider]] ?? "").trim();
|
|
604
|
-
|
|
698
|
+
// The dedup judge asks the run's model at the lowest effort its API takes; --dedup rule asks nothing.
|
|
699
|
+
const judge = options.dedup === "judge" && !options.show ? { ...resolved, effort: judgeEffort(resolved.provider) } : null;
|
|
700
|
+
say(`Exploring ${options.url} with ${resolved.provider} ${resolved.model} (effort ${resolved.effort}), ${options.mode} mode, level ${options.level}` +
|
|
701
|
+
`${judge ? `, duplicates judged by the model at effort ${judge.effort}` : ""} …`);
|
|
605
702
|
let run;
|
|
606
703
|
try {
|
|
607
704
|
run = await runCi(options, resolved, {
|
|
608
705
|
makeClient: (system, tools, kickoff) => httpClient(resolved, key, system, tools, kickoff),
|
|
706
|
+
...(judge ? { judge: httpJudgeAsk(judge, key), judgeEffort: judge.effort } : {}),
|
|
609
707
|
log: say,
|
|
610
708
|
secrets,
|
|
611
709
|
version: packageVersion(),
|
|
@@ -627,8 +725,6 @@ async function ci(args) {
|
|
|
627
725
|
}
|
|
628
726
|
/** `scenescout login`: exit 0 saved, 1 nothing saved. */
|
|
629
727
|
async function login(args) {
|
|
630
|
-
if (args.includes("--help") || args.includes("-h"))
|
|
631
|
-
usage(0);
|
|
632
728
|
const parsed = parseLoginArgs(args, process.cwd());
|
|
633
729
|
if (!parsed.ok) {
|
|
634
730
|
console.error(`scenescout login: ${parsed.error}`);
|
|
@@ -672,61 +768,40 @@ const [, , command, ...args] = process.argv;
|
|
|
672
768
|
// one line the user needs. `serve` is deliberately outside this: it hands off
|
|
673
769
|
// to the MCP server, whose own transport owns error reporting from then on.
|
|
674
770
|
try {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
case "ci": {
|
|
711
|
-
await ci(args);
|
|
712
|
-
break;
|
|
713
|
-
}
|
|
714
|
-
case "login": {
|
|
715
|
-
await login(args);
|
|
716
|
-
break;
|
|
717
|
-
}
|
|
718
|
-
case "status": {
|
|
719
|
-
status(path.resolve(args[0] ?? process.cwd()));
|
|
720
|
-
break;
|
|
721
|
-
}
|
|
722
|
-
case "watch": {
|
|
723
|
-
const positional = args.filter((a) => !a.startsWith("--"));
|
|
724
|
-
watch(path.resolve(positional[0] ?? process.cwd()), !args.includes("--no-open"));
|
|
725
|
-
break;
|
|
726
|
-
}
|
|
727
|
-
default:
|
|
728
|
-
usage();
|
|
729
|
-
}
|
|
771
|
+
await dispatch(command, args, {
|
|
772
|
+
usage,
|
|
773
|
+
version: () => console.log(packageVersion()),
|
|
774
|
+
refuse: (message) => {
|
|
775
|
+
console.error(`scenescout ${command}: ${message}`);
|
|
776
|
+
console.error("Run `scenescout --help` for the options.");
|
|
777
|
+
process.exit(1);
|
|
778
|
+
},
|
|
779
|
+
commands: {
|
|
780
|
+
scan: (a) => {
|
|
781
|
+
if (!a[0])
|
|
782
|
+
usage();
|
|
783
|
+
console.log(formatScan(scanProject(a[0])));
|
|
784
|
+
},
|
|
785
|
+
serve: async () => {
|
|
786
|
+
await import("./mcp-server.js");
|
|
787
|
+
},
|
|
788
|
+
install,
|
|
789
|
+
doctor,
|
|
790
|
+
check,
|
|
791
|
+
ci,
|
|
792
|
+
login,
|
|
793
|
+
status: (a) => status(path.resolve(a[0] ?? process.cwd())),
|
|
794
|
+
watch: (a) => {
|
|
795
|
+
const positional = a.filter((x) => !x.startsWith("--"));
|
|
796
|
+
watch(path.resolve(positional[0] ?? process.cwd()), !a.includes("--no-open"));
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
// Anything unforeseen is still "could not run" (2), not the exit 1 the other commands share below.
|
|
800
|
+
firstRun: (a) => firstRun(a).catch((err) => {
|
|
801
|
+
console.error(`scenescout: could not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
802
|
+
process.exit(EXIT_FIRST_RUN.couldNotRun);
|
|
803
|
+
}),
|
|
804
|
+
});
|
|
730
805
|
}
|
|
731
806
|
catch (err) {
|
|
732
807
|
console.error(`scenescout ${command ?? ""}: ${err instanceof Error ? err.message : String(err)}`);
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the CLI decides what a command line asks for, before any command runs.
|
|
3
|
+
*
|
|
4
|
+
* It lives apart from cli.ts so it can be table-tested: `scenescout install
|
|
5
|
+
* --help` once ran a real install, because install read only the flags it knew
|
|
6
|
+
* and ignored the rest. Every subcommand now answers `--help` / `-h` with the
|
|
7
|
+
* usage text and exit 0 before it does anything, and a command that parses its
|
|
8
|
+
* own flags by hand refuses one it does not know.
|
|
9
|
+
*
|
|
10
|
+
* A first argument that is an address instead of a subcommand is a first run,
|
|
11
|
+
* `scenescout <url>` (first-run.ts), which parses its own options.
|
|
12
|
+
*/
|
|
13
|
+
const HANDLERS_OF = {
|
|
14
|
+
scan: true,
|
|
15
|
+
serve: true,
|
|
16
|
+
install: true,
|
|
17
|
+
doctor: true,
|
|
18
|
+
check: true,
|
|
19
|
+
ci: true,
|
|
20
|
+
login: true,
|
|
21
|
+
status: true,
|
|
22
|
+
watch: true,
|
|
23
|
+
};
|
|
24
|
+
export const SUBCOMMANDS = Object.keys(HANDLERS_OF);
|
|
25
|
+
/**
|
|
26
|
+
* Commands that read their arguments by hand. `check`, `ci` and `login` are
|
|
27
|
+
* absent: their own parsers refuse unknown options. `serve` is absent on
|
|
28
|
+
* purpose: it is the line an MCP client launches, and a stray argument there
|
|
29
|
+
* should not stop the server from starting.
|
|
30
|
+
*/
|
|
31
|
+
export const HAND_PARSED = {
|
|
32
|
+
install: {
|
|
33
|
+
switches: ["--skip-browser", "--no-register", "--no-command", "--browser-only"],
|
|
34
|
+
valued: ["--browsers", "--client", "--clients"],
|
|
35
|
+
// `--browser` is what `check` and `login` call it; for install it would otherwise download Chromium regardless.
|
|
36
|
+
hints: { "--browser": "--browsers" },
|
|
37
|
+
positional: 0,
|
|
38
|
+
},
|
|
39
|
+
doctor: { switches: ["--engine"], valued: [], positional: 0 },
|
|
40
|
+
scan: { switches: [], valued: [], positional: 1 },
|
|
41
|
+
status: { switches: [], valued: [], positional: 1 },
|
|
42
|
+
watch: { switches: ["--no-open"], valued: [], positional: 1 },
|
|
43
|
+
};
|
|
44
|
+
/** True when the arguments ask for help. */
|
|
45
|
+
export function wantsHelp(args) {
|
|
46
|
+
return args.includes("--help") || args.includes("-h");
|
|
47
|
+
}
|
|
48
|
+
/** The first argument `spec` does not accept, as an error sentence; null when every one is accepted. */
|
|
49
|
+
export function unknownArgument(args, spec) {
|
|
50
|
+
let positional = 0;
|
|
51
|
+
for (let i = 0; i < args.length; i++) {
|
|
52
|
+
const arg = args[i];
|
|
53
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
54
|
+
if (++positional > spec.positional)
|
|
55
|
+
return `unexpected argument ${arg}`;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const name = arg.split("=")[0];
|
|
59
|
+
if (spec.valued.includes(name)) {
|
|
60
|
+
// The value after a valued flag is not itself a flag, even when it starts with a dash.
|
|
61
|
+
if (!arg.includes("="))
|
|
62
|
+
i++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (spec.switches.includes(name) && !arg.includes("="))
|
|
66
|
+
continue;
|
|
67
|
+
const hint = spec.hints?.[name];
|
|
68
|
+
return `unknown option ${name}${hint ? ` — did you mean ${hint}?` : ""}`;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** Whether `command` names a subcommand. */
|
|
73
|
+
export function isSubcommand(command) {
|
|
74
|
+
return command !== undefined && Object.hasOwn(HANDLERS_OF, command);
|
|
75
|
+
}
|
|
76
|
+
/** A host with no scheme: a name or address, an optional port, then optionally a path, query or fragment. */
|
|
77
|
+
const BARE_HOST = /^(?:\[[0-9a-f:.]+\]|[a-z0-9-]+(?:\.[a-z0-9-]+)*)(?::\d{1,5})?(?:[/?#].*)?$/i;
|
|
78
|
+
/** Whether an argument starts with a scheme, such as `http://`. */
|
|
79
|
+
export function hasScheme(arg) {
|
|
80
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(arg);
|
|
81
|
+
}
|
|
82
|
+
/** The host part of an address written without its scheme: everything before its path, query or fragment. */
|
|
83
|
+
export function hostOf(arg) {
|
|
84
|
+
return arg.split(/[/?#]/)[0];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether the first argument is an address, which makes the command line a
|
|
88
|
+
* first run (`scenescout <url>`): it has a scheme (`http://…`, and `ftp://…`,
|
|
89
|
+
* which the first run then refuses with a reason), or it reads as a host
|
|
90
|
+
* without one (`localhost:3000`, `example.com/app`), which the first run asks
|
|
91
|
+
* to be written in full. Subcommands are settled first, and a word that is
|
|
92
|
+
* neither (`instal`) still gets the usage.
|
|
93
|
+
*/
|
|
94
|
+
export function looksLikeUrl(arg) {
|
|
95
|
+
if (!arg || arg.startsWith("-"))
|
|
96
|
+
return false;
|
|
97
|
+
if (hasScheme(arg))
|
|
98
|
+
return true;
|
|
99
|
+
if (!BARE_HOST.test(arg))
|
|
100
|
+
return false;
|
|
101
|
+
const host = hostOf(arg);
|
|
102
|
+
return host.includes(".") || host.includes(":") || host.toLowerCase() === "localhost";
|
|
103
|
+
}
|
|
104
|
+
/** Settle help and flag errors for a subcommand before its handler is reached. */
|
|
105
|
+
export function preflight(command, args) {
|
|
106
|
+
if (wantsHelp(args))
|
|
107
|
+
return { kind: "help" };
|
|
108
|
+
const spec = Object.hasOwn(HAND_PARSED, command) ? HAND_PARSED[command] : undefined;
|
|
109
|
+
const problem = spec ? unknownArgument(args, spec) : null;
|
|
110
|
+
return problem ? { kind: "error", message: problem } : { kind: "run" };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Run the command line: help and flag errors are settled here, so a subcommand's
|
|
114
|
+
* handler is reached only when it is actually meant to run.
|
|
115
|
+
*/
|
|
116
|
+
export async function dispatch(command, args, h) {
|
|
117
|
+
switch (command) {
|
|
118
|
+
// Asking for help is not an error; scripts and shells treat a non-zero exit as one.
|
|
119
|
+
case "--help":
|
|
120
|
+
case "-h":
|
|
121
|
+
case "help":
|
|
122
|
+
return h.usage(0);
|
|
123
|
+
case "--version":
|
|
124
|
+
case "-v":
|
|
125
|
+
return h.version();
|
|
126
|
+
}
|
|
127
|
+
if (!isSubcommand(command)) {
|
|
128
|
+
if (!looksLikeUrl(command))
|
|
129
|
+
return h.usage(1);
|
|
130
|
+
// Its own parser refuses an option it does not know, with the first run's exit code, as check's does.
|
|
131
|
+
if (wantsHelp(args))
|
|
132
|
+
return h.usage(0);
|
|
133
|
+
return h.firstRun([command, ...args]);
|
|
134
|
+
}
|
|
135
|
+
const verdict = preflight(command, args);
|
|
136
|
+
if (verdict.kind === "help")
|
|
137
|
+
return h.usage(0);
|
|
138
|
+
if (verdict.kind === "error")
|
|
139
|
+
return h.refuse(verdict.message);
|
|
140
|
+
await h.commands[command](args);
|
|
141
|
+
}
|