@extension.dev/mcp 5.3.1 → 5.4.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 +49 -0
- package/dist/module.js +394 -195
- package/dist/src/lib/process-manager.d.ts +1 -0
- package/dist/src/lib/session-browser.d.ts +1 -0
- package/dist/src/lib/types.d.ts +6 -1
- package/dist/src/tools/wait.d.ts +6 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "extension-mcp",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "MCP tools for browser extension development: scaffold from 60+ templates, run the dev server with HMR, inspect the live DOM and logs, and publish store-ready builds for Chrome, Edge, and Firefox.",
|
|
13
|
-
"version": "5.
|
|
13
|
+
"version": "5.4.0",
|
|
14
14
|
"category": "development",
|
|
15
15
|
"author": {
|
|
16
16
|
"name": "Cezar Augusto"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "extension-mcp",
|
|
3
3
|
"description": "MCP tools for browser extension development: scaffold from 60+ templates, run the dev server with HMR, inspect the live DOM and logs, and publish store-ready builds for Chrome, Edge, and Firefox. Ships /extension, /extension-add, /extension-debug, and /extension-publish commands.",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.4.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cezar Augusto",
|
|
7
7
|
"email": "boss@cezaraugusto.net",
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.4.0
|
|
4
|
+
|
|
5
|
+
The DevX swarm's non-blocker friction clusters, cleared: waiting is
|
|
6
|
+
narrated, ports tell the truth, artifacts say where they are, and error
|
|
7
|
+
prose explains the extension instead of the engine.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `extension_wait` narrates its budget. `timeoutMs` is a documented
|
|
12
|
+
argument (default 45000, clamped 1000 to 50000; the legacy `timeout`
|
|
13
|
+
spelling stays as a deprecated alias), every result carries `budgetMs`
|
|
14
|
+
and `elapsedMs`, and a timeout says what WAS observed plus a
|
|
15
|
+
call-again hint instead of an opaque failure. The status splits
|
|
16
|
+
`compiled` from `browserAttached`, and a `noBrowser` build-only
|
|
17
|
+
session returns immediately with `buildOnly: true` and a plain
|
|
18
|
+
statement that no browser will ever attach.
|
|
19
|
+
- `extension_build` with `zip: true` returns `zipPath`, the absolute
|
|
20
|
+
path of the file the engine actually wrote (its name sanitizer strips
|
|
21
|
+
punctuation, so the filename rarely matches the project name), and
|
|
22
|
+
`zipSourcePath` for `zipSource: true`. When a zip cannot be located
|
|
23
|
+
after a successful build the result says so in `zipPathNote` instead
|
|
24
|
+
of omitting the field silently.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `extension_dev` reports the actually-bound port. The engine's
|
|
29
|
+
ready.json carries the bound port from its first stamp, so dev reads
|
|
30
|
+
it after the health window and re-registers the session with the true
|
|
31
|
+
port; when the stamp has not landed yet, dev claims no port at all
|
|
32
|
+
and says `extension_wait` reports the bound one. dev and wait now
|
|
33
|
+
share one source of truth and cannot disagree about the same session.
|
|
34
|
+
- `extension_build` warns when it writes over a live dev session's
|
|
35
|
+
dist: the dev browser may serve the production artifact until the
|
|
36
|
+
next recompile. The build is never blocked; the clobber is named.
|
|
37
|
+
- `extension_inspect` classifies `.zip` files as archives and excludes
|
|
38
|
+
them from `shippableSize` and the 10MB store gate (the package is not
|
|
39
|
+
payload), with an `archiveNote` explaining the exclusion.
|
|
40
|
+
- `extension_whoami` anchors its identity to the stored token that
|
|
41
|
+
`extension_login` minted: it does not follow the current working
|
|
42
|
+
directory, and the wording now says so.
|
|
43
|
+
- `extension_open` explains a popup-less extension instead of relaying
|
|
44
|
+
the engine error: a manifest pre-check reports that nothing sets
|
|
45
|
+
`action.default_popup`, lists the surfaces the manifest DOES declare,
|
|
46
|
+
and points at the right next verb. Headless popup errors state the
|
|
47
|
+
exact headed path (`extension_dev` with `replace: true` and
|
|
48
|
+
`EXTENSION_HEADLESS=0`).
|
|
49
|
+
- `extension_dev`'s `earlyOutput` drops V8 asm.js verdict lines (pure
|
|
50
|
+
noise); real errors are preserved.
|
|
51
|
+
|
|
3
52
|
## 5.3.1
|
|
4
53
|
|
|
5
54
|
### Added
|
package/dist/module.js
CHANGED
|
@@ -210,7 +210,7 @@ __webpack_require__.d(whoami_namespaceObject, {
|
|
|
210
210
|
handler: ()=>whoami_handler,
|
|
211
211
|
schema: ()=>whoami_schema
|
|
212
212
|
});
|
|
213
|
-
var package_namespaceObject = JSON.parse('{"rE":"5.
|
|
213
|
+
var package_namespaceObject = JSON.parse('{"rE":"5.4.0","El":{"OP":"^4.0.13"}}');
|
|
214
214
|
function scaffoldEnginePin(projectPath) {
|
|
215
215
|
try {
|
|
216
216
|
const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
|
|
@@ -623,6 +623,185 @@ function spawnExtensionCli(args, options) {
|
|
|
623
623
|
}
|
|
624
624
|
};
|
|
625
625
|
}
|
|
626
|
+
const sessions = new Map();
|
|
627
|
+
function sessionKey(projectPath, browser) {
|
|
628
|
+
return `${node_path.resolve(projectPath)}::${browser}`;
|
|
629
|
+
}
|
|
630
|
+
function markerDir() {
|
|
631
|
+
return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
|
|
632
|
+
}
|
|
633
|
+
function markerPath(projectPath, browser) {
|
|
634
|
+
const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
|
|
635
|
+
return node_path.join(markerDir(), `${digest}.json`);
|
|
636
|
+
}
|
|
637
|
+
function removeSessionMarker(projectPath, browser) {
|
|
638
|
+
try {
|
|
639
|
+
node_fs.rmSync(markerPath(projectPath, browser), {
|
|
640
|
+
force: true
|
|
641
|
+
});
|
|
642
|
+
} catch {}
|
|
643
|
+
}
|
|
644
|
+
function listSessionMarkers() {
|
|
645
|
+
let files;
|
|
646
|
+
try {
|
|
647
|
+
files = node_fs.readdirSync(markerDir());
|
|
648
|
+
} catch {
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
const out = [];
|
|
652
|
+
for (const file of files)if (file.endsWith(".json")) try {
|
|
653
|
+
const parsed = JSON.parse(node_fs.readFileSync(node_path.join(markerDir(), file), "utf8"));
|
|
654
|
+
if ("string" == typeof parsed?.projectPath && "string" == typeof parsed?.browser) out.push(parsed);
|
|
655
|
+
} catch {}
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
function registerSession(info) {
|
|
659
|
+
sessions.set(sessionKey(info.projectPath, info.browser), info);
|
|
660
|
+
try {
|
|
661
|
+
node_fs.mkdirSync(markerDir(), {
|
|
662
|
+
recursive: true
|
|
663
|
+
});
|
|
664
|
+
node_fs.writeFileSync(markerPath(info.projectPath, info.browser), JSON.stringify({
|
|
665
|
+
...info,
|
|
666
|
+
projectPath: node_path.resolve(info.projectPath),
|
|
667
|
+
registeredAt: new Date().toISOString()
|
|
668
|
+
}));
|
|
669
|
+
} catch {}
|
|
670
|
+
}
|
|
671
|
+
function getSession(projectPath, browser) {
|
|
672
|
+
return sessions.get(sessionKey(projectPath, browser));
|
|
673
|
+
}
|
|
674
|
+
function findSessionInfo(projectPath, browser) {
|
|
675
|
+
const inMemory = sessions.get(sessionKey(projectPath, browser));
|
|
676
|
+
if (inMemory) return inMemory;
|
|
677
|
+
const resolved = node_path.resolve(projectPath);
|
|
678
|
+
for (const marker of listSessionMarkers())if (node_path.resolve(marker.projectPath) === resolved && marker.browser === browser) return marker;
|
|
679
|
+
}
|
|
680
|
+
function removeSession(projectPath, browser) {
|
|
681
|
+
sessions.delete(sessionKey(projectPath, browser));
|
|
682
|
+
}
|
|
683
|
+
function listSessions() {
|
|
684
|
+
return Array.from(sessions.values());
|
|
685
|
+
}
|
|
686
|
+
function contractSightings(projectPath) {
|
|
687
|
+
const root = node_path.resolve(projectPath, "dist", "extension-js");
|
|
688
|
+
let dirs;
|
|
689
|
+
try {
|
|
690
|
+
dirs = node_fs.readdirSync(root);
|
|
691
|
+
} catch {
|
|
692
|
+
return [];
|
|
693
|
+
}
|
|
694
|
+
const sightings = [];
|
|
695
|
+
for (const dir of dirs){
|
|
696
|
+
const readyPath = node_path.join(root, dir, "ready.json");
|
|
697
|
+
try {
|
|
698
|
+
const stat = node_fs.statSync(readyPath);
|
|
699
|
+
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
700
|
+
if (contract?.status !== "ready") continue;
|
|
701
|
+
sightings.push({
|
|
702
|
+
browser: dir,
|
|
703
|
+
mtimeMs: stat.mtimeMs,
|
|
704
|
+
pid: "number" == typeof contract.pid ? contract.pid : void 0
|
|
705
|
+
});
|
|
706
|
+
} catch {}
|
|
707
|
+
}
|
|
708
|
+
return sightings;
|
|
709
|
+
}
|
|
710
|
+
function pidAlive(pid) {
|
|
711
|
+
try {
|
|
712
|
+
process.kill(pid, 0);
|
|
713
|
+
return true;
|
|
714
|
+
} catch {
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function knownSessionBrowsers(projectPath) {
|
|
719
|
+
const resolved = node_path.resolve(projectPath);
|
|
720
|
+
const browsers = [];
|
|
721
|
+
for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
|
|
722
|
+
for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
|
|
723
|
+
return Array.from(new Set(browsers));
|
|
724
|
+
}
|
|
725
|
+
function liveProjectSessions(projectPath) {
|
|
726
|
+
const resolved = node_path.resolve(projectPath);
|
|
727
|
+
const out = new Map();
|
|
728
|
+
for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) {
|
|
729
|
+
if (pidAlive(session.pid)) out.set(session.browser, {
|
|
730
|
+
browser: session.browser,
|
|
731
|
+
pid: session.pid,
|
|
732
|
+
source: "registry"
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && pidAlive(sighting.pid)) {
|
|
736
|
+
if (!out.has(sighting.browser)) out.set(sighting.browser, {
|
|
737
|
+
browser: sighting.browser,
|
|
738
|
+
pid: sighting.pid,
|
|
739
|
+
source: "contract"
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
return [
|
|
743
|
+
...out.values()
|
|
744
|
+
];
|
|
745
|
+
}
|
|
746
|
+
function deadReadySession(projectPath) {
|
|
747
|
+
for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
|
|
748
|
+
browser: sighting.browser,
|
|
749
|
+
pid: sighting.pid
|
|
750
|
+
};
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
function browserExitStamp(projectPath, browser, since) {
|
|
754
|
+
const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
755
|
+
try {
|
|
756
|
+
const stat = node_fs.statSync(readyPath);
|
|
757
|
+
if (stat.mtimeMs < since) return null;
|
|
758
|
+
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
759
|
+
const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
|
|
760
|
+
if (contract?.status === "error" && exited) return {
|
|
761
|
+
code: contract.code,
|
|
762
|
+
browserExitCode: contract.browserExitCode ?? null,
|
|
763
|
+
browserExitedAt: contract.browserExitedAt
|
|
764
|
+
};
|
|
765
|
+
} catch {}
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
function contractBoundPort(projectPath, browser, since) {
|
|
769
|
+
const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
770
|
+
try {
|
|
771
|
+
const stat = node_fs.statSync(readyPath);
|
|
772
|
+
if (stat.mtimeMs < since) return null;
|
|
773
|
+
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
774
|
+
return "number" == typeof contract?.port && Number.isFinite(contract.port) ? contract.port : null;
|
|
775
|
+
} catch {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
|
|
780
|
+
if (explicit) return {
|
|
781
|
+
browser: explicit,
|
|
782
|
+
source: "explicit"
|
|
783
|
+
};
|
|
784
|
+
const resolved = node_path.resolve(projectPath);
|
|
785
|
+
const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
|
|
786
|
+
if (mine.length > 0) return {
|
|
787
|
+
browser: mine[mine.length - 1].browser,
|
|
788
|
+
source: "session"
|
|
789
|
+
};
|
|
790
|
+
const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
|
|
791
|
+
const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
|
|
792
|
+
if (live.length > 0) return {
|
|
793
|
+
browser: live[0].browser,
|
|
794
|
+
source: "contract"
|
|
795
|
+
};
|
|
796
|
+
if (sightings.length > 0) return {
|
|
797
|
+
browser: sightings[0].browser,
|
|
798
|
+
source: "stale"
|
|
799
|
+
};
|
|
800
|
+
return {
|
|
801
|
+
browser: fallback,
|
|
802
|
+
source: "fallback"
|
|
803
|
+
};
|
|
804
|
+
}
|
|
626
805
|
function readBuildSummary(projectPath, browser, since) {
|
|
627
806
|
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
|
|
628
807
|
try {
|
|
@@ -678,6 +857,46 @@ function builtEntrypoints(distDir) {
|
|
|
678
857
|
});
|
|
679
858
|
return out;
|
|
680
859
|
}
|
|
860
|
+
function engineSanitize(input) {
|
|
861
|
+
return input.toLowerCase().replace(/[^a-z0-9 ]/gi, "").trim().replace(/\s+/g, "-");
|
|
862
|
+
}
|
|
863
|
+
function newestZip(dir, since, match) {
|
|
864
|
+
try {
|
|
865
|
+
const fresh = node_fs.readdirSync(dir).filter((name)=>name.endsWith(".zip") && (!match || match(name))).map((name)=>{
|
|
866
|
+
const full = node_path.join(dir, name);
|
|
867
|
+
return {
|
|
868
|
+
full,
|
|
869
|
+
mtimeMs: node_fs.statSync(full).mtimeMs
|
|
870
|
+
};
|
|
871
|
+
}).filter((entry)=>entry.mtimeMs >= since).sort((a, b)=>b.mtimeMs - a.mtimeMs);
|
|
872
|
+
return fresh[0]?.full ?? null;
|
|
873
|
+
} catch {
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
function engineZipBase(distDir, projectPath) {
|
|
878
|
+
let manifest = {};
|
|
879
|
+
try {
|
|
880
|
+
manifest = JSON.parse(node_fs.readFileSync(node_path.join(distDir, "manifest.json"), "utf8"));
|
|
881
|
+
} catch {}
|
|
882
|
+
const rawName = "string" != typeof manifest.name || /^__MSG_.+__$/.test(manifest.name) ? node_path.basename(node_path.resolve(projectPath)) : manifest.name;
|
|
883
|
+
const version = "string" == typeof manifest.version && manifest.version ? manifest.version : "0.0.0";
|
|
884
|
+
return `${engineSanitize(rawName)}-${version}`;
|
|
885
|
+
}
|
|
886
|
+
function locateDistZip(projectPath, browser, zipFilename, since) {
|
|
887
|
+
const distDir = node_path.resolve(projectPath, "dist", browser);
|
|
888
|
+
const base = zipFilename ? engineSanitize(zipFilename) : engineZipBase(distDir, projectPath);
|
|
889
|
+
const expected = node_path.join(distDir, `${base}.zip`);
|
|
890
|
+
if (node_fs.existsSync(expected)) return expected;
|
|
891
|
+
return newestZip(distDir, since);
|
|
892
|
+
}
|
|
893
|
+
function locateSourceZip(projectPath, browser, since) {
|
|
894
|
+
const distDir = node_path.resolve(projectPath, "dist", browser);
|
|
895
|
+
const distRoot = node_path.resolve(projectPath, "dist");
|
|
896
|
+
const expected = node_path.join(distRoot, `${engineZipBase(distDir, projectPath)}-source.zip`);
|
|
897
|
+
if (node_fs.existsSync(expected)) return expected;
|
|
898
|
+
return newestZip(distRoot, since, (name)=>name.endsWith("-source.zip"));
|
|
899
|
+
}
|
|
681
900
|
const build_schema = {
|
|
682
901
|
name: "extension_build",
|
|
683
902
|
description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
|
|
@@ -816,6 +1035,8 @@ async function build_handler(args) {
|
|
|
816
1035
|
duration: Date.now() - start,
|
|
817
1036
|
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."
|
|
818
1037
|
});
|
|
1038
|
+
const clobberedSessions = liveProjectSessions(args.projectPath).filter((session)=>session.browser === browser);
|
|
1039
|
+
const warnings = clobberedSessions.map((session)=>`A live dev session (pid ${session.pid}) is running on this project for ${browser}, and this build wrote over its dist/${browser} output. The dev browser may now serve the production artifact instead of the dev build until the next recompile. Run extension_stop, or let dev recompile on the next source change, to resolve it.`);
|
|
819
1040
|
const cliArgs = [
|
|
820
1041
|
"build",
|
|
821
1042
|
args.projectPath,
|
|
@@ -858,6 +1079,9 @@ async function build_handler(args) {
|
|
|
858
1079
|
manifestWarnings: preflight.warnings
|
|
859
1080
|
} : {},
|
|
860
1081
|
...buildWarnings,
|
|
1082
|
+
...warnings.length ? {
|
|
1083
|
+
warnings
|
|
1084
|
+
} : {},
|
|
861
1085
|
duration,
|
|
862
1086
|
output: lastLines(out, 12),
|
|
863
1087
|
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."
|
|
@@ -884,7 +1108,26 @@ async function build_handler(args) {
|
|
|
884
1108
|
productionDivergence: divergence
|
|
885
1109
|
} : {};
|
|
886
1110
|
})(),
|
|
1111
|
+
...warnings.length ? {
|
|
1112
|
+
warnings
|
|
1113
|
+
} : {},
|
|
887
1114
|
zip: args.zip ?? false,
|
|
1115
|
+
...args.zip ? (()=>{
|
|
1116
|
+
const zipPath = locateDistZip(args.projectPath, browser, args.zipFilename, start);
|
|
1117
|
+
return zipPath ? {
|
|
1118
|
+
zipPath
|
|
1119
|
+
} : {
|
|
1120
|
+
zipPathNote: `zip: true was requested and the build succeeded, but no .zip file could be located in dist/${browser}. The engine may not have packaged it; check the build output below.`
|
|
1121
|
+
};
|
|
1122
|
+
})() : {},
|
|
1123
|
+
...args.zipSource ? (()=>{
|
|
1124
|
+
const zipSourcePath = locateSourceZip(args.projectPath, browser, start);
|
|
1125
|
+
return zipSourcePath ? {
|
|
1126
|
+
zipSourcePath
|
|
1127
|
+
} : {
|
|
1128
|
+
zipSourcePathNote: "zipSource: true was requested and the build succeeded, but no *-source.zip file could be located in dist/. The engine may not have packaged it; check the build output below."
|
|
1129
|
+
};
|
|
1130
|
+
})() : {},
|
|
888
1131
|
duration,
|
|
889
1132
|
output: lastLines(out, 12)
|
|
890
1133
|
});
|
|
@@ -894,172 +1137,13 @@ async function build_handler(args) {
|
|
|
894
1137
|
success: false,
|
|
895
1138
|
browser,
|
|
896
1139
|
error: message.slice(0, 1200),
|
|
1140
|
+
...warnings.length ? {
|
|
1141
|
+
warnings
|
|
1142
|
+
} : {},
|
|
897
1143
|
duration,
|
|
898
1144
|
hint: "Check that the project has a valid src/manifest.json and its dependencies are installed (extension_dev auto-installs; build does not)."
|
|
899
1145
|
});
|
|
900
1146
|
}
|
|
901
|
-
const sessions = new Map();
|
|
902
|
-
function sessionKey(projectPath, browser) {
|
|
903
|
-
return `${node_path.resolve(projectPath)}::${browser}`;
|
|
904
|
-
}
|
|
905
|
-
function markerDir() {
|
|
906
|
-
return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
|
|
907
|
-
}
|
|
908
|
-
function markerPath(projectPath, browser) {
|
|
909
|
-
const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
|
|
910
|
-
return node_path.join(markerDir(), `${digest}.json`);
|
|
911
|
-
}
|
|
912
|
-
function removeSessionMarker(projectPath, browser) {
|
|
913
|
-
try {
|
|
914
|
-
node_fs.rmSync(markerPath(projectPath, browser), {
|
|
915
|
-
force: true
|
|
916
|
-
});
|
|
917
|
-
} catch {}
|
|
918
|
-
}
|
|
919
|
-
function listSessionMarkers() {
|
|
920
|
-
let files;
|
|
921
|
-
try {
|
|
922
|
-
files = node_fs.readdirSync(markerDir());
|
|
923
|
-
} catch {
|
|
924
|
-
return [];
|
|
925
|
-
}
|
|
926
|
-
const out = [];
|
|
927
|
-
for (const file of files)if (file.endsWith(".json")) try {
|
|
928
|
-
const parsed = JSON.parse(node_fs.readFileSync(node_path.join(markerDir(), file), "utf8"));
|
|
929
|
-
if ("string" == typeof parsed?.projectPath && "string" == typeof parsed?.browser) out.push(parsed);
|
|
930
|
-
} catch {}
|
|
931
|
-
return out;
|
|
932
|
-
}
|
|
933
|
-
function registerSession(info) {
|
|
934
|
-
sessions.set(sessionKey(info.projectPath, info.browser), info);
|
|
935
|
-
try {
|
|
936
|
-
node_fs.mkdirSync(markerDir(), {
|
|
937
|
-
recursive: true
|
|
938
|
-
});
|
|
939
|
-
node_fs.writeFileSync(markerPath(info.projectPath, info.browser), JSON.stringify({
|
|
940
|
-
...info,
|
|
941
|
-
projectPath: node_path.resolve(info.projectPath),
|
|
942
|
-
registeredAt: new Date().toISOString()
|
|
943
|
-
}));
|
|
944
|
-
} catch {}
|
|
945
|
-
}
|
|
946
|
-
function getSession(projectPath, browser) {
|
|
947
|
-
return sessions.get(sessionKey(projectPath, browser));
|
|
948
|
-
}
|
|
949
|
-
function removeSession(projectPath, browser) {
|
|
950
|
-
sessions.delete(sessionKey(projectPath, browser));
|
|
951
|
-
}
|
|
952
|
-
function listSessions() {
|
|
953
|
-
return Array.from(sessions.values());
|
|
954
|
-
}
|
|
955
|
-
function contractSightings(projectPath) {
|
|
956
|
-
const root = node_path.resolve(projectPath, "dist", "extension-js");
|
|
957
|
-
let dirs;
|
|
958
|
-
try {
|
|
959
|
-
dirs = node_fs.readdirSync(root);
|
|
960
|
-
} catch {
|
|
961
|
-
return [];
|
|
962
|
-
}
|
|
963
|
-
const sightings = [];
|
|
964
|
-
for (const dir of dirs){
|
|
965
|
-
const readyPath = node_path.join(root, dir, "ready.json");
|
|
966
|
-
try {
|
|
967
|
-
const stat = node_fs.statSync(readyPath);
|
|
968
|
-
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
969
|
-
if (contract?.status !== "ready") continue;
|
|
970
|
-
sightings.push({
|
|
971
|
-
browser: dir,
|
|
972
|
-
mtimeMs: stat.mtimeMs,
|
|
973
|
-
pid: "number" == typeof contract.pid ? contract.pid : void 0
|
|
974
|
-
});
|
|
975
|
-
} catch {}
|
|
976
|
-
}
|
|
977
|
-
return sightings;
|
|
978
|
-
}
|
|
979
|
-
function pidAlive(pid) {
|
|
980
|
-
try {
|
|
981
|
-
process.kill(pid, 0);
|
|
982
|
-
return true;
|
|
983
|
-
} catch {
|
|
984
|
-
return false;
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
function knownSessionBrowsers(projectPath) {
|
|
988
|
-
const resolved = node_path.resolve(projectPath);
|
|
989
|
-
const browsers = [];
|
|
990
|
-
for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
|
|
991
|
-
for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
|
|
992
|
-
return Array.from(new Set(browsers));
|
|
993
|
-
}
|
|
994
|
-
function liveProjectSessions(projectPath) {
|
|
995
|
-
const resolved = node_path.resolve(projectPath);
|
|
996
|
-
const out = new Map();
|
|
997
|
-
for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) {
|
|
998
|
-
if (pidAlive(session.pid)) out.set(session.browser, {
|
|
999
|
-
browser: session.browser,
|
|
1000
|
-
pid: session.pid,
|
|
1001
|
-
source: "registry"
|
|
1002
|
-
});
|
|
1003
|
-
}
|
|
1004
|
-
for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && pidAlive(sighting.pid)) {
|
|
1005
|
-
if (!out.has(sighting.browser)) out.set(sighting.browser, {
|
|
1006
|
-
browser: sighting.browser,
|
|
1007
|
-
pid: sighting.pid,
|
|
1008
|
-
source: "contract"
|
|
1009
|
-
});
|
|
1010
|
-
}
|
|
1011
|
-
return [
|
|
1012
|
-
...out.values()
|
|
1013
|
-
];
|
|
1014
|
-
}
|
|
1015
|
-
function deadReadySession(projectPath) {
|
|
1016
|
-
for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
|
|
1017
|
-
browser: sighting.browser,
|
|
1018
|
-
pid: sighting.pid
|
|
1019
|
-
};
|
|
1020
|
-
return null;
|
|
1021
|
-
}
|
|
1022
|
-
function browserExitStamp(projectPath, browser, since) {
|
|
1023
|
-
const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
1024
|
-
try {
|
|
1025
|
-
const stat = node_fs.statSync(readyPath);
|
|
1026
|
-
if (stat.mtimeMs < since) return null;
|
|
1027
|
-
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
1028
|
-
const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
|
|
1029
|
-
if (contract?.status === "error" && exited) return {
|
|
1030
|
-
code: contract.code,
|
|
1031
|
-
browserExitCode: contract.browserExitCode ?? null,
|
|
1032
|
-
browserExitedAt: contract.browserExitedAt
|
|
1033
|
-
};
|
|
1034
|
-
} catch {}
|
|
1035
|
-
return null;
|
|
1036
|
-
}
|
|
1037
|
-
function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
|
|
1038
|
-
if (explicit) return {
|
|
1039
|
-
browser: explicit,
|
|
1040
|
-
source: "explicit"
|
|
1041
|
-
};
|
|
1042
|
-
const resolved = node_path.resolve(projectPath);
|
|
1043
|
-
const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
|
|
1044
|
-
if (mine.length > 0) return {
|
|
1045
|
-
browser: mine[mine.length - 1].browser,
|
|
1046
|
-
source: "session"
|
|
1047
|
-
};
|
|
1048
|
-
const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
|
|
1049
|
-
const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
|
|
1050
|
-
if (live.length > 0) return {
|
|
1051
|
-
browser: live[0].browser,
|
|
1052
|
-
source: "contract"
|
|
1053
|
-
};
|
|
1054
|
-
if (sightings.length > 0) return {
|
|
1055
|
-
browser: sightings[0].browser,
|
|
1056
|
-
source: "stale"
|
|
1057
|
-
};
|
|
1058
|
-
return {
|
|
1059
|
-
browser: fallback,
|
|
1060
|
-
source: "fallback"
|
|
1061
|
-
};
|
|
1062
|
-
}
|
|
1063
1147
|
const stop_schema = {
|
|
1064
1148
|
name: "extension_stop",
|
|
1065
1149
|
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.",
|
|
@@ -1380,7 +1464,8 @@ async function dev_handler(args) {
|
|
|
1380
1464
|
browser,
|
|
1381
1465
|
port: args.port,
|
|
1382
1466
|
projectPath: args.projectPath,
|
|
1383
|
-
command: "dev"
|
|
1467
|
+
command: "dev",
|
|
1468
|
+
noBrowser: Boolean(args.noBrowser)
|
|
1384
1469
|
});
|
|
1385
1470
|
child.on("exit", ()=>removeSession(args.projectPath, browser));
|
|
1386
1471
|
await new Promise((resolve)=>setTimeout(resolve, 3000));
|
|
@@ -1437,11 +1522,30 @@ async function dev_handler(args) {
|
|
|
1437
1522
|
allowEval: Boolean(args.allowEval),
|
|
1438
1523
|
unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
|
|
1439
1524
|
};
|
|
1525
|
+
const boundPort = contractBoundPort(args.projectPath, browser, spawnedAt);
|
|
1526
|
+
if (null !== boundPort && boundPort !== args.port) registerSession({
|
|
1527
|
+
pid,
|
|
1528
|
+
browser,
|
|
1529
|
+
port: boundPort,
|
|
1530
|
+
projectPath: args.projectPath,
|
|
1531
|
+
command: "dev",
|
|
1532
|
+
noBrowser: Boolean(args.noBrowser)
|
|
1533
|
+
});
|
|
1534
|
+
const portReport = null !== boundPort ? {
|
|
1535
|
+
port: boundPort,
|
|
1536
|
+
...void 0 !== args.port && args.port !== boundPort ? {
|
|
1537
|
+
requestedPort: args.port,
|
|
1538
|
+
portNote: `Requested port ${args.port} was not available; the dev server bound ${boundPort} (read from the engine's ready.json contract, the same source extension_wait reports).`
|
|
1539
|
+
} : {}
|
|
1540
|
+
} : {
|
|
1541
|
+
requestedPort: args.port ?? 8080,
|
|
1542
|
+
portNote: "The engine has not stamped its ready.json contract yet, so the bound port is not known at response time (a taken port makes the server bind the next free one). extension_wait reports the bound port from that contract once it lands; requestedPort above is only what was asked for."
|
|
1543
|
+
};
|
|
1440
1544
|
return JSON.stringify({
|
|
1441
1545
|
ok: true,
|
|
1442
1546
|
pid,
|
|
1443
1547
|
browser,
|
|
1444
|
-
|
|
1548
|
+
...portReport,
|
|
1445
1549
|
projectPath: args.projectPath,
|
|
1446
1550
|
status: "started",
|
|
1447
1551
|
...replaced.length > 0 ? {
|
|
@@ -1451,7 +1555,7 @@ async function dev_handler(args) {
|
|
|
1451
1555
|
} : {}
|
|
1452
1556
|
} : {},
|
|
1453
1557
|
capabilities,
|
|
1454
|
-
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). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
|
|
1558
|
+
hint: args.noBrowser ? "Build-only session (noBrowser: true): no browser will launch, so no runtime will ever attach. extension_wait returns as soon as the first compile lands (compiled: true, browserAttached: false) instead of waiting out its budget; do not wait for a browser. The control verbs (storage/reload/open/dom_inspect/eval) need a live browser and will not work against this session. When you are done, call extension_stop to shut down the dev server." : "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). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
|
|
1455
1559
|
earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
|
|
1456
1560
|
logPath
|
|
1457
1561
|
});
|
|
@@ -1463,7 +1567,10 @@ function denoiseEarlyOutput(raw) {
|
|
|
1463
1567
|
/^npm warn config/i,
|
|
1464
1568
|
/V8: .*Invalid asm\.js/i,
|
|
1465
1569
|
/^\(node:\d+\) V8:/i,
|
|
1466
|
-
/Use `node --trace-warnings/i
|
|
1570
|
+
/Use `node --trace-warnings/i,
|
|
1571
|
+
/Invalid asm\.js:/i,
|
|
1572
|
+
/Linking failure in asm\.js/i,
|
|
1573
|
+
/Successfully compiled asm\.js/i
|
|
1467
1574
|
];
|
|
1468
1575
|
return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
|
|
1469
1576
|
}
|
|
@@ -2307,6 +2414,9 @@ function walkDir(dir, base = "") {
|
|
|
2307
2414
|
else if ([
|
|
2308
2415
|
".map"
|
|
2309
2416
|
].includes(ext)) type = "sourcemap";
|
|
2417
|
+
else if ([
|
|
2418
|
+
".zip"
|
|
2419
|
+
].includes(ext)) type = "archive";
|
|
2310
2420
|
entries.push({
|
|
2311
2421
|
path: rel,
|
|
2312
2422
|
size: stat.size,
|
|
@@ -2347,7 +2457,8 @@ async function inspect_handler(args) {
|
|
|
2347
2457
|
}
|
|
2348
2458
|
const sourcemapSize = byType.sourcemap?.size ?? 0;
|
|
2349
2459
|
const buildType = sourcemapSize > 0 ? "development" : "production";
|
|
2350
|
-
const
|
|
2460
|
+
const archiveSize = byType.archive?.size ?? 0;
|
|
2461
|
+
const shippableSize = totalSize - sourcemapSize - archiveSize;
|
|
2351
2462
|
const sizeByPath = new Map(files.map((f)=>[
|
|
2352
2463
|
f.path,
|
|
2353
2464
|
f.size
|
|
@@ -2377,7 +2488,7 @@ async function inspect_handler(args) {
|
|
|
2377
2488
|
});
|
|
2378
2489
|
const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
|
|
2379
2490
|
const sizeWarnings = [];
|
|
2380
|
-
for (const f of files)if ("sourcemap" !== f.type) {
|
|
2491
|
+
for (const f of files)if ("sourcemap" !== f.type && "archive" !== f.type) {
|
|
2381
2492
|
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.`);
|
|
2382
2493
|
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.`);
|
|
2383
2494
|
}
|
|
@@ -2397,6 +2508,9 @@ async function inspect_handler(args) {
|
|
|
2397
2508
|
..."development" === buildType ? {
|
|
2398
2509
|
note: `This dist contains ${formatBytes(sourcemapSize)} of sourcemaps and looks like a dev build; run extension_build for production sizes. shippableSize excludes sourcemaps.`
|
|
2399
2510
|
} : {},
|
|
2511
|
+
...archiveSize > 0 ? {
|
|
2512
|
+
archiveNote: `This dist contains ${formatBytes(archiveSize)} of .zip archive(s) (store packaging output, written into dist by zip builds). shippableSize excludes them so the packaged copy does not double-count the files it contains.`
|
|
2513
|
+
} : {},
|
|
2400
2514
|
manifest: {
|
|
2401
2515
|
name: manifest.name,
|
|
2402
2516
|
version: manifest.version,
|
|
@@ -2429,7 +2543,7 @@ async function inspect_handler(args) {
|
|
|
2429
2543
|
has128Icon: "string" == typeof manifest.icons?.["128"],
|
|
2430
2544
|
noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
|
|
2431
2545
|
noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
|
|
2432
|
-
under10MB: totalSize < 10485760
|
|
2546
|
+
under10MB: totalSize - archiveSize < 10485760
|
|
2433
2547
|
}
|
|
2434
2548
|
};
|
|
2435
2549
|
return JSON.stringify(result);
|
|
@@ -3909,6 +4023,57 @@ function surfaceDocument(projectPath, browser, surface) {
|
|
|
3909
4023
|
}
|
|
3910
4024
|
return null;
|
|
3911
4025
|
}
|
|
4026
|
+
const SURFACE_MANIFEST_KEYS = {
|
|
4027
|
+
popup: "action.default_popup",
|
|
4028
|
+
options: "options_ui.page (or options_page)",
|
|
4029
|
+
sidebar: "side_panel.default_path (or sidebar_action.default_panel)",
|
|
4030
|
+
newtab: "chrome_url_overrides.newtab",
|
|
4031
|
+
history: "chrome_url_overrides.history",
|
|
4032
|
+
bookmarks: "chrome_url_overrides.bookmarks"
|
|
4033
|
+
};
|
|
4034
|
+
function declaredSurfaces(projectPath, browser) {
|
|
4035
|
+
const candidates = [
|
|
4036
|
+
node_path.join(projectPath, "dist", browser, "manifest.json"),
|
|
4037
|
+
node_path.join(projectPath, "dist", "manifest.json"),
|
|
4038
|
+
node_path.join(projectPath, "src", "manifest.json"),
|
|
4039
|
+
node_path.join(projectPath, "manifest.json")
|
|
4040
|
+
];
|
|
4041
|
+
const readable = candidates.some((file)=>{
|
|
4042
|
+
try {
|
|
4043
|
+
JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
4044
|
+
return true;
|
|
4045
|
+
} catch {
|
|
4046
|
+
return false;
|
|
4047
|
+
}
|
|
4048
|
+
});
|
|
4049
|
+
if (!readable) return null;
|
|
4050
|
+
return Object.keys(SURFACE_MANIFEST_KEYS).filter((s)=>null !== surfaceDocument(projectPath, browser, s));
|
|
4051
|
+
}
|
|
4052
|
+
function missingSurfaceError(projectPath, browser, surface, consequence) {
|
|
4053
|
+
const declared = declaredSurfaces(projectPath, browser);
|
|
4054
|
+
if (null === declared) return JSON.stringify({
|
|
4055
|
+
ok: false,
|
|
4056
|
+
error: {
|
|
4057
|
+
name: "NoSurfaceDocument",
|
|
4058
|
+
message: `No readable manifest was found for this project (checked dist/${browser}, dist, src, and the project root), so the ${surface} document cannot be resolved.`
|
|
4059
|
+
},
|
|
4060
|
+
hint: "Check projectPath, or build the project first (extension_build)."
|
|
4061
|
+
});
|
|
4062
|
+
const key = SURFACE_MANIFEST_KEYS[surface] ?? surface;
|
|
4063
|
+
const others = declared.filter((s)=>s !== surface);
|
|
4064
|
+
const nextVerb = "popup" === surface ? 'To exercise the toolbar button of a popup-less extension, call extension_open with surface: "action", which replays chrome.action.onClicked. To give the extension a popup, set action.default_popup in the manifest and rebuild.' : `To add one, set ${key} in the manifest and rebuild.`;
|
|
4065
|
+
return JSON.stringify({
|
|
4066
|
+
ok: false,
|
|
4067
|
+
error: {
|
|
4068
|
+
name: "NoSurfaceDocument",
|
|
4069
|
+
message: `This extension declares no ${surface}: nothing in its manifest sets ${key}, ${consequence}. That is how the extension is built, not a failure of the session or the tooling.`
|
|
4070
|
+
},
|
|
4071
|
+
...others.length ? {
|
|
4072
|
+
declaredSurfaces: others
|
|
4073
|
+
} : {},
|
|
4074
|
+
hint: (others.length ? `Surfaces this extension does declare: ${others.join(", ")}; extension_open can target those. ` : "The manifest declares no other UI surface documents either. ") + nextVerb
|
|
4075
|
+
});
|
|
4076
|
+
}
|
|
3912
4077
|
const POPUP_MIN = 25;
|
|
3913
4078
|
const POPUP_MAX_WIDTH = 800;
|
|
3914
4079
|
const POPUP_MAX_HEIGHT = 600;
|
|
@@ -3967,14 +4132,7 @@ async function applyPopupBounds(projectPath, browser, targetId) {
|
|
|
3967
4132
|
}
|
|
3968
4133
|
async function openSurfaceAsTab(projectPath, browser, surface) {
|
|
3969
4134
|
const doc = surfaceDocument(projectPath, browser, surface);
|
|
3970
|
-
if (!doc) return
|
|
3971
|
-
ok: false,
|
|
3972
|
-
error: {
|
|
3973
|
-
name: "NoSurfaceDocument",
|
|
3974
|
-
message: `The manifest declares no document for surface "${surface}", so there is no page to render as a tab.`
|
|
3975
|
-
},
|
|
3976
|
-
hint: "Check the manifest: popup needs action.default_popup, options needs options_ui.page or options_page, sidebar needs side_panel.default_path."
|
|
3977
|
-
});
|
|
4135
|
+
if (!doc) return missingSurfaceError(projectPath, browser, surface, "so there is no page to render as a tab");
|
|
3978
4136
|
const id = await resolveExtensionId(projectPath, browser);
|
|
3979
4137
|
if (!id) return JSON.stringify({
|
|
3980
4138
|
ok: false,
|
|
@@ -4087,6 +4245,10 @@ async function open_handler(args) {
|
|
|
4087
4245
|
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."
|
|
4088
4246
|
});
|
|
4089
4247
|
}
|
|
4248
|
+
if ("popup" === args.surface) {
|
|
4249
|
+
const declared = declaredSurfaces(args.projectPath, browser);
|
|
4250
|
+
if (declared && !declared.includes("popup")) return missingSurfaceError(args.projectPath, browser, "popup", "so there is no popup to open");
|
|
4251
|
+
}
|
|
4090
4252
|
const cli = [
|
|
4091
4253
|
"open",
|
|
4092
4254
|
args.surface,
|
|
@@ -4110,12 +4272,12 @@ async function open_handler(args) {
|
|
|
4110
4272
|
try {
|
|
4111
4273
|
const parsedFallback = JSON.parse(fallback);
|
|
4112
4274
|
if (parsedFallback?.ok) {
|
|
4113
|
-
parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS),
|
|
4275
|
+
parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS=1), and a real popup/sidebar window can only open in a headed session, so the surface was rendered as a tab instead. For the real window, start a headed session: extension_dev with replace: true, with EXTENSION_HEADLESS=0 (or unset) in the environment, then open the surface again without asTab.";
|
|
4114
4276
|
return JSON.stringify(parsedFallback);
|
|
4115
4277
|
}
|
|
4116
4278
|
} catch {}
|
|
4117
4279
|
}
|
|
4118
|
-
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),
|
|
4280
|
+
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=1), and a popup/sidebar window needs a headed session. Retry with asTab: true to render the surface document in a tab, or start a headed session for the real window: extension_dev with replace: true, with EXTENSION_HEADLESS=0 (or unset) in the environment.";
|
|
4119
4281
|
return JSON.stringify(parsed);
|
|
4120
4282
|
}
|
|
4121
4283
|
} catch {}
|
|
@@ -5227,6 +5389,8 @@ async function doctor_handler(args) {
|
|
|
5227
5389
|
}
|
|
5228
5390
|
}
|
|
5229
5391
|
const SAFE_CEILING_MS = 50000;
|
|
5392
|
+
const DEFAULT_TIMEOUT_MS = 45000;
|
|
5393
|
+
const MIN_TIMEOUT_MS = 1000;
|
|
5230
5394
|
function wait_isAlive(pid) {
|
|
5231
5395
|
try {
|
|
5232
5396
|
process.kill(pid, 0);
|
|
@@ -5237,7 +5401,7 @@ function wait_isAlive(pid) {
|
|
|
5237
5401
|
}
|
|
5238
5402
|
const wait_schema = {
|
|
5239
5403
|
name: "extension_wait",
|
|
5240
|
-
description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status
|
|
5404
|
+
description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status with two separate facts: compiled (the compiler finished) and browserAttached (the extension's runtime connected from a live browser). Every result reports budgetMs (this call's wait budget) and elapsedMs; on status:'timeout' call again to keep waiting (polling resumes on the same contract). In a noBrowser (build-only) session it returns as soon as the compile lands instead of waiting for a browser that will never attach. Ports in the result come from the ready contract, so they always match what the dev server actually bound.",
|
|
5241
5405
|
inputSchema: {
|
|
5242
5406
|
type: "object",
|
|
5243
5407
|
properties: {
|
|
@@ -5249,10 +5413,14 @@ const wait_schema = {
|
|
|
5249
5413
|
type: "string",
|
|
5250
5414
|
description: "Browser to check readiness for. Defaults to the active dev session's browser for this project."
|
|
5251
5415
|
},
|
|
5416
|
+
timeoutMs: {
|
|
5417
|
+
type: "number",
|
|
5418
|
+
default: DEFAULT_TIMEOUT_MS,
|
|
5419
|
+
description: `Wait budget in milliseconds for this call. Default ${DEFAULT_TIMEOUT_MS}; clamped to ${MIN_TIMEOUT_MS}-${SAFE_CEILING_MS} (the ceiling keeps one call under the MCP client's 60s request timeout). On timeout the result reports elapsedMs plus what was observed (compiled, browserAttached); call again to keep waiting, polling resumes on the same contract.`
|
|
5420
|
+
},
|
|
5252
5421
|
timeout: {
|
|
5253
5422
|
type: "number",
|
|
5254
|
-
|
|
5255
|
-
description: "Requested wait in milliseconds. Clamped to ~50s per call so it never exceeds the MCP client request timeout; call again to keep waiting for slower builds."
|
|
5423
|
+
description: "Deprecated alias of timeoutMs, kept for callers that already pass it. timeoutMs wins when both are given."
|
|
5256
5424
|
}
|
|
5257
5425
|
},
|
|
5258
5426
|
required: [
|
|
@@ -5262,26 +5430,47 @@ const wait_schema = {
|
|
|
5262
5430
|
};
|
|
5263
5431
|
async function wait_handler(args) {
|
|
5264
5432
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
|
|
5265
|
-
const requested = args.timeout ??
|
|
5266
|
-
const
|
|
5433
|
+
const requested = args.timeoutMs ?? args.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
5434
|
+
const budgetMs = Math.min(Math.max(requested, MIN_TIMEOUT_MS), SAFE_CEILING_MS);
|
|
5267
5435
|
const clamped = requested > SAFE_CEILING_MS;
|
|
5268
5436
|
const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
|
|
5437
|
+
const buildOnly = findSessionInfo(args.projectPath, browser)?.noBrowser === true;
|
|
5269
5438
|
const start = Date.now();
|
|
5270
5439
|
const pollInterval = 1000;
|
|
5271
5440
|
let sawCompiledButUnattached = false;
|
|
5272
|
-
|
|
5441
|
+
let lastContractStatus = null;
|
|
5442
|
+
while(Date.now() - start < budgetMs){
|
|
5273
5443
|
try {
|
|
5274
5444
|
const raw = node_fs.readFileSync(readyPath, "utf8");
|
|
5275
5445
|
const contract = JSON.parse(raw);
|
|
5446
|
+
lastContractStatus = contract.status;
|
|
5276
5447
|
if ("ready" === contract.status) {
|
|
5277
5448
|
if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
|
|
5278
5449
|
status: "stale",
|
|
5279
5450
|
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.`,
|
|
5280
5451
|
browser: contract.browser,
|
|
5281
5452
|
pid: contract.pid,
|
|
5282
|
-
|
|
5453
|
+
budgetMs,
|
|
5454
|
+
elapsedMs: Date.now() - start
|
|
5283
5455
|
});
|
|
5284
5456
|
const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
|
|
5457
|
+
if (!attached && buildOnly) return JSON.stringify({
|
|
5458
|
+
status: "ready",
|
|
5459
|
+
buildOnly: true,
|
|
5460
|
+
compiled: true,
|
|
5461
|
+
browserAttached: false,
|
|
5462
|
+
message: "Build-only session (noBrowser): the extension compiled and the dev server is live, but no browser was launched, so browserAttached will never become true. Do not call extension_wait again to wait for a browser. The control verbs (storage/reload/open/dom_inspect/eval) need a live browser and will not work against this session.",
|
|
5463
|
+
command: contract.command,
|
|
5464
|
+
browser: contract.browser,
|
|
5465
|
+
port: contract.port,
|
|
5466
|
+
pid: contract.pid,
|
|
5467
|
+
distPath: contract.distPath,
|
|
5468
|
+
manifestPath: contract.manifestPath,
|
|
5469
|
+
compiledAt: contract.compiledAt,
|
|
5470
|
+
startedAt: contract.startedAt,
|
|
5471
|
+
budgetMs,
|
|
5472
|
+
elapsedMs: Date.now() - start
|
|
5473
|
+
});
|
|
5285
5474
|
if (!attached) {
|
|
5286
5475
|
await new Promise((r)=>setTimeout(r, pollInterval));
|
|
5287
5476
|
sawCompiledButUnattached = true;
|
|
@@ -5290,6 +5479,8 @@ async function wait_handler(args) {
|
|
|
5290
5479
|
const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
|
|
5291
5480
|
return JSON.stringify({
|
|
5292
5481
|
status: "ready",
|
|
5482
|
+
compiled: true,
|
|
5483
|
+
browserAttached: true,
|
|
5293
5484
|
command: contract.command,
|
|
5294
5485
|
browser: contract.browser,
|
|
5295
5486
|
port: contract.port,
|
|
@@ -5298,7 +5489,8 @@ async function wait_handler(args) {
|
|
|
5298
5489
|
manifestPath: contract.manifestPath,
|
|
5299
5490
|
compiledAt: contract.compiledAt,
|
|
5300
5491
|
startedAt: contract.startedAt,
|
|
5301
|
-
|
|
5492
|
+
budgetMs,
|
|
5493
|
+
elapsedMs: Date.now() - start,
|
|
5302
5494
|
...runtimeErrors.length ? {
|
|
5303
5495
|
runtimeErrors,
|
|
5304
5496
|
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.`
|
|
@@ -5311,23 +5503,30 @@ async function wait_handler(args) {
|
|
|
5311
5503
|
errors: contract.errors,
|
|
5312
5504
|
code: contract.code,
|
|
5313
5505
|
browser: contract.browser,
|
|
5314
|
-
|
|
5506
|
+
budgetMs,
|
|
5507
|
+
elapsedMs: Date.now() - start
|
|
5315
5508
|
});
|
|
5316
5509
|
} catch {}
|
|
5317
5510
|
await new Promise((resolve)=>setTimeout(resolve, pollInterval));
|
|
5318
5511
|
}
|
|
5319
5512
|
if (sawCompiledButUnattached) return JSON.stringify({
|
|
5320
5513
|
status: "compiled-not-attached",
|
|
5321
|
-
|
|
5514
|
+
compiled: true,
|
|
5515
|
+
browserAttached: false,
|
|
5516
|
+
message: `The extension compiled, but the runtime executor never attached within this call's ${budgetMs}ms budget. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`,
|
|
5322
5517
|
readyPath,
|
|
5323
|
-
|
|
5518
|
+
budgetMs,
|
|
5519
|
+
elapsedMs: Date.now() - start,
|
|
5324
5520
|
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."
|
|
5325
5521
|
});
|
|
5326
5522
|
return JSON.stringify({
|
|
5327
5523
|
status: "timeout",
|
|
5328
|
-
|
|
5524
|
+
compiled: false,
|
|
5525
|
+
browserAttached: false,
|
|
5526
|
+
message: "starting" === lastContractStatus ? `Not ready after ${budgetMs}ms this call: the dev server stamped its contract (status: starting) but the first compile has not landed yet.` : `Not ready after ${budgetMs}ms this call: no ready contract was observed at ${readyPath}, so neither the compile nor a browser attach has been seen.`,
|
|
5329
5527
|
readyPath,
|
|
5330
|
-
|
|
5528
|
+
budgetMs,
|
|
5529
|
+
elapsedMs: Date.now() - start,
|
|
5331
5530
|
clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
|
|
5332
5531
|
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."
|
|
5333
5532
|
});
|
|
@@ -5936,7 +6135,7 @@ async function login_handler(args) {
|
|
|
5936
6135
|
}
|
|
5937
6136
|
const whoami_schema = {
|
|
5938
6137
|
name: "extension_whoami",
|
|
5939
|
-
description: "Report the locally stored extension.dev
|
|
6138
|
+
description: "Report the identity carried by the locally stored extension.dev token that extension_login minted (workspace/project scoped), plus its expiry, without revealing the token. The identity comes from that stored token alone; it does not change with the current working directory or whichever project folder you are in. Returns logged-out status when no credentials are stored.",
|
|
5940
6139
|
inputSchema: {
|
|
5941
6140
|
type: "object",
|
|
5942
6141
|
properties: {}
|
|
@@ -5961,7 +6160,7 @@ async function whoami_handler() {
|
|
|
5961
6160
|
expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
|
|
5962
6161
|
expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
|
|
5963
6162
|
expired,
|
|
5964
|
-
message: expired ? "
|
|
6163
|
+
message: expired ? "The stored token has expired. Run extension_login to refresh it." : `Logged in as ${creds.workspaceSlug}/${creds.projectSlug}, per the token extension_login stored on this machine. That token is what scopes the identity: it does not follow the current working directory or project folder.`
|
|
5965
6164
|
});
|
|
5966
6165
|
}
|
|
5967
6166
|
const logout_schema = {
|
|
@@ -3,5 +3,6 @@ export declare function removeSessionMarker(projectPath: string, browser: string
|
|
|
3
3
|
export declare function listSessionMarkers(): ProcessInfo[];
|
|
4
4
|
export declare function registerSession(info: ProcessInfo): void;
|
|
5
5
|
export declare function getSession(projectPath: string, browser: string): ProcessInfo | undefined;
|
|
6
|
+
export declare function findSessionInfo(projectPath: string, browser: string): ProcessInfo | undefined;
|
|
6
7
|
export declare function removeSession(projectPath: string, browser: string): void;
|
|
7
8
|
export declare function listSessions(): ProcessInfo[];
|
|
@@ -19,4 +19,5 @@ export interface BrowserExitStamp {
|
|
|
19
19
|
browserExitedAt?: string;
|
|
20
20
|
}
|
|
21
21
|
export declare function browserExitStamp(projectPath: string, browser: string, since: number): BrowserExitStamp | null;
|
|
22
|
+
export declare function contractBoundPort(projectPath: string, browser: string, since: number): number | null;
|
|
22
23
|
export declare function resolveSessionBrowser(projectPath: string, explicit: string | undefined, fallback?: string): ResolvedBrowser;
|
package/dist/src/lib/types.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export interface TemplatesMetaV2 {
|
|
|
43
43
|
templates: TemplateMeta[];
|
|
44
44
|
}
|
|
45
45
|
export interface ReadyContract {
|
|
46
|
-
status: "ready" | "error" | "stopped";
|
|
46
|
+
status: "starting" | "ready" | "error" | "stopped";
|
|
47
47
|
message?: string;
|
|
48
48
|
errors?: string[];
|
|
49
49
|
code?: string;
|
|
@@ -56,9 +56,13 @@ export interface ReadyContract {
|
|
|
56
56
|
distPath?: string;
|
|
57
57
|
manifestPath?: string;
|
|
58
58
|
port?: number | null;
|
|
59
|
+
host?: string;
|
|
60
|
+
cdpPort?: number;
|
|
59
61
|
pid?: number;
|
|
60
62
|
ts?: string;
|
|
61
63
|
compiledAt?: string | null;
|
|
64
|
+
executorAttachedAt?: string;
|
|
65
|
+
runtime?: string;
|
|
62
66
|
}
|
|
63
67
|
export interface ProcessInfo {
|
|
64
68
|
pid: number;
|
|
@@ -66,5 +70,6 @@ export interface ProcessInfo {
|
|
|
66
70
|
port?: number;
|
|
67
71
|
projectPath: string;
|
|
68
72
|
command: "dev" | "start" | "preview";
|
|
73
|
+
noBrowser?: boolean;
|
|
69
74
|
}
|
|
70
75
|
export type BrowserType = "chrome" | "edge" | "firefox" | "chromium-based" | "gecko-based";
|
package/dist/src/tools/wait.d.ts
CHANGED
|
@@ -12,11 +12,15 @@ export declare const schema: {
|
|
|
12
12
|
type: string;
|
|
13
13
|
description: string;
|
|
14
14
|
};
|
|
15
|
-
|
|
15
|
+
timeoutMs: {
|
|
16
16
|
type: string;
|
|
17
17
|
default: number;
|
|
18
18
|
description: string;
|
|
19
19
|
};
|
|
20
|
+
timeout: {
|
|
21
|
+
type: string;
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
20
24
|
};
|
|
21
25
|
required: string[];
|
|
22
26
|
};
|
|
@@ -24,5 +28,6 @@ export declare const schema: {
|
|
|
24
28
|
export declare function handler(args: {
|
|
25
29
|
projectPath: string;
|
|
26
30
|
browser?: string;
|
|
31
|
+
timeoutMs?: number;
|
|
27
32
|
timeout?: number;
|
|
28
33
|
}): Promise<string>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@extension.dev/mcp",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.4.0",
|
|
5
5
|
"description": "MCP server that lets AI agents (Claude Code, Claude Desktop, Cursor, Copilot, Codex) build, run, inspect, and publish browser extensions. 32 tools for scaffolding, live DOM inspection, log streaming, and store-ready builds across Chrome, Edge, Firefox, Safari, and every Chromium- or Gecko-based browser (Brave, Opera, Vivaldi, Yandex, Waterfox, LibreWolf). Powered by extension.dev and Extension.js.",
|
|
6
6
|
"mcpName": "io.github.extensiondev/mcp",
|
|
7
7
|
"license": "MIT",
|
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://extension.dev",
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.4.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@extension.dev/mcp",
|
|
16
|
-
"version": "5.
|
|
16
|
+
"version": "5.4.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|