@extension.dev/mcp 4.8.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +198 -20
- 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 +1378 -360
- package/dist/src/lib/exec.d.ts +6 -1
- package/dist/src/lib/session-browser.d.ts +11 -1
- package/dist/src/lib/types.d.ts +3 -1
- package/dist/src/tools/build.d.ts +6 -0
- package/dist/src/tools/doctor.d.ts +1 -0
- package/dist/src/tools/dom-inspect.d.ts +13 -2
- package/dist/src/tools/manifest-validate.d.ts +5 -0
- package/dist/src/tools/open.d.ts +17 -1
- package/package.json +6 -4
- package/server.json +2 -2
package/dist/module.js
CHANGED
|
@@ -9,8 +9,9 @@ import node_os from "node:os";
|
|
|
9
9
|
import cross_spawn from "cross-spawn";
|
|
10
10
|
import { execFile, execFileSync } from "node:child_process";
|
|
11
11
|
import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
|
|
12
|
-
import
|
|
12
|
+
import ws_0 from "ws";
|
|
13
13
|
import node_http from "node:http";
|
|
14
|
+
import node_crypto from "node:crypto";
|
|
14
15
|
import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
|
|
15
16
|
import { promisify } from "node:util";
|
|
16
17
|
var add_feature_namespaceObject = {};
|
|
@@ -53,6 +54,7 @@ var doctor_namespaceObject = {};
|
|
|
53
54
|
__webpack_require__.r(doctor_namespaceObject);
|
|
54
55
|
__webpack_require__.d(doctor_namespaceObject, {
|
|
55
56
|
handler: ()=>doctor_handler,
|
|
57
|
+
recentErrorLogs: ()=>recentErrorLogs,
|
|
56
58
|
schema: ()=>doctor_schema
|
|
57
59
|
});
|
|
58
60
|
var dom_inspect_namespaceObject = {};
|
|
@@ -130,6 +132,7 @@ __webpack_require__.d(manifest_validate_namespaceObject, {
|
|
|
130
132
|
var open_namespaceObject = {};
|
|
131
133
|
__webpack_require__.r(open_namespaceObject);
|
|
132
134
|
__webpack_require__.d(open_namespaceObject, {
|
|
135
|
+
clampPopupBounds: ()=>clampPopupBounds,
|
|
133
136
|
handler: ()=>open_handler,
|
|
134
137
|
schema: ()=>open_schema
|
|
135
138
|
});
|
|
@@ -199,7 +202,16 @@ __webpack_require__.d(whoami_namespaceObject, {
|
|
|
199
202
|
handler: ()=>whoami_handler,
|
|
200
203
|
schema: ()=>whoami_schema
|
|
201
204
|
});
|
|
202
|
-
var package_namespaceObject = JSON.parse('{"rE":"4.
|
|
205
|
+
var package_namespaceObject = JSON.parse('{"rE":"4.9.0","El":{"OP":"^4.0.13"}}');
|
|
206
|
+
function scaffoldEnginePin(projectPath) {
|
|
207
|
+
try {
|
|
208
|
+
const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
|
|
209
|
+
const spec = pkg?.devDependencies?.extension ?? pkg?.dependencies?.extension ?? null;
|
|
210
|
+
return "string" == typeof spec ? spec : null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
203
215
|
function detectPackageManager(projectPath) {
|
|
204
216
|
const byLockfile = [
|
|
205
217
|
[
|
|
@@ -238,7 +250,7 @@ const create_schema = {
|
|
|
238
250
|
},
|
|
239
251
|
parentDir: {
|
|
240
252
|
type: "string",
|
|
241
|
-
description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect
|
|
253
|
+
description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect, pass this explicitly when you care where the project lands."
|
|
242
254
|
},
|
|
243
255
|
template: {
|
|
244
256
|
type: "string",
|
|
@@ -259,14 +271,27 @@ const create_schema = {
|
|
|
259
271
|
async function create_handler(args) {
|
|
260
272
|
const start = Date.now();
|
|
261
273
|
const projectInput = args.parentDir ? node_path.resolve(args.parentDir, args.projectName) : args.projectName;
|
|
274
|
+
process.env.GIT_TERMINAL_PROMPT = "0";
|
|
275
|
+
if (void 0 === process.env.GIT_ASKPASS) process.env.GIT_ASKPASS = "";
|
|
262
276
|
const logLines = [];
|
|
263
277
|
const capture = (stream)=>(...parts)=>{
|
|
264
278
|
const line = parts.map((p)=>"string" == typeof p ? p : String(p)).join(" ").trim();
|
|
265
279
|
if (line) logLines.push("error" === stream ? `[error] ${line}` : line);
|
|
266
280
|
};
|
|
267
281
|
const logTail = (max = 20)=>logLines.slice(-max);
|
|
268
|
-
|
|
269
|
-
const
|
|
282
|
+
const looksTransient = ()=>{
|
|
283
|
+
const blob = logLines.join("\n").toLowerCase();
|
|
284
|
+
return /timed out|timeout|etimedout|econnreset|rate limit|\b429\b|network|could not resolve host|terminal prompts disabled|authentication failed|early eof|rpc failed|remote end hung up/.test(blob);
|
|
285
|
+
};
|
|
286
|
+
const cleanPartial = ()=>{
|
|
287
|
+
try {
|
|
288
|
+
if (args.parentDir && node_fs.existsSync(projectInput)) node_fs.rmSync(projectInput, {
|
|
289
|
+
recursive: true,
|
|
290
|
+
force: true
|
|
291
|
+
});
|
|
292
|
+
} catch {}
|
|
293
|
+
};
|
|
294
|
+
const attempt = ()=>extensionCreate(projectInput, {
|
|
270
295
|
template: args.template ?? "typescript",
|
|
271
296
|
install: args.install ?? true,
|
|
272
297
|
logger: {
|
|
@@ -274,34 +299,66 @@ async function create_handler(args) {
|
|
|
274
299
|
error: capture("error")
|
|
275
300
|
}
|
|
276
301
|
});
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
template: result.template,
|
|
283
|
-
depsInstalled: result.depsInstalled,
|
|
284
|
-
packageManager: result.depsInstalled ? packageManager : null,
|
|
285
|
-
duration: Date.now() - start,
|
|
286
|
-
nextSteps: result.depsInstalled ? [
|
|
287
|
-
`cd ${result.projectPath}`,
|
|
288
|
-
runDev
|
|
289
|
-
] : [
|
|
290
|
-
`cd ${result.projectPath}`,
|
|
291
|
-
"npm install",
|
|
292
|
-
"npm run dev"
|
|
293
|
-
],
|
|
294
|
-
...result.depsInstalled ? {} : {
|
|
295
|
-
warnings: logTail()
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
} catch (err) {
|
|
299
|
-
return JSON.stringify({
|
|
300
|
-
error: err instanceof Error ? err.message : String(err),
|
|
302
|
+
const failure = (err, transient)=>JSON.stringify({
|
|
303
|
+
error: transient ? "Template download failed (network/timeout/rate-limit). This is not a bad template name. Retry, or check connectivity/GitHub rate limits." : err instanceof Error ? err.message : String(err),
|
|
304
|
+
...transient ? {
|
|
305
|
+
cause: err instanceof Error ? err.message : String(err)
|
|
306
|
+
} : {},
|
|
301
307
|
duration: Date.now() - start,
|
|
302
308
|
log: logTail()
|
|
303
309
|
});
|
|
310
|
+
let result;
|
|
311
|
+
try {
|
|
312
|
+
result = await attempt();
|
|
313
|
+
} catch (err1) {
|
|
314
|
+
if (!looksTransient()) return failure(err1, false);
|
|
315
|
+
logLines.push("[retry] transient template-download failure; retrying once");
|
|
316
|
+
cleanPartial();
|
|
317
|
+
try {
|
|
318
|
+
result = await attempt();
|
|
319
|
+
} catch (err2) {
|
|
320
|
+
return failure(err2, looksTransient());
|
|
321
|
+
}
|
|
304
322
|
}
|
|
323
|
+
const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
|
|
324
|
+
if (!hasManifest) return JSON.stringify({
|
|
325
|
+
ok: false,
|
|
326
|
+
status: "incomplete",
|
|
327
|
+
projectPath: result.projectPath,
|
|
328
|
+
error: `The scaffold is incomplete: no manifest.json exists under ${result.projectPath} (checked the root and src/). Do not run extension_dev against it.`,
|
|
329
|
+
duration: Date.now() - start,
|
|
330
|
+
log: logTail(),
|
|
331
|
+
hint: "Delete the directory and retry extension_create; a template download interrupted mid-way can leave a partial tree."
|
|
332
|
+
});
|
|
333
|
+
const packageManager = result.packageManager || (result.depsInstalled ? detectPackageManager(result.projectPath) : "npm");
|
|
334
|
+
const runDev = `${packageManager} run dev`;
|
|
335
|
+
const addDev = (spec)=>"npm" === packageManager ? `npm i -D ${spec}` : "yarn" === packageManager ? `yarn add -D ${spec}` : `${packageManager} add -D ${spec}`;
|
|
336
|
+
const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
|
|
337
|
+
const scaffoldPin = scaffoldEnginePin(result.projectPath);
|
|
338
|
+
const pinMatches = null !== scaffoldPin && "" !== pin && scaffoldPin.includes(pin);
|
|
339
|
+
const engineWarning = pin && "latest" !== pin && !pinMatches ? `The scaffold pins "extension": "${scaffoldPin ?? "unknown"}"; the project-local engine wins over EXTENSION_MCP_CLI_VERSION=${pin}. Run \`(cd ${result.projectPath} && ${addDev(`extension@${pin}`)})\` to match the pinned engine.` : void 0;
|
|
340
|
+
return JSON.stringify({
|
|
341
|
+
projectPath: result.projectPath,
|
|
342
|
+
projectName: result.projectName,
|
|
343
|
+
template: result.template,
|
|
344
|
+
depsInstalled: result.depsInstalled,
|
|
345
|
+
packageManager: result.depsInstalled ? packageManager : null,
|
|
346
|
+
duration: Date.now() - start,
|
|
347
|
+
nextSteps: result.depsInstalled ? [
|
|
348
|
+
`cd ${result.projectPath}`,
|
|
349
|
+
runDev
|
|
350
|
+
] : [
|
|
351
|
+
`cd ${result.projectPath}`,
|
|
352
|
+
`${packageManager} install`,
|
|
353
|
+
runDev
|
|
354
|
+
],
|
|
355
|
+
...engineWarning ? {
|
|
356
|
+
engineWarning
|
|
357
|
+
} : {},
|
|
358
|
+
...result.depsInstalled ? {} : {
|
|
359
|
+
warnings: logTail()
|
|
360
|
+
}
|
|
361
|
+
});
|
|
305
362
|
}
|
|
306
363
|
const CACHE_DIR = node_path.join(node_os.homedir(), ".cache", "extension-js");
|
|
307
364
|
const CACHE_FILE = node_path.join(CACHE_DIR, "templates-meta.json");
|
|
@@ -515,6 +572,9 @@ function runExtensionCli(args, options) {
|
|
|
515
572
|
}
|
|
516
573
|
function spawnExtensionCli(args, options) {
|
|
517
574
|
const { command, prefixArgs } = resolveExtensionInvocation(options?.projectDir ?? options?.cwd);
|
|
575
|
+
const logDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "extension-mcp-"));
|
|
576
|
+
const logPath = node_path.join(logDir, "session.log");
|
|
577
|
+
const fd = node_fs.openSync(logPath, "a");
|
|
518
578
|
const child = cross_spawn(command, [
|
|
519
579
|
...prefixArgs,
|
|
520
580
|
...args
|
|
@@ -523,8 +583,8 @@ function spawnExtensionCli(args, options) {
|
|
|
523
583
|
detached: true,
|
|
524
584
|
stdio: [
|
|
525
585
|
"ignore",
|
|
526
|
-
|
|
527
|
-
|
|
586
|
+
fd,
|
|
587
|
+
fd
|
|
528
588
|
],
|
|
529
589
|
env: {
|
|
530
590
|
...process.env,
|
|
@@ -532,8 +592,74 @@ function spawnExtensionCli(args, options) {
|
|
|
532
592
|
NO_COLOR: "1"
|
|
533
593
|
}
|
|
534
594
|
});
|
|
595
|
+
node_fs.closeSync(fd);
|
|
535
596
|
child.unref();
|
|
536
|
-
return
|
|
597
|
+
return {
|
|
598
|
+
child,
|
|
599
|
+
logPath,
|
|
600
|
+
readOutput: ()=>{
|
|
601
|
+
try {
|
|
602
|
+
return node_fs.readFileSync(logPath, "utf8");
|
|
603
|
+
} catch {
|
|
604
|
+
return "";
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function readBuildSummary(projectPath, browser, since) {
|
|
610
|
+
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
|
|
611
|
+
try {
|
|
612
|
+
const stat = node_fs.statSync(file);
|
|
613
|
+
if (stat.mtimeMs < since) return null;
|
|
614
|
+
const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
615
|
+
if (summary && "object" == typeof summary) return summary;
|
|
616
|
+
} catch {}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
function builtEntrypoints(distDir) {
|
|
620
|
+
let manifest;
|
|
621
|
+
try {
|
|
622
|
+
manifest = JSON.parse(node_fs.readFileSync(node_path.join(distDir, "manifest.json"), "utf8"));
|
|
623
|
+
} catch {
|
|
624
|
+
return [];
|
|
625
|
+
}
|
|
626
|
+
const out = [];
|
|
627
|
+
const add = (role, ref)=>{
|
|
628
|
+
if ("string" != typeof ref) return;
|
|
629
|
+
out.push({
|
|
630
|
+
role,
|
|
631
|
+
path: ref,
|
|
632
|
+
present: node_fs.existsSync(node_path.join(distDir, ref.replace(/^\.?\//, "")))
|
|
633
|
+
});
|
|
634
|
+
};
|
|
635
|
+
const bg = manifest.background;
|
|
636
|
+
if (bg?.service_worker) add("background.service_worker", bg.service_worker);
|
|
637
|
+
if (bg?.page) add("background.page", bg.page);
|
|
638
|
+
if (Array.isArray(bg?.scripts)) bg.scripts.forEach((s)=>add("background.scripts", s));
|
|
639
|
+
const action = manifest.action || manifest.browser_action;
|
|
640
|
+
if (action?.default_popup) add("action.default_popup", action.default_popup);
|
|
641
|
+
const pageAction = manifest.page_action;
|
|
642
|
+
if (pageAction?.default_popup) add("page_action.default_popup", pageAction.default_popup);
|
|
643
|
+
const cs = manifest.content_scripts;
|
|
644
|
+
if (Array.isArray(cs)) cs.forEach((c, i)=>{
|
|
645
|
+
if (Array.isArray(c.js)) c.js.forEach((j)=>add(`content_scripts[${i}].js`, j));
|
|
646
|
+
if (Array.isArray(c.css)) c.css.forEach((s)=>add(`content_scripts[${i}].css`, s));
|
|
647
|
+
});
|
|
648
|
+
add("devtools_page", manifest.devtools_page);
|
|
649
|
+
add("options_page", manifest.options_page);
|
|
650
|
+
const optionsUi = manifest.options_ui;
|
|
651
|
+
if (optionsUi?.page) add("options_ui.page", optionsUi.page);
|
|
652
|
+
const sidePanel = manifest.side_panel;
|
|
653
|
+
if (sidePanel?.default_path) add("side_panel.default_path", sidePanel.default_path);
|
|
654
|
+
const sidebarAction = manifest.sidebar_action;
|
|
655
|
+
if (sidebarAction?.default_panel) add("sidebar_action.default_panel", sidebarAction.default_panel);
|
|
656
|
+
const overrides = manifest.chrome_url_overrides;
|
|
657
|
+
if (overrides) for (const [key, ref] of Object.entries(overrides))add(`chrome_url_overrides.${key}`, ref);
|
|
658
|
+
const dnr = manifest.declarative_net_request;
|
|
659
|
+
if (dnr && Array.isArray(dnr.rule_resources)) dnr.rule_resources.forEach((r, i)=>{
|
|
660
|
+
if (r && "object" == typeof r) add(`declarative_net_request[${i}].path`, r.path);
|
|
661
|
+
});
|
|
662
|
+
return out;
|
|
537
663
|
}
|
|
538
664
|
const build_schema = {
|
|
539
665
|
name: "extension_build",
|
|
@@ -600,6 +726,11 @@ const build_schema = {
|
|
|
600
726
|
],
|
|
601
727
|
default: "production",
|
|
602
728
|
description: "Bundler mode override (also sets NODE_ENV)"
|
|
729
|
+
},
|
|
730
|
+
skipValidation: {
|
|
731
|
+
type: "boolean",
|
|
732
|
+
default: false,
|
|
733
|
+
description: "Build even when extension_manifest_validate reports build-blocking errors. The build normally refuses, because a manifest error means the bundle it produces is broken in ways the bundler itself does not report."
|
|
603
734
|
}
|
|
604
735
|
},
|
|
605
736
|
required: [
|
|
@@ -607,9 +738,67 @@ const build_schema = {
|
|
|
607
738
|
]
|
|
608
739
|
}
|
|
609
740
|
};
|
|
741
|
+
function manifestDivergence(projectPath, browser) {
|
|
742
|
+
const read = (p)=>{
|
|
743
|
+
try {
|
|
744
|
+
return JSON.parse(node_fs.readFileSync(p, "utf8"));
|
|
745
|
+
} catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
const built = read(node_path.resolve(projectPath, "dist", browser, "manifest.json"));
|
|
750
|
+
const source = read(node_path.resolve(projectPath, "src", "manifest.json")) ?? read(node_path.resolve(projectPath, "manifest.json"));
|
|
751
|
+
if (!built || !source) return [];
|
|
752
|
+
const notes = [];
|
|
753
|
+
const listOf = (m, key)=>Array.isArray(m[key]) ? m[key].filter((x)=>"string" == typeof x) : [];
|
|
754
|
+
for (const key of [
|
|
755
|
+
"permissions",
|
|
756
|
+
"host_permissions",
|
|
757
|
+
"optional_permissions"
|
|
758
|
+
]){
|
|
759
|
+
const lost = listOf(source, key).filter((p)=>!listOf(built, key).includes(p));
|
|
760
|
+
if (lost.length) notes.push(`The built manifest drops ${key}: ${lost.join(", ")}. The production build has narrower access than the source you tested in dev.`);
|
|
761
|
+
}
|
|
762
|
+
const sourceWar = source.web_accessible_resources;
|
|
763
|
+
const builtWar = built.web_accessible_resources;
|
|
764
|
+
if (Array.isArray(sourceWar) && sourceWar.length && !Array.isArray(builtWar)) notes.push("The built manifest has no web_accessible_resources although the source declares them. Anything injected into a page (scripting.insertCSS targets, injected scripts, images) will be blocked at runtime.");
|
|
765
|
+
return notes;
|
|
766
|
+
}
|
|
767
|
+
async function validationPreflight(projectPath, browser) {
|
|
768
|
+
try {
|
|
769
|
+
const manifestValidate = await Promise.resolve().then(()=>({
|
|
770
|
+
handler: manifest_validate_handler
|
|
771
|
+
}));
|
|
772
|
+
const parsed = JSON.parse(await manifestValidate.handler({
|
|
773
|
+
projectPath,
|
|
774
|
+
browsers: [
|
|
775
|
+
browser
|
|
776
|
+
]
|
|
777
|
+
}));
|
|
778
|
+
return {
|
|
779
|
+
valid: Boolean(parsed.valid),
|
|
780
|
+
buildBlocking: Boolean(parsed.buildBlocking),
|
|
781
|
+
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
|
782
|
+
warnings: Array.isArray(parsed.warnings) ? parsed.warnings : []
|
|
783
|
+
};
|
|
784
|
+
} catch {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
610
788
|
async function build_handler(args) {
|
|
611
789
|
const start = Date.now();
|
|
612
790
|
const browser = args.browser ?? "chrome";
|
|
791
|
+
const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
|
|
792
|
+
if (preflight?.buildBlocking) return JSON.stringify({
|
|
793
|
+
success: false,
|
|
794
|
+
status: "blocked",
|
|
795
|
+
browser,
|
|
796
|
+
error: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds.",
|
|
797
|
+
errors: preflight.errors,
|
|
798
|
+
warnings: preflight.warnings,
|
|
799
|
+
duration: Date.now() - start,
|
|
800
|
+
hint: "Fix the errors above, then build again. Run extension_manifest_validate for the full report. To build anyway (for example to inspect the broken output), pass skipValidation: true."
|
|
801
|
+
});
|
|
613
802
|
const cliArgs = [
|
|
614
803
|
"build",
|
|
615
804
|
args.projectPath,
|
|
@@ -632,6 +821,30 @@ async function build_handler(args) {
|
|
|
632
821
|
if (0 === code) {
|
|
633
822
|
const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
|
|
634
823
|
const status = out.match(/Build Status:\s*(\w+)/)?.[1];
|
|
824
|
+
const engineSummary = readBuildSummary(args.projectPath, browser, start);
|
|
825
|
+
const buildWarnings = engineSummary?.warnings?.length ? {
|
|
826
|
+
buildWarnings: engineSummary.warnings,
|
|
827
|
+
..."number" == typeof engineSummary.warnings_count && engineSummary.warnings_count > engineSummary.warnings.length ? {
|
|
828
|
+
buildWarningsTruncated: engineSummary.warnings_count
|
|
829
|
+
} : {}
|
|
830
|
+
} : {};
|
|
831
|
+
const entrypoints = builtEntrypoints(node_path.resolve(args.projectPath, "dist", browser));
|
|
832
|
+
const missing = entrypoints.filter((e)=>!e.present);
|
|
833
|
+
if (missing.length) return JSON.stringify({
|
|
834
|
+
success: false,
|
|
835
|
+
status: "incomplete",
|
|
836
|
+
browser,
|
|
837
|
+
buildExitCode: 0,
|
|
838
|
+
error: `The build reported success but ${missing.length} declared entrypoint(s) are missing from dist/${browser}: ` + missing.map((m)=>`${m.role} -> ${m.path}`).join(", ") + ". The browser will refuse to load this build.",
|
|
839
|
+
entrypoints,
|
|
840
|
+
...preflight?.warnings.length ? {
|
|
841
|
+
manifestWarnings: preflight.warnings
|
|
842
|
+
} : {},
|
|
843
|
+
...buildWarnings,
|
|
844
|
+
duration,
|
|
845
|
+
output: lastLines(out, 12),
|
|
846
|
+
hint: "The bundler exited 0 but did not emit these files. Check that the manifest paths match what the build produces, and that nothing references a file outside the source tree."
|
|
847
|
+
});
|
|
635
848
|
return JSON.stringify({
|
|
636
849
|
success: true,
|
|
637
850
|
browser,
|
|
@@ -641,6 +854,19 @@ async function build_handler(args) {
|
|
|
641
854
|
...status ? {
|
|
642
855
|
status
|
|
643
856
|
} : {},
|
|
857
|
+
...entrypoints.length ? {
|
|
858
|
+
entrypoints
|
|
859
|
+
} : {},
|
|
860
|
+
...preflight?.warnings.length ? {
|
|
861
|
+
manifestWarnings: preflight.warnings
|
|
862
|
+
} : {},
|
|
863
|
+
...buildWarnings,
|
|
864
|
+
...(()=>{
|
|
865
|
+
const divergence = manifestDivergence(args.projectPath, browser);
|
|
866
|
+
return divergence.length ? {
|
|
867
|
+
productionDivergence: divergence
|
|
868
|
+
} : {};
|
|
869
|
+
})(),
|
|
644
870
|
zip: args.zip ?? false,
|
|
645
871
|
duration,
|
|
646
872
|
output: lastLines(out, 12)
|
|
@@ -769,7 +995,7 @@ const dev_schema = {
|
|
|
769
995
|
allowEval: {
|
|
770
996
|
type: "boolean",
|
|
771
997
|
default: false,
|
|
772
|
-
description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect
|
|
998
|
+
description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect. You do not need to pass both."
|
|
773
999
|
}
|
|
774
1000
|
},
|
|
775
1001
|
required: [
|
|
@@ -792,9 +1018,10 @@ async function dev_handler(args) {
|
|
|
792
1018
|
cliArgs.push(...launchFlagArgs(args));
|
|
793
1019
|
if (allowControl) cliArgs.push("--allow-control");
|
|
794
1020
|
if (args.allowEval) cliArgs.push("--allow-eval");
|
|
795
|
-
const
|
|
1021
|
+
const spawned = spawnExtensionCli(cliArgs, {
|
|
796
1022
|
projectDir: args.projectPath
|
|
797
1023
|
});
|
|
1024
|
+
const { child, logPath } = spawned;
|
|
798
1025
|
const pid = child.pid;
|
|
799
1026
|
registerSession({
|
|
800
1027
|
pid,
|
|
@@ -804,15 +1031,37 @@ async function dev_handler(args) {
|
|
|
804
1031
|
command: "dev"
|
|
805
1032
|
});
|
|
806
1033
|
child.on("exit", ()=>removeSession(args.projectPath, browser));
|
|
807
|
-
let earlyOutput = "";
|
|
808
|
-
const collector = (data)=>{
|
|
809
|
-
earlyOutput += data.toString();
|
|
810
|
-
};
|
|
811
|
-
child.stdout?.on("data", collector);
|
|
812
|
-
child.stderr?.on("data", collector);
|
|
813
1034
|
await new Promise((resolve)=>setTimeout(resolve, 3000));
|
|
814
|
-
|
|
815
|
-
child.
|
|
1035
|
+
const earlyOutput = spawned.readOutput();
|
|
1036
|
+
if (null !== child.exitCode || null !== child.signalCode) {
|
|
1037
|
+
const code = child.exitCode;
|
|
1038
|
+
const signal = child.signalCode;
|
|
1039
|
+
return JSON.stringify({
|
|
1040
|
+
ok: false,
|
|
1041
|
+
status: "exited",
|
|
1042
|
+
projectPath: args.projectPath,
|
|
1043
|
+
browser,
|
|
1044
|
+
pid,
|
|
1045
|
+
exitCode: code,
|
|
1046
|
+
signal,
|
|
1047
|
+
error: `The dev server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running, so extension_logs/wait/eval and the control verbs have nothing to attach to.`,
|
|
1048
|
+
output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
|
|
1049
|
+
logPath,
|
|
1050
|
+
hint: "Read `output` above for the cause: a port already in use, a manifest the build rejects, or a missing browser binary are the common ones. Fix it and call extension_dev again; extension_doctor with this projectPath will also report what the last session recorded."
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(earlyOutput);
|
|
1054
|
+
if (compileFailed) return JSON.stringify({
|
|
1055
|
+
ok: false,
|
|
1056
|
+
status: "compile-failed",
|
|
1057
|
+
projectPath: args.projectPath,
|
|
1058
|
+
browser,
|
|
1059
|
+
pid,
|
|
1060
|
+
error: "The dev server started but the FIRST COMPILE FAILED, so the browser has nothing usable to load. The session is running; the extension is not.",
|
|
1061
|
+
output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
|
|
1062
|
+
logPath,
|
|
1063
|
+
hint: "Fix the compile error in `output` above and save: the dev server is still running and will recompile. Do not call extension_wait yet, it will report ready for a build that failed."
|
|
1064
|
+
});
|
|
816
1065
|
const controlVerbs = "storage, reload, open, dom_inspect";
|
|
817
1066
|
const capabilities = {
|
|
818
1067
|
allowControl,
|
|
@@ -820,6 +1069,7 @@ async function dev_handler(args) {
|
|
|
820
1069
|
unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
|
|
821
1070
|
};
|
|
822
1071
|
return JSON.stringify({
|
|
1072
|
+
ok: true,
|
|
823
1073
|
pid,
|
|
824
1074
|
browser,
|
|
825
1075
|
port: args.port ?? 8080,
|
|
@@ -827,7 +1077,8 @@ async function dev_handler(args) {
|
|
|
827
1077
|
status: "started",
|
|
828
1078
|
capabilities,
|
|
829
1079
|
hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). Restart extension_dev with the flag you need.") + " When you are done, call extension_stop to shut down the dev server and browser.",
|
|
830
|
-
earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500)
|
|
1080
|
+
earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
|
|
1081
|
+
logPath
|
|
831
1082
|
});
|
|
832
1083
|
}
|
|
833
1084
|
function denoiseEarlyOutput(raw) {
|
|
@@ -841,6 +1092,93 @@ function denoiseEarlyOutput(raw) {
|
|
|
841
1092
|
];
|
|
842
1093
|
return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
|
|
843
1094
|
}
|
|
1095
|
+
function contractSightings(projectPath) {
|
|
1096
|
+
const root = node_path.resolve(projectPath, "dist", "extension-js");
|
|
1097
|
+
let dirs;
|
|
1098
|
+
try {
|
|
1099
|
+
dirs = node_fs.readdirSync(root);
|
|
1100
|
+
} catch {
|
|
1101
|
+
return [];
|
|
1102
|
+
}
|
|
1103
|
+
const sightings = [];
|
|
1104
|
+
for (const dir of dirs){
|
|
1105
|
+
const readyPath = node_path.join(root, dir, "ready.json");
|
|
1106
|
+
try {
|
|
1107
|
+
const stat = node_fs.statSync(readyPath);
|
|
1108
|
+
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
1109
|
+
if (contract?.status !== "ready") continue;
|
|
1110
|
+
sightings.push({
|
|
1111
|
+
browser: dir,
|
|
1112
|
+
mtimeMs: stat.mtimeMs,
|
|
1113
|
+
pid: "number" == typeof contract.pid ? contract.pid : void 0
|
|
1114
|
+
});
|
|
1115
|
+
} catch {}
|
|
1116
|
+
}
|
|
1117
|
+
return sightings;
|
|
1118
|
+
}
|
|
1119
|
+
function pidAlive(pid) {
|
|
1120
|
+
try {
|
|
1121
|
+
process.kill(pid, 0);
|
|
1122
|
+
return true;
|
|
1123
|
+
} catch {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
function knownSessionBrowsers(projectPath) {
|
|
1128
|
+
const resolved = node_path.resolve(projectPath);
|
|
1129
|
+
const browsers = [];
|
|
1130
|
+
for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
|
|
1131
|
+
for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
|
|
1132
|
+
return Array.from(new Set(browsers));
|
|
1133
|
+
}
|
|
1134
|
+
function deadReadySession(projectPath) {
|
|
1135
|
+
for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
|
|
1136
|
+
browser: sighting.browser,
|
|
1137
|
+
pid: sighting.pid
|
|
1138
|
+
};
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
function browserExitStamp(projectPath, browser, since) {
|
|
1142
|
+
const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
1143
|
+
try {
|
|
1144
|
+
const stat = node_fs.statSync(readyPath);
|
|
1145
|
+
if (stat.mtimeMs < since) return null;
|
|
1146
|
+
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
1147
|
+
const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
|
|
1148
|
+
if (contract?.status === "error" && exited) return {
|
|
1149
|
+
code: contract.code,
|
|
1150
|
+
browserExitCode: contract.browserExitCode ?? null,
|
|
1151
|
+
browserExitedAt: contract.browserExitedAt
|
|
1152
|
+
};
|
|
1153
|
+
} catch {}
|
|
1154
|
+
return null;
|
|
1155
|
+
}
|
|
1156
|
+
function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
|
|
1157
|
+
if (explicit) return {
|
|
1158
|
+
browser: explicit,
|
|
1159
|
+
source: "explicit"
|
|
1160
|
+
};
|
|
1161
|
+
const resolved = node_path.resolve(projectPath);
|
|
1162
|
+
const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
|
|
1163
|
+
if (mine.length > 0) return {
|
|
1164
|
+
browser: mine[mine.length - 1].browser,
|
|
1165
|
+
source: "session"
|
|
1166
|
+
};
|
|
1167
|
+
const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
|
|
1168
|
+
const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
|
|
1169
|
+
if (live.length > 0) return {
|
|
1170
|
+
browser: live[0].browser,
|
|
1171
|
+
source: "contract"
|
|
1172
|
+
};
|
|
1173
|
+
if (sightings.length > 0) return {
|
|
1174
|
+
browser: sightings[0].browser,
|
|
1175
|
+
source: "stale"
|
|
1176
|
+
};
|
|
1177
|
+
return {
|
|
1178
|
+
browser: fallback,
|
|
1179
|
+
source: "fallback"
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
844
1182
|
const start_schema = {
|
|
845
1183
|
name: "extension_start",
|
|
846
1184
|
description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
|
|
@@ -905,9 +1243,11 @@ async function start_handler(args) {
|
|
|
905
1243
|
if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
|
|
906
1244
|
if (args.noBrowser) cliArgs.push("--no-browser");
|
|
907
1245
|
cliArgs.push(...launchFlagArgs(args));
|
|
908
|
-
const
|
|
1246
|
+
const spawnedAt = Date.now();
|
|
1247
|
+
const spawned = spawnExtensionCli(cliArgs, {
|
|
909
1248
|
projectDir: args.projectPath
|
|
910
1249
|
});
|
|
1250
|
+
const { child, logPath } = spawned;
|
|
911
1251
|
const pid = child.pid;
|
|
912
1252
|
registerSession({
|
|
913
1253
|
pid,
|
|
@@ -916,22 +1256,47 @@ async function start_handler(args) {
|
|
|
916
1256
|
command: "start"
|
|
917
1257
|
});
|
|
918
1258
|
child.on("exit", ()=>removeSession(args.projectPath, browser));
|
|
919
|
-
let earlyOutput = "";
|
|
920
|
-
const collector = (data)=>{
|
|
921
|
-
earlyOutput += data.toString();
|
|
922
|
-
};
|
|
923
|
-
child.stdout?.on("data", collector);
|
|
924
|
-
child.stderr?.on("data", collector);
|
|
925
1259
|
await new Promise((resolve)=>setTimeout(resolve, 5000));
|
|
926
|
-
|
|
927
|
-
child.
|
|
1260
|
+
const earlyOutput = spawned.readOutput();
|
|
1261
|
+
if (null !== child.exitCode || null !== child.signalCode) {
|
|
1262
|
+
const code = child.exitCode;
|
|
1263
|
+
const signal = child.signalCode;
|
|
1264
|
+
return JSON.stringify({
|
|
1265
|
+
ok: false,
|
|
1266
|
+
status: "exited",
|
|
1267
|
+
projectPath: args.projectPath,
|
|
1268
|
+
browser,
|
|
1269
|
+
pid,
|
|
1270
|
+
exitCode: code,
|
|
1271
|
+
signal,
|
|
1272
|
+
error: `The preview server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`,
|
|
1273
|
+
output: earlyOutput.slice(0, 2000),
|
|
1274
|
+
logPath,
|
|
1275
|
+
hint: "Read `output` above for the cause: a failed production build, a port already in use, or a missing browser binary are the common ones. extension_build will surface a build error on its own."
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
|
|
1279
|
+
if (exitStamp) return JSON.stringify({
|
|
1280
|
+
ok: false,
|
|
1281
|
+
status: "browser-exited",
|
|
1282
|
+
projectPath: args.projectPath,
|
|
1283
|
+
browser,
|
|
1284
|
+
pid,
|
|
1285
|
+
...exitStamp,
|
|
1286
|
+
error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
|
|
1287
|
+
output: earlyOutput.slice(0, 2000),
|
|
1288
|
+
logPath,
|
|
1289
|
+
hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
|
|
1290
|
+
});
|
|
928
1291
|
return JSON.stringify({
|
|
1292
|
+
ok: true,
|
|
929
1293
|
pid,
|
|
930
1294
|
browser,
|
|
931
1295
|
projectPath: args.projectPath,
|
|
932
1296
|
status: "started",
|
|
933
1297
|
hint: "Use extension_wait to check when the build and browser launch are complete. When you are done, call extension_stop to shut down the session.",
|
|
934
|
-
earlyOutput: earlyOutput.slice(0, 500)
|
|
1298
|
+
earlyOutput: earlyOutput.slice(0, 500),
|
|
1299
|
+
logPath
|
|
935
1300
|
});
|
|
936
1301
|
}
|
|
937
1302
|
const preview_schema = {
|
|
@@ -992,9 +1357,11 @@ async function preview_handler(args) {
|
|
|
992
1357
|
if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
|
|
993
1358
|
if (args.noBrowser) cliArgs.push("--no-browser");
|
|
994
1359
|
cliArgs.push(...launchFlagArgs(args));
|
|
995
|
-
const
|
|
1360
|
+
const spawnedAt = Date.now();
|
|
1361
|
+
const spawned = spawnExtensionCli(cliArgs, {
|
|
996
1362
|
projectDir: args.projectPath
|
|
997
1363
|
});
|
|
1364
|
+
const { child, logPath } = spawned;
|
|
998
1365
|
const pid = child.pid;
|
|
999
1366
|
registerSession({
|
|
1000
1367
|
pid,
|
|
@@ -1003,74 +1370,49 @@ async function preview_handler(args) {
|
|
|
1003
1370
|
command: "preview"
|
|
1004
1371
|
});
|
|
1005
1372
|
child.on("exit", ()=>removeSession(args.projectPath, browser));
|
|
1373
|
+
await new Promise((resolve)=>setTimeout(resolve, 5000));
|
|
1374
|
+
const earlyOutput = spawned.readOutput();
|
|
1375
|
+
if (null !== child.exitCode || null !== child.signalCode) {
|
|
1376
|
+
const code = child.exitCode;
|
|
1377
|
+
const signal = child.signalCode;
|
|
1378
|
+
return JSON.stringify({
|
|
1379
|
+
ok: false,
|
|
1380
|
+
status: "exited",
|
|
1381
|
+
projectPath: args.projectPath,
|
|
1382
|
+
browser,
|
|
1383
|
+
pid,
|
|
1384
|
+
exitCode: code,
|
|
1385
|
+
signal,
|
|
1386
|
+
error: `The preview process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). Nothing is running.`,
|
|
1387
|
+
output: earlyOutput.slice(0, 2000),
|
|
1388
|
+
logPath,
|
|
1389
|
+
hint: "Read `output` above for the cause: a missing or broken dist/ (run extension_build first), or a missing browser binary are the common ones."
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
|
|
1393
|
+
if (exitStamp) return JSON.stringify({
|
|
1394
|
+
ok: false,
|
|
1395
|
+
status: "browser-exited",
|
|
1396
|
+
projectPath: args.projectPath,
|
|
1397
|
+
browser,
|
|
1398
|
+
pid,
|
|
1399
|
+
...exitStamp,
|
|
1400
|
+
error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
|
|
1401
|
+
output: earlyOutput.slice(0, 2000),
|
|
1402
|
+
logPath,
|
|
1403
|
+
hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
|
|
1404
|
+
});
|
|
1006
1405
|
return JSON.stringify({
|
|
1406
|
+
ok: true,
|
|
1007
1407
|
pid,
|
|
1008
1408
|
browser,
|
|
1009
1409
|
projectPath: args.projectPath,
|
|
1010
1410
|
status: "launched",
|
|
1011
|
-
hint: "Call extension_stop when you are done to close the preview browser."
|
|
1411
|
+
hint: "Call extension_stop when you are done to close the preview browser.",
|
|
1412
|
+
earlyOutput: earlyOutput.slice(0, 500),
|
|
1413
|
+
logPath
|
|
1012
1414
|
});
|
|
1013
1415
|
}
|
|
1014
|
-
function contractSightings(projectPath) {
|
|
1015
|
-
const root = node_path.resolve(projectPath, "dist", "extension-js");
|
|
1016
|
-
let dirs;
|
|
1017
|
-
try {
|
|
1018
|
-
dirs = node_fs.readdirSync(root);
|
|
1019
|
-
} catch {
|
|
1020
|
-
return [];
|
|
1021
|
-
}
|
|
1022
|
-
const sightings = [];
|
|
1023
|
-
for (const dir of dirs){
|
|
1024
|
-
const readyPath = node_path.join(root, dir, "ready.json");
|
|
1025
|
-
try {
|
|
1026
|
-
const stat = node_fs.statSync(readyPath);
|
|
1027
|
-
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
1028
|
-
if (contract?.status !== "ready") continue;
|
|
1029
|
-
sightings.push({
|
|
1030
|
-
browser: dir,
|
|
1031
|
-
mtimeMs: stat.mtimeMs,
|
|
1032
|
-
pid: "number" == typeof contract.pid ? contract.pid : void 0
|
|
1033
|
-
});
|
|
1034
|
-
} catch {}
|
|
1035
|
-
}
|
|
1036
|
-
return sightings;
|
|
1037
|
-
}
|
|
1038
|
-
function pidAlive(pid) {
|
|
1039
|
-
try {
|
|
1040
|
-
process.kill(pid, 0);
|
|
1041
|
-
return true;
|
|
1042
|
-
} catch {
|
|
1043
|
-
return false;
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
function knownSessionBrowsers(projectPath) {
|
|
1047
|
-
const resolved = node_path.resolve(projectPath);
|
|
1048
|
-
const browsers = [];
|
|
1049
|
-
for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
|
|
1050
|
-
for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
|
|
1051
|
-
return Array.from(new Set(browsers));
|
|
1052
|
-
}
|
|
1053
|
-
function resolveSessionBrowser(projectPath, explicit, fallback = "chromium") {
|
|
1054
|
-
if (explicit) return {
|
|
1055
|
-
browser: explicit,
|
|
1056
|
-
source: "explicit"
|
|
1057
|
-
};
|
|
1058
|
-
const resolved = node_path.resolve(projectPath);
|
|
1059
|
-
const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
|
|
1060
|
-
if (mine.length > 0) return {
|
|
1061
|
-
browser: mine[mine.length - 1].browser,
|
|
1062
|
-
source: "session"
|
|
1063
|
-
};
|
|
1064
|
-
const sightings = contractSightings(projectPath).filter((s)=>void 0 === s.pid || pidAlive(s.pid)).sort((a, b)=>b.mtimeMs - a.mtimeMs);
|
|
1065
|
-
if (sightings.length > 0) return {
|
|
1066
|
-
browser: sightings[0].browser,
|
|
1067
|
-
source: "contract"
|
|
1068
|
-
};
|
|
1069
|
-
return {
|
|
1070
|
-
browser: fallback,
|
|
1071
|
-
source: "fallback"
|
|
1072
|
-
};
|
|
1073
|
-
}
|
|
1074
1416
|
const stop_schema = {
|
|
1075
1417
|
name: "extension_stop",
|
|
1076
1418
|
description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
|
|
@@ -1094,12 +1436,11 @@ const stop_schema = {
|
|
|
1094
1436
|
required: []
|
|
1095
1437
|
}
|
|
1096
1438
|
};
|
|
1097
|
-
function
|
|
1098
|
-
const marker = node_path.resolve(projectPath, "dist", `extension-profile-${browser}`);
|
|
1439
|
+
function pgrepPids(pattern) {
|
|
1099
1440
|
try {
|
|
1100
1441
|
const out = execFileSync("pgrep", [
|
|
1101
1442
|
"-f",
|
|
1102
|
-
|
|
1443
|
+
pattern
|
|
1103
1444
|
], {
|
|
1104
1445
|
encoding: "utf8"
|
|
1105
1446
|
});
|
|
@@ -1108,8 +1449,19 @@ function profileProcessPids(projectPath, browser) {
|
|
|
1108
1449
|
return [];
|
|
1109
1450
|
}
|
|
1110
1451
|
}
|
|
1111
|
-
function
|
|
1112
|
-
const
|
|
1452
|
+
function sessionProcessPids(projectPath) {
|
|
1453
|
+
const resolved = node_path.resolve(projectPath);
|
|
1454
|
+
const pids = new Set();
|
|
1455
|
+
for (const marker of [
|
|
1456
|
+
`extension dev ${resolved}`,
|
|
1457
|
+
node_path.join(resolved, "dist")
|
|
1458
|
+
])for (const pid of pgrepPids(marker))pids.add(pid);
|
|
1459
|
+
return [
|
|
1460
|
+
...pids
|
|
1461
|
+
];
|
|
1462
|
+
}
|
|
1463
|
+
function reapSessionProcesses(projectPath) {
|
|
1464
|
+
const pids = sessionProcessPids(projectPath);
|
|
1113
1465
|
for (const pid of pids)try {
|
|
1114
1466
|
process.kill(pid, "SIGKILL");
|
|
1115
1467
|
} catch {}
|
|
@@ -1123,7 +1475,7 @@ function isAlive(pid) {
|
|
|
1123
1475
|
return false;
|
|
1124
1476
|
}
|
|
1125
1477
|
}
|
|
1126
|
-
function
|
|
1478
|
+
function stop_signal(pid, sig) {
|
|
1127
1479
|
try {
|
|
1128
1480
|
process.kill(-pid, sig);
|
|
1129
1481
|
return true;
|
|
@@ -1152,7 +1504,7 @@ async function stopOne(projectPath, browser) {
|
|
|
1152
1504
|
const session = getSession(projectPath, browser);
|
|
1153
1505
|
const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
|
|
1154
1506
|
if (null == pid) {
|
|
1155
|
-
const reaped =
|
|
1507
|
+
const reaped = reapSessionProcesses(projectPath);
|
|
1156
1508
|
return {
|
|
1157
1509
|
projectPath,
|
|
1158
1510
|
browser,
|
|
@@ -1164,22 +1516,22 @@ async function stopOne(projectPath, browser) {
|
|
|
1164
1516
|
}
|
|
1165
1517
|
let detail;
|
|
1166
1518
|
if (isAlive(pid)) {
|
|
1167
|
-
|
|
1519
|
+
stop_signal(pid, "SIGTERM");
|
|
1168
1520
|
await new Promise((resolve)=>setTimeout(resolve, 1500));
|
|
1169
1521
|
if (isAlive(pid)) {
|
|
1170
|
-
|
|
1522
|
+
stop_signal(pid, "SIGKILL");
|
|
1171
1523
|
await new Promise((resolve)=>setTimeout(resolve, 250));
|
|
1172
1524
|
}
|
|
1173
1525
|
detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
|
|
1174
1526
|
} else detail = "Process was already gone; cleaned up session records.";
|
|
1175
|
-
const reaped =
|
|
1527
|
+
const reaped = reapSessionProcesses(projectPath);
|
|
1176
1528
|
removeSession(projectPath, browser);
|
|
1177
1529
|
try {
|
|
1178
1530
|
node_fs.rmSync(readyJsonPath(projectPath, browser), {
|
|
1179
1531
|
force: true
|
|
1180
1532
|
});
|
|
1181
1533
|
} catch {}
|
|
1182
|
-
const survivors =
|
|
1534
|
+
const survivors = sessionProcessPids(projectPath);
|
|
1183
1535
|
const stopped = !isAlive(pid) && 0 === survivors.length;
|
|
1184
1536
|
if (survivors.length) detail += ` Warning: ${survivors.length} browser process(es) still alive after reap (pids ${survivors.join(", ")}).`;
|
|
1185
1537
|
else if (reaped.length) detail += ` Reaped ${reaped.length} browser process(es).`;
|
|
@@ -1410,9 +1762,14 @@ const manifest_validate_schema = {
|
|
|
1410
1762
|
},
|
|
1411
1763
|
default: [
|
|
1412
1764
|
"chrome",
|
|
1413
|
-
"firefox"
|
|
1765
|
+
"firefox",
|
|
1766
|
+
"edge"
|
|
1414
1767
|
],
|
|
1415
1768
|
description: "Browsers to validate against"
|
|
1769
|
+
},
|
|
1770
|
+
browser: {
|
|
1771
|
+
type: "string",
|
|
1772
|
+
description: "Single browser to validate against; alias for browsers:[browser] to match the other tools."
|
|
1416
1773
|
}
|
|
1417
1774
|
},
|
|
1418
1775
|
required: []
|
|
@@ -1450,6 +1807,19 @@ function collectPathRefs(m) {
|
|
|
1450
1807
|
if (sa) push(sa.default_panel);
|
|
1451
1808
|
const cuo = m.chrome_url_overrides;
|
|
1452
1809
|
if (cuo) Object.values(cuo).forEach(push);
|
|
1810
|
+
const dnr = m.declarative_net_request;
|
|
1811
|
+
if (dnr && Array.isArray(dnr.rule_resources)) {
|
|
1812
|
+
for (const r of dnr.rule_resources)if (r && "object" == typeof r) push(r.path);
|
|
1813
|
+
}
|
|
1814
|
+
const storage = m.storage;
|
|
1815
|
+
if (storage) push(storage.managed_schema);
|
|
1816
|
+
push(m.devtools_page);
|
|
1817
|
+
const pa = m.page_action;
|
|
1818
|
+
if (pa) {
|
|
1819
|
+
push(pa.default_popup);
|
|
1820
|
+
if ("string" == typeof pa.default_icon) push(pa.default_icon);
|
|
1821
|
+
else if (pa.default_icon) Object.values(pa.default_icon).forEach(push);
|
|
1822
|
+
}
|
|
1453
1823
|
return refs;
|
|
1454
1824
|
}
|
|
1455
1825
|
function fileResolvesSomewhere(ref, roots) {
|
|
@@ -1473,10 +1843,95 @@ function findManifest(projectPath) {
|
|
|
1473
1843
|
}
|
|
1474
1844
|
return null;
|
|
1475
1845
|
}
|
|
1846
|
+
const API_PERMISSION = {
|
|
1847
|
+
storage: "storage",
|
|
1848
|
+
webNavigation: "webNavigation",
|
|
1849
|
+
history: "history",
|
|
1850
|
+
cookies: "cookies",
|
|
1851
|
+
bookmarks: "bookmarks",
|
|
1852
|
+
alarms: "alarms",
|
|
1853
|
+
contextMenus: "contextMenus",
|
|
1854
|
+
notifications: "notifications",
|
|
1855
|
+
downloads: "downloads",
|
|
1856
|
+
webRequest: "webRequest",
|
|
1857
|
+
tabGroups: "tabGroups",
|
|
1858
|
+
topSites: "topSites",
|
|
1859
|
+
idle: "idle",
|
|
1860
|
+
management: "management",
|
|
1861
|
+
scripting: "scripting",
|
|
1862
|
+
declarativeNetRequest: "declarativeNetRequest",
|
|
1863
|
+
sessions: "sessions",
|
|
1864
|
+
proxy: "proxy",
|
|
1865
|
+
tts: "tts",
|
|
1866
|
+
pageCapture: "pageCapture",
|
|
1867
|
+
desktopCapture: "desktopCapture",
|
|
1868
|
+
debugger: "debugger",
|
|
1869
|
+
geolocation: "geolocation"
|
|
1870
|
+
};
|
|
1871
|
+
const HARD_APIS = new Set([
|
|
1872
|
+
"history",
|
|
1873
|
+
"cookies",
|
|
1874
|
+
"bookmarks",
|
|
1875
|
+
"webNavigation",
|
|
1876
|
+
"downloads",
|
|
1877
|
+
"webRequest",
|
|
1878
|
+
"topSites",
|
|
1879
|
+
"management",
|
|
1880
|
+
"tabGroups",
|
|
1881
|
+
"sessions",
|
|
1882
|
+
"proxy",
|
|
1883
|
+
"debugger",
|
|
1884
|
+
"pageCapture",
|
|
1885
|
+
"desktopCapture"
|
|
1886
|
+
]);
|
|
1887
|
+
function scanApiUsage(roots) {
|
|
1888
|
+
const used = new Set();
|
|
1889
|
+
let filesRead = 0;
|
|
1890
|
+
const walk = (dir, depth)=>{
|
|
1891
|
+
if (depth > 6 || filesRead > 300) return;
|
|
1892
|
+
let entries;
|
|
1893
|
+
try {
|
|
1894
|
+
entries = node_fs.readdirSync(dir, {
|
|
1895
|
+
withFileTypes: true
|
|
1896
|
+
});
|
|
1897
|
+
} catch {
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
for (const e of entries){
|
|
1901
|
+
if ("node_modules" === e.name || "dist" === e.name || e.name.startsWith(".")) continue;
|
|
1902
|
+
const full = node_path.join(dir, e.name);
|
|
1903
|
+
if (e.isDirectory()) {
|
|
1904
|
+
walk(full, depth + 1);
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
if (!/\.(js|mjs|cjs|ts|tsx|jsx|svelte|vue)$/.test(e.name)) continue;
|
|
1908
|
+
if (filesRead++ > 300) return;
|
|
1909
|
+
let src;
|
|
1910
|
+
try {
|
|
1911
|
+
src = node_fs.readFileSync(full, "utf8");
|
|
1912
|
+
} catch {
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
const re = /\b(?:chrome|browser)\.(\w+)/g;
|
|
1916
|
+
let m;
|
|
1917
|
+
while(m = re.exec(src))if (API_PERMISSION[m[1]]) used.add(m[1]);
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
for (const root of new Set(roots))walk(root, 0);
|
|
1921
|
+
return used;
|
|
1922
|
+
}
|
|
1476
1923
|
async function manifest_validate_handler(args) {
|
|
1924
|
+
if (!args.browsers && "string" == typeof args.browser) args = {
|
|
1925
|
+
...args,
|
|
1926
|
+
browsers: [
|
|
1927
|
+
args.browser
|
|
1928
|
+
]
|
|
1929
|
+
};
|
|
1930
|
+
const explicitBrowsers = Array.isArray(args.browsers) && args.browsers.length > 0;
|
|
1477
1931
|
const browsers = args.browsers ?? [
|
|
1478
1932
|
"chrome",
|
|
1479
|
-
"firefox"
|
|
1933
|
+
"firefox",
|
|
1934
|
+
"edge"
|
|
1480
1935
|
];
|
|
1481
1936
|
const result = {
|
|
1482
1937
|
valid: true,
|
|
@@ -1521,19 +1976,46 @@ async function manifest_validate_handler(args) {
|
|
|
1521
1976
|
node_path.dirname(manifestDir)
|
|
1522
1977
|
] : []
|
|
1523
1978
|
];
|
|
1524
|
-
for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.
|
|
1525
|
-
|
|
1979
|
+
for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.errors.push(`Referenced file "${ref}" was not found near the manifest. extension_build fails on this dangling reference.`);
|
|
1980
|
+
const defaultLocale = manifest.default_locale;
|
|
1981
|
+
if ("string" == typeof defaultLocale && defaultLocale) {
|
|
1982
|
+
const hasCatalog = roots.some((root)=>node_fs.existsSync(node_path.resolve(root, "_locales", defaultLocale, "messages.json")));
|
|
1983
|
+
if (!hasCatalog) result.errors.push(`default_locale "${defaultLocale}" is declared but _locales/${defaultLocale}/messages.json was not found. The build fails on this; add the catalog or remove default_locale.`);
|
|
1984
|
+
}
|
|
1985
|
+
const iconMap = chromiumManifest.icons;
|
|
1986
|
+
if (!iconMap || "string" != typeof iconMap["128"]) result.warnings.push('No 128x128 icon declared ("128" key in icons). The Chrome Web Store requires one for a store listing, and Edge Add-ons expects it too.');
|
|
1987
|
+
const effectiveByBrowser = new Map();
|
|
1988
|
+
for (const b of browsers)effectiveByBrowser.set(b, filterKeysForThisBrowser(manifest, b));
|
|
1989
|
+
const declaredPermSet = new Set();
|
|
1990
|
+
for (const view of [
|
|
1991
|
+
chromiumManifest,
|
|
1992
|
+
...effectiveByBrowser.values()
|
|
1993
|
+
])for (const p of [
|
|
1994
|
+
...view.permissions ?? [],
|
|
1995
|
+
...view.optional_permissions ?? []
|
|
1996
|
+
])if ("string" == typeof p) declaredPermSet.add(p);
|
|
1997
|
+
const usedApis = scanApiUsage(roots);
|
|
1998
|
+
for (const api of usedApis){
|
|
1999
|
+
const perm = API_PERMISSION[api];
|
|
2000
|
+
if (declaredPermSet.has(perm)) continue;
|
|
2001
|
+
const base = `Code calls chrome.${api} but "${perm}" is not in permissions`;
|
|
2002
|
+
if (HARD_APIS.has(api)) result.errors.push(`${base}, chrome.${api} is undefined without it and will crash the context at runtime.`);
|
|
2003
|
+
else result.warnings.push(`${base}; it may be undefined at runtime, add "${perm}" if you use it.`);
|
|
2004
|
+
}
|
|
2005
|
+
if (chromiumManifest.manifest_version) {
|
|
2006
|
+
if (2 !== chromiumManifest.manifest_version && 3 !== chromiumManifest.manifest_version) result.errors.push(`manifest_version must be 2 or 3, got ${JSON.stringify(chromiumManifest.manifest_version)}. No browser installs this manifest.`);
|
|
2007
|
+
} else result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
|
|
1526
2008
|
const declaredPerms = [
|
|
1527
2009
|
...chromiumManifest.permissions ?? [],
|
|
1528
2010
|
...chromiumManifest.optional_permissions ?? []
|
|
1529
2011
|
].filter((p)=>"string" == typeof p);
|
|
1530
2012
|
for (const perm of declaredPerms)if (!(perm.includes("://") || perm.includes("*")) && "<all_urls>" !== perm) {
|
|
1531
|
-
if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}"
|
|
2013
|
+
if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}", check for a typo (host/match patterns belong in host_permissions, not permissions).`);
|
|
1532
2014
|
}
|
|
1533
2015
|
for (const browser of browsers){
|
|
1534
2016
|
const isChromium = isChromiumFamily(browser);
|
|
1535
2017
|
const isFirefox = isGeckoFamily(browser);
|
|
1536
|
-
const effective = filterKeysForThisBrowser(manifest, browser);
|
|
2018
|
+
const effective = effectiveByBrowser.get(browser) ?? filterKeysForThisBrowser(manifest, browser);
|
|
1537
2019
|
const issues = [];
|
|
1538
2020
|
if (isChromium) {
|
|
1539
2021
|
const mv = effective.manifest_version;
|
|
@@ -1555,6 +2037,17 @@ async function manifest_validate_handler(args) {
|
|
|
1555
2037
|
if (bg.service_worker && !bg.scripts) issues.push('Background service_worker declared but no firefox:scripts. Firefox uses "scripts" (array) instead of "service_worker".');
|
|
1556
2038
|
}
|
|
1557
2039
|
}
|
|
2040
|
+
const effectivePerms = new Set([
|
|
2041
|
+
...effective.permissions ?? [],
|
|
2042
|
+
...effective.optional_permissions ?? []
|
|
2043
|
+
].filter((p)=>"string" == typeof p));
|
|
2044
|
+
for (const api of usedApis){
|
|
2045
|
+
const perm = API_PERMISSION[api];
|
|
2046
|
+
if (effectivePerms.has(perm)) continue;
|
|
2047
|
+
if (!declaredPermSet.has(perm)) continue;
|
|
2048
|
+
const ns = isFirefox ? "browser" : "chrome";
|
|
2049
|
+
issues.push(`Code calls ${ns}.${api} but the ${browser} build's permissions do not include "${perm}" (it is declared only under another target's prefixed key, e.g. chromium:permissions). This target crashes at runtime.`);
|
|
2050
|
+
}
|
|
1558
2051
|
result.browserSupport[browser] = {
|
|
1559
2052
|
supported: 0 === issues.length,
|
|
1560
2053
|
issues
|
|
@@ -1580,8 +2073,17 @@ async function manifest_validate_handler(args) {
|
|
|
1580
2073
|
surfaces: t.surfaces
|
|
1581
2074
|
}));
|
|
1582
2075
|
} catch {}
|
|
1583
|
-
|
|
1584
|
-
|
|
2076
|
+
for (const [browser, support] of Object.entries(result.browserSupport)){
|
|
2077
|
+
if (support.supported) continue;
|
|
2078
|
+
const issues = support.issues?.length ? support.issues.join("; ") : `${browser} is not supported by this manifest.`;
|
|
2079
|
+
if (explicitBrowsers) result.errors.push(`${browser}: ${issues}`);
|
|
2080
|
+
else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
|
|
2081
|
+
}
|
|
2082
|
+
result.valid = 0 === result.errors.length;
|
|
2083
|
+
return JSON.stringify({
|
|
2084
|
+
...result,
|
|
2085
|
+
buildBlocking: result.errors.length > 0
|
|
2086
|
+
});
|
|
1585
2087
|
}
|
|
1586
2088
|
const inspect_schema = {
|
|
1587
2089
|
name: "extension_inspect",
|
|
@@ -1730,8 +2232,8 @@ async function inspect_handler(args) {
|
|
|
1730
2232
|
const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
|
|
1731
2233
|
const sizeWarnings = [];
|
|
1732
2234
|
for (const f of files)if ("sourcemap" !== f.type) {
|
|
1733
|
-
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
|
|
1734
|
-
else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle
|
|
2235
|
+
if (PROMO_RE.test(f.path)) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) looks like a store-listing promo image shipped inside the extension package, move it out of the bundled sources so it does not inflate the store zip.`);
|
|
2236
|
+
else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle, unusually large for a shipped asset.`);
|
|
1735
2237
|
}
|
|
1736
2238
|
const result = {
|
|
1737
2239
|
browser,
|
|
@@ -1778,6 +2280,7 @@ async function inspect_handler(args) {
|
|
|
1778
2280
|
storeReadiness: {
|
|
1779
2281
|
hasManifest: node_fs.existsSync(manifestPath),
|
|
1780
2282
|
hasIcons: files.some((f)=>"image" === f.type && f.path.includes("icon")),
|
|
2283
|
+
has128Icon: "string" == typeof manifest.icons?.["128"],
|
|
1781
2284
|
noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
|
|
1782
2285
|
noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
|
|
1783
2286
|
under10MB: totalSize < 10485760
|
|
@@ -1799,7 +2302,7 @@ const COMMAND_TIMEOUT_MS = 15000;
|
|
|
1799
2302
|
class CDPConnection {
|
|
1800
2303
|
async connect(wsUrl) {
|
|
1801
2304
|
return new Promise((resolve, reject)=>{
|
|
1802
|
-
this.ws = new
|
|
2305
|
+
this.ws = new ws_0(wsUrl);
|
|
1803
2306
|
this.ws.on("open", ()=>resolve());
|
|
1804
2307
|
this.ws.on("message", (data)=>{
|
|
1805
2308
|
this.handleMessage(data.toString());
|
|
@@ -1868,7 +2371,7 @@ class CDPConnection {
|
|
|
1868
2371
|
}
|
|
1869
2372
|
async sendCommand(method, params = {}, sessionId) {
|
|
1870
2373
|
return new Promise((resolve, reject)=>{
|
|
1871
|
-
if (!this.ws || this.ws.readyState !==
|
|
2374
|
+
if (!this.ws || this.ws.readyState !== ws_0.OPEN) return reject(new Error("CDP WebSocket is not connected"));
|
|
1872
2375
|
const id = ++this.messageId;
|
|
1873
2376
|
const message = {
|
|
1874
2377
|
id,
|
|
@@ -2259,7 +2762,7 @@ const source_inspect_schema = {
|
|
|
2259
2762
|
items: {
|
|
2260
2763
|
type: "string"
|
|
2261
2764
|
},
|
|
2262
|
-
description: "CSS selectors to query
|
|
2765
|
+
description: "CSS selectors to query, returns element counts and samples for each"
|
|
2263
2766
|
},
|
|
2264
2767
|
include: {
|
|
2265
2768
|
type: "array",
|
|
@@ -2311,7 +2814,7 @@ async function source_inspect_handler(args) {
|
|
|
2311
2814
|
const maxBytes = args.maxBytes ?? 262144;
|
|
2312
2815
|
if (!isChromiumFamily(browser)) return JSON.stringify({
|
|
2313
2816
|
error: `Source inspection reads the live DOM over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. This is a capability limit, not a missing session.`,
|
|
2314
|
-
hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval (
|
|
2817
|
+
hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval / extension_dom_inspect (content/page, an open surface like popup/options/sidebar, or an override page like newtab) which work against Firefox over the control channel. To get CDP DOM inspection, run a Chromium-family dev session (extension_dev with browser: "chrome") in parallel.`
|
|
2315
2818
|
});
|
|
2316
2819
|
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
2317
2820
|
if (!resolved) return JSON.stringify({
|
|
@@ -2393,7 +2896,11 @@ async function source_inspect_handler(args) {
|
|
|
2393
2896
|
if (include.has("dom_snapshot")) result.domSnapshot = await cdp.getDomSnapshot(sessionId);
|
|
2394
2897
|
if (include.has("console")) result.console = cdp.getConsoleSummary();
|
|
2395
2898
|
if (include.has("extension_roots")) result.extensionRoots = await cdp.getExtensionRootMeta(sessionId);
|
|
2396
|
-
if (args.probe?.length)
|
|
2899
|
+
if (args.probe?.length) {
|
|
2900
|
+
result.probes = await cdp.probeSelectors(sessionId, args.probe);
|
|
2901
|
+
const jsLooking = args.probe.filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
|
|
2902
|
+
if (jsLooking.length) result.probeWarning = `Probes are CSS selectors run through querySelectorAll against the live page, NOT JavaScript expressions. ${jsLooking.map((s)=>`"${s}"`).join(", ")} parsed as selectors and will match nothing. To evaluate JS, use extension_eval.`;
|
|
2903
|
+
}
|
|
2397
2904
|
if (args.deepDom) {
|
|
2398
2905
|
const closed = await cdp.getClosedShadowRoots(sessionId, maxBytes > 0 ? maxBytes : 65536);
|
|
2399
2906
|
result.closedShadowRoots = closed;
|
|
@@ -2412,7 +2919,7 @@ async function source_inspect_handler(args) {
|
|
|
2412
2919
|
}
|
|
2413
2920
|
const list_extensions_schema = {
|
|
2414
2921
|
name: "extension_list_extensions",
|
|
2415
|
-
description: "List the extensions with a live context in the running dev browser via Chrome DevTools Protocol. Returns each extension's id, name, version, and live contexts (service worker, page). Identity is read read-only via the Extensions domain
|
|
2922
|
+
description: "List the extensions with a live context in the running dev browser via Chrome DevTools Protocol. Returns each extension's id, name, version, and live contexts (service worker, page). Identity is read read-only via the Extensions domain, other extensions' contexts are never attached to or evaluated in. A dormant MV3 service worker with no open page may be absent until it wakes. Chromium only (Firefox uses RDP, not yet supported). Requires an active dev or start session.",
|
|
2416
2923
|
inputSchema: {
|
|
2417
2924
|
type: "object",
|
|
2418
2925
|
properties: {
|
|
@@ -2667,10 +3174,28 @@ function capRecent(events, limit) {
|
|
|
2667
3174
|
truncated: true
|
|
2668
3175
|
};
|
|
2669
3176
|
}
|
|
2670
|
-
function
|
|
3177
|
+
function emptyReason(projectPath, browser) {
|
|
3178
|
+
let contract;
|
|
3179
|
+
try {
|
|
3180
|
+
contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
|
|
3181
|
+
} catch {
|
|
3182
|
+
return "No ready.json for this project/browser: no dev session has produced a build here, so there is nothing to log. Start one with extension_dev.";
|
|
3183
|
+
}
|
|
3184
|
+
if ("error" === contract.status) {
|
|
3185
|
+
const errs = contract.errors;
|
|
3186
|
+
return `The dev session recorded status:"error"${errs?.length ? ` (${errs.join("; ")})` : ""}, so the extension never ran. There are no logs because there was no working build, not because your code is silent.`;
|
|
3187
|
+
}
|
|
3188
|
+
if ("number" == typeof contract.pid) try {
|
|
3189
|
+
process.kill(contract.pid, 0);
|
|
3190
|
+
} catch {
|
|
3191
|
+
return `ready.json reports ready but its dev-server pid ${contract.pid} is dead: the session exited. Logs stop at the moment it died. Restart with extension_dev; extension_doctor will confirm.`;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
function summarize(events, source, browser, runId, limit, dropped, projectPath, staleNote) {
|
|
2671
3195
|
const matched = events.length;
|
|
2672
3196
|
const { events: out, truncated } = capRecent(events, limit);
|
|
2673
3197
|
const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
|
|
3198
|
+
const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
|
|
2674
3199
|
return JSON.stringify({
|
|
2675
3200
|
ok: true,
|
|
2676
3201
|
source,
|
|
@@ -2681,9 +3206,30 @@ function summarize(events, source, browser, runId, limit, dropped) {
|
|
|
2681
3206
|
truncated,
|
|
2682
3207
|
dropped: dropped || void 0,
|
|
2683
3208
|
nextSince: lastSeq >= 0 ? lastSeq : void 0,
|
|
3209
|
+
...reason ? {
|
|
3210
|
+
emptyReason: reason
|
|
3211
|
+
} : {},
|
|
3212
|
+
...staleNote && matched > 0 ? {
|
|
3213
|
+
stale: true,
|
|
3214
|
+
warning: staleNote
|
|
3215
|
+
} : {},
|
|
2684
3216
|
events: out
|
|
2685
3217
|
});
|
|
2686
3218
|
}
|
|
3219
|
+
function staleFileNote(projectPath, browser, eventsRunId) {
|
|
3220
|
+
let contract;
|
|
3221
|
+
try {
|
|
3222
|
+
contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
|
|
3223
|
+
} catch {
|
|
3224
|
+
return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
|
|
3225
|
+
}
|
|
3226
|
+
if ("number" == typeof contract.pid) try {
|
|
3227
|
+
process.kill(contract.pid, 0);
|
|
3228
|
+
} catch {
|
|
3229
|
+
return `These events are from a PAST run: the session that wrote them (pid ${contract.pid}) is dead. Nothing current is producing logs; do not read these as live output.`;
|
|
3230
|
+
}
|
|
3231
|
+
if (eventsRunId && contract.runId && String(contract.runId) !== eventsRunId) return `These events carry runId ${eventsRunId} but the current session is run ${String(contract.runId)}, which has written nothing yet. Do not read these as the current run's output.`;
|
|
3232
|
+
}
|
|
2687
3233
|
async function readFromFile(args, browser, limit) {
|
|
2688
3234
|
const file = logsFilePath(args.projectPath, browser);
|
|
2689
3235
|
if (!node_fs.existsSync(file)) return JSON.stringify({
|
|
@@ -2707,13 +3253,13 @@ async function readFromFile(args, browser, limit) {
|
|
|
2707
3253
|
}
|
|
2708
3254
|
if (matches(event)) events.push(event);
|
|
2709
3255
|
}
|
|
2710
|
-
return summarize(events, "file", browser, runId, limit, 0);
|
|
3256
|
+
return summarize(events, "file", browser, runId, limit, 0, args.projectPath, staleFileNote(args.projectPath, browser, runId));
|
|
2711
3257
|
}
|
|
2712
3258
|
async function readFromStream(args, browser, limit) {
|
|
2713
3259
|
const ready = readReadyContract(args.projectPath, browser);
|
|
2714
3260
|
if (!ready) {
|
|
2715
3261
|
const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
|
|
2716
|
-
const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}
|
|
3262
|
+
const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
|
|
2717
3263
|
return JSON.stringify({
|
|
2718
3264
|
error: `No active control channel found for ${browser}.`,
|
|
2719
3265
|
hint: `${retarget} extension_dev (browser: ${browser}) and wait for it to be ready, then retry. For past logs without a live channel, call without follow.`
|
|
@@ -2729,7 +3275,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
2729
3275
|
const url = `ws://127.0.0.1:${ready.controlPort}${CONTROL_WS_PATH}`;
|
|
2730
3276
|
let socket;
|
|
2731
3277
|
try {
|
|
2732
|
-
socket = new
|
|
3278
|
+
socket = new ws_0(url);
|
|
2733
3279
|
} catch (err) {
|
|
2734
3280
|
resolve(JSON.stringify({
|
|
2735
3281
|
error: `Could not open control channel at ${url}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -2743,7 +3289,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
2743
3289
|
try {
|
|
2744
3290
|
socket.close();
|
|
2745
3291
|
} catch {}
|
|
2746
|
-
resolve(summarize(events, "stream", browser, runId, limit, dropped));
|
|
3292
|
+
resolve(summarize(events, "stream", browser, runId, limit, dropped, args.projectPath));
|
|
2747
3293
|
};
|
|
2748
3294
|
const timer = setTimeout(finish, followMs);
|
|
2749
3295
|
socket.on("open", ()=>{
|
|
@@ -2787,13 +3333,16 @@ async function logs_handler(args) {
|
|
|
2787
3333
|
return readFromFile(args, browser, limit);
|
|
2788
3334
|
}
|
|
2789
3335
|
function toMcpSpeak(text) {
|
|
2790
|
-
return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/--browser[= ]([\w-]+)/g, 'browser: "$1"').replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev");
|
|
3336
|
+
return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/--context[= ]([\w-]+)/g, 'context: "$1"').replace(/--tab[= ](\d+)/g, "tab: $1").replace(/--url[= ](\S+)/g, 'url: "$1"').replace(/--browser[= ]([\w-]+)/g, 'browser: "$1"').replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev");
|
|
2791
3337
|
}
|
|
2792
3338
|
function withSessionContext(message, projectPath) {
|
|
2793
|
-
|
|
3339
|
+
const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
|
|
3340
|
+
if (!isControlError) return message;
|
|
3341
|
+
const dead = deadReadySession(projectPath);
|
|
3342
|
+
if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
|
|
2794
3343
|
const running = knownSessionBrowsers(projectPath);
|
|
2795
3344
|
if (0 === running.length) return message;
|
|
2796
|
-
return `${message} Active session browser(s) for this project: ${running.join(", ")}
|
|
3345
|
+
return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
|
|
2797
3346
|
}
|
|
2798
3347
|
function translateFrame(frame, projectPath) {
|
|
2799
3348
|
if (!frame || false !== frame.ok) return frame;
|
|
@@ -2837,7 +3386,7 @@ function commonFlags(args) {
|
|
|
2837
3386
|
}
|
|
2838
3387
|
const eval_schema = {
|
|
2839
3388
|
name: "extension_eval",
|
|
2840
|
-
description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads).
|
|
3389
|
+
description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Targeting for context content/page: pass `url` to pick the matching tab, or omit both `url` and `tab` to use the ACTIVE tab; a numeric `tab` id is only needed to disambiguate. Extension surfaces (popup/options/sidebar/devtools) and override pages (newtab/history/bookmarks) evaluate over the in-bundle relay and need NO tab id; the surface must be OPEN (extension_open first; a closed surface returns an explicit error). Chromium caveat: eval in the MV3 background/service_worker is blocked by CSP (use an MV2/Firefox build for that context). Use extension_dom_inspect with listTabs: true to enumerate {tabId,url,title}. Wraps `extension eval`.",
|
|
2841
3390
|
inputSchema: {
|
|
2842
3391
|
type: "object",
|
|
2843
3392
|
properties: {
|
|
@@ -2857,6 +3406,9 @@ const eval_schema = {
|
|
|
2857
3406
|
"options",
|
|
2858
3407
|
"sidebar",
|
|
2859
3408
|
"devtools",
|
|
3409
|
+
"newtab",
|
|
3410
|
+
"history",
|
|
3411
|
+
"bookmarks",
|
|
2860
3412
|
"content",
|
|
2861
3413
|
"page"
|
|
2862
3414
|
],
|
|
@@ -2865,11 +3417,11 @@ const eval_schema = {
|
|
|
2865
3417
|
},
|
|
2866
3418
|
url: {
|
|
2867
3419
|
type: "string",
|
|
2868
|
-
description: "For content/page:
|
|
3420
|
+
description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`. You do not need a numeric id."
|
|
2869
3421
|
},
|
|
2870
3422
|
tab: {
|
|
2871
3423
|
type: "number",
|
|
2872
|
-
description: "Numeric chrome.tabs id
|
|
3424
|
+
description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
|
|
2873
3425
|
},
|
|
2874
3426
|
browser: {
|
|
2875
3427
|
type: "string",
|
|
@@ -2888,7 +3440,7 @@ const eval_schema = {
|
|
|
2888
3440
|
};
|
|
2889
3441
|
async function eval_handler(args) {
|
|
2890
3442
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
2891
|
-
|
|
3443
|
+
const raw = await runActVerb([
|
|
2892
3444
|
"eval",
|
|
2893
3445
|
args.expression,
|
|
2894
3446
|
args.projectPath,
|
|
@@ -2897,6 +3449,14 @@ async function eval_handler(args) {
|
|
|
2897
3449
|
browser
|
|
2898
3450
|
})
|
|
2899
3451
|
], args.projectPath, args.timeout);
|
|
3452
|
+
if ("content" === args.context) try {
|
|
3453
|
+
const parsed = JSON.parse(raw);
|
|
3454
|
+
if (parsed?.ok === true && (null === parsed.value || void 0 === parsed.value)) {
|
|
3455
|
+
parsed.note = "On Extension.js >= 4.0.14 a failed injection errors explicitly, so this null is the expression's real result. On OLDER engines (bug 61) it could mean the injection never ran; if this result looks wrong, check the engine version with extension_doctor, or verify with extension_logs or context:'page'.";
|
|
3456
|
+
return JSON.stringify(parsed);
|
|
3457
|
+
}
|
|
3458
|
+
} catch {}
|
|
3459
|
+
return raw;
|
|
2900
3460
|
}
|
|
2901
3461
|
const storage_schema = {
|
|
2902
3462
|
name: "extension_storage",
|
|
@@ -2976,6 +3536,13 @@ async function storage_handler(args) {
|
|
|
2976
3536
|
message: "storage set requires a value"
|
|
2977
3537
|
}
|
|
2978
3538
|
});
|
|
3539
|
+
if (void 0 === args.key) return JSON.stringify({
|
|
3540
|
+
ok: false,
|
|
3541
|
+
error: {
|
|
3542
|
+
name: "BadRequest",
|
|
3543
|
+
message: 'storage set requires `key` (string) and `value` args, one key per call. There is no bulk-object set: to seed {a: 1, b: 2}, call once with key: "a" and once with key: "b".'
|
|
3544
|
+
}
|
|
3545
|
+
});
|
|
2979
3546
|
cli.push("--value", JSON.stringify(args.value));
|
|
2980
3547
|
}
|
|
2981
3548
|
if (args.context) cli.push("--context", args.context);
|
|
@@ -3031,9 +3598,266 @@ async function reload_handler(args) {
|
|
|
3031
3598
|
})
|
|
3032
3599
|
], args.projectPath, args.timeout);
|
|
3033
3600
|
}
|
|
3601
|
+
async function pollForTarget(port, url, budgetMs) {
|
|
3602
|
+
const deadline = Date.now() + budgetMs;
|
|
3603
|
+
const wanted = url.replace(/#.*$/, "");
|
|
3604
|
+
for(;;){
|
|
3605
|
+
try {
|
|
3606
|
+
const targets = await CDPClient.discoverTargets(port);
|
|
3607
|
+
for (const t of targets){
|
|
3608
|
+
const tUrl = String(t.url ?? "");
|
|
3609
|
+
if ("page" === t.type) {
|
|
3610
|
+
if (tUrl === wanted || tUrl.startsWith(wanted)) return {
|
|
3611
|
+
id: String(t.id),
|
|
3612
|
+
url: tUrl,
|
|
3613
|
+
title: "string" == typeof t.title ? t.title : void 0
|
|
3614
|
+
};
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
} catch {}
|
|
3618
|
+
if (Date.now() >= deadline) return null;
|
|
3619
|
+
await new Promise((r)=>setTimeout(r, 250));
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
async function navigateToUrl(projectPath, browser, url) {
|
|
3623
|
+
if (!isChromiumFamily(browser)) return JSON.stringify({
|
|
3624
|
+
ok: false,
|
|
3625
|
+
error: {
|
|
3626
|
+
name: "Unsupported",
|
|
3627
|
+
message: `URL navigation drives a tab over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. On Firefox, drive the page via extension_eval (context: "page"/"content") or read extension_logs.`
|
|
3628
|
+
}
|
|
3629
|
+
});
|
|
3630
|
+
const resolved = await resolveCdpPort(projectPath, browser);
|
|
3631
|
+
if (!resolved) return JSON.stringify({
|
|
3632
|
+
ok: false,
|
|
3633
|
+
error: {
|
|
3634
|
+
name: "NoSession",
|
|
3635
|
+
message: `No active dev session / CDP port for ${browser}. Start extension_dev and extension_wait for ready. ${CDP_PORT_MISSING_HINT}`
|
|
3636
|
+
}
|
|
3637
|
+
});
|
|
3638
|
+
const cdp = new CDPClient();
|
|
3639
|
+
try {
|
|
3640
|
+
const targets = await CDPClient.discoverTargets(resolved.port);
|
|
3641
|
+
const pageTargets = targets.filter((t)=>"page" === t.type && !String(t.url || "").startsWith("devtools://"));
|
|
3642
|
+
if (0 === pageTargets.length) return JSON.stringify({
|
|
3643
|
+
ok: false,
|
|
3644
|
+
error: {
|
|
3645
|
+
name: "NoTab",
|
|
3646
|
+
message: "The dev browser has no open page tab to navigate. Trigger one (e.g. extension_open surface, or open the extension) first."
|
|
3647
|
+
}
|
|
3648
|
+
});
|
|
3649
|
+
const target = pageTargets[0];
|
|
3650
|
+
const browserWsUrl = await CDPClient.discoverBrowserWsUrl(resolved.port);
|
|
3651
|
+
await cdp.connect(browserWsUrl);
|
|
3652
|
+
const sessionId = await cdp.attachToTarget(String(target.id));
|
|
3653
|
+
await cdp.navigate(sessionId, url);
|
|
3654
|
+
const settled = await pollForTarget(resolved.port, url, 6000);
|
|
3655
|
+
if (!settled) return JSON.stringify({
|
|
3656
|
+
ok: false,
|
|
3657
|
+
error: {
|
|
3658
|
+
name: "NavigateFailed",
|
|
3659
|
+
message: `Navigation to ${url} did not produce a live page target. The URL may not exist in the extension bundle, or Chrome refused the navigation.`
|
|
3660
|
+
},
|
|
3661
|
+
hint: "Confirm the path exists in the built dist (extension_build / extension_inspect list entrypoints). For an extension page, the path must match the BUILT manifest, which may differ from your source layout."
|
|
3662
|
+
});
|
|
3663
|
+
return JSON.stringify({
|
|
3664
|
+
ok: true,
|
|
3665
|
+
navigated: url,
|
|
3666
|
+
target: {
|
|
3667
|
+
targetId: settled.id,
|
|
3668
|
+
title: settled.title,
|
|
3669
|
+
url: settled.url
|
|
3670
|
+
},
|
|
3671
|
+
hint: "Inspect it with extension_dom_inspect or extension_source_inspect using url (context: 'page'), they resolve the tab themselves. `target.targetId` is a CDP target id, NOT a chrome.tabs id: do not pass it as `tab`. If you need a numeric tab id, call extension_dom_inspect with listTabs: true."
|
|
3672
|
+
});
|
|
3673
|
+
} catch (e) {
|
|
3674
|
+
return JSON.stringify({
|
|
3675
|
+
ok: false,
|
|
3676
|
+
error: {
|
|
3677
|
+
name: "NavigateError",
|
|
3678
|
+
message: e instanceof Error ? e.message : String(e)
|
|
3679
|
+
}
|
|
3680
|
+
});
|
|
3681
|
+
} finally{
|
|
3682
|
+
try {
|
|
3683
|
+
cdp.disconnect();
|
|
3684
|
+
} catch {}
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
function unpackedExtensionId(distPath) {
|
|
3688
|
+
const digest = node_crypto.createHash("sha256").update(distPath).digest();
|
|
3689
|
+
let id = "";
|
|
3690
|
+
for(let i = 0; i < 16; i++){
|
|
3691
|
+
id += String.fromCharCode(97 + (digest[i] >> 4));
|
|
3692
|
+
id += String.fromCharCode(97 + (0x0f & digest[i]));
|
|
3693
|
+
}
|
|
3694
|
+
return id;
|
|
3695
|
+
}
|
|
3696
|
+
async function resolveExtensionId(projectPath, browser) {
|
|
3697
|
+
const distPath = readDistPath(projectPath, browser);
|
|
3698
|
+
const computed = distPath ? unpackedExtensionId(distPath) : null;
|
|
3699
|
+
const resolved = await resolveCdpPort(projectPath, browser);
|
|
3700
|
+
if (!resolved) return computed;
|
|
3701
|
+
const ids = new Set();
|
|
3702
|
+
try {
|
|
3703
|
+
for (const t of (await CDPClient.discoverTargets(resolved.port))){
|
|
3704
|
+
const url = String(t.url ?? "");
|
|
3705
|
+
if (!url.startsWith("chrome-extension://")) continue;
|
|
3706
|
+
const id = url.slice(19).split("/")[0];
|
|
3707
|
+
if (id) ids.add(id);
|
|
3708
|
+
}
|
|
3709
|
+
} catch {}
|
|
3710
|
+
if (computed && ids.has(computed)) return computed;
|
|
3711
|
+
if (computed) return computed;
|
|
3712
|
+
return 1 === ids.size ? [
|
|
3713
|
+
...ids
|
|
3714
|
+
][0] : null;
|
|
3715
|
+
}
|
|
3716
|
+
function declaredCommands(projectPath, browser) {
|
|
3717
|
+
const candidates = [
|
|
3718
|
+
node_path.join(projectPath, "dist", browser, "manifest.json"),
|
|
3719
|
+
node_path.join(projectPath, "dist", "manifest.json"),
|
|
3720
|
+
node_path.join(projectPath, "src", "manifest.json"),
|
|
3721
|
+
node_path.join(projectPath, "manifest.json")
|
|
3722
|
+
];
|
|
3723
|
+
for (const file of candidates)try {
|
|
3724
|
+
const manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3725
|
+
const commands = manifest?.commands;
|
|
3726
|
+
if (commands && "object" == typeof commands) return Object.keys(commands);
|
|
3727
|
+
return [];
|
|
3728
|
+
} catch {
|
|
3729
|
+
continue;
|
|
3730
|
+
}
|
|
3731
|
+
return null;
|
|
3732
|
+
}
|
|
3733
|
+
function readDistPath(projectPath, browser) {
|
|
3734
|
+
try {
|
|
3735
|
+
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
3736
|
+
const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3737
|
+
return "string" == typeof contract?.distPath ? contract.distPath : null;
|
|
3738
|
+
} catch {
|
|
3739
|
+
return null;
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
function surfaceDocument(projectPath, browser, surface) {
|
|
3743
|
+
const candidates = [
|
|
3744
|
+
node_path.join(projectPath, "dist", browser, "manifest.json"),
|
|
3745
|
+
node_path.join(projectPath, "dist", "manifest.json"),
|
|
3746
|
+
node_path.join(projectPath, "src", "manifest.json"),
|
|
3747
|
+
node_path.join(projectPath, "manifest.json")
|
|
3748
|
+
];
|
|
3749
|
+
for (const file of candidates){
|
|
3750
|
+
let manifest;
|
|
3751
|
+
try {
|
|
3752
|
+
manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
3753
|
+
} catch {
|
|
3754
|
+
continue;
|
|
3755
|
+
}
|
|
3756
|
+
const action = manifest.action ?? manifest.browser_action;
|
|
3757
|
+
const ref = "popup" === surface || "action" === surface ? action?.default_popup : "options" === surface ? manifest.options_ui?.page ?? manifest.options_page : "sidebar" === surface ? manifest.side_panel?.default_path ?? manifest.sidebar_action?.default_panel : "newtab" === surface || "history" === surface || "bookmarks" === surface ? manifest.chrome_url_overrides?.[surface] : null;
|
|
3758
|
+
if ("string" == typeof ref && ref) return ref.replace(/^\.?\//, "");
|
|
3759
|
+
}
|
|
3760
|
+
return null;
|
|
3761
|
+
}
|
|
3762
|
+
const POPUP_MIN = 25;
|
|
3763
|
+
const POPUP_MAX_WIDTH = 800;
|
|
3764
|
+
const POPUP_MAX_HEIGHT = 600;
|
|
3765
|
+
function clampPopupBounds(width, height) {
|
|
3766
|
+
const w = Math.min(Math.max(Math.ceil(width), POPUP_MIN), POPUP_MAX_WIDTH);
|
|
3767
|
+
const h = Math.min(Math.max(Math.ceil(height), POPUP_MIN), POPUP_MAX_HEIGHT);
|
|
3768
|
+
return {
|
|
3769
|
+
width: w,
|
|
3770
|
+
height: h,
|
|
3771
|
+
clamped: w !== Math.ceil(width) || h !== Math.ceil(height)
|
|
3772
|
+
};
|
|
3773
|
+
}
|
|
3774
|
+
async function applyPopupBounds(projectPath, browser, targetId) {
|
|
3775
|
+
const resolved = await resolveCdpPort(projectPath, browser);
|
|
3776
|
+
if (!resolved) return null;
|
|
3777
|
+
const cdp = new CDPClient();
|
|
3778
|
+
try {
|
|
3779
|
+
const ws = await CDPClient.discoverBrowserWsUrl(resolved.port);
|
|
3780
|
+
await cdp.connect(ws);
|
|
3781
|
+
const sessionId = await cdp.attachToTarget(targetId);
|
|
3782
|
+
const measured = await cdp.evaluate(sessionId, `(() => {
|
|
3783
|
+
const de = document.documentElement, b = document.body;
|
|
3784
|
+
if (!de || !b) return null;
|
|
3785
|
+
const prev = de.style.width;
|
|
3786
|
+
de.style.width = "fit-content";
|
|
3787
|
+
const w = Math.max(de.getBoundingClientRect().width, b.getBoundingClientRect().width);
|
|
3788
|
+
const h = Math.max(de.getBoundingClientRect().height, b.getBoundingClientRect().height, b.scrollHeight);
|
|
3789
|
+
de.style.width = prev;
|
|
3790
|
+
return { w: Math.ceil(w), h: Math.ceil(h) };
|
|
3791
|
+
})()`);
|
|
3792
|
+
if (!measured || "number" != typeof measured.w || "number" != typeof measured.h || measured.w <= 0 || measured.h <= 0) return null;
|
|
3793
|
+
const bounds = clampPopupBounds(measured.w, measured.h);
|
|
3794
|
+
const win = await cdp.sendCommand("Browser.getWindowForTarget", {
|
|
3795
|
+
targetId
|
|
3796
|
+
});
|
|
3797
|
+
if ("number" != typeof win?.windowId) return null;
|
|
3798
|
+
await cdp.sendCommand("Browser.setWindowBounds", {
|
|
3799
|
+
windowId: win.windowId,
|
|
3800
|
+
bounds: {
|
|
3801
|
+
width: bounds.width,
|
|
3802
|
+
height: bounds.height
|
|
3803
|
+
}
|
|
3804
|
+
});
|
|
3805
|
+
const after = await cdp.sendCommand("Browser.getWindowBounds", {
|
|
3806
|
+
windowId: win.windowId
|
|
3807
|
+
});
|
|
3808
|
+
if (after?.bounds?.width !== bounds.width || after?.bounds?.height !== bounds.height) return null;
|
|
3809
|
+
return bounds;
|
|
3810
|
+
} catch {
|
|
3811
|
+
return null;
|
|
3812
|
+
} finally{
|
|
3813
|
+
try {
|
|
3814
|
+
cdp.disconnect();
|
|
3815
|
+
} catch {}
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
async function openSurfaceAsTab(projectPath, browser, surface) {
|
|
3819
|
+
const doc = surfaceDocument(projectPath, browser, surface);
|
|
3820
|
+
if (!doc) return JSON.stringify({
|
|
3821
|
+
ok: false,
|
|
3822
|
+
error: {
|
|
3823
|
+
name: "NoSurfaceDocument",
|
|
3824
|
+
message: `The manifest declares no document for surface "${surface}", so there is no page to render as a tab.`
|
|
3825
|
+
},
|
|
3826
|
+
hint: "Check the manifest: popup needs action.default_popup, options needs options_ui.page or options_page, sidebar needs side_panel.default_path."
|
|
3827
|
+
});
|
|
3828
|
+
const id = await resolveExtensionId(projectPath, browser);
|
|
3829
|
+
if (!id) return JSON.stringify({
|
|
3830
|
+
ok: false,
|
|
3831
|
+
error: {
|
|
3832
|
+
name: "NoExtensionId",
|
|
3833
|
+
message: "Could not resolve the extension id from the live session's CDP targets."
|
|
3834
|
+
},
|
|
3835
|
+
hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
|
|
3836
|
+
});
|
|
3837
|
+
const url = `chrome-extension://${id}/${doc}`;
|
|
3838
|
+
const raw = await navigateToUrl(projectPath, browser, url);
|
|
3839
|
+
try {
|
|
3840
|
+
const parsed = JSON.parse(raw);
|
|
3841
|
+
if (parsed?.ok) {
|
|
3842
|
+
parsed.renderedAsTab = {
|
|
3843
|
+
surface,
|
|
3844
|
+
document: doc,
|
|
3845
|
+
extensionId: id
|
|
3846
|
+
};
|
|
3847
|
+
let popupBounds = null;
|
|
3848
|
+
if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
|
|
3849
|
+
popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
|
|
3850
|
+
if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
|
|
3851
|
+
}
|
|
3852
|
+
parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_inspect context: '${surface}' (include: ['html']), or extension_source_inspect with this url. ` + "Do NOT pass this chrome-extension:// url to extension_dom_inspect or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
|
|
3853
|
+
return JSON.stringify(parsed);
|
|
3854
|
+
}
|
|
3855
|
+
} catch {}
|
|
3856
|
+
return raw;
|
|
3857
|
+
}
|
|
3034
3858
|
const open_schema = {
|
|
3035
3859
|
name: "extension_open",
|
|
3036
|
-
description: "Open an extension surface or replay an event in a running session. 'popup'/'options'/'sidebar' open UI surfaces. 'action' triggers the toolbar action: opens the action's popup, or (no popup) replays chrome.action.onClicked. 'command' replays a chrome.commands.onCommand keyboard shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result includes gesture:false and a warning when activeTab is declared). Requires the dev session to be started with allowControl: true (extension_dev). Wraps `extension open`.",
|
|
3860
|
+
description: "Open an extension surface or replay an event in a running session. 'popup'/'options'/'sidebar' open UI surfaces; 'newtab'/'history'/'bookmarks' open the extension's chrome_url_overrides page in a tab. 'action' triggers the toolbar action: opens the action's popup, or (no popup) replays chrome.action.onClicked. 'command' replays a chrome.commands.onCommand keyboard shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result includes gesture:false and a warning when activeTab is declared). Requires the dev session to be started with allowControl: true (extension_dev). Wraps `extension open`.",
|
|
3037
3861
|
inputSchema: {
|
|
3038
3862
|
type: "object",
|
|
3039
3863
|
properties: {
|
|
@@ -3047,15 +3871,27 @@ const open_schema = {
|
|
|
3047
3871
|
"popup",
|
|
3048
3872
|
"options",
|
|
3049
3873
|
"sidebar",
|
|
3874
|
+
"newtab",
|
|
3875
|
+
"history",
|
|
3876
|
+
"bookmarks",
|
|
3050
3877
|
"action",
|
|
3051
3878
|
"command"
|
|
3052
3879
|
],
|
|
3053
|
-
description: "Which surface to open or event to replay. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
|
|
3880
|
+
description: "Which surface to open or event to replay. 'newtab'/'history'/'bookmarks' open the matching chrome_url_overrides page. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
|
|
3054
3881
|
},
|
|
3055
3882
|
name: {
|
|
3056
3883
|
type: "string",
|
|
3057
3884
|
description: "For surface 'command': the chrome.commands name to trigger."
|
|
3058
3885
|
},
|
|
3886
|
+
url: {
|
|
3887
|
+
type: "string",
|
|
3888
|
+
description: "Navigate a real tab to this URL (Chromium only, via CDP) instead of opening a surface. Use for content-script/webNavigation test pages, or the popup as a page: chrome-extension://<id>/popup.html. Alternative to `surface`."
|
|
3889
|
+
},
|
|
3890
|
+
asTab: {
|
|
3891
|
+
type: "boolean",
|
|
3892
|
+
default: false,
|
|
3893
|
+
description: "For surface popup/options/sidebar: render the surface's document in a real tab (chrome-extension://<id>/<doc>) instead of opening a real popup window. This is how you inspect a surface HEADLESSLY, where no window exists to host a popup. Applied automatically as a fallback when a headless session refuses to open the surface. Same page and APIs, but no popup sizing and window.close() closes the tab."
|
|
3894
|
+
},
|
|
3059
3895
|
browser: {
|
|
3060
3896
|
type: "string",
|
|
3061
3897
|
description: "Browser session to target. Defaults to the active dev session's browser for this project."
|
|
@@ -3066,13 +3902,41 @@ const open_schema = {
|
|
|
3066
3902
|
}
|
|
3067
3903
|
},
|
|
3068
3904
|
required: [
|
|
3069
|
-
"projectPath"
|
|
3070
|
-
"surface"
|
|
3905
|
+
"projectPath"
|
|
3071
3906
|
]
|
|
3072
3907
|
}
|
|
3073
3908
|
};
|
|
3074
3909
|
async function open_handler(args) {
|
|
3075
3910
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
3911
|
+
if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
|
|
3912
|
+
const AS_TAB_SURFACES = [
|
|
3913
|
+
"popup",
|
|
3914
|
+
"options",
|
|
3915
|
+
"sidebar",
|
|
3916
|
+
"newtab",
|
|
3917
|
+
"history",
|
|
3918
|
+
"bookmarks"
|
|
3919
|
+
];
|
|
3920
|
+
if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
3921
|
+
if (!args.surface) return JSON.stringify({
|
|
3922
|
+
ok: false,
|
|
3923
|
+
error: {
|
|
3924
|
+
name: "BadRequest",
|
|
3925
|
+
message: "Pass `surface` (popup/options/sidebar/action/command) to open a surface, or `url` to navigate a tab."
|
|
3926
|
+
}
|
|
3927
|
+
});
|
|
3928
|
+
if ("command" === args.surface) {
|
|
3929
|
+
const declared = declaredCommands(args.projectPath, browser);
|
|
3930
|
+
if (declared && args.name && !declared.includes(args.name)) return JSON.stringify({
|
|
3931
|
+
ok: false,
|
|
3932
|
+
error: {
|
|
3933
|
+
name: "UnknownCommand",
|
|
3934
|
+
message: `"${args.name}" is not declared in the manifest's \`commands\`, so triggering it can only ever be a no-op.`
|
|
3935
|
+
},
|
|
3936
|
+
declaredCommands: declared,
|
|
3937
|
+
hint: declared.length ? `Declared commands are: ${declared.join(", ")}. Check for a typo, or add "${args.name}" to the manifest.` : "This manifest declares no commands at all. Add a `commands` block, rebuild, then retry."
|
|
3938
|
+
});
|
|
3939
|
+
}
|
|
3076
3940
|
const cli = [
|
|
3077
3941
|
"open",
|
|
3078
3942
|
args.surface,
|
|
@@ -3090,8 +3954,18 @@ async function open_handler(args) {
|
|
|
3090
3954
|
].includes(args.surface)) try {
|
|
3091
3955
|
const parsed = JSON.parse(raw);
|
|
3092
3956
|
const msg = String(parsed?.error?.message ?? "");
|
|
3093
|
-
if (parsed?.ok === false &&
|
|
3094
|
-
|
|
3957
|
+
if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
|
|
3958
|
+
if (AS_TAB_SURFACES.includes(args.surface) && isChromiumFamily(browser)) {
|
|
3959
|
+
const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
3960
|
+
try {
|
|
3961
|
+
const parsedFallback = JSON.parse(fallback);
|
|
3962
|
+
if (parsedFallback?.ok) {
|
|
3963
|
+
parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS), which has no window to attach a popup/sidebar to, so the surface was rendered as a tab instead. Pass asTab: false and relaunch headed for a real popup window.";
|
|
3964
|
+
return JSON.stringify(parsedFallback);
|
|
3965
|
+
}
|
|
3966
|
+
} catch {}
|
|
3967
|
+
}
|
|
3968
|
+
if (!parsed.hint) parsed.hint = /user gesture/i.test(msg) ? "This surface can only open from a real user gesture, which headless automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : "The dev browser is running headless (EXTENSION_HEADLESS), which has no visible window to attach a popup/sidebar to. Retry with asTab: true to render the surface document in a tab, or relaunch a headed dev session for a real popup window.";
|
|
3095
3969
|
return JSON.stringify(parsed);
|
|
3096
3970
|
}
|
|
3097
3971
|
} catch {}
|
|
@@ -3109,7 +3983,16 @@ const dom_inspect_schema = {
|
|
|
3109
3983
|
},
|
|
3110
3984
|
tab: {
|
|
3111
3985
|
type: "number",
|
|
3112
|
-
description: "
|
|
3986
|
+
description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
|
|
3987
|
+
},
|
|
3988
|
+
url: {
|
|
3989
|
+
type: "string",
|
|
3990
|
+
description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`."
|
|
3991
|
+
},
|
|
3992
|
+
listTabs: {
|
|
3993
|
+
type: "boolean",
|
|
3994
|
+
default: false,
|
|
3995
|
+
description: "Enumerate open tabs as {tabId,url,title} and return, ignoring the other args. The discovery path when you need an explicit numeric tab id."
|
|
3113
3996
|
},
|
|
3114
3997
|
context: {
|
|
3115
3998
|
type: "string",
|
|
@@ -3119,10 +4002,13 @@ const dom_inspect_schema = {
|
|
|
3119
4002
|
"popup",
|
|
3120
4003
|
"options",
|
|
3121
4004
|
"sidebar",
|
|
3122
|
-
"devtools"
|
|
4005
|
+
"devtools",
|
|
4006
|
+
"newtab",
|
|
4007
|
+
"history",
|
|
4008
|
+
"bookmarks"
|
|
3123
4009
|
],
|
|
3124
4010
|
default: "content",
|
|
3125
|
-
description: "content/page (
|
|
4011
|
+
description: "content/page (targets `url`, else the active tab), an OPEN extension surface (popup/options/sidebar/devtools), or an override page (newtab/history/bookmarks)"
|
|
3126
4012
|
},
|
|
3127
4013
|
include: {
|
|
3128
4014
|
type: "array",
|
|
@@ -3143,8 +4029,11 @@ const dom_inspect_schema = {
|
|
|
3143
4029
|
default: 262144
|
|
3144
4030
|
},
|
|
3145
4031
|
withConsole: {
|
|
3146
|
-
type:
|
|
3147
|
-
|
|
4032
|
+
type: [
|
|
4033
|
+
"number",
|
|
4034
|
+
"boolean"
|
|
4035
|
+
],
|
|
4036
|
+
description: "Also include recent console lines for the target (DOM + console in one call). A number is how many lines; true means 50."
|
|
3148
4037
|
},
|
|
3149
4038
|
browser: {
|
|
3150
4039
|
type: "string",
|
|
@@ -3161,30 +4050,28 @@ const dom_inspect_schema = {
|
|
|
3161
4050
|
}
|
|
3162
4051
|
};
|
|
3163
4052
|
async function dom_inspect_handler(args) {
|
|
3164
|
-
const
|
|
3165
|
-
|
|
3166
|
-
"
|
|
3167
|
-
|
|
3168
|
-
"
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
},
|
|
3177
|
-
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)."
|
|
3178
|
-
});
|
|
4053
|
+
const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
|
|
4054
|
+
if (args.listTabs) return runActVerb([
|
|
4055
|
+
"inspect",
|
|
4056
|
+
args.projectPath,
|
|
4057
|
+
"--list-tabs",
|
|
4058
|
+
"--browser",
|
|
4059
|
+
resolveSessionBrowser(args.projectPath, args.browser).browser,
|
|
4060
|
+
...null != args.timeout ? [
|
|
4061
|
+
"--timeout",
|
|
4062
|
+
String(args.timeout)
|
|
4063
|
+
] : []
|
|
4064
|
+
], args.projectPath, args.timeout);
|
|
3179
4065
|
const cli = [
|
|
3180
4066
|
"inspect",
|
|
3181
4067
|
args.projectPath
|
|
3182
4068
|
];
|
|
3183
4069
|
if (null != args.tab) cli.push("--tab", String(args.tab));
|
|
4070
|
+
if (args.url) cli.push("--url", args.url);
|
|
3184
4071
|
if (args.context) cli.push("--context", args.context);
|
|
3185
4072
|
if (args.include?.length) cli.push("--include", args.include.join(","));
|
|
3186
4073
|
if (null != args.maxBytes) cli.push("--max-bytes", String(args.maxBytes));
|
|
3187
|
-
if (null !=
|
|
4074
|
+
if (null != withConsole) cli.push("--with-console", String(withConsole));
|
|
3188
4075
|
cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
|
|
3189
4076
|
if (null != args.timeout) cli.push("--timeout", String(args.timeout));
|
|
3190
4077
|
return runActVerb(cli, args.projectPath, args.timeout);
|
|
@@ -3624,61 +4511,251 @@ const deploy_schema = {
|
|
|
3624
4511
|
};
|
|
3625
4512
|
function deploy_fail(name, message) {
|
|
3626
4513
|
return JSON.stringify({
|
|
3627
|
-
ok: false,
|
|
3628
|
-
error: {
|
|
3629
|
-
name,
|
|
3630
|
-
message
|
|
3631
|
-
}
|
|
4514
|
+
ok: false,
|
|
4515
|
+
error: {
|
|
4516
|
+
name,
|
|
4517
|
+
message
|
|
4518
|
+
}
|
|
4519
|
+
});
|
|
4520
|
+
}
|
|
4521
|
+
async function deploy_handler(args) {
|
|
4522
|
+
const token = resolveToken();
|
|
4523
|
+
if (!token) return deploy_fail("DeployAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens).");
|
|
4524
|
+
const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
|
|
4525
|
+
if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge"]).');
|
|
4526
|
+
const buildSha = String(args.buildSha || "").trim();
|
|
4527
|
+
if (!buildSha) return deploy_fail("DeployInputError", "buildSha is required (the built commit to submit).");
|
|
4528
|
+
const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || deploy_DEFAULT_API));
|
|
4529
|
+
if (!apiCheck.ok) return deploy_fail("DeployConfigError", apiCheck.message);
|
|
4530
|
+
const url = `${apiCheck.base}/api/cli/stores/submit`;
|
|
4531
|
+
const dryRun = false !== args.dryRun;
|
|
4532
|
+
const body = {
|
|
4533
|
+
browsers,
|
|
4534
|
+
buildSha,
|
|
4535
|
+
dryRun
|
|
4536
|
+
};
|
|
4537
|
+
if (args.channel) body.channel = String(args.channel).trim();
|
|
4538
|
+
if (args.version) body.version = String(args.version).trim();
|
|
4539
|
+
let res;
|
|
4540
|
+
try {
|
|
4541
|
+
res = await fetch(url, {
|
|
4542
|
+
method: "POST",
|
|
4543
|
+
headers: {
|
|
4544
|
+
authorization: `Bearer ${token}`,
|
|
4545
|
+
"content-type": "application/json"
|
|
4546
|
+
},
|
|
4547
|
+
body: JSON.stringify(body)
|
|
4548
|
+
});
|
|
4549
|
+
} catch (err) {
|
|
4550
|
+
return deploy_fail("DeployNetworkError", `Could not reach ${url}: ${err?.message || err}`);
|
|
4551
|
+
}
|
|
4552
|
+
const text = await res.text();
|
|
4553
|
+
let data;
|
|
4554
|
+
try {
|
|
4555
|
+
data = JSON.parse(text);
|
|
4556
|
+
} catch {
|
|
4557
|
+
data = {
|
|
4558
|
+
message: text
|
|
4559
|
+
};
|
|
4560
|
+
}
|
|
4561
|
+
if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
|
|
4562
|
+
return JSON.stringify({
|
|
4563
|
+
mode: "platform",
|
|
4564
|
+
dryRun,
|
|
4565
|
+
...data
|
|
4566
|
+
});
|
|
4567
|
+
}
|
|
4568
|
+
function doctor_readReadyContract(projectPath, browser) {
|
|
4569
|
+
try {
|
|
4570
|
+
const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
|
|
4571
|
+
return JSON.parse(raw);
|
|
4572
|
+
} catch {
|
|
4573
|
+
return null;
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
const doctor_schema = {
|
|
4577
|
+
name: "extension_doctor",
|
|
4578
|
+
description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order, a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
|
|
4579
|
+
inputSchema: {
|
|
4580
|
+
type: "object",
|
|
4581
|
+
properties: {
|
|
4582
|
+
projectPath: {
|
|
4583
|
+
type: "string",
|
|
4584
|
+
description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
|
|
4585
|
+
},
|
|
4586
|
+
browser: {
|
|
4587
|
+
type: "string",
|
|
4588
|
+
description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
}
|
|
4592
|
+
};
|
|
4593
|
+
async function environmentPreflight() {
|
|
4594
|
+
const checks = [];
|
|
4595
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
4596
|
+
checks.push({
|
|
4597
|
+
check: "node",
|
|
4598
|
+
status: nodeMajor >= 20 ? "pass" : "fail",
|
|
4599
|
+
detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
|
|
4600
|
+
remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
|
|
4601
|
+
});
|
|
4602
|
+
const { code, stdout, stderr } = await runExtensionCli([
|
|
4603
|
+
"--version"
|
|
4604
|
+
], {
|
|
4605
|
+
timeoutMs: 60000
|
|
4606
|
+
});
|
|
4607
|
+
const cliVersion = stdout.trim() || stderr.trim();
|
|
4608
|
+
checks.push({
|
|
4609
|
+
check: "extension-cli",
|
|
4610
|
+
status: 0 === code ? "pass" : "fail",
|
|
4611
|
+
detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
|
|
4612
|
+
remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
|
|
4613
|
+
});
|
|
4614
|
+
const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
|
|
4615
|
+
const cacheExists = node_fs.existsSync(cacheFile);
|
|
4616
|
+
checks.push({
|
|
4617
|
+
check: "template-cache",
|
|
4618
|
+
status: cacheExists ? "pass" : "warn",
|
|
4619
|
+
detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
|
|
4620
|
+
});
|
|
4621
|
+
const healthy = checks.every((c)=>"fail" !== c.status);
|
|
4622
|
+
return JSON.stringify({
|
|
4623
|
+
mode: "environment",
|
|
4624
|
+
healthy,
|
|
4625
|
+
checks,
|
|
4626
|
+
hint: "Pass projectPath to diagnose a live dev session end-to-end."
|
|
3632
4627
|
});
|
|
3633
4628
|
}
|
|
3634
|
-
|
|
3635
|
-
const token = resolveToken();
|
|
3636
|
-
if (!token) return deploy_fail("DeployAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens).");
|
|
3637
|
-
const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
|
|
3638
|
-
if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge"]).');
|
|
3639
|
-
const buildSha = String(args.buildSha || "").trim();
|
|
3640
|
-
if (!buildSha) return deploy_fail("DeployInputError", "buildSha is required (the built commit to submit).");
|
|
3641
|
-
const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || deploy_DEFAULT_API));
|
|
3642
|
-
if (!apiCheck.ok) return deploy_fail("DeployConfigError", apiCheck.message);
|
|
3643
|
-
const url = `${apiCheck.base}/api/cli/stores/submit`;
|
|
3644
|
-
const dryRun = false !== args.dryRun;
|
|
3645
|
-
const body = {
|
|
3646
|
-
browsers,
|
|
3647
|
-
buildSha,
|
|
3648
|
-
dryRun
|
|
3649
|
-
};
|
|
3650
|
-
if (args.channel) body.channel = String(args.channel).trim();
|
|
3651
|
-
if (args.version) body.version = String(args.version).trim();
|
|
3652
|
-
let res;
|
|
4629
|
+
function safeStringify(value) {
|
|
3653
4630
|
try {
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
authorization: `Bearer ${token}`,
|
|
3658
|
-
"content-type": "application/json"
|
|
3659
|
-
},
|
|
3660
|
-
body: JSON.stringify(body)
|
|
3661
|
-
});
|
|
3662
|
-
} catch (err) {
|
|
3663
|
-
return deploy_fail("DeployNetworkError", `Could not reach ${url}: ${err?.message || err}`);
|
|
4631
|
+
return JSON.stringify(value) ?? String(value);
|
|
4632
|
+
} catch {
|
|
4633
|
+
return String(value);
|
|
3664
4634
|
}
|
|
3665
|
-
|
|
3666
|
-
|
|
4635
|
+
}
|
|
4636
|
+
function recentErrorLogs(projectPath, browser, max = 5) {
|
|
4637
|
+
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
|
|
4638
|
+
let lines;
|
|
3667
4639
|
try {
|
|
3668
|
-
|
|
4640
|
+
lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
3669
4641
|
} catch {
|
|
3670
|
-
|
|
3671
|
-
message: text
|
|
3672
|
-
};
|
|
4642
|
+
return [];
|
|
3673
4643
|
}
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
4644
|
+
const errs = [];
|
|
4645
|
+
for (const line of lines){
|
|
4646
|
+
let ev;
|
|
4647
|
+
try {
|
|
4648
|
+
ev = JSON.parse(line);
|
|
4649
|
+
} catch {
|
|
4650
|
+
continue;
|
|
4651
|
+
}
|
|
4652
|
+
if (!ev || "error" !== ev.level) continue;
|
|
4653
|
+
const parts = Array.isArray(ev.messageParts) ? ev.messageParts : Array.isArray(ev.args) ? ev.args : null;
|
|
4654
|
+
let msg = parts ? parts.map((p)=>"string" == typeof p ? p : safeStringify(p)).join(" ") : ev.message || ev.text || "";
|
|
4655
|
+
if (!msg && ev.errorName) msg = ev.stack ? `${ev.errorName}: ${ev.stack}` : ev.errorName;
|
|
4656
|
+
msg = msg.replace(/\s+/g, " ").trim();
|
|
4657
|
+
if (msg) errs.push(msg.slice(0, 300));
|
|
4658
|
+
}
|
|
4659
|
+
return [
|
|
4660
|
+
...new Set(errs)
|
|
4661
|
+
].slice(-max);
|
|
4662
|
+
}
|
|
4663
|
+
function projectEngineVersion(projectPath) {
|
|
4664
|
+
try {
|
|
4665
|
+
const p = node_path.resolve(projectPath, "node_modules", "extension", "package.json");
|
|
4666
|
+
return JSON.parse(node_fs.readFileSync(p, "utf8")).version || null;
|
|
4667
|
+
} catch {
|
|
4668
|
+
return null;
|
|
4669
|
+
}
|
|
4670
|
+
}
|
|
4671
|
+
async function doctor_handler(args) {
|
|
4672
|
+
if (!args.projectPath) return environmentPreflight();
|
|
4673
|
+
const projectPath = args.projectPath;
|
|
4674
|
+
const { browser } = resolveSessionBrowser(projectPath, args.browser);
|
|
4675
|
+
const { code, stdout, stderr } = await runExtensionCli([
|
|
4676
|
+
"doctor",
|
|
4677
|
+
projectPath,
|
|
4678
|
+
"--browser",
|
|
4679
|
+
browser,
|
|
4680
|
+
"--output",
|
|
4681
|
+
"json"
|
|
4682
|
+
], {
|
|
4683
|
+
cwd: projectPath
|
|
3679
4684
|
});
|
|
4685
|
+
const out = stdout.trim();
|
|
4686
|
+
try {
|
|
4687
|
+
const checks = JSON.parse(out);
|
|
4688
|
+
if (!Array.isArray(checks)) throw new Error("not a check array");
|
|
4689
|
+
for (const check of checks){
|
|
4690
|
+
if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
|
|
4691
|
+
if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
|
|
4692
|
+
}
|
|
4693
|
+
let healthy = 0 === code;
|
|
4694
|
+
const contract = doctor_readReadyContract(projectPath, browser);
|
|
4695
|
+
if (contract?.status === "error") {
|
|
4696
|
+
healthy = false;
|
|
4697
|
+
const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
|
|
4698
|
+
const detail = browserExited ? `The ${browser} browser for this session exited unexpectedly${null != contract.browserExitCode ? ` (exit code ${contract.browserExitCode})` : ""}; the extension may have been rejected or the browser crashed. The session cannot be driven.` : contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
|
|
4699
|
+
checks.push({
|
|
4700
|
+
check: "runtime-errors",
|
|
4701
|
+
status: "fail",
|
|
4702
|
+
detail: toMcpSpeak(detail),
|
|
4703
|
+
remediation: browserExited ? "Read extension_logs and the session log for the rejection cause, call extension_stop to clean up, then relaunch." : "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
|
|
4704
|
+
});
|
|
4705
|
+
} else {
|
|
4706
|
+
const errs = recentErrorLogs(projectPath, browser);
|
|
4707
|
+
if (errs.length) {
|
|
4708
|
+
healthy = false;
|
|
4709
|
+
checks.push({
|
|
4710
|
+
check: "runtime-errors",
|
|
4711
|
+
status: "fail",
|
|
4712
|
+
detail: `Recent error-level logs: ${errs.join(" | ")}`,
|
|
4713
|
+
remediation: "The extension is throwing at runtime. Inspect with extension_logs. A chrome.* API called without its permission is a common cause: extension_manifest_validate catches a permission MISSING FROM permissions[], but it does not model host-permission scope (e.g. webRequest with no matching host_permissions) or gesture requirements (e.g. activeTab without a user gesture), so a valid:true there does not rule those out."
|
|
4714
|
+
});
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
const engineVersion = projectEngineVersion(projectPath);
|
|
4718
|
+
if (engineVersion) {
|
|
4719
|
+
const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
|
|
4720
|
+
const mismatch = "" !== pin && "latest" !== pin && !engineVersion.includes(pin);
|
|
4721
|
+
checks.push({
|
|
4722
|
+
check: "project-engine",
|
|
4723
|
+
status: mismatch ? "warn" : "pass",
|
|
4724
|
+
detail: `project-local extension@${engineVersion}${mismatch ? `, but EXTENSION_MCP_CLI_VERSION=${pin}; the dev loop uses the project bin, not the pin` : ""}`,
|
|
4725
|
+
...mismatch ? {
|
|
4726
|
+
remediation: `Run \`(cd ${projectPath} && npm i -D extension@${pin})\` to match the pinned engine.`
|
|
4727
|
+
} : {}
|
|
4728
|
+
});
|
|
4729
|
+
}
|
|
4730
|
+
return JSON.stringify({
|
|
4731
|
+
browser,
|
|
4732
|
+
...engineVersion ? {
|
|
4733
|
+
engineVersion
|
|
4734
|
+
} : {},
|
|
4735
|
+
healthy,
|
|
4736
|
+
checks
|
|
4737
|
+
});
|
|
4738
|
+
} catch {
|
|
4739
|
+
const message = stderr.trim() || `extension exited with code ${code}`;
|
|
4740
|
+
return JSON.stringify({
|
|
4741
|
+
ok: false,
|
|
4742
|
+
error: {
|
|
4743
|
+
name: "CliError",
|
|
4744
|
+
message: toMcpSpeak(message),
|
|
4745
|
+
hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
|
|
4746
|
+
}
|
|
4747
|
+
});
|
|
4748
|
+
}
|
|
3680
4749
|
}
|
|
3681
4750
|
const SAFE_CEILING_MS = 50000;
|
|
4751
|
+
function wait_isAlive(pid) {
|
|
4752
|
+
try {
|
|
4753
|
+
process.kill(pid, 0);
|
|
4754
|
+
return true;
|
|
4755
|
+
} catch {
|
|
4756
|
+
return false;
|
|
4757
|
+
}
|
|
4758
|
+
}
|
|
3682
4759
|
const wait_schema = {
|
|
3683
4760
|
name: "extension_wait",
|
|
3684
4761
|
description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status. A single call is bounded to ~50s to stay under the MCP client request timeout; if it returns status:'timeout', call it again to keep waiting (polling resumes on the same contract).",
|
|
@@ -3712,22 +4789,43 @@ async function wait_handler(args) {
|
|
|
3712
4789
|
const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
|
|
3713
4790
|
const start = Date.now();
|
|
3714
4791
|
const pollInterval = 1000;
|
|
4792
|
+
let sawCompiledButUnattached = false;
|
|
3715
4793
|
while(Date.now() - start < timeout){
|
|
3716
4794
|
try {
|
|
3717
4795
|
const raw = node_fs.readFileSync(readyPath, "utf8");
|
|
3718
4796
|
const contract = JSON.parse(raw);
|
|
3719
|
-
if ("ready" === contract.status)
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
4797
|
+
if ("ready" === contract.status) {
|
|
4798
|
+
if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
|
|
4799
|
+
status: "stale",
|
|
4800
|
+
message: `ready.json reports ready but its dev-server pid ${contract.pid} is dead, the session exited. Restart with extension_dev; extension_doctor will confirm.`,
|
|
4801
|
+
browser: contract.browser,
|
|
4802
|
+
pid: contract.pid,
|
|
4803
|
+
waitDuration: Date.now() - start
|
|
4804
|
+
});
|
|
4805
|
+
const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
|
|
4806
|
+
if (!attached) {
|
|
4807
|
+
await new Promise((r)=>setTimeout(r, pollInterval));
|
|
4808
|
+
sawCompiledButUnattached = true;
|
|
4809
|
+
continue;
|
|
4810
|
+
}
|
|
4811
|
+
const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
|
|
4812
|
+
return JSON.stringify({
|
|
4813
|
+
status: "ready",
|
|
4814
|
+
command: contract.command,
|
|
4815
|
+
browser: contract.browser,
|
|
4816
|
+
port: contract.port,
|
|
4817
|
+
pid: contract.pid,
|
|
4818
|
+
distPath: contract.distPath,
|
|
4819
|
+
manifestPath: contract.manifestPath,
|
|
4820
|
+
compiledAt: contract.compiledAt,
|
|
4821
|
+
startedAt: contract.startedAt,
|
|
4822
|
+
waitDuration: Date.now() - start,
|
|
4823
|
+
...runtimeErrors.length ? {
|
|
4824
|
+
runtimeErrors,
|
|
4825
|
+
warning: `Compiled and attached, but the extension is throwing at runtime (${runtimeErrors.length} recent error event${1 === runtimeErrors.length ? "" : "s"} above). Check extension_logs (level: error) or extension_doctor before trusting this session.`
|
|
4826
|
+
} : {}
|
|
4827
|
+
});
|
|
4828
|
+
}
|
|
3731
4829
|
if ("error" === contract.status) return JSON.stringify({
|
|
3732
4830
|
status: "error",
|
|
3733
4831
|
message: contract.message,
|
|
@@ -3739,13 +4837,20 @@ async function wait_handler(args) {
|
|
|
3739
4837
|
} catch {}
|
|
3740
4838
|
await new Promise((resolve)=>setTimeout(resolve, pollInterval));
|
|
3741
4839
|
}
|
|
4840
|
+
if (sawCompiledButUnattached) return JSON.stringify({
|
|
4841
|
+
status: "compiled-not-attached",
|
|
4842
|
+
message: `The extension compiled, but the runtime executor never attached within ${timeout}ms. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`,
|
|
4843
|
+
readyPath,
|
|
4844
|
+
waitDuration: Date.now() - start,
|
|
4845
|
+
hint: "This is usually transient: call extension_wait again. If it persists, stop and restart the session with extension_dev (a restart reliably reattaches); extension_doctor reports the executor leg."
|
|
4846
|
+
});
|
|
3742
4847
|
return JSON.stringify({
|
|
3743
4848
|
status: "timeout",
|
|
3744
4849
|
message: `Extension not ready after ${timeout}ms this call`,
|
|
3745
4850
|
readyPath,
|
|
3746
4851
|
waitDuration: Date.now() - start,
|
|
3747
4852
|
clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
|
|
3748
|
-
hint: "Still building
|
|
4853
|
+
hint: "Still building, call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
|
|
3749
4854
|
});
|
|
3750
4855
|
}
|
|
3751
4856
|
const add_feature_schema = {
|
|
@@ -4864,135 +5969,27 @@ async function detect_browsers_handler(args) {
|
|
|
4864
5969
|
hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}.${missing.some((d)=>MANAGED_INSTALLABLE.has(d.browser)) ? ` Use extension_install_browser to install ${missing.filter((d)=>MANAGED_INSTALLABLE.has(d.browser)).map((d)=>d.browser).join(", ")}.` : ""}` : "All requested browsers are available."
|
|
4865
5970
|
});
|
|
4866
5971
|
}
|
|
4867
|
-
function doctor_readReadyContract(projectPath, browser) {
|
|
4868
|
-
try {
|
|
4869
|
-
const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
|
|
4870
|
-
return JSON.parse(raw);
|
|
4871
|
-
} catch {
|
|
4872
|
-
return null;
|
|
4873
|
-
}
|
|
4874
|
-
}
|
|
4875
|
-
const doctor_schema = {
|
|
4876
|
-
name: "extension_doctor",
|
|
4877
|
-
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.",
|
|
4878
|
-
inputSchema: {
|
|
4879
|
-
type: "object",
|
|
4880
|
-
properties: {
|
|
4881
|
-
projectPath: {
|
|
4882
|
-
type: "string",
|
|
4883
|
-
description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
|
|
4884
|
-
},
|
|
4885
|
-
browser: {
|
|
4886
|
-
type: "string",
|
|
4887
|
-
description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
|
|
4888
|
-
}
|
|
4889
|
-
}
|
|
4890
|
-
}
|
|
4891
|
-
};
|
|
4892
|
-
async function environmentPreflight() {
|
|
4893
|
-
const checks = [];
|
|
4894
|
-
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
4895
|
-
checks.push({
|
|
4896
|
-
check: "node",
|
|
4897
|
-
status: nodeMajor >= 20 ? "pass" : "fail",
|
|
4898
|
-
detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
|
|
4899
|
-
remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
|
|
4900
|
-
});
|
|
4901
|
-
const { code, stdout, stderr } = await runExtensionCli([
|
|
4902
|
-
"--version"
|
|
4903
|
-
], {
|
|
4904
|
-
timeoutMs: 60000
|
|
4905
|
-
});
|
|
4906
|
-
const cliVersion = stdout.trim() || stderr.trim();
|
|
4907
|
-
checks.push({
|
|
4908
|
-
check: "extension-cli",
|
|
4909
|
-
status: 0 === code ? "pass" : "fail",
|
|
4910
|
-
detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
|
|
4911
|
-
remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
|
|
4912
|
-
});
|
|
4913
|
-
const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
|
|
4914
|
-
const cacheExists = node_fs.existsSync(cacheFile);
|
|
4915
|
-
checks.push({
|
|
4916
|
-
check: "template-cache",
|
|
4917
|
-
status: cacheExists ? "pass" : "warn",
|
|
4918
|
-
detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
|
|
4919
|
-
});
|
|
4920
|
-
const healthy = checks.every((c)=>"fail" !== c.status);
|
|
4921
|
-
return JSON.stringify({
|
|
4922
|
-
mode: "environment",
|
|
4923
|
-
healthy,
|
|
4924
|
-
checks,
|
|
4925
|
-
hint: "Pass projectPath to diagnose a live dev session end-to-end."
|
|
4926
|
-
});
|
|
4927
|
-
}
|
|
4928
|
-
async function doctor_handler(args) {
|
|
4929
|
-
if (!args.projectPath) return environmentPreflight();
|
|
4930
|
-
const projectPath = args.projectPath;
|
|
4931
|
-
const { browser } = resolveSessionBrowser(projectPath, args.browser);
|
|
4932
|
-
const { code, stdout, stderr } = await runExtensionCli([
|
|
4933
|
-
"doctor",
|
|
4934
|
-
projectPath,
|
|
4935
|
-
"--browser",
|
|
4936
|
-
browser,
|
|
4937
|
-
"--output",
|
|
4938
|
-
"json"
|
|
4939
|
-
], {
|
|
4940
|
-
cwd: projectPath
|
|
4941
|
-
});
|
|
4942
|
-
const out = stdout.trim();
|
|
4943
|
-
try {
|
|
4944
|
-
const checks = JSON.parse(out);
|
|
4945
|
-
if (!Array.isArray(checks)) throw new Error("not a check array");
|
|
4946
|
-
for (const check of checks){
|
|
4947
|
-
if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
|
|
4948
|
-
if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
|
|
4949
|
-
}
|
|
4950
|
-
let healthy = 0 === code;
|
|
4951
|
-
const contract = doctor_readReadyContract(projectPath, browser);
|
|
4952
|
-
if (contract?.status === "error") {
|
|
4953
|
-
healthy = false;
|
|
4954
|
-
const detail = contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
|
|
4955
|
-
checks.push({
|
|
4956
|
-
check: "runtime-errors",
|
|
4957
|
-
status: "fail",
|
|
4958
|
-
detail: toMcpSpeak(detail),
|
|
4959
|
-
remediation: "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
|
|
4960
|
-
});
|
|
4961
|
-
}
|
|
4962
|
-
return JSON.stringify({
|
|
4963
|
-
browser,
|
|
4964
|
-
healthy,
|
|
4965
|
-
checks
|
|
4966
|
-
});
|
|
4967
|
-
} catch {
|
|
4968
|
-
const message = stderr.trim() || `extension exited with code ${code}`;
|
|
4969
|
-
return JSON.stringify({
|
|
4970
|
-
ok: false,
|
|
4971
|
-
error: {
|
|
4972
|
-
name: "CliError",
|
|
4973
|
-
message: toMcpSpeak(message),
|
|
4974
|
-
hint: "extension doctor requires a recent extension CLI — the project's local install may predate it."
|
|
4975
|
-
}
|
|
4976
|
-
});
|
|
4977
|
-
}
|
|
4978
|
-
}
|
|
4979
5972
|
function typeOf(value) {
|
|
4980
5973
|
if (Array.isArray(value)) return "array";
|
|
4981
5974
|
if (null === value) return "null";
|
|
4982
5975
|
return typeof value;
|
|
4983
5976
|
}
|
|
4984
5977
|
function checkPrimitive(path, value, schema, issues) {
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
5978
|
+
const allowed = void 0 === schema.type ? [] : Array.isArray(schema.type) ? schema.type : [
|
|
5979
|
+
schema.type
|
|
5980
|
+
];
|
|
5981
|
+
const primitives = allowed.filter((t)=>[
|
|
5982
|
+
"string",
|
|
5983
|
+
"number",
|
|
5984
|
+
"boolean"
|
|
5985
|
+
].includes(t));
|
|
5986
|
+
if (primitives.length > 0 && primitives.length === allowed.length) {
|
|
5987
|
+
if (!primitives.includes(typeOf(value))) return void issues.push({
|
|
4991
5988
|
path,
|
|
4992
|
-
message: `expected ${
|
|
5989
|
+
message: `expected ${primitives.join(" or ")}, got ${typeOf(value)}`
|
|
4993
5990
|
});
|
|
4994
5991
|
}
|
|
4995
|
-
if ("array"
|
|
5992
|
+
if (allowed.includes("array")) {
|
|
4996
5993
|
if (!Array.isArray(value)) return void issues.push({
|
|
4997
5994
|
path,
|
|
4998
5995
|
message: `expected array, got ${typeOf(value)}`
|
|
@@ -5035,7 +6032,28 @@ const ARG_ALIASES = {
|
|
|
5035
6032
|
"manifest"
|
|
5036
6033
|
],
|
|
5037
6034
|
surface: [
|
|
5038
|
-
"view"
|
|
6035
|
+
"view",
|
|
6036
|
+
"target"
|
|
6037
|
+
],
|
|
6038
|
+
timeout: [
|
|
6039
|
+
"timeoutMs",
|
|
6040
|
+
"timeoutMillis"
|
|
6041
|
+
],
|
|
6042
|
+
limit: [
|
|
6043
|
+
"lines",
|
|
6044
|
+
"count",
|
|
6045
|
+
"max",
|
|
6046
|
+
"maxLines"
|
|
6047
|
+
],
|
|
6048
|
+
tab: [
|
|
6049
|
+
"tabId"
|
|
6050
|
+
],
|
|
6051
|
+
url: [
|
|
6052
|
+
"href",
|
|
6053
|
+
"pageUrl"
|
|
6054
|
+
],
|
|
6055
|
+
browser: [
|
|
6056
|
+
"browserName"
|
|
5039
6057
|
]
|
|
5040
6058
|
};
|
|
5041
6059
|
function normalizeArgAliases(inputSchema, args) {
|