@extension.dev/mcp 4.9.0 → 5.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +172 -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 +1071 -353
- 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/deploy.d.ts +1 -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 = {};
|
|
@@ -35,7 +36,8 @@ var deploy_namespaceObject = {};
|
|
|
35
36
|
__webpack_require__.r(deploy_namespaceObject);
|
|
36
37
|
__webpack_require__.d(deploy_namespaceObject, {
|
|
37
38
|
handler: ()=>deploy_handler,
|
|
38
|
-
schema: ()=>deploy_schema
|
|
39
|
+
schema: ()=>deploy_schema,
|
|
40
|
+
storeMdWarnings: ()=>storeMdWarnings
|
|
39
41
|
});
|
|
40
42
|
var detect_browsers_namespaceObject = {};
|
|
41
43
|
__webpack_require__.r(detect_browsers_namespaceObject);
|
|
@@ -53,6 +55,7 @@ var doctor_namespaceObject = {};
|
|
|
53
55
|
__webpack_require__.r(doctor_namespaceObject);
|
|
54
56
|
__webpack_require__.d(doctor_namespaceObject, {
|
|
55
57
|
handler: ()=>doctor_handler,
|
|
58
|
+
recentErrorLogs: ()=>recentErrorLogs,
|
|
56
59
|
schema: ()=>doctor_schema
|
|
57
60
|
});
|
|
58
61
|
var dom_inspect_namespaceObject = {};
|
|
@@ -130,6 +133,7 @@ __webpack_require__.d(manifest_validate_namespaceObject, {
|
|
|
130
133
|
var open_namespaceObject = {};
|
|
131
134
|
__webpack_require__.r(open_namespaceObject);
|
|
132
135
|
__webpack_require__.d(open_namespaceObject, {
|
|
136
|
+
clampPopupBounds: ()=>clampPopupBounds,
|
|
133
137
|
handler: ()=>open_handler,
|
|
134
138
|
schema: ()=>open_schema
|
|
135
139
|
});
|
|
@@ -199,7 +203,16 @@ __webpack_require__.d(whoami_namespaceObject, {
|
|
|
199
203
|
handler: ()=>whoami_handler,
|
|
200
204
|
schema: ()=>whoami_schema
|
|
201
205
|
});
|
|
202
|
-
var package_namespaceObject = JSON.parse('{"rE":"
|
|
206
|
+
var package_namespaceObject = JSON.parse('{"rE":"5.1.0","El":{"OP":"^4.0.13"}}');
|
|
207
|
+
function scaffoldEnginePin(projectPath) {
|
|
208
|
+
try {
|
|
209
|
+
const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
|
|
210
|
+
const spec = pkg?.devDependencies?.extension ?? pkg?.dependencies?.extension ?? null;
|
|
211
|
+
return "string" == typeof spec ? spec : null;
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
203
216
|
function detectPackageManager(projectPath) {
|
|
204
217
|
const byLockfile = [
|
|
205
218
|
[
|
|
@@ -238,7 +251,7 @@ const create_schema = {
|
|
|
238
251
|
},
|
|
239
252
|
parentDir: {
|
|
240
253
|
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
|
|
254
|
+
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
255
|
},
|
|
243
256
|
template: {
|
|
244
257
|
type: "string",
|
|
@@ -288,7 +301,7 @@ async function create_handler(args) {
|
|
|
288
301
|
}
|
|
289
302
|
});
|
|
290
303
|
const failure = (err, transient)=>JSON.stringify({
|
|
291
|
-
error: transient ? "Template download failed (network/timeout/rate-limit)
|
|
304
|
+
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
305
|
...transient ? {
|
|
293
306
|
cause: err instanceof Error ? err.message : String(err)
|
|
294
307
|
} : {},
|
|
@@ -308,10 +321,23 @@ async function create_handler(args) {
|
|
|
308
321
|
return failure(err2, looksTransient());
|
|
309
322
|
}
|
|
310
323
|
}
|
|
311
|
-
const
|
|
324
|
+
const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
|
|
325
|
+
if (!hasManifest) return JSON.stringify({
|
|
326
|
+
ok: false,
|
|
327
|
+
status: "incomplete",
|
|
328
|
+
projectPath: result.projectPath,
|
|
329
|
+
error: `The scaffold is incomplete: no manifest.json exists under ${result.projectPath} (checked the root and src/). Do not run extension_dev against it.`,
|
|
330
|
+
duration: Date.now() - start,
|
|
331
|
+
log: logTail(),
|
|
332
|
+
hint: "Delete the directory and retry extension_create; a template download interrupted mid-way can leave a partial tree."
|
|
333
|
+
});
|
|
334
|
+
const packageManager = result.packageManager || (result.depsInstalled ? detectPackageManager(result.projectPath) : "npm");
|
|
312
335
|
const runDev = `${packageManager} run dev`;
|
|
336
|
+
const addDev = (spec)=>"npm" === packageManager ? `npm i -D ${spec}` : "yarn" === packageManager ? `yarn add -D ${spec}` : `${packageManager} add -D ${spec}`;
|
|
313
337
|
const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
|
|
314
|
-
const
|
|
338
|
+
const scaffoldPin = scaffoldEnginePin(result.projectPath);
|
|
339
|
+
const pinMatches = null !== scaffoldPin && "" !== pin && scaffoldPin.includes(pin);
|
|
340
|
+
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
341
|
return JSON.stringify({
|
|
316
342
|
projectPath: result.projectPath,
|
|
317
343
|
projectName: result.projectName,
|
|
@@ -324,8 +350,8 @@ async function create_handler(args) {
|
|
|
324
350
|
runDev
|
|
325
351
|
] : [
|
|
326
352
|
`cd ${result.projectPath}`,
|
|
327
|
-
|
|
328
|
-
|
|
353
|
+
`${packageManager} install`,
|
|
354
|
+
runDev
|
|
329
355
|
],
|
|
330
356
|
...engineWarning ? {
|
|
331
357
|
engineWarning
|
|
@@ -547,6 +573,9 @@ function runExtensionCli(args, options) {
|
|
|
547
573
|
}
|
|
548
574
|
function spawnExtensionCli(args, options) {
|
|
549
575
|
const { command, prefixArgs } = resolveExtensionInvocation(options?.projectDir ?? options?.cwd);
|
|
576
|
+
const logDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "extension-mcp-"));
|
|
577
|
+
const logPath = node_path.join(logDir, "session.log");
|
|
578
|
+
const fd = node_fs.openSync(logPath, "a");
|
|
550
579
|
const child = cross_spawn(command, [
|
|
551
580
|
...prefixArgs,
|
|
552
581
|
...args
|
|
@@ -555,8 +584,8 @@ function spawnExtensionCli(args, options) {
|
|
|
555
584
|
detached: true,
|
|
556
585
|
stdio: [
|
|
557
586
|
"ignore",
|
|
558
|
-
|
|
559
|
-
|
|
587
|
+
fd,
|
|
588
|
+
fd
|
|
560
589
|
],
|
|
561
590
|
env: {
|
|
562
591
|
...process.env,
|
|
@@ -564,8 +593,29 @@ function spawnExtensionCli(args, options) {
|
|
|
564
593
|
NO_COLOR: "1"
|
|
565
594
|
}
|
|
566
595
|
});
|
|
596
|
+
node_fs.closeSync(fd);
|
|
567
597
|
child.unref();
|
|
568
|
-
return
|
|
598
|
+
return {
|
|
599
|
+
child,
|
|
600
|
+
logPath,
|
|
601
|
+
readOutput: ()=>{
|
|
602
|
+
try {
|
|
603
|
+
return node_fs.readFileSync(logPath, "utf8");
|
|
604
|
+
} catch {
|
|
605
|
+
return "";
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function readBuildSummary(projectPath, browser, since) {
|
|
611
|
+
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
|
|
612
|
+
try {
|
|
613
|
+
const stat = node_fs.statSync(file);
|
|
614
|
+
if (stat.mtimeMs < since) return null;
|
|
615
|
+
const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
616
|
+
if (summary && "object" == typeof summary) return summary;
|
|
617
|
+
} catch {}
|
|
618
|
+
return null;
|
|
569
619
|
}
|
|
570
620
|
function builtEntrypoints(distDir) {
|
|
571
621
|
let manifest;
|
|
@@ -585,14 +635,31 @@ function builtEntrypoints(distDir) {
|
|
|
585
635
|
};
|
|
586
636
|
const bg = manifest.background;
|
|
587
637
|
if (bg?.service_worker) add("background.service_worker", bg.service_worker);
|
|
638
|
+
if (bg?.page) add("background.page", bg.page);
|
|
588
639
|
if (Array.isArray(bg?.scripts)) bg.scripts.forEach((s)=>add("background.scripts", s));
|
|
589
640
|
const action = manifest.action || manifest.browser_action;
|
|
590
641
|
if (action?.default_popup) add("action.default_popup", action.default_popup);
|
|
642
|
+
const pageAction = manifest.page_action;
|
|
643
|
+
if (pageAction?.default_popup) add("page_action.default_popup", pageAction.default_popup);
|
|
591
644
|
const cs = manifest.content_scripts;
|
|
592
645
|
if (Array.isArray(cs)) cs.forEach((c, i)=>{
|
|
593
646
|
if (Array.isArray(c.js)) c.js.forEach((j)=>add(`content_scripts[${i}].js`, j));
|
|
594
647
|
if (Array.isArray(c.css)) c.css.forEach((s)=>add(`content_scripts[${i}].css`, s));
|
|
595
648
|
});
|
|
649
|
+
add("devtools_page", manifest.devtools_page);
|
|
650
|
+
add("options_page", manifest.options_page);
|
|
651
|
+
const optionsUi = manifest.options_ui;
|
|
652
|
+
if (optionsUi?.page) add("options_ui.page", optionsUi.page);
|
|
653
|
+
const sidePanel = manifest.side_panel;
|
|
654
|
+
if (sidePanel?.default_path) add("side_panel.default_path", sidePanel.default_path);
|
|
655
|
+
const sidebarAction = manifest.sidebar_action;
|
|
656
|
+
if (sidebarAction?.default_panel) add("sidebar_action.default_panel", sidebarAction.default_panel);
|
|
657
|
+
const overrides = manifest.chrome_url_overrides;
|
|
658
|
+
if (overrides) for (const [key, ref] of Object.entries(overrides))add(`chrome_url_overrides.${key}`, ref);
|
|
659
|
+
const dnr = manifest.declarative_net_request;
|
|
660
|
+
if (dnr && Array.isArray(dnr.rule_resources)) dnr.rule_resources.forEach((r, i)=>{
|
|
661
|
+
if (r && "object" == typeof r) add(`declarative_net_request[${i}].path`, r.path);
|
|
662
|
+
});
|
|
596
663
|
return out;
|
|
597
664
|
}
|
|
598
665
|
const build_schema = {
|
|
@@ -660,6 +727,11 @@ const build_schema = {
|
|
|
660
727
|
],
|
|
661
728
|
default: "production",
|
|
662
729
|
description: "Bundler mode override (also sets NODE_ENV)"
|
|
730
|
+
},
|
|
731
|
+
skipValidation: {
|
|
732
|
+
type: "boolean",
|
|
733
|
+
default: false,
|
|
734
|
+
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
735
|
}
|
|
664
736
|
},
|
|
665
737
|
required: [
|
|
@@ -667,9 +739,67 @@ const build_schema = {
|
|
|
667
739
|
]
|
|
668
740
|
}
|
|
669
741
|
};
|
|
742
|
+
function manifestDivergence(projectPath, browser) {
|
|
743
|
+
const read = (p)=>{
|
|
744
|
+
try {
|
|
745
|
+
return JSON.parse(node_fs.readFileSync(p, "utf8"));
|
|
746
|
+
} catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
const built = read(node_path.resolve(projectPath, "dist", browser, "manifest.json"));
|
|
751
|
+
const source = read(node_path.resolve(projectPath, "src", "manifest.json")) ?? read(node_path.resolve(projectPath, "manifest.json"));
|
|
752
|
+
if (!built || !source) return [];
|
|
753
|
+
const notes = [];
|
|
754
|
+
const listOf = (m, key)=>Array.isArray(m[key]) ? m[key].filter((x)=>"string" == typeof x) : [];
|
|
755
|
+
for (const key of [
|
|
756
|
+
"permissions",
|
|
757
|
+
"host_permissions",
|
|
758
|
+
"optional_permissions"
|
|
759
|
+
]){
|
|
760
|
+
const lost = listOf(source, key).filter((p)=>!listOf(built, key).includes(p));
|
|
761
|
+
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.`);
|
|
762
|
+
}
|
|
763
|
+
const sourceWar = source.web_accessible_resources;
|
|
764
|
+
const builtWar = built.web_accessible_resources;
|
|
765
|
+
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.");
|
|
766
|
+
return notes;
|
|
767
|
+
}
|
|
768
|
+
async function validationPreflight(projectPath, browser) {
|
|
769
|
+
try {
|
|
770
|
+
const manifestValidate = await Promise.resolve().then(()=>({
|
|
771
|
+
handler: manifest_validate_handler
|
|
772
|
+
}));
|
|
773
|
+
const parsed = JSON.parse(await manifestValidate.handler({
|
|
774
|
+
projectPath,
|
|
775
|
+
browsers: [
|
|
776
|
+
browser
|
|
777
|
+
]
|
|
778
|
+
}));
|
|
779
|
+
return {
|
|
780
|
+
valid: Boolean(parsed.valid),
|
|
781
|
+
buildBlocking: Boolean(parsed.buildBlocking),
|
|
782
|
+
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
|
783
|
+
warnings: Array.isArray(parsed.warnings) ? parsed.warnings : []
|
|
784
|
+
};
|
|
785
|
+
} catch {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
670
789
|
async function build_handler(args) {
|
|
671
790
|
const start = Date.now();
|
|
672
791
|
const browser = args.browser ?? "chrome";
|
|
792
|
+
const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
|
|
793
|
+
if (preflight?.buildBlocking) return JSON.stringify({
|
|
794
|
+
success: false,
|
|
795
|
+
status: "blocked",
|
|
796
|
+
browser,
|
|
797
|
+
error: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds.",
|
|
798
|
+
errors: preflight.errors,
|
|
799
|
+
warnings: preflight.warnings,
|
|
800
|
+
duration: Date.now() - start,
|
|
801
|
+
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."
|
|
802
|
+
});
|
|
673
803
|
const cliArgs = [
|
|
674
804
|
"build",
|
|
675
805
|
args.projectPath,
|
|
@@ -692,7 +822,30 @@ async function build_handler(args) {
|
|
|
692
822
|
if (0 === code) {
|
|
693
823
|
const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
|
|
694
824
|
const status = out.match(/Build Status:\s*(\w+)/)?.[1];
|
|
825
|
+
const engineSummary = readBuildSummary(args.projectPath, browser, start);
|
|
826
|
+
const buildWarnings = engineSummary?.warnings?.length ? {
|
|
827
|
+
buildWarnings: engineSummary.warnings,
|
|
828
|
+
..."number" == typeof engineSummary.warnings_count && engineSummary.warnings_count > engineSummary.warnings.length ? {
|
|
829
|
+
buildWarningsTruncated: engineSummary.warnings_count
|
|
830
|
+
} : {}
|
|
831
|
+
} : {};
|
|
695
832
|
const entrypoints = builtEntrypoints(node_path.resolve(args.projectPath, "dist", browser));
|
|
833
|
+
const missing = entrypoints.filter((e)=>!e.present);
|
|
834
|
+
if (missing.length) return JSON.stringify({
|
|
835
|
+
success: false,
|
|
836
|
+
status: "incomplete",
|
|
837
|
+
browser,
|
|
838
|
+
buildExitCode: 0,
|
|
839
|
+
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.",
|
|
840
|
+
entrypoints,
|
|
841
|
+
...preflight?.warnings.length ? {
|
|
842
|
+
manifestWarnings: preflight.warnings
|
|
843
|
+
} : {},
|
|
844
|
+
...buildWarnings,
|
|
845
|
+
duration,
|
|
846
|
+
output: lastLines(out, 12),
|
|
847
|
+
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."
|
|
848
|
+
});
|
|
696
849
|
return JSON.stringify({
|
|
697
850
|
success: true,
|
|
698
851
|
browser,
|
|
@@ -705,6 +858,16 @@ async function build_handler(args) {
|
|
|
705
858
|
...entrypoints.length ? {
|
|
706
859
|
entrypoints
|
|
707
860
|
} : {},
|
|
861
|
+
...preflight?.warnings.length ? {
|
|
862
|
+
manifestWarnings: preflight.warnings
|
|
863
|
+
} : {},
|
|
864
|
+
...buildWarnings,
|
|
865
|
+
...(()=>{
|
|
866
|
+
const divergence = manifestDivergence(args.projectPath, browser);
|
|
867
|
+
return divergence.length ? {
|
|
868
|
+
productionDivergence: divergence
|
|
869
|
+
} : {};
|
|
870
|
+
})(),
|
|
708
871
|
zip: args.zip ?? false,
|
|
709
872
|
duration,
|
|
710
873
|
output: lastLines(out, 12)
|
|
@@ -833,7 +996,7 @@ const dev_schema = {
|
|
|
833
996
|
allowEval: {
|
|
834
997
|
type: "boolean",
|
|
835
998
|
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
|
|
999
|
+
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
1000
|
}
|
|
838
1001
|
},
|
|
839
1002
|
required: [
|
|
@@ -856,9 +1019,10 @@ async function dev_handler(args) {
|
|
|
856
1019
|
cliArgs.push(...launchFlagArgs(args));
|
|
857
1020
|
if (allowControl) cliArgs.push("--allow-control");
|
|
858
1021
|
if (args.allowEval) cliArgs.push("--allow-eval");
|
|
859
|
-
const
|
|
1022
|
+
const spawned = spawnExtensionCli(cliArgs, {
|
|
860
1023
|
projectDir: args.projectPath
|
|
861
1024
|
});
|
|
1025
|
+
const { child, logPath } = spawned;
|
|
862
1026
|
const pid = child.pid;
|
|
863
1027
|
registerSession({
|
|
864
1028
|
pid,
|
|
@@ -868,15 +1032,37 @@ async function dev_handler(args) {
|
|
|
868
1032
|
command: "dev"
|
|
869
1033
|
});
|
|
870
1034
|
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
1035
|
await new Promise((resolve)=>setTimeout(resolve, 3000));
|
|
878
|
-
|
|
879
|
-
child.
|
|
1036
|
+
const earlyOutput = spawned.readOutput();
|
|
1037
|
+
if (null !== child.exitCode || null !== child.signalCode) {
|
|
1038
|
+
const code = child.exitCode;
|
|
1039
|
+
const signal = child.signalCode;
|
|
1040
|
+
return JSON.stringify({
|
|
1041
|
+
ok: false,
|
|
1042
|
+
status: "exited",
|
|
1043
|
+
projectPath: args.projectPath,
|
|
1044
|
+
browser,
|
|
1045
|
+
pid,
|
|
1046
|
+
exitCode: code,
|
|
1047
|
+
signal,
|
|
1048
|
+
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.`,
|
|
1049
|
+
output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
|
|
1050
|
+
logPath,
|
|
1051
|
+
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."
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(earlyOutput);
|
|
1055
|
+
if (compileFailed) return JSON.stringify({
|
|
1056
|
+
ok: false,
|
|
1057
|
+
status: "compile-failed",
|
|
1058
|
+
projectPath: args.projectPath,
|
|
1059
|
+
browser,
|
|
1060
|
+
pid,
|
|
1061
|
+
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.",
|
|
1062
|
+
output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
|
|
1063
|
+
logPath,
|
|
1064
|
+
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."
|
|
1065
|
+
});
|
|
880
1066
|
const controlVerbs = "storage, reload, open, dom_inspect";
|
|
881
1067
|
const capabilities = {
|
|
882
1068
|
allowControl,
|
|
@@ -884,6 +1070,7 @@ async function dev_handler(args) {
|
|
|
884
1070
|
unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
|
|
885
1071
|
};
|
|
886
1072
|
return JSON.stringify({
|
|
1073
|
+
ok: true,
|
|
887
1074
|
pid,
|
|
888
1075
|
browser,
|
|
889
1076
|
port: args.port ?? 8080,
|
|
@@ -891,7 +1078,8 @@ async function dev_handler(args) {
|
|
|
891
1078
|
status: "started",
|
|
892
1079
|
capabilities,
|
|
893
1080
|
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)
|
|
1081
|
+
earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
|
|
1082
|
+
logPath
|
|
895
1083
|
});
|
|
896
1084
|
}
|
|
897
1085
|
function denoiseEarlyOutput(raw) {
|
|
@@ -905,6 +1093,93 @@ function denoiseEarlyOutput(raw) {
|
|
|
905
1093
|
];
|
|
906
1094
|
return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
|
|
907
1095
|
}
|
|
1096
|
+
function contractSightings(projectPath) {
|
|
1097
|
+
const root = node_path.resolve(projectPath, "dist", "extension-js");
|
|
1098
|
+
let dirs;
|
|
1099
|
+
try {
|
|
1100
|
+
dirs = node_fs.readdirSync(root);
|
|
1101
|
+
} catch {
|
|
1102
|
+
return [];
|
|
1103
|
+
}
|
|
1104
|
+
const sightings = [];
|
|
1105
|
+
for (const dir of dirs){
|
|
1106
|
+
const readyPath = node_path.join(root, dir, "ready.json");
|
|
1107
|
+
try {
|
|
1108
|
+
const stat = node_fs.statSync(readyPath);
|
|
1109
|
+
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
1110
|
+
if (contract?.status !== "ready") continue;
|
|
1111
|
+
sightings.push({
|
|
1112
|
+
browser: dir,
|
|
1113
|
+
mtimeMs: stat.mtimeMs,
|
|
1114
|
+
pid: "number" == typeof contract.pid ? contract.pid : void 0
|
|
1115
|
+
});
|
|
1116
|
+
} catch {}
|
|
1117
|
+
}
|
|
1118
|
+
return sightings;
|
|
1119
|
+
}
|
|
1120
|
+
function pidAlive(pid) {
|
|
1121
|
+
try {
|
|
1122
|
+
process.kill(pid, 0);
|
|
1123
|
+
return true;
|
|
1124
|
+
} catch {
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
function knownSessionBrowsers(projectPath) {
|
|
1129
|
+
const resolved = node_path.resolve(projectPath);
|
|
1130
|
+
const browsers = [];
|
|
1131
|
+
for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
|
|
1132
|
+
for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
|
|
1133
|
+
return Array.from(new Set(browsers));
|
|
1134
|
+
}
|
|
1135
|
+
function deadReadySession(projectPath) {
|
|
1136
|
+
for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
|
|
1137
|
+
browser: sighting.browser,
|
|
1138
|
+
pid: sighting.pid
|
|
1139
|
+
};
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
function browserExitStamp(projectPath, browser, since) {
|
|
1143
|
+
const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
1144
|
+
try {
|
|
1145
|
+
const stat = node_fs.statSync(readyPath);
|
|
1146
|
+
if (stat.mtimeMs < since) return null;
|
|
1147
|
+
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
1148
|
+
const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
|
|
1149
|
+
if (contract?.status === "error" && exited) return {
|
|
1150
|
+
code: contract.code,
|
|
1151
|
+
browserExitCode: contract.browserExitCode ?? null,
|
|
1152
|
+
browserExitedAt: contract.browserExitedAt
|
|
1153
|
+
};
|
|
1154
|
+
} catch {}
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
|
|
1158
|
+
if (explicit) return {
|
|
1159
|
+
browser: explicit,
|
|
1160
|
+
source: "explicit"
|
|
1161
|
+
};
|
|
1162
|
+
const resolved = node_path.resolve(projectPath);
|
|
1163
|
+
const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
|
|
1164
|
+
if (mine.length > 0) return {
|
|
1165
|
+
browser: mine[mine.length - 1].browser,
|
|
1166
|
+
source: "session"
|
|
1167
|
+
};
|
|
1168
|
+
const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
|
|
1169
|
+
const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
|
|
1170
|
+
if (live.length > 0) return {
|
|
1171
|
+
browser: live[0].browser,
|
|
1172
|
+
source: "contract"
|
|
1173
|
+
};
|
|
1174
|
+
if (sightings.length > 0) return {
|
|
1175
|
+
browser: sightings[0].browser,
|
|
1176
|
+
source: "stale"
|
|
1177
|
+
};
|
|
1178
|
+
return {
|
|
1179
|
+
browser: fallback,
|
|
1180
|
+
source: "fallback"
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
908
1183
|
const start_schema = {
|
|
909
1184
|
name: "extension_start",
|
|
910
1185
|
description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
|
|
@@ -969,9 +1244,11 @@ async function start_handler(args) {
|
|
|
969
1244
|
if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
|
|
970
1245
|
if (args.noBrowser) cliArgs.push("--no-browser");
|
|
971
1246
|
cliArgs.push(...launchFlagArgs(args));
|
|
972
|
-
const
|
|
1247
|
+
const spawnedAt = Date.now();
|
|
1248
|
+
const spawned = spawnExtensionCli(cliArgs, {
|
|
973
1249
|
projectDir: args.projectPath
|
|
974
1250
|
});
|
|
1251
|
+
const { child, logPath } = spawned;
|
|
975
1252
|
const pid = child.pid;
|
|
976
1253
|
registerSession({
|
|
977
1254
|
pid,
|
|
@@ -980,22 +1257,47 @@ async function start_handler(args) {
|
|
|
980
1257
|
command: "start"
|
|
981
1258
|
});
|
|
982
1259
|
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
1260
|
await new Promise((resolve)=>setTimeout(resolve, 5000));
|
|
990
|
-
|
|
991
|
-
child.
|
|
1261
|
+
const earlyOutput = spawned.readOutput();
|
|
1262
|
+
if (null !== child.exitCode || null !== child.signalCode) {
|
|
1263
|
+
const code = child.exitCode;
|
|
1264
|
+
const signal = child.signalCode;
|
|
1265
|
+
return JSON.stringify({
|
|
1266
|
+
ok: false,
|
|
1267
|
+
status: "exited",
|
|
1268
|
+
projectPath: args.projectPath,
|
|
1269
|
+
browser,
|
|
1270
|
+
pid,
|
|
1271
|
+
exitCode: code,
|
|
1272
|
+
signal,
|
|
1273
|
+
error: `The preview server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`,
|
|
1274
|
+
output: earlyOutput.slice(0, 2000),
|
|
1275
|
+
logPath,
|
|
1276
|
+
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."
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
|
|
1280
|
+
if (exitStamp) return JSON.stringify({
|
|
1281
|
+
ok: false,
|
|
1282
|
+
status: "browser-exited",
|
|
1283
|
+
projectPath: args.projectPath,
|
|
1284
|
+
browser,
|
|
1285
|
+
pid,
|
|
1286
|
+
...exitStamp,
|
|
1287
|
+
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.",
|
|
1288
|
+
output: earlyOutput.slice(0, 2000),
|
|
1289
|
+
logPath,
|
|
1290
|
+
hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
|
|
1291
|
+
});
|
|
992
1292
|
return JSON.stringify({
|
|
1293
|
+
ok: true,
|
|
993
1294
|
pid,
|
|
994
1295
|
browser,
|
|
995
1296
|
projectPath: args.projectPath,
|
|
996
1297
|
status: "started",
|
|
997
1298
|
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)
|
|
1299
|
+
earlyOutput: earlyOutput.slice(0, 500),
|
|
1300
|
+
logPath
|
|
999
1301
|
});
|
|
1000
1302
|
}
|
|
1001
1303
|
const preview_schema = {
|
|
@@ -1056,9 +1358,11 @@ async function preview_handler(args) {
|
|
|
1056
1358
|
if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
|
|
1057
1359
|
if (args.noBrowser) cliArgs.push("--no-browser");
|
|
1058
1360
|
cliArgs.push(...launchFlagArgs(args));
|
|
1059
|
-
const
|
|
1361
|
+
const spawnedAt = Date.now();
|
|
1362
|
+
const spawned = spawnExtensionCli(cliArgs, {
|
|
1060
1363
|
projectDir: args.projectPath
|
|
1061
1364
|
});
|
|
1365
|
+
const { child, logPath } = spawned;
|
|
1062
1366
|
const pid = child.pid;
|
|
1063
1367
|
registerSession({
|
|
1064
1368
|
pid,
|
|
@@ -1067,81 +1371,49 @@ async function preview_handler(args) {
|
|
|
1067
1371
|
command: "preview"
|
|
1068
1372
|
});
|
|
1069
1373
|
child.on("exit", ()=>removeSession(args.projectPath, browser));
|
|
1374
|
+
await new Promise((resolve)=>setTimeout(resolve, 5000));
|
|
1375
|
+
const earlyOutput = spawned.readOutput();
|
|
1376
|
+
if (null !== child.exitCode || null !== child.signalCode) {
|
|
1377
|
+
const code = child.exitCode;
|
|
1378
|
+
const signal = child.signalCode;
|
|
1379
|
+
return JSON.stringify({
|
|
1380
|
+
ok: false,
|
|
1381
|
+
status: "exited",
|
|
1382
|
+
projectPath: args.projectPath,
|
|
1383
|
+
browser,
|
|
1384
|
+
pid,
|
|
1385
|
+
exitCode: code,
|
|
1386
|
+
signal,
|
|
1387
|
+
error: `The preview process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). Nothing is running.`,
|
|
1388
|
+
output: earlyOutput.slice(0, 2000),
|
|
1389
|
+
logPath,
|
|
1390
|
+
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."
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
|
|
1394
|
+
if (exitStamp) return JSON.stringify({
|
|
1395
|
+
ok: false,
|
|
1396
|
+
status: "browser-exited",
|
|
1397
|
+
projectPath: args.projectPath,
|
|
1398
|
+
browser,
|
|
1399
|
+
pid,
|
|
1400
|
+
...exitStamp,
|
|
1401
|
+
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.",
|
|
1402
|
+
output: earlyOutput.slice(0, 2000),
|
|
1403
|
+
logPath,
|
|
1404
|
+
hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
|
|
1405
|
+
});
|
|
1070
1406
|
return JSON.stringify({
|
|
1407
|
+
ok: true,
|
|
1071
1408
|
pid,
|
|
1072
1409
|
browser,
|
|
1073
1410
|
projectPath: args.projectPath,
|
|
1074
1411
|
status: "launched",
|
|
1075
|
-
hint: "Call extension_stop when you are done to close the preview browser."
|
|
1412
|
+
hint: "Call extension_stop when you are done to close the preview browser.",
|
|
1413
|
+
earlyOutput: earlyOutput.slice(0, 500),
|
|
1414
|
+
logPath
|
|
1076
1415
|
});
|
|
1077
1416
|
}
|
|
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
1417
|
const stop_schema = {
|
|
1146
1418
|
name: "extension_stop",
|
|
1147
1419
|
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 +1476,7 @@ function isAlive(pid) {
|
|
|
1204
1476
|
return false;
|
|
1205
1477
|
}
|
|
1206
1478
|
}
|
|
1207
|
-
function
|
|
1479
|
+
function stop_signal(pid, sig) {
|
|
1208
1480
|
try {
|
|
1209
1481
|
process.kill(-pid, sig);
|
|
1210
1482
|
return true;
|
|
@@ -1245,10 +1517,10 @@ async function stopOne(projectPath, browser) {
|
|
|
1245
1517
|
}
|
|
1246
1518
|
let detail;
|
|
1247
1519
|
if (isAlive(pid)) {
|
|
1248
|
-
|
|
1520
|
+
stop_signal(pid, "SIGTERM");
|
|
1249
1521
|
await new Promise((resolve)=>setTimeout(resolve, 1500));
|
|
1250
1522
|
if (isAlive(pid)) {
|
|
1251
|
-
|
|
1523
|
+
stop_signal(pid, "SIGKILL");
|
|
1252
1524
|
await new Promise((resolve)=>setTimeout(resolve, 250));
|
|
1253
1525
|
}
|
|
1254
1526
|
detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
|
|
@@ -1491,7 +1763,8 @@ const manifest_validate_schema = {
|
|
|
1491
1763
|
},
|
|
1492
1764
|
default: [
|
|
1493
1765
|
"chrome",
|
|
1494
|
-
"firefox"
|
|
1766
|
+
"firefox",
|
|
1767
|
+
"edge"
|
|
1495
1768
|
],
|
|
1496
1769
|
description: "Browsers to validate against"
|
|
1497
1770
|
},
|
|
@@ -1535,6 +1808,19 @@ function collectPathRefs(m) {
|
|
|
1535
1808
|
if (sa) push(sa.default_panel);
|
|
1536
1809
|
const cuo = m.chrome_url_overrides;
|
|
1537
1810
|
if (cuo) Object.values(cuo).forEach(push);
|
|
1811
|
+
const dnr = m.declarative_net_request;
|
|
1812
|
+
if (dnr && Array.isArray(dnr.rule_resources)) {
|
|
1813
|
+
for (const r of dnr.rule_resources)if (r && "object" == typeof r) push(r.path);
|
|
1814
|
+
}
|
|
1815
|
+
const storage = m.storage;
|
|
1816
|
+
if (storage) push(storage.managed_schema);
|
|
1817
|
+
push(m.devtools_page);
|
|
1818
|
+
const pa = m.page_action;
|
|
1819
|
+
if (pa) {
|
|
1820
|
+
push(pa.default_popup);
|
|
1821
|
+
if ("string" == typeof pa.default_icon) push(pa.default_icon);
|
|
1822
|
+
else if (pa.default_icon) Object.values(pa.default_icon).forEach(push);
|
|
1823
|
+
}
|
|
1538
1824
|
return refs;
|
|
1539
1825
|
}
|
|
1540
1826
|
function fileResolvesSomewhere(ref, roots) {
|
|
@@ -1642,9 +1928,11 @@ async function manifest_validate_handler(args) {
|
|
|
1642
1928
|
args.browser
|
|
1643
1929
|
]
|
|
1644
1930
|
};
|
|
1931
|
+
const explicitBrowsers = Array.isArray(args.browsers) && args.browsers.length > 0;
|
|
1645
1932
|
const browsers = args.browsers ?? [
|
|
1646
1933
|
"chrome",
|
|
1647
|
-
"firefox"
|
|
1934
|
+
"firefox",
|
|
1935
|
+
"edge"
|
|
1648
1936
|
];
|
|
1649
1937
|
const result = {
|
|
1650
1938
|
valid: true,
|
|
@@ -1689,30 +1977,46 @@ async function manifest_validate_handler(args) {
|
|
|
1689
1977
|
node_path.dirname(manifestDir)
|
|
1690
1978
|
] : []
|
|
1691
1979
|
];
|
|
1692
|
-
for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.
|
|
1693
|
-
const
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1980
|
+
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.`);
|
|
1981
|
+
const defaultLocale = manifest.default_locale;
|
|
1982
|
+
if ("string" == typeof defaultLocale && defaultLocale) {
|
|
1983
|
+
const hasCatalog = roots.some((root)=>node_fs.existsSync(node_path.resolve(root, "_locales", defaultLocale, "messages.json")));
|
|
1984
|
+
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.`);
|
|
1985
|
+
}
|
|
1986
|
+
const iconMap = chromiumManifest.icons;
|
|
1987
|
+
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.');
|
|
1988
|
+
const effectiveByBrowser = new Map();
|
|
1989
|
+
for (const b of browsers)effectiveByBrowser.set(b, filterKeysForThisBrowser(manifest, b));
|
|
1990
|
+
const declaredPermSet = new Set();
|
|
1991
|
+
for (const view of [
|
|
1992
|
+
chromiumManifest,
|
|
1993
|
+
...effectiveByBrowser.values()
|
|
1994
|
+
])for (const p of [
|
|
1995
|
+
...view.permissions ?? [],
|
|
1996
|
+
...view.optional_permissions ?? []
|
|
1997
|
+
])if ("string" == typeof p) declaredPermSet.add(p);
|
|
1998
|
+
const usedApis = scanApiUsage(roots);
|
|
1999
|
+
for (const api of usedApis){
|
|
1698
2000
|
const perm = API_PERMISSION[api];
|
|
1699
2001
|
if (declaredPermSet.has(perm)) continue;
|
|
1700
2002
|
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
|
|
2003
|
+
if (HARD_APIS.has(api)) result.errors.push(`${base}, chrome.${api} is undefined without it and will crash the context at runtime.`);
|
|
2004
|
+
else result.warnings.push(`${base}; it may be undefined at runtime, add "${perm}" if you use it.`);
|
|
1703
2005
|
}
|
|
1704
|
-
if (
|
|
2006
|
+
if (chromiumManifest.manifest_version) {
|
|
2007
|
+
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.`);
|
|
2008
|
+
} else result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
|
|
1705
2009
|
const declaredPerms = [
|
|
1706
2010
|
...chromiumManifest.permissions ?? [],
|
|
1707
2011
|
...chromiumManifest.optional_permissions ?? []
|
|
1708
2012
|
].filter((p)=>"string" == typeof p);
|
|
1709
2013
|
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}"
|
|
2014
|
+
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
2015
|
}
|
|
1712
2016
|
for (const browser of browsers){
|
|
1713
2017
|
const isChromium = isChromiumFamily(browser);
|
|
1714
2018
|
const isFirefox = isGeckoFamily(browser);
|
|
1715
|
-
const effective = filterKeysForThisBrowser(manifest, browser);
|
|
2019
|
+
const effective = effectiveByBrowser.get(browser) ?? filterKeysForThisBrowser(manifest, browser);
|
|
1716
2020
|
const issues = [];
|
|
1717
2021
|
if (isChromium) {
|
|
1718
2022
|
const mv = effective.manifest_version;
|
|
@@ -1734,6 +2038,17 @@ async function manifest_validate_handler(args) {
|
|
|
1734
2038
|
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
2039
|
}
|
|
1736
2040
|
}
|
|
2041
|
+
const effectivePerms = new Set([
|
|
2042
|
+
...effective.permissions ?? [],
|
|
2043
|
+
...effective.optional_permissions ?? []
|
|
2044
|
+
].filter((p)=>"string" == typeof p));
|
|
2045
|
+
for (const api of usedApis){
|
|
2046
|
+
const perm = API_PERMISSION[api];
|
|
2047
|
+
if (effectivePerms.has(perm)) continue;
|
|
2048
|
+
if (!declaredPermSet.has(perm)) continue;
|
|
2049
|
+
const ns = isFirefox ? "browser" : "chrome";
|
|
2050
|
+
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.`);
|
|
2051
|
+
}
|
|
1737
2052
|
result.browserSupport[browser] = {
|
|
1738
2053
|
supported: 0 === issues.length,
|
|
1739
2054
|
issues
|
|
@@ -1759,7 +2074,13 @@ async function manifest_validate_handler(args) {
|
|
|
1759
2074
|
surfaces: t.surfaces
|
|
1760
2075
|
}));
|
|
1761
2076
|
} catch {}
|
|
1762
|
-
|
|
2077
|
+
for (const [browser, support] of Object.entries(result.browserSupport)){
|
|
2078
|
+
if (support.supported) continue;
|
|
2079
|
+
const issues = support.issues?.length ? support.issues.join("; ") : `${browser} is not supported by this manifest.`;
|
|
2080
|
+
if (explicitBrowsers) result.errors.push(`${browser}: ${issues}`);
|
|
2081
|
+
else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
|
|
2082
|
+
}
|
|
2083
|
+
result.valid = 0 === result.errors.length;
|
|
1763
2084
|
return JSON.stringify({
|
|
1764
2085
|
...result,
|
|
1765
2086
|
buildBlocking: result.errors.length > 0
|
|
@@ -1912,8 +2233,8 @@ async function inspect_handler(args) {
|
|
|
1912
2233
|
const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
|
|
1913
2234
|
const sizeWarnings = [];
|
|
1914
2235
|
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
|
|
2236
|
+
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.`);
|
|
2237
|
+
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
2238
|
}
|
|
1918
2239
|
const result = {
|
|
1919
2240
|
browser,
|
|
@@ -1960,6 +2281,7 @@ async function inspect_handler(args) {
|
|
|
1960
2281
|
storeReadiness: {
|
|
1961
2282
|
hasManifest: node_fs.existsSync(manifestPath),
|
|
1962
2283
|
hasIcons: files.some((f)=>"image" === f.type && f.path.includes("icon")),
|
|
2284
|
+
has128Icon: "string" == typeof manifest.icons?.["128"],
|
|
1963
2285
|
noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
|
|
1964
2286
|
noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
|
|
1965
2287
|
under10MB: totalSize < 10485760
|
|
@@ -1981,7 +2303,7 @@ const COMMAND_TIMEOUT_MS = 15000;
|
|
|
1981
2303
|
class CDPConnection {
|
|
1982
2304
|
async connect(wsUrl) {
|
|
1983
2305
|
return new Promise((resolve, reject)=>{
|
|
1984
|
-
this.ws = new
|
|
2306
|
+
this.ws = new ws_0(wsUrl);
|
|
1985
2307
|
this.ws.on("open", ()=>resolve());
|
|
1986
2308
|
this.ws.on("message", (data)=>{
|
|
1987
2309
|
this.handleMessage(data.toString());
|
|
@@ -2050,7 +2372,7 @@ class CDPConnection {
|
|
|
2050
2372
|
}
|
|
2051
2373
|
async sendCommand(method, params = {}, sessionId) {
|
|
2052
2374
|
return new Promise((resolve, reject)=>{
|
|
2053
|
-
if (!this.ws || this.ws.readyState !==
|
|
2375
|
+
if (!this.ws || this.ws.readyState !== ws_0.OPEN) return reject(new Error("CDP WebSocket is not connected"));
|
|
2054
2376
|
const id = ++this.messageId;
|
|
2055
2377
|
const message = {
|
|
2056
2378
|
id,
|
|
@@ -2441,7 +2763,7 @@ const source_inspect_schema = {
|
|
|
2441
2763
|
items: {
|
|
2442
2764
|
type: "string"
|
|
2443
2765
|
},
|
|
2444
|
-
description: "CSS selectors to query
|
|
2766
|
+
description: "CSS selectors to query, returns element counts and samples for each"
|
|
2445
2767
|
},
|
|
2446
2768
|
include: {
|
|
2447
2769
|
type: "array",
|
|
@@ -2493,7 +2815,7 @@ async function source_inspect_handler(args) {
|
|
|
2493
2815
|
const maxBytes = args.maxBytes ?? 262144;
|
|
2494
2816
|
if (!isChromiumFamily(browser)) return JSON.stringify({
|
|
2495
2817
|
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 (
|
|
2818
|
+
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
2819
|
});
|
|
2498
2820
|
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
2499
2821
|
if (!resolved) return JSON.stringify({
|
|
@@ -2575,7 +2897,11 @@ async function source_inspect_handler(args) {
|
|
|
2575
2897
|
if (include.has("dom_snapshot")) result.domSnapshot = await cdp.getDomSnapshot(sessionId);
|
|
2576
2898
|
if (include.has("console")) result.console = cdp.getConsoleSummary();
|
|
2577
2899
|
if (include.has("extension_roots")) result.extensionRoots = await cdp.getExtensionRootMeta(sessionId);
|
|
2578
|
-
if (args.probe?.length)
|
|
2900
|
+
if (args.probe?.length) {
|
|
2901
|
+
result.probes = await cdp.probeSelectors(sessionId, args.probe);
|
|
2902
|
+
const jsLooking = args.probe.filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
|
|
2903
|
+
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.`;
|
|
2904
|
+
}
|
|
2579
2905
|
if (args.deepDom) {
|
|
2580
2906
|
const closed = await cdp.getClosedShadowRoots(sessionId, maxBytes > 0 ? maxBytes : 65536);
|
|
2581
2907
|
result.closedShadowRoots = closed;
|
|
@@ -2594,7 +2920,7 @@ async function source_inspect_handler(args) {
|
|
|
2594
2920
|
}
|
|
2595
2921
|
const list_extensions_schema = {
|
|
2596
2922
|
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
|
|
2923
|
+
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
2924
|
inputSchema: {
|
|
2599
2925
|
type: "object",
|
|
2600
2926
|
properties: {
|
|
@@ -2849,10 +3175,28 @@ function capRecent(events, limit) {
|
|
|
2849
3175
|
truncated: true
|
|
2850
3176
|
};
|
|
2851
3177
|
}
|
|
2852
|
-
function
|
|
3178
|
+
function emptyReason(projectPath, browser) {
|
|
3179
|
+
let contract;
|
|
3180
|
+
try {
|
|
3181
|
+
contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
|
|
3182
|
+
} catch {
|
|
3183
|
+
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.";
|
|
3184
|
+
}
|
|
3185
|
+
if ("error" === contract.status) {
|
|
3186
|
+
const errs = contract.errors;
|
|
3187
|
+
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.`;
|
|
3188
|
+
}
|
|
3189
|
+
if ("number" == typeof contract.pid) try {
|
|
3190
|
+
process.kill(contract.pid, 0);
|
|
3191
|
+
} catch {
|
|
3192
|
+
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.`;
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
function summarize(events, source, browser, runId, limit, dropped, projectPath, staleNote) {
|
|
2853
3196
|
const matched = events.length;
|
|
2854
3197
|
const { events: out, truncated } = capRecent(events, limit);
|
|
2855
3198
|
const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
|
|
3199
|
+
const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
|
|
2856
3200
|
return JSON.stringify({
|
|
2857
3201
|
ok: true,
|
|
2858
3202
|
source,
|
|
@@ -2863,9 +3207,30 @@ function summarize(events, source, browser, runId, limit, dropped) {
|
|
|
2863
3207
|
truncated,
|
|
2864
3208
|
dropped: dropped || void 0,
|
|
2865
3209
|
nextSince: lastSeq >= 0 ? lastSeq : void 0,
|
|
3210
|
+
...reason ? {
|
|
3211
|
+
emptyReason: reason
|
|
3212
|
+
} : {},
|
|
3213
|
+
...staleNote && matched > 0 ? {
|
|
3214
|
+
stale: true,
|
|
3215
|
+
warning: staleNote
|
|
3216
|
+
} : {},
|
|
2866
3217
|
events: out
|
|
2867
3218
|
});
|
|
2868
3219
|
}
|
|
3220
|
+
function staleFileNote(projectPath, browser, eventsRunId) {
|
|
3221
|
+
let contract;
|
|
3222
|
+
try {
|
|
3223
|
+
contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
|
|
3224
|
+
} catch {
|
|
3225
|
+
return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
|
|
3226
|
+
}
|
|
3227
|
+
if ("number" == typeof contract.pid) try {
|
|
3228
|
+
process.kill(contract.pid, 0);
|
|
3229
|
+
} catch {
|
|
3230
|
+
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.`;
|
|
3231
|
+
}
|
|
3232
|
+
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.`;
|
|
3233
|
+
}
|
|
2869
3234
|
async function readFromFile(args, browser, limit) {
|
|
2870
3235
|
const file = logsFilePath(args.projectPath, browser);
|
|
2871
3236
|
if (!node_fs.existsSync(file)) return JSON.stringify({
|
|
@@ -2889,13 +3254,13 @@ async function readFromFile(args, browser, limit) {
|
|
|
2889
3254
|
}
|
|
2890
3255
|
if (matches(event)) events.push(event);
|
|
2891
3256
|
}
|
|
2892
|
-
return summarize(events, "file", browser, runId, limit, 0);
|
|
3257
|
+
return summarize(events, "file", browser, runId, limit, 0, args.projectPath, staleFileNote(args.projectPath, browser, runId));
|
|
2893
3258
|
}
|
|
2894
3259
|
async function readFromStream(args, browser, limit) {
|
|
2895
3260
|
const ready = readReadyContract(args.projectPath, browser);
|
|
2896
3261
|
if (!ready) {
|
|
2897
3262
|
const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
|
|
2898
|
-
const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}
|
|
3263
|
+
const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
|
|
2899
3264
|
return JSON.stringify({
|
|
2900
3265
|
error: `No active control channel found for ${browser}.`,
|
|
2901
3266
|
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 +3276,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
2911
3276
|
const url = `ws://127.0.0.1:${ready.controlPort}${CONTROL_WS_PATH}`;
|
|
2912
3277
|
let socket;
|
|
2913
3278
|
try {
|
|
2914
|
-
socket = new
|
|
3279
|
+
socket = new ws_0(url);
|
|
2915
3280
|
} catch (err) {
|
|
2916
3281
|
resolve(JSON.stringify({
|
|
2917
3282
|
error: `Could not open control channel at ${url}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -2925,7 +3290,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
2925
3290
|
try {
|
|
2926
3291
|
socket.close();
|
|
2927
3292
|
} catch {}
|
|
2928
|
-
resolve(summarize(events, "stream", browser, runId, limit, dropped));
|
|
3293
|
+
resolve(summarize(events, "stream", browser, runId, limit, dropped, args.projectPath));
|
|
2929
3294
|
};
|
|
2930
3295
|
const timer = setTimeout(finish, followMs);
|
|
2931
3296
|
socket.on("open", ()=>{
|
|
@@ -2975,10 +3340,10 @@ function withSessionContext(message, projectPath) {
|
|
|
2975
3340
|
const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
|
|
2976
3341
|
if (!isControlError) return message;
|
|
2977
3342
|
const dead = deadReadySession(projectPath);
|
|
2978
|
-
if (dead) return `${message}\nLikely cause: the dev server has exited
|
|
3343
|
+
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
3344
|
const running = knownSessionBrowsers(projectPath);
|
|
2980
3345
|
if (0 === running.length) return message;
|
|
2981
|
-
return `${message} Active session browser(s) for this project: ${running.join(", ")}
|
|
3346
|
+
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
3347
|
}
|
|
2983
3348
|
function translateFrame(frame, projectPath) {
|
|
2984
3349
|
if (!frame || false !== frame.ok) return frame;
|
|
@@ -3022,7 +3387,7 @@ function commonFlags(args) {
|
|
|
3022
3387
|
}
|
|
3023
3388
|
const eval_schema = {
|
|
3024
3389
|
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).
|
|
3390
|
+
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
3391
|
inputSchema: {
|
|
3027
3392
|
type: "object",
|
|
3028
3393
|
properties: {
|
|
@@ -3042,6 +3407,9 @@ const eval_schema = {
|
|
|
3042
3407
|
"options",
|
|
3043
3408
|
"sidebar",
|
|
3044
3409
|
"devtools",
|
|
3410
|
+
"newtab",
|
|
3411
|
+
"history",
|
|
3412
|
+
"bookmarks",
|
|
3045
3413
|
"content",
|
|
3046
3414
|
"page"
|
|
3047
3415
|
],
|
|
@@ -3050,11 +3418,11 @@ const eval_schema = {
|
|
|
3050
3418
|
},
|
|
3051
3419
|
url: {
|
|
3052
3420
|
type: "string",
|
|
3053
|
-
description: "For content/page:
|
|
3421
|
+
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
3422
|
},
|
|
3055
3423
|
tab: {
|
|
3056
3424
|
type: "number",
|
|
3057
|
-
description: "Numeric chrome.tabs id
|
|
3425
|
+
description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
|
|
3058
3426
|
},
|
|
3059
3427
|
browser: {
|
|
3060
3428
|
type: "string",
|
|
@@ -3073,7 +3441,7 @@ const eval_schema = {
|
|
|
3073
3441
|
};
|
|
3074
3442
|
async function eval_handler(args) {
|
|
3075
3443
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
3076
|
-
|
|
3444
|
+
const raw = await runActVerb([
|
|
3077
3445
|
"eval",
|
|
3078
3446
|
args.expression,
|
|
3079
3447
|
args.projectPath,
|
|
@@ -3082,6 +3450,14 @@ async function eval_handler(args) {
|
|
|
3082
3450
|
browser
|
|
3083
3451
|
})
|
|
3084
3452
|
], args.projectPath, args.timeout);
|
|
3453
|
+
if ("content" === args.context) try {
|
|
3454
|
+
const parsed = JSON.parse(raw);
|
|
3455
|
+
if (parsed?.ok === true && (null === parsed.value || void 0 === parsed.value)) {
|
|
3456
|
+
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'.";
|
|
3457
|
+
return JSON.stringify(parsed);
|
|
3458
|
+
}
|
|
3459
|
+
} catch {}
|
|
3460
|
+
return raw;
|
|
3085
3461
|
}
|
|
3086
3462
|
const storage_schema = {
|
|
3087
3463
|
name: "extension_storage",
|
|
@@ -3161,7 +3537,14 @@ async function storage_handler(args) {
|
|
|
3161
3537
|
message: "storage set requires a value"
|
|
3162
3538
|
}
|
|
3163
3539
|
});
|
|
3164
|
-
|
|
3540
|
+
if (void 0 === args.key) return JSON.stringify({
|
|
3541
|
+
ok: false,
|
|
3542
|
+
error: {
|
|
3543
|
+
name: "BadRequest",
|
|
3544
|
+
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".'
|
|
3545
|
+
}
|
|
3546
|
+
});
|
|
3547
|
+
cli.push("--value", JSON.stringify(args.value));
|
|
3165
3548
|
}
|
|
3166
3549
|
if (args.context) cli.push("--context", args.context);
|
|
3167
3550
|
cli.push("--browser", browser);
|
|
@@ -3216,6 +3599,27 @@ async function reload_handler(args) {
|
|
|
3216
3599
|
})
|
|
3217
3600
|
], args.projectPath, args.timeout);
|
|
3218
3601
|
}
|
|
3602
|
+
async function pollForTarget(port, url, budgetMs) {
|
|
3603
|
+
const deadline = Date.now() + budgetMs;
|
|
3604
|
+
const wanted = url.replace(/#.*$/, "");
|
|
3605
|
+
for(;;){
|
|
3606
|
+
try {
|
|
3607
|
+
const targets = await CDPClient.discoverTargets(port);
|
|
3608
|
+
for (const t of targets){
|
|
3609
|
+
const tUrl = String(t.url ?? "");
|
|
3610
|
+
if ("page" === t.type) {
|
|
3611
|
+
if (tUrl === wanted || tUrl.startsWith(wanted)) return {
|
|
3612
|
+
id: String(t.id),
|
|
3613
|
+
url: tUrl,
|
|
3614
|
+
title: "string" == typeof t.title ? t.title : void 0
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
} catch {}
|
|
3619
|
+
if (Date.now() >= deadline) return null;
|
|
3620
|
+
await new Promise((r)=>setTimeout(r, 250));
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3219
3623
|
async function navigateToUrl(projectPath, browser, url) {
|
|
3220
3624
|
if (!isChromiumFamily(browser)) return JSON.stringify({
|
|
3221
3625
|
ok: false,
|
|
@@ -3248,17 +3652,24 @@ async function navigateToUrl(projectPath, browser, url) {
|
|
|
3248
3652
|
await cdp.connect(browserWsUrl);
|
|
3249
3653
|
const sessionId = await cdp.attachToTarget(String(target.id));
|
|
3250
3654
|
await cdp.navigate(sessionId, url);
|
|
3251
|
-
await
|
|
3252
|
-
|
|
3655
|
+
const settled = await pollForTarget(resolved.port, url, 6000);
|
|
3656
|
+
if (!settled) return JSON.stringify({
|
|
3657
|
+
ok: false,
|
|
3658
|
+
error: {
|
|
3659
|
+
name: "NavigateFailed",
|
|
3660
|
+
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.`
|
|
3661
|
+
},
|
|
3662
|
+
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."
|
|
3663
|
+
});
|
|
3253
3664
|
return JSON.stringify({
|
|
3254
3665
|
ok: true,
|
|
3255
3666
|
navigated: url,
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
title:
|
|
3259
|
-
url:
|
|
3667
|
+
target: {
|
|
3668
|
+
targetId: settled.id,
|
|
3669
|
+
title: settled.title,
|
|
3670
|
+
url: settled.url
|
|
3260
3671
|
},
|
|
3261
|
-
hint: "Inspect it with extension_dom_inspect or extension_source_inspect (context: 'page')."
|
|
3672
|
+
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
3673
|
});
|
|
3263
3674
|
} catch (e) {
|
|
3264
3675
|
return JSON.stringify({
|
|
@@ -3274,9 +3685,180 @@ async function navigateToUrl(projectPath, browser, url) {
|
|
|
3274
3685
|
} catch {}
|
|
3275
3686
|
}
|
|
3276
3687
|
}
|
|
3688
|
+
function unpackedExtensionId(distPath) {
|
|
3689
|
+
const digest = node_crypto.createHash("sha256").update(distPath).digest();
|
|
3690
|
+
let id = "";
|
|
3691
|
+
for(let i = 0; i < 16; i++){
|
|
3692
|
+
id += String.fromCharCode(97 + (digest[i] >> 4));
|
|
3693
|
+
id += String.fromCharCode(97 + (0x0f & digest[i]));
|
|
3694
|
+
}
|
|
3695
|
+
return id;
|
|
3696
|
+
}
|
|
3697
|
+
async function resolveExtensionId(projectPath, browser) {
|
|
3698
|
+
const distPath = readDistPath(projectPath, browser);
|
|
3699
|
+
const computed = distPath ? unpackedExtensionId(distPath) : null;
|
|
3700
|
+
const resolved = await resolveCdpPort(projectPath, browser);
|
|
3701
|
+
if (!resolved) return computed;
|
|
3702
|
+
const ids = new Set();
|
|
3703
|
+
try {
|
|
3704
|
+
for (const t of (await CDPClient.discoverTargets(resolved.port))){
|
|
3705
|
+
const url = String(t.url ?? "");
|
|
3706
|
+
if (!url.startsWith("chrome-extension://")) continue;
|
|
3707
|
+
const id = url.slice(19).split("/")[0];
|
|
3708
|
+
if (id) ids.add(id);
|
|
3709
|
+
}
|
|
3710
|
+
} catch {}
|
|
3711
|
+
if (computed && ids.has(computed)) return computed;
|
|
3712
|
+
if (computed) return computed;
|
|
3713
|
+
return 1 === ids.size ? [
|
|
3714
|
+
...ids
|
|
3715
|
+
][0] : null;
|
|
3716
|
+
}
|
|
3717
|
+
function declaredCommands(projectPath, browser) {
|
|
3718
|
+
const candidates = [
|
|
3719
|
+
node_path.join(projectPath, "dist", browser, "manifest.json"),
|
|
3720
|
+
node_path.join(projectPath, "dist", "manifest.json"),
|
|
3721
|
+
node_path.join(projectPath, "src", "manifest.json"),
|
|
3722
|
+
node_path.join(projectPath, "manifest.json")
|
|
3723
|
+
];
|
|
3724
|
+
for (const file of candidates)try {
|
|
3725
|
+
const manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3726
|
+
const commands = manifest?.commands;
|
|
3727
|
+
if (commands && "object" == typeof commands) return Object.keys(commands);
|
|
3728
|
+
return [];
|
|
3729
|
+
} catch {
|
|
3730
|
+
continue;
|
|
3731
|
+
}
|
|
3732
|
+
return null;
|
|
3733
|
+
}
|
|
3734
|
+
function readDistPath(projectPath, browser) {
|
|
3735
|
+
try {
|
|
3736
|
+
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
3737
|
+
const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3738
|
+
return "string" == typeof contract?.distPath ? contract.distPath : null;
|
|
3739
|
+
} catch {
|
|
3740
|
+
return null;
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
function surfaceDocument(projectPath, browser, surface) {
|
|
3744
|
+
const candidates = [
|
|
3745
|
+
node_path.join(projectPath, "dist", browser, "manifest.json"),
|
|
3746
|
+
node_path.join(projectPath, "dist", "manifest.json"),
|
|
3747
|
+
node_path.join(projectPath, "src", "manifest.json"),
|
|
3748
|
+
node_path.join(projectPath, "manifest.json")
|
|
3749
|
+
];
|
|
3750
|
+
for (const file of candidates){
|
|
3751
|
+
let manifest;
|
|
3752
|
+
try {
|
|
3753
|
+
manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3754
|
+
} catch {
|
|
3755
|
+
continue;
|
|
3756
|
+
}
|
|
3757
|
+
const action = manifest.action ?? manifest.browser_action;
|
|
3758
|
+
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;
|
|
3759
|
+
if ("string" == typeof ref && ref) return ref.replace(/^\.?\//, "");
|
|
3760
|
+
}
|
|
3761
|
+
return null;
|
|
3762
|
+
}
|
|
3763
|
+
const POPUP_MIN = 25;
|
|
3764
|
+
const POPUP_MAX_WIDTH = 800;
|
|
3765
|
+
const POPUP_MAX_HEIGHT = 600;
|
|
3766
|
+
function clampPopupBounds(width, height) {
|
|
3767
|
+
const w = Math.min(Math.max(Math.ceil(width), POPUP_MIN), POPUP_MAX_WIDTH);
|
|
3768
|
+
const h = Math.min(Math.max(Math.ceil(height), POPUP_MIN), POPUP_MAX_HEIGHT);
|
|
3769
|
+
return {
|
|
3770
|
+
width: w,
|
|
3771
|
+
height: h,
|
|
3772
|
+
clamped: w !== Math.ceil(width) || h !== Math.ceil(height)
|
|
3773
|
+
};
|
|
3774
|
+
}
|
|
3775
|
+
async function applyPopupBounds(projectPath, browser, targetId) {
|
|
3776
|
+
const resolved = await resolveCdpPort(projectPath, browser);
|
|
3777
|
+
if (!resolved) return null;
|
|
3778
|
+
const cdp = new CDPClient();
|
|
3779
|
+
try {
|
|
3780
|
+
const ws = await CDPClient.discoverBrowserWsUrl(resolved.port);
|
|
3781
|
+
await cdp.connect(ws);
|
|
3782
|
+
const sessionId = await cdp.attachToTarget(targetId);
|
|
3783
|
+
const measured = await cdp.evaluate(sessionId, `(() => {
|
|
3784
|
+
const de = document.documentElement, b = document.body;
|
|
3785
|
+
if (!de || !b) return null;
|
|
3786
|
+
const prev = de.style.width;
|
|
3787
|
+
de.style.width = "fit-content";
|
|
3788
|
+
const w = Math.max(de.getBoundingClientRect().width, b.getBoundingClientRect().width);
|
|
3789
|
+
const h = Math.max(de.getBoundingClientRect().height, b.getBoundingClientRect().height, b.scrollHeight);
|
|
3790
|
+
de.style.width = prev;
|
|
3791
|
+
return { w: Math.ceil(w), h: Math.ceil(h) };
|
|
3792
|
+
})()`);
|
|
3793
|
+
if (!measured || "number" != typeof measured.w || "number" != typeof measured.h || measured.w <= 0 || measured.h <= 0) return null;
|
|
3794
|
+
const bounds = clampPopupBounds(measured.w, measured.h);
|
|
3795
|
+
const win = await cdp.sendCommand("Browser.getWindowForTarget", {
|
|
3796
|
+
targetId
|
|
3797
|
+
});
|
|
3798
|
+
if ("number" != typeof win?.windowId) return null;
|
|
3799
|
+
await cdp.sendCommand("Browser.setWindowBounds", {
|
|
3800
|
+
windowId: win.windowId,
|
|
3801
|
+
bounds: {
|
|
3802
|
+
width: bounds.width,
|
|
3803
|
+
height: bounds.height
|
|
3804
|
+
}
|
|
3805
|
+
});
|
|
3806
|
+
const after = await cdp.sendCommand("Browser.getWindowBounds", {
|
|
3807
|
+
windowId: win.windowId
|
|
3808
|
+
});
|
|
3809
|
+
if (after?.bounds?.width !== bounds.width || after?.bounds?.height !== bounds.height) return null;
|
|
3810
|
+
return bounds;
|
|
3811
|
+
} catch {
|
|
3812
|
+
return null;
|
|
3813
|
+
} finally{
|
|
3814
|
+
try {
|
|
3815
|
+
cdp.disconnect();
|
|
3816
|
+
} catch {}
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
async function openSurfaceAsTab(projectPath, browser, surface) {
|
|
3820
|
+
const doc = surfaceDocument(projectPath, browser, surface);
|
|
3821
|
+
if (!doc) return JSON.stringify({
|
|
3822
|
+
ok: false,
|
|
3823
|
+
error: {
|
|
3824
|
+
name: "NoSurfaceDocument",
|
|
3825
|
+
message: `The manifest declares no document for surface "${surface}", so there is no page to render as a tab.`
|
|
3826
|
+
},
|
|
3827
|
+
hint: "Check the manifest: popup needs action.default_popup, options needs options_ui.page or options_page, sidebar needs side_panel.default_path."
|
|
3828
|
+
});
|
|
3829
|
+
const id = await resolveExtensionId(projectPath, browser);
|
|
3830
|
+
if (!id) return JSON.stringify({
|
|
3831
|
+
ok: false,
|
|
3832
|
+
error: {
|
|
3833
|
+
name: "NoExtensionId",
|
|
3834
|
+
message: "Could not resolve the extension id from the live session's CDP targets."
|
|
3835
|
+
},
|
|
3836
|
+
hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
|
|
3837
|
+
});
|
|
3838
|
+
const url = `chrome-extension://${id}/${doc}`;
|
|
3839
|
+
const raw = await navigateToUrl(projectPath, browser, url);
|
|
3840
|
+
try {
|
|
3841
|
+
const parsed = JSON.parse(raw);
|
|
3842
|
+
if (parsed?.ok) {
|
|
3843
|
+
parsed.renderedAsTab = {
|
|
3844
|
+
surface,
|
|
3845
|
+
document: doc,
|
|
3846
|
+
extensionId: id
|
|
3847
|
+
};
|
|
3848
|
+
let popupBounds = null;
|
|
3849
|
+
if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
|
|
3850
|
+
popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
|
|
3851
|
+
if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
|
|
3852
|
+
}
|
|
3853
|
+
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.";
|
|
3854
|
+
return JSON.stringify(parsed);
|
|
3855
|
+
}
|
|
3856
|
+
} catch {}
|
|
3857
|
+
return raw;
|
|
3858
|
+
}
|
|
3277
3859
|
const open_schema = {
|
|
3278
3860
|
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`.",
|
|
3861
|
+
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
3862
|
inputSchema: {
|
|
3281
3863
|
type: "object",
|
|
3282
3864
|
properties: {
|
|
@@ -3290,10 +3872,13 @@ const open_schema = {
|
|
|
3290
3872
|
"popup",
|
|
3291
3873
|
"options",
|
|
3292
3874
|
"sidebar",
|
|
3875
|
+
"newtab",
|
|
3876
|
+
"history",
|
|
3877
|
+
"bookmarks",
|
|
3293
3878
|
"action",
|
|
3294
3879
|
"command"
|
|
3295
3880
|
],
|
|
3296
|
-
description: "Which surface to open or event to replay. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
|
|
3881
|
+
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
3882
|
},
|
|
3298
3883
|
name: {
|
|
3299
3884
|
type: "string",
|
|
@@ -3303,6 +3888,11 @@ const open_schema = {
|
|
|
3303
3888
|
type: "string",
|
|
3304
3889
|
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
3890
|
},
|
|
3891
|
+
asTab: {
|
|
3892
|
+
type: "boolean",
|
|
3893
|
+
default: false,
|
|
3894
|
+
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."
|
|
3895
|
+
},
|
|
3306
3896
|
browser: {
|
|
3307
3897
|
type: "string",
|
|
3308
3898
|
description: "Browser session to target. Defaults to the active dev session's browser for this project."
|
|
@@ -3320,6 +3910,15 @@ const open_schema = {
|
|
|
3320
3910
|
async function open_handler(args) {
|
|
3321
3911
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
3322
3912
|
if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
|
|
3913
|
+
const AS_TAB_SURFACES = [
|
|
3914
|
+
"popup",
|
|
3915
|
+
"options",
|
|
3916
|
+
"sidebar",
|
|
3917
|
+
"newtab",
|
|
3918
|
+
"history",
|
|
3919
|
+
"bookmarks"
|
|
3920
|
+
];
|
|
3921
|
+
if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
3323
3922
|
if (!args.surface) return JSON.stringify({
|
|
3324
3923
|
ok: false,
|
|
3325
3924
|
error: {
|
|
@@ -3327,6 +3926,18 @@ async function open_handler(args) {
|
|
|
3327
3926
|
message: "Pass `surface` (popup/options/sidebar/action/command) to open a surface, or `url` to navigate a tab."
|
|
3328
3927
|
}
|
|
3329
3928
|
});
|
|
3929
|
+
if ("command" === args.surface) {
|
|
3930
|
+
const declared = declaredCommands(args.projectPath, browser);
|
|
3931
|
+
if (declared && args.name && !declared.includes(args.name)) return JSON.stringify({
|
|
3932
|
+
ok: false,
|
|
3933
|
+
error: {
|
|
3934
|
+
name: "UnknownCommand",
|
|
3935
|
+
message: `"${args.name}" is not declared in the manifest's \`commands\`, so triggering it can only ever be a no-op.`
|
|
3936
|
+
},
|
|
3937
|
+
declaredCommands: declared,
|
|
3938
|
+
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."
|
|
3939
|
+
});
|
|
3940
|
+
}
|
|
3330
3941
|
const cli = [
|
|
3331
3942
|
"open",
|
|
3332
3943
|
args.surface,
|
|
@@ -3344,8 +3955,18 @@ async function open_handler(args) {
|
|
|
3344
3955
|
].includes(args.surface)) try {
|
|
3345
3956
|
const parsed = JSON.parse(raw);
|
|
3346
3957
|
const msg = String(parsed?.error?.message ?? "");
|
|
3347
|
-
if (parsed?.ok === false &&
|
|
3348
|
-
|
|
3958
|
+
if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
|
|
3959
|
+
if (AS_TAB_SURFACES.includes(args.surface) && isChromiumFamily(browser)) {
|
|
3960
|
+
const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
3961
|
+
try {
|
|
3962
|
+
const parsedFallback = JSON.parse(fallback);
|
|
3963
|
+
if (parsedFallback?.ok) {
|
|
3964
|
+
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.";
|
|
3965
|
+
return JSON.stringify(parsedFallback);
|
|
3966
|
+
}
|
|
3967
|
+
} catch {}
|
|
3968
|
+
}
|
|
3969
|
+
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
3970
|
return JSON.stringify(parsed);
|
|
3350
3971
|
}
|
|
3351
3972
|
} catch {}
|
|
@@ -3363,7 +3984,16 @@ const dom_inspect_schema = {
|
|
|
3363
3984
|
},
|
|
3364
3985
|
tab: {
|
|
3365
3986
|
type: "number",
|
|
3366
|
-
description: "
|
|
3987
|
+
description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
|
|
3988
|
+
},
|
|
3989
|
+
url: {
|
|
3990
|
+
type: "string",
|
|
3991
|
+
description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`."
|
|
3992
|
+
},
|
|
3993
|
+
listTabs: {
|
|
3994
|
+
type: "boolean",
|
|
3995
|
+
default: false,
|
|
3996
|
+
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
3997
|
},
|
|
3368
3998
|
context: {
|
|
3369
3999
|
type: "string",
|
|
@@ -3373,10 +4003,13 @@ const dom_inspect_schema = {
|
|
|
3373
4003
|
"popup",
|
|
3374
4004
|
"options",
|
|
3375
4005
|
"sidebar",
|
|
3376
|
-
"devtools"
|
|
4006
|
+
"devtools",
|
|
4007
|
+
"newtab",
|
|
4008
|
+
"history",
|
|
4009
|
+
"bookmarks"
|
|
3377
4010
|
],
|
|
3378
4011
|
default: "content",
|
|
3379
|
-
description: "content/page (
|
|
4012
|
+
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
4013
|
},
|
|
3381
4014
|
include: {
|
|
3382
4015
|
type: "array",
|
|
@@ -3397,8 +4030,11 @@ const dom_inspect_schema = {
|
|
|
3397
4030
|
default: 262144
|
|
3398
4031
|
},
|
|
3399
4032
|
withConsole: {
|
|
3400
|
-
type:
|
|
3401
|
-
|
|
4033
|
+
type: [
|
|
4034
|
+
"number",
|
|
4035
|
+
"boolean"
|
|
4036
|
+
],
|
|
4037
|
+
description: "Also include recent console lines for the target (DOM + console in one call). A number is how many lines; true means 50."
|
|
3402
4038
|
},
|
|
3403
4039
|
browser: {
|
|
3404
4040
|
type: "string",
|
|
@@ -3415,30 +4051,28 @@ const dom_inspect_schema = {
|
|
|
3415
4051
|
}
|
|
3416
4052
|
};
|
|
3417
4053
|
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
|
-
});
|
|
4054
|
+
const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
|
|
4055
|
+
if (args.listTabs) return runActVerb([
|
|
4056
|
+
"inspect",
|
|
4057
|
+
args.projectPath,
|
|
4058
|
+
"--list-tabs",
|
|
4059
|
+
"--browser",
|
|
4060
|
+
resolveSessionBrowser(args.projectPath, args.browser).browser,
|
|
4061
|
+
...null != args.timeout ? [
|
|
4062
|
+
"--timeout",
|
|
4063
|
+
String(args.timeout)
|
|
4064
|
+
] : []
|
|
4065
|
+
], args.projectPath, args.timeout);
|
|
3433
4066
|
const cli = [
|
|
3434
4067
|
"inspect",
|
|
3435
4068
|
args.projectPath
|
|
3436
4069
|
];
|
|
3437
4070
|
if (null != args.tab) cli.push("--tab", String(args.tab));
|
|
4071
|
+
if (args.url) cli.push("--url", args.url);
|
|
3438
4072
|
if (args.context) cli.push("--context", args.context);
|
|
3439
4073
|
if (args.include?.length) cli.push("--include", args.include.join(","));
|
|
3440
4074
|
if (null != args.maxBytes) cli.push("--max-bytes", String(args.maxBytes));
|
|
3441
|
-
if (null !=
|
|
4075
|
+
if (null != withConsole) cli.push("--with-console", String(withConsole));
|
|
3442
4076
|
cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
|
|
3443
4077
|
if (null != args.timeout) cli.push("--timeout", String(args.timeout));
|
|
3444
4078
|
return runActVerb(cli, args.projectPath, args.timeout);
|
|
@@ -3829,6 +4463,32 @@ async function release_promote_handler(args) {
|
|
|
3829
4463
|
return JSON.stringify(data);
|
|
3830
4464
|
}
|
|
3831
4465
|
const deploy_DEFAULT_API = "https://www.extension.dev";
|
|
4466
|
+
function storeMdWarnings(browsers, cwd) {
|
|
4467
|
+
const wantsFirefox = browsers.includes("firefox");
|
|
4468
|
+
const wantsEdge = browsers.includes("edge");
|
|
4469
|
+
if (!wantsFirefox && !wantsEdge) return [];
|
|
4470
|
+
let content;
|
|
4471
|
+
try {
|
|
4472
|
+
content = node_fs.readFileSync(node_path.join(cwd, "STORE.md"), "utf8");
|
|
4473
|
+
} catch {
|
|
4474
|
+
return [
|
|
4475
|
+
"No STORE.md found in the project root. Reviewer notes (Firefox) and certification notes (Edge) will not accompany the submission. See the extension-dev skill's store-md reference."
|
|
4476
|
+
];
|
|
4477
|
+
}
|
|
4478
|
+
const hasField = (section, field)=>{
|
|
4479
|
+
const parts = content.split(/^## +/m);
|
|
4480
|
+
const match = parts.find((p)=>section.test(p.split("\n", 1)[0] ?? ""));
|
|
4481
|
+
if (!match) return false;
|
|
4482
|
+
const sub = match.split(/^### +/m).find((p)=>field.test(p.split("\n", 1)[0] ?? ""));
|
|
4483
|
+
if (!sub) return false;
|
|
4484
|
+
const body = sub.split("\n").slice(1).join("\n");
|
|
4485
|
+
return body.replace(/<!--[\s\S]*?-->/g, "").trim().length > 0;
|
|
4486
|
+
};
|
|
4487
|
+
const warnings = [];
|
|
4488
|
+
if (wantsFirefox && !hasField(/firefox|amo/i, /reviewer notes/i)) warnings.push("STORE.md has no Firefox reviewer notes; AMO reviews go faster with test credentials and steps.");
|
|
4489
|
+
if (wantsEdge && !hasField(/edge/i, /certification notes/i)) warnings.push("STORE.md has no Edge certification notes; the certification team gets no testing guidance.");
|
|
4490
|
+
return warnings;
|
|
4491
|
+
}
|
|
3832
4492
|
const deploy_schema = {
|
|
3833
4493
|
name: "extension_deploy",
|
|
3834
4494
|
description: "Submit a built extension to the Chrome Web Store, Firefox AMO, and/or Edge Add-ons THROUGH extension.dev, which holds your store credentials and dispatches the release from your project's mirror CI. DEFAULTS TO A DRY RUN (preflight: verifies auth, the project, that the build exists, and the store workflow - dispatches nothing); pass dryRun:false to actually submit, which is irreversible and enters store review. The target project is identified by your token (extension_login or a release token in EXTENSION_DEV_TOKEN); store credentials are never tool arguments and local files are not uploaded. Pass browsers + buildSha. Posts to the platform's CLI store-submission endpoint.",
|
|
@@ -3926,11 +4586,196 @@ async function deploy_handler(args) {
|
|
|
3926
4586
|
};
|
|
3927
4587
|
}
|
|
3928
4588
|
if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
|
|
3929
|
-
|
|
4589
|
+
const warnings = storeMdWarnings(browsers, process.cwd());
|
|
4590
|
+
const result = {
|
|
3930
4591
|
mode: "platform",
|
|
3931
4592
|
dryRun,
|
|
3932
4593
|
...data
|
|
4594
|
+
};
|
|
4595
|
+
if (warnings.length > 0) result.warnings = warnings;
|
|
4596
|
+
return JSON.stringify(result);
|
|
4597
|
+
}
|
|
4598
|
+
function doctor_readReadyContract(projectPath, browser) {
|
|
4599
|
+
try {
|
|
4600
|
+
const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
|
|
4601
|
+
return JSON.parse(raw);
|
|
4602
|
+
} catch {
|
|
4603
|
+
return null;
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
const doctor_schema = {
|
|
4607
|
+
name: "extension_doctor",
|
|
4608
|
+
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.",
|
|
4609
|
+
inputSchema: {
|
|
4610
|
+
type: "object",
|
|
4611
|
+
properties: {
|
|
4612
|
+
projectPath: {
|
|
4613
|
+
type: "string",
|
|
4614
|
+
description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
|
|
4615
|
+
},
|
|
4616
|
+
browser: {
|
|
4617
|
+
type: "string",
|
|
4618
|
+
description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
};
|
|
4623
|
+
async function environmentPreflight() {
|
|
4624
|
+
const checks = [];
|
|
4625
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
4626
|
+
checks.push({
|
|
4627
|
+
check: "node",
|
|
4628
|
+
status: nodeMajor >= 20 ? "pass" : "fail",
|
|
4629
|
+
detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
|
|
4630
|
+
remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
|
|
4631
|
+
});
|
|
4632
|
+
const { code, stdout, stderr } = await runExtensionCli([
|
|
4633
|
+
"--version"
|
|
4634
|
+
], {
|
|
4635
|
+
timeoutMs: 60000
|
|
4636
|
+
});
|
|
4637
|
+
const cliVersion = stdout.trim() || stderr.trim();
|
|
4638
|
+
checks.push({
|
|
4639
|
+
check: "extension-cli",
|
|
4640
|
+
status: 0 === code ? "pass" : "fail",
|
|
4641
|
+
detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
|
|
4642
|
+
remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
|
|
4643
|
+
});
|
|
4644
|
+
const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
|
|
4645
|
+
const cacheExists = node_fs.existsSync(cacheFile);
|
|
4646
|
+
checks.push({
|
|
4647
|
+
check: "template-cache",
|
|
4648
|
+
status: cacheExists ? "pass" : "warn",
|
|
4649
|
+
detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
|
|
4650
|
+
});
|
|
4651
|
+
const healthy = checks.every((c)=>"fail" !== c.status);
|
|
4652
|
+
return JSON.stringify({
|
|
4653
|
+
mode: "environment",
|
|
4654
|
+
healthy,
|
|
4655
|
+
checks,
|
|
4656
|
+
hint: "Pass projectPath to diagnose a live dev session end-to-end."
|
|
4657
|
+
});
|
|
4658
|
+
}
|
|
4659
|
+
function safeStringify(value) {
|
|
4660
|
+
try {
|
|
4661
|
+
return JSON.stringify(value) ?? String(value);
|
|
4662
|
+
} catch {
|
|
4663
|
+
return String(value);
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
function recentErrorLogs(projectPath, browser, max = 5) {
|
|
4667
|
+
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
|
|
4668
|
+
let lines;
|
|
4669
|
+
try {
|
|
4670
|
+
lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
4671
|
+
} catch {
|
|
4672
|
+
return [];
|
|
4673
|
+
}
|
|
4674
|
+
const errs = [];
|
|
4675
|
+
for (const line of lines){
|
|
4676
|
+
let ev;
|
|
4677
|
+
try {
|
|
4678
|
+
ev = JSON.parse(line);
|
|
4679
|
+
} catch {
|
|
4680
|
+
continue;
|
|
4681
|
+
}
|
|
4682
|
+
if (!ev || "error" !== ev.level) continue;
|
|
4683
|
+
const parts = Array.isArray(ev.messageParts) ? ev.messageParts : Array.isArray(ev.args) ? ev.args : null;
|
|
4684
|
+
let msg = parts ? parts.map((p)=>"string" == typeof p ? p : safeStringify(p)).join(" ") : ev.message || ev.text || "";
|
|
4685
|
+
if (!msg && ev.errorName) msg = ev.stack ? `${ev.errorName}: ${ev.stack}` : ev.errorName;
|
|
4686
|
+
msg = msg.replace(/\s+/g, " ").trim();
|
|
4687
|
+
if (msg) errs.push(msg.slice(0, 300));
|
|
4688
|
+
}
|
|
4689
|
+
return [
|
|
4690
|
+
...new Set(errs)
|
|
4691
|
+
].slice(-max);
|
|
4692
|
+
}
|
|
4693
|
+
function projectEngineVersion(projectPath) {
|
|
4694
|
+
try {
|
|
4695
|
+
const p = node_path.resolve(projectPath, "node_modules", "extension", "package.json");
|
|
4696
|
+
return JSON.parse(node_fs.readFileSync(p, "utf8")).version || null;
|
|
4697
|
+
} catch {
|
|
4698
|
+
return null;
|
|
4699
|
+
}
|
|
4700
|
+
}
|
|
4701
|
+
async function doctor_handler(args) {
|
|
4702
|
+
if (!args.projectPath) return environmentPreflight();
|
|
4703
|
+
const projectPath = args.projectPath;
|
|
4704
|
+
const { browser } = resolveSessionBrowser(projectPath, args.browser);
|
|
4705
|
+
const { code, stdout, stderr } = await runExtensionCli([
|
|
4706
|
+
"doctor",
|
|
4707
|
+
projectPath,
|
|
4708
|
+
"--browser",
|
|
4709
|
+
browser,
|
|
4710
|
+
"--output",
|
|
4711
|
+
"json"
|
|
4712
|
+
], {
|
|
4713
|
+
cwd: projectPath
|
|
3933
4714
|
});
|
|
4715
|
+
const out = stdout.trim();
|
|
4716
|
+
try {
|
|
4717
|
+
const checks = JSON.parse(out);
|
|
4718
|
+
if (!Array.isArray(checks)) throw new Error("not a check array");
|
|
4719
|
+
for (const check of checks){
|
|
4720
|
+
if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
|
|
4721
|
+
if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
|
|
4722
|
+
}
|
|
4723
|
+
let healthy = 0 === code;
|
|
4724
|
+
const contract = doctor_readReadyContract(projectPath, browser);
|
|
4725
|
+
if (contract?.status === "error") {
|
|
4726
|
+
healthy = false;
|
|
4727
|
+
const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
|
|
4728
|
+
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.";
|
|
4729
|
+
checks.push({
|
|
4730
|
+
check: "runtime-errors",
|
|
4731
|
+
status: "fail",
|
|
4732
|
+
detail: toMcpSpeak(detail),
|
|
4733
|
+
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."
|
|
4734
|
+
});
|
|
4735
|
+
} else {
|
|
4736
|
+
const errs = recentErrorLogs(projectPath, browser);
|
|
4737
|
+
if (errs.length) {
|
|
4738
|
+
healthy = false;
|
|
4739
|
+
checks.push({
|
|
4740
|
+
check: "runtime-errors",
|
|
4741
|
+
status: "fail",
|
|
4742
|
+
detail: `Recent error-level logs: ${errs.join(" | ")}`,
|
|
4743
|
+
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."
|
|
4744
|
+
});
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
const engineVersion = projectEngineVersion(projectPath);
|
|
4748
|
+
if (engineVersion) {
|
|
4749
|
+
const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
|
|
4750
|
+
const mismatch = "" !== pin && "latest" !== pin && !engineVersion.includes(pin);
|
|
4751
|
+
checks.push({
|
|
4752
|
+
check: "project-engine",
|
|
4753
|
+
status: mismatch ? "warn" : "pass",
|
|
4754
|
+
detail: `project-local extension@${engineVersion}${mismatch ? `, but EXTENSION_MCP_CLI_VERSION=${pin}; the dev loop uses the project bin, not the pin` : ""}`,
|
|
4755
|
+
...mismatch ? {
|
|
4756
|
+
remediation: `Run \`(cd ${projectPath} && npm i -D extension@${pin})\` to match the pinned engine.`
|
|
4757
|
+
} : {}
|
|
4758
|
+
});
|
|
4759
|
+
}
|
|
4760
|
+
return JSON.stringify({
|
|
4761
|
+
browser,
|
|
4762
|
+
...engineVersion ? {
|
|
4763
|
+
engineVersion
|
|
4764
|
+
} : {},
|
|
4765
|
+
healthy,
|
|
4766
|
+
checks
|
|
4767
|
+
});
|
|
4768
|
+
} catch {
|
|
4769
|
+
const message = stderr.trim() || `extension exited with code ${code}`;
|
|
4770
|
+
return JSON.stringify({
|
|
4771
|
+
ok: false,
|
|
4772
|
+
error: {
|
|
4773
|
+
name: "CliError",
|
|
4774
|
+
message: toMcpSpeak(message),
|
|
4775
|
+
hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
|
|
4776
|
+
}
|
|
4777
|
+
});
|
|
4778
|
+
}
|
|
3934
4779
|
}
|
|
3935
4780
|
const SAFE_CEILING_MS = 50000;
|
|
3936
4781
|
function wait_isAlive(pid) {
|
|
@@ -3974,6 +4819,7 @@ async function wait_handler(args) {
|
|
|
3974
4819
|
const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
|
|
3975
4820
|
const start = Date.now();
|
|
3976
4821
|
const pollInterval = 1000;
|
|
4822
|
+
let sawCompiledButUnattached = false;
|
|
3977
4823
|
while(Date.now() - start < timeout){
|
|
3978
4824
|
try {
|
|
3979
4825
|
const raw = node_fs.readFileSync(readyPath, "utf8");
|
|
@@ -3981,11 +4827,18 @@ async function wait_handler(args) {
|
|
|
3981
4827
|
if ("ready" === contract.status) {
|
|
3982
4828
|
if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
|
|
3983
4829
|
status: "stale",
|
|
3984
|
-
message: `ready.json reports ready but its dev-server pid ${contract.pid} is dead
|
|
4830
|
+
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
4831
|
browser: contract.browser,
|
|
3986
4832
|
pid: contract.pid,
|
|
3987
4833
|
waitDuration: Date.now() - start
|
|
3988
4834
|
});
|
|
4835
|
+
const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
|
|
4836
|
+
if (!attached) {
|
|
4837
|
+
await new Promise((r)=>setTimeout(r, pollInterval));
|
|
4838
|
+
sawCompiledButUnattached = true;
|
|
4839
|
+
continue;
|
|
4840
|
+
}
|
|
4841
|
+
const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
|
|
3989
4842
|
return JSON.stringify({
|
|
3990
4843
|
status: "ready",
|
|
3991
4844
|
command: contract.command,
|
|
@@ -3996,7 +4849,11 @@ async function wait_handler(args) {
|
|
|
3996
4849
|
manifestPath: contract.manifestPath,
|
|
3997
4850
|
compiledAt: contract.compiledAt,
|
|
3998
4851
|
startedAt: contract.startedAt,
|
|
3999
|
-
waitDuration: Date.now() - start
|
|
4852
|
+
waitDuration: Date.now() - start,
|
|
4853
|
+
...runtimeErrors.length ? {
|
|
4854
|
+
runtimeErrors,
|
|
4855
|
+
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.`
|
|
4856
|
+
} : {}
|
|
4000
4857
|
});
|
|
4001
4858
|
}
|
|
4002
4859
|
if ("error" === contract.status) return JSON.stringify({
|
|
@@ -4010,13 +4867,20 @@ async function wait_handler(args) {
|
|
|
4010
4867
|
} catch {}
|
|
4011
4868
|
await new Promise((resolve)=>setTimeout(resolve, pollInterval));
|
|
4012
4869
|
}
|
|
4870
|
+
if (sawCompiledButUnattached) return JSON.stringify({
|
|
4871
|
+
status: "compiled-not-attached",
|
|
4872
|
+
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".`,
|
|
4873
|
+
readyPath,
|
|
4874
|
+
waitDuration: Date.now() - start,
|
|
4875
|
+
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."
|
|
4876
|
+
});
|
|
4013
4877
|
return JSON.stringify({
|
|
4014
4878
|
status: "timeout",
|
|
4015
4879
|
message: `Extension not ready after ${timeout}ms this call`,
|
|
4016
4880
|
readyPath,
|
|
4017
4881
|
waitDuration: Date.now() - start,
|
|
4018
4882
|
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
|
|
4883
|
+
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
4884
|
});
|
|
4021
4885
|
}
|
|
4022
4886
|
const add_feature_schema = {
|
|
@@ -5135,193 +5999,27 @@ async function detect_browsers_handler(args) {
|
|
|
5135
5999
|
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
6000
|
});
|
|
5137
6001
|
}
|
|
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
6002
|
function typeOf(value) {
|
|
5309
6003
|
if (Array.isArray(value)) return "array";
|
|
5310
6004
|
if (null === value) return "null";
|
|
5311
6005
|
return typeof value;
|
|
5312
6006
|
}
|
|
5313
6007
|
function checkPrimitive(path, value, schema, issues) {
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
6008
|
+
const allowed = void 0 === schema.type ? [] : Array.isArray(schema.type) ? schema.type : [
|
|
6009
|
+
schema.type
|
|
6010
|
+
];
|
|
6011
|
+
const primitives = allowed.filter((t)=>[
|
|
6012
|
+
"string",
|
|
6013
|
+
"number",
|
|
6014
|
+
"boolean"
|
|
6015
|
+
].includes(t));
|
|
6016
|
+
if (primitives.length > 0 && primitives.length === allowed.length) {
|
|
6017
|
+
if (!primitives.includes(typeOf(value))) return void issues.push({
|
|
5320
6018
|
path,
|
|
5321
|
-
message: `expected ${
|
|
6019
|
+
message: `expected ${primitives.join(" or ")}, got ${typeOf(value)}`
|
|
5322
6020
|
});
|
|
5323
6021
|
}
|
|
5324
|
-
if ("array"
|
|
6022
|
+
if (allowed.includes("array")) {
|
|
5325
6023
|
if (!Array.isArray(value)) return void issues.push({
|
|
5326
6024
|
path,
|
|
5327
6025
|
message: `expected array, got ${typeOf(value)}`
|
|
@@ -5366,6 +6064,26 @@ const ARG_ALIASES = {
|
|
|
5366
6064
|
surface: [
|
|
5367
6065
|
"view",
|
|
5368
6066
|
"target"
|
|
6067
|
+
],
|
|
6068
|
+
timeout: [
|
|
6069
|
+
"timeoutMs",
|
|
6070
|
+
"timeoutMillis"
|
|
6071
|
+
],
|
|
6072
|
+
limit: [
|
|
6073
|
+
"lines",
|
|
6074
|
+
"count",
|
|
6075
|
+
"max",
|
|
6076
|
+
"maxLines"
|
|
6077
|
+
],
|
|
6078
|
+
tab: [
|
|
6079
|
+
"tabId"
|
|
6080
|
+
],
|
|
6081
|
+
url: [
|
|
6082
|
+
"href",
|
|
6083
|
+
"pageUrl"
|
|
6084
|
+
],
|
|
6085
|
+
browser: [
|
|
6086
|
+
"browserName"
|
|
5369
6087
|
]
|
|
5370
6088
|
};
|
|
5371
6089
|
function normalizeArgAliases(inputSchema, args) {
|