@extension.dev/mcp 4.9.0 → 5.1.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +162 -22
- package/README.md +3 -3
- package/claude/ARCHITECTURE.md +2 -2
- package/claude/CLAUDE.md +15 -15
- package/claude/README.md +8 -8
- package/claude/commands/extension-add.md +10 -10
- package/claude/commands/extension-debug.md +10 -10
- package/claude/commands/extension.md +8 -8
- package/claude/examples/add-sidebar.md +2 -2
- package/claude/examples/create-extension.md +2 -2
- package/claude/rules/cross-browser.md +1 -1
- package/claude/rules/extension-dev.md +12 -12
- package/claude/rules/mcp-tools.md +45 -45
- package/dist/module.js +1039 -351
- package/dist/src/lib/exec.d.ts +6 -1
- package/dist/src/lib/session-browser.d.ts +7 -1
- package/dist/src/lib/types.d.ts +3 -1
- package/dist/src/tools/build.d.ts +6 -0
- package/dist/src/tools/doctor.d.ts +1 -0
- package/dist/src/tools/dom-inspect.d.ts +13 -2
- package/dist/src/tools/open.d.ts +11 -0
- package/package.json +6 -4
- package/server.json +2 -2
package/dist/module.js
CHANGED
|
@@ -9,8 +9,9 @@ import node_os from "node:os";
|
|
|
9
9
|
import cross_spawn from "cross-spawn";
|
|
10
10
|
import { execFile, execFileSync } from "node:child_process";
|
|
11
11
|
import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
|
|
12
|
-
import
|
|
12
|
+
import ws_0 from "ws";
|
|
13
13
|
import node_http from "node:http";
|
|
14
|
+
import node_crypto from "node:crypto";
|
|
14
15
|
import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
|
|
15
16
|
import { promisify } from "node:util";
|
|
16
17
|
var add_feature_namespaceObject = {};
|
|
@@ -53,6 +54,7 @@ var doctor_namespaceObject = {};
|
|
|
53
54
|
__webpack_require__.r(doctor_namespaceObject);
|
|
54
55
|
__webpack_require__.d(doctor_namespaceObject, {
|
|
55
56
|
handler: ()=>doctor_handler,
|
|
57
|
+
recentErrorLogs: ()=>recentErrorLogs,
|
|
56
58
|
schema: ()=>doctor_schema
|
|
57
59
|
});
|
|
58
60
|
var dom_inspect_namespaceObject = {};
|
|
@@ -130,6 +132,7 @@ __webpack_require__.d(manifest_validate_namespaceObject, {
|
|
|
130
132
|
var open_namespaceObject = {};
|
|
131
133
|
__webpack_require__.r(open_namespaceObject);
|
|
132
134
|
__webpack_require__.d(open_namespaceObject, {
|
|
135
|
+
clampPopupBounds: ()=>clampPopupBounds,
|
|
133
136
|
handler: ()=>open_handler,
|
|
134
137
|
schema: ()=>open_schema
|
|
135
138
|
});
|
|
@@ -199,7 +202,16 @@ __webpack_require__.d(whoami_namespaceObject, {
|
|
|
199
202
|
handler: ()=>whoami_handler,
|
|
200
203
|
schema: ()=>whoami_schema
|
|
201
204
|
});
|
|
202
|
-
var package_namespaceObject = JSON.parse('{"rE":"4.
|
|
205
|
+
var package_namespaceObject = JSON.parse('{"rE":"4.9.0","El":{"OP":"^4.0.13"}}');
|
|
206
|
+
function scaffoldEnginePin(projectPath) {
|
|
207
|
+
try {
|
|
208
|
+
const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
|
|
209
|
+
const spec = pkg?.devDependencies?.extension ?? pkg?.dependencies?.extension ?? null;
|
|
210
|
+
return "string" == typeof spec ? spec : null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
203
215
|
function detectPackageManager(projectPath) {
|
|
204
216
|
const byLockfile = [
|
|
205
217
|
[
|
|
@@ -238,7 +250,7 @@ const create_schema = {
|
|
|
238
250
|
},
|
|
239
251
|
parentDir: {
|
|
240
252
|
type: "string",
|
|
241
|
-
description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect
|
|
253
|
+
description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect, pass this explicitly when you care where the project lands."
|
|
242
254
|
},
|
|
243
255
|
template: {
|
|
244
256
|
type: "string",
|
|
@@ -288,7 +300,7 @@ async function create_handler(args) {
|
|
|
288
300
|
}
|
|
289
301
|
});
|
|
290
302
|
const failure = (err, transient)=>JSON.stringify({
|
|
291
|
-
error: transient ? "Template download failed (network/timeout/rate-limit)
|
|
303
|
+
error: transient ? "Template download failed (network/timeout/rate-limit). This is not a bad template name. Retry, or check connectivity/GitHub rate limits." : err instanceof Error ? err.message : String(err),
|
|
292
304
|
...transient ? {
|
|
293
305
|
cause: err instanceof Error ? err.message : String(err)
|
|
294
306
|
} : {},
|
|
@@ -308,10 +320,23 @@ async function create_handler(args) {
|
|
|
308
320
|
return failure(err2, looksTransient());
|
|
309
321
|
}
|
|
310
322
|
}
|
|
311
|
-
const
|
|
323
|
+
const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
|
|
324
|
+
if (!hasManifest) return JSON.stringify({
|
|
325
|
+
ok: false,
|
|
326
|
+
status: "incomplete",
|
|
327
|
+
projectPath: result.projectPath,
|
|
328
|
+
error: `The scaffold is incomplete: no manifest.json exists under ${result.projectPath} (checked the root and src/). Do not run extension_dev against it.`,
|
|
329
|
+
duration: Date.now() - start,
|
|
330
|
+
log: logTail(),
|
|
331
|
+
hint: "Delete the directory and retry extension_create; a template download interrupted mid-way can leave a partial tree."
|
|
332
|
+
});
|
|
333
|
+
const packageManager = result.packageManager || (result.depsInstalled ? detectPackageManager(result.projectPath) : "npm");
|
|
312
334
|
const runDev = `${packageManager} run dev`;
|
|
335
|
+
const addDev = (spec)=>"npm" === packageManager ? `npm i -D ${spec}` : "yarn" === packageManager ? `yarn add -D ${spec}` : `${packageManager} add -D ${spec}`;
|
|
313
336
|
const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
|
|
314
|
-
const
|
|
337
|
+
const scaffoldPin = scaffoldEnginePin(result.projectPath);
|
|
338
|
+
const pinMatches = null !== scaffoldPin && "" !== pin && scaffoldPin.includes(pin);
|
|
339
|
+
const engineWarning = pin && "latest" !== pin && !pinMatches ? `The scaffold pins "extension": "${scaffoldPin ?? "unknown"}"; the project-local engine wins over EXTENSION_MCP_CLI_VERSION=${pin}. Run \`(cd ${result.projectPath} && ${addDev(`extension@${pin}`)})\` to match the pinned engine.` : void 0;
|
|
315
340
|
return JSON.stringify({
|
|
316
341
|
projectPath: result.projectPath,
|
|
317
342
|
projectName: result.projectName,
|
|
@@ -324,8 +349,8 @@ async function create_handler(args) {
|
|
|
324
349
|
runDev
|
|
325
350
|
] : [
|
|
326
351
|
`cd ${result.projectPath}`,
|
|
327
|
-
|
|
328
|
-
|
|
352
|
+
`${packageManager} install`,
|
|
353
|
+
runDev
|
|
329
354
|
],
|
|
330
355
|
...engineWarning ? {
|
|
331
356
|
engineWarning
|
|
@@ -547,6 +572,9 @@ function runExtensionCli(args, options) {
|
|
|
547
572
|
}
|
|
548
573
|
function spawnExtensionCli(args, options) {
|
|
549
574
|
const { command, prefixArgs } = resolveExtensionInvocation(options?.projectDir ?? options?.cwd);
|
|
575
|
+
const logDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "extension-mcp-"));
|
|
576
|
+
const logPath = node_path.join(logDir, "session.log");
|
|
577
|
+
const fd = node_fs.openSync(logPath, "a");
|
|
550
578
|
const child = cross_spawn(command, [
|
|
551
579
|
...prefixArgs,
|
|
552
580
|
...args
|
|
@@ -555,8 +583,8 @@ function spawnExtensionCli(args, options) {
|
|
|
555
583
|
detached: true,
|
|
556
584
|
stdio: [
|
|
557
585
|
"ignore",
|
|
558
|
-
|
|
559
|
-
|
|
586
|
+
fd,
|
|
587
|
+
fd
|
|
560
588
|
],
|
|
561
589
|
env: {
|
|
562
590
|
...process.env,
|
|
@@ -564,8 +592,29 @@ function spawnExtensionCli(args, options) {
|
|
|
564
592
|
NO_COLOR: "1"
|
|
565
593
|
}
|
|
566
594
|
});
|
|
595
|
+
node_fs.closeSync(fd);
|
|
567
596
|
child.unref();
|
|
568
|
-
return
|
|
597
|
+
return {
|
|
598
|
+
child,
|
|
599
|
+
logPath,
|
|
600
|
+
readOutput: ()=>{
|
|
601
|
+
try {
|
|
602
|
+
return node_fs.readFileSync(logPath, "utf8");
|
|
603
|
+
} catch {
|
|
604
|
+
return "";
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function readBuildSummary(projectPath, browser, since) {
|
|
610
|
+
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
|
|
611
|
+
try {
|
|
612
|
+
const stat = node_fs.statSync(file);
|
|
613
|
+
if (stat.mtimeMs < since) return null;
|
|
614
|
+
const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
615
|
+
if (summary && "object" == typeof summary) return summary;
|
|
616
|
+
} catch {}
|
|
617
|
+
return null;
|
|
569
618
|
}
|
|
570
619
|
function builtEntrypoints(distDir) {
|
|
571
620
|
let manifest;
|
|
@@ -585,14 +634,31 @@ function builtEntrypoints(distDir) {
|
|
|
585
634
|
};
|
|
586
635
|
const bg = manifest.background;
|
|
587
636
|
if (bg?.service_worker) add("background.service_worker", bg.service_worker);
|
|
637
|
+
if (bg?.page) add("background.page", bg.page);
|
|
588
638
|
if (Array.isArray(bg?.scripts)) bg.scripts.forEach((s)=>add("background.scripts", s));
|
|
589
639
|
const action = manifest.action || manifest.browser_action;
|
|
590
640
|
if (action?.default_popup) add("action.default_popup", action.default_popup);
|
|
641
|
+
const pageAction = manifest.page_action;
|
|
642
|
+
if (pageAction?.default_popup) add("page_action.default_popup", pageAction.default_popup);
|
|
591
643
|
const cs = manifest.content_scripts;
|
|
592
644
|
if (Array.isArray(cs)) cs.forEach((c, i)=>{
|
|
593
645
|
if (Array.isArray(c.js)) c.js.forEach((j)=>add(`content_scripts[${i}].js`, j));
|
|
594
646
|
if (Array.isArray(c.css)) c.css.forEach((s)=>add(`content_scripts[${i}].css`, s));
|
|
595
647
|
});
|
|
648
|
+
add("devtools_page", manifest.devtools_page);
|
|
649
|
+
add("options_page", manifest.options_page);
|
|
650
|
+
const optionsUi = manifest.options_ui;
|
|
651
|
+
if (optionsUi?.page) add("options_ui.page", optionsUi.page);
|
|
652
|
+
const sidePanel = manifest.side_panel;
|
|
653
|
+
if (sidePanel?.default_path) add("side_panel.default_path", sidePanel.default_path);
|
|
654
|
+
const sidebarAction = manifest.sidebar_action;
|
|
655
|
+
if (sidebarAction?.default_panel) add("sidebar_action.default_panel", sidebarAction.default_panel);
|
|
656
|
+
const overrides = manifest.chrome_url_overrides;
|
|
657
|
+
if (overrides) for (const [key, ref] of Object.entries(overrides))add(`chrome_url_overrides.${key}`, ref);
|
|
658
|
+
const dnr = manifest.declarative_net_request;
|
|
659
|
+
if (dnr && Array.isArray(dnr.rule_resources)) dnr.rule_resources.forEach((r, i)=>{
|
|
660
|
+
if (r && "object" == typeof r) add(`declarative_net_request[${i}].path`, r.path);
|
|
661
|
+
});
|
|
596
662
|
return out;
|
|
597
663
|
}
|
|
598
664
|
const build_schema = {
|
|
@@ -660,6 +726,11 @@ const build_schema = {
|
|
|
660
726
|
],
|
|
661
727
|
default: "production",
|
|
662
728
|
description: "Bundler mode override (also sets NODE_ENV)"
|
|
729
|
+
},
|
|
730
|
+
skipValidation: {
|
|
731
|
+
type: "boolean",
|
|
732
|
+
default: false,
|
|
733
|
+
description: "Build even when extension_manifest_validate reports build-blocking errors. The build normally refuses, because a manifest error means the bundle it produces is broken in ways the bundler itself does not report."
|
|
663
734
|
}
|
|
664
735
|
},
|
|
665
736
|
required: [
|
|
@@ -667,9 +738,67 @@ const build_schema = {
|
|
|
667
738
|
]
|
|
668
739
|
}
|
|
669
740
|
};
|
|
741
|
+
function manifestDivergence(projectPath, browser) {
|
|
742
|
+
const read = (p)=>{
|
|
743
|
+
try {
|
|
744
|
+
return JSON.parse(node_fs.readFileSync(p, "utf8"));
|
|
745
|
+
} catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
const built = read(node_path.resolve(projectPath, "dist", browser, "manifest.json"));
|
|
750
|
+
const source = read(node_path.resolve(projectPath, "src", "manifest.json")) ?? read(node_path.resolve(projectPath, "manifest.json"));
|
|
751
|
+
if (!built || !source) return [];
|
|
752
|
+
const notes = [];
|
|
753
|
+
const listOf = (m, key)=>Array.isArray(m[key]) ? m[key].filter((x)=>"string" == typeof x) : [];
|
|
754
|
+
for (const key of [
|
|
755
|
+
"permissions",
|
|
756
|
+
"host_permissions",
|
|
757
|
+
"optional_permissions"
|
|
758
|
+
]){
|
|
759
|
+
const lost = listOf(source, key).filter((p)=>!listOf(built, key).includes(p));
|
|
760
|
+
if (lost.length) notes.push(`The built manifest drops ${key}: ${lost.join(", ")}. The production build has narrower access than the source you tested in dev.`);
|
|
761
|
+
}
|
|
762
|
+
const sourceWar = source.web_accessible_resources;
|
|
763
|
+
const builtWar = built.web_accessible_resources;
|
|
764
|
+
if (Array.isArray(sourceWar) && sourceWar.length && !Array.isArray(builtWar)) notes.push("The built manifest has no web_accessible_resources although the source declares them. Anything injected into a page (scripting.insertCSS targets, injected scripts, images) will be blocked at runtime.");
|
|
765
|
+
return notes;
|
|
766
|
+
}
|
|
767
|
+
async function validationPreflight(projectPath, browser) {
|
|
768
|
+
try {
|
|
769
|
+
const manifestValidate = await Promise.resolve().then(()=>({
|
|
770
|
+
handler: manifest_validate_handler
|
|
771
|
+
}));
|
|
772
|
+
const parsed = JSON.parse(await manifestValidate.handler({
|
|
773
|
+
projectPath,
|
|
774
|
+
browsers: [
|
|
775
|
+
browser
|
|
776
|
+
]
|
|
777
|
+
}));
|
|
778
|
+
return {
|
|
779
|
+
valid: Boolean(parsed.valid),
|
|
780
|
+
buildBlocking: Boolean(parsed.buildBlocking),
|
|
781
|
+
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
|
782
|
+
warnings: Array.isArray(parsed.warnings) ? parsed.warnings : []
|
|
783
|
+
};
|
|
784
|
+
} catch {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
670
788
|
async function build_handler(args) {
|
|
671
789
|
const start = Date.now();
|
|
672
790
|
const browser = args.browser ?? "chrome";
|
|
791
|
+
const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
|
|
792
|
+
if (preflight?.buildBlocking) return JSON.stringify({
|
|
793
|
+
success: false,
|
|
794
|
+
status: "blocked",
|
|
795
|
+
browser,
|
|
796
|
+
error: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds.",
|
|
797
|
+
errors: preflight.errors,
|
|
798
|
+
warnings: preflight.warnings,
|
|
799
|
+
duration: Date.now() - start,
|
|
800
|
+
hint: "Fix the errors above, then build again. Run extension_manifest_validate for the full report. To build anyway (for example to inspect the broken output), pass skipValidation: true."
|
|
801
|
+
});
|
|
673
802
|
const cliArgs = [
|
|
674
803
|
"build",
|
|
675
804
|
args.projectPath,
|
|
@@ -692,7 +821,30 @@ async function build_handler(args) {
|
|
|
692
821
|
if (0 === code) {
|
|
693
822
|
const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
|
|
694
823
|
const status = out.match(/Build Status:\s*(\w+)/)?.[1];
|
|
824
|
+
const engineSummary = readBuildSummary(args.projectPath, browser, start);
|
|
825
|
+
const buildWarnings = engineSummary?.warnings?.length ? {
|
|
826
|
+
buildWarnings: engineSummary.warnings,
|
|
827
|
+
..."number" == typeof engineSummary.warnings_count && engineSummary.warnings_count > engineSummary.warnings.length ? {
|
|
828
|
+
buildWarningsTruncated: engineSummary.warnings_count
|
|
829
|
+
} : {}
|
|
830
|
+
} : {};
|
|
695
831
|
const entrypoints = builtEntrypoints(node_path.resolve(args.projectPath, "dist", browser));
|
|
832
|
+
const missing = entrypoints.filter((e)=>!e.present);
|
|
833
|
+
if (missing.length) return JSON.stringify({
|
|
834
|
+
success: false,
|
|
835
|
+
status: "incomplete",
|
|
836
|
+
browser,
|
|
837
|
+
buildExitCode: 0,
|
|
838
|
+
error: `The build reported success but ${missing.length} declared entrypoint(s) are missing from dist/${browser}: ` + missing.map((m)=>`${m.role} -> ${m.path}`).join(", ") + ". The browser will refuse to load this build.",
|
|
839
|
+
entrypoints,
|
|
840
|
+
...preflight?.warnings.length ? {
|
|
841
|
+
manifestWarnings: preflight.warnings
|
|
842
|
+
} : {},
|
|
843
|
+
...buildWarnings,
|
|
844
|
+
duration,
|
|
845
|
+
output: lastLines(out, 12),
|
|
846
|
+
hint: "The bundler exited 0 but did not emit these files. Check that the manifest paths match what the build produces, and that nothing references a file outside the source tree."
|
|
847
|
+
});
|
|
696
848
|
return JSON.stringify({
|
|
697
849
|
success: true,
|
|
698
850
|
browser,
|
|
@@ -705,6 +857,16 @@ async function build_handler(args) {
|
|
|
705
857
|
...entrypoints.length ? {
|
|
706
858
|
entrypoints
|
|
707
859
|
} : {},
|
|
860
|
+
...preflight?.warnings.length ? {
|
|
861
|
+
manifestWarnings: preflight.warnings
|
|
862
|
+
} : {},
|
|
863
|
+
...buildWarnings,
|
|
864
|
+
...(()=>{
|
|
865
|
+
const divergence = manifestDivergence(args.projectPath, browser);
|
|
866
|
+
return divergence.length ? {
|
|
867
|
+
productionDivergence: divergence
|
|
868
|
+
} : {};
|
|
869
|
+
})(),
|
|
708
870
|
zip: args.zip ?? false,
|
|
709
871
|
duration,
|
|
710
872
|
output: lastLines(out, 12)
|
|
@@ -833,7 +995,7 @@ const dev_schema = {
|
|
|
833
995
|
allowEval: {
|
|
834
996
|
type: "boolean",
|
|
835
997
|
default: false,
|
|
836
|
-
description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect
|
|
998
|
+
description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect. You do not need to pass both."
|
|
837
999
|
}
|
|
838
1000
|
},
|
|
839
1001
|
required: [
|
|
@@ -856,9 +1018,10 @@ async function dev_handler(args) {
|
|
|
856
1018
|
cliArgs.push(...launchFlagArgs(args));
|
|
857
1019
|
if (allowControl) cliArgs.push("--allow-control");
|
|
858
1020
|
if (args.allowEval) cliArgs.push("--allow-eval");
|
|
859
|
-
const
|
|
1021
|
+
const spawned = spawnExtensionCli(cliArgs, {
|
|
860
1022
|
projectDir: args.projectPath
|
|
861
1023
|
});
|
|
1024
|
+
const { child, logPath } = spawned;
|
|
862
1025
|
const pid = child.pid;
|
|
863
1026
|
registerSession({
|
|
864
1027
|
pid,
|
|
@@ -868,15 +1031,37 @@ async function dev_handler(args) {
|
|
|
868
1031
|
command: "dev"
|
|
869
1032
|
});
|
|
870
1033
|
child.on("exit", ()=>removeSession(args.projectPath, browser));
|
|
871
|
-
let earlyOutput = "";
|
|
872
|
-
const collector = (data)=>{
|
|
873
|
-
earlyOutput += data.toString();
|
|
874
|
-
};
|
|
875
|
-
child.stdout?.on("data", collector);
|
|
876
|
-
child.stderr?.on("data", collector);
|
|
877
1034
|
await new Promise((resolve)=>setTimeout(resolve, 3000));
|
|
878
|
-
|
|
879
|
-
child.
|
|
1035
|
+
const earlyOutput = spawned.readOutput();
|
|
1036
|
+
if (null !== child.exitCode || null !== child.signalCode) {
|
|
1037
|
+
const code = child.exitCode;
|
|
1038
|
+
const signal = child.signalCode;
|
|
1039
|
+
return JSON.stringify({
|
|
1040
|
+
ok: false,
|
|
1041
|
+
status: "exited",
|
|
1042
|
+
projectPath: args.projectPath,
|
|
1043
|
+
browser,
|
|
1044
|
+
pid,
|
|
1045
|
+
exitCode: code,
|
|
1046
|
+
signal,
|
|
1047
|
+
error: `The dev server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running, so extension_logs/wait/eval and the control verbs have nothing to attach to.`,
|
|
1048
|
+
output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
|
|
1049
|
+
logPath,
|
|
1050
|
+
hint: "Read `output` above for the cause: a port already in use, a manifest the build rejects, or a missing browser binary are the common ones. Fix it and call extension_dev again; extension_doctor with this projectPath will also report what the last session recorded."
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(earlyOutput);
|
|
1054
|
+
if (compileFailed) return JSON.stringify({
|
|
1055
|
+
ok: false,
|
|
1056
|
+
status: "compile-failed",
|
|
1057
|
+
projectPath: args.projectPath,
|
|
1058
|
+
browser,
|
|
1059
|
+
pid,
|
|
1060
|
+
error: "The dev server started but the FIRST COMPILE FAILED, so the browser has nothing usable to load. The session is running; the extension is not.",
|
|
1061
|
+
output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
|
|
1062
|
+
logPath,
|
|
1063
|
+
hint: "Fix the compile error in `output` above and save: the dev server is still running and will recompile. Do not call extension_wait yet, it will report ready for a build that failed."
|
|
1064
|
+
});
|
|
880
1065
|
const controlVerbs = "storage, reload, open, dom_inspect";
|
|
881
1066
|
const capabilities = {
|
|
882
1067
|
allowControl,
|
|
@@ -884,6 +1069,7 @@ async function dev_handler(args) {
|
|
|
884
1069
|
unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
|
|
885
1070
|
};
|
|
886
1071
|
return JSON.stringify({
|
|
1072
|
+
ok: true,
|
|
887
1073
|
pid,
|
|
888
1074
|
browser,
|
|
889
1075
|
port: args.port ?? 8080,
|
|
@@ -891,7 +1077,8 @@ async function dev_handler(args) {
|
|
|
891
1077
|
status: "started",
|
|
892
1078
|
capabilities,
|
|
893
1079
|
hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). Restart extension_dev with the flag you need.") + " When you are done, call extension_stop to shut down the dev server and browser.",
|
|
894
|
-
earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500)
|
|
1080
|
+
earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
|
|
1081
|
+
logPath
|
|
895
1082
|
});
|
|
896
1083
|
}
|
|
897
1084
|
function denoiseEarlyOutput(raw) {
|
|
@@ -905,6 +1092,93 @@ function denoiseEarlyOutput(raw) {
|
|
|
905
1092
|
];
|
|
906
1093
|
return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
|
|
907
1094
|
}
|
|
1095
|
+
function contractSightings(projectPath) {
|
|
1096
|
+
const root = node_path.resolve(projectPath, "dist", "extension-js");
|
|
1097
|
+
let dirs;
|
|
1098
|
+
try {
|
|
1099
|
+
dirs = node_fs.readdirSync(root);
|
|
1100
|
+
} catch {
|
|
1101
|
+
return [];
|
|
1102
|
+
}
|
|
1103
|
+
const sightings = [];
|
|
1104
|
+
for (const dir of dirs){
|
|
1105
|
+
const readyPath = node_path.join(root, dir, "ready.json");
|
|
1106
|
+
try {
|
|
1107
|
+
const stat = node_fs.statSync(readyPath);
|
|
1108
|
+
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
1109
|
+
if (contract?.status !== "ready") continue;
|
|
1110
|
+
sightings.push({
|
|
1111
|
+
browser: dir,
|
|
1112
|
+
mtimeMs: stat.mtimeMs,
|
|
1113
|
+
pid: "number" == typeof contract.pid ? contract.pid : void 0
|
|
1114
|
+
});
|
|
1115
|
+
} catch {}
|
|
1116
|
+
}
|
|
1117
|
+
return sightings;
|
|
1118
|
+
}
|
|
1119
|
+
function pidAlive(pid) {
|
|
1120
|
+
try {
|
|
1121
|
+
process.kill(pid, 0);
|
|
1122
|
+
return true;
|
|
1123
|
+
} catch {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
function knownSessionBrowsers(projectPath) {
|
|
1128
|
+
const resolved = node_path.resolve(projectPath);
|
|
1129
|
+
const browsers = [];
|
|
1130
|
+
for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
|
|
1131
|
+
for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
|
|
1132
|
+
return Array.from(new Set(browsers));
|
|
1133
|
+
}
|
|
1134
|
+
function deadReadySession(projectPath) {
|
|
1135
|
+
for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
|
|
1136
|
+
browser: sighting.browser,
|
|
1137
|
+
pid: sighting.pid
|
|
1138
|
+
};
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
function browserExitStamp(projectPath, browser, since) {
|
|
1142
|
+
const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
1143
|
+
try {
|
|
1144
|
+
const stat = node_fs.statSync(readyPath);
|
|
1145
|
+
if (stat.mtimeMs < since) return null;
|
|
1146
|
+
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
1147
|
+
const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
|
|
1148
|
+
if (contract?.status === "error" && exited) return {
|
|
1149
|
+
code: contract.code,
|
|
1150
|
+
browserExitCode: contract.browserExitCode ?? null,
|
|
1151
|
+
browserExitedAt: contract.browserExitedAt
|
|
1152
|
+
};
|
|
1153
|
+
} catch {}
|
|
1154
|
+
return null;
|
|
1155
|
+
}
|
|
1156
|
+
function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
|
|
1157
|
+
if (explicit) return {
|
|
1158
|
+
browser: explicit,
|
|
1159
|
+
source: "explicit"
|
|
1160
|
+
};
|
|
1161
|
+
const resolved = node_path.resolve(projectPath);
|
|
1162
|
+
const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
|
|
1163
|
+
if (mine.length > 0) return {
|
|
1164
|
+
browser: mine[mine.length - 1].browser,
|
|
1165
|
+
source: "session"
|
|
1166
|
+
};
|
|
1167
|
+
const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
|
|
1168
|
+
const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
|
|
1169
|
+
if (live.length > 0) return {
|
|
1170
|
+
browser: live[0].browser,
|
|
1171
|
+
source: "contract"
|
|
1172
|
+
};
|
|
1173
|
+
if (sightings.length > 0) return {
|
|
1174
|
+
browser: sightings[0].browser,
|
|
1175
|
+
source: "stale"
|
|
1176
|
+
};
|
|
1177
|
+
return {
|
|
1178
|
+
browser: fallback,
|
|
1179
|
+
source: "fallback"
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
908
1182
|
const start_schema = {
|
|
909
1183
|
name: "extension_start",
|
|
910
1184
|
description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
|
|
@@ -969,9 +1243,11 @@ async function start_handler(args) {
|
|
|
969
1243
|
if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
|
|
970
1244
|
if (args.noBrowser) cliArgs.push("--no-browser");
|
|
971
1245
|
cliArgs.push(...launchFlagArgs(args));
|
|
972
|
-
const
|
|
1246
|
+
const spawnedAt = Date.now();
|
|
1247
|
+
const spawned = spawnExtensionCli(cliArgs, {
|
|
973
1248
|
projectDir: args.projectPath
|
|
974
1249
|
});
|
|
1250
|
+
const { child, logPath } = spawned;
|
|
975
1251
|
const pid = child.pid;
|
|
976
1252
|
registerSession({
|
|
977
1253
|
pid,
|
|
@@ -980,22 +1256,47 @@ async function start_handler(args) {
|
|
|
980
1256
|
command: "start"
|
|
981
1257
|
});
|
|
982
1258
|
child.on("exit", ()=>removeSession(args.projectPath, browser));
|
|
983
|
-
let earlyOutput = "";
|
|
984
|
-
const collector = (data)=>{
|
|
985
|
-
earlyOutput += data.toString();
|
|
986
|
-
};
|
|
987
|
-
child.stdout?.on("data", collector);
|
|
988
|
-
child.stderr?.on("data", collector);
|
|
989
1259
|
await new Promise((resolve)=>setTimeout(resolve, 5000));
|
|
990
|
-
|
|
991
|
-
child.
|
|
1260
|
+
const earlyOutput = spawned.readOutput();
|
|
1261
|
+
if (null !== child.exitCode || null !== child.signalCode) {
|
|
1262
|
+
const code = child.exitCode;
|
|
1263
|
+
const signal = child.signalCode;
|
|
1264
|
+
return JSON.stringify({
|
|
1265
|
+
ok: false,
|
|
1266
|
+
status: "exited",
|
|
1267
|
+
projectPath: args.projectPath,
|
|
1268
|
+
browser,
|
|
1269
|
+
pid,
|
|
1270
|
+
exitCode: code,
|
|
1271
|
+
signal,
|
|
1272
|
+
error: `The preview server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`,
|
|
1273
|
+
output: earlyOutput.slice(0, 2000),
|
|
1274
|
+
logPath,
|
|
1275
|
+
hint: "Read `output` above for the cause: a failed production build, a port already in use, or a missing browser binary are the common ones. extension_build will surface a build error on its own."
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
|
|
1279
|
+
if (exitStamp) return JSON.stringify({
|
|
1280
|
+
ok: false,
|
|
1281
|
+
status: "browser-exited",
|
|
1282
|
+
projectPath: args.projectPath,
|
|
1283
|
+
browser,
|
|
1284
|
+
pid,
|
|
1285
|
+
...exitStamp,
|
|
1286
|
+
error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
|
|
1287
|
+
output: earlyOutput.slice(0, 2000),
|
|
1288
|
+
logPath,
|
|
1289
|
+
hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
|
|
1290
|
+
});
|
|
992
1291
|
return JSON.stringify({
|
|
1292
|
+
ok: true,
|
|
993
1293
|
pid,
|
|
994
1294
|
browser,
|
|
995
1295
|
projectPath: args.projectPath,
|
|
996
1296
|
status: "started",
|
|
997
1297
|
hint: "Use extension_wait to check when the build and browser launch are complete. When you are done, call extension_stop to shut down the session.",
|
|
998
|
-
earlyOutput: earlyOutput.slice(0, 500)
|
|
1298
|
+
earlyOutput: earlyOutput.slice(0, 500),
|
|
1299
|
+
logPath
|
|
999
1300
|
});
|
|
1000
1301
|
}
|
|
1001
1302
|
const preview_schema = {
|
|
@@ -1056,9 +1357,11 @@ async function preview_handler(args) {
|
|
|
1056
1357
|
if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
|
|
1057
1358
|
if (args.noBrowser) cliArgs.push("--no-browser");
|
|
1058
1359
|
cliArgs.push(...launchFlagArgs(args));
|
|
1059
|
-
const
|
|
1360
|
+
const spawnedAt = Date.now();
|
|
1361
|
+
const spawned = spawnExtensionCli(cliArgs, {
|
|
1060
1362
|
projectDir: args.projectPath
|
|
1061
1363
|
});
|
|
1364
|
+
const { child, logPath } = spawned;
|
|
1062
1365
|
const pid = child.pid;
|
|
1063
1366
|
registerSession({
|
|
1064
1367
|
pid,
|
|
@@ -1067,81 +1370,49 @@ async function preview_handler(args) {
|
|
|
1067
1370
|
command: "preview"
|
|
1068
1371
|
});
|
|
1069
1372
|
child.on("exit", ()=>removeSession(args.projectPath, browser));
|
|
1373
|
+
await new Promise((resolve)=>setTimeout(resolve, 5000));
|
|
1374
|
+
const earlyOutput = spawned.readOutput();
|
|
1375
|
+
if (null !== child.exitCode || null !== child.signalCode) {
|
|
1376
|
+
const code = child.exitCode;
|
|
1377
|
+
const signal = child.signalCode;
|
|
1378
|
+
return JSON.stringify({
|
|
1379
|
+
ok: false,
|
|
1380
|
+
status: "exited",
|
|
1381
|
+
projectPath: args.projectPath,
|
|
1382
|
+
browser,
|
|
1383
|
+
pid,
|
|
1384
|
+
exitCode: code,
|
|
1385
|
+
signal,
|
|
1386
|
+
error: `The preview process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). Nothing is running.`,
|
|
1387
|
+
output: earlyOutput.slice(0, 2000),
|
|
1388
|
+
logPath,
|
|
1389
|
+
hint: "Read `output` above for the cause: a missing or broken dist/ (run extension_build first), or a missing browser binary are the common ones."
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
|
|
1393
|
+
if (exitStamp) return JSON.stringify({
|
|
1394
|
+
ok: false,
|
|
1395
|
+
status: "browser-exited",
|
|
1396
|
+
projectPath: args.projectPath,
|
|
1397
|
+
browser,
|
|
1398
|
+
pid,
|
|
1399
|
+
...exitStamp,
|
|
1400
|
+
error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
|
|
1401
|
+
output: earlyOutput.slice(0, 2000),
|
|
1402
|
+
logPath,
|
|
1403
|
+
hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
|
|
1404
|
+
});
|
|
1070
1405
|
return JSON.stringify({
|
|
1406
|
+
ok: true,
|
|
1071
1407
|
pid,
|
|
1072
1408
|
browser,
|
|
1073
1409
|
projectPath: args.projectPath,
|
|
1074
1410
|
status: "launched",
|
|
1075
|
-
hint: "Call extension_stop when you are done to close the preview browser."
|
|
1411
|
+
hint: "Call extension_stop when you are done to close the preview browser.",
|
|
1412
|
+
earlyOutput: earlyOutput.slice(0, 500),
|
|
1413
|
+
logPath
|
|
1076
1414
|
});
|
|
1077
1415
|
}
|
|
1078
|
-
function contractSightings(projectPath) {
|
|
1079
|
-
const root = node_path.resolve(projectPath, "dist", "extension-js");
|
|
1080
|
-
let dirs;
|
|
1081
|
-
try {
|
|
1082
|
-
dirs = node_fs.readdirSync(root);
|
|
1083
|
-
} catch {
|
|
1084
|
-
return [];
|
|
1085
|
-
}
|
|
1086
|
-
const sightings = [];
|
|
1087
|
-
for (const dir of dirs){
|
|
1088
|
-
const readyPath = node_path.join(root, dir, "ready.json");
|
|
1089
|
-
try {
|
|
1090
|
-
const stat = node_fs.statSync(readyPath);
|
|
1091
|
-
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
1092
|
-
if (contract?.status !== "ready") continue;
|
|
1093
|
-
sightings.push({
|
|
1094
|
-
browser: dir,
|
|
1095
|
-
mtimeMs: stat.mtimeMs,
|
|
1096
|
-
pid: "number" == typeof contract.pid ? contract.pid : void 0
|
|
1097
|
-
});
|
|
1098
|
-
} catch {}
|
|
1099
|
-
}
|
|
1100
|
-
return sightings;
|
|
1101
|
-
}
|
|
1102
|
-
function pidAlive(pid) {
|
|
1103
|
-
try {
|
|
1104
|
-
process.kill(pid, 0);
|
|
1105
|
-
return true;
|
|
1106
|
-
} catch {
|
|
1107
|
-
return false;
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
function knownSessionBrowsers(projectPath) {
|
|
1111
|
-
const resolved = node_path.resolve(projectPath);
|
|
1112
|
-
const browsers = [];
|
|
1113
|
-
for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
|
|
1114
|
-
for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
|
|
1115
|
-
return Array.from(new Set(browsers));
|
|
1116
|
-
}
|
|
1117
|
-
function deadReadySession(projectPath) {
|
|
1118
|
-
for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
|
|
1119
|
-
browser: sighting.browser,
|
|
1120
|
-
pid: sighting.pid
|
|
1121
|
-
};
|
|
1122
|
-
return null;
|
|
1123
|
-
}
|
|
1124
|
-
function resolveSessionBrowser(projectPath, explicit, fallback = "chromium") {
|
|
1125
|
-
if (explicit) return {
|
|
1126
|
-
browser: explicit,
|
|
1127
|
-
source: "explicit"
|
|
1128
|
-
};
|
|
1129
|
-
const resolved = node_path.resolve(projectPath);
|
|
1130
|
-
const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
|
|
1131
|
-
if (mine.length > 0) return {
|
|
1132
|
-
browser: mine[mine.length - 1].browser,
|
|
1133
|
-
source: "session"
|
|
1134
|
-
};
|
|
1135
|
-
const sightings = contractSightings(projectPath).filter((s)=>void 0 === s.pid || pidAlive(s.pid)).sort((a, b)=>b.mtimeMs - a.mtimeMs);
|
|
1136
|
-
if (sightings.length > 0) return {
|
|
1137
|
-
browser: sightings[0].browser,
|
|
1138
|
-
source: "contract"
|
|
1139
|
-
};
|
|
1140
|
-
return {
|
|
1141
|
-
browser: fallback,
|
|
1142
|
-
source: "fallback"
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1145
1416
|
const stop_schema = {
|
|
1146
1417
|
name: "extension_stop",
|
|
1147
1418
|
description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
|
|
@@ -1204,7 +1475,7 @@ function isAlive(pid) {
|
|
|
1204
1475
|
return false;
|
|
1205
1476
|
}
|
|
1206
1477
|
}
|
|
1207
|
-
function
|
|
1478
|
+
function stop_signal(pid, sig) {
|
|
1208
1479
|
try {
|
|
1209
1480
|
process.kill(-pid, sig);
|
|
1210
1481
|
return true;
|
|
@@ -1245,10 +1516,10 @@ async function stopOne(projectPath, browser) {
|
|
|
1245
1516
|
}
|
|
1246
1517
|
let detail;
|
|
1247
1518
|
if (isAlive(pid)) {
|
|
1248
|
-
|
|
1519
|
+
stop_signal(pid, "SIGTERM");
|
|
1249
1520
|
await new Promise((resolve)=>setTimeout(resolve, 1500));
|
|
1250
1521
|
if (isAlive(pid)) {
|
|
1251
|
-
|
|
1522
|
+
stop_signal(pid, "SIGKILL");
|
|
1252
1523
|
await new Promise((resolve)=>setTimeout(resolve, 250));
|
|
1253
1524
|
}
|
|
1254
1525
|
detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
|
|
@@ -1491,7 +1762,8 @@ const manifest_validate_schema = {
|
|
|
1491
1762
|
},
|
|
1492
1763
|
default: [
|
|
1493
1764
|
"chrome",
|
|
1494
|
-
"firefox"
|
|
1765
|
+
"firefox",
|
|
1766
|
+
"edge"
|
|
1495
1767
|
],
|
|
1496
1768
|
description: "Browsers to validate against"
|
|
1497
1769
|
},
|
|
@@ -1535,6 +1807,19 @@ function collectPathRefs(m) {
|
|
|
1535
1807
|
if (sa) push(sa.default_panel);
|
|
1536
1808
|
const cuo = m.chrome_url_overrides;
|
|
1537
1809
|
if (cuo) Object.values(cuo).forEach(push);
|
|
1810
|
+
const dnr = m.declarative_net_request;
|
|
1811
|
+
if (dnr && Array.isArray(dnr.rule_resources)) {
|
|
1812
|
+
for (const r of dnr.rule_resources)if (r && "object" == typeof r) push(r.path);
|
|
1813
|
+
}
|
|
1814
|
+
const storage = m.storage;
|
|
1815
|
+
if (storage) push(storage.managed_schema);
|
|
1816
|
+
push(m.devtools_page);
|
|
1817
|
+
const pa = m.page_action;
|
|
1818
|
+
if (pa) {
|
|
1819
|
+
push(pa.default_popup);
|
|
1820
|
+
if ("string" == typeof pa.default_icon) push(pa.default_icon);
|
|
1821
|
+
else if (pa.default_icon) Object.values(pa.default_icon).forEach(push);
|
|
1822
|
+
}
|
|
1538
1823
|
return refs;
|
|
1539
1824
|
}
|
|
1540
1825
|
function fileResolvesSomewhere(ref, roots) {
|
|
@@ -1642,9 +1927,11 @@ async function manifest_validate_handler(args) {
|
|
|
1642
1927
|
args.browser
|
|
1643
1928
|
]
|
|
1644
1929
|
};
|
|
1930
|
+
const explicitBrowsers = Array.isArray(args.browsers) && args.browsers.length > 0;
|
|
1645
1931
|
const browsers = args.browsers ?? [
|
|
1646
1932
|
"chrome",
|
|
1647
|
-
"firefox"
|
|
1933
|
+
"firefox",
|
|
1934
|
+
"edge"
|
|
1648
1935
|
];
|
|
1649
1936
|
const result = {
|
|
1650
1937
|
valid: true,
|
|
@@ -1689,30 +1976,46 @@ async function manifest_validate_handler(args) {
|
|
|
1689
1976
|
node_path.dirname(manifestDir)
|
|
1690
1977
|
] : []
|
|
1691
1978
|
];
|
|
1692
|
-
for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.
|
|
1693
|
-
const
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1979
|
+
for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.errors.push(`Referenced file "${ref}" was not found near the manifest. extension_build fails on this dangling reference.`);
|
|
1980
|
+
const defaultLocale = manifest.default_locale;
|
|
1981
|
+
if ("string" == typeof defaultLocale && defaultLocale) {
|
|
1982
|
+
const hasCatalog = roots.some((root)=>node_fs.existsSync(node_path.resolve(root, "_locales", defaultLocale, "messages.json")));
|
|
1983
|
+
if (!hasCatalog) result.errors.push(`default_locale "${defaultLocale}" is declared but _locales/${defaultLocale}/messages.json was not found. The build fails on this; add the catalog or remove default_locale.`);
|
|
1984
|
+
}
|
|
1985
|
+
const iconMap = chromiumManifest.icons;
|
|
1986
|
+
if (!iconMap || "string" != typeof iconMap["128"]) result.warnings.push('No 128x128 icon declared ("128" key in icons). The Chrome Web Store requires one for a store listing, and Edge Add-ons expects it too.');
|
|
1987
|
+
const effectiveByBrowser = new Map();
|
|
1988
|
+
for (const b of browsers)effectiveByBrowser.set(b, filterKeysForThisBrowser(manifest, b));
|
|
1989
|
+
const declaredPermSet = new Set();
|
|
1990
|
+
for (const view of [
|
|
1991
|
+
chromiumManifest,
|
|
1992
|
+
...effectiveByBrowser.values()
|
|
1993
|
+
])for (const p of [
|
|
1994
|
+
...view.permissions ?? [],
|
|
1995
|
+
...view.optional_permissions ?? []
|
|
1996
|
+
])if ("string" == typeof p) declaredPermSet.add(p);
|
|
1997
|
+
const usedApis = scanApiUsage(roots);
|
|
1998
|
+
for (const api of usedApis){
|
|
1698
1999
|
const perm = API_PERMISSION[api];
|
|
1699
2000
|
if (declaredPermSet.has(perm)) continue;
|
|
1700
2001
|
const base = `Code calls chrome.${api} but "${perm}" is not in permissions`;
|
|
1701
|
-
if (HARD_APIS.has(api)) result.errors.push(`${base}
|
|
1702
|
-
else result.warnings.push(`${base}; it may be undefined at runtime
|
|
2002
|
+
if (HARD_APIS.has(api)) result.errors.push(`${base}, chrome.${api} is undefined without it and will crash the context at runtime.`);
|
|
2003
|
+
else result.warnings.push(`${base}; it may be undefined at runtime, add "${perm}" if you use it.`);
|
|
1703
2004
|
}
|
|
1704
|
-
if (
|
|
2005
|
+
if (chromiumManifest.manifest_version) {
|
|
2006
|
+
if (2 !== chromiumManifest.manifest_version && 3 !== chromiumManifest.manifest_version) result.errors.push(`manifest_version must be 2 or 3, got ${JSON.stringify(chromiumManifest.manifest_version)}. No browser installs this manifest.`);
|
|
2007
|
+
} else result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
|
|
1705
2008
|
const declaredPerms = [
|
|
1706
2009
|
...chromiumManifest.permissions ?? [],
|
|
1707
2010
|
...chromiumManifest.optional_permissions ?? []
|
|
1708
2011
|
].filter((p)=>"string" == typeof p);
|
|
1709
2012
|
for (const perm of declaredPerms)if (!(perm.includes("://") || perm.includes("*")) && "<all_urls>" !== perm) {
|
|
1710
|
-
if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}"
|
|
2013
|
+
if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}", check for a typo (host/match patterns belong in host_permissions, not permissions).`);
|
|
1711
2014
|
}
|
|
1712
2015
|
for (const browser of browsers){
|
|
1713
2016
|
const isChromium = isChromiumFamily(browser);
|
|
1714
2017
|
const isFirefox = isGeckoFamily(browser);
|
|
1715
|
-
const effective = filterKeysForThisBrowser(manifest, browser);
|
|
2018
|
+
const effective = effectiveByBrowser.get(browser) ?? filterKeysForThisBrowser(manifest, browser);
|
|
1716
2019
|
const issues = [];
|
|
1717
2020
|
if (isChromium) {
|
|
1718
2021
|
const mv = effective.manifest_version;
|
|
@@ -1734,6 +2037,17 @@ async function manifest_validate_handler(args) {
|
|
|
1734
2037
|
if (bg.service_worker && !bg.scripts) issues.push('Background service_worker declared but no firefox:scripts. Firefox uses "scripts" (array) instead of "service_worker".');
|
|
1735
2038
|
}
|
|
1736
2039
|
}
|
|
2040
|
+
const effectivePerms = new Set([
|
|
2041
|
+
...effective.permissions ?? [],
|
|
2042
|
+
...effective.optional_permissions ?? []
|
|
2043
|
+
].filter((p)=>"string" == typeof p));
|
|
2044
|
+
for (const api of usedApis){
|
|
2045
|
+
const perm = API_PERMISSION[api];
|
|
2046
|
+
if (effectivePerms.has(perm)) continue;
|
|
2047
|
+
if (!declaredPermSet.has(perm)) continue;
|
|
2048
|
+
const ns = isFirefox ? "browser" : "chrome";
|
|
2049
|
+
issues.push(`Code calls ${ns}.${api} but the ${browser} build's permissions do not include "${perm}" (it is declared only under another target's prefixed key, e.g. chromium:permissions). This target crashes at runtime.`);
|
|
2050
|
+
}
|
|
1737
2051
|
result.browserSupport[browser] = {
|
|
1738
2052
|
supported: 0 === issues.length,
|
|
1739
2053
|
issues
|
|
@@ -1759,7 +2073,13 @@ async function manifest_validate_handler(args) {
|
|
|
1759
2073
|
surfaces: t.surfaces
|
|
1760
2074
|
}));
|
|
1761
2075
|
} catch {}
|
|
1762
|
-
|
|
2076
|
+
for (const [browser, support] of Object.entries(result.browserSupport)){
|
|
2077
|
+
if (support.supported) continue;
|
|
2078
|
+
const issues = support.issues?.length ? support.issues.join("; ") : `${browser} is not supported by this manifest.`;
|
|
2079
|
+
if (explicitBrowsers) result.errors.push(`${browser}: ${issues}`);
|
|
2080
|
+
else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
|
|
2081
|
+
}
|
|
2082
|
+
result.valid = 0 === result.errors.length;
|
|
1763
2083
|
return JSON.stringify({
|
|
1764
2084
|
...result,
|
|
1765
2085
|
buildBlocking: result.errors.length > 0
|
|
@@ -1912,8 +2232,8 @@ async function inspect_handler(args) {
|
|
|
1912
2232
|
const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
|
|
1913
2233
|
const sizeWarnings = [];
|
|
1914
2234
|
for (const f of files)if ("sourcemap" !== f.type) {
|
|
1915
|
-
if (PROMO_RE.test(f.path)) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) looks like a store-listing promo image shipped inside the extension package
|
|
1916
|
-
else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle
|
|
2235
|
+
if (PROMO_RE.test(f.path)) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) looks like a store-listing promo image shipped inside the extension package, move it out of the bundled sources so it does not inflate the store zip.`);
|
|
2236
|
+
else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle, unusually large for a shipped asset.`);
|
|
1917
2237
|
}
|
|
1918
2238
|
const result = {
|
|
1919
2239
|
browser,
|
|
@@ -1960,6 +2280,7 @@ async function inspect_handler(args) {
|
|
|
1960
2280
|
storeReadiness: {
|
|
1961
2281
|
hasManifest: node_fs.existsSync(manifestPath),
|
|
1962
2282
|
hasIcons: files.some((f)=>"image" === f.type && f.path.includes("icon")),
|
|
2283
|
+
has128Icon: "string" == typeof manifest.icons?.["128"],
|
|
1963
2284
|
noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
|
|
1964
2285
|
noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
|
|
1965
2286
|
under10MB: totalSize < 10485760
|
|
@@ -1981,7 +2302,7 @@ const COMMAND_TIMEOUT_MS = 15000;
|
|
|
1981
2302
|
class CDPConnection {
|
|
1982
2303
|
async connect(wsUrl) {
|
|
1983
2304
|
return new Promise((resolve, reject)=>{
|
|
1984
|
-
this.ws = new
|
|
2305
|
+
this.ws = new ws_0(wsUrl);
|
|
1985
2306
|
this.ws.on("open", ()=>resolve());
|
|
1986
2307
|
this.ws.on("message", (data)=>{
|
|
1987
2308
|
this.handleMessage(data.toString());
|
|
@@ -2050,7 +2371,7 @@ class CDPConnection {
|
|
|
2050
2371
|
}
|
|
2051
2372
|
async sendCommand(method, params = {}, sessionId) {
|
|
2052
2373
|
return new Promise((resolve, reject)=>{
|
|
2053
|
-
if (!this.ws || this.ws.readyState !==
|
|
2374
|
+
if (!this.ws || this.ws.readyState !== ws_0.OPEN) return reject(new Error("CDP WebSocket is not connected"));
|
|
2054
2375
|
const id = ++this.messageId;
|
|
2055
2376
|
const message = {
|
|
2056
2377
|
id,
|
|
@@ -2441,7 +2762,7 @@ const source_inspect_schema = {
|
|
|
2441
2762
|
items: {
|
|
2442
2763
|
type: "string"
|
|
2443
2764
|
},
|
|
2444
|
-
description: "CSS selectors to query
|
|
2765
|
+
description: "CSS selectors to query, returns element counts and samples for each"
|
|
2445
2766
|
},
|
|
2446
2767
|
include: {
|
|
2447
2768
|
type: "array",
|
|
@@ -2493,7 +2814,7 @@ async function source_inspect_handler(args) {
|
|
|
2493
2814
|
const maxBytes = args.maxBytes ?? 262144;
|
|
2494
2815
|
if (!isChromiumFamily(browser)) return JSON.stringify({
|
|
2495
2816
|
error: `Source inspection reads the live DOM over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. This is a capability limit, not a missing session.`,
|
|
2496
|
-
hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval (
|
|
2817
|
+
hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval / extension_dom_inspect (content/page, an open surface like popup/options/sidebar, or an override page like newtab) which work against Firefox over the control channel. To get CDP DOM inspection, run a Chromium-family dev session (extension_dev with browser: "chrome") in parallel.`
|
|
2497
2818
|
});
|
|
2498
2819
|
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
2499
2820
|
if (!resolved) return JSON.stringify({
|
|
@@ -2575,7 +2896,11 @@ async function source_inspect_handler(args) {
|
|
|
2575
2896
|
if (include.has("dom_snapshot")) result.domSnapshot = await cdp.getDomSnapshot(sessionId);
|
|
2576
2897
|
if (include.has("console")) result.console = cdp.getConsoleSummary();
|
|
2577
2898
|
if (include.has("extension_roots")) result.extensionRoots = await cdp.getExtensionRootMeta(sessionId);
|
|
2578
|
-
if (args.probe?.length)
|
|
2899
|
+
if (args.probe?.length) {
|
|
2900
|
+
result.probes = await cdp.probeSelectors(sessionId, args.probe);
|
|
2901
|
+
const jsLooking = args.probe.filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
|
|
2902
|
+
if (jsLooking.length) result.probeWarning = `Probes are CSS selectors run through querySelectorAll against the live page, NOT JavaScript expressions. ${jsLooking.map((s)=>`"${s}"`).join(", ")} parsed as selectors and will match nothing. To evaluate JS, use extension_eval.`;
|
|
2903
|
+
}
|
|
2579
2904
|
if (args.deepDom) {
|
|
2580
2905
|
const closed = await cdp.getClosedShadowRoots(sessionId, maxBytes > 0 ? maxBytes : 65536);
|
|
2581
2906
|
result.closedShadowRoots = closed;
|
|
@@ -2594,7 +2919,7 @@ async function source_inspect_handler(args) {
|
|
|
2594
2919
|
}
|
|
2595
2920
|
const list_extensions_schema = {
|
|
2596
2921
|
name: "extension_list_extensions",
|
|
2597
|
-
description: "List the extensions with a live context in the running dev browser via Chrome DevTools Protocol. Returns each extension's id, name, version, and live contexts (service worker, page). Identity is read read-only via the Extensions domain
|
|
2922
|
+
description: "List the extensions with a live context in the running dev browser via Chrome DevTools Protocol. Returns each extension's id, name, version, and live contexts (service worker, page). Identity is read read-only via the Extensions domain, other extensions' contexts are never attached to or evaluated in. A dormant MV3 service worker with no open page may be absent until it wakes. Chromium only (Firefox uses RDP, not yet supported). Requires an active dev or start session.",
|
|
2598
2923
|
inputSchema: {
|
|
2599
2924
|
type: "object",
|
|
2600
2925
|
properties: {
|
|
@@ -2849,10 +3174,28 @@ function capRecent(events, limit) {
|
|
|
2849
3174
|
truncated: true
|
|
2850
3175
|
};
|
|
2851
3176
|
}
|
|
2852
|
-
function
|
|
3177
|
+
function emptyReason(projectPath, browser) {
|
|
3178
|
+
let contract;
|
|
3179
|
+
try {
|
|
3180
|
+
contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
|
|
3181
|
+
} catch {
|
|
3182
|
+
return "No ready.json for this project/browser: no dev session has produced a build here, so there is nothing to log. Start one with extension_dev.";
|
|
3183
|
+
}
|
|
3184
|
+
if ("error" === contract.status) {
|
|
3185
|
+
const errs = contract.errors;
|
|
3186
|
+
return `The dev session recorded status:"error"${errs?.length ? ` (${errs.join("; ")})` : ""}, so the extension never ran. There are no logs because there was no working build, not because your code is silent.`;
|
|
3187
|
+
}
|
|
3188
|
+
if ("number" == typeof contract.pid) try {
|
|
3189
|
+
process.kill(contract.pid, 0);
|
|
3190
|
+
} catch {
|
|
3191
|
+
return `ready.json reports ready but its dev-server pid ${contract.pid} is dead: the session exited. Logs stop at the moment it died. Restart with extension_dev; extension_doctor will confirm.`;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
function summarize(events, source, browser, runId, limit, dropped, projectPath, staleNote) {
|
|
2853
3195
|
const matched = events.length;
|
|
2854
3196
|
const { events: out, truncated } = capRecent(events, limit);
|
|
2855
3197
|
const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
|
|
3198
|
+
const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
|
|
2856
3199
|
return JSON.stringify({
|
|
2857
3200
|
ok: true,
|
|
2858
3201
|
source,
|
|
@@ -2863,9 +3206,30 @@ function summarize(events, source, browser, runId, limit, dropped) {
|
|
|
2863
3206
|
truncated,
|
|
2864
3207
|
dropped: dropped || void 0,
|
|
2865
3208
|
nextSince: lastSeq >= 0 ? lastSeq : void 0,
|
|
3209
|
+
...reason ? {
|
|
3210
|
+
emptyReason: reason
|
|
3211
|
+
} : {},
|
|
3212
|
+
...staleNote && matched > 0 ? {
|
|
3213
|
+
stale: true,
|
|
3214
|
+
warning: staleNote
|
|
3215
|
+
} : {},
|
|
2866
3216
|
events: out
|
|
2867
3217
|
});
|
|
2868
3218
|
}
|
|
3219
|
+
function staleFileNote(projectPath, browser, eventsRunId) {
|
|
3220
|
+
let contract;
|
|
3221
|
+
try {
|
|
3222
|
+
contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
|
|
3223
|
+
} catch {
|
|
3224
|
+
return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
|
|
3225
|
+
}
|
|
3226
|
+
if ("number" == typeof contract.pid) try {
|
|
3227
|
+
process.kill(contract.pid, 0);
|
|
3228
|
+
} catch {
|
|
3229
|
+
return `These events are from a PAST run: the session that wrote them (pid ${contract.pid}) is dead. Nothing current is producing logs; do not read these as live output.`;
|
|
3230
|
+
}
|
|
3231
|
+
if (eventsRunId && contract.runId && String(contract.runId) !== eventsRunId) return `These events carry runId ${eventsRunId} but the current session is run ${String(contract.runId)}, which has written nothing yet. Do not read these as the current run's output.`;
|
|
3232
|
+
}
|
|
2869
3233
|
async function readFromFile(args, browser, limit) {
|
|
2870
3234
|
const file = logsFilePath(args.projectPath, browser);
|
|
2871
3235
|
if (!node_fs.existsSync(file)) return JSON.stringify({
|
|
@@ -2889,13 +3253,13 @@ async function readFromFile(args, browser, limit) {
|
|
|
2889
3253
|
}
|
|
2890
3254
|
if (matches(event)) events.push(event);
|
|
2891
3255
|
}
|
|
2892
|
-
return summarize(events, "file", browser, runId, limit, 0);
|
|
3256
|
+
return summarize(events, "file", browser, runId, limit, 0, args.projectPath, staleFileNote(args.projectPath, browser, runId));
|
|
2893
3257
|
}
|
|
2894
3258
|
async function readFromStream(args, browser, limit) {
|
|
2895
3259
|
const ready = readReadyContract(args.projectPath, browser);
|
|
2896
3260
|
if (!ready) {
|
|
2897
3261
|
const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
|
|
2898
|
-
const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}
|
|
3262
|
+
const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
|
|
2899
3263
|
return JSON.stringify({
|
|
2900
3264
|
error: `No active control channel found for ${browser}.`,
|
|
2901
3265
|
hint: `${retarget} extension_dev (browser: ${browser}) and wait for it to be ready, then retry. For past logs without a live channel, call without follow.`
|
|
@@ -2911,7 +3275,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
2911
3275
|
const url = `ws://127.0.0.1:${ready.controlPort}${CONTROL_WS_PATH}`;
|
|
2912
3276
|
let socket;
|
|
2913
3277
|
try {
|
|
2914
|
-
socket = new
|
|
3278
|
+
socket = new ws_0(url);
|
|
2915
3279
|
} catch (err) {
|
|
2916
3280
|
resolve(JSON.stringify({
|
|
2917
3281
|
error: `Could not open control channel at ${url}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -2925,7 +3289,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
2925
3289
|
try {
|
|
2926
3290
|
socket.close();
|
|
2927
3291
|
} catch {}
|
|
2928
|
-
resolve(summarize(events, "stream", browser, runId, limit, dropped));
|
|
3292
|
+
resolve(summarize(events, "stream", browser, runId, limit, dropped, args.projectPath));
|
|
2929
3293
|
};
|
|
2930
3294
|
const timer = setTimeout(finish, followMs);
|
|
2931
3295
|
socket.on("open", ()=>{
|
|
@@ -2975,10 +3339,10 @@ function withSessionContext(message, projectPath) {
|
|
|
2975
3339
|
const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
|
|
2976
3340
|
if (!isControlError) return message;
|
|
2977
3341
|
const dead = deadReadySession(projectPath);
|
|
2978
|
-
if (dead) return `${message}\nLikely cause: the dev server has exited
|
|
3342
|
+
if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
|
|
2979
3343
|
const running = knownSessionBrowsers(projectPath);
|
|
2980
3344
|
if (0 === running.length) return message;
|
|
2981
|
-
return `${message} Active session browser(s) for this project: ${running.join(", ")}
|
|
3345
|
+
return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
|
|
2982
3346
|
}
|
|
2983
3347
|
function translateFrame(frame, projectPath) {
|
|
2984
3348
|
if (!frame || false !== frame.ok) return frame;
|
|
@@ -3022,7 +3386,7 @@ function commonFlags(args) {
|
|
|
3022
3386
|
}
|
|
3023
3387
|
const eval_schema = {
|
|
3024
3388
|
name: "extension_eval",
|
|
3025
|
-
description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads).
|
|
3389
|
+
description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Targeting for context content/page: pass `url` to pick the matching tab, or omit both `url` and `tab` to use the ACTIVE tab; a numeric `tab` id is only needed to disambiguate. Extension surfaces (popup/options/sidebar/devtools) and override pages (newtab/history/bookmarks) evaluate over the in-bundle relay and need NO tab id; the surface must be OPEN (extension_open first; a closed surface returns an explicit error). Chromium caveat: eval in the MV3 background/service_worker is blocked by CSP (use an MV2/Firefox build for that context). Use extension_dom_inspect with listTabs: true to enumerate {tabId,url,title}. Wraps `extension eval`.",
|
|
3026
3390
|
inputSchema: {
|
|
3027
3391
|
type: "object",
|
|
3028
3392
|
properties: {
|
|
@@ -3042,6 +3406,9 @@ const eval_schema = {
|
|
|
3042
3406
|
"options",
|
|
3043
3407
|
"sidebar",
|
|
3044
3408
|
"devtools",
|
|
3409
|
+
"newtab",
|
|
3410
|
+
"history",
|
|
3411
|
+
"bookmarks",
|
|
3045
3412
|
"content",
|
|
3046
3413
|
"page"
|
|
3047
3414
|
],
|
|
@@ -3050,11 +3417,11 @@ const eval_schema = {
|
|
|
3050
3417
|
},
|
|
3051
3418
|
url: {
|
|
3052
3419
|
type: "string",
|
|
3053
|
-
description: "For content/page:
|
|
3420
|
+
description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`. You do not need a numeric id."
|
|
3054
3421
|
},
|
|
3055
3422
|
tab: {
|
|
3056
3423
|
type: "number",
|
|
3057
|
-
description: "Numeric chrome.tabs id
|
|
3424
|
+
description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
|
|
3058
3425
|
},
|
|
3059
3426
|
browser: {
|
|
3060
3427
|
type: "string",
|
|
@@ -3073,7 +3440,7 @@ const eval_schema = {
|
|
|
3073
3440
|
};
|
|
3074
3441
|
async function eval_handler(args) {
|
|
3075
3442
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
3076
|
-
|
|
3443
|
+
const raw = await runActVerb([
|
|
3077
3444
|
"eval",
|
|
3078
3445
|
args.expression,
|
|
3079
3446
|
args.projectPath,
|
|
@@ -3082,6 +3449,14 @@ async function eval_handler(args) {
|
|
|
3082
3449
|
browser
|
|
3083
3450
|
})
|
|
3084
3451
|
], args.projectPath, args.timeout);
|
|
3452
|
+
if ("content" === args.context) try {
|
|
3453
|
+
const parsed = JSON.parse(raw);
|
|
3454
|
+
if (parsed?.ok === true && (null === parsed.value || void 0 === parsed.value)) {
|
|
3455
|
+
parsed.note = "On Extension.js >= 4.0.14 a failed injection errors explicitly, so this null is the expression's real result. On OLDER engines (bug 61) it could mean the injection never ran; if this result looks wrong, check the engine version with extension_doctor, or verify with extension_logs or context:'page'.";
|
|
3456
|
+
return JSON.stringify(parsed);
|
|
3457
|
+
}
|
|
3458
|
+
} catch {}
|
|
3459
|
+
return raw;
|
|
3085
3460
|
}
|
|
3086
3461
|
const storage_schema = {
|
|
3087
3462
|
name: "extension_storage",
|
|
@@ -3161,7 +3536,14 @@ async function storage_handler(args) {
|
|
|
3161
3536
|
message: "storage set requires a value"
|
|
3162
3537
|
}
|
|
3163
3538
|
});
|
|
3164
|
-
|
|
3539
|
+
if (void 0 === args.key) return JSON.stringify({
|
|
3540
|
+
ok: false,
|
|
3541
|
+
error: {
|
|
3542
|
+
name: "BadRequest",
|
|
3543
|
+
message: 'storage set requires `key` (string) and `value` args, one key per call. There is no bulk-object set: to seed {a: 1, b: 2}, call once with key: "a" and once with key: "b".'
|
|
3544
|
+
}
|
|
3545
|
+
});
|
|
3546
|
+
cli.push("--value", JSON.stringify(args.value));
|
|
3165
3547
|
}
|
|
3166
3548
|
if (args.context) cli.push("--context", args.context);
|
|
3167
3549
|
cli.push("--browser", browser);
|
|
@@ -3216,6 +3598,27 @@ async function reload_handler(args) {
|
|
|
3216
3598
|
})
|
|
3217
3599
|
], args.projectPath, args.timeout);
|
|
3218
3600
|
}
|
|
3601
|
+
async function pollForTarget(port, url, budgetMs) {
|
|
3602
|
+
const deadline = Date.now() + budgetMs;
|
|
3603
|
+
const wanted = url.replace(/#.*$/, "");
|
|
3604
|
+
for(;;){
|
|
3605
|
+
try {
|
|
3606
|
+
const targets = await CDPClient.discoverTargets(port);
|
|
3607
|
+
for (const t of targets){
|
|
3608
|
+
const tUrl = String(t.url ?? "");
|
|
3609
|
+
if ("page" === t.type) {
|
|
3610
|
+
if (tUrl === wanted || tUrl.startsWith(wanted)) return {
|
|
3611
|
+
id: String(t.id),
|
|
3612
|
+
url: tUrl,
|
|
3613
|
+
title: "string" == typeof t.title ? t.title : void 0
|
|
3614
|
+
};
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
} catch {}
|
|
3618
|
+
if (Date.now() >= deadline) return null;
|
|
3619
|
+
await new Promise((r)=>setTimeout(r, 250));
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3219
3622
|
async function navigateToUrl(projectPath, browser, url) {
|
|
3220
3623
|
if (!isChromiumFamily(browser)) return JSON.stringify({
|
|
3221
3624
|
ok: false,
|
|
@@ -3248,17 +3651,24 @@ async function navigateToUrl(projectPath, browser, url) {
|
|
|
3248
3651
|
await cdp.connect(browserWsUrl);
|
|
3249
3652
|
const sessionId = await cdp.attachToTarget(String(target.id));
|
|
3250
3653
|
await cdp.navigate(sessionId, url);
|
|
3251
|
-
await
|
|
3252
|
-
|
|
3654
|
+
const settled = await pollForTarget(resolved.port, url, 6000);
|
|
3655
|
+
if (!settled) return JSON.stringify({
|
|
3656
|
+
ok: false,
|
|
3657
|
+
error: {
|
|
3658
|
+
name: "NavigateFailed",
|
|
3659
|
+
message: `Navigation to ${url} did not produce a live page target. The URL may not exist in the extension bundle, or Chrome refused the navigation.`
|
|
3660
|
+
},
|
|
3661
|
+
hint: "Confirm the path exists in the built dist (extension_build / extension_inspect list entrypoints). For an extension page, the path must match the BUILT manifest, which may differ from your source layout."
|
|
3662
|
+
});
|
|
3253
3663
|
return JSON.stringify({
|
|
3254
3664
|
ok: true,
|
|
3255
3665
|
navigated: url,
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
title:
|
|
3259
|
-
url:
|
|
3666
|
+
target: {
|
|
3667
|
+
targetId: settled.id,
|
|
3668
|
+
title: settled.title,
|
|
3669
|
+
url: settled.url
|
|
3260
3670
|
},
|
|
3261
|
-
hint: "Inspect it with extension_dom_inspect or extension_source_inspect (context: 'page')."
|
|
3671
|
+
hint: "Inspect it with extension_dom_inspect or extension_source_inspect using url (context: 'page'), they resolve the tab themselves. `target.targetId` is a CDP target id, NOT a chrome.tabs id: do not pass it as `tab`. If you need a numeric tab id, call extension_dom_inspect with listTabs: true."
|
|
3262
3672
|
});
|
|
3263
3673
|
} catch (e) {
|
|
3264
3674
|
return JSON.stringify({
|
|
@@ -3274,9 +3684,180 @@ async function navigateToUrl(projectPath, browser, url) {
|
|
|
3274
3684
|
} catch {}
|
|
3275
3685
|
}
|
|
3276
3686
|
}
|
|
3687
|
+
function unpackedExtensionId(distPath) {
|
|
3688
|
+
const digest = node_crypto.createHash("sha256").update(distPath).digest();
|
|
3689
|
+
let id = "";
|
|
3690
|
+
for(let i = 0; i < 16; i++){
|
|
3691
|
+
id += String.fromCharCode(97 + (digest[i] >> 4));
|
|
3692
|
+
id += String.fromCharCode(97 + (0x0f & digest[i]));
|
|
3693
|
+
}
|
|
3694
|
+
return id;
|
|
3695
|
+
}
|
|
3696
|
+
async function resolveExtensionId(projectPath, browser) {
|
|
3697
|
+
const distPath = readDistPath(projectPath, browser);
|
|
3698
|
+
const computed = distPath ? unpackedExtensionId(distPath) : null;
|
|
3699
|
+
const resolved = await resolveCdpPort(projectPath, browser);
|
|
3700
|
+
if (!resolved) return computed;
|
|
3701
|
+
const ids = new Set();
|
|
3702
|
+
try {
|
|
3703
|
+
for (const t of (await CDPClient.discoverTargets(resolved.port))){
|
|
3704
|
+
const url = String(t.url ?? "");
|
|
3705
|
+
if (!url.startsWith("chrome-extension://")) continue;
|
|
3706
|
+
const id = url.slice(19).split("/")[0];
|
|
3707
|
+
if (id) ids.add(id);
|
|
3708
|
+
}
|
|
3709
|
+
} catch {}
|
|
3710
|
+
if (computed && ids.has(computed)) return computed;
|
|
3711
|
+
if (computed) return computed;
|
|
3712
|
+
return 1 === ids.size ? [
|
|
3713
|
+
...ids
|
|
3714
|
+
][0] : null;
|
|
3715
|
+
}
|
|
3716
|
+
function declaredCommands(projectPath, browser) {
|
|
3717
|
+
const candidates = [
|
|
3718
|
+
node_path.join(projectPath, "dist", browser, "manifest.json"),
|
|
3719
|
+
node_path.join(projectPath, "dist", "manifest.json"),
|
|
3720
|
+
node_path.join(projectPath, "src", "manifest.json"),
|
|
3721
|
+
node_path.join(projectPath, "manifest.json")
|
|
3722
|
+
];
|
|
3723
|
+
for (const file of candidates)try {
|
|
3724
|
+
const manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3725
|
+
const commands = manifest?.commands;
|
|
3726
|
+
if (commands && "object" == typeof commands) return Object.keys(commands);
|
|
3727
|
+
return [];
|
|
3728
|
+
} catch {
|
|
3729
|
+
continue;
|
|
3730
|
+
}
|
|
3731
|
+
return null;
|
|
3732
|
+
}
|
|
3733
|
+
function readDistPath(projectPath, browser) {
|
|
3734
|
+
try {
|
|
3735
|
+
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
3736
|
+
const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3737
|
+
return "string" == typeof contract?.distPath ? contract.distPath : null;
|
|
3738
|
+
} catch {
|
|
3739
|
+
return null;
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
function surfaceDocument(projectPath, browser, surface) {
|
|
3743
|
+
const candidates = [
|
|
3744
|
+
node_path.join(projectPath, "dist", browser, "manifest.json"),
|
|
3745
|
+
node_path.join(projectPath, "dist", "manifest.json"),
|
|
3746
|
+
node_path.join(projectPath, "src", "manifest.json"),
|
|
3747
|
+
node_path.join(projectPath, "manifest.json")
|
|
3748
|
+
];
|
|
3749
|
+
for (const file of candidates){
|
|
3750
|
+
let manifest;
|
|
3751
|
+
try {
|
|
3752
|
+
manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3753
|
+
} catch {
|
|
3754
|
+
continue;
|
|
3755
|
+
}
|
|
3756
|
+
const action = manifest.action ?? manifest.browser_action;
|
|
3757
|
+
const ref = "popup" === surface || "action" === surface ? action?.default_popup : "options" === surface ? manifest.options_ui?.page ?? manifest.options_page : "sidebar" === surface ? manifest.side_panel?.default_path ?? manifest.sidebar_action?.default_panel : "newtab" === surface || "history" === surface || "bookmarks" === surface ? manifest.chrome_url_overrides?.[surface] : null;
|
|
3758
|
+
if ("string" == typeof ref && ref) return ref.replace(/^\.?\//, "");
|
|
3759
|
+
}
|
|
3760
|
+
return null;
|
|
3761
|
+
}
|
|
3762
|
+
const POPUP_MIN = 25;
|
|
3763
|
+
const POPUP_MAX_WIDTH = 800;
|
|
3764
|
+
const POPUP_MAX_HEIGHT = 600;
|
|
3765
|
+
function clampPopupBounds(width, height) {
|
|
3766
|
+
const w = Math.min(Math.max(Math.ceil(width), POPUP_MIN), POPUP_MAX_WIDTH);
|
|
3767
|
+
const h = Math.min(Math.max(Math.ceil(height), POPUP_MIN), POPUP_MAX_HEIGHT);
|
|
3768
|
+
return {
|
|
3769
|
+
width: w,
|
|
3770
|
+
height: h,
|
|
3771
|
+
clamped: w !== Math.ceil(width) || h !== Math.ceil(height)
|
|
3772
|
+
};
|
|
3773
|
+
}
|
|
3774
|
+
async function applyPopupBounds(projectPath, browser, targetId) {
|
|
3775
|
+
const resolved = await resolveCdpPort(projectPath, browser);
|
|
3776
|
+
if (!resolved) return null;
|
|
3777
|
+
const cdp = new CDPClient();
|
|
3778
|
+
try {
|
|
3779
|
+
const ws = await CDPClient.discoverBrowserWsUrl(resolved.port);
|
|
3780
|
+
await cdp.connect(ws);
|
|
3781
|
+
const sessionId = await cdp.attachToTarget(targetId);
|
|
3782
|
+
const measured = await cdp.evaluate(sessionId, `(() => {
|
|
3783
|
+
const de = document.documentElement, b = document.body;
|
|
3784
|
+
if (!de || !b) return null;
|
|
3785
|
+
const prev = de.style.width;
|
|
3786
|
+
de.style.width = "fit-content";
|
|
3787
|
+
const w = Math.max(de.getBoundingClientRect().width, b.getBoundingClientRect().width);
|
|
3788
|
+
const h = Math.max(de.getBoundingClientRect().height, b.getBoundingClientRect().height, b.scrollHeight);
|
|
3789
|
+
de.style.width = prev;
|
|
3790
|
+
return { w: Math.ceil(w), h: Math.ceil(h) };
|
|
3791
|
+
})()`);
|
|
3792
|
+
if (!measured || "number" != typeof measured.w || "number" != typeof measured.h || measured.w <= 0 || measured.h <= 0) return null;
|
|
3793
|
+
const bounds = clampPopupBounds(measured.w, measured.h);
|
|
3794
|
+
const win = await cdp.sendCommand("Browser.getWindowForTarget", {
|
|
3795
|
+
targetId
|
|
3796
|
+
});
|
|
3797
|
+
if ("number" != typeof win?.windowId) return null;
|
|
3798
|
+
await cdp.sendCommand("Browser.setWindowBounds", {
|
|
3799
|
+
windowId: win.windowId,
|
|
3800
|
+
bounds: {
|
|
3801
|
+
width: bounds.width,
|
|
3802
|
+
height: bounds.height
|
|
3803
|
+
}
|
|
3804
|
+
});
|
|
3805
|
+
const after = await cdp.sendCommand("Browser.getWindowBounds", {
|
|
3806
|
+
windowId: win.windowId
|
|
3807
|
+
});
|
|
3808
|
+
if (after?.bounds?.width !== bounds.width || after?.bounds?.height !== bounds.height) return null;
|
|
3809
|
+
return bounds;
|
|
3810
|
+
} catch {
|
|
3811
|
+
return null;
|
|
3812
|
+
} finally{
|
|
3813
|
+
try {
|
|
3814
|
+
cdp.disconnect();
|
|
3815
|
+
} catch {}
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
async function openSurfaceAsTab(projectPath, browser, surface) {
|
|
3819
|
+
const doc = surfaceDocument(projectPath, browser, surface);
|
|
3820
|
+
if (!doc) return JSON.stringify({
|
|
3821
|
+
ok: false,
|
|
3822
|
+
error: {
|
|
3823
|
+
name: "NoSurfaceDocument",
|
|
3824
|
+
message: `The manifest declares no document for surface "${surface}", so there is no page to render as a tab.`
|
|
3825
|
+
},
|
|
3826
|
+
hint: "Check the manifest: popup needs action.default_popup, options needs options_ui.page or options_page, sidebar needs side_panel.default_path."
|
|
3827
|
+
});
|
|
3828
|
+
const id = await resolveExtensionId(projectPath, browser);
|
|
3829
|
+
if (!id) return JSON.stringify({
|
|
3830
|
+
ok: false,
|
|
3831
|
+
error: {
|
|
3832
|
+
name: "NoExtensionId",
|
|
3833
|
+
message: "Could not resolve the extension id from the live session's CDP targets."
|
|
3834
|
+
},
|
|
3835
|
+
hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
|
|
3836
|
+
});
|
|
3837
|
+
const url = `chrome-extension://${id}/${doc}`;
|
|
3838
|
+
const raw = await navigateToUrl(projectPath, browser, url);
|
|
3839
|
+
try {
|
|
3840
|
+
const parsed = JSON.parse(raw);
|
|
3841
|
+
if (parsed?.ok) {
|
|
3842
|
+
parsed.renderedAsTab = {
|
|
3843
|
+
surface,
|
|
3844
|
+
document: doc,
|
|
3845
|
+
extensionId: id
|
|
3846
|
+
};
|
|
3847
|
+
let popupBounds = null;
|
|
3848
|
+
if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
|
|
3849
|
+
popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
|
|
3850
|
+
if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
|
|
3851
|
+
}
|
|
3852
|
+
parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_inspect context: '${surface}' (include: ['html']), or extension_source_inspect with this url. ` + "Do NOT pass this chrome-extension:// url to extension_dom_inspect or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
|
|
3853
|
+
return JSON.stringify(parsed);
|
|
3854
|
+
}
|
|
3855
|
+
} catch {}
|
|
3856
|
+
return raw;
|
|
3857
|
+
}
|
|
3277
3858
|
const open_schema = {
|
|
3278
3859
|
name: "extension_open",
|
|
3279
|
-
description: "Open an extension surface or replay an event in a running session. 'popup'/'options'/'sidebar' open UI surfaces. 'action' triggers the toolbar action: opens the action's popup, or (no popup) replays chrome.action.onClicked. 'command' replays a chrome.commands.onCommand keyboard shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result includes gesture:false and a warning when activeTab is declared). Requires the dev session to be started with allowControl: true (extension_dev). Wraps `extension open`.",
|
|
3860
|
+
description: "Open an extension surface or replay an event in a running session. 'popup'/'options'/'sidebar' open UI surfaces; 'newtab'/'history'/'bookmarks' open the extension's chrome_url_overrides page in a tab. 'action' triggers the toolbar action: opens the action's popup, or (no popup) replays chrome.action.onClicked. 'command' replays a chrome.commands.onCommand keyboard shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result includes gesture:false and a warning when activeTab is declared). Requires the dev session to be started with allowControl: true (extension_dev). Wraps `extension open`.",
|
|
3280
3861
|
inputSchema: {
|
|
3281
3862
|
type: "object",
|
|
3282
3863
|
properties: {
|
|
@@ -3290,10 +3871,13 @@ const open_schema = {
|
|
|
3290
3871
|
"popup",
|
|
3291
3872
|
"options",
|
|
3292
3873
|
"sidebar",
|
|
3874
|
+
"newtab",
|
|
3875
|
+
"history",
|
|
3876
|
+
"bookmarks",
|
|
3293
3877
|
"action",
|
|
3294
3878
|
"command"
|
|
3295
3879
|
],
|
|
3296
|
-
description: "Which surface to open or event to replay. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
|
|
3880
|
+
description: "Which surface to open or event to replay. 'newtab'/'history'/'bookmarks' open the matching chrome_url_overrides page. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
|
|
3297
3881
|
},
|
|
3298
3882
|
name: {
|
|
3299
3883
|
type: "string",
|
|
@@ -3303,6 +3887,11 @@ const open_schema = {
|
|
|
3303
3887
|
type: "string",
|
|
3304
3888
|
description: "Navigate a real tab to this URL (Chromium only, via CDP) instead of opening a surface. Use for content-script/webNavigation test pages, or the popup as a page: chrome-extension://<id>/popup.html. Alternative to `surface`."
|
|
3305
3889
|
},
|
|
3890
|
+
asTab: {
|
|
3891
|
+
type: "boolean",
|
|
3892
|
+
default: false,
|
|
3893
|
+
description: "For surface popup/options/sidebar: render the surface's document in a real tab (chrome-extension://<id>/<doc>) instead of opening a real popup window. This is how you inspect a surface HEADLESSLY, where no window exists to host a popup. Applied automatically as a fallback when a headless session refuses to open the surface. Same page and APIs, but no popup sizing and window.close() closes the tab."
|
|
3894
|
+
},
|
|
3306
3895
|
browser: {
|
|
3307
3896
|
type: "string",
|
|
3308
3897
|
description: "Browser session to target. Defaults to the active dev session's browser for this project."
|
|
@@ -3320,6 +3909,15 @@ const open_schema = {
|
|
|
3320
3909
|
async function open_handler(args) {
|
|
3321
3910
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
3322
3911
|
if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
|
|
3912
|
+
const AS_TAB_SURFACES = [
|
|
3913
|
+
"popup",
|
|
3914
|
+
"options",
|
|
3915
|
+
"sidebar",
|
|
3916
|
+
"newtab",
|
|
3917
|
+
"history",
|
|
3918
|
+
"bookmarks"
|
|
3919
|
+
];
|
|
3920
|
+
if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
3323
3921
|
if (!args.surface) return JSON.stringify({
|
|
3324
3922
|
ok: false,
|
|
3325
3923
|
error: {
|
|
@@ -3327,6 +3925,18 @@ async function open_handler(args) {
|
|
|
3327
3925
|
message: "Pass `surface` (popup/options/sidebar/action/command) to open a surface, or `url` to navigate a tab."
|
|
3328
3926
|
}
|
|
3329
3927
|
});
|
|
3928
|
+
if ("command" === args.surface) {
|
|
3929
|
+
const declared = declaredCommands(args.projectPath, browser);
|
|
3930
|
+
if (declared && args.name && !declared.includes(args.name)) return JSON.stringify({
|
|
3931
|
+
ok: false,
|
|
3932
|
+
error: {
|
|
3933
|
+
name: "UnknownCommand",
|
|
3934
|
+
message: `"${args.name}" is not declared in the manifest's \`commands\`, so triggering it can only ever be a no-op.`
|
|
3935
|
+
},
|
|
3936
|
+
declaredCommands: declared,
|
|
3937
|
+
hint: declared.length ? `Declared commands are: ${declared.join(", ")}. Check for a typo, or add "${args.name}" to the manifest.` : "This manifest declares no commands at all. Add a `commands` block, rebuild, then retry."
|
|
3938
|
+
});
|
|
3939
|
+
}
|
|
3330
3940
|
const cli = [
|
|
3331
3941
|
"open",
|
|
3332
3942
|
args.surface,
|
|
@@ -3344,8 +3954,18 @@ async function open_handler(args) {
|
|
|
3344
3954
|
].includes(args.surface)) try {
|
|
3345
3955
|
const parsed = JSON.parse(raw);
|
|
3346
3956
|
const msg = String(parsed?.error?.message ?? "");
|
|
3347
|
-
if (parsed?.ok === false &&
|
|
3348
|
-
|
|
3957
|
+
if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
|
|
3958
|
+
if (AS_TAB_SURFACES.includes(args.surface) && isChromiumFamily(browser)) {
|
|
3959
|
+
const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
3960
|
+
try {
|
|
3961
|
+
const parsedFallback = JSON.parse(fallback);
|
|
3962
|
+
if (parsedFallback?.ok) {
|
|
3963
|
+
parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS), which has no window to attach a popup/sidebar to, so the surface was rendered as a tab instead. Pass asTab: false and relaunch headed for a real popup window.";
|
|
3964
|
+
return JSON.stringify(parsedFallback);
|
|
3965
|
+
}
|
|
3966
|
+
} catch {}
|
|
3967
|
+
}
|
|
3968
|
+
if (!parsed.hint) parsed.hint = /user gesture/i.test(msg) ? "This surface can only open from a real user gesture, which headless automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : "The dev browser is running headless (EXTENSION_HEADLESS), which has no visible window to attach a popup/sidebar to. Retry with asTab: true to render the surface document in a tab, or relaunch a headed dev session for a real popup window.";
|
|
3349
3969
|
return JSON.stringify(parsed);
|
|
3350
3970
|
}
|
|
3351
3971
|
} catch {}
|
|
@@ -3363,7 +3983,16 @@ const dom_inspect_schema = {
|
|
|
3363
3983
|
},
|
|
3364
3984
|
tab: {
|
|
3365
3985
|
type: "number",
|
|
3366
|
-
description: "
|
|
3986
|
+
description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
|
|
3987
|
+
},
|
|
3988
|
+
url: {
|
|
3989
|
+
type: "string",
|
|
3990
|
+
description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`."
|
|
3991
|
+
},
|
|
3992
|
+
listTabs: {
|
|
3993
|
+
type: "boolean",
|
|
3994
|
+
default: false,
|
|
3995
|
+
description: "Enumerate open tabs as {tabId,url,title} and return, ignoring the other args. The discovery path when you need an explicit numeric tab id."
|
|
3367
3996
|
},
|
|
3368
3997
|
context: {
|
|
3369
3998
|
type: "string",
|
|
@@ -3373,10 +4002,13 @@ const dom_inspect_schema = {
|
|
|
3373
4002
|
"popup",
|
|
3374
4003
|
"options",
|
|
3375
4004
|
"sidebar",
|
|
3376
|
-
"devtools"
|
|
4005
|
+
"devtools",
|
|
4006
|
+
"newtab",
|
|
4007
|
+
"history",
|
|
4008
|
+
"bookmarks"
|
|
3377
4009
|
],
|
|
3378
4010
|
default: "content",
|
|
3379
|
-
description: "content/page (
|
|
4011
|
+
description: "content/page (targets `url`, else the active tab), an OPEN extension surface (popup/options/sidebar/devtools), or an override page (newtab/history/bookmarks)"
|
|
3380
4012
|
},
|
|
3381
4013
|
include: {
|
|
3382
4014
|
type: "array",
|
|
@@ -3397,8 +4029,11 @@ const dom_inspect_schema = {
|
|
|
3397
4029
|
default: 262144
|
|
3398
4030
|
},
|
|
3399
4031
|
withConsole: {
|
|
3400
|
-
type:
|
|
3401
|
-
|
|
4032
|
+
type: [
|
|
4033
|
+
"number",
|
|
4034
|
+
"boolean"
|
|
4035
|
+
],
|
|
4036
|
+
description: "Also include recent console lines for the target (DOM + console in one call). A number is how many lines; true means 50."
|
|
3402
4037
|
},
|
|
3403
4038
|
browser: {
|
|
3404
4039
|
type: "string",
|
|
@@ -3415,30 +4050,28 @@ const dom_inspect_schema = {
|
|
|
3415
4050
|
}
|
|
3416
4051
|
};
|
|
3417
4052
|
async function dom_inspect_handler(args) {
|
|
3418
|
-
const
|
|
3419
|
-
|
|
3420
|
-
"
|
|
3421
|
-
|
|
3422
|
-
"
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
},
|
|
3431
|
-
hint: "To inspect a content script's DOM without hunting for a tab id, use extension_source_inspect (it auto-selects the active page and can navigate via its url arg). Use extension_dom_inspect with context popup/options/sidebar/devtools for an open surface (no tab needed)."
|
|
3432
|
-
});
|
|
4053
|
+
const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
|
|
4054
|
+
if (args.listTabs) return runActVerb([
|
|
4055
|
+
"inspect",
|
|
4056
|
+
args.projectPath,
|
|
4057
|
+
"--list-tabs",
|
|
4058
|
+
"--browser",
|
|
4059
|
+
resolveSessionBrowser(args.projectPath, args.browser).browser,
|
|
4060
|
+
...null != args.timeout ? [
|
|
4061
|
+
"--timeout",
|
|
4062
|
+
String(args.timeout)
|
|
4063
|
+
] : []
|
|
4064
|
+
], args.projectPath, args.timeout);
|
|
3433
4065
|
const cli = [
|
|
3434
4066
|
"inspect",
|
|
3435
4067
|
args.projectPath
|
|
3436
4068
|
];
|
|
3437
4069
|
if (null != args.tab) cli.push("--tab", String(args.tab));
|
|
4070
|
+
if (args.url) cli.push("--url", args.url);
|
|
3438
4071
|
if (args.context) cli.push("--context", args.context);
|
|
3439
4072
|
if (args.include?.length) cli.push("--include", args.include.join(","));
|
|
3440
4073
|
if (null != args.maxBytes) cli.push("--max-bytes", String(args.maxBytes));
|
|
3441
|
-
if (null !=
|
|
4074
|
+
if (null != withConsole) cli.push("--with-console", String(withConsole));
|
|
3442
4075
|
cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
|
|
3443
4076
|
if (null != args.timeout) cli.push("--timeout", String(args.timeout));
|
|
3444
4077
|
return runActVerb(cli, args.projectPath, args.timeout);
|
|
@@ -3932,6 +4565,188 @@ async function deploy_handler(args) {
|
|
|
3932
4565
|
...data
|
|
3933
4566
|
});
|
|
3934
4567
|
}
|
|
4568
|
+
function doctor_readReadyContract(projectPath, browser) {
|
|
4569
|
+
try {
|
|
4570
|
+
const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
|
|
4571
|
+
return JSON.parse(raw);
|
|
4572
|
+
} catch {
|
|
4573
|
+
return null;
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
const doctor_schema = {
|
|
4577
|
+
name: "extension_doctor",
|
|
4578
|
+
description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order, a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
|
|
4579
|
+
inputSchema: {
|
|
4580
|
+
type: "object",
|
|
4581
|
+
properties: {
|
|
4582
|
+
projectPath: {
|
|
4583
|
+
type: "string",
|
|
4584
|
+
description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
|
|
4585
|
+
},
|
|
4586
|
+
browser: {
|
|
4587
|
+
type: "string",
|
|
4588
|
+
description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
}
|
|
4592
|
+
};
|
|
4593
|
+
async function environmentPreflight() {
|
|
4594
|
+
const checks = [];
|
|
4595
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
4596
|
+
checks.push({
|
|
4597
|
+
check: "node",
|
|
4598
|
+
status: nodeMajor >= 20 ? "pass" : "fail",
|
|
4599
|
+
detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
|
|
4600
|
+
remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
|
|
4601
|
+
});
|
|
4602
|
+
const { code, stdout, stderr } = await runExtensionCli([
|
|
4603
|
+
"--version"
|
|
4604
|
+
], {
|
|
4605
|
+
timeoutMs: 60000
|
|
4606
|
+
});
|
|
4607
|
+
const cliVersion = stdout.trim() || stderr.trim();
|
|
4608
|
+
checks.push({
|
|
4609
|
+
check: "extension-cli",
|
|
4610
|
+
status: 0 === code ? "pass" : "fail",
|
|
4611
|
+
detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
|
|
4612
|
+
remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
|
|
4613
|
+
});
|
|
4614
|
+
const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
|
|
4615
|
+
const cacheExists = node_fs.existsSync(cacheFile);
|
|
4616
|
+
checks.push({
|
|
4617
|
+
check: "template-cache",
|
|
4618
|
+
status: cacheExists ? "pass" : "warn",
|
|
4619
|
+
detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
|
|
4620
|
+
});
|
|
4621
|
+
const healthy = checks.every((c)=>"fail" !== c.status);
|
|
4622
|
+
return JSON.stringify({
|
|
4623
|
+
mode: "environment",
|
|
4624
|
+
healthy,
|
|
4625
|
+
checks,
|
|
4626
|
+
hint: "Pass projectPath to diagnose a live dev session end-to-end."
|
|
4627
|
+
});
|
|
4628
|
+
}
|
|
4629
|
+
function safeStringify(value) {
|
|
4630
|
+
try {
|
|
4631
|
+
return JSON.stringify(value) ?? String(value);
|
|
4632
|
+
} catch {
|
|
4633
|
+
return String(value);
|
|
4634
|
+
}
|
|
4635
|
+
}
|
|
4636
|
+
function recentErrorLogs(projectPath, browser, max = 5) {
|
|
4637
|
+
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
|
|
4638
|
+
let lines;
|
|
4639
|
+
try {
|
|
4640
|
+
lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
4641
|
+
} catch {
|
|
4642
|
+
return [];
|
|
4643
|
+
}
|
|
4644
|
+
const errs = [];
|
|
4645
|
+
for (const line of lines){
|
|
4646
|
+
let ev;
|
|
4647
|
+
try {
|
|
4648
|
+
ev = JSON.parse(line);
|
|
4649
|
+
} catch {
|
|
4650
|
+
continue;
|
|
4651
|
+
}
|
|
4652
|
+
if (!ev || "error" !== ev.level) continue;
|
|
4653
|
+
const parts = Array.isArray(ev.messageParts) ? ev.messageParts : Array.isArray(ev.args) ? ev.args : null;
|
|
4654
|
+
let msg = parts ? parts.map((p)=>"string" == typeof p ? p : safeStringify(p)).join(" ") : ev.message || ev.text || "";
|
|
4655
|
+
if (!msg && ev.errorName) msg = ev.stack ? `${ev.errorName}: ${ev.stack}` : ev.errorName;
|
|
4656
|
+
msg = msg.replace(/\s+/g, " ").trim();
|
|
4657
|
+
if (msg) errs.push(msg.slice(0, 300));
|
|
4658
|
+
}
|
|
4659
|
+
return [
|
|
4660
|
+
...new Set(errs)
|
|
4661
|
+
].slice(-max);
|
|
4662
|
+
}
|
|
4663
|
+
function projectEngineVersion(projectPath) {
|
|
4664
|
+
try {
|
|
4665
|
+
const p = node_path.resolve(projectPath, "node_modules", "extension", "package.json");
|
|
4666
|
+
return JSON.parse(node_fs.readFileSync(p, "utf8")).version || null;
|
|
4667
|
+
} catch {
|
|
4668
|
+
return null;
|
|
4669
|
+
}
|
|
4670
|
+
}
|
|
4671
|
+
async function doctor_handler(args) {
|
|
4672
|
+
if (!args.projectPath) return environmentPreflight();
|
|
4673
|
+
const projectPath = args.projectPath;
|
|
4674
|
+
const { browser } = resolveSessionBrowser(projectPath, args.browser);
|
|
4675
|
+
const { code, stdout, stderr } = await runExtensionCli([
|
|
4676
|
+
"doctor",
|
|
4677
|
+
projectPath,
|
|
4678
|
+
"--browser",
|
|
4679
|
+
browser,
|
|
4680
|
+
"--output",
|
|
4681
|
+
"json"
|
|
4682
|
+
], {
|
|
4683
|
+
cwd: projectPath
|
|
4684
|
+
});
|
|
4685
|
+
const out = stdout.trim();
|
|
4686
|
+
try {
|
|
4687
|
+
const checks = JSON.parse(out);
|
|
4688
|
+
if (!Array.isArray(checks)) throw new Error("not a check array");
|
|
4689
|
+
for (const check of checks){
|
|
4690
|
+
if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
|
|
4691
|
+
if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
|
|
4692
|
+
}
|
|
4693
|
+
let healthy = 0 === code;
|
|
4694
|
+
const contract = doctor_readReadyContract(projectPath, browser);
|
|
4695
|
+
if (contract?.status === "error") {
|
|
4696
|
+
healthy = false;
|
|
4697
|
+
const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
|
|
4698
|
+
const detail = browserExited ? `The ${browser} browser for this session exited unexpectedly${null != contract.browserExitCode ? ` (exit code ${contract.browserExitCode})` : ""}; the extension may have been rejected or the browser crashed. The session cannot be driven.` : contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
|
|
4699
|
+
checks.push({
|
|
4700
|
+
check: "runtime-errors",
|
|
4701
|
+
status: "fail",
|
|
4702
|
+
detail: toMcpSpeak(detail),
|
|
4703
|
+
remediation: browserExited ? "Read extension_logs and the session log for the rejection cause, call extension_stop to clean up, then relaunch." : "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
|
|
4704
|
+
});
|
|
4705
|
+
} else {
|
|
4706
|
+
const errs = recentErrorLogs(projectPath, browser);
|
|
4707
|
+
if (errs.length) {
|
|
4708
|
+
healthy = false;
|
|
4709
|
+
checks.push({
|
|
4710
|
+
check: "runtime-errors",
|
|
4711
|
+
status: "fail",
|
|
4712
|
+
detail: `Recent error-level logs: ${errs.join(" | ")}`,
|
|
4713
|
+
remediation: "The extension is throwing at runtime. Inspect with extension_logs. A chrome.* API called without its permission is a common cause: extension_manifest_validate catches a permission MISSING FROM permissions[], but it does not model host-permission scope (e.g. webRequest with no matching host_permissions) or gesture requirements (e.g. activeTab without a user gesture), so a valid:true there does not rule those out."
|
|
4714
|
+
});
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
const engineVersion = projectEngineVersion(projectPath);
|
|
4718
|
+
if (engineVersion) {
|
|
4719
|
+
const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
|
|
4720
|
+
const mismatch = "" !== pin && "latest" !== pin && !engineVersion.includes(pin);
|
|
4721
|
+
checks.push({
|
|
4722
|
+
check: "project-engine",
|
|
4723
|
+
status: mismatch ? "warn" : "pass",
|
|
4724
|
+
detail: `project-local extension@${engineVersion}${mismatch ? `, but EXTENSION_MCP_CLI_VERSION=${pin}; the dev loop uses the project bin, not the pin` : ""}`,
|
|
4725
|
+
...mismatch ? {
|
|
4726
|
+
remediation: `Run \`(cd ${projectPath} && npm i -D extension@${pin})\` to match the pinned engine.`
|
|
4727
|
+
} : {}
|
|
4728
|
+
});
|
|
4729
|
+
}
|
|
4730
|
+
return JSON.stringify({
|
|
4731
|
+
browser,
|
|
4732
|
+
...engineVersion ? {
|
|
4733
|
+
engineVersion
|
|
4734
|
+
} : {},
|
|
4735
|
+
healthy,
|
|
4736
|
+
checks
|
|
4737
|
+
});
|
|
4738
|
+
} catch {
|
|
4739
|
+
const message = stderr.trim() || `extension exited with code ${code}`;
|
|
4740
|
+
return JSON.stringify({
|
|
4741
|
+
ok: false,
|
|
4742
|
+
error: {
|
|
4743
|
+
name: "CliError",
|
|
4744
|
+
message: toMcpSpeak(message),
|
|
4745
|
+
hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
|
|
4746
|
+
}
|
|
4747
|
+
});
|
|
4748
|
+
}
|
|
4749
|
+
}
|
|
3935
4750
|
const SAFE_CEILING_MS = 50000;
|
|
3936
4751
|
function wait_isAlive(pid) {
|
|
3937
4752
|
try {
|
|
@@ -3974,6 +4789,7 @@ async function wait_handler(args) {
|
|
|
3974
4789
|
const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
|
|
3975
4790
|
const start = Date.now();
|
|
3976
4791
|
const pollInterval = 1000;
|
|
4792
|
+
let sawCompiledButUnattached = false;
|
|
3977
4793
|
while(Date.now() - start < timeout){
|
|
3978
4794
|
try {
|
|
3979
4795
|
const raw = node_fs.readFileSync(readyPath, "utf8");
|
|
@@ -3981,11 +4797,18 @@ async function wait_handler(args) {
|
|
|
3981
4797
|
if ("ready" === contract.status) {
|
|
3982
4798
|
if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
|
|
3983
4799
|
status: "stale",
|
|
3984
|
-
message: `ready.json reports ready but its dev-server pid ${contract.pid} is dead
|
|
4800
|
+
message: `ready.json reports ready but its dev-server pid ${contract.pid} is dead, the session exited. Restart with extension_dev; extension_doctor will confirm.`,
|
|
3985
4801
|
browser: contract.browser,
|
|
3986
4802
|
pid: contract.pid,
|
|
3987
4803
|
waitDuration: Date.now() - start
|
|
3988
4804
|
});
|
|
4805
|
+
const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
|
|
4806
|
+
if (!attached) {
|
|
4807
|
+
await new Promise((r)=>setTimeout(r, pollInterval));
|
|
4808
|
+
sawCompiledButUnattached = true;
|
|
4809
|
+
continue;
|
|
4810
|
+
}
|
|
4811
|
+
const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
|
|
3989
4812
|
return JSON.stringify({
|
|
3990
4813
|
status: "ready",
|
|
3991
4814
|
command: contract.command,
|
|
@@ -3996,7 +4819,11 @@ async function wait_handler(args) {
|
|
|
3996
4819
|
manifestPath: contract.manifestPath,
|
|
3997
4820
|
compiledAt: contract.compiledAt,
|
|
3998
4821
|
startedAt: contract.startedAt,
|
|
3999
|
-
waitDuration: Date.now() - start
|
|
4822
|
+
waitDuration: Date.now() - start,
|
|
4823
|
+
...runtimeErrors.length ? {
|
|
4824
|
+
runtimeErrors,
|
|
4825
|
+
warning: `Compiled and attached, but the extension is throwing at runtime (${runtimeErrors.length} recent error event${1 === runtimeErrors.length ? "" : "s"} above). Check extension_logs (level: error) or extension_doctor before trusting this session.`
|
|
4826
|
+
} : {}
|
|
4000
4827
|
});
|
|
4001
4828
|
}
|
|
4002
4829
|
if ("error" === contract.status) return JSON.stringify({
|
|
@@ -4010,13 +4837,20 @@ async function wait_handler(args) {
|
|
|
4010
4837
|
} catch {}
|
|
4011
4838
|
await new Promise((resolve)=>setTimeout(resolve, pollInterval));
|
|
4012
4839
|
}
|
|
4840
|
+
if (sawCompiledButUnattached) return JSON.stringify({
|
|
4841
|
+
status: "compiled-not-attached",
|
|
4842
|
+
message: `The extension compiled, but the runtime executor never attached within ${timeout}ms. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`,
|
|
4843
|
+
readyPath,
|
|
4844
|
+
waitDuration: Date.now() - start,
|
|
4845
|
+
hint: "This is usually transient: call extension_wait again. If it persists, stop and restart the session with extension_dev (a restart reliably reattaches); extension_doctor reports the executor leg."
|
|
4846
|
+
});
|
|
4013
4847
|
return JSON.stringify({
|
|
4014
4848
|
status: "timeout",
|
|
4015
4849
|
message: `Extension not ready after ${timeout}ms this call`,
|
|
4016
4850
|
readyPath,
|
|
4017
4851
|
waitDuration: Date.now() - start,
|
|
4018
4852
|
clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
|
|
4019
|
-
hint: "Still building
|
|
4853
|
+
hint: "Still building, call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
|
|
4020
4854
|
});
|
|
4021
4855
|
}
|
|
4022
4856
|
const add_feature_schema = {
|
|
@@ -5135,193 +5969,27 @@ async function detect_browsers_handler(args) {
|
|
|
5135
5969
|
hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}.${missing.some((d)=>MANAGED_INSTALLABLE.has(d.browser)) ? ` Use extension_install_browser to install ${missing.filter((d)=>MANAGED_INSTALLABLE.has(d.browser)).map((d)=>d.browser).join(", ")}.` : ""}` : "All requested browsers are available."
|
|
5136
5970
|
});
|
|
5137
5971
|
}
|
|
5138
|
-
function doctor_readReadyContract(projectPath, browser) {
|
|
5139
|
-
try {
|
|
5140
|
-
const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
|
|
5141
|
-
return JSON.parse(raw);
|
|
5142
|
-
} catch {
|
|
5143
|
-
return null;
|
|
5144
|
-
}
|
|
5145
|
-
}
|
|
5146
|
-
const doctor_schema = {
|
|
5147
|
-
name: "extension_doctor",
|
|
5148
|
-
description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order — a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
|
|
5149
|
-
inputSchema: {
|
|
5150
|
-
type: "object",
|
|
5151
|
-
properties: {
|
|
5152
|
-
projectPath: {
|
|
5153
|
-
type: "string",
|
|
5154
|
-
description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
|
|
5155
|
-
},
|
|
5156
|
-
browser: {
|
|
5157
|
-
type: "string",
|
|
5158
|
-
description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
|
|
5159
|
-
}
|
|
5160
|
-
}
|
|
5161
|
-
}
|
|
5162
|
-
};
|
|
5163
|
-
async function environmentPreflight() {
|
|
5164
|
-
const checks = [];
|
|
5165
|
-
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
5166
|
-
checks.push({
|
|
5167
|
-
check: "node",
|
|
5168
|
-
status: nodeMajor >= 20 ? "pass" : "fail",
|
|
5169
|
-
detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
|
|
5170
|
-
remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
|
|
5171
|
-
});
|
|
5172
|
-
const { code, stdout, stderr } = await runExtensionCli([
|
|
5173
|
-
"--version"
|
|
5174
|
-
], {
|
|
5175
|
-
timeoutMs: 60000
|
|
5176
|
-
});
|
|
5177
|
-
const cliVersion = stdout.trim() || stderr.trim();
|
|
5178
|
-
checks.push({
|
|
5179
|
-
check: "extension-cli",
|
|
5180
|
-
status: 0 === code ? "pass" : "fail",
|
|
5181
|
-
detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
|
|
5182
|
-
remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
|
|
5183
|
-
});
|
|
5184
|
-
const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
|
|
5185
|
-
const cacheExists = node_fs.existsSync(cacheFile);
|
|
5186
|
-
checks.push({
|
|
5187
|
-
check: "template-cache",
|
|
5188
|
-
status: cacheExists ? "pass" : "warn",
|
|
5189
|
-
detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
|
|
5190
|
-
});
|
|
5191
|
-
const healthy = checks.every((c)=>"fail" !== c.status);
|
|
5192
|
-
return JSON.stringify({
|
|
5193
|
-
mode: "environment",
|
|
5194
|
-
healthy,
|
|
5195
|
-
checks,
|
|
5196
|
-
hint: "Pass projectPath to diagnose a live dev session end-to-end."
|
|
5197
|
-
});
|
|
5198
|
-
}
|
|
5199
|
-
function recentErrorLogs(projectPath, browser, max = 5) {
|
|
5200
|
-
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
|
|
5201
|
-
let lines;
|
|
5202
|
-
try {
|
|
5203
|
-
lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
5204
|
-
} catch {
|
|
5205
|
-
return [];
|
|
5206
|
-
}
|
|
5207
|
-
const errs = [];
|
|
5208
|
-
for (const line of lines){
|
|
5209
|
-
let ev;
|
|
5210
|
-
try {
|
|
5211
|
-
ev = JSON.parse(line);
|
|
5212
|
-
} catch {
|
|
5213
|
-
continue;
|
|
5214
|
-
}
|
|
5215
|
-
if (ev && "error" === ev.level) {
|
|
5216
|
-
const msg = Array.isArray(ev.args) ? ev.args.join(" ") : ev.message || ev.text || "";
|
|
5217
|
-
if (msg) errs.push(String(msg).slice(0, 300));
|
|
5218
|
-
}
|
|
5219
|
-
}
|
|
5220
|
-
return errs.slice(-max);
|
|
5221
|
-
}
|
|
5222
|
-
function projectEngineVersion(projectPath) {
|
|
5223
|
-
try {
|
|
5224
|
-
const p = node_path.resolve(projectPath, "node_modules", "extension", "package.json");
|
|
5225
|
-
return JSON.parse(node_fs.readFileSync(p, "utf8")).version || null;
|
|
5226
|
-
} catch {
|
|
5227
|
-
return null;
|
|
5228
|
-
}
|
|
5229
|
-
}
|
|
5230
|
-
async function doctor_handler(args) {
|
|
5231
|
-
if (!args.projectPath) return environmentPreflight();
|
|
5232
|
-
const projectPath = args.projectPath;
|
|
5233
|
-
const { browser } = resolveSessionBrowser(projectPath, args.browser);
|
|
5234
|
-
const { code, stdout, stderr } = await runExtensionCli([
|
|
5235
|
-
"doctor",
|
|
5236
|
-
projectPath,
|
|
5237
|
-
"--browser",
|
|
5238
|
-
browser,
|
|
5239
|
-
"--output",
|
|
5240
|
-
"json"
|
|
5241
|
-
], {
|
|
5242
|
-
cwd: projectPath
|
|
5243
|
-
});
|
|
5244
|
-
const out = stdout.trim();
|
|
5245
|
-
try {
|
|
5246
|
-
const checks = JSON.parse(out);
|
|
5247
|
-
if (!Array.isArray(checks)) throw new Error("not a check array");
|
|
5248
|
-
for (const check of checks){
|
|
5249
|
-
if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
|
|
5250
|
-
if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
|
|
5251
|
-
}
|
|
5252
|
-
let healthy = 0 === code;
|
|
5253
|
-
const contract = doctor_readReadyContract(projectPath, browser);
|
|
5254
|
-
if (contract?.status === "error") {
|
|
5255
|
-
healthy = false;
|
|
5256
|
-
const detail = contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
|
|
5257
|
-
checks.push({
|
|
5258
|
-
check: "runtime-errors",
|
|
5259
|
-
status: "fail",
|
|
5260
|
-
detail: toMcpSpeak(detail),
|
|
5261
|
-
remediation: "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
|
|
5262
|
-
});
|
|
5263
|
-
} else {
|
|
5264
|
-
const errs = recentErrorLogs(projectPath, browser);
|
|
5265
|
-
if (errs.length) {
|
|
5266
|
-
healthy = false;
|
|
5267
|
-
checks.push({
|
|
5268
|
-
check: "runtime-errors",
|
|
5269
|
-
status: "fail",
|
|
5270
|
-
detail: `Recent error-level logs: ${errs.join(" | ")}`,
|
|
5271
|
-
remediation: "The extension is throwing at runtime. Inspect with extension_logs; a missing permission for a called chrome.* API is a common cause (extension_manifest_validate now checks this)."
|
|
5272
|
-
});
|
|
5273
|
-
}
|
|
5274
|
-
}
|
|
5275
|
-
const engineVersion = projectEngineVersion(projectPath);
|
|
5276
|
-
if (engineVersion) {
|
|
5277
|
-
const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
|
|
5278
|
-
const mismatch = "" !== pin && "latest" !== pin && !engineVersion.includes(pin);
|
|
5279
|
-
checks.push({
|
|
5280
|
-
check: "project-engine",
|
|
5281
|
-
status: mismatch ? "warn" : "pass",
|
|
5282
|
-
detail: `project-local extension@${engineVersion}${mismatch ? ` — but EXTENSION_MCP_CLI_VERSION=${pin}; the dev loop uses the project bin, not the pin` : ""}`,
|
|
5283
|
-
...mismatch ? {
|
|
5284
|
-
remediation: `Run \`(cd ${projectPath} && npm i -D extension@${pin})\` to match the pinned engine.`
|
|
5285
|
-
} : {}
|
|
5286
|
-
});
|
|
5287
|
-
}
|
|
5288
|
-
return JSON.stringify({
|
|
5289
|
-
browser,
|
|
5290
|
-
...engineVersion ? {
|
|
5291
|
-
engineVersion
|
|
5292
|
-
} : {},
|
|
5293
|
-
healthy,
|
|
5294
|
-
checks
|
|
5295
|
-
});
|
|
5296
|
-
} catch {
|
|
5297
|
-
const message = stderr.trim() || `extension exited with code ${code}`;
|
|
5298
|
-
return JSON.stringify({
|
|
5299
|
-
ok: false,
|
|
5300
|
-
error: {
|
|
5301
|
-
name: "CliError",
|
|
5302
|
-
message: toMcpSpeak(message),
|
|
5303
|
-
hint: "extension doctor requires a recent extension CLI — the project's local install may predate it."
|
|
5304
|
-
}
|
|
5305
|
-
});
|
|
5306
|
-
}
|
|
5307
|
-
}
|
|
5308
5972
|
function typeOf(value) {
|
|
5309
5973
|
if (Array.isArray(value)) return "array";
|
|
5310
5974
|
if (null === value) return "null";
|
|
5311
5975
|
return typeof value;
|
|
5312
5976
|
}
|
|
5313
5977
|
function checkPrimitive(path, value, schema, issues) {
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5978
|
+
const allowed = void 0 === schema.type ? [] : Array.isArray(schema.type) ? schema.type : [
|
|
5979
|
+
schema.type
|
|
5980
|
+
];
|
|
5981
|
+
const primitives = allowed.filter((t)=>[
|
|
5982
|
+
"string",
|
|
5983
|
+
"number",
|
|
5984
|
+
"boolean"
|
|
5985
|
+
].includes(t));
|
|
5986
|
+
if (primitives.length > 0 && primitives.length === allowed.length) {
|
|
5987
|
+
if (!primitives.includes(typeOf(value))) return void issues.push({
|
|
5320
5988
|
path,
|
|
5321
|
-
message: `expected ${
|
|
5989
|
+
message: `expected ${primitives.join(" or ")}, got ${typeOf(value)}`
|
|
5322
5990
|
});
|
|
5323
5991
|
}
|
|
5324
|
-
if ("array"
|
|
5992
|
+
if (allowed.includes("array")) {
|
|
5325
5993
|
if (!Array.isArray(value)) return void issues.push({
|
|
5326
5994
|
path,
|
|
5327
5995
|
message: `expected array, got ${typeOf(value)}`
|
|
@@ -5366,6 +6034,26 @@ const ARG_ALIASES = {
|
|
|
5366
6034
|
surface: [
|
|
5367
6035
|
"view",
|
|
5368
6036
|
"target"
|
|
6037
|
+
],
|
|
6038
|
+
timeout: [
|
|
6039
|
+
"timeoutMs",
|
|
6040
|
+
"timeoutMillis"
|
|
6041
|
+
],
|
|
6042
|
+
limit: [
|
|
6043
|
+
"lines",
|
|
6044
|
+
"count",
|
|
6045
|
+
"max",
|
|
6046
|
+
"maxLines"
|
|
6047
|
+
],
|
|
6048
|
+
tab: [
|
|
6049
|
+
"tabId"
|
|
6050
|
+
],
|
|
6051
|
+
url: [
|
|
6052
|
+
"href",
|
|
6053
|
+
"pageUrl"
|
|
6054
|
+
],
|
|
6055
|
+
browser: [
|
|
6056
|
+
"browserName"
|
|
5369
6057
|
]
|
|
5370
6058
|
};
|
|
5371
6059
|
function normalizeArgAliases(inputSchema, args) {
|