@extension.dev/mcp 10.1.0 → 10.2.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 +60 -0
- package/README.md +5 -4
- package/dist/module.js +1776 -605
- package/dist/src/index.d.ts +7 -0
- package/dist/src/lib/artifacts-api.d.ts +2 -0
- package/dist/src/lib/boot-verdict.d.ts +0 -1
- package/dist/src/lib/carrier-exit.d.ts +8 -0
- package/dist/src/lib/carrier-registry.d.ts +4 -0
- package/dist/src/lib/carrier.d.ts +8 -0
- package/dist/src/lib/engine-version.d.ts +24 -0
- package/dist/src/lib/envelope.d.ts +1 -1
- package/dist/src/lib/guest-load-oracle.d.ts +1 -0
- package/dist/src/lib/login-flow.d.ts +1 -1
- package/dist/src/lib/process-manager.d.ts +1 -0
- package/dist/src/lib/server-identity.d.ts +16 -0
- package/dist/src/lib/session-paths.d.ts +12 -0
- package/dist/src/lib/share-cors-probe.d.ts +17 -0
- package/dist/src/lib/template-artifact-source.d.ts +1 -1
- package/dist/src/lib/zip-entries.d.ts +5 -0
- package/dist/src/tools/build.d.ts +23 -0
- package/dist/src/tools/logs-constants.d.ts +2 -2
- package/dist/src/tools/logs-filter.d.ts +3 -1
- package/dist/src/tools/logs.d.ts +8 -0
- package/dist/src/tools/whoami.d.ts +3 -1
- package/package.json +20 -4
- package/server.json +2 -2
package/dist/module.js
CHANGED
|
@@ -8,6 +8,7 @@ import { extensionCreate } from "extension-create";
|
|
|
8
8
|
import node_os from "node:os";
|
|
9
9
|
import cross_spawn from "cross-spawn";
|
|
10
10
|
import node_crypto, { createHash } from "node:crypto";
|
|
11
|
+
import { CLOSE_BAD_HELLO, CLOSE_BAD_INSTANCE, CLOSE_CONTROL_UNAVAILABLE, CLOSE_SLOW_CONSUMER, CONTROL_ENVELOPE_VERSION, CONTROL_WS_PATH, buildSummaryPath, logsPath, matchesLogQuery, readLogEvents, readReadyContract, readyContractPath as bridge_readyContractPath, sessionArtifactsRootDir } from "extension-develop/bridge";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { execFile, execFileSync } from "node:child_process";
|
|
13
14
|
import ws_0 from "ws";
|
|
@@ -431,6 +432,7 @@ __webpack_require__.d(browsers_namespaceObject, {
|
|
|
431
432
|
var build_namespaceObject = {};
|
|
432
433
|
__webpack_require__.r(build_namespaceObject);
|
|
433
434
|
__webpack_require__.d(build_namespaceObject, {
|
|
435
|
+
BUNDLE_ID_PATTERN: ()=>BUNDLE_ID_PATTERN,
|
|
434
436
|
handler: ()=>build_handler,
|
|
435
437
|
schema: ()=>build_schema
|
|
436
438
|
});
|
|
@@ -481,6 +483,7 @@ __webpack_require__.d(list_extensions_namespaceObject, {
|
|
|
481
483
|
var logs_namespaceObject = {};
|
|
482
484
|
__webpack_require__.r(logs_namespaceObject);
|
|
483
485
|
__webpack_require__.d(logs_namespaceObject, {
|
|
486
|
+
controlRefusal: ()=>controlRefusal,
|
|
484
487
|
handler: ()=>logs_handler,
|
|
485
488
|
schema: ()=>logs_schema_schema
|
|
486
489
|
});
|
|
@@ -579,7 +582,7 @@ __webpack_require__.d(wait_namespaceObject, {
|
|
|
579
582
|
handler: ()=>wait_handler,
|
|
580
583
|
schema: ()=>wait_schema
|
|
581
584
|
});
|
|
582
|
-
var package_namespaceObject = JSON.parse('{"rE":"10.
|
|
585
|
+
var package_namespaceObject = JSON.parse('{"rE":"10.2.0","El":{"OP":"4.0.20"}}');
|
|
583
586
|
const ENVELOPE_SCHEMA = 1;
|
|
584
587
|
const collectWarnings = (warnings)=>{
|
|
585
588
|
if (!warnings) return [];
|
|
@@ -605,7 +608,7 @@ function envelopeObject(init) {
|
|
|
605
608
|
if ("string" == typeof init.hint && init.hint) frame.hint = init.hint;
|
|
606
609
|
return frame;
|
|
607
610
|
}
|
|
608
|
-
function
|
|
611
|
+
function envelope_envelope(init) {
|
|
609
612
|
return JSON.stringify(envelopeObject(init));
|
|
610
613
|
}
|
|
611
614
|
function sessionCommandSinceEnvelopeOwnsCommand(contract) {
|
|
@@ -769,7 +772,7 @@ async function spawnFailedEnvelope(command, spawned) {
|
|
|
769
772
|
await new Promise((resolve)=>setTimeout(resolve, 25));
|
|
770
773
|
cause = spawned.spawnError?.() ?? null;
|
|
771
774
|
}
|
|
772
|
-
return
|
|
775
|
+
return envelope_envelope({
|
|
773
776
|
ok: false,
|
|
774
777
|
command,
|
|
775
778
|
status: "spawn-failed",
|
|
@@ -865,7 +868,11 @@ function tokenTtlNote(workspaceSlug, projectSlug) {
|
|
|
865
868
|
function resolveApiBase(api) {
|
|
866
869
|
return String(api || process.env.EXTENSION_DEV_API_URL || DEFAULT_API).replace(/\/+$/, "");
|
|
867
870
|
}
|
|
868
|
-
function
|
|
871
|
+
function isExtensionDevHost(hostname) {
|
|
872
|
+
const host = hostname.toLowerCase().replace(/\.+$/, "");
|
|
873
|
+
return "extension.dev" === host || host.endsWith(".extension.dev");
|
|
874
|
+
}
|
|
875
|
+
function safeApiBase(raw, callerSupplied) {
|
|
869
876
|
let parsed;
|
|
870
877
|
try {
|
|
871
878
|
parsed = new URL(raw);
|
|
@@ -880,6 +887,11 @@ function safeApiBase(raw) {
|
|
|
880
887
|
ok: false,
|
|
881
888
|
message: `Refusing to send the access token to ${raw}: use https (http is allowed only for localhost).`
|
|
882
889
|
};
|
|
890
|
+
const fromCaller = String(callerSupplied || "").trim();
|
|
891
|
+
if (fromCaller && !isLocalhost && !isExtensionDevHost(parsed.hostname)) return {
|
|
892
|
+
ok: false,
|
|
893
|
+
message: `Refusing to send the access token to ${raw}: an api argument may only name an extension.dev host or a local dev server. To use a self-hosted platform, set EXTENSION_DEV_API_URL where the server is launched instead of passing it per call.`
|
|
894
|
+
};
|
|
883
895
|
return {
|
|
884
896
|
ok: true,
|
|
885
897
|
base: `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "")
|
|
@@ -1081,7 +1093,7 @@ class RegistryAccessTokens {
|
|
|
1081
1093
|
status: "no-credential"
|
|
1082
1094
|
};
|
|
1083
1095
|
}
|
|
1084
|
-
const check = safeApiBase(resolveApiBase(apiHint || creds?.api));
|
|
1096
|
+
const check = safeApiBase(resolveApiBase(apiHint || creds?.api), apiHint);
|
|
1085
1097
|
if (!check.ok) return {
|
|
1086
1098
|
status: "denied",
|
|
1087
1099
|
message: check.message
|
|
@@ -1415,7 +1427,7 @@ async function create_handler(args) {
|
|
|
1415
1427
|
error: capture("error")
|
|
1416
1428
|
}
|
|
1417
1429
|
});
|
|
1418
|
-
const failure = (err, transient)=>transient ?
|
|
1430
|
+
const failure = (err, transient)=>transient ? envelope_envelope({
|
|
1419
1431
|
ok: false,
|
|
1420
1432
|
command: COMMAND,
|
|
1421
1433
|
status: "template-fetch-failed",
|
|
@@ -1428,7 +1440,7 @@ async function create_handler(args) {
|
|
|
1428
1440
|
duration: Date.now() - start,
|
|
1429
1441
|
log: logTail()
|
|
1430
1442
|
}
|
|
1431
|
-
}) :
|
|
1443
|
+
}) : envelope_envelope({
|
|
1432
1444
|
ok: false,
|
|
1433
1445
|
command: COMMAND,
|
|
1434
1446
|
status: "scaffold-failed",
|
|
@@ -1455,7 +1467,7 @@ async function create_handler(args) {
|
|
|
1455
1467
|
}
|
|
1456
1468
|
}
|
|
1457
1469
|
const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
|
|
1458
|
-
if (!hasManifest) return
|
|
1470
|
+
if (!hasManifest) return envelope_envelope({
|
|
1459
1471
|
ok: false,
|
|
1460
1472
|
command: COMMAND,
|
|
1461
1473
|
status: "scaffold-incomplete",
|
|
@@ -1483,7 +1495,7 @@ async function create_handler(args) {
|
|
|
1483
1495
|
const deployUrl = `${wwwOrigin}${(0, paths_0.wwwNewPath)({
|
|
1484
1496
|
template: result.template
|
|
1485
1497
|
})}`;
|
|
1486
|
-
return
|
|
1498
|
+
return envelope_envelope({
|
|
1487
1499
|
ok: true,
|
|
1488
1500
|
command: COMMAND,
|
|
1489
1501
|
status: "created",
|
|
@@ -1525,7 +1537,7 @@ async function create_handler(args) {
|
|
|
1525
1537
|
}
|
|
1526
1538
|
const DEFAULT_MEDIA_ORIGIN = "https://media.extension.land";
|
|
1527
1539
|
const DEFAULT_CHANNEL = "latest";
|
|
1528
|
-
const PINNED_COMMIT = "
|
|
1540
|
+
const PINNED_COMMIT = "520a8ede767fe1937c7184f4857408caf9c5934f";
|
|
1529
1541
|
const CHANNEL_CACHE_TTL_MS = 300000;
|
|
1530
1542
|
function mediaOrigin() {
|
|
1531
1543
|
return (process.env.EXTENSION_MEDIA_ORIGIN || "").trim() || DEFAULT_MEDIA_ORIGIN;
|
|
@@ -1733,7 +1745,7 @@ async function searchTemplates(args) {
|
|
|
1733
1745
|
repositoryUrl: t.repositoryUrl,
|
|
1734
1746
|
downloads: t.downloads
|
|
1735
1747
|
}));
|
|
1736
|
-
return
|
|
1748
|
+
return envelope_envelope({
|
|
1737
1749
|
ok: true,
|
|
1738
1750
|
command: "extension_templates",
|
|
1739
1751
|
status: "listed",
|
|
@@ -1746,7 +1758,7 @@ async function searchTemplates(args) {
|
|
|
1746
1758
|
const get_template_source_COMMAND = "extension_templates";
|
|
1747
1759
|
async function readTemplateSource(args) {
|
|
1748
1760
|
const template = await getTemplateBySlug(args.slug);
|
|
1749
|
-
if (!template) return
|
|
1761
|
+
if (!template) return envelope_envelope({
|
|
1750
1762
|
ok: false,
|
|
1751
1763
|
command: get_template_source_COMMAND,
|
|
1752
1764
|
status: "template-not-found",
|
|
@@ -1766,7 +1778,7 @@ async function readTemplateSource(args) {
|
|
|
1766
1778
|
keyFiles: template.keyFiles,
|
|
1767
1779
|
repositoryUrl: template.repositoryUrl
|
|
1768
1780
|
};
|
|
1769
|
-
if (!args.files?.length) return
|
|
1781
|
+
if (!args.files?.length) return envelope_envelope({
|
|
1770
1782
|
ok: true,
|
|
1771
1783
|
command: get_template_source_COMMAND,
|
|
1772
1784
|
status: "file-list",
|
|
@@ -1791,7 +1803,7 @@ async function readTemplateSource(args) {
|
|
|
1791
1803
|
} catch {}
|
|
1792
1804
|
errors.push(`${filePath}: ${lastStatus || "fetch failed"}`);
|
|
1793
1805
|
}));
|
|
1794
|
-
return
|
|
1806
|
+
return envelope_envelope({
|
|
1795
1807
|
ok: 0 === errors.length,
|
|
1796
1808
|
command: get_template_source_COMMAND,
|
|
1797
1809
|
status: errors.length ? "partial" : "read",
|
|
@@ -1873,7 +1885,7 @@ const templates_schema = {
|
|
|
1873
1885
|
};
|
|
1874
1886
|
async function templates_handler(args) {
|
|
1875
1887
|
if ((args.action ?? "list") === "source" || !args.action && args.slug) {
|
|
1876
|
-
if (!args.slug) return
|
|
1888
|
+
if (!args.slug) return envelope_envelope({
|
|
1877
1889
|
ok: false,
|
|
1878
1890
|
command: "extension_templates",
|
|
1879
1891
|
status: "bad-request",
|
|
@@ -1957,14 +1969,239 @@ const LAUNCH_BROWSER = {
|
|
|
1957
1969
|
enum: LAUNCHABLE_BROWSERS,
|
|
1958
1970
|
default: "chrome"
|
|
1959
1971
|
};
|
|
1972
|
+
const OUTPUT_JSON_FLOOR = {
|
|
1973
|
+
act: "3.18.1",
|
|
1974
|
+
doctor: "4.0.11",
|
|
1975
|
+
dev: "4.0.17",
|
|
1976
|
+
build: "4.0.17"
|
|
1977
|
+
};
|
|
1978
|
+
const VERDICT_TTL_MS = 60000;
|
|
1979
|
+
const PROBE_TIMEOUT_MS = 20000;
|
|
1980
|
+
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
1981
|
+
function decompose(version) {
|
|
1982
|
+
const match = SEMVER.exec(version.trim());
|
|
1983
|
+
if (!match) return null;
|
|
1984
|
+
return {
|
|
1985
|
+
release: [
|
|
1986
|
+
Number(match[1]),
|
|
1987
|
+
Number(match[2]),
|
|
1988
|
+
Number(match[3])
|
|
1989
|
+
],
|
|
1990
|
+
prerelease: match[4] ? match[4].split(".") : []
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
function parseVersion(text) {
|
|
1994
|
+
for (const line of text.split("\n")){
|
|
1995
|
+
const candidate = line.trim();
|
|
1996
|
+
if (candidate) {
|
|
1997
|
+
if (decompose(candidate)) return candidate.replace(/^v/, "");
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return null;
|
|
2001
|
+
}
|
|
2002
|
+
function compareNumericIdentifiers(a, b) {
|
|
2003
|
+
const left = a.replace(/^0+(?=\d)/, "");
|
|
2004
|
+
const right = b.replace(/^0+(?=\d)/, "");
|
|
2005
|
+
if (left.length !== right.length) return left.length < right.length ? -1 : 1;
|
|
2006
|
+
if (left === right) return 0;
|
|
2007
|
+
return left < right ? -1 : 1;
|
|
2008
|
+
}
|
|
2009
|
+
function compareVersions(a, b) {
|
|
2010
|
+
const left = decompose(a);
|
|
2011
|
+
const right = decompose(b);
|
|
2012
|
+
if (!left || !right) return null;
|
|
2013
|
+
for(let i = 0; i < 3; i += 1)if (left.release[i] !== right.release[i]) return left.release[i] < right.release[i] ? -1 : 1;
|
|
2014
|
+
if (!left.prerelease.length && !right.prerelease.length) return 0;
|
|
2015
|
+
if (!left.prerelease.length) return 1;
|
|
2016
|
+
if (!right.prerelease.length) return -1;
|
|
2017
|
+
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
2018
|
+
for(let i = 0; i < length; i += 1){
|
|
2019
|
+
const one = left.prerelease[i];
|
|
2020
|
+
const other = right.prerelease[i];
|
|
2021
|
+
if (void 0 === one) return -1;
|
|
2022
|
+
if (void 0 === other) return 1;
|
|
2023
|
+
const oneIsNumeric = /^\d+$/.test(one);
|
|
2024
|
+
const otherIsNumeric = /^\d+$/.test(other);
|
|
2025
|
+
if (oneIsNumeric && otherIsNumeric) {
|
|
2026
|
+
const ordering = compareNumericIdentifiers(one, other);
|
|
2027
|
+
if (0 !== ordering) return ordering;
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
if (oneIsNumeric !== otherIsNumeric) return oneIsNumeric ? -1 : 1;
|
|
2031
|
+
if (one !== other) return one < other ? -1 : 1;
|
|
2032
|
+
}
|
|
2033
|
+
return 0;
|
|
2034
|
+
}
|
|
2035
|
+
const verdicts = new Map();
|
|
2036
|
+
function invocationKey(command, prefixArgs) {
|
|
2037
|
+
return [
|
|
2038
|
+
command,
|
|
2039
|
+
...prefixArgs
|
|
2040
|
+
].join("\0");
|
|
2041
|
+
}
|
|
2042
|
+
const NPX_PIN = /^extension@(.+)$/;
|
|
2043
|
+
function parseCapabilities(stdout) {
|
|
2044
|
+
for (const line of stdout.split("\n")){
|
|
2045
|
+
const candidate = line.trim();
|
|
2046
|
+
if (!candidate.startsWith("{")) continue;
|
|
2047
|
+
let frame;
|
|
2048
|
+
try {
|
|
2049
|
+
frame = JSON.parse(candidate);
|
|
2050
|
+
} catch {
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
if (!frame || "object" != typeof frame) continue;
|
|
2054
|
+
const envelope = frame;
|
|
2055
|
+
if (1 !== envelope.schema) continue;
|
|
2056
|
+
if (true !== envelope.ok) continue;
|
|
2057
|
+
if ("capabilities" !== envelope.command) continue;
|
|
2058
|
+
const value = envelope.value;
|
|
2059
|
+
if (!value || "object" != typeof value) continue;
|
|
2060
|
+
const version = "string" == typeof value.version ? parseVersion(value.version) : null;
|
|
2061
|
+
const roster = value.outputJsonCommands;
|
|
2062
|
+
if (version && Array.isArray(roster)) {
|
|
2063
|
+
if (roster.every((name)=>"string" == typeof name)) return {
|
|
2064
|
+
version,
|
|
2065
|
+
outputJsonCommands: roster
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return null;
|
|
2070
|
+
}
|
|
2071
|
+
async function resolvedEngineFacts(projectPath) {
|
|
2072
|
+
const { command, prefixArgs } = resolveExtensionInvocation(projectPath);
|
|
2073
|
+
const key = invocationKey(command, prefixArgs);
|
|
2074
|
+
const cached = verdicts.get(key);
|
|
2075
|
+
if (cached && cached.expiresAt > Date.now()) return {
|
|
2076
|
+
version: cached.version,
|
|
2077
|
+
outputJsonCommands: cached.outputJsonCommands
|
|
2078
|
+
};
|
|
2079
|
+
const remember = (found)=>{
|
|
2080
|
+
verdicts.set(key, {
|
|
2081
|
+
...found,
|
|
2082
|
+
expiresAt: Date.now() + VERDICT_TTL_MS
|
|
2083
|
+
});
|
|
2084
|
+
return found;
|
|
2085
|
+
};
|
|
2086
|
+
for (const arg of prefixArgs){
|
|
2087
|
+
const pin = NPX_PIN.exec(arg);
|
|
2088
|
+
const pinned = pin ? parseVersion(pin[1]) : null;
|
|
2089
|
+
if (pinned) return remember({
|
|
2090
|
+
version: pinned,
|
|
2091
|
+
outputJsonCommands: null
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
try {
|
|
2095
|
+
const probe = await runExtensionCli([
|
|
2096
|
+
"capabilities"
|
|
2097
|
+
], {
|
|
2098
|
+
cwd: projectPath,
|
|
2099
|
+
timeoutMs: PROBE_TIMEOUT_MS
|
|
2100
|
+
});
|
|
2101
|
+
if (0 === probe.code) {
|
|
2102
|
+
const answered = parseCapabilities(probe.stdout ?? "");
|
|
2103
|
+
if (answered) return remember(answered);
|
|
2104
|
+
}
|
|
2105
|
+
} catch {}
|
|
2106
|
+
try {
|
|
2107
|
+
const probe = await runExtensionCli([
|
|
2108
|
+
"--version"
|
|
2109
|
+
], {
|
|
2110
|
+
cwd: projectPath,
|
|
2111
|
+
timeoutMs: PROBE_TIMEOUT_MS
|
|
2112
|
+
});
|
|
2113
|
+
if (0 !== probe.code) return remember({
|
|
2114
|
+
version: null,
|
|
2115
|
+
outputJsonCommands: null
|
|
2116
|
+
});
|
|
2117
|
+
return remember({
|
|
2118
|
+
version: parseVersion(probe.stdout ?? "") ?? parseVersion(probe.stderr ?? ""),
|
|
2119
|
+
outputJsonCommands: null
|
|
2120
|
+
});
|
|
2121
|
+
} catch {
|
|
2122
|
+
return remember({
|
|
2123
|
+
version: null,
|
|
2124
|
+
outputJsonCommands: null
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
const ROSTER_NAMES = {
|
|
2129
|
+
act: [
|
|
2130
|
+
"eval",
|
|
2131
|
+
"inspect",
|
|
2132
|
+
"open",
|
|
2133
|
+
"reload",
|
|
2134
|
+
"storage"
|
|
2135
|
+
],
|
|
2136
|
+
doctor: [
|
|
2137
|
+
"doctor"
|
|
2138
|
+
],
|
|
2139
|
+
dev: [
|
|
2140
|
+
"dev"
|
|
2141
|
+
],
|
|
2142
|
+
build: [
|
|
2143
|
+
"build"
|
|
2144
|
+
]
|
|
2145
|
+
};
|
|
2146
|
+
async function outputJsonVerdict(command, projectPath) {
|
|
2147
|
+
const floor = OUTPUT_JSON_FLOOR[command];
|
|
2148
|
+
let engine;
|
|
2149
|
+
try {
|
|
2150
|
+
engine = await resolvedEngineFacts(projectPath);
|
|
2151
|
+
} catch {
|
|
2152
|
+
return {
|
|
2153
|
+
supported: null,
|
|
2154
|
+
version: null,
|
|
2155
|
+
floor
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
const roster = engine.outputJsonCommands;
|
|
2159
|
+
if (roster) return {
|
|
2160
|
+
supported: ROSTER_NAMES[command].every((name)=>roster.includes(name)),
|
|
2161
|
+
version: engine.version,
|
|
2162
|
+
floor
|
|
2163
|
+
};
|
|
2164
|
+
const version = engine.version;
|
|
2165
|
+
if (null === version) return {
|
|
2166
|
+
supported: null,
|
|
2167
|
+
version: null,
|
|
2168
|
+
floor
|
|
2169
|
+
};
|
|
2170
|
+
const ordering = compareVersions(version, floor);
|
|
2171
|
+
if (null === ordering) return {
|
|
2172
|
+
supported: null,
|
|
2173
|
+
version,
|
|
2174
|
+
floor
|
|
2175
|
+
};
|
|
2176
|
+
return {
|
|
2177
|
+
supported: ordering >= 0,
|
|
2178
|
+
version,
|
|
2179
|
+
floor
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
const UNKNOWN_OUTPUT_FLAG = /unknown option[^\n]*--output\b/i;
|
|
2183
|
+
function refusedTheOutputFlag(stderr) {
|
|
2184
|
+
return UNKNOWN_OUTPUT_FLAG.test(stderr);
|
|
2185
|
+
}
|
|
2186
|
+
async function outputFlagRefusalMessage(command, cliName, projectPath) {
|
|
2187
|
+
const verdict = await outputJsonVerdict(command, projectPath);
|
|
2188
|
+
const preamble = `The Extension.js resolved for this project refused \`--output json\` on \`extension ${cliName}\`.`;
|
|
2189
|
+
const why = "You did not pass that flag: this server adds it so it can read a structured result instead of parsing a report written for a human, and unlike `build` there is no second source it can fall back to here, so it cannot simply drop it.";
|
|
2190
|
+
if (false === verdict.supported) return `${preamble} It reports ${verdict.version}, and that flag only reached \`extension ${cliName}\` in ${verdict.floor}. ${why} Upgrade the project's Extension.js to ${verdict.floor} or newer and run this again.`;
|
|
2191
|
+
if (true === verdict.supported) return `${preamble} It reports ${verdict.version}, which is at or above ${verdict.floor}, the release where that flag reached \`extension ${cliName}\`, so the binary being run is not the version it claims to be. ${why} Check the project's node_modules/.bin/extension and reinstall it, rather than upgrading a version that already looks new enough.`;
|
|
2192
|
+
return `${preamble} Its version could not be read, so the cause cannot be confirmed, but that flag only reached \`extension ${cliName}\` in ${verdict.floor} and a refusal is what an engine below that does. ${why} Check the project's Extension.js install and bring it to ${verdict.floor} or newer.`;
|
|
2193
|
+
}
|
|
1960
2194
|
const sessions = new Map();
|
|
1961
2195
|
const registrationStamps = new Map();
|
|
1962
2196
|
function sessionKey(projectPath, browser) {
|
|
1963
2197
|
return `${node_path.resolve(projectPath)}::${browser}`;
|
|
1964
2198
|
}
|
|
1965
|
-
function
|
|
2199
|
+
function sessionStateDir() {
|
|
1966
2200
|
return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
|
|
1967
2201
|
}
|
|
2202
|
+
function markerDir() {
|
|
2203
|
+
return sessionStateDir();
|
|
2204
|
+
}
|
|
1968
2205
|
function markerPath(projectPath, browser) {
|
|
1969
2206
|
const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
|
|
1970
2207
|
return node_path.join(markerDir(), `${digest}.json`);
|
|
@@ -2047,8 +2284,29 @@ function removeSession(projectPath, browser, pid) {
|
|
|
2047
2284
|
function listSessions() {
|
|
2048
2285
|
return Array.from(sessions.values());
|
|
2049
2286
|
}
|
|
2287
|
+
function sessionPathHint(file) {
|
|
2288
|
+
return `Looked at ${file} (the session-state layout this MCP's pinned engine publishes). If the project runs an older Extension.js, its layout may differ and this path will never appear.`;
|
|
2289
|
+
}
|
|
2290
|
+
function profilesRootDir(projectPath) {
|
|
2291
|
+
return node_path.join(sessionArtifactsRootDir(projectPath), "profiles");
|
|
2292
|
+
}
|
|
2293
|
+
function browserProfileRootDir(projectPath, browser) {
|
|
2294
|
+
return node_path.join(profilesRootDir(projectPath), `${browser}-profile`);
|
|
2295
|
+
}
|
|
2296
|
+
const PERSISTED_PROFILE_DIR_NAME = "dev";
|
|
2297
|
+
function profileRemediation(input) {
|
|
2298
|
+
const { projectPath, browser, profile } = input;
|
|
2299
|
+
const raw = "string" == typeof profile ? profile.trim() : "";
|
|
2300
|
+
if ("false" === raw.toLowerCase()) return `This session was launched with profile:"false", so it runs your real ${browser} profile and there is no Extension.js profile directory to remove. Quit the ${browser} window that holds it and start again.`;
|
|
2301
|
+
if (raw.length > 0) {
|
|
2302
|
+
const explicit = node_path.isAbsolute(raw) ? raw : node_path.resolve(projectPath, raw);
|
|
2303
|
+
return `This session was launched against the profile you passed, ${explicit}. Close whatever still holds it, or remove that directory, before retrying.`;
|
|
2304
|
+
}
|
|
2305
|
+
const root = browserProfileRootDir(projectPath, browser);
|
|
2306
|
+
return `The engine keeps this session's profile in a directory inside ${root}, one per run: "${PERSISTED_PROFILE_DIR_NAME}" when the profile is persisted, otherwise three random words drawn fresh on every start, which no caller can predict. List ${root} to see which run directories exist and remove the one the stuck browser holds, or remove ${root} entirely once no session is running.`;
|
|
2307
|
+
}
|
|
2050
2308
|
function contractSightings(projectPath) {
|
|
2051
|
-
const root =
|
|
2309
|
+
const root = sessionArtifactsRootDir(projectPath);
|
|
2052
2310
|
let dirs;
|
|
2053
2311
|
try {
|
|
2054
2312
|
dirs = node_fs.readdirSync(root);
|
|
@@ -2057,7 +2315,7 @@ function contractSightings(projectPath) {
|
|
|
2057
2315
|
}
|
|
2058
2316
|
const sightings = [];
|
|
2059
2317
|
for (const dir of dirs){
|
|
2060
|
-
const readyPath =
|
|
2318
|
+
const readyPath = bridge_readyContractPath(projectPath, dir);
|
|
2061
2319
|
try {
|
|
2062
2320
|
const stat = node_fs.statSync(readyPath);
|
|
2063
2321
|
const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
@@ -2126,7 +2384,7 @@ function deadReadySession(projectPath) {
|
|
|
2126
2384
|
return null;
|
|
2127
2385
|
}
|
|
2128
2386
|
function contractBoundPort(projectPath, browser, since) {
|
|
2129
|
-
const readyPath =
|
|
2387
|
+
const readyPath = bridge_readyContractPath(projectPath, browser);
|
|
2130
2388
|
try {
|
|
2131
2389
|
const stat = node_fs.statSync(readyPath);
|
|
2132
2390
|
if (stat.mtimeMs < since) return null;
|
|
@@ -2189,9 +2447,18 @@ function isChromiumFamily(browser) {
|
|
|
2189
2447
|
function isGeckoFamily(browser) {
|
|
2190
2448
|
return GECKO_FAMILY.has(browser);
|
|
2191
2449
|
}
|
|
2450
|
+
function insideRepository(projectPath) {
|
|
2451
|
+
let current = node_path.resolve(projectPath);
|
|
2452
|
+
for(;;){
|
|
2453
|
+
if (node_fs.existsSync(node_path.join(current, ".git"))) return true;
|
|
2454
|
+
const parent = node_path.dirname(current);
|
|
2455
|
+
if (parent === current) return false;
|
|
2456
|
+
current = parent;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2192
2459
|
function ensureProjectIgnored(projectPath, options) {
|
|
2193
2460
|
const entry = options.entry;
|
|
2194
|
-
if (!
|
|
2461
|
+
if (!insideRepository(projectPath)) return {
|
|
2195
2462
|
state: "not-a-repo",
|
|
2196
2463
|
entry
|
|
2197
2464
|
};
|
|
@@ -2223,6 +2490,56 @@ function ensureProjectIgnored(projectPath, options) {
|
|
|
2223
2490
|
};
|
|
2224
2491
|
}
|
|
2225
2492
|
}
|
|
2493
|
+
const placedHere = new Set();
|
|
2494
|
+
function recordDir() {
|
|
2495
|
+
return node_path.join(sessionStateDir(), "carriers");
|
|
2496
|
+
}
|
|
2497
|
+
function recordPath(resolved) {
|
|
2498
|
+
const digest = node_crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 16);
|
|
2499
|
+
return node_path.join(recordDir(), `${digest}.json`);
|
|
2500
|
+
}
|
|
2501
|
+
function rememberCarrier(projectPath) {
|
|
2502
|
+
const resolved = node_path.resolve(projectPath);
|
|
2503
|
+
placedHere.add(resolved);
|
|
2504
|
+
try {
|
|
2505
|
+
node_fs.mkdirSync(recordDir(), {
|
|
2506
|
+
recursive: true
|
|
2507
|
+
});
|
|
2508
|
+
node_fs.writeFileSync(recordPath(resolved), `${JSON.stringify({
|
|
2509
|
+
projectPath: resolved,
|
|
2510
|
+
pid: process.pid,
|
|
2511
|
+
placedAt: new Date().toISOString()
|
|
2512
|
+
})}\n`);
|
|
2513
|
+
} catch {}
|
|
2514
|
+
}
|
|
2515
|
+
function forgetCarrier(projectPath) {
|
|
2516
|
+
const resolved = node_path.resolve(projectPath);
|
|
2517
|
+
placedHere.delete(resolved);
|
|
2518
|
+
try {
|
|
2519
|
+
node_fs.rmSync(recordPath(resolved), {
|
|
2520
|
+
force: true
|
|
2521
|
+
});
|
|
2522
|
+
} catch {}
|
|
2523
|
+
}
|
|
2524
|
+
function carriersPlacedHere() {
|
|
2525
|
+
return [
|
|
2526
|
+
...placedHere
|
|
2527
|
+
];
|
|
2528
|
+
}
|
|
2529
|
+
function rememberedCarriers() {
|
|
2530
|
+
let files;
|
|
2531
|
+
try {
|
|
2532
|
+
files = node_fs.readdirSync(recordDir());
|
|
2533
|
+
} catch {
|
|
2534
|
+
return [];
|
|
2535
|
+
}
|
|
2536
|
+
const out = [];
|
|
2537
|
+
for (const file of files)if (file.endsWith(".json")) try {
|
|
2538
|
+
const parsed = JSON.parse(node_fs.readFileSync(node_path.join(recordDir(), file), "utf8"));
|
|
2539
|
+
if ("string" == typeof parsed.projectPath && parsed.projectPath) out.push(parsed.projectPath);
|
|
2540
|
+
} catch {}
|
|
2541
|
+
return out;
|
|
2542
|
+
}
|
|
2226
2543
|
const CARRIER_DIR_NAME = "extension-dev-live-preview";
|
|
2227
2544
|
const CARRIER_EXTENSION_ID = "ibppeifnekhjjjmpjfiobccjlicbmgcb";
|
|
2228
2545
|
const MARKER_FILE = "managed-by-extension-dev-mcp.json";
|
|
@@ -2241,16 +2558,78 @@ function deriveCarrierId(source) {
|
|
|
2241
2558
|
function carrierPath(projectPath) {
|
|
2242
2559
|
return node_path.join(projectPath, "extensions", CARRIER_DIR_NAME);
|
|
2243
2560
|
}
|
|
2561
|
+
function relativeFiles(dir, base = dir, depth = 0) {
|
|
2562
|
+
if (depth > 4) return null;
|
|
2563
|
+
let entries;
|
|
2564
|
+
try {
|
|
2565
|
+
entries = node_fs.readdirSync(dir, {
|
|
2566
|
+
withFileTypes: true
|
|
2567
|
+
});
|
|
2568
|
+
} catch {
|
|
2569
|
+
return null;
|
|
2570
|
+
}
|
|
2571
|
+
const out = [];
|
|
2572
|
+
for (const entry of entries){
|
|
2573
|
+
const full = node_path.join(dir, entry.name);
|
|
2574
|
+
if (entry.isDirectory()) {
|
|
2575
|
+
const nested = relativeFiles(full, base, depth + 1);
|
|
2576
|
+
if (null === nested) return null;
|
|
2577
|
+
out.push(...nested);
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
out.push(node_path.relative(base, full));
|
|
2581
|
+
if (out.length > 500) return null;
|
|
2582
|
+
}
|
|
2583
|
+
return out;
|
|
2584
|
+
}
|
|
2585
|
+
function claimCarrier(target) {
|
|
2586
|
+
if (node_fs.existsSync(node_path.join(target, MARKER_FILE))) return {
|
|
2587
|
+
ours: true,
|
|
2588
|
+
how: "marker"
|
|
2589
|
+
};
|
|
2590
|
+
if (deriveCarrierId(target) === CARRIER_EXTENSION_ID) return {
|
|
2591
|
+
ours: true,
|
|
2592
|
+
how: "payload"
|
|
2593
|
+
};
|
|
2594
|
+
if (node_fs.existsSync(node_path.join(target, "manifest.json"))) return {
|
|
2595
|
+
ours: false,
|
|
2596
|
+
how: "foreign"
|
|
2597
|
+
};
|
|
2598
|
+
const source = findBundledCarrier("chromium");
|
|
2599
|
+
const bundled = source ? relativeFiles(source) : null;
|
|
2600
|
+
const present = relativeFiles(target);
|
|
2601
|
+
if (!bundled || !present) return {
|
|
2602
|
+
ours: false,
|
|
2603
|
+
how: "foreign"
|
|
2604
|
+
};
|
|
2605
|
+
const known = new Set(bundled);
|
|
2606
|
+
return present.every((file)=>known.has(file)) ? {
|
|
2607
|
+
ours: true,
|
|
2608
|
+
how: "partial"
|
|
2609
|
+
} : {
|
|
2610
|
+
ours: false,
|
|
2611
|
+
how: "foreign"
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2614
|
+
const RECOVERY_NOTE = {
|
|
2615
|
+
payload: `Its ${MARKER_FILE} marker was missing, but its manifest key derives the carrier's own extension id ${CARRIER_EXTENSION_ID}, which only this package's payload has, so it was recognised as ours and taken back.`,
|
|
2616
|
+
partial: `Its ${MARKER_FILE} marker was missing and it holds no manifest, but every file in it belongs to the bundled carrier payload, so it was recognised as a half-written copy of ours and taken back.`
|
|
2617
|
+
};
|
|
2618
|
+
const FOREIGN_NOTE = `extensions/${CARRIER_DIR_NAME} has no ${MARKER_FILE} marker, does not carry the carrier's own manifest key, and holds files this package never ships, so it is not the carrier this tool placed and was left untouched. Nothing here deletes a directory this tool did not write: rename it or move it out of ./extensions yourself if you want the carrier to live at that path.`;
|
|
2244
2619
|
function removeCarrier(projectPath) {
|
|
2245
2620
|
const target = carrierPath(projectPath);
|
|
2246
|
-
if (!node_fs.existsSync(target))
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2621
|
+
if (!node_fs.existsSync(target)) {
|
|
2622
|
+
forgetCarrier(projectPath);
|
|
2623
|
+
return {
|
|
2624
|
+
removed: false,
|
|
2625
|
+
path: target
|
|
2626
|
+
};
|
|
2627
|
+
}
|
|
2628
|
+
const claim = claimCarrier(target);
|
|
2629
|
+
if (!claim.ours) return {
|
|
2251
2630
|
removed: false,
|
|
2252
2631
|
path: target,
|
|
2253
|
-
note:
|
|
2632
|
+
note: FOREIGN_NOTE
|
|
2254
2633
|
};
|
|
2255
2634
|
try {
|
|
2256
2635
|
node_fs.rmSync(target, {
|
|
@@ -2264,13 +2643,17 @@ function removeCarrier(projectPath) {
|
|
|
2264
2643
|
note: `Could not remove the carrier: ${error instanceof Error ? error.message : String(error)}`
|
|
2265
2644
|
};
|
|
2266
2645
|
}
|
|
2646
|
+
forgetCarrier(projectPath);
|
|
2267
2647
|
const parent = node_path.join(projectPath, "extensions");
|
|
2268
2648
|
try {
|
|
2269
2649
|
if (0 === node_fs.readdirSync(parent).length) node_fs.rmdirSync(parent);
|
|
2270
2650
|
} catch {}
|
|
2271
2651
|
return {
|
|
2272
2652
|
removed: true,
|
|
2273
|
-
path: target
|
|
2653
|
+
path: target,
|
|
2654
|
+
..."marker" === claim.how ? {} : {
|
|
2655
|
+
note: RECOVERY_NOTE[claim.how]
|
|
2656
|
+
}
|
|
2274
2657
|
};
|
|
2275
2658
|
}
|
|
2276
2659
|
function ensureCarrierIgnored(projectPath) {
|
|
@@ -2308,10 +2691,14 @@ function materializeCarrier(projectPath, browser) {
|
|
|
2308
2691
|
};
|
|
2309
2692
|
const target = carrierPath(projectPath);
|
|
2310
2693
|
const marker = node_path.join(target, MARKER_FILE);
|
|
2311
|
-
|
|
2694
|
+
const claim = node_fs.existsSync(target) ? claimCarrier(target) : {
|
|
2695
|
+
ours: true,
|
|
2696
|
+
how: "marker"
|
|
2697
|
+
};
|
|
2698
|
+
if (!claim.ours) return {
|
|
2312
2699
|
loaded: false,
|
|
2313
2700
|
path: target,
|
|
2314
|
-
note: `A directory already exists at extensions/${CARRIER_DIR_NAME}
|
|
2701
|
+
note: `A directory already exists at extensions/${CARRIER_DIR_NAME} that this tool did not place: no ${MARKER_FILE} marker, no carrier manifest key, and files this package never ships. It was left untouched. Rename it or move it out of ./extensions to let extension_dev place the carrier there.`
|
|
2315
2702
|
};
|
|
2316
2703
|
const carrierId = deriveCarrierId(source);
|
|
2317
2704
|
try {
|
|
@@ -2328,6 +2715,7 @@ function materializeCarrier(projectPath, browser) {
|
|
|
2328
2715
|
carrierVersion: manifest.version ?? "unknown",
|
|
2329
2716
|
note: "Safe to delete; extension_dev recreates it when carrier: true. extension_stop and extension_build remove it for you."
|
|
2330
2717
|
}, null, 2)}\n`);
|
|
2718
|
+
rememberCarrier(projectPath);
|
|
2331
2719
|
const ignored = ensureCarrierIgnored(projectPath);
|
|
2332
2720
|
return {
|
|
2333
2721
|
loaded: true,
|
|
@@ -2335,7 +2723,7 @@ function materializeCarrier(projectPath, browser) {
|
|
|
2335
2723
|
...ignored ? {
|
|
2336
2724
|
gitignored: ignored
|
|
2337
2725
|
} : {},
|
|
2338
|
-
note: "Live-preview carrier placed in ./extensions; Extension.js loads it as a companion beside your extension. Open https://preview.extension.dev
|
|
2726
|
+
note: "Live-preview carrier placed in ./extensions; Extension.js loads it as a companion beside your extension. Open https://preview.extension.dev/ in the dev browser, load a build from this machine, and switch the lane toggle to Real to watch the session's real-lane chrome.* trace on the Trace tab. It is a debug companion, never part of a release: extension_stop and extension_build remove it again" + (ignored ? `, and ${ignored} was added to .gitignore.` : "."),
|
|
2339
2727
|
limitations: [
|
|
2340
2728
|
"The trace shows calls a PAGE bridges to the carrier. Your extension's own chrome.* calls run directly in its contexts and never cross the carrier, so they do not appear.",
|
|
2341
2729
|
"Bridged calls run under the CARRIER's identity, not your extension's. The preview assumes a single active guest and does not namespace per-extension state, so storage, action/badge state, messaging delivery, offscreen documents and relative script paths belong to the carrier. Rows affected are badged carrier-scoped in the Trace tab.",
|
|
@@ -2345,7 +2733,7 @@ function materializeCarrier(projectPath, browser) {
|
|
|
2345
2733
|
...carrierId ? {
|
|
2346
2734
|
bridgeProtocol: {
|
|
2347
2735
|
carrierExtensionId: carrierId,
|
|
2348
|
-
allowedOrigins: "https://preview.extension.dev, https://code.extension.dev, https://themes.extension.dev, http://localhost
|
|
2736
|
+
allowedOrigins: "https://preview.extension.dev, https://code.extension.dev, https://themes.extension.dev, and those same apps' dev servers on http://localhost and http://127.0.0.1 (ports 3103, 3104, 3110, 3111). The carrier checks the sender's origin, so a page on any other localhost port is refused.",
|
|
2349
2737
|
howTo: "From a page on an allowed origin, register your guest once with a 'session' message (it declares the permissions the carrier enforces), then send 'bridge' messages to run chrome.* for real; each one streams into the Trace tab. Use the EXACT dotted wire names the bridge dispatcher accepts: storage is storage.get/set/remove/clear with the AREA AS AN ARGUMENT, NOT storage.local.get.",
|
|
2350
2738
|
example: [
|
|
2351
2739
|
`const id = '${carrierId}'`,
|
|
@@ -2364,16 +2752,115 @@ function materializeCarrier(projectPath, browser) {
|
|
|
2364
2752
|
};
|
|
2365
2753
|
}
|
|
2366
2754
|
}
|
|
2755
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
2756
|
+
const CENTRAL_SIGNATURE = 0x02014b50;
|
|
2757
|
+
const EOCD_MIN_LENGTH = 22;
|
|
2758
|
+
const MAX_COMMENT_LENGTH = 0xffff;
|
|
2759
|
+
const MAX_DIRECTORY_BYTES = 33554432;
|
|
2760
|
+
const UNREADABLE = {
|
|
2761
|
+
names: [],
|
|
2762
|
+
readable: false
|
|
2763
|
+
};
|
|
2764
|
+
function readZipEntryNames(zipPath) {
|
|
2765
|
+
let handle = null;
|
|
2766
|
+
try {
|
|
2767
|
+
const size = node_fs.statSync(zipPath).size;
|
|
2768
|
+
if (size < EOCD_MIN_LENGTH) return UNREADABLE;
|
|
2769
|
+
handle = node_fs.openSync(zipPath, "r");
|
|
2770
|
+
const tailLength = Math.min(size, MAX_COMMENT_LENGTH + EOCD_MIN_LENGTH);
|
|
2771
|
+
const tail = Buffer.alloc(tailLength);
|
|
2772
|
+
node_fs.readSync(handle, tail, 0, tailLength, size - tailLength);
|
|
2773
|
+
let eocd = -1;
|
|
2774
|
+
for(let at = tail.length - EOCD_MIN_LENGTH; at >= 0; at -= 1)if (tail.readUInt32LE(at) === EOCD_SIGNATURE) {
|
|
2775
|
+
eocd = at;
|
|
2776
|
+
break;
|
|
2777
|
+
}
|
|
2778
|
+
if (eocd < 0) return UNREADABLE;
|
|
2779
|
+
const expected = tail.readUInt16LE(eocd + 10);
|
|
2780
|
+
const directoryBytes = tail.readUInt32LE(eocd + 12);
|
|
2781
|
+
const directoryOffset = tail.readUInt32LE(eocd + 16);
|
|
2782
|
+
if (0xffff === expected || 0xffffffff === directoryBytes || 0xffffffff === directoryOffset || directoryBytes > MAX_DIRECTORY_BYTES || directoryOffset + directoryBytes > size) return UNREADABLE;
|
|
2783
|
+
if (0 === directoryBytes) return {
|
|
2784
|
+
names: [],
|
|
2785
|
+
readable: 0 === expected
|
|
2786
|
+
};
|
|
2787
|
+
const directory = Buffer.alloc(directoryBytes);
|
|
2788
|
+
node_fs.readSync(handle, directory, 0, directoryBytes, directoryOffset);
|
|
2789
|
+
const names = [];
|
|
2790
|
+
let at = 0;
|
|
2791
|
+
while(at + 46 <= directory.length){
|
|
2792
|
+
if (directory.readUInt32LE(at) !== CENTRAL_SIGNATURE) return UNREADABLE;
|
|
2793
|
+
const nameLength = directory.readUInt16LE(at + 28);
|
|
2794
|
+
const extraLength = directory.readUInt16LE(at + 30);
|
|
2795
|
+
const commentLength = directory.readUInt16LE(at + 32);
|
|
2796
|
+
const nameStart = at + 46;
|
|
2797
|
+
if (nameStart + nameLength > directory.length) return UNREADABLE;
|
|
2798
|
+
names.push(directory.subarray(nameStart, nameStart + nameLength).toString("utf8"));
|
|
2799
|
+
at = nameStart + nameLength + extraLength + commentLength;
|
|
2800
|
+
}
|
|
2801
|
+
return {
|
|
2802
|
+
names,
|
|
2803
|
+
readable: names.length === expected
|
|
2804
|
+
};
|
|
2805
|
+
} catch {
|
|
2806
|
+
return UNREADABLE;
|
|
2807
|
+
} finally{
|
|
2808
|
+
if (null !== handle) try {
|
|
2809
|
+
node_fs.closeSync(handle);
|
|
2810
|
+
} catch {}
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2367
2813
|
const build_COMMAND = "extension_build";
|
|
2368
2814
|
function readBuildSummary(projectPath, browser, since) {
|
|
2369
|
-
const file =
|
|
2815
|
+
const file = buildSummaryPath(projectPath, browser);
|
|
2370
2816
|
try {
|
|
2371
2817
|
const stat = node_fs.statSync(file);
|
|
2372
|
-
if (stat.mtimeMs
|
|
2373
|
-
|
|
2374
|
-
|
|
2818
|
+
if (stat.mtimeMs >= since) {
|
|
2819
|
+
const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
2820
|
+
if (summary && "object" == typeof summary) return {
|
|
2821
|
+
file,
|
|
2822
|
+
summary
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2375
2825
|
} catch {}
|
|
2376
|
-
return
|
|
2826
|
+
return {
|
|
2827
|
+
file,
|
|
2828
|
+
summary: null
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
function readEngineOutput(stdout, stderr) {
|
|
2832
|
+
let frame = null;
|
|
2833
|
+
const rest = [];
|
|
2834
|
+
for (const line of stdout.split("\n")){
|
|
2835
|
+
const text = line.trim();
|
|
2836
|
+
if (!frame && text.startsWith("{")) {
|
|
2837
|
+
let parsed = null;
|
|
2838
|
+
try {
|
|
2839
|
+
parsed = JSON.parse(text);
|
|
2840
|
+
} catch {}
|
|
2841
|
+
if (isEnvelope(parsed)) {
|
|
2842
|
+
frame = parsed;
|
|
2843
|
+
continue;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
rest.push(line);
|
|
2847
|
+
}
|
|
2848
|
+
const narration = [
|
|
2849
|
+
rest.join("\n").trim(),
|
|
2850
|
+
stderr.trim()
|
|
2851
|
+
].filter(Boolean).join("\n");
|
|
2852
|
+
return {
|
|
2853
|
+
frame,
|
|
2854
|
+
narration
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
function engineSummaries(frame) {
|
|
2858
|
+
const value = frame?.value;
|
|
2859
|
+
if (!value || !Array.isArray(value.summaries)) return [];
|
|
2860
|
+
return value.summaries.filter((entry)=>Boolean(entry) && "object" == typeof entry);
|
|
2861
|
+
}
|
|
2862
|
+
function summaryForBrowser(summaries, browser) {
|
|
2863
|
+
return summaries.find((entry)=>entry.browser === browser) ?? summaries[0] ?? null;
|
|
2377
2864
|
}
|
|
2378
2865
|
function builtEntrypoints(distDir) {
|
|
2379
2866
|
let manifest;
|
|
@@ -2462,7 +2949,7 @@ function locateSourceZip(projectPath, browser, since) {
|
|
|
2462
2949
|
}
|
|
2463
2950
|
const build_schema = {
|
|
2464
2951
|
name: "extension_build",
|
|
2465
|
-
description: "Build a browser extension for production. The output lands in dist/<browser>/. Pass zip:true to also package a .zip for store submission. The build refuses a manifest with build-blocking errors unless you pass skipValidation:true, because such a manifest yields a broken bundle the bundler itself never flags.",
|
|
2952
|
+
description: "Build a browser extension for production. The output lands in dist/<browser>/. Pass zip:true to also package a .zip for store submission. With browser:'safari' the build converts the extension into a macOS app through Xcode, and bundleId sets the identifier it ships under. The build refuses a manifest with build-blocking errors unless you pass skipValidation:true, because such a manifest yields a broken bundle the bundler itself never flags.",
|
|
2466
2953
|
inputSchema: {
|
|
2467
2954
|
type: "object",
|
|
2468
2955
|
properties: {
|
|
@@ -2506,6 +2993,24 @@ const build_schema = {
|
|
|
2506
2993
|
type: "boolean",
|
|
2507
2994
|
default: false,
|
|
2508
2995
|
description: "Build even when extension_manifest_validate reports build-blocking errors. The build normally refuses: a manifest error yields a broken bundle the bundler itself never flags."
|
|
2996
|
+
},
|
|
2997
|
+
appName: {
|
|
2998
|
+
type: "string",
|
|
2999
|
+
description: "Safari targets only: name of the generated macOS app, which also names the Xcode scheme and the .app on disk. Defaults to the manifest name."
|
|
3000
|
+
},
|
|
3001
|
+
bundleId: {
|
|
3002
|
+
type: "string",
|
|
3003
|
+
description: "Safari targets only: a reverse-DNS bundle identifier you own, such as com.acme.readinglist. Without one the app is packaged under a generated dev.extensionjs.* identifier derived from the app name, which every project built from the same template shares, and the first team to register it takes it."
|
|
3004
|
+
},
|
|
3005
|
+
macOsOnly: {
|
|
3006
|
+
type: "boolean",
|
|
3007
|
+
default: true,
|
|
3008
|
+
description: "Safari targets only: generate a macOS-only Xcode project. Pass false for a universal project that also targets iOS and iPadOS, which is what you want if the extension ships on iPhone or iPad."
|
|
3009
|
+
},
|
|
3010
|
+
forceRegenerate: {
|
|
3011
|
+
type: "boolean",
|
|
3012
|
+
default: false,
|
|
3013
|
+
description: "Safari targets only: regenerate the Xcode project even when the engine considers it up to date. Use it when an earlier packaging run left the project broken."
|
|
2509
3014
|
}
|
|
2510
3015
|
},
|
|
2511
3016
|
required: [
|
|
@@ -2513,6 +3018,11 @@ const build_schema = {
|
|
|
2513
3018
|
]
|
|
2514
3019
|
}
|
|
2515
3020
|
};
|
|
3021
|
+
const SAFARI_VENDORS = new Set([
|
|
3022
|
+
"safari",
|
|
3023
|
+
"webkit-based"
|
|
3024
|
+
]);
|
|
3025
|
+
const BUNDLE_ID_PATTERN = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$/;
|
|
2516
3026
|
function manifestDivergence(projectPath, browser) {
|
|
2517
3027
|
const read = (p)=>{
|
|
2518
3028
|
try {
|
|
@@ -2540,23 +3050,58 @@ function manifestDivergence(projectPath, browser) {
|
|
|
2540
3050
|
return notes;
|
|
2541
3051
|
}
|
|
2542
3052
|
const MARKER_FILE_NAME = "managed-by-extension-dev-mcp.json";
|
|
2543
|
-
function
|
|
2544
|
-
|
|
3053
|
+
function namesCarrier(entryName) {
|
|
3054
|
+
return entryName === CARRIER_DIR_NAME || entryName === MARKER_FILE_NAME;
|
|
3055
|
+
}
|
|
3056
|
+
function carrierEntriesInZip(zipPath) {
|
|
3057
|
+
const listing = readZipEntryNames(zipPath);
|
|
3058
|
+
if (!listing.readable) return {
|
|
3059
|
+
paths: [],
|
|
3060
|
+
unchecked: [
|
|
3061
|
+
zipPath
|
|
3062
|
+
]
|
|
3063
|
+
};
|
|
3064
|
+
const hits = listing.names.filter((name)=>name.split("/").some(namesCarrier)).map((name)=>`${zipPath} -> ${name}`);
|
|
3065
|
+
return {
|
|
3066
|
+
paths: hits,
|
|
3067
|
+
unchecked: []
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
function carrierContamination(dir, depth = 0) {
|
|
3071
|
+
if (depth > 4) return {
|
|
3072
|
+
paths: [],
|
|
3073
|
+
unchecked: []
|
|
3074
|
+
};
|
|
2545
3075
|
let entries;
|
|
2546
3076
|
try {
|
|
2547
|
-
entries = node_fs.readdirSync(
|
|
3077
|
+
entries = node_fs.readdirSync(dir, {
|
|
2548
3078
|
withFileTypes: true
|
|
2549
3079
|
});
|
|
2550
3080
|
} catch {
|
|
2551
|
-
return
|
|
3081
|
+
return {
|
|
3082
|
+
paths: [],
|
|
3083
|
+
unchecked: []
|
|
3084
|
+
};
|
|
2552
3085
|
}
|
|
2553
|
-
const found =
|
|
3086
|
+
const found = {
|
|
3087
|
+
paths: [],
|
|
3088
|
+
unchecked: []
|
|
3089
|
+
};
|
|
3090
|
+
const absorb = (other)=>{
|
|
3091
|
+
found.paths.push(...other.paths);
|
|
3092
|
+
found.unchecked.push(...other.unchecked);
|
|
3093
|
+
};
|
|
2554
3094
|
for (const entry of entries){
|
|
2555
|
-
|
|
2556
|
-
|
|
3095
|
+
const full = node_path.join(dir, entry.name);
|
|
3096
|
+
if (namesCarrier(entry.name)) {
|
|
3097
|
+
found.paths.push(full);
|
|
2557
3098
|
continue;
|
|
2558
3099
|
}
|
|
2559
|
-
if (entry.isDirectory())
|
|
3100
|
+
if (entry.isDirectory()) {
|
|
3101
|
+
absorb(carrierContamination(full, depth + 1));
|
|
3102
|
+
continue;
|
|
3103
|
+
}
|
|
3104
|
+
if (entry.isFile() && entry.name.endsWith(".zip")) absorb(carrierEntriesInZip(full));
|
|
2560
3105
|
}
|
|
2561
3106
|
return found;
|
|
2562
3107
|
}
|
|
@@ -2585,12 +3130,61 @@ async function validationPreflight(projectPath, browser) {
|
|
|
2585
3130
|
async function build_handler(args) {
|
|
2586
3131
|
const start = Date.now();
|
|
2587
3132
|
const browser = args.browser ?? "chrome";
|
|
3133
|
+
const safari = SAFARI_VENDORS.has(browser);
|
|
3134
|
+
const safariOnly = [
|
|
3135
|
+
[
|
|
3136
|
+
"appName",
|
|
3137
|
+
args.appName
|
|
3138
|
+
],
|
|
3139
|
+
[
|
|
3140
|
+
"bundleId",
|
|
3141
|
+
args.bundleId
|
|
3142
|
+
],
|
|
3143
|
+
[
|
|
3144
|
+
"macOsOnly",
|
|
3145
|
+
args.macOsOnly
|
|
3146
|
+
],
|
|
3147
|
+
[
|
|
3148
|
+
"forceRegenerate",
|
|
3149
|
+
true === args.forceRegenerate ? true : void 0
|
|
3150
|
+
]
|
|
3151
|
+
].filter(([, value])=>void 0 !== value).map(([name])=>name);
|
|
3152
|
+
if (safariOnly.length > 0 && !safari) return envelope_envelope({
|
|
3153
|
+
ok: false,
|
|
3154
|
+
command: build_COMMAND,
|
|
3155
|
+
status: "safari-only-option",
|
|
3156
|
+
error: {
|
|
3157
|
+
code: "E_SAFARI_ONLY_OPTION",
|
|
3158
|
+
message: `${safariOnly.join(", ")} configure the Safari web-extension conversion and mean nothing for a ${browser} build. Nothing was built, so the options were not silently ignored.`
|
|
3159
|
+
},
|
|
3160
|
+
value: {
|
|
3161
|
+
browser,
|
|
3162
|
+
options: safariOnly,
|
|
3163
|
+
duration: Date.now() - start
|
|
3164
|
+
},
|
|
3165
|
+
hint: `Pass browser: "safari" to package a Safari app, or drop these options to build for ${browser}.`
|
|
3166
|
+
});
|
|
3167
|
+
if (void 0 !== args.bundleId && !BUNDLE_ID_PATTERN.test(args.bundleId)) return envelope_envelope({
|
|
3168
|
+
ok: false,
|
|
3169
|
+
command: build_COMMAND,
|
|
3170
|
+
status: "invalid-bundle-id",
|
|
3171
|
+
error: {
|
|
3172
|
+
code: "E_INVALID_BUNDLE_ID",
|
|
3173
|
+
message: `bundleId ${JSON.stringify(args.bundleId)} is not a reverse-DNS identifier, so Xcode would reject it. Expected two or more dot-separated segments of letters, digits and hyphens, each starting with a letter.`
|
|
3174
|
+
},
|
|
3175
|
+
value: {
|
|
3176
|
+
browser,
|
|
3177
|
+
bundleId: args.bundleId,
|
|
3178
|
+
duration: Date.now() - start
|
|
3179
|
+
},
|
|
3180
|
+
hint: 'Use an identifier under a domain you own, for example "com.acme.readinglist".'
|
|
3181
|
+
});
|
|
2588
3182
|
const carrierCleanup = removeCarrier(args.projectPath);
|
|
2589
3183
|
const carrierNotes = [];
|
|
2590
3184
|
if (carrierCleanup.removed) carrierNotes.push("Removed the Extension.dev live-preview carrier from ./extensions before building. It is a debug companion, not part of your extension; run extension_dev with carrier: true to get it back.");
|
|
2591
3185
|
else if (carrierCleanup.note) carrierNotes.push(carrierCleanup.note);
|
|
2592
3186
|
const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
|
|
2593
|
-
if (preflight?.buildBlocking) return
|
|
3187
|
+
if (preflight?.buildBlocking) return envelope_envelope({
|
|
2594
3188
|
ok: false,
|
|
2595
3189
|
command: build_COMMAND,
|
|
2596
3190
|
status: "manifest-blocked",
|
|
@@ -2623,40 +3217,63 @@ async function build_handler(args) {
|
|
|
2623
3217
|
if (args.polyfill) cliArgs.push("--polyfill");
|
|
2624
3218
|
if (args.silent) cliArgs.push("--silent");
|
|
2625
3219
|
if (args.mode) cliArgs.push("--mode", args.mode);
|
|
2626
|
-
|
|
3220
|
+
if (args.appName) cliArgs.push("--app-name", args.appName);
|
|
3221
|
+
if (args.bundleId) cliArgs.push("--bundle-id", args.bundleId);
|
|
3222
|
+
if (void 0 !== args.macOsOnly) cliArgs.push("--macos-only", String(args.macOsOnly));
|
|
3223
|
+
if (args.forceRegenerate) cliArgs.push("--force-regenerate");
|
|
3224
|
+
const spawn = {
|
|
2627
3225
|
cwd: args.projectPath,
|
|
2628
3226
|
timeoutMs: 180000
|
|
2629
|
-
}
|
|
3227
|
+
};
|
|
3228
|
+
const verdict = await outputJsonVerdict("build", args.projectPath);
|
|
3229
|
+
const engineKnownTooOld = false === verdict.supported;
|
|
3230
|
+
let attempt = engineKnownTooOld ? await runExtensionCli(cliArgs, spawn) : await runExtensionCli([
|
|
3231
|
+
...cliArgs,
|
|
3232
|
+
"--output",
|
|
3233
|
+
"json"
|
|
3234
|
+
], spawn);
|
|
3235
|
+
const engineRefusedJsonOutput = !engineKnownTooOld && 0 !== attempt.code && refusedTheOutputFlag(attempt.stderr ?? "");
|
|
3236
|
+
if (engineRefusedJsonOutput) attempt = await runExtensionCli(cliArgs, spawn);
|
|
3237
|
+
const { code, stdout, stderr } = attempt;
|
|
3238
|
+
if (engineRefusedJsonOutput) warnings.push("The Extension.js installed in this project is older than the one this server expects: it rejected --output json on build, so the build was run a second time without that flag and the result was read from the build summary the engine writes into dist/extension-js/. The extension that came out is exactly the same one. Upgrade the project's Extension.js to get the richer report back, including the Safari app identity and the byte totals from the run that just happened, and to stop paying for the second build.");
|
|
3239
|
+
else if (engineKnownTooOld) warnings.push(`The Extension.js installed in this project is older than the one this server expects: it reports ${verdict.version}, and --output json only reached extension build in ${verdict.floor}, so the build was run without that flag and the result was read from the build summary the engine writes into dist/extension-js/. The extension that came out is exactly the same one, and nothing was built twice. Upgrade the project's Extension.js to get the richer report back, including the Safari app identity and the byte totals from the run that just happened.`);
|
|
2630
3240
|
const duration = Date.now() - start;
|
|
2631
|
-
const
|
|
3241
|
+
const engine = readEngineOutput(stdout ?? "", stderr ?? "");
|
|
3242
|
+
const out = engine.narration;
|
|
2632
3243
|
const lastLines = (text, n)=>text.split("\n").slice(-n).join("\n");
|
|
2633
3244
|
if (0 === code) {
|
|
2634
|
-
const
|
|
2635
|
-
const
|
|
2636
|
-
const engineSummary =
|
|
3245
|
+
const inlineSummary = summaryForBrowser(engineSummaries(engine.frame), browser);
|
|
3246
|
+
const persisted = inlineSummary ? null : readBuildSummary(args.projectPath, browser, start);
|
|
3247
|
+
const engineSummary = inlineSummary ?? persisted?.summary ?? null;
|
|
3248
|
+
const summaryPathNote = persisted && !persisted.summary ? `This build reported no summary of its own, and no summary from this run was found on disk either, so the byte totals and the engine's structured warnings are missing from the result below. The extension that was built is unaffected. ${sessionPathHint(persisted.file)}` : null;
|
|
3249
|
+
const status = engine.frame?.status;
|
|
2637
3250
|
const buildWarnings = engineSummary?.warnings?.length ? engineSummary.warnings : [];
|
|
2638
3251
|
const buildWarningsTruncated = buildWarnings.length && "number" == typeof engineSummary?.warnings_count && engineSummary.warnings_count > buildWarnings.length ? engineSummary.warnings_count : void 0;
|
|
2639
3252
|
const distDir = node_path.resolve(args.projectPath, "dist", browser);
|
|
2640
3253
|
const entrypoints = builtEntrypoints(distDir);
|
|
2641
|
-
const contamination =
|
|
2642
|
-
|
|
3254
|
+
const contamination = carrierContamination(node_path.resolve(args.projectPath, "dist"));
|
|
3255
|
+
const uncheckedNote = contamination.unchecked.length ? `Could not read the entry table of ${contamination.unchecked.join(", ")}, so those archives were not checked for the live-preview carrier. Unpack and check them yourself before submitting.` : null;
|
|
3256
|
+
if (contamination.paths.length) return envelope_envelope({
|
|
2643
3257
|
ok: false,
|
|
2644
3258
|
command: build_COMMAND,
|
|
2645
3259
|
status: "carrier-in-dist",
|
|
2646
3260
|
error: {
|
|
2647
3261
|
code: "E_CARRIER_IN_DIST",
|
|
2648
|
-
message: `The build output contains the Extension.dev live-preview carrier: ${contamination.join(", ")}. That is a local debug companion and must never ship. This artifact is not safe to submit.`
|
|
3262
|
+
message: `The build output contains the Extension.dev live-preview carrier: ${contamination.paths.join(", ")}. That is a local debug companion and must never ship. This artifact is not safe to submit.`
|
|
2649
3263
|
},
|
|
2650
3264
|
value: {
|
|
2651
3265
|
browser,
|
|
2652
3266
|
buildExitCode: 0,
|
|
2653
3267
|
duration
|
|
2654
3268
|
},
|
|
2655
|
-
warnings
|
|
2656
|
-
|
|
3269
|
+
warnings: [
|
|
3270
|
+
...warnings,
|
|
3271
|
+
uncheckedNote
|
|
3272
|
+
],
|
|
3273
|
+
hint: "Delete the listed paths from dist and build again. The carrier lives in ./extensions and is taken back before every build run through this tool, so an entry inside a zip means that archive was packed by something else, usually 'extension build --zip-source' driven straight at the engine while a dev session had the carrier in place."
|
|
2657
3274
|
});
|
|
2658
3275
|
const missing = entrypoints.filter((e)=>!e.present);
|
|
2659
|
-
if (missing.length) return
|
|
3276
|
+
if (missing.length) return envelope_envelope({
|
|
2660
3277
|
ok: false,
|
|
2661
3278
|
command: build_COMMAND,
|
|
2662
3279
|
status: "entrypoint-missing",
|
|
@@ -2684,18 +3301,36 @@ async function build_handler(args) {
|
|
|
2684
3301
|
const zipSourcePath = args.zipSource ? locateSourceZip(args.projectPath, browser, start) : null;
|
|
2685
3302
|
if (args.zipSource && !zipSourcePath) zipNotes.push("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.");
|
|
2686
3303
|
const divergence = manifestDivergence(args.projectPath, browser);
|
|
2687
|
-
|
|
3304
|
+
const safariIdentity = safari ? engineSummary?.safari ?? null : null;
|
|
3305
|
+
const derivedBundleIdNote = safariIdentity?.bundleIdDerived === true ? `The Safari app was packaged under the generated bundle identifier ${safariIdentity.bundleId ?? "the engine derived for you"}, which the engine derived from your app name rather than one you chose. It is fine for running the app locally. Every project built from the same template derives the same identifier, and Apple binds one permanently to the first team that registers it, so whoever submits first takes it and everyone after is locked out. Rebuild with bundleId set to a reverse-DNS identifier under a domain you own, which regenerates the Xcode project, and do it before your first submission: afterwards a new identifier is a new extension carrying none of your users.` : null;
|
|
3306
|
+
const safariIdentityMissingNote = safari && !safariIdentity ? `The build succeeded but reported no Safari app identity, so this run cannot tell you which bundle identifier the app carries. Either the packager did not run (a non-macOS host, or Xcode missing, skips packaging and leaves a plain bundle in dist/${browser}), or the engine installed in this project predates the reporting contract. Check the build output below, and run extension_doctor if you expected an app.` : null;
|
|
3307
|
+
return envelope_envelope({
|
|
2688
3308
|
ok: true,
|
|
2689
3309
|
command: build_COMMAND,
|
|
2690
3310
|
status: "built",
|
|
2691
3311
|
value: {
|
|
2692
3312
|
browser,
|
|
2693
|
-
...
|
|
2694
|
-
|
|
3313
|
+
...safariIdentity ? {
|
|
3314
|
+
safariApp: safariIdentity
|
|
3315
|
+
} : {},
|
|
3316
|
+
..."string" == typeof engineSummary?.output_path ? {
|
|
3317
|
+
outputPath: engineSummary.output_path
|
|
3318
|
+
} : {},
|
|
3319
|
+
..."number" == typeof engineSummary?.total_bytes ? {
|
|
3320
|
+
totalBytes: engineSummary.total_bytes
|
|
3321
|
+
} : {},
|
|
3322
|
+
..."number" == typeof engineSummary?.total_assets ? {
|
|
3323
|
+
totalAssets: engineSummary.total_assets
|
|
3324
|
+
} : {},
|
|
3325
|
+
..."number" == typeof engineSummary?.largest_asset_bytes ? {
|
|
3326
|
+
largestAssetBytes: engineSummary.largest_asset_bytes
|
|
2695
3327
|
} : {},
|
|
2696
3328
|
...status ? {
|
|
2697
3329
|
engineBuildStatus: status
|
|
2698
3330
|
} : {},
|
|
3331
|
+
...engineRefusedJsonOutput ? {
|
|
3332
|
+
engineRejectedJsonOutput: true
|
|
3333
|
+
} : {},
|
|
2699
3334
|
...entrypoints.length ? {
|
|
2700
3335
|
entrypoints
|
|
2701
3336
|
} : {},
|
|
@@ -2719,12 +3354,17 @@ async function build_handler(args) {
|
|
|
2719
3354
|
...warnings,
|
|
2720
3355
|
...preflight?.warnings ?? [],
|
|
2721
3356
|
...buildWarnings,
|
|
2722
|
-
...zipNotes
|
|
3357
|
+
...zipNotes,
|
|
3358
|
+
uncheckedNote,
|
|
3359
|
+
derivedBundleIdNote,
|
|
3360
|
+
safariIdentityMissingNote,
|
|
3361
|
+
summaryPathNote
|
|
2723
3362
|
]
|
|
2724
3363
|
});
|
|
2725
3364
|
}
|
|
2726
|
-
const
|
|
2727
|
-
|
|
3365
|
+
const engineFailure = "string" == typeof engine.frame?.error?.message ? engine.frame.error.message.trim() : "";
|
|
3366
|
+
const message = engineFailure || stderr.trim() || out || `extension build exited with code ${code}`;
|
|
3367
|
+
return envelope_envelope({
|
|
2728
3368
|
ok: false,
|
|
2729
3369
|
command: build_COMMAND,
|
|
2730
3370
|
status: "build-failed",
|
|
@@ -2737,7 +3377,7 @@ async function build_handler(args) {
|
|
|
2737
3377
|
duration
|
|
2738
3378
|
},
|
|
2739
3379
|
warnings,
|
|
2740
|
-
hint: "Check that the project has a valid src/manifest.json
|
|
3380
|
+
hint: "Check that the project has a valid src/manifest.json. Missing dependencies are installed by the build itself, so a failure here is usually the manifest, a compile error, or a Safari toolchain the host does not have."
|
|
2741
3381
|
});
|
|
2742
3382
|
}
|
|
2743
3383
|
const MERGED_FD_CHATTER = [
|
|
@@ -2764,12 +3404,9 @@ function legacyProfileLockScrape(cleanOutput) {
|
|
|
2764
3404
|
}
|
|
2765
3405
|
const LEGACY_FIDELITY_WARNING = "This session ran a CLI that does not stamp the machine contract, so the boot verdict was read from the dev server's output instead of its ready contract. Diagnostics are less precise: compile errors come back as a text tail rather than a list. Upgrade the project's extension dependency to get the precise verdict.";
|
|
2766
3406
|
const READY_STAMP_PROFILE_LOCKED_CODE = "profile_locked";
|
|
2767
|
-
function readyContractPath(projectPath, browser) {
|
|
2768
|
-
return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
2769
|
-
}
|
|
2770
3407
|
function readContract(projectPath, browser, since) {
|
|
2771
3408
|
try {
|
|
2772
|
-
const file =
|
|
3409
|
+
const file = bridge_readyContractPath(projectPath, browser);
|
|
2773
3410
|
const stat = node_fs.statSync(file);
|
|
2774
3411
|
const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
2775
3412
|
if (!contract || "object" != typeof contract) return null;
|
|
@@ -2893,6 +3530,71 @@ async function pollBootVerdict(projectPath, browser, options) {
|
|
|
2893
3530
|
warnings: []
|
|
2894
3531
|
};
|
|
2895
3532
|
}
|
|
3533
|
+
function sweepCarriers(projectPaths) {
|
|
3534
|
+
const out = [];
|
|
3535
|
+
const seen = new Set();
|
|
3536
|
+
for (const projectPath of projectPaths){
|
|
3537
|
+
const resolved = node_path.resolve(projectPath);
|
|
3538
|
+
if (!seen.has(resolved)) {
|
|
3539
|
+
seen.add(resolved);
|
|
3540
|
+
try {
|
|
3541
|
+
const removal = removeCarrier(resolved);
|
|
3542
|
+
if (!removal.removed && !removal.note) continue;
|
|
3543
|
+
out.push({
|
|
3544
|
+
projectPath: resolved,
|
|
3545
|
+
...removal
|
|
3546
|
+
});
|
|
3547
|
+
} catch (error) {
|
|
3548
|
+
out.push({
|
|
3549
|
+
projectPath: resolved,
|
|
3550
|
+
removed: false,
|
|
3551
|
+
path: carrierPath(resolved),
|
|
3552
|
+
note: `Could not remove the carrier: ${error instanceof Error ? error.message : String(error)}`
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
return out;
|
|
3558
|
+
}
|
|
3559
|
+
function sweepCarriersPlacedHere() {
|
|
3560
|
+
try {
|
|
3561
|
+
return sweepCarriers(carriersPlacedHere());
|
|
3562
|
+
} catch {
|
|
3563
|
+
return [];
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
const EXIT_SIGNALS = [
|
|
3567
|
+
"SIGINT",
|
|
3568
|
+
"SIGTERM",
|
|
3569
|
+
"SIGHUP"
|
|
3570
|
+
];
|
|
3571
|
+
let carrier_exit_installed = [];
|
|
3572
|
+
function installCarrierExitCleanup() {
|
|
3573
|
+
if (carrier_exit_installed.length) return;
|
|
3574
|
+
const onExit = ()=>{
|
|
3575
|
+
sweepCarriersPlacedHere();
|
|
3576
|
+
};
|
|
3577
|
+
process.on("exit", onExit);
|
|
3578
|
+
carrier_exit_installed.push({
|
|
3579
|
+
event: "exit",
|
|
3580
|
+
handler: onExit
|
|
3581
|
+
});
|
|
3582
|
+
for (const signal of EXIT_SIGNALS){
|
|
3583
|
+
const onSignal = ()=>{
|
|
3584
|
+
sweepCarriersPlacedHere();
|
|
3585
|
+
process.removeListener(signal, onSignal);
|
|
3586
|
+
carrier_exit_installed = carrier_exit_installed.filter((entry)=>entry.handler !== onSignal);
|
|
3587
|
+
if (0 === process.listenerCount(signal)) try {
|
|
3588
|
+
process.kill(process.pid, signal);
|
|
3589
|
+
} catch {}
|
|
3590
|
+
};
|
|
3591
|
+
process.on(signal, onSignal);
|
|
3592
|
+
carrier_exit_installed.push({
|
|
3593
|
+
event: signal,
|
|
3594
|
+
handler: onSignal
|
|
3595
|
+
});
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
2896
3598
|
const stop_schema = {
|
|
2897
3599
|
name: "extension_stop",
|
|
2898
3600
|
description: "Stop a session that extension_dev or extension_start is running: terminate the server and the browser it launched, and remove the live-preview carrier if extension_dev placed one. This covers extension_start build:false too, which the registry records as a preview session. Call it when you are done verifying, so sessions do not accumulate.",
|
|
@@ -2907,7 +3609,7 @@ const stop_schema = {
|
|
|
2907
3609
|
all: {
|
|
2908
3610
|
type: "boolean",
|
|
2909
3611
|
default: false,
|
|
2910
|
-
description: "Stop every known session across projects and browsers, found from this server's registry AND the on-disk markers earlier runs left, so it still works after an MCP restart. projectPath/browser are then ignored."
|
|
3612
|
+
description: "Stop every known session across projects and browsers, found from this server's registry AND the on-disk markers earlier runs left, so it still works after an MCP restart. It also takes back every live-preview carrier still recorded on this machine, including one in a project whose session was never stopped. projectPath/browser are then ignored."
|
|
2911
3613
|
}
|
|
2912
3614
|
},
|
|
2913
3615
|
required: []
|
|
@@ -2973,7 +3675,7 @@ function sessionProcessPids(projectPath) {
|
|
|
2973
3675
|
const escaped = escapeRegex(form);
|
|
2974
3676
|
const patterns = [
|
|
2975
3677
|
`extension[^ ]* (dev|start|preview) ${escaped}`,
|
|
2976
|
-
`${
|
|
3678
|
+
`${escapeRegex(profilesRootDir(form))}${escapeRegex(node_path.sep)}`
|
|
2977
3679
|
];
|
|
2978
3680
|
for (const pattern of patterns)for (const pid of pgrepPids(pattern))pids.add(pid);
|
|
2979
3681
|
}
|
|
@@ -3009,12 +3711,9 @@ function stop_signal(pid, sig) {
|
|
|
3009
3711
|
}
|
|
3010
3712
|
}
|
|
3011
3713
|
}
|
|
3012
|
-
function readyJsonPath(projectPath, browser) {
|
|
3013
|
-
return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
3014
|
-
}
|
|
3015
3714
|
function pidFromReadyContract(projectPath, browser) {
|
|
3016
3715
|
try {
|
|
3017
|
-
const raw = node_fs.readFileSync(
|
|
3716
|
+
const raw = node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8");
|
|
3018
3717
|
const contract = JSON.parse(raw);
|
|
3019
3718
|
return "number" == typeof contract.pid ? contract.pid : null;
|
|
3020
3719
|
} catch {
|
|
@@ -3051,7 +3750,7 @@ async function stopOne(projectPath, browser) {
|
|
|
3051
3750
|
removeSession(projectPath, browser);
|
|
3052
3751
|
removeSessionMarker(projectPath, browser);
|
|
3053
3752
|
try {
|
|
3054
|
-
node_fs.rmSync(
|
|
3753
|
+
node_fs.rmSync(bridge_readyContractPath(projectPath, browser), {
|
|
3055
3754
|
force: true
|
|
3056
3755
|
});
|
|
3057
3756
|
} catch {}
|
|
@@ -3077,28 +3776,36 @@ async function stop_handler(args) {
|
|
|
3077
3776
|
const key = `${node_path.resolve(m.projectPath)}::${m.browser}`;
|
|
3078
3777
|
if (!candidates.has(key)) candidates.set(key, m);
|
|
3079
3778
|
}
|
|
3080
|
-
|
|
3779
|
+
const outcomes = [];
|
|
3780
|
+
for (const c of candidates.values())outcomes.push(await stopOne(c.projectPath, c.browser));
|
|
3781
|
+
const visited = new Set(outcomes.map((outcome)=>node_path.resolve(outcome.projectPath)));
|
|
3782
|
+
const carriers = sweepCarriers(rememberedCarriers().filter((p)=>!visited.has(node_path.resolve(p))));
|
|
3783
|
+
if (0 === candidates.size && 0 === carriers.length) return envelope_envelope({
|
|
3081
3784
|
ok: true,
|
|
3082
3785
|
command: stop_schema.name,
|
|
3083
3786
|
status: "nothing-to-stop",
|
|
3084
3787
|
value: {
|
|
3085
3788
|
stopped: []
|
|
3086
3789
|
},
|
|
3087
|
-
hint: "No sessions registered in this server
|
|
3790
|
+
hint: "No sessions registered in this server, no session markers on disk, and no carrier left in any project this machine recorded. Nothing to stop."
|
|
3088
3791
|
});
|
|
3089
|
-
|
|
3090
|
-
for (const c of candidates.values())outcomes.push(await stopOne(c.projectPath, c.browser));
|
|
3091
|
-
return envelope({
|
|
3792
|
+
return envelope_envelope({
|
|
3092
3793
|
ok: outcomes.every((o)=>o.stopped),
|
|
3093
3794
|
command: stop_schema.name,
|
|
3094
3795
|
status: "stopped-all",
|
|
3095
3796
|
value: {
|
|
3096
|
-
stopped: outcomes
|
|
3797
|
+
stopped: outcomes,
|
|
3798
|
+
...carriers.length ? {
|
|
3799
|
+
carriersSwept: carriers
|
|
3800
|
+
} : {}
|
|
3097
3801
|
},
|
|
3098
|
-
warnings:
|
|
3802
|
+
warnings: [
|
|
3803
|
+
...outcomes.map((o)=>o.stopped ? null : o.detail),
|
|
3804
|
+
...carriers.map((c)=>c.removed ? `Took the live-preview carrier back out of ${c.projectPath}, which had no session left to stop it.` : `Left the carrier in ${c.projectPath} alone: ${c.note ?? "unknown reason"}`)
|
|
3805
|
+
]
|
|
3099
3806
|
});
|
|
3100
3807
|
}
|
|
3101
|
-
if (!args.projectPath) return
|
|
3808
|
+
if (!args.projectPath) return envelope_envelope({
|
|
3102
3809
|
ok: false,
|
|
3103
3810
|
command: stop_schema.name,
|
|
3104
3811
|
status: "bad-request",
|
|
@@ -3109,7 +3816,7 @@ async function stop_handler(args) {
|
|
|
3109
3816
|
});
|
|
3110
3817
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
3111
3818
|
const outcome = await stopOne(args.projectPath, browser);
|
|
3112
|
-
return
|
|
3819
|
+
return envelope_envelope({
|
|
3113
3820
|
ok: outcome.stopped,
|
|
3114
3821
|
command: stop_schema.name,
|
|
3115
3822
|
status: null === outcome.pid ? "not-found" : outcome.stopped ? "stopped" : "still-alive",
|
|
@@ -3219,7 +3926,7 @@ async function dev_handler(args) {
|
|
|
3219
3926
|
if (existing.length > 0) {
|
|
3220
3927
|
if (!args.replace) {
|
|
3221
3928
|
const listed = existing.map((s)=>`pid ${s.pid} (${s.browser})`).join(", ");
|
|
3222
|
-
return
|
|
3929
|
+
return envelope_envelope({
|
|
3223
3930
|
ok: false,
|
|
3224
3931
|
command: dev_schema.name,
|
|
3225
3932
|
status: "session-exists",
|
|
@@ -3277,6 +3984,9 @@ async function dev_handler(args) {
|
|
|
3277
3984
|
child.on("exit", ()=>{
|
|
3278
3985
|
removeSession(args.projectPath, browser, pid);
|
|
3279
3986
|
removeSessionMarker(args.projectPath, browser, pid);
|
|
3987
|
+
if (carrier) try {
|
|
3988
|
+
removeCarrier(args.projectPath);
|
|
3989
|
+
} catch {}
|
|
3280
3990
|
});
|
|
3281
3991
|
const boot = await pollBootVerdict(args.projectPath, browser, {
|
|
3282
3992
|
child,
|
|
@@ -3294,7 +4004,7 @@ async function dev_handler(args) {
|
|
|
3294
4004
|
};
|
|
3295
4005
|
if ("exited" === boot.verdict.kind) {
|
|
3296
4006
|
const { exitCode: code, signal } = boot.verdict;
|
|
3297
|
-
return
|
|
4007
|
+
return envelope_envelope({
|
|
3298
4008
|
ok: false,
|
|
3299
4009
|
command: dev_schema.name,
|
|
3300
4010
|
status: "exited",
|
|
@@ -3314,7 +4024,7 @@ async function dev_handler(args) {
|
|
|
3314
4024
|
}
|
|
3315
4025
|
if ("compile-failed" === boot.verdict.kind) {
|
|
3316
4026
|
const { compileErrors } = boot.verdict;
|
|
3317
|
-
return
|
|
4027
|
+
return envelope_envelope({
|
|
3318
4028
|
ok: false,
|
|
3319
4029
|
command: dev_schema.name,
|
|
3320
4030
|
status: "compile-failed",
|
|
@@ -3333,10 +4043,16 @@ async function dev_handler(args) {
|
|
|
3333
4043
|
warnings: boot.warnings
|
|
3334
4044
|
});
|
|
3335
4045
|
}
|
|
3336
|
-
const
|
|
4046
|
+
const usesManagedProfile = "string" != typeof args.profile || 0 === args.profile.trim().length;
|
|
4047
|
+
const profileRootDir = usesManagedProfile ? browserProfileRootDir(args.projectPath, browser) : null;
|
|
4048
|
+
const profileAdvice = profileRemediation({
|
|
4049
|
+
projectPath: args.projectPath,
|
|
4050
|
+
browser,
|
|
4051
|
+
profile: args.profile
|
|
4052
|
+
});
|
|
3337
4053
|
if ("profile-locked" === boot.verdict.kind) {
|
|
3338
4054
|
const { owner, lockedAt } = boot.verdict;
|
|
3339
|
-
return
|
|
4055
|
+
return envelope_envelope({
|
|
3340
4056
|
ok: false,
|
|
3341
4057
|
command: dev_schema.name,
|
|
3342
4058
|
status: "profile-locked",
|
|
@@ -3346,18 +4062,20 @@ async function dev_handler(args) {
|
|
|
3346
4062
|
},
|
|
3347
4063
|
value: {
|
|
3348
4064
|
...session,
|
|
3349
|
-
|
|
4065
|
+
...profileRootDir ? {
|
|
4066
|
+
profileRootDir
|
|
4067
|
+
} : {},
|
|
3350
4068
|
owner,
|
|
3351
4069
|
...lockedAt ? {
|
|
3352
4070
|
lockedAt
|
|
3353
4071
|
} : {},
|
|
3354
4072
|
output: cleanOutput.slice(0, 2000)
|
|
3355
4073
|
},
|
|
3356
|
-
hint: `A locked profile means another session's browser still holds it: call extension_stop with this projectPath to kill that session, then start extension_dev again. If the lock survives a crash,
|
|
4074
|
+
hint: `A locked profile means another session's browser still holds it: call extension_stop with this projectPath to kill that session, then start extension_dev again. If the lock survives a crash, clear it by hand before retrying. ${profileAdvice}`,
|
|
3357
4075
|
warnings: boot.warnings
|
|
3358
4076
|
});
|
|
3359
4077
|
}
|
|
3360
|
-
if ("browser-exited" === boot.verdict.kind) return
|
|
4078
|
+
if ("browser-exited" === boot.verdict.kind) return envelope_envelope({
|
|
3361
4079
|
ok: false,
|
|
3362
4080
|
command: dev_schema.name,
|
|
3363
4081
|
status: "browser-exited",
|
|
@@ -3370,7 +4088,7 @@ async function dev_handler(args) {
|
|
|
3370
4088
|
...boot.verdict.stamp,
|
|
3371
4089
|
output: cleanOutput.slice(0, 2000)
|
|
3372
4090
|
},
|
|
3373
|
-
hint: `A locked profile means another session's browser still holds it: call extension_stop with this projectPath to kill that session, then start extension_dev again. If the lock survives a crash,
|
|
4091
|
+
hint: `A locked profile means another session's browser still holds it: call extension_stop with this projectPath to kill that session, then start extension_dev again. If the lock survives a crash, clear it by hand before retrying. ${profileAdvice}`,
|
|
3374
4092
|
warnings: boot.warnings
|
|
3375
4093
|
});
|
|
3376
4094
|
const controlVerbs = "storage, reload, open, dom_snapshot";
|
|
@@ -3397,7 +4115,7 @@ async function dev_handler(args) {
|
|
|
3397
4115
|
requestedPort: args.port ?? 8080
|
|
3398
4116
|
};
|
|
3399
4117
|
const portNote = null !== boundPort ? void 0 !== args.port && args.port !== boundPort ? `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).` : null : "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.";
|
|
3400
|
-
return
|
|
4118
|
+
return envelope_envelope({
|
|
3401
4119
|
ok: true,
|
|
3402
4120
|
command: dev_schema.name,
|
|
3403
4121
|
status: "started",
|
|
@@ -3473,6 +4191,7 @@ async function start_handler(args) {
|
|
|
3473
4191
|
if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
|
|
3474
4192
|
if (args.noBrowser) cliArgs.push("--no-browser");
|
|
3475
4193
|
cliArgs.push(...launchFlagArgs(args));
|
|
4194
|
+
const stale = removeCarrier(args.projectPath);
|
|
3476
4195
|
const spawnedAt = Date.now();
|
|
3477
4196
|
const spawned = spawnExtensionCli(cliArgs, {
|
|
3478
4197
|
projectDir: args.projectPath
|
|
@@ -3506,7 +4225,7 @@ async function start_handler(args) {
|
|
|
3506
4225
|
};
|
|
3507
4226
|
if ("exited" === boot.verdict.kind) {
|
|
3508
4227
|
const { exitCode: code, signal } = boot.verdict;
|
|
3509
|
-
return
|
|
4228
|
+
return envelope_envelope({
|
|
3510
4229
|
ok: false,
|
|
3511
4230
|
command: start_schema.name,
|
|
3512
4231
|
status: "exited",
|
|
@@ -3526,7 +4245,7 @@ async function start_handler(args) {
|
|
|
3526
4245
|
}
|
|
3527
4246
|
if ("compile-failed" === boot.verdict.kind) {
|
|
3528
4247
|
const { compileErrors } = boot.verdict;
|
|
3529
|
-
return
|
|
4248
|
+
return envelope_envelope({
|
|
3530
4249
|
ok: false,
|
|
3531
4250
|
command: start_schema.name,
|
|
3532
4251
|
status: "compile-failed",
|
|
@@ -3547,7 +4266,7 @@ async function start_handler(args) {
|
|
|
3547
4266
|
}
|
|
3548
4267
|
if ("browser-exited" === boot.verdict.kind || "profile-locked" === boot.verdict.kind) {
|
|
3549
4268
|
const stamp = "browser-exited" === boot.verdict.kind ? boot.verdict.stamp : {};
|
|
3550
|
-
return
|
|
4269
|
+
return envelope_envelope({
|
|
3551
4270
|
ok: false,
|
|
3552
4271
|
command: start_schema.name,
|
|
3553
4272
|
status: boot.verdict.kind,
|
|
@@ -3564,7 +4283,7 @@ async function start_handler(args) {
|
|
|
3564
4283
|
warnings: boot.warnings
|
|
3565
4284
|
});
|
|
3566
4285
|
}
|
|
3567
|
-
return
|
|
4286
|
+
return envelope_envelope({
|
|
3568
4287
|
ok: true,
|
|
3569
4288
|
command: start_schema.name,
|
|
3570
4289
|
status: building ? "started" : "launched",
|
|
@@ -3575,7 +4294,10 @@ async function start_handler(args) {
|
|
|
3575
4294
|
logPath
|
|
3576
4295
|
},
|
|
3577
4296
|
hint: building ? "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." : "Call extension_stop when you are done to close the preview browser.",
|
|
3578
|
-
warnings:
|
|
4297
|
+
warnings: [
|
|
4298
|
+
...boot.warnings,
|
|
4299
|
+
stale.removed && "Removed a Live Preview carrier left behind by an earlier dev session, so it was not loaded beside your extension here."
|
|
4300
|
+
]
|
|
3579
4301
|
});
|
|
3580
4302
|
}
|
|
3581
4303
|
function toMcpSpeak(text) {
|
|
@@ -3685,8 +4407,19 @@ async function runActVerb(args, projectPath, timeoutMs, tool) {
|
|
|
3685
4407
|
}
|
|
3686
4408
|
if (frame && "object" == typeof frame) return JSON.stringify(wrapLegacyFrame(frame, command));
|
|
3687
4409
|
} catch {}
|
|
4410
|
+
if (refusedTheOutputFlag(stderr ?? "")) return envelope_envelope({
|
|
4411
|
+
ok: false,
|
|
4412
|
+
command,
|
|
4413
|
+
status: "engine-too-old",
|
|
4414
|
+
error: {
|
|
4415
|
+
code: "E_ENGINE_TOO_OLD",
|
|
4416
|
+
name: "CliError",
|
|
4417
|
+
message: await outputFlagRefusalMessage("act", args[0], projectPath)
|
|
4418
|
+
},
|
|
4419
|
+
hint: "extension_doctor reports the project's engine version next to the one this server pins."
|
|
4420
|
+
});
|
|
3688
4421
|
const message = stderr.trim() || `extension exited with code ${code}`;
|
|
3689
|
-
return
|
|
4422
|
+
return envelope_envelope({
|
|
3690
4423
|
ok: false,
|
|
3691
4424
|
command,
|
|
3692
4425
|
status: "cli-failed",
|
|
@@ -4140,7 +4873,7 @@ class CDPClient extends CDPConnection {
|
|
|
4140
4873
|
async function resolveContractPort(projectPath, browser, field, options) {
|
|
4141
4874
|
const waitMs = options?.waitMs ?? 20000;
|
|
4142
4875
|
const graceMs = options?.graceMs ?? 2500;
|
|
4143
|
-
const readyPath =
|
|
4876
|
+
const readyPath = bridge_readyContractPath(projectPath, browser);
|
|
4144
4877
|
const deadline = Date.now() + waitMs;
|
|
4145
4878
|
let contractSeen = false;
|
|
4146
4879
|
let contractSeenAt = null;
|
|
@@ -4209,6 +4942,10 @@ const ENGINE_COMPANION_IDS = new Set([
|
|
|
4209
4942
|
CARRIER_EXTENSION_ID
|
|
4210
4943
|
]);
|
|
4211
4944
|
const EXTENSION_URL = /^chrome-extension:\/\/([a-p]{32})\//i;
|
|
4945
|
+
function isEngineCompanionUrl(url) {
|
|
4946
|
+
const match = EXTENSION_URL.exec(String(url ?? ""));
|
|
4947
|
+
return match ? ENGINE_COMPANION_IDS.has(match[1].toLowerCase()) : false;
|
|
4948
|
+
}
|
|
4212
4949
|
async function verifyGuestLoaded(projectPath, browser, options) {
|
|
4213
4950
|
let cdpPort;
|
|
4214
4951
|
try {
|
|
@@ -4339,7 +5076,7 @@ async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool =
|
|
|
4339
5076
|
return raw;
|
|
4340
5077
|
}
|
|
4341
5078
|
const settled = await pollForBridgeTab(projectPath, browser, url, null != timeout ? Math.min(timeout, 6000) : 6000);
|
|
4342
|
-
if (!settled) return
|
|
5079
|
+
if (!settled) return envelope_envelope({
|
|
4343
5080
|
ok: false,
|
|
4344
5081
|
command: tool,
|
|
4345
5082
|
status: "navigate-failed",
|
|
@@ -4350,7 +5087,7 @@ async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool =
|
|
|
4350
5087
|
},
|
|
4351
5088
|
hint: "Confirm the URL, or discover open tabs with extension_dom_snapshot listTabs: true. For an extension page, the path must match the BUILT manifest."
|
|
4352
5089
|
});
|
|
4353
|
-
return
|
|
5090
|
+
return envelope_envelope({
|
|
4354
5091
|
ok: true,
|
|
4355
5092
|
command: tool,
|
|
4356
5093
|
status: "navigated",
|
|
@@ -4422,7 +5159,7 @@ function isDisposableTab(tabUrl, destination) {
|
|
|
4422
5159
|
async function navigateToUrl(projectPath, browser, url, timeout) {
|
|
4423
5160
|
if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
|
|
4424
5161
|
const resolved = await resolveCdpPort(projectPath, browser);
|
|
4425
|
-
if (!resolved) return
|
|
5162
|
+
if (!resolved) return envelope_envelope({
|
|
4426
5163
|
ok: false,
|
|
4427
5164
|
command: open_schema.name,
|
|
4428
5165
|
status: "no-session",
|
|
@@ -4458,7 +5195,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
|
|
|
4458
5195
|
const settled = await pollForTarget(resolved.port, url, 6000, navigatedTargetId);
|
|
4459
5196
|
if (!settled) {
|
|
4460
5197
|
const isExtensionPage = url.startsWith("chrome-extension://");
|
|
4461
|
-
return
|
|
5198
|
+
return envelope_envelope({
|
|
4462
5199
|
ok: false,
|
|
4463
5200
|
command: open_schema.name,
|
|
4464
5201
|
status: "navigate-failed",
|
|
@@ -4470,7 +5207,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
|
|
|
4470
5207
|
hint: isExtensionPage ? "Confirm the path exists in the built dist (extension_build / extension_analyze list entrypoints). For an extension page, the path must match the BUILT manifest, which may differ from your source layout." : "Confirm the URL loads in a normal browser and that the dev session's browser has network access. Nothing about your extension bundle is implicated in a failed http(s) navigation."
|
|
4471
5208
|
});
|
|
4472
5209
|
}
|
|
4473
|
-
return
|
|
5210
|
+
return envelope_envelope({
|
|
4474
5211
|
ok: true,
|
|
4475
5212
|
command: open_schema.name,
|
|
4476
5213
|
status: "navigated",
|
|
@@ -4494,7 +5231,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
|
|
|
4494
5231
|
hint: "Inspect it with extension_dom_snapshot or extension_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_snapshot with listTabs: true."
|
|
4495
5232
|
});
|
|
4496
5233
|
} catch (e) {
|
|
4497
|
-
return
|
|
5234
|
+
return envelope_envelope({
|
|
4498
5235
|
ok: false,
|
|
4499
5236
|
command: open_schema.name,
|
|
4500
5237
|
status: "navigate-failed",
|
|
@@ -4569,7 +5306,7 @@ function declaredCommands(projectPath, browser) {
|
|
|
4569
5306
|
}
|
|
4570
5307
|
function readDistPath(projectPath, browser) {
|
|
4571
5308
|
try {
|
|
4572
|
-
const file =
|
|
5309
|
+
const file = bridge_readyContractPath(projectPath, browser);
|
|
4573
5310
|
const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
4574
5311
|
return "string" == typeof contract?.distPath ? contract.distPath : null;
|
|
4575
5312
|
} catch {
|
|
@@ -4624,7 +5361,7 @@ function declaredSurfaces(projectPath, browser) {
|
|
|
4624
5361
|
}
|
|
4625
5362
|
function missingSurfaceError(projectPath, browser, surface, consequence) {
|
|
4626
5363
|
const declared = declaredSurfaces(projectPath, browser);
|
|
4627
|
-
if (null === declared) return
|
|
5364
|
+
if (null === declared) return envelope_envelope({
|
|
4628
5365
|
ok: false,
|
|
4629
5366
|
command: open_schema.name,
|
|
4630
5367
|
status: "no-manifest",
|
|
@@ -4638,7 +5375,7 @@ function missingSurfaceError(projectPath, browser, surface, consequence) {
|
|
|
4638
5375
|
const key = SURFACE_MANIFEST_KEYS[surface] ?? surface;
|
|
4639
5376
|
const others = declared.filter((s)=>s !== surface);
|
|
4640
5377
|
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.`;
|
|
4641
|
-
return
|
|
5378
|
+
return envelope_envelope({
|
|
4642
5379
|
ok: false,
|
|
4643
5380
|
command: open_schema.name,
|
|
4644
5381
|
status: "no-surface",
|
|
@@ -4716,7 +5453,7 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
|
|
|
4716
5453
|
let extensionId = null;
|
|
4717
5454
|
if (isChromiumFamily(browser)) {
|
|
4718
5455
|
extensionId = await resolveExtensionId(projectPath, browser);
|
|
4719
|
-
if (!extensionId) return
|
|
5456
|
+
if (!extensionId) return envelope_envelope({
|
|
4720
5457
|
ok: false,
|
|
4721
5458
|
command: open_schema.name,
|
|
4722
5459
|
status: "no-extension-id",
|
|
@@ -4730,7 +5467,7 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
|
|
|
4730
5467
|
url = `chrome-extension://${extensionId}/${doc}`;
|
|
4731
5468
|
} else {
|
|
4732
5469
|
const base = await resolveBridgeBaseUrl(projectPath, browser);
|
|
4733
|
-
if (!base) return
|
|
5470
|
+
if (!base) return envelope_envelope({
|
|
4734
5471
|
ok: false,
|
|
4735
5472
|
command: open_schema.name,
|
|
4736
5473
|
status: "no-extension-id",
|
|
@@ -4793,7 +5530,7 @@ async function confirmSurfaceTarget(projectPath, browser, surface, raw) {
|
|
|
4793
5530
|
});
|
|
4794
5531
|
return actFrameJson(parsed);
|
|
4795
5532
|
}
|
|
4796
|
-
return
|
|
5533
|
+
return envelope_envelope({
|
|
4797
5534
|
ok: false,
|
|
4798
5535
|
command: open_schema.name,
|
|
4799
5536
|
status: "surface-did-not-open",
|
|
@@ -4867,7 +5604,7 @@ async function open_handler(args) {
|
|
|
4867
5604
|
"bookmarks"
|
|
4868
5605
|
];
|
|
4869
5606
|
if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
4870
|
-
if (!args.surface) return
|
|
5607
|
+
if (!args.surface) return envelope_envelope({
|
|
4871
5608
|
ok: false,
|
|
4872
5609
|
command: open_schema.name,
|
|
4873
5610
|
status: "bad-request",
|
|
@@ -4879,7 +5616,7 @@ async function open_handler(args) {
|
|
|
4879
5616
|
});
|
|
4880
5617
|
if ("command" === args.surface) {
|
|
4881
5618
|
const declared = declaredCommands(args.projectPath, browser);
|
|
4882
|
-
if (declared && args.name && !declared.includes(args.name)) return
|
|
5619
|
+
if (declared && args.name && !declared.includes(args.name)) return envelope_envelope({
|
|
4883
5620
|
ok: false,
|
|
4884
5621
|
command: open_schema.name,
|
|
4885
5622
|
status: "unknown-command",
|
|
@@ -4916,7 +5653,7 @@ async function open_handler(args) {
|
|
|
4916
5653
|
const parsed = JSON.parse(raw);
|
|
4917
5654
|
const msg = String(parsed?.error?.message ?? "");
|
|
4918
5655
|
const code = "string" == typeof parsed?.error?.code ? parsed.error.code : "";
|
|
4919
|
-
const refusedWindow = "
|
|
5656
|
+
const refusedWindow = "E_TARGET_NOT_FOUND" === code || /active browser window|no active|headless|user gesture/i.test(msg);
|
|
4920
5657
|
if (parsed?.ok === false && refusedWindow) {
|
|
4921
5658
|
if (AS_TAB_SURFACES.includes(args.surface)) {
|
|
4922
5659
|
const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
|
|
@@ -4950,7 +5687,7 @@ async function publish(options = {}) {
|
|
|
4950
5687
|
}
|
|
4951
5688
|
};
|
|
4952
5689
|
const doFetch = options.fetchImpl ?? fetch;
|
|
4953
|
-
const apiCheck = safeApiBase(resolveApiBase(options.api));
|
|
5690
|
+
const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
|
|
4954
5691
|
if (!apiCheck.ok) return {
|
|
4955
5692
|
ok: false,
|
|
4956
5693
|
error: {
|
|
@@ -5003,15 +5740,207 @@ async function publish(options = {}) {
|
|
|
5003
5740
|
data
|
|
5004
5741
|
};
|
|
5005
5742
|
}
|
|
5743
|
+
const ARTIFACT_ID = /^gen_(?:[0-9a-f]{32}|[0-9a-f]{64})$/;
|
|
5744
|
+
const ARTIFACT_ID_CANDIDATE = /gen_[0-9a-f]+/;
|
|
5745
|
+
function parseArtifactRef(input) {
|
|
5746
|
+
const raw = String(input ?? "").trim();
|
|
5747
|
+
if (!raw) return null;
|
|
5748
|
+
if (ARTIFACT_ID.test(raw)) return raw;
|
|
5749
|
+
let parsed = null;
|
|
5750
|
+
try {
|
|
5751
|
+
parsed = new URL(raw);
|
|
5752
|
+
} catch {
|
|
5753
|
+
parsed = null;
|
|
5754
|
+
}
|
|
5755
|
+
if (parsed) {
|
|
5756
|
+
const fromQuery = parsed.searchParams.get("preview");
|
|
5757
|
+
if (fromQuery && ARTIFACT_ID.test(fromQuery.trim())) return fromQuery.trim();
|
|
5758
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
5759
|
+
for(let i = segments.length - 1; i >= 0; i -= 1){
|
|
5760
|
+
const raw = segments[i];
|
|
5761
|
+
if (!raw) continue;
|
|
5762
|
+
const segment = decodeURIComponent(raw);
|
|
5763
|
+
if (ARTIFACT_ID.test(segment)) return segment;
|
|
5764
|
+
}
|
|
5765
|
+
}
|
|
5766
|
+
const loose = ARTIFACT_ID_CANDIDATE.exec(raw);
|
|
5767
|
+
return loose && ARTIFACT_ID.test(loose[0]) ? loose[0] : null;
|
|
5768
|
+
}
|
|
5769
|
+
function wwwRevokeUrl(value) {
|
|
5770
|
+
let parsed;
|
|
5771
|
+
try {
|
|
5772
|
+
parsed = new URL(value);
|
|
5773
|
+
} catch {
|
|
5774
|
+
return value;
|
|
5775
|
+
}
|
|
5776
|
+
if ("extension.dev" !== parsed.hostname.toLowerCase()) return value;
|
|
5777
|
+
parsed.hostname = "www.extension.dev";
|
|
5778
|
+
return parsed.toString();
|
|
5779
|
+
}
|
|
5780
|
+
function authError(name) {
|
|
5781
|
+
return {
|
|
5782
|
+
ok: false,
|
|
5783
|
+
error: {
|
|
5784
|
+
name,
|
|
5785
|
+
message: "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
|
|
5786
|
+
}
|
|
5787
|
+
};
|
|
5788
|
+
}
|
|
5789
|
+
async function readBody(res) {
|
|
5790
|
+
const text = await res.text();
|
|
5791
|
+
try {
|
|
5792
|
+
return JSON.parse(text);
|
|
5793
|
+
} catch {
|
|
5794
|
+
return {
|
|
5795
|
+
message: text
|
|
5796
|
+
};
|
|
5797
|
+
}
|
|
5798
|
+
}
|
|
5799
|
+
async function listArtifacts(options = {}) {
|
|
5800
|
+
const token = options.token ?? resolveToken();
|
|
5801
|
+
if (!token) return authError("SharesAuthError");
|
|
5802
|
+
const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
|
|
5803
|
+
if (!apiCheck.ok) return {
|
|
5804
|
+
ok: false,
|
|
5805
|
+
error: {
|
|
5806
|
+
name: "SharesConfigError",
|
|
5807
|
+
message: apiCheck.message
|
|
5808
|
+
}
|
|
5809
|
+
};
|
|
5810
|
+
const url = new URL(`${apiCheck.base}/api/artifacts`);
|
|
5811
|
+
if (null != options.limit) url.searchParams.set("limit", String(options.limit));
|
|
5812
|
+
if (options.liveOnly) url.searchParams.set("status", "live");
|
|
5813
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
5814
|
+
let res;
|
|
5815
|
+
try {
|
|
5816
|
+
res = await doFetch(url.toString(), {
|
|
5817
|
+
headers: {
|
|
5818
|
+
authorization: `Bearer ${token}`,
|
|
5819
|
+
accept: "application/json",
|
|
5820
|
+
...identityHeaders("extension_shares")
|
|
5821
|
+
}
|
|
5822
|
+
});
|
|
5823
|
+
} catch (err) {
|
|
5824
|
+
return {
|
|
5825
|
+
ok: false,
|
|
5826
|
+
error: {
|
|
5827
|
+
name: "SharesNetworkError",
|
|
5828
|
+
message: `Could not reach ${url.toString()}: ${err?.message || err}`
|
|
5829
|
+
}
|
|
5830
|
+
};
|
|
5831
|
+
}
|
|
5832
|
+
const data = await readBody(res);
|
|
5833
|
+
if (401 === res.status) return authError("SharesAuthError");
|
|
5834
|
+
if (!res.ok) return {
|
|
5835
|
+
ok: false,
|
|
5836
|
+
error: {
|
|
5837
|
+
name: "SharesListError",
|
|
5838
|
+
status: res.status,
|
|
5839
|
+
message: `Listing shares failed (${res.status}): ${data?.message || "unknown error"}`
|
|
5840
|
+
}
|
|
5841
|
+
};
|
|
5842
|
+
const artifacts = Array.isArray(data.artifacts) ? data.artifacts.map((artifact)=>artifact && "string" == typeof artifact.revokeUrl ? {
|
|
5843
|
+
...artifact,
|
|
5844
|
+
revokeUrl: wwwRevokeUrl(artifact.revokeUrl)
|
|
5845
|
+
} : artifact) : [];
|
|
5846
|
+
const num = (value, fallback)=>"number" == typeof value && Number.isFinite(value) ? value : fallback;
|
|
5847
|
+
return {
|
|
5848
|
+
ok: true,
|
|
5849
|
+
data: {
|
|
5850
|
+
artifacts,
|
|
5851
|
+
count: num(data.count, artifacts.length),
|
|
5852
|
+
matched: num(data.matched, artifacts.length),
|
|
5853
|
+
limit: num(data.limit, artifacts.length),
|
|
5854
|
+
truncated: true === data.truncated,
|
|
5855
|
+
truncatedReported: "boolean" == typeof data.truncated,
|
|
5856
|
+
scanned: num(data.scanned, 0)
|
|
5857
|
+
}
|
|
5858
|
+
};
|
|
5859
|
+
}
|
|
5860
|
+
async function revokeArtifact(options) {
|
|
5861
|
+
const token = options.token ?? resolveToken();
|
|
5862
|
+
if (!token) return authError("SharesAuthError");
|
|
5863
|
+
const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
|
|
5864
|
+
if (!apiCheck.ok) return {
|
|
5865
|
+
ok: false,
|
|
5866
|
+
error: {
|
|
5867
|
+
name: "SharesConfigError",
|
|
5868
|
+
message: apiCheck.message
|
|
5869
|
+
}
|
|
5870
|
+
};
|
|
5871
|
+
const url = `${apiCheck.base}/api/artifacts/${encodeURIComponent(options.artifactId)}`;
|
|
5872
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
5873
|
+
let res;
|
|
5874
|
+
try {
|
|
5875
|
+
res = await doFetch(url, {
|
|
5876
|
+
method: "DELETE",
|
|
5877
|
+
headers: {
|
|
5878
|
+
authorization: `Bearer ${token}`,
|
|
5879
|
+
accept: "application/json",
|
|
5880
|
+
...identityHeaders("extension_shares")
|
|
5881
|
+
}
|
|
5882
|
+
});
|
|
5883
|
+
} catch (err) {
|
|
5884
|
+
return {
|
|
5885
|
+
ok: false,
|
|
5886
|
+
error: {
|
|
5887
|
+
name: "SharesNetworkError",
|
|
5888
|
+
message: `Could not reach ${url}: ${err?.message || err}`
|
|
5889
|
+
}
|
|
5890
|
+
};
|
|
5891
|
+
}
|
|
5892
|
+
const data = await readBody(res);
|
|
5893
|
+
if (401 === res.status) return authError("SharesAuthError");
|
|
5894
|
+
if (404 === res.status) return {
|
|
5895
|
+
ok: false,
|
|
5896
|
+
error: {
|
|
5897
|
+
name: "SharesNotFoundError",
|
|
5898
|
+
status: 404,
|
|
5899
|
+
message: `The platform has no live share ${options.artifactId} for this token. It may already be revoked, already expired, owned by a different project than the one this token is scoped to, or a teammate's personal share, which belongs to that person alone and no project token can revoke.`
|
|
5900
|
+
}
|
|
5901
|
+
};
|
|
5902
|
+
if (!res.ok) return {
|
|
5903
|
+
ok: false,
|
|
5904
|
+
error: {
|
|
5905
|
+
name: "SharesRevokeError",
|
|
5906
|
+
status: res.status,
|
|
5907
|
+
message: `Revoking ${options.artifactId} failed (${res.status}): ${data?.message || "unknown error"}`
|
|
5908
|
+
}
|
|
5909
|
+
};
|
|
5910
|
+
return {
|
|
5911
|
+
ok: true,
|
|
5912
|
+
data: {
|
|
5913
|
+
artifactId: options.artifactId,
|
|
5914
|
+
revoked: true === data.revoked,
|
|
5915
|
+
..."string" == typeof data.revokedAt ? {
|
|
5916
|
+
revokedAt: data.revokedAt
|
|
5917
|
+
} : {}
|
|
5918
|
+
}
|
|
5919
|
+
};
|
|
5920
|
+
}
|
|
5006
5921
|
const IGNORED_SEGMENTS = new Set([
|
|
5007
5922
|
"node_modules",
|
|
5008
5923
|
".git",
|
|
5009
5924
|
".DS_Store",
|
|
5010
5925
|
"__MACOSX"
|
|
5011
5926
|
]);
|
|
5927
|
+
const IGNORED_FILES = /^\.env(\..*)?$|\.(pem|key|p12|pfx|keystore)$/i;
|
|
5012
5928
|
const TEXTUAL = /\.(json|js|mjs|cjs|ts|tsx|jsx|html|htm|css|svg|txt|md|map)$/i;
|
|
5013
5929
|
const MAX_FILES = 2000;
|
|
5014
5930
|
const MAX_CONTENT_CHARS = 67108864;
|
|
5931
|
+
function encodeFile(relativePath, bytes) {
|
|
5932
|
+
if (TEXTUAL.test(relativePath)) {
|
|
5933
|
+
const text = bytes.toString("utf8");
|
|
5934
|
+
if (Buffer.from(text, "utf8").equals(bytes)) return {
|
|
5935
|
+
content: text,
|
|
5936
|
+
encoding: "utf8"
|
|
5937
|
+
};
|
|
5938
|
+
}
|
|
5939
|
+
return {
|
|
5940
|
+
content: bytes.toString("base64"),
|
|
5941
|
+
encoding: "base64"
|
|
5942
|
+
};
|
|
5943
|
+
}
|
|
5015
5944
|
function collectDistFiles(distDir) {
|
|
5016
5945
|
const files = [];
|
|
5017
5946
|
const walk = (dir)=>{
|
|
@@ -5026,12 +5955,13 @@ function collectDistFiles(distDir) {
|
|
|
5026
5955
|
continue;
|
|
5027
5956
|
}
|
|
5028
5957
|
if (!entry.isFile()) continue;
|
|
5958
|
+
if (IGNORED_FILES.test(entry.name)) continue;
|
|
5029
5959
|
const relative = node_path.relative(distDir, absolute).split(node_path.sep).join("/");
|
|
5030
5960
|
const bytes = node_fs.readFileSync(absolute);
|
|
5031
|
-
const encoding =
|
|
5961
|
+
const { content, encoding } = encodeFile(relative, bytes);
|
|
5032
5962
|
files.push({
|
|
5033
5963
|
path: relative,
|
|
5034
|
-
content
|
|
5964
|
+
content,
|
|
5035
5965
|
encoding
|
|
5036
5966
|
});
|
|
5037
5967
|
}
|
|
@@ -5048,7 +5978,7 @@ async function uploadPreview(options) {
|
|
|
5048
5978
|
message: "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
|
|
5049
5979
|
}
|
|
5050
5980
|
};
|
|
5051
|
-
const apiCheck = safeApiBase(resolveApiBase(options.api));
|
|
5981
|
+
const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
|
|
5052
5982
|
if (!apiCheck.ok) return {
|
|
5053
5983
|
ok: false,
|
|
5054
5984
|
error: {
|
|
@@ -5083,13 +6013,17 @@ async function uploadPreview(options) {
|
|
|
5083
6013
|
}
|
|
5084
6014
|
};
|
|
5085
6015
|
const totalChars = files.reduce((sum, file)=>sum + file.content.length, 0);
|
|
5086
|
-
if (totalChars > MAX_CONTENT_CHARS)
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
6016
|
+
if (totalChars > MAX_CONTENT_CHARS) {
|
|
6017
|
+
const encodedBytes = files.reduce((sum, file)=>sum + Buffer.byteLength(file.content, file.encoding), 0);
|
|
6018
|
+
const mb = (value)=>(value / 1048576).toFixed(1);
|
|
6019
|
+
return {
|
|
6020
|
+
ok: false,
|
|
6021
|
+
error: {
|
|
6022
|
+
name: "PreviewTooLargeError",
|
|
6023
|
+
message: `${options.distDir} is too large to share: ${mb(encodedBytes)}MB of files. A shared preview holds about ${mb(MAX_CONTENT_CHARS)}MB of text, and roughly ${mb(3 * MAX_CONTENT_CHARS / 4)}MB when the build is mostly images, fonts or wasm, which travel base64-encoded. Trim the bundled assets, or build without source maps if yours are large.`
|
|
6024
|
+
}
|
|
6025
|
+
};
|
|
6026
|
+
}
|
|
5093
6027
|
const name = "string" == typeof options.manifest.name && options.manifest.name.trim() ? options.manifest.name : node_path.basename(options.distDir);
|
|
5094
6028
|
const version = "string" == typeof options.manifest.version ? options.manifest.version : void 0;
|
|
5095
6029
|
const doFetch = options.fetchImpl ?? fetch;
|
|
@@ -5100,6 +6034,7 @@ async function uploadPreview(options) {
|
|
|
5100
6034
|
headers: {
|
|
5101
6035
|
authorization: `Bearer ${token}`,
|
|
5102
6036
|
"content-type": "application/json",
|
|
6037
|
+
"x-extensiondev-origin": "mcp",
|
|
5103
6038
|
...identityHeaders("extension_preview_web")
|
|
5104
6039
|
},
|
|
5105
6040
|
body: JSON.stringify({
|
|
@@ -5153,14 +6088,14 @@ async function uploadPreview(options) {
|
|
|
5153
6088
|
artifactId,
|
|
5154
6089
|
previewUrl,
|
|
5155
6090
|
zipUrl: "string" == typeof data.zipUrl ? data.zipUrl : void 0,
|
|
5156
|
-
revokeUrl: "string" == typeof data.revokeUrl ? data.revokeUrl : void 0,
|
|
6091
|
+
revokeUrl: "string" == typeof data.revokeUrl ? wwwRevokeUrl(data.revokeUrl) : void 0,
|
|
5157
6092
|
expiresAt: "string" == typeof data.expiresAt ? data.expiresAt : void 0
|
|
5158
6093
|
}
|
|
5159
6094
|
};
|
|
5160
6095
|
}
|
|
5161
6096
|
const SHARE_STATE_DIR = ".extension.dev";
|
|
5162
6097
|
const SHARE_RECORD_FILE = "shared-previews.json";
|
|
5163
|
-
const FILE_NOTE = "Every preview link shared from this project. revokeUrl is the
|
|
6098
|
+
const FILE_NOTE = "Every preview link shared from this project. revokeUrl is the handle that kills a link before expiresAt. Losing it is recoverable: run extension_shares to list every share this token owns, each with its revokeUrl. Re-sharing an unchanged build returns the same link rather than a new one, and only a revoked link is replaced. Safe to delete once every link below has expired.";
|
|
5164
6099
|
const IGNORE_COMMENT = "# Extension.dev local state: revoke handles for shared previews, not part of your extension.";
|
|
5165
6100
|
function sharedPreviewsPath(projectPath) {
|
|
5166
6101
|
return node_path.join(projectPath, SHARE_STATE_DIR, SHARE_RECORD_FILE);
|
|
@@ -5233,7 +6168,7 @@ function recordSharedPreview(projectPath, entry) {
|
|
|
5233
6168
|
return {
|
|
5234
6169
|
recorded: false,
|
|
5235
6170
|
path: file,
|
|
5236
|
-
note: `Could not write the share record to ${file}: ${error instanceof Error ? error.message : String(error)}.
|
|
6171
|
+
note: `Could not write the share record to ${file}: ${error instanceof Error ? error.message : String(error)}. Keep the revokeUrl in this response, or run extension_shares later to list every share this token owns with its revokeUrl.`
|
|
5237
6172
|
};
|
|
5238
6173
|
}
|
|
5239
6174
|
const ignore = ensureProjectIgnored(projectPath, {
|
|
@@ -5259,8 +6194,117 @@ function recordSharedPreview(projectPath, entry) {
|
|
|
5259
6194
|
} : {}
|
|
5260
6195
|
};
|
|
5261
6196
|
}
|
|
6197
|
+
const MAX_HOPS = 4;
|
|
6198
|
+
function allows(allowOrigin, origin) {
|
|
6199
|
+
if (!allowOrigin) return false;
|
|
6200
|
+
const value = allowOrigin.trim();
|
|
6201
|
+
return "*" === value || value.toLowerCase() === origin.toLowerCase();
|
|
6202
|
+
}
|
|
6203
|
+
async function probeShareCors(options) {
|
|
6204
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
6205
|
+
const origin = options.origin;
|
|
6206
|
+
let url = options.zipUrl;
|
|
6207
|
+
let redirects = 0;
|
|
6208
|
+
const verdict = (extra)=>({
|
|
6209
|
+
checkedUrl: options.zipUrl,
|
|
6210
|
+
origin,
|
|
6211
|
+
finalUrl: url,
|
|
6212
|
+
finalStatus: 0,
|
|
6213
|
+
redirects,
|
|
6214
|
+
allowOrigin: null,
|
|
6215
|
+
...extra
|
|
6216
|
+
});
|
|
6217
|
+
for(let hop = 0; hop < MAX_HOPS; hop++){
|
|
6218
|
+
const controller = new AbortController();
|
|
6219
|
+
let res;
|
|
6220
|
+
try {
|
|
6221
|
+
res = await doFetch(url, {
|
|
6222
|
+
method: "GET",
|
|
6223
|
+
redirect: "manual",
|
|
6224
|
+
signal: controller.signal,
|
|
6225
|
+
headers: {
|
|
6226
|
+
origin,
|
|
6227
|
+
"sec-fetch-mode": "cors"
|
|
6228
|
+
}
|
|
6229
|
+
});
|
|
6230
|
+
} catch (err) {
|
|
6231
|
+
return verdict({
|
|
6232
|
+
ok: false,
|
|
6233
|
+
reason: `Could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`
|
|
6234
|
+
});
|
|
6235
|
+
}
|
|
6236
|
+
try {
|
|
6237
|
+
await res.body?.cancel();
|
|
6238
|
+
} catch {
|
|
6239
|
+
controller.abort();
|
|
6240
|
+
}
|
|
6241
|
+
const status = res.status;
|
|
6242
|
+
const allowOrigin = res.headers.get("access-control-allow-origin");
|
|
6243
|
+
if (status >= 300 && status < 400) {
|
|
6244
|
+
const location = res.headers.get("location");
|
|
6245
|
+
if (!location) return verdict({
|
|
6246
|
+
ok: false,
|
|
6247
|
+
finalStatus: status,
|
|
6248
|
+
allowOrigin,
|
|
6249
|
+
reason: `${url} answered ${status} with no Location, so the download goes nowhere.`
|
|
6250
|
+
});
|
|
6251
|
+
url = new URL(location, url).toString();
|
|
6252
|
+
redirects += 1;
|
|
6253
|
+
continue;
|
|
6254
|
+
}
|
|
6255
|
+
if (status >= 400) return verdict({
|
|
6256
|
+
ok: false,
|
|
6257
|
+
finalStatus: status,
|
|
6258
|
+
allowOrigin,
|
|
6259
|
+
reason: `The build's zip answered ${status}, so the link has nothing to render.`
|
|
6260
|
+
});
|
|
6261
|
+
if (!allows(allowOrigin, origin)) return verdict({
|
|
6262
|
+
ok: false,
|
|
6263
|
+
finalStatus: status,
|
|
6264
|
+
allowOrigin,
|
|
6265
|
+
reason: `${url} answered ${status} but with ` + (allowOrigin ? `access-control-allow-origin: ${allowOrigin}, which does not cover ${origin}` : "no access-control-allow-origin header") + `. A browser at ${origin} will refuse to read it, so the link opens to an error even though this fetch succeeded.` + (redirects > 0 ? " The header has to be on this response, not on the redirect that led here." : "")
|
|
6266
|
+
});
|
|
6267
|
+
return verdict({
|
|
6268
|
+
ok: true,
|
|
6269
|
+
finalStatus: status,
|
|
6270
|
+
allowOrigin,
|
|
6271
|
+
reason: `A browser at ${origin} can read the build's zip: the final response after ${redirects} redirect(s) answered ${status} with access-control-allow-origin: ${allowOrigin}.`
|
|
6272
|
+
});
|
|
6273
|
+
}
|
|
6274
|
+
return verdict({
|
|
6275
|
+
ok: false,
|
|
6276
|
+
reason: `The build's zip redirected more than ${MAX_HOPS} times, so nothing could be read from it.`
|
|
6277
|
+
});
|
|
6278
|
+
}
|
|
5262
6279
|
const preview_web_COMMAND = "extension_preview_web";
|
|
5263
6280
|
const DEFAULT_PREVIEW_DEV_URL = "http://localhost:3110";
|
|
6281
|
+
const LOCAL_HOSTS = new Set([
|
|
6282
|
+
"localhost",
|
|
6283
|
+
"127.0.0.1",
|
|
6284
|
+
"[::1]",
|
|
6285
|
+
"::1"
|
|
6286
|
+
]);
|
|
6287
|
+
function safeHostBase(raw) {
|
|
6288
|
+
const trimmed = String(raw || "").replace(/\/+$/, "");
|
|
6289
|
+
let parsed;
|
|
6290
|
+
try {
|
|
6291
|
+
parsed = new URL(trimmed);
|
|
6292
|
+
} catch {
|
|
6293
|
+
return {
|
|
6294
|
+
ok: false,
|
|
6295
|
+
message: `hostUrl is not a URL: ${raw}. Leave it unset to use ${DEFAULT_PREVIEW_DEV_URL}.`
|
|
6296
|
+
};
|
|
6297
|
+
}
|
|
6298
|
+
const isLocal = LOCAL_HOSTS.has(parsed.hostname) || parsed.hostname.endsWith(".localhost") || "preview.extension.dev" === parsed.hostname;
|
|
6299
|
+
if (!isLocal) return {
|
|
6300
|
+
ok: false,
|
|
6301
|
+
message: `Refusing to use ${raw} as the preview host: hostUrl may only name a local preview dev server. Leave it unset to use ${DEFAULT_PREVIEW_DEV_URL}, or pass share:true for a link that needs no local server.`
|
|
6302
|
+
};
|
|
6303
|
+
return {
|
|
6304
|
+
ok: true,
|
|
6305
|
+
base: trimmed
|
|
6306
|
+
};
|
|
6307
|
+
}
|
|
5264
6308
|
const SURFACE = {
|
|
5265
6309
|
defaultOrigin: DEFAULT_PREVIEW_DEV_URL,
|
|
5266
6310
|
scheme: (encoded)=>`preview://build/${encoded}`,
|
|
@@ -5268,7 +6312,58 @@ const SURFACE = {
|
|
|
5268
6312
|
devCommand: "pnpm --filter preview.extension.dev dev",
|
|
5269
6313
|
label: "preview.extension.dev"
|
|
5270
6314
|
};
|
|
5271
|
-
|
|
6315
|
+
const PREVIEW_APP_LOCATIONS = [
|
|
6316
|
+
[
|
|
6317
|
+
"apps",
|
|
6318
|
+
"web",
|
|
6319
|
+
"preview.extension.dev"
|
|
6320
|
+
],
|
|
6321
|
+
[
|
|
6322
|
+
"apps",
|
|
6323
|
+
"preview.extension.dev"
|
|
6324
|
+
],
|
|
6325
|
+
[
|
|
6326
|
+
"preview.extension.dev"
|
|
6327
|
+
]
|
|
6328
|
+
];
|
|
6329
|
+
function previewDevCheckout(startPaths) {
|
|
6330
|
+
const seen = new Set();
|
|
6331
|
+
for (const start of startPaths){
|
|
6332
|
+
let dir;
|
|
6333
|
+
try {
|
|
6334
|
+
dir = node_path.resolve(start);
|
|
6335
|
+
} catch {
|
|
6336
|
+
continue;
|
|
6337
|
+
}
|
|
6338
|
+
for(let depth = 0; depth < 8; depth++){
|
|
6339
|
+
if (seen.has(dir)) break;
|
|
6340
|
+
seen.add(dir);
|
|
6341
|
+
for (const location of PREVIEW_APP_LOCATIONS){
|
|
6342
|
+
const manifest = node_path.join(dir, ...location, "package.json");
|
|
6343
|
+
try {
|
|
6344
|
+
const parsed = JSON.parse(node_fs.readFileSync(manifest, "utf8"));
|
|
6345
|
+
if (parsed.name === SURFACE.label) return dir;
|
|
6346
|
+
} catch {}
|
|
6347
|
+
}
|
|
6348
|
+
const parent = node_path.dirname(dir);
|
|
6349
|
+
if (parent === dir) break;
|
|
6350
|
+
dir = parent;
|
|
6351
|
+
}
|
|
6352
|
+
}
|
|
6353
|
+
return null;
|
|
6354
|
+
}
|
|
6355
|
+
function localLaneRemedy(checkout) {
|
|
6356
|
+
const shareOut = "Pass share:true for a link that needs no local server and opens for anyone.";
|
|
6357
|
+
return checkout ? `Start it with '${SURFACE.devCommand}' in ${checkout}. ${shareOut}` : `${SURFACE.label} is a private app of the extension.dev monorepo and no npm install of this server can start it, so the default lane cannot resolve on this machine. ${shareOut}`;
|
|
6358
|
+
}
|
|
6359
|
+
function previewOriginOf(previewUrl) {
|
|
6360
|
+
try {
|
|
6361
|
+
return new URL(previewUrl).origin;
|
|
6362
|
+
} catch {
|
|
6363
|
+
return null;
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
async function buildShare(projectPath, distDir, manifest, browser, verifyInBrowserTerms) {
|
|
5272
6367
|
const result = await uploadPreview({
|
|
5273
6368
|
distDir,
|
|
5274
6369
|
manifest,
|
|
@@ -5287,6 +6382,11 @@ async function buildShare(projectPath, distDir, manifest, browser) {
|
|
|
5287
6382
|
} : {}
|
|
5288
6383
|
};
|
|
5289
6384
|
}
|
|
6385
|
+
const origin = previewOriginOf(result.data.previewUrl);
|
|
6386
|
+
const browserCheck = verifyInBrowserTerms && result.data.zipUrl && origin ? await probeShareCors({
|
|
6387
|
+
zipUrl: result.data.zipUrl,
|
|
6388
|
+
origin
|
|
6389
|
+
}) : null;
|
|
5290
6390
|
const sharedAt = new Date().toISOString();
|
|
5291
6391
|
const record = recordSharedPreview(projectPath, {
|
|
5292
6392
|
sharedAt,
|
|
@@ -5327,8 +6427,14 @@ async function buildShare(projectPath, distDir, manifest, browser) {
|
|
|
5327
6427
|
} : {},
|
|
5328
6428
|
serves: "uploaded-local-build",
|
|
5329
6429
|
localBuildUploaded: true,
|
|
6430
|
+
...browserCheck ? {
|
|
6431
|
+
browserLoadable: browserCheck.ok,
|
|
6432
|
+
browserCheck
|
|
6433
|
+
} : {
|
|
6434
|
+
browserLoadable: null
|
|
6435
|
+
},
|
|
5330
6436
|
record,
|
|
5331
|
-
note: "Anyone with this link can open the build you just made, running in the emulator. No install, no sign-in, no dev server. They can also download the whole build as a zip from zipUrl, so the link hands over the built code. It stays live until expiresAt; DELETE revokeUrl with the same token to kill it sooner, and a revoked link stays dead. revokeUrl is the
|
|
6437
|
+
note: "Anyone with this link can open the build you just made, running in the emulator. No install, no sign-in, no dev server. They can also download the whole build as a zip from zipUrl, so the link hands over the built code. It stays live until expiresAt; DELETE revokeUrl with the same token to kill it sooner, and a revoked link stays dead. revokeUrl is the handle that pulls this link early. Re-sharing an unchanged build returns this same link rather than a second one, and only a revoked link is replaced by a different one, so " + (record.recorded ? `it was also written to ${record.path} (record.path), which lists every share from this project.` : `keep it: ${record.note}`) + " To find this link again later, or to pull it back once it has left this conversation, run extension_shares: it lists every link this token has shared with its live or dead state, and revokes one by artifactId or by pasting any of its URLs." + (record.warning ? ` ${record.warning}` : "")
|
|
5332
6438
|
};
|
|
5333
6439
|
}
|
|
5334
6440
|
function detectSurfaces(manifest) {
|
|
@@ -5351,7 +6457,7 @@ function detectSurfaces(manifest) {
|
|
|
5351
6457
|
}
|
|
5352
6458
|
const preview_web_schema = {
|
|
5353
6459
|
name: "extension_preview_web",
|
|
5354
|
-
description: "Preview an in-progress extension in the web emulator, with no real browser. This builds the project (unless build:false)
|
|
6460
|
+
description: "Preview an in-progress extension in the web emulator, with no real browser. This builds the project (unless build:false) and previews dist/<browser>. Pass share:true unless you are working inside the extension.dev monorepo: it uploads the build and returns a link anyone can open, with no install, sign-in or dev server, and it is the only lane that works from an npm install of this server. Sharing also serves the build as a zip, so it hands over the built code; read the share property before using it. The default lane instead returns a deep link over the dev-only preview://build scheme, which resolves only against a preview.extension.dev dev server on this machine, so it is for people developing extension.dev itself. Call extension_shares to list and revoke every link shared this way, so one never vanishes with this response.",
|
|
5355
6461
|
inputSchema: {
|
|
5356
6462
|
type: "object",
|
|
5357
6463
|
properties: {
|
|
@@ -5378,7 +6484,7 @@ const preview_web_schema = {
|
|
|
5378
6484
|
probe: {
|
|
5379
6485
|
type: "boolean",
|
|
5380
6486
|
default: true,
|
|
5381
|
-
description: "Fetch the surface's dev middleware first to confirm the artifact loads."
|
|
6487
|
+
description: "Fetch the surface's dev middleware first to confirm the artifact loads on the local host. With share:true it also checks the shared link the way a browser would, following the zip's redirects and asserting the final response allows the preview origin, and reports that as share.browserLoadable."
|
|
5382
6488
|
},
|
|
5383
6489
|
open: {
|
|
5384
6490
|
type: "boolean",
|
|
@@ -5393,7 +6499,7 @@ const preview_web_schema = {
|
|
|
5393
6499
|
share: {
|
|
5394
6500
|
type: "boolean",
|
|
5395
6501
|
default: false,
|
|
5396
|
-
description: "Upload the built dist and return a public link (share.previewUrl) that renders those exact bytes for anyone: no install, sign-in or dev server. It also serves the build as a zip (share.zipUrl), so sharing hands over the code. Needs a token scoped to an extension.dev project (extension_auth or EXTENSION_DEV_TOKEN); without one you get a login hint and the local preview still succeeds. Live until share.expiresAt; DELETE share.revokeUrl to kill it sooner. Revocation is permanent and re-sharing
|
|
6502
|
+
description: "Upload the built dist and return a public link (share.previewUrl) that renders those exact bytes for anyone: no install, sign-in or dev server. It also serves the build as a zip (share.zipUrl), so sharing hands over the code. Needs a token scoped to an extension.dev project (extension_auth or EXTENSION_DEV_TOKEN); without one you get a login hint and the local preview still succeeds. Live until share.expiresAt; DELETE share.revokeUrl to kill it sooner. Revocation is permanent, and re-sharing an unchanged build returns the same link unless it was revoked, so each share is also appended to the project's gitignored .extension.dev/shared-previews.json."
|
|
5397
6503
|
}
|
|
5398
6504
|
},
|
|
5399
6505
|
required: [
|
|
@@ -5403,7 +6509,21 @@ const preview_web_schema = {
|
|
|
5403
6509
|
};
|
|
5404
6510
|
async function preview_web_handler(args) {
|
|
5405
6511
|
const browser = args.browser ?? "chrome";
|
|
5406
|
-
const
|
|
6512
|
+
const host = safeHostBase(args.hostUrl ?? SURFACE.defaultOrigin);
|
|
6513
|
+
if (!host.ok) return envelope_envelope({
|
|
6514
|
+
ok: false,
|
|
6515
|
+
command: preview_web_COMMAND,
|
|
6516
|
+
status: "bad-host-url",
|
|
6517
|
+
error: {
|
|
6518
|
+
code: "E_BAD_HOST_URL",
|
|
6519
|
+
message: host.message
|
|
6520
|
+
},
|
|
6521
|
+
value: {
|
|
6522
|
+
stage: "resolve-host",
|
|
6523
|
+
hostUrl: args.hostUrl
|
|
6524
|
+
}
|
|
6525
|
+
});
|
|
6526
|
+
const hostBase = host.base;
|
|
5407
6527
|
const shouldBuild = args.distPath ? false : false !== args.build;
|
|
5408
6528
|
let buildResult = null;
|
|
5409
6529
|
if (shouldBuild) {
|
|
@@ -5419,7 +6539,7 @@ async function preview_web_handler(args) {
|
|
|
5419
6539
|
raw
|
|
5420
6540
|
};
|
|
5421
6541
|
}
|
|
5422
|
-
if (!buildResult || true !== buildResult.ok) return
|
|
6542
|
+
if (!buildResult || true !== buildResult.ok) return envelope_envelope({
|
|
5423
6543
|
ok: false,
|
|
5424
6544
|
command: preview_web_COMMAND,
|
|
5425
6545
|
status: "build-failed",
|
|
@@ -5435,7 +6555,7 @@ async function preview_web_handler(args) {
|
|
|
5435
6555
|
}
|
|
5436
6556
|
const distDir = args.distPath ? node_path.resolve(args.distPath) : node_path.resolve(args.projectPath, "dist", browser);
|
|
5437
6557
|
const manifestPath = node_path.join(distDir, "manifest.json");
|
|
5438
|
-
if (!node_fs.existsSync(manifestPath)) return
|
|
6558
|
+
if (!node_fs.existsSync(manifestPath)) return envelope_envelope({
|
|
5439
6559
|
ok: false,
|
|
5440
6560
|
command: preview_web_COMMAND,
|
|
5441
6561
|
status: "no-dist",
|
|
@@ -5452,7 +6572,7 @@ async function preview_web_handler(args) {
|
|
|
5452
6572
|
try {
|
|
5453
6573
|
manifest = JSON.parse(node_fs.readFileSync(manifestPath, "utf8"));
|
|
5454
6574
|
} catch (err) {
|
|
5455
|
-
return
|
|
6575
|
+
return envelope_envelope({
|
|
5456
6576
|
ok: false,
|
|
5457
6577
|
command: preview_web_COMMAND,
|
|
5458
6578
|
status: "bad-dist-manifest",
|
|
@@ -5484,7 +6604,12 @@ async function preview_web_handler(args) {
|
|
|
5484
6604
|
built: false
|
|
5485
6605
|
}
|
|
5486
6606
|
};
|
|
5487
|
-
const
|
|
6607
|
+
const checkout = previewDevCheckout([
|
|
6608
|
+
args.projectPath,
|
|
6609
|
+
process.cwd()
|
|
6610
|
+
]);
|
|
6611
|
+
const remedy = localLaneRemedy(checkout);
|
|
6612
|
+
const hint = `Open deepLink in a browser to see the extension render in ${SURFACE.label}'s emulator, where the Trace tab shows every chrome.* call it makes and the lane toggle switches between the emulated backend and a real carrier-equipped browser. It needs that surface's dev server running on this machine. ${remedy}`;
|
|
5488
6613
|
const previewWarnings = [];
|
|
5489
6614
|
if (args.open) {
|
|
5490
6615
|
const sessionBrowser = args.openIn ?? browser;
|
|
@@ -5494,250 +6619,122 @@ async function preview_web_handler(args) {
|
|
|
5494
6619
|
opened = JSON.parse(navRaw);
|
|
5495
6620
|
} catch {
|
|
5496
6621
|
opened = {
|
|
5497
|
-
ok: false,
|
|
5498
|
-
raw: navRaw
|
|
5499
|
-
};
|
|
5500
|
-
}
|
|
5501
|
-
result.opened = opened;
|
|
5502
|
-
result.openedIn = sessionBrowser;
|
|
5503
|
-
if (true !== opened.ok) previewWarnings.push("Could not open the preview in a browser. This needs a live dev session (run extension_dev, then extension_wait for ready). The deepLink above still works if you open it yourself.");
|
|
5504
|
-
}
|
|
5505
|
-
if (args.share) result.share = await buildShare(args.projectPath, distDir, manifest, browser);
|
|
5506
|
-
if (false === args.probe) return envelope({
|
|
5507
|
-
ok: true,
|
|
5508
|
-
command: preview_web_COMMAND,
|
|
5509
|
-
status: "previewed",
|
|
5510
|
-
value: result,
|
|
5511
|
-
hint,
|
|
5512
|
-
warnings: previewWarnings
|
|
5513
|
-
});
|
|
5514
|
-
const probeUrl = `${hostBase}${SURFACE.fetchPath}?url=${encodeURIComponent(internalUrl)}`;
|
|
5515
|
-
try {
|
|
5516
|
-
const res = await fetch(probeUrl, {
|
|
5517
|
-
headers: {
|
|
5518
|
-
accept: "application/json"
|
|
5519
|
-
}
|
|
5520
|
-
});
|
|
5521
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
5522
|
-
if (!res.ok || !contentType.includes("application/json")) return envelope({
|
|
5523
|
-
ok: true,
|
|
5524
|
-
command: preview_web_COMMAND,
|
|
5525
|
-
status: "host-not-serving-preview",
|
|
5526
|
-
value: {
|
|
5527
|
-
...result,
|
|
5528
|
-
hostReachable: true,
|
|
5529
|
-
previewLoadable: false,
|
|
5530
|
-
probe: {
|
|
5531
|
-
status: res.status,
|
|
5532
|
-
contentType
|
|
5533
|
-
}
|
|
5534
|
-
},
|
|
5535
|
-
hint,
|
|
5536
|
-
warnings: [
|
|
5537
|
-
...previewWarnings,
|
|
5538
|
-
`${SURFACE.label} answered but not with a preview payload. On the deployed host ${SURFACE.fetchPath} does not exist (dev-only); run a local dev server (${SURFACE.devCommand}) to use web preview.`
|
|
5539
|
-
]
|
|
5540
|
-
});
|
|
5541
|
-
const payload = await res.json();
|
|
5542
|
-
return envelope({
|
|
5543
|
-
ok: true,
|
|
5544
|
-
command: preview_web_COMMAND,
|
|
5545
|
-
status: "previewed",
|
|
5546
|
-
value: {
|
|
5547
|
-
...result,
|
|
5548
|
-
hostReachable: true,
|
|
5549
|
-
previewLoadable: true,
|
|
5550
|
-
probe: {
|
|
5551
|
-
identifier: payload.identifier,
|
|
5552
|
-
loadedName: payload.manifest?.name,
|
|
5553
|
-
loadedVersion: payload.version,
|
|
5554
|
-
fileCount: Array.isArray(payload.files) ? payload.files.length : 0
|
|
5555
|
-
}
|
|
5556
|
-
},
|
|
5557
|
-
hint,
|
|
5558
|
-
warnings: previewWarnings
|
|
5559
|
-
});
|
|
5560
|
-
} catch (err) {
|
|
5561
|
-
return envelope({
|
|
5562
|
-
ok: true,
|
|
5563
|
-
command: preview_web_COMMAND,
|
|
5564
|
-
status: "host-unreachable",
|
|
5565
|
-
value: {
|
|
5566
|
-
...result,
|
|
5567
|
-
hostReachable: false,
|
|
5568
|
-
previewLoadable: false,
|
|
5569
|
-
probe: {
|
|
5570
|
-
error: err instanceof Error ? err.message : String(err)
|
|
5571
|
-
}
|
|
5572
|
-
},
|
|
5573
|
-
hint,
|
|
5574
|
-
warnings: [
|
|
5575
|
-
...previewWarnings,
|
|
5576
|
-
`Could not reach ${SURFACE.label} at ${hostBase}. Start it with '${SURFACE.devCommand}', then open deepLink.`
|
|
5577
|
-
]
|
|
5578
|
-
});
|
|
5579
|
-
}
|
|
5580
|
-
}
|
|
5581
|
-
const ARTIFACT_ID = /^gen_[0-9a-f]{32}$/;
|
|
5582
|
-
const ARTIFACT_ID_ANYWHERE = /gen_[0-9a-f]{32}/;
|
|
5583
|
-
function parseArtifactRef(input) {
|
|
5584
|
-
const raw = String(input ?? "").trim();
|
|
5585
|
-
if (!raw) return null;
|
|
5586
|
-
if (ARTIFACT_ID.test(raw)) return raw;
|
|
5587
|
-
let parsed = null;
|
|
5588
|
-
try {
|
|
5589
|
-
parsed = new URL(raw);
|
|
5590
|
-
} catch {
|
|
5591
|
-
parsed = null;
|
|
5592
|
-
}
|
|
5593
|
-
if (parsed) {
|
|
5594
|
-
const fromQuery = parsed.searchParams.get("preview");
|
|
5595
|
-
if (fromQuery && ARTIFACT_ID.test(fromQuery.trim())) return fromQuery.trim();
|
|
5596
|
-
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
5597
|
-
for(let i = segments.length - 1; i >= 0; i -= 1){
|
|
5598
|
-
const segment = decodeURIComponent(segments[i]);
|
|
5599
|
-
if (ARTIFACT_ID.test(segment)) return segment;
|
|
5600
|
-
}
|
|
5601
|
-
}
|
|
5602
|
-
const loose = ARTIFACT_ID_ANYWHERE.exec(raw);
|
|
5603
|
-
return loose ? loose[0] : null;
|
|
5604
|
-
}
|
|
5605
|
-
function authError(name) {
|
|
5606
|
-
return {
|
|
5607
|
-
ok: false,
|
|
5608
|
-
error: {
|
|
5609
|
-
name,
|
|
5610
|
-
message: "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
|
|
5611
|
-
}
|
|
5612
|
-
};
|
|
5613
|
-
}
|
|
5614
|
-
async function readBody(res) {
|
|
5615
|
-
const text = await res.text();
|
|
5616
|
-
try {
|
|
5617
|
-
return JSON.parse(text);
|
|
5618
|
-
} catch {
|
|
5619
|
-
return {
|
|
5620
|
-
message: text
|
|
5621
|
-
};
|
|
5622
|
-
}
|
|
5623
|
-
}
|
|
5624
|
-
async function listArtifacts(options = {}) {
|
|
5625
|
-
const token = options.token ?? resolveToken();
|
|
5626
|
-
if (!token) return authError("SharesAuthError");
|
|
5627
|
-
const apiCheck = safeApiBase(resolveApiBase(options.api));
|
|
5628
|
-
if (!apiCheck.ok) return {
|
|
5629
|
-
ok: false,
|
|
5630
|
-
error: {
|
|
5631
|
-
name: "SharesConfigError",
|
|
5632
|
-
message: apiCheck.message
|
|
5633
|
-
}
|
|
5634
|
-
};
|
|
5635
|
-
const url = new URL(`${apiCheck.base}/api/artifacts`);
|
|
5636
|
-
if (null != options.limit) url.searchParams.set("limit", String(options.limit));
|
|
5637
|
-
if (options.liveOnly) url.searchParams.set("status", "live");
|
|
5638
|
-
const doFetch = options.fetchImpl ?? fetch;
|
|
5639
|
-
let res;
|
|
5640
|
-
try {
|
|
5641
|
-
res = await doFetch(url.toString(), {
|
|
5642
|
-
headers: {
|
|
5643
|
-
authorization: `Bearer ${token}`,
|
|
5644
|
-
accept: "application/json",
|
|
5645
|
-
...identityHeaders("extension_shares")
|
|
5646
|
-
}
|
|
5647
|
-
});
|
|
5648
|
-
} catch (err) {
|
|
5649
|
-
return {
|
|
5650
|
-
ok: false,
|
|
5651
|
-
error: {
|
|
5652
|
-
name: "SharesNetworkError",
|
|
5653
|
-
message: `Could not reach ${url.toString()}: ${err?.message || err}`
|
|
5654
|
-
}
|
|
5655
|
-
};
|
|
5656
|
-
}
|
|
5657
|
-
const data = await readBody(res);
|
|
5658
|
-
if (401 === res.status) return authError("SharesAuthError");
|
|
5659
|
-
if (!res.ok) return {
|
|
5660
|
-
ok: false,
|
|
5661
|
-
error: {
|
|
5662
|
-
name: "SharesListError",
|
|
5663
|
-
status: res.status,
|
|
5664
|
-
message: `Listing shares failed (${res.status}): ${data?.message || "unknown error"}`
|
|
5665
|
-
}
|
|
5666
|
-
};
|
|
5667
|
-
const artifacts = Array.isArray(data.artifacts) ? data.artifacts : [];
|
|
5668
|
-
const num = (value, fallback)=>"number" == typeof value && Number.isFinite(value) ? value : fallback;
|
|
5669
|
-
return {
|
|
5670
|
-
ok: true,
|
|
5671
|
-
data: {
|
|
5672
|
-
artifacts,
|
|
5673
|
-
count: num(data.count, artifacts.length),
|
|
5674
|
-
matched: num(data.matched, artifacts.length),
|
|
5675
|
-
limit: num(data.limit, artifacts.length),
|
|
5676
|
-
truncated: true === data.truncated,
|
|
5677
|
-
scanned: num(data.scanned, 0)
|
|
5678
|
-
}
|
|
5679
|
-
};
|
|
5680
|
-
}
|
|
5681
|
-
async function revokeArtifact(options) {
|
|
5682
|
-
const token = options.token ?? resolveToken();
|
|
5683
|
-
if (!token) return authError("SharesAuthError");
|
|
5684
|
-
const apiCheck = safeApiBase(resolveApiBase(options.api));
|
|
5685
|
-
if (!apiCheck.ok) return {
|
|
5686
|
-
ok: false,
|
|
5687
|
-
error: {
|
|
5688
|
-
name: "SharesConfigError",
|
|
5689
|
-
message: apiCheck.message
|
|
6622
|
+
ok: false,
|
|
6623
|
+
raw: navRaw
|
|
6624
|
+
};
|
|
5690
6625
|
}
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
6626
|
+
result.opened = opened;
|
|
6627
|
+
result.openedIn = sessionBrowser;
|
|
6628
|
+
if (true !== opened.ok) previewWarnings.push("Could not open the preview in a browser. This needs a live dev session (run extension_dev, then extension_wait for ready). The deepLink above still works if you open it yourself.");
|
|
6629
|
+
}
|
|
6630
|
+
if (args.share) {
|
|
6631
|
+
const share = await buildShare(args.projectPath, distDir, manifest, browser, false !== args.probe);
|
|
6632
|
+
result.share = share;
|
|
6633
|
+
if (true === share.ok && false === share.browserLoadable) {
|
|
6634
|
+
const check = share.browserCheck;
|
|
6635
|
+
previewWarnings.push(`The link uploaded, but it will not render for anyone: ${check?.reason ?? "its zip is not readable from the preview origin."} Do not hand this link out as working until that is fixed.`);
|
|
6636
|
+
}
|
|
6637
|
+
}
|
|
6638
|
+
if (false === args.probe) return envelope_envelope({
|
|
6639
|
+
ok: true,
|
|
6640
|
+
command: preview_web_COMMAND,
|
|
6641
|
+
status: "previewed",
|
|
6642
|
+
value: result,
|
|
6643
|
+
hint,
|
|
6644
|
+
warnings: previewWarnings
|
|
6645
|
+
});
|
|
6646
|
+
const probeUrl = `${hostBase}${SURFACE.fetchPath}?url=${encodeURIComponent(internalUrl)}`;
|
|
5695
6647
|
try {
|
|
5696
|
-
res = await
|
|
5697
|
-
method: "DELETE",
|
|
6648
|
+
const res = await fetch(probeUrl, {
|
|
5698
6649
|
headers: {
|
|
5699
|
-
|
|
5700
|
-
accept: "application/json",
|
|
5701
|
-
...identityHeaders("extension_shares")
|
|
6650
|
+
accept: "application/json"
|
|
5702
6651
|
}
|
|
5703
6652
|
});
|
|
6653
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
6654
|
+
if (!res.ok || !contentType.includes("application/json")) return envelope_envelope({
|
|
6655
|
+
ok: true,
|
|
6656
|
+
command: preview_web_COMMAND,
|
|
6657
|
+
status: "host-not-serving-preview",
|
|
6658
|
+
value: {
|
|
6659
|
+
...result,
|
|
6660
|
+
hostReachable: true,
|
|
6661
|
+
previewLoadable: false,
|
|
6662
|
+
probe: {
|
|
6663
|
+
status: res.status,
|
|
6664
|
+
contentType
|
|
6665
|
+
}
|
|
6666
|
+
},
|
|
6667
|
+
hint,
|
|
6668
|
+
warnings: [
|
|
6669
|
+
...previewWarnings,
|
|
6670
|
+
`${SURFACE.label} answered but not with a preview payload. On the deployed host ${SURFACE.fetchPath} does not exist, because it is dev-only. ${remedy}`
|
|
6671
|
+
]
|
|
6672
|
+
});
|
|
6673
|
+
const payload = await res.json();
|
|
6674
|
+
return envelope_envelope({
|
|
6675
|
+
ok: true,
|
|
6676
|
+
command: preview_web_COMMAND,
|
|
6677
|
+
status: "previewed",
|
|
6678
|
+
value: {
|
|
6679
|
+
...result,
|
|
6680
|
+
hostReachable: true,
|
|
6681
|
+
previewLoadable: true,
|
|
6682
|
+
previewLoadableLane: "local-dev-host",
|
|
6683
|
+
probe: {
|
|
6684
|
+
identifier: payload.identifier,
|
|
6685
|
+
loadedName: payload.manifest?.name,
|
|
6686
|
+
loadedVersion: payload.version,
|
|
6687
|
+
fileCount: Array.isArray(payload.files) ? payload.files.length : 0,
|
|
6688
|
+
method: "server-fetch",
|
|
6689
|
+
provesBrowserLoad: false
|
|
6690
|
+
}
|
|
6691
|
+
},
|
|
6692
|
+
hint,
|
|
6693
|
+
warnings: previewWarnings
|
|
6694
|
+
});
|
|
5704
6695
|
} catch (err) {
|
|
5705
|
-
|
|
6696
|
+
const shared = result.share;
|
|
6697
|
+
if (args.share && shared?.ok === true) return envelope_envelope({
|
|
6698
|
+
ok: true,
|
|
6699
|
+
command: preview_web_COMMAND,
|
|
6700
|
+
status: "shared",
|
|
6701
|
+
value: {
|
|
6702
|
+
...result,
|
|
6703
|
+
hostReachable: false,
|
|
6704
|
+
previewLoadable: false,
|
|
6705
|
+
probe: {
|
|
6706
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6707
|
+
}
|
|
6708
|
+
},
|
|
6709
|
+
hint: "share.previewUrl is live and needs no local server. The deepLink lane is separate: it only resolves against a preview.extension.dev dev server on this machine, and none answered.",
|
|
6710
|
+
warnings: [
|
|
6711
|
+
...previewWarnings,
|
|
6712
|
+
`The share link works; only the local ${SURFACE.label} dev lane at ${hostBase} is unreachable, which is expected outside the extension.dev monorepo.`
|
|
6713
|
+
]
|
|
6714
|
+
});
|
|
6715
|
+
return envelope_envelope({
|
|
5706
6716
|
ok: false,
|
|
6717
|
+
command: preview_web_COMMAND,
|
|
6718
|
+
status: "host-unreachable",
|
|
5707
6719
|
error: {
|
|
5708
|
-
|
|
5709
|
-
message: `
|
|
5710
|
-
}
|
|
5711
|
-
|
|
6720
|
+
code: "E_PREVIEW_HOST_UNREACHABLE",
|
|
6721
|
+
message: `Nothing is serving ${SURFACE.label} at ${hostBase}, so deepLink has nothing to open. ${remedy}`
|
|
6722
|
+
},
|
|
6723
|
+
value: {
|
|
6724
|
+
...result,
|
|
6725
|
+
hostReachable: false,
|
|
6726
|
+
previewLoadable: false,
|
|
6727
|
+
probe: {
|
|
6728
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6729
|
+
}
|
|
6730
|
+
},
|
|
6731
|
+
hint,
|
|
6732
|
+
warnings: [
|
|
6733
|
+
...previewWarnings,
|
|
6734
|
+
`Could not reach ${SURFACE.label} at ${hostBase}. ${remedy}`
|
|
6735
|
+
]
|
|
6736
|
+
});
|
|
5712
6737
|
}
|
|
5713
|
-
const data = await readBody(res);
|
|
5714
|
-
if (401 === res.status) return authError("SharesAuthError");
|
|
5715
|
-
if (404 === res.status) return {
|
|
5716
|
-
ok: false,
|
|
5717
|
-
error: {
|
|
5718
|
-
name: "SharesNotFoundError",
|
|
5719
|
-
status: 404,
|
|
5720
|
-
message: `The platform has no live share ${options.artifactId} for this token. It may already be revoked, already expired, owned by a different project than the one this token is scoped to, or a teammate's personal share, which belongs to that person alone and no project token can revoke.`
|
|
5721
|
-
}
|
|
5722
|
-
};
|
|
5723
|
-
if (!res.ok) return {
|
|
5724
|
-
ok: false,
|
|
5725
|
-
error: {
|
|
5726
|
-
name: "SharesRevokeError",
|
|
5727
|
-
status: res.status,
|
|
5728
|
-
message: `Revoking ${options.artifactId} failed (${res.status}): ${data?.message || "unknown error"}`
|
|
5729
|
-
}
|
|
5730
|
-
};
|
|
5731
|
-
return {
|
|
5732
|
-
ok: true,
|
|
5733
|
-
data: {
|
|
5734
|
-
artifactId: options.artifactId,
|
|
5735
|
-
revoked: true === data.revoked,
|
|
5736
|
-
..."string" == typeof data.revokedAt ? {
|
|
5737
|
-
revokedAt: data.revokedAt
|
|
5738
|
-
} : {}
|
|
5739
|
-
}
|
|
5740
|
-
};
|
|
5741
6738
|
}
|
|
5742
6739
|
const LOGIN_HINT = "Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).";
|
|
5743
6740
|
const shares_schema = {
|
|
@@ -5839,8 +6836,9 @@ function localIndex(entries) {
|
|
|
5839
6836
|
}
|
|
5840
6837
|
return index;
|
|
5841
6838
|
}
|
|
5842
|
-
function localOnlyStatus(entry,
|
|
5843
|
-
if (
|
|
6839
|
+
function localOnlyStatus(entry, completeness, liveFiltered, now) {
|
|
6840
|
+
if ("cut" === completeness) return "unknown: the platform list was cut short, so this share may simply be past the returned window.";
|
|
6841
|
+
if ("unsaid" === completeness) return "unknown: the platform did not say whether that list was whole, so this share may simply be past the returned window.";
|
|
5844
6842
|
const expiresAt = entry.expiresAt ? Date.parse(entry.expiresAt) : NaN;
|
|
5845
6843
|
if (Number.isFinite(expiresAt) && expiresAt <= now) return "expired: its own expiresAt has passed, and the platform no longer lists it.";
|
|
5846
6844
|
if (liveFiltered) return 'not in this live-only listing: the platform was asked for live shares only, so this one may be expired or revoked rather than not owned. Rerun with status:"all" to tell.';
|
|
@@ -5871,7 +6869,7 @@ async function listShares(args) {
|
|
|
5871
6869
|
});
|
|
5872
6870
|
if (!listing.ok) {
|
|
5873
6871
|
const isAuth = "SharesAuthError" === listing.error.name;
|
|
5874
|
-
return
|
|
6872
|
+
return envelope_envelope({
|
|
5875
6873
|
ok: true,
|
|
5876
6874
|
command: "extension_shares",
|
|
5877
6875
|
status: "listed-local-only",
|
|
@@ -5887,7 +6885,10 @@ async function listShares(args) {
|
|
|
5887
6885
|
},
|
|
5888
6886
|
shares: [],
|
|
5889
6887
|
localOnly: (local?.entries ?? []).map((entry)=>({
|
|
5890
|
-
...entry
|
|
6888
|
+
...entry,
|
|
6889
|
+
...entry.revokeUrl ? {
|
|
6890
|
+
revokeUrl: wwwRevokeUrl(entry.revokeUrl)
|
|
6891
|
+
} : {}
|
|
5891
6892
|
})),
|
|
5892
6893
|
localRecord
|
|
5893
6894
|
},
|
|
@@ -5921,9 +6922,13 @@ async function listShares(args) {
|
|
|
5921
6922
|
} : {}
|
|
5922
6923
|
};
|
|
5923
6924
|
});
|
|
6925
|
+
const completeness = listing.data.truncatedReported ? listing.data.truncated ? "cut" : "whole" : "unsaid";
|
|
5924
6926
|
const localOnly = (local?.entries ?? []).filter((entry)=>!seen.has(entry.artifactId)).map((entry)=>({
|
|
5925
6927
|
...entry,
|
|
5926
|
-
|
|
6928
|
+
...entry.revokeUrl ? {
|
|
6929
|
+
revokeUrl: wwwRevokeUrl(entry.revokeUrl)
|
|
6930
|
+
} : {},
|
|
6931
|
+
status: localOnlyStatus(entry, completeness, liveFiltered, now)
|
|
5927
6932
|
}));
|
|
5928
6933
|
const liveCount = shares.filter((share)=>share.live).length;
|
|
5929
6934
|
const ownership = {
|
|
@@ -5931,8 +6936,8 @@ async function listShares(args) {
|
|
|
5931
6936
|
personal: shares.filter((s)=>"personal" === s.attribution.ownership).length,
|
|
5932
6937
|
unknown: shares.filter((s)=>"unknown" === s.attribution.ownership).length
|
|
5933
6938
|
};
|
|
5934
|
-
const truncatedNote =
|
|
5935
|
-
return
|
|
6939
|
+
const truncatedNote = "cut" === completeness ? `This is not the whole set: ${listing.data.count} of ${listing.data.matched} matched shares came back at limit ${listing.data.limit}. truncated also goes true when the server spent its budget working out which shares you are entitled to see, so matched is a floor and not a total. Raise limit (max 200) or pass status:"live" to narrow it, and do not read a missing share as revoked.` : "unsaid" === completeness ? `This listing cannot be called whole: the platform returned no truncated field, so whether ${listing.data.count} shares are all of them is unstated. A share missing from it is unaccounted for rather than dead, and no missing share is reported as revoked or unowned here.` : null;
|
|
6940
|
+
return envelope_envelope({
|
|
5936
6941
|
ok: true,
|
|
5937
6942
|
command: "extension_shares",
|
|
5938
6943
|
status: "listed",
|
|
@@ -5944,6 +6949,7 @@ async function listShares(args) {
|
|
|
5944
6949
|
matched: listing.data.matched,
|
|
5945
6950
|
limit: listing.data.limit,
|
|
5946
6951
|
truncated: listing.data.truncated,
|
|
6952
|
+
truncatedReported: listing.data.truncatedReported,
|
|
5947
6953
|
scanned: listing.data.scanned,
|
|
5948
6954
|
ownership,
|
|
5949
6955
|
...truncatedNote ? {
|
|
@@ -5958,25 +6964,29 @@ async function listShares(args) {
|
|
|
5958
6964
|
},
|
|
5959
6965
|
hint: `${liveCount} of ${shares.length} listed shares still resolve${local ? `; ${localOnly.length} local record ${1 === localOnly.length ? "entry is" : "entries are"} ${liveFiltered ? 'not in this live-only listing (possibly dead rather than not owned; rerun with status:"all" to tell)' : "without an artifact behind them"}` : ""}. Revoke one with action:"revoke" and its artifactId or any of its URLs.`,
|
|
5960
6966
|
warnings: [
|
|
5961
|
-
"previewUrl and zipUrl are null for a share that is no longer live, because a revoked or expired link cannot resolve for anyone. revokeUrl stays on every row. Revocation is permanent: a revoked id is burned and re-sharing
|
|
6967
|
+
"previewUrl and zipUrl are null for a share that is no longer live, because a revoked or expired link cannot resolve for anyone. revokeUrl stays on every row. Revocation is permanent: a revoked id is burned, and re-sharing that build mints a different link. Re-sharing a build that was NOT revoked returns its existing link instead.",
|
|
5962
6968
|
"attribution.ownership says who the share belongs to and therefore who may revoke it: project means the owning workspace holds it and any member can pull it back, personal means one person holds it alone. attribution.credit names the publisher and is attribution only, granting and restricting nothing. A credit of \"CLI token ...\" means the platform could not resolve which human minted that token, and a credit of \"not recorded\" means it never knew; neither is a name, and neither should be reported as one.",
|
|
5963
6969
|
truncatedNote
|
|
5964
6970
|
]
|
|
5965
6971
|
});
|
|
5966
6972
|
}
|
|
5967
6973
|
async function revokeShare(args) {
|
|
5968
|
-
const
|
|
5969
|
-
|
|
6974
|
+
const supplied = String(args.artifactId || args.url || "").trim();
|
|
6975
|
+
const ref = parseArtifactRef(supplied);
|
|
6976
|
+
if (!ref) return envelope_envelope({
|
|
5970
6977
|
ok: false,
|
|
5971
6978
|
command: "extension_shares",
|
|
5972
6979
|
status: "bad-request",
|
|
5973
6980
|
value: {
|
|
5974
|
-
action: "revoke"
|
|
6981
|
+
action: "revoke",
|
|
6982
|
+
...supplied ? {
|
|
6983
|
+
ref: supplied
|
|
6984
|
+
} : {}
|
|
5975
6985
|
},
|
|
5976
6986
|
error: {
|
|
5977
6987
|
code: "E_BAD_REQUEST",
|
|
5978
6988
|
name: "SharesInputError",
|
|
5979
|
-
message: "Nothing to revoke. Pass artifactId (a gen_... id) or url (the previewUrl, zipUrl, viewUrl, or revokeUrl of the share). Run action
|
|
6989
|
+
message: supplied ? `The reference is malformed and nothing was sent to the platform: no share id could be read out of ${JSON.stringify(supplied.length > 200 ? `${supplied.slice(0, 200)}...` : supplied)}. A share id is gen_ followed by exactly 64 lowercase hex characters (32 on shares minted before 2026-07-30), and a url revokes only when it contains one whole. Check the ref for a copy-paste cut, or run action:"list" and copy the exact artifactId.` : 'Nothing to revoke. Pass artifactId (a gen_... id) or url (the previewUrl, zipUrl, viewUrl, or revokeUrl of the share). Run action:"list" to see both.'
|
|
5980
6990
|
}
|
|
5981
6991
|
});
|
|
5982
6992
|
const result = await revokeArtifact({
|
|
@@ -5990,7 +7000,7 @@ async function revokeShare(args) {
|
|
|
5990
7000
|
const recordNote = local ? entry ? `${local.path} still lists this share as its own append-only history and was not rewritten, so the entry stays with its original sharedAt. The platform is the truth for whether a link resolves.` : `${local.path} has no entry for this share, so it was made from another machine or another checkout.` : void 0;
|
|
5991
7001
|
if (!result.ok) {
|
|
5992
7002
|
const isAuth = "SharesAuthError" === result.error.name;
|
|
5993
|
-
return
|
|
7003
|
+
return envelope_envelope({
|
|
5994
7004
|
ok: false,
|
|
5995
7005
|
command: "extension_shares",
|
|
5996
7006
|
status: "revoke-failed",
|
|
@@ -6011,7 +7021,7 @@ async function revokeShare(args) {
|
|
|
6011
7021
|
]
|
|
6012
7022
|
});
|
|
6013
7023
|
}
|
|
6014
|
-
if (true !== result.data.revoked) return
|
|
7024
|
+
if (true !== result.data.revoked) return envelope_envelope({
|
|
6015
7025
|
ok: true,
|
|
6016
7026
|
command: "extension_shares",
|
|
6017
7027
|
status: "revoke-unconfirmed",
|
|
@@ -6028,7 +7038,7 @@ async function revokeShare(args) {
|
|
|
6028
7038
|
recordNote
|
|
6029
7039
|
]
|
|
6030
7040
|
});
|
|
6031
|
-
return
|
|
7041
|
+
return envelope_envelope({
|
|
6032
7042
|
ok: true,
|
|
6033
7043
|
command: "extension_shares",
|
|
6034
7044
|
status: "revoked",
|
|
@@ -6374,7 +7384,7 @@ async function manifest_validate_handler(args) {
|
|
|
6374
7384
|
const errors = [
|
|
6375
7385
|
args.projectPath ? `No manifest.json found under ${args.projectPath} (looked in the root and src/).` : "Pass manifestPath (path to manifest.json) or projectPath (project root)."
|
|
6376
7386
|
];
|
|
6377
|
-
return
|
|
7387
|
+
return envelope_envelope({
|
|
6378
7388
|
ok: false,
|
|
6379
7389
|
command: manifest_validate_COMMAND,
|
|
6380
7390
|
status: "manifest-not-found",
|
|
@@ -6400,7 +7410,7 @@ async function manifest_validate_handler(args) {
|
|
|
6400
7410
|
const errors = [
|
|
6401
7411
|
`Cannot read manifest: ${err instanceof Error ? err.message : err}`
|
|
6402
7412
|
];
|
|
6403
|
-
return
|
|
7413
|
+
return envelope_envelope({
|
|
6404
7414
|
ok: false,
|
|
6405
7415
|
command: manifest_validate_COMMAND,
|
|
6406
7416
|
status: "manifest-unreadable",
|
|
@@ -6535,7 +7545,7 @@ async function manifest_validate_handler(args) {
|
|
|
6535
7545
|
else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
|
|
6536
7546
|
}
|
|
6537
7547
|
result.valid = 0 === result.errors.length;
|
|
6538
|
-
return
|
|
7548
|
+
return envelope_envelope({
|
|
6539
7549
|
ok: result.valid,
|
|
6540
7550
|
command: manifest_validate_COMMAND,
|
|
6541
7551
|
status: result.valid ? "valid" : "invalid",
|
|
@@ -7217,7 +8227,7 @@ async function theme_verify_handler(args) {
|
|
|
7217
8227
|
try {
|
|
7218
8228
|
text = await promises.readFile(abs, "utf8");
|
|
7219
8229
|
} catch {
|
|
7220
|
-
return
|
|
8230
|
+
return envelope_envelope({
|
|
7221
8231
|
ok: false,
|
|
7222
8232
|
command: theme_verify_COMMAND,
|
|
7223
8233
|
status: "bad-input",
|
|
@@ -7231,7 +8241,7 @@ async function theme_verify_handler(args) {
|
|
|
7231
8241
|
try {
|
|
7232
8242
|
raw = JSON.parse(text);
|
|
7233
8243
|
} catch (err) {
|
|
7234
|
-
return
|
|
8244
|
+
return envelope_envelope({
|
|
7235
8245
|
ok: false,
|
|
7236
8246
|
command: theme_verify_COMMAND,
|
|
7237
8247
|
status: "bad-input",
|
|
@@ -7243,7 +8253,7 @@ async function theme_verify_handler(args) {
|
|
|
7243
8253
|
});
|
|
7244
8254
|
}
|
|
7245
8255
|
} else {
|
|
7246
|
-
if (!args.manifest) return
|
|
8256
|
+
if (!args.manifest) return envelope_envelope({
|
|
7247
8257
|
ok: false,
|
|
7248
8258
|
command: theme_verify_COMMAND,
|
|
7249
8259
|
status: "bad-request",
|
|
@@ -7260,7 +8270,7 @@ async function theme_verify_handler(args) {
|
|
|
7260
8270
|
try {
|
|
7261
8271
|
({ manifest, theme } = coerceInput(raw));
|
|
7262
8272
|
} catch (err) {
|
|
7263
|
-
return
|
|
8273
|
+
return envelope_envelope({
|
|
7264
8274
|
ok: false,
|
|
7265
8275
|
command: theme_verify_COMMAND,
|
|
7266
8276
|
status: "bad-input",
|
|
@@ -7364,7 +8374,7 @@ async function theme_verify_handler(args) {
|
|
|
7364
8374
|
how: "a Chrome theme is an unpacked extension: extension_dev then extension_logs on the exported theme dir (headless, focus-safe)"
|
|
7365
8375
|
}
|
|
7366
8376
|
];
|
|
7367
|
-
return
|
|
8377
|
+
return envelope_envelope({
|
|
7368
8378
|
ok: true,
|
|
7369
8379
|
command: theme_verify_COMMAND,
|
|
7370
8380
|
status: verdict,
|
|
@@ -7526,7 +8536,7 @@ function formatBytes(bytes) {
|
|
|
7526
8536
|
async function analyze_handler(args) {
|
|
7527
8537
|
const browser = args.browser ?? "chrome";
|
|
7528
8538
|
const distPath = node_path.resolve(args.projectPath, "dist", browser);
|
|
7529
|
-
if (!node_fs.existsSync(distPath)) return
|
|
8539
|
+
if (!node_fs.existsSync(distPath)) return envelope_envelope({
|
|
7530
8540
|
ok: false,
|
|
7531
8541
|
command: analyze_COMMAND,
|
|
7532
8542
|
status: "no-dist",
|
|
@@ -7636,7 +8646,7 @@ async function analyze_handler(args) {
|
|
|
7636
8646
|
under10MB: totalSize - archiveSize < 10485760
|
|
7637
8647
|
}
|
|
7638
8648
|
};
|
|
7639
|
-
return
|
|
8649
|
+
return envelope_envelope({
|
|
7640
8650
|
ok: true,
|
|
7641
8651
|
command: analyze_COMMAND,
|
|
7642
8652
|
status: "analyzed",
|
|
@@ -8124,7 +9134,7 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
|
|
|
8124
9134
|
}
|
|
8125
9135
|
const frame = Array.isArray(parsed?.value?.frames) ? parsed.value.frames[0] : null;
|
|
8126
9136
|
if (parsed?.ok === true && frame && "object" == typeof frame) value = frame;
|
|
8127
|
-
else if (parsed?.ok === true) return
|
|
9137
|
+
else if (parsed?.ok === true) return envelope_envelope({
|
|
8128
9138
|
ok: false,
|
|
8129
9139
|
command: TOOL,
|
|
8130
9140
|
status: "inspect-failed",
|
|
@@ -8167,7 +9177,7 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
|
|
|
8167
9177
|
const cap = maxBytes > 0 ? maxBytes : 65536;
|
|
8168
9178
|
await collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes);
|
|
8169
9179
|
}
|
|
8170
|
-
return
|
|
9180
|
+
return envelope_envelope({
|
|
8171
9181
|
ok: true,
|
|
8172
9182
|
command: TOOL,
|
|
8173
9183
|
status: "inspected",
|
|
@@ -8188,7 +9198,7 @@ async function inspect_handler(args) {
|
|
|
8188
9198
|
const maxBytes = args.maxBytes ?? 262144;
|
|
8189
9199
|
if (!isChromiumFamily(browser)) return inspectViaBridge(args, browser, include, maxBytes);
|
|
8190
9200
|
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
8191
|
-
if (!resolved) return
|
|
9201
|
+
if (!resolved) return envelope_envelope({
|
|
8192
9202
|
ok: false,
|
|
8193
9203
|
command: inspect_schema_schema.name,
|
|
8194
9204
|
status: "no-session",
|
|
@@ -8212,7 +9222,7 @@ async function inspect_handler(args) {
|
|
|
8212
9222
|
const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("devtools://") && (!t.url.startsWith("chrome://") || isOverridePage(t.url)));
|
|
8213
9223
|
if (0 === pageTargets.length) {
|
|
8214
9224
|
const chromeOnly = allTargets.some((t)=>"page" === t.type && t.url.startsWith("chrome://"));
|
|
8215
|
-
return
|
|
9225
|
+
return envelope_envelope({
|
|
8216
9226
|
ok: false,
|
|
8217
9227
|
command: inspect_schema_schema.name,
|
|
8218
9228
|
status: "no-inspectable-target",
|
|
@@ -8231,7 +9241,17 @@ async function inspect_handler(args) {
|
|
|
8231
9241
|
});
|
|
8232
9242
|
}
|
|
8233
9243
|
const isExtensionSurface = (u)=>u.startsWith("chrome-extension://") || u.startsWith("moz-extension://");
|
|
8234
|
-
const
|
|
9244
|
+
const defaultRank = (u)=>{
|
|
9245
|
+
if (isEngineCompanionUrl(u)) return 3;
|
|
9246
|
+
if (isOverridePage(u)) return 2;
|
|
9247
|
+
if (isExtensionSurface(u)) return 0;
|
|
9248
|
+
return 1;
|
|
9249
|
+
};
|
|
9250
|
+
const target = args.url ? pageTargets.find((t)=>t.url.includes(args.url)) ?? pageTargets.find((t)=>!isExtensionSurface(t.url)) ?? pageTargets[0] : pageTargets.map((t, index)=>({
|
|
9251
|
+
t,
|
|
9252
|
+
index,
|
|
9253
|
+
rank: defaultRank(t.url)
|
|
9254
|
+
})).sort((a, b)=>a.rank - b.rank || a.index - b.index)[0].t;
|
|
8235
9255
|
const browserWsUrl = await CDPClient.discoverBrowserWsUrl(cdpPort);
|
|
8236
9256
|
await cdp.connect(browserWsUrl);
|
|
8237
9257
|
const sessionId = await cdp.attachToTarget(target.id);
|
|
@@ -8240,13 +9260,27 @@ async function inspect_handler(args) {
|
|
|
8240
9260
|
await cdp.navigate(sessionId, args.url);
|
|
8241
9261
|
await new Promise((r)=>setTimeout(r, 1500));
|
|
8242
9262
|
} else await new Promise((r)=>setTimeout(r, 500));
|
|
9263
|
+
let documentUrl = "";
|
|
9264
|
+
let toolchainWarning = null;
|
|
9265
|
+
if (!args.url && defaultRank(target.url) >= 2) {
|
|
9266
|
+
try {
|
|
9267
|
+
const href = await cdp.evaluate(sessionId, "location.href");
|
|
9268
|
+
documentUrl = "string" == typeof href ? href : "";
|
|
9269
|
+
} catch {
|
|
9270
|
+
documentUrl = "";
|
|
9271
|
+
}
|
|
9272
|
+
toolchainWarning = isEngineCompanionUrl(documentUrl) || isEngineCompanionUrl(target.url) ? `Inspected ${target.url}, which is rendered by the Extension.js toolchain's own companion extension, not by this project. Pass url, or open one of the extension's surfaces first (extension_open), then inspect again.` : `No url was given and only override pages were open, so this inspected ${target.url}. Unless this extension provides that override itself, this is the toolchain's welcome surface, not your extension: pass url or open a surface with extension_open.`;
|
|
9273
|
+
}
|
|
8243
9274
|
const result = {
|
|
8244
9275
|
cdpPort,
|
|
8245
9276
|
browser,
|
|
8246
9277
|
target: {
|
|
8247
9278
|
id: target.id,
|
|
8248
9279
|
url: target.url,
|
|
8249
|
-
title: target.title
|
|
9280
|
+
title: target.title,
|
|
9281
|
+
...documentUrl && documentUrl !== target.url ? {
|
|
9282
|
+
documentUrl
|
|
9283
|
+
} : {}
|
|
8250
9284
|
},
|
|
8251
9285
|
targets: pageTargets.map((t)=>({
|
|
8252
9286
|
id: t.id,
|
|
@@ -8294,17 +9328,18 @@ async function inspect_handler(args) {
|
|
|
8294
9328
|
}
|
|
8295
9329
|
const probeWarning = result.probeWarning;
|
|
8296
9330
|
delete result.probeWarning;
|
|
8297
|
-
return
|
|
9331
|
+
return envelope_envelope({
|
|
8298
9332
|
ok: true,
|
|
8299
9333
|
command: inspect_schema_schema.name,
|
|
8300
9334
|
status: "inspected",
|
|
8301
9335
|
value: result,
|
|
8302
9336
|
warnings: [
|
|
8303
|
-
"string" == typeof probeWarning ? probeWarning : null
|
|
9337
|
+
"string" == typeof probeWarning ? probeWarning : null,
|
|
9338
|
+
toolchainWarning
|
|
8304
9339
|
]
|
|
8305
9340
|
});
|
|
8306
9341
|
} catch (err) {
|
|
8307
|
-
return
|
|
9342
|
+
return envelope_envelope({
|
|
8308
9343
|
ok: false,
|
|
8309
9344
|
command: inspect_schema_schema.name,
|
|
8310
9345
|
status: "cdp-failed",
|
|
@@ -8347,8 +9382,7 @@ function list_extensions_unpackedExtensionId(distPath) {
|
|
|
8347
9382
|
function readOwnIdentity(projectPath, browser) {
|
|
8348
9383
|
let contract;
|
|
8349
9384
|
try {
|
|
8350
|
-
|
|
8351
|
-
contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
9385
|
+
contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
|
|
8352
9386
|
} catch {
|
|
8353
9387
|
return null;
|
|
8354
9388
|
}
|
|
@@ -8379,7 +9413,7 @@ const UNRESOLVED_NOTE = "Identity unresolved: the browser's Extensions CDP domai
|
|
|
8379
9413
|
async function list_extensions_handler(args) {
|
|
8380
9414
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
|
|
8381
9415
|
if (isGeckoFamily(browser)) return listGeckoExtensions(args.projectPath, browser);
|
|
8382
|
-
if (!isChromiumFamily(browser)) return
|
|
9416
|
+
if (!isChromiumFamily(browser)) return envelope_envelope({
|
|
8383
9417
|
ok: false,
|
|
8384
9418
|
command: list_extensions_schema.name,
|
|
8385
9419
|
status: "unsupported-browser",
|
|
@@ -8390,7 +9424,7 @@ async function list_extensions_handler(args) {
|
|
|
8390
9424
|
hint: "Target a Chromium-family (CDP) or Firefox-family (RDP) dev session."
|
|
8391
9425
|
});
|
|
8392
9426
|
const resolved = await resolveCdpPort(args.projectPath, browser);
|
|
8393
|
-
if (!resolved) return
|
|
9427
|
+
if (!resolved) return envelope_envelope({
|
|
8394
9428
|
ok: false,
|
|
8395
9429
|
command: list_extensions_schema.name,
|
|
8396
9430
|
status: "no-session",
|
|
@@ -8470,7 +9504,7 @@ async function list_extensions_handler(args) {
|
|
|
8470
9504
|
return (a.name ?? a.id).localeCompare(b.name ?? b.id);
|
|
8471
9505
|
});
|
|
8472
9506
|
const ownEntry = extensions.find((e)=>e.ownExtension);
|
|
8473
|
-
return
|
|
9507
|
+
return envelope_envelope({
|
|
8474
9508
|
ok: true,
|
|
8475
9509
|
command: list_extensions_schema.name,
|
|
8476
9510
|
status: "listed",
|
|
@@ -8486,7 +9520,7 @@ async function list_extensions_handler(args) {
|
|
|
8486
9520
|
]
|
|
8487
9521
|
});
|
|
8488
9522
|
} catch (error) {
|
|
8489
|
-
return
|
|
9523
|
+
return envelope_envelope({
|
|
8490
9524
|
ok: false,
|
|
8491
9525
|
command: list_extensions_schema.name,
|
|
8492
9526
|
status: "cdp-failed",
|
|
@@ -8501,7 +9535,7 @@ async function list_extensions_handler(args) {
|
|
|
8501
9535
|
}
|
|
8502
9536
|
async function listGeckoExtensions(projectPath, browser) {
|
|
8503
9537
|
const resolved = await resolveRdpPort(projectPath, browser);
|
|
8504
|
-
if (!resolved) return
|
|
9538
|
+
if (!resolved) return envelope_envelope({
|
|
8505
9539
|
ok: false,
|
|
8506
9540
|
command: list_extensions_schema.name,
|
|
8507
9541
|
status: "no-session",
|
|
@@ -8555,7 +9589,7 @@ async function listGeckoExtensions(projectPath, browser) {
|
|
|
8555
9589
|
return (a.name ?? a.id).localeCompare(b.name ?? b.id);
|
|
8556
9590
|
});
|
|
8557
9591
|
const ownEntry = extensions.find((e)=>e.ownExtension);
|
|
8558
|
-
return
|
|
9592
|
+
return envelope_envelope({
|
|
8559
9593
|
ok: true,
|
|
8560
9594
|
command: list_extensions_schema.name,
|
|
8561
9595
|
status: "listed",
|
|
@@ -8571,7 +9605,7 @@ async function listGeckoExtensions(projectPath, browser) {
|
|
|
8571
9605
|
]
|
|
8572
9606
|
});
|
|
8573
9607
|
} catch (error) {
|
|
8574
|
-
return
|
|
9608
|
+
return envelope_envelope({
|
|
8575
9609
|
ok: false,
|
|
8576
9610
|
command: list_extensions_schema.name,
|
|
8577
9611
|
status: "rdp-failed",
|
|
@@ -8582,57 +9616,23 @@ async function listGeckoExtensions(projectPath, browser) {
|
|
|
8582
9616
|
});
|
|
8583
9617
|
}
|
|
8584
9618
|
}
|
|
8585
|
-
const
|
|
8586
|
-
const
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
"debug",
|
|
8591
|
-
"trace"
|
|
8592
|
-
];
|
|
8593
|
-
function levelRank(level) {
|
|
8594
|
-
const l = "log" === level ? "info" : level;
|
|
8595
|
-
const i = LEVEL_ORDER.indexOf(l);
|
|
8596
|
-
return -1 === i ? LEVEL_ORDER.length : i;
|
|
8597
|
-
}
|
|
8598
|
-
function makeUrlMatcher(pattern) {
|
|
8599
|
-
const hasGlob = pattern.includes("*");
|
|
8600
|
-
let re = null;
|
|
8601
|
-
if (hasGlob) {
|
|
8602
|
-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
8603
|
-
re = new RegExp(escaped);
|
|
8604
|
-
}
|
|
8605
|
-
return (event)=>{
|
|
8606
|
-
const candidates = [
|
|
8607
|
-
event.url,
|
|
8608
|
-
event.hostname
|
|
8609
|
-
].filter((v)=>"string" == typeof v);
|
|
8610
|
-
if (0 === candidates.length) return false;
|
|
8611
|
-
return candidates.some((c)=>re ? re.test(c) : c.includes(pattern));
|
|
8612
|
-
};
|
|
8613
|
-
}
|
|
9619
|
+
const CLOSE_REFUSAL_FLOOR = 4000;
|
|
9620
|
+
const DEFAULT_LIMIT = 200;
|
|
9621
|
+
const DEFAULT_FOLLOW_MS = 4000;
|
|
9622
|
+
const MIN_FOLLOW_MS = 500;
|
|
9623
|
+
const MAX_FOLLOW_MS = 15000;
|
|
8614
9624
|
function makeFilter(args) {
|
|
8615
|
-
const
|
|
8616
|
-
const
|
|
8617
|
-
const
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
if ("off" === minLevel) {
|
|
8627
|
-
if ("dx.signal" !== event.eventType) return false;
|
|
8628
|
-
} else if ("all" !== minLevel) {
|
|
8629
|
-
if (levelRank(event.level) > levelRank(minLevel)) return false;
|
|
8630
|
-
}
|
|
8631
|
-
if (null != sinceSeq && Number.isFinite(sinceSeq) && "number" == typeof event.seq && event.seq <= sinceSeq) return false;
|
|
8632
|
-
if (urlMatches && !urlMatches(event)) return false;
|
|
8633
|
-
if (null != tabId && Number.isFinite(tabId) && event.tabId !== tabId) return false;
|
|
8634
|
-
return true;
|
|
8635
|
-
};
|
|
9625
|
+
const level = String(args.level || "all").toLowerCase();
|
|
9626
|
+
const loggingOff = "off" === level;
|
|
9627
|
+
const query = {
|
|
9628
|
+
context: args.context,
|
|
9629
|
+
level: loggingOff ? "all" : level,
|
|
9630
|
+
signalsOnly: Boolean(args.signalsOnly) || loggingOff,
|
|
9631
|
+
since: args.since,
|
|
9632
|
+
url: args.url,
|
|
9633
|
+
tab: args.tab
|
|
9634
|
+
};
|
|
9635
|
+
return (event)=>matchesLogQuery(event, query);
|
|
8636
9636
|
}
|
|
8637
9637
|
const logs_schema_schema = {
|
|
8638
9638
|
name: "extension_logs",
|
|
@@ -8702,12 +9702,12 @@ const logs_schema_schema = {
|
|
|
8702
9702
|
},
|
|
8703
9703
|
followMs: {
|
|
8704
9704
|
type: "number",
|
|
8705
|
-
default:
|
|
8706
|
-
description:
|
|
9705
|
+
default: DEFAULT_FOLLOW_MS,
|
|
9706
|
+
description: `How long to collect live frames when follow=true (clamped ${MIN_FOLLOW_MS}–${MAX_FOLLOW_MS}ms).`
|
|
8707
9707
|
},
|
|
8708
9708
|
limit: {
|
|
8709
9709
|
type: "number",
|
|
8710
|
-
default:
|
|
9710
|
+
default: DEFAULT_LIMIT,
|
|
8711
9711
|
description: "How many of the most recent events to return."
|
|
8712
9712
|
}
|
|
8713
9713
|
},
|
|
@@ -8717,23 +9717,6 @@ const logs_schema_schema = {
|
|
|
8717
9717
|
}
|
|
8718
9718
|
};
|
|
8719
9719
|
const logs_TOOL = "extension_logs";
|
|
8720
|
-
function logsFilePath(projectPath, browser) {
|
|
8721
|
-
return node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
|
|
8722
|
-
}
|
|
8723
|
-
function readReadyContract(projectPath, browser) {
|
|
8724
|
-
const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
|
|
8725
|
-
try {
|
|
8726
|
-
const c = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
|
|
8727
|
-
if ("number" != typeof c.controlPort || !c.instanceId) return null;
|
|
8728
|
-
return {
|
|
8729
|
-
controlPort: c.controlPort,
|
|
8730
|
-
instanceId: String(c.instanceId),
|
|
8731
|
-
runId: String(c.runId || "")
|
|
8732
|
-
};
|
|
8733
|
-
} catch {
|
|
8734
|
-
return null;
|
|
8735
|
-
}
|
|
8736
|
-
}
|
|
8737
9720
|
function capRecent(events, limit) {
|
|
8738
9721
|
if (events.length <= limit) return {
|
|
8739
9722
|
events,
|
|
@@ -8747,9 +9730,9 @@ function capRecent(events, limit) {
|
|
|
8747
9730
|
function emptyReason(projectPath, browser) {
|
|
8748
9731
|
let contract;
|
|
8749
9732
|
try {
|
|
8750
|
-
contract = JSON.parse(node_fs.readFileSync(
|
|
9733
|
+
contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
|
|
8751
9734
|
} catch {
|
|
8752
|
-
return
|
|
9735
|
+
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. ${sessionPathHint(bridge_readyContractPath(projectPath, browser))}`;
|
|
8753
9736
|
}
|
|
8754
9737
|
if ("error" === contract.status) {
|
|
8755
9738
|
const errs = contract.errors;
|
|
@@ -8767,7 +9750,7 @@ function summarize(events, source, browser, runId, limit, dropped, projectPath,
|
|
|
8767
9750
|
const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
|
|
8768
9751
|
const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
|
|
8769
9752
|
const stale = Boolean(staleNote) && matched > 0;
|
|
8770
|
-
return
|
|
9753
|
+
return envelope_envelope({
|
|
8771
9754
|
ok: true,
|
|
8772
9755
|
command: logs_TOOL,
|
|
8773
9756
|
status: 0 === matched ? "empty" : stale ? "stale" : "read",
|
|
@@ -8792,7 +9775,7 @@ function summarize(events, source, browser, runId, limit, dropped, projectPath,
|
|
|
8792
9775
|
function staleFileNote(projectPath, browser, eventsRunId) {
|
|
8793
9776
|
let contract;
|
|
8794
9777
|
try {
|
|
8795
|
-
contract = JSON.parse(node_fs.readFileSync(
|
|
9778
|
+
contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
|
|
8796
9779
|
} catch {
|
|
8797
9780
|
return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
|
|
8798
9781
|
}
|
|
@@ -8808,8 +9791,8 @@ function staleFileNote(projectPath, browser, eventsRunId) {
|
|
|
8808
9791
|
if (eventsRunId && liveIds.length > 0 && !liveIds.includes(eventsRunId)) return `These events carry runId ${eventsRunId} but the current session is run ${liveIds.join(" / ")}, which has written nothing yet. Do not read these as the current run's output.`;
|
|
8809
9792
|
}
|
|
8810
9793
|
async function readFromFile(args, browser, limit) {
|
|
8811
|
-
const file =
|
|
8812
|
-
if (!node_fs.existsSync(file)) return
|
|
9794
|
+
const file = logsPath(args.projectPath, browser);
|
|
9795
|
+
if (!node_fs.existsSync(file)) return envelope_envelope({
|
|
8813
9796
|
ok: false,
|
|
8814
9797
|
command: logs_TOOL,
|
|
8815
9798
|
status: "no-log-file",
|
|
@@ -8817,7 +9800,7 @@ async function readFromFile(args, browser, limit) {
|
|
|
8817
9800
|
code: "E_LOGS_MISSING",
|
|
8818
9801
|
message: `No logs found at ${file}.`
|
|
8819
9802
|
},
|
|
8820
|
-
hint:
|
|
9803
|
+
hint: `Start a dev session first (extension_dev), or pass browser to match it. For live frames before any line is written, use follow:true. ${sessionPathHint(file)}`
|
|
8821
9804
|
});
|
|
8822
9805
|
const matches = makeFilter(args);
|
|
8823
9806
|
const lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
@@ -8838,12 +9821,47 @@ async function readFromFile(args, browser, limit) {
|
|
|
8838
9821
|
}
|
|
8839
9822
|
return summarize(events, "file", browser, runId, limit, 0, args.projectPath, staleFileNote(args.projectPath, browser, runId));
|
|
8840
9823
|
}
|
|
9824
|
+
function controlRefusal(closeCode, reason, url, sentVersion) {
|
|
9825
|
+
if (!Number.isFinite(closeCode) || closeCode < CLOSE_REFUSAL_FLOOR) return;
|
|
9826
|
+
const said = reason.trim();
|
|
9827
|
+
const preamble = `The dev server refused the control channel at ${url}: close code ${closeCode}${said ? ` ("${said}")` : ""}.`;
|
|
9828
|
+
if (closeCode === CLOSE_BAD_HELLO) return {
|
|
9829
|
+
code: "E_CONTROL_ENVELOPE",
|
|
9830
|
+
status: "control-channel-refused",
|
|
9831
|
+
message: `${preamble} That code is the broker rejecting the hello frame itself. This MCP dialed as a consumer with control envelope version ${sentVersion}, the version its pinned Extension.js speaks, and the role is one the broker always accepts, so the envelope version is the part that differs: the engine installed in this project is older or newer and speaks a different one. The session may be logging normally even though this read returned nothing.`,
|
|
9832
|
+
hint: "Update the project's Extension.js so its control envelope matches this MCP's pinned engine, or read the file instead by calling extension_logs without follow. extension_doctor reports both versions."
|
|
9833
|
+
};
|
|
9834
|
+
if (closeCode === CLOSE_BAD_INSTANCE) return {
|
|
9835
|
+
code: "E_STALE_CONTRACT",
|
|
9836
|
+
status: "control-channel-stale",
|
|
9837
|
+
message: `${preamble} That code means the instanceId this MCP sent belongs to a PREVIOUS dev session: the ready.json it read is stale, so the dev server was replaced after that file was written. Nothing here is a version problem, and the engine is not at fault; the session that is running now has an instance this read never named.`,
|
|
9838
|
+
hint: "Wait for the current session to publish its contract with extension_wait and retry, or start one with extension_dev if none is running. extension_stop clears a contract left behind by a session that has gone away."
|
|
9839
|
+
};
|
|
9840
|
+
if (closeCode === CLOSE_CONTROL_UNAVAILABLE) return {
|
|
9841
|
+
code: "E_NO_CONTROL_CHANNEL",
|
|
9842
|
+
status: "control-channel-unavailable",
|
|
9843
|
+
message: `${preamble} That code means the broker has no control channel to hand out: the session was started without allowControl, so it turns controlling clients away. Following logs is a read, not a control operation, so a session that refuses this connection cannot be streamed at all until it is relaunched.`,
|
|
9844
|
+
hint: "Relaunch the session with extension_dev and allowControl: true, or read the file instead by calling extension_logs without follow."
|
|
9845
|
+
};
|
|
9846
|
+
if (closeCode === CLOSE_SLOW_CONSUMER) return {
|
|
9847
|
+
code: "E_CONTROL_CHANNEL",
|
|
9848
|
+
status: "control-channel-dropped",
|
|
9849
|
+
message: `${preamble} That code means this reader fell far enough behind that the broker dropped it to protect itself. The session, the engine version and the control envelope are all fine: the log volume simply outran this follow window, and whatever came back stops at the moment of the drop rather than at the end of the window.`,
|
|
9850
|
+
hint: "Narrow the query before following again (level, context, url or tab), lower followMs, or read the completed file by calling extension_logs without follow."
|
|
9851
|
+
};
|
|
9852
|
+
return {
|
|
9853
|
+
code: "E_CONTROL_CHANNEL",
|
|
9854
|
+
status: "control-channel-refused",
|
|
9855
|
+
message: `${preamble} That code is an application-level refusal this MCP does not recognise, so the broker's own reason above is the whole diagnosis. It dialed as a consumer with control envelope version ${sentVersion}, the version its pinned Extension.js speaks; an engine newer than the pin can refuse for reasons this release has never seen.`,
|
|
9856
|
+
hint: "Read the file instead by calling extension_logs without follow, and compare the engine versions with extension_doctor."
|
|
9857
|
+
};
|
|
9858
|
+
}
|
|
8841
9859
|
async function readFromStream(args, browser, limit) {
|
|
8842
9860
|
const ready = readReadyContract(args.projectPath, browser);
|
|
8843
9861
|
if (!ready) {
|
|
8844
9862
|
const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
|
|
8845
9863
|
const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
|
|
8846
|
-
return
|
|
9864
|
+
return envelope_envelope({
|
|
8847
9865
|
ok: false,
|
|
8848
9866
|
command: logs_TOOL,
|
|
8849
9867
|
status: "no-control-channel",
|
|
@@ -8854,7 +9872,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
8854
9872
|
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.`
|
|
8855
9873
|
});
|
|
8856
9874
|
}
|
|
8857
|
-
const followMs = Math.min(Math.max(args.followMs ??
|
|
9875
|
+
const followMs = Math.min(Math.max(args.followMs ?? DEFAULT_FOLLOW_MS, MIN_FOLLOW_MS), MAX_FOLLOW_MS);
|
|
8858
9876
|
const matches = makeFilter(args);
|
|
8859
9877
|
const events = [];
|
|
8860
9878
|
let dropped = 0;
|
|
@@ -8866,7 +9884,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
8866
9884
|
try {
|
|
8867
9885
|
socket = new ws_0(url);
|
|
8868
9886
|
} catch (err) {
|
|
8869
|
-
resolve(
|
|
9887
|
+
resolve(envelope_envelope({
|
|
8870
9888
|
ok: false,
|
|
8871
9889
|
command: logs_TOOL,
|
|
8872
9890
|
status: "control-channel-failed",
|
|
@@ -8892,7 +9910,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
8892
9910
|
try {
|
|
8893
9911
|
socket.send(JSON.stringify({
|
|
8894
9912
|
type: "hello",
|
|
8895
|
-
v:
|
|
9913
|
+
v: CONTROL_ENVELOPE_VERSION,
|
|
8896
9914
|
role: "consumer",
|
|
8897
9915
|
instanceId: ready.instanceId
|
|
8898
9916
|
}));
|
|
@@ -8922,7 +9940,7 @@ async function readFromStream(args, browser, limit) {
|
|
|
8922
9940
|
try {
|
|
8923
9941
|
socket.close();
|
|
8924
9942
|
} catch {}
|
|
8925
|
-
resolve(
|
|
9943
|
+
resolve(envelope_envelope({
|
|
8926
9944
|
ok: false,
|
|
8927
9945
|
command: logs_TOOL,
|
|
8928
9946
|
status: "control-channel-failed",
|
|
@@ -8933,12 +9951,33 @@ async function readFromStream(args, browser, limit) {
|
|
|
8933
9951
|
hint: "The dev session may have stopped or the control port changed. Re-check with extension_wait."
|
|
8934
9952
|
}));
|
|
8935
9953
|
});
|
|
8936
|
-
socket.on("close",
|
|
9954
|
+
socket.on("close", (code, reason)=>{
|
|
9955
|
+
if (settled) return;
|
|
9956
|
+
const refusal = controlRefusal(code, reason?.toString() ?? "", url, CONTROL_ENVELOPE_VERSION);
|
|
9957
|
+
if (!refusal) return void finish();
|
|
9958
|
+
if (events.length > 0 || dropped > 0) {
|
|
9959
|
+
streamNote = refusal.message;
|
|
9960
|
+
finish();
|
|
9961
|
+
return;
|
|
9962
|
+
}
|
|
9963
|
+
settled = true;
|
|
9964
|
+
clearTimeout(timer);
|
|
9965
|
+
resolve(envelope_envelope({
|
|
9966
|
+
ok: false,
|
|
9967
|
+
command: logs_TOOL,
|
|
9968
|
+
status: refusal.status,
|
|
9969
|
+
error: {
|
|
9970
|
+
code: refusal.code,
|
|
9971
|
+
message: refusal.message
|
|
9972
|
+
},
|
|
9973
|
+
hint: refusal.hint
|
|
9974
|
+
}));
|
|
9975
|
+
});
|
|
8937
9976
|
});
|
|
8938
9977
|
}
|
|
8939
9978
|
async function logs_handler(args) {
|
|
8940
9979
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
8941
|
-
const limit = args.limit && args.limit > 0 ? args.limit :
|
|
9980
|
+
const limit = args.limit && args.limit > 0 ? args.limit : DEFAULT_LIMIT;
|
|
8942
9981
|
if (args.follow) return readFromStream(args, browser, limit);
|
|
8943
9982
|
return readFromFile(args, browser, limit);
|
|
8944
9983
|
}
|
|
@@ -9106,7 +10145,7 @@ async function storage_handler(args) {
|
|
|
9106
10145
|
if (args.area) cli.push("--area", args.area);
|
|
9107
10146
|
if (args.key) cli.push("--key", args.key);
|
|
9108
10147
|
if ("set" === args.action) {
|
|
9109
|
-
if (void 0 === args.value) return
|
|
10148
|
+
if (void 0 === args.value) return envelope_envelope({
|
|
9110
10149
|
ok: false,
|
|
9111
10150
|
command: storage_schema.name,
|
|
9112
10151
|
status: "bad-request",
|
|
@@ -9116,7 +10155,7 @@ async function storage_handler(args) {
|
|
|
9116
10155
|
message: "storage set requires a value"
|
|
9117
10156
|
}
|
|
9118
10157
|
});
|
|
9119
|
-
if (void 0 === args.key) return
|
|
10158
|
+
if (void 0 === args.key) return envelope_envelope({
|
|
9120
10159
|
ok: false,
|
|
9121
10160
|
command: storage_schema.name,
|
|
9122
10161
|
status: "bad-request",
|
|
@@ -9271,7 +10310,7 @@ const dom_snapshot_schema = {
|
|
|
9271
10310
|
};
|
|
9272
10311
|
async function cdpPortOrError(projectPath, browser, feature) {
|
|
9273
10312
|
if (!isChromiumFamily(browser)) return {
|
|
9274
|
-
error:
|
|
10313
|
+
error: envelope_envelope({
|
|
9275
10314
|
ok: false,
|
|
9276
10315
|
command: dom_snapshot_schema.name,
|
|
9277
10316
|
status: "unsupported-browser",
|
|
@@ -9284,7 +10323,7 @@ async function cdpPortOrError(projectPath, browser, feature) {
|
|
|
9284
10323
|
};
|
|
9285
10324
|
const resolved = await resolveCdpPort(projectPath, browser);
|
|
9286
10325
|
if (!resolved) return {
|
|
9287
|
-
error:
|
|
10326
|
+
error: envelope_envelope({
|
|
9288
10327
|
ok: false,
|
|
9289
10328
|
command: dom_snapshot_schema.name,
|
|
9290
10329
|
status: "no-session",
|
|
@@ -9305,7 +10344,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9305
10344
|
const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
|
|
9306
10345
|
if (isGeckoFamily(browser)) {
|
|
9307
10346
|
const resolved = await resolveRdpPort(args.projectPath, browser);
|
|
9308
|
-
if (!resolved) return
|
|
10347
|
+
if (!resolved) return envelope_envelope({
|
|
9309
10348
|
ok: false,
|
|
9310
10349
|
command: dom_snapshot_schema.name,
|
|
9311
10350
|
status: "no-session",
|
|
@@ -9317,7 +10356,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9317
10356
|
});
|
|
9318
10357
|
try {
|
|
9319
10358
|
const tabs = await rdpListTabs(resolved.port);
|
|
9320
|
-
return
|
|
10359
|
+
return envelope_envelope({
|
|
9321
10360
|
ok: true,
|
|
9322
10361
|
command: dom_snapshot_schema.name,
|
|
9323
10362
|
status: "listed-targets",
|
|
@@ -9339,7 +10378,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9339
10378
|
]
|
|
9340
10379
|
});
|
|
9341
10380
|
} catch (e) {
|
|
9342
|
-
return
|
|
10381
|
+
return envelope_envelope({
|
|
9343
10382
|
ok: false,
|
|
9344
10383
|
command: dom_snapshot_schema.name,
|
|
9345
10384
|
status: "rdp-failed",
|
|
@@ -9356,7 +10395,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9356
10395
|
if ("error" in cdp) return cdp.error;
|
|
9357
10396
|
try {
|
|
9358
10397
|
const targets = await listPageTargets(cdp.port);
|
|
9359
|
-
return
|
|
10398
|
+
return envelope_envelope({
|
|
9360
10399
|
ok: true,
|
|
9361
10400
|
command: dom_snapshot_schema.name,
|
|
9362
10401
|
status: "listed-targets",
|
|
@@ -9369,7 +10408,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9369
10408
|
]
|
|
9370
10409
|
});
|
|
9371
10410
|
} catch (e) {
|
|
9372
|
-
return
|
|
10411
|
+
return envelope_envelope({
|
|
9373
10412
|
ok: false,
|
|
9374
10413
|
command: dom_snapshot_schema.name,
|
|
9375
10414
|
status: "cdp-failed",
|
|
@@ -9397,7 +10436,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9397
10436
|
let targetTab = args.tab;
|
|
9398
10437
|
let resolvedTarget = null;
|
|
9399
10438
|
if (args.tabUrl) {
|
|
9400
|
-
if (null != args.tab || args.url) return
|
|
10439
|
+
if (null != args.tab || args.url) return envelope_envelope({
|
|
9401
10440
|
ok: false,
|
|
9402
10441
|
command: dom_snapshot_schema.name,
|
|
9403
10442
|
status: "bad-request",
|
|
@@ -9415,7 +10454,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9415
10454
|
try {
|
|
9416
10455
|
targets = await listPageTargets(cdp.port);
|
|
9417
10456
|
} catch (e) {
|
|
9418
|
-
return
|
|
10457
|
+
return envelope_envelope({
|
|
9419
10458
|
ok: false,
|
|
9420
10459
|
command: dom_snapshot_schema.name,
|
|
9421
10460
|
status: "cdp-failed",
|
|
@@ -9428,7 +10467,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9428
10467
|
});
|
|
9429
10468
|
}
|
|
9430
10469
|
const matches = matchTargetsByUrl(targets, args.tabUrl);
|
|
9431
|
-
if (0 === matches.length) return
|
|
10470
|
+
if (0 === matches.length) return envelope_envelope({
|
|
9432
10471
|
ok: false,
|
|
9433
10472
|
command: dom_snapshot_schema.name,
|
|
9434
10473
|
status: "no-matching-target",
|
|
@@ -9442,7 +10481,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9442
10481
|
},
|
|
9443
10482
|
hint: `Pick one from availableTargets and retry with a \`tabUrl\` substring of its url, or open the page first (extension_open with \`url\`). ${TARGET_ID_NOTE}`
|
|
9444
10483
|
});
|
|
9445
|
-
if (matches.length > 1) return
|
|
10484
|
+
if (matches.length > 1) return envelope_envelope({
|
|
9446
10485
|
ok: false,
|
|
9447
10486
|
command: dom_snapshot_schema.name,
|
|
9448
10487
|
status: "ambiguous-target",
|
|
@@ -9464,7 +10503,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9464
10503
|
const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
|
|
9465
10504
|
if ("error" in listed) return listed.error;
|
|
9466
10505
|
const matches = matchTabsByUrl(listed.tabs, args.tabUrl);
|
|
9467
|
-
if (0 === matches.length) return
|
|
10506
|
+
if (0 === matches.length) return envelope_envelope({
|
|
9468
10507
|
ok: false,
|
|
9469
10508
|
command: dom_snapshot_schema.name,
|
|
9470
10509
|
status: "no-matching-target",
|
|
@@ -9478,7 +10517,7 @@ async function dom_snapshot_handler(args) {
|
|
|
9478
10517
|
},
|
|
9479
10518
|
hint: "Pick one from availableTabs and retry with a `tabUrl` substring of its url, or open the page first (extension_open with `url`)."
|
|
9480
10519
|
});
|
|
9481
|
-
if (matches.length > 1) return
|
|
10520
|
+
if (matches.length > 1) return envelope_envelope({
|
|
9482
10521
|
ok: false,
|
|
9483
10522
|
command: dom_snapshot_schema.name,
|
|
9484
10523
|
status: "ambiguous-target",
|
|
@@ -9546,7 +10585,7 @@ const publish_schema = {
|
|
|
9546
10585
|
}
|
|
9547
10586
|
};
|
|
9548
10587
|
function fail(name, message, status, code) {
|
|
9549
|
-
return
|
|
10588
|
+
return envelope_envelope({
|
|
9550
10589
|
ok: false,
|
|
9551
10590
|
command: "extension_publish",
|
|
9552
10591
|
status,
|
|
@@ -9573,16 +10612,22 @@ async function publish_handler(args) {
|
|
|
9573
10612
|
api: args.api,
|
|
9574
10613
|
token
|
|
9575
10614
|
});
|
|
9576
|
-
if (!result.ok)
|
|
9577
|
-
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
|
|
10615
|
+
if (!result.ok) {
|
|
10616
|
+
const projectMissing = /\(404\)/.test(result.error.message);
|
|
10617
|
+
return envelope_envelope({
|
|
10618
|
+
ok: false,
|
|
10619
|
+
command: "extension_publish",
|
|
10620
|
+
status: "publish-failed",
|
|
10621
|
+
error: {
|
|
10622
|
+
code: "E_PLATFORM",
|
|
10623
|
+
name: result.error.name,
|
|
10624
|
+
message: result.error.message
|
|
10625
|
+
},
|
|
10626
|
+
...projectMissing ? {
|
|
10627
|
+
hint: "The token's project does not exist on the host this call targeted. Run extension_auth (action: status) to see which workspace/project the token is scoped to, create that project first (import a repo or a template at extension.dev/new), or run extension_auth (action: login) against a project that exists there."
|
|
10628
|
+
} : {}
|
|
10629
|
+
});
|
|
10630
|
+
}
|
|
9586
10631
|
const data = result.data;
|
|
9587
10632
|
let note = null;
|
|
9588
10633
|
if (null != args.ttlHours && "public" === data.visibility) note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
|
|
@@ -9627,7 +10672,7 @@ async function publish_handler(args) {
|
|
|
9627
10672
|
}
|
|
9628
10673
|
}
|
|
9629
10674
|
}
|
|
9630
|
-
return
|
|
10675
|
+
return envelope_envelope({
|
|
9631
10676
|
ok: true,
|
|
9632
10677
|
command: "extension_publish",
|
|
9633
10678
|
status: "published",
|
|
@@ -9680,7 +10725,7 @@ const release_promote_schema = {
|
|
|
9680
10725
|
}
|
|
9681
10726
|
};
|
|
9682
10727
|
function release_promote_fail(name, message, status, code) {
|
|
9683
|
-
return
|
|
10728
|
+
return envelope_envelope({
|
|
9684
10729
|
ok: false,
|
|
9685
10730
|
command: "extension_release_promote",
|
|
9686
10731
|
status,
|
|
@@ -9697,7 +10742,7 @@ async function release_promote_handler(args) {
|
|
|
9697
10742
|
const buildId = String(args.buildId || "").trim();
|
|
9698
10743
|
const channel = String(args.channel || "").trim();
|
|
9699
10744
|
if (!buildId || !channel) return release_promote_fail("ReleaseInputError", "buildId and channel are required.", "bad-request", "E_BAD_REQUEST");
|
|
9700
|
-
const apiCheck = safeApiBase(resolveApiBase(args.api));
|
|
10745
|
+
const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
|
|
9701
10746
|
if (!apiCheck.ok) return release_promote_fail("ReleaseConfigError", apiCheck.message, "bad-config", "E_CONFIG");
|
|
9702
10747
|
const url = `${apiCheck.base}/api/cli/release/promote`;
|
|
9703
10748
|
const body = {
|
|
@@ -9754,7 +10799,7 @@ async function release_promote_handler(args) {
|
|
|
9754
10799
|
}
|
|
9755
10800
|
}
|
|
9756
10801
|
}
|
|
9757
|
-
return
|
|
10802
|
+
return envelope_envelope({
|
|
9758
10803
|
ok: false,
|
|
9759
10804
|
command: "extension_release_promote",
|
|
9760
10805
|
status: "promote-failed",
|
|
@@ -9782,7 +10827,7 @@ async function release_promote_handler(args) {
|
|
|
9782
10827
|
publicBuildUrl
|
|
9783
10828
|
} : {}
|
|
9784
10829
|
} : data;
|
|
9785
|
-
return
|
|
10830
|
+
return envelope_envelope({
|
|
9786
10831
|
ok: true,
|
|
9787
10832
|
command: "extension_release_promote",
|
|
9788
10833
|
status: "promoted",
|
|
@@ -9790,7 +10835,7 @@ async function release_promote_handler(args) {
|
|
|
9790
10835
|
});
|
|
9791
10836
|
}
|
|
9792
10837
|
function release_list_fail(name, message, status, code, extra) {
|
|
9793
|
-
return
|
|
10838
|
+
return envelope_envelope({
|
|
9794
10839
|
ok: false,
|
|
9795
10840
|
command: "extension_release_status",
|
|
9796
10841
|
status,
|
|
@@ -9847,7 +10892,7 @@ async function readReleases(args) {
|
|
|
9847
10892
|
const publicUrlNote = publicProjectUrl ? isPrivate ? "publicUrl links open only for workspace members. This project is private, so an outside recipient needs a share link from extension_publish." : "publicUrl links are the public build pages: no login needed, and they carry the per-browser downloads and the run locally instructions." : null;
|
|
9848
10893
|
const channelsUnavailable = channelsRes.ok ? null : `channels.json unreadable: ${channelsRes.message}`;
|
|
9849
10894
|
const buildsUnavailable = buildsRes.ok ? null : `builds/index.json unreadable: ${buildsRes.message}`;
|
|
9850
|
-
return
|
|
10895
|
+
return envelope_envelope({
|
|
9851
10896
|
ok: true,
|
|
9852
10897
|
command: "extension_release_status",
|
|
9853
10898
|
status: "read",
|
|
@@ -9961,7 +11006,7 @@ function normalizeStoresStatus(json) {
|
|
|
9961
11006
|
return out;
|
|
9962
11007
|
}
|
|
9963
11008
|
function store_status_fail(name, message, status, code, extra) {
|
|
9964
|
-
return
|
|
11009
|
+
return envelope_envelope({
|
|
9965
11010
|
ok: false,
|
|
9966
11011
|
command: "extension_release_status",
|
|
9967
11012
|
status,
|
|
@@ -10053,7 +11098,7 @@ async function readStores(args) {
|
|
|
10053
11098
|
const healthUnavailable = healthRes.ok ? null : `stores/health.json unreadable: ${healthRes.message}`;
|
|
10054
11099
|
const statusUnavailable = statusRes.ok ? null : `stores/status.json unreadable: ${statusRes.message}`;
|
|
10055
11100
|
const submissionsUnavailable = submissionsRes.ok || 404 === submissionsRes.status ? null : `stores/submissions.json unreadable: ${submissionsRes.message}`;
|
|
10056
|
-
return
|
|
11101
|
+
return envelope_envelope({
|
|
10057
11102
|
ok: true,
|
|
10058
11103
|
command: "extension_release_status",
|
|
10059
11104
|
status: "read",
|
|
@@ -10151,7 +11196,7 @@ async function release_status_handler(args) {
|
|
|
10151
11196
|
stores
|
|
10152
11197
|
].filter(Boolean);
|
|
10153
11198
|
const ok = sections.some((section)=>true === section.ok);
|
|
10154
|
-
return
|
|
11199
|
+
return envelope_envelope({
|
|
10155
11200
|
ok,
|
|
10156
11201
|
command: "extension_release_status",
|
|
10157
11202
|
status: ok ? "read" : "unavailable",
|
|
@@ -10240,7 +11285,7 @@ const submit_schema = {
|
|
|
10240
11285
|
}
|
|
10241
11286
|
};
|
|
10242
11287
|
function submit_fail(name, message, status, code) {
|
|
10243
|
-
return
|
|
11288
|
+
return envelope_envelope({
|
|
10244
11289
|
ok: false,
|
|
10245
11290
|
command: "extension_submit",
|
|
10246
11291
|
status,
|
|
@@ -10258,7 +11303,7 @@ async function submit_handler(args) {
|
|
|
10258
11303
|
if (0 === browsers.length) return submit_fail("SubmitInputError", 'browsers is required (e.g. ["chrome","firefox","edge","safari"]).', "bad-request", "E_BAD_REQUEST");
|
|
10259
11304
|
const buildSha = String(args.buildSha || "").trim();
|
|
10260
11305
|
if (!buildSha) return submit_fail("SubmitInputError", "buildSha is required (the built commit to submit).", "bad-request", "E_BAD_REQUEST");
|
|
10261
|
-
const apiCheck = safeApiBase(resolveApiBase(args.api));
|
|
11306
|
+
const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
|
|
10262
11307
|
if (!apiCheck.ok) return submit_fail("SubmitConfigError", apiCheck.message, "bad-config", "E_CONFIG");
|
|
10263
11308
|
const url = `${apiCheck.base}/api/cli/stores/submit`;
|
|
10264
11309
|
const dryRun = false !== args.dryRun;
|
|
@@ -10390,7 +11435,7 @@ async function submit_handler(args) {
|
|
|
10390
11435
|
message = summaryParts.join(" ");
|
|
10391
11436
|
}
|
|
10392
11437
|
if (!dryRun) statusNote = "Track this submission with extension_release_status: it reads the recorded outcome, per-store credential health, and review state from the public registry.";
|
|
10393
|
-
return
|
|
11438
|
+
return envelope_envelope({
|
|
10394
11439
|
ok,
|
|
10395
11440
|
command: "extension_submit",
|
|
10396
11441
|
status: dryRun ? "preflight" : "submitted",
|
|
@@ -10403,9 +11448,9 @@ async function submit_handler(args) {
|
|
|
10403
11448
|
]
|
|
10404
11449
|
});
|
|
10405
11450
|
}
|
|
10406
|
-
function
|
|
11451
|
+
function readContractForDiagnosis(projectPath, browser) {
|
|
10407
11452
|
try {
|
|
10408
|
-
const raw = node_fs.readFileSync(
|
|
11453
|
+
const raw = node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8");
|
|
10409
11454
|
return JSON.parse(raw);
|
|
10410
11455
|
} catch {
|
|
10411
11456
|
return null;
|
|
@@ -10457,7 +11502,7 @@ async function environmentPreflight() {
|
|
|
10457
11502
|
detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (extension_templates will fetch it)"
|
|
10458
11503
|
});
|
|
10459
11504
|
const healthy = checks.every((c)=>"fail" !== c.status);
|
|
10460
|
-
return
|
|
11505
|
+
return envelope_envelope({
|
|
10461
11506
|
ok: healthy,
|
|
10462
11507
|
command: doctor_schema.name,
|
|
10463
11508
|
status: healthy ? "healthy" : "unhealthy",
|
|
@@ -10477,22 +11522,11 @@ function safeStringify(value) {
|
|
|
10477
11522
|
}
|
|
10478
11523
|
}
|
|
10479
11524
|
function recentErrorLogs(projectPath, browser, max = 5) {
|
|
10480
|
-
const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
|
|
10481
|
-
let lines;
|
|
10482
|
-
try {
|
|
10483
|
-
lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
10484
|
-
} catch {
|
|
10485
|
-
return [];
|
|
10486
|
-
}
|
|
10487
11525
|
const errs = [];
|
|
10488
|
-
for (const
|
|
10489
|
-
|
|
10490
|
-
|
|
10491
|
-
|
|
10492
|
-
} catch {
|
|
10493
|
-
continue;
|
|
10494
|
-
}
|
|
10495
|
-
if (!ev || "error" !== ev.level) continue;
|
|
11526
|
+
for (const event of readLogEvents(projectPath, browser, {
|
|
11527
|
+
level: "error"
|
|
11528
|
+
})){
|
|
11529
|
+
const ev = event;
|
|
10496
11530
|
const parts = Array.isArray(ev.messageParts) ? ev.messageParts : Array.isArray(ev.args) ? ev.args : null;
|
|
10497
11531
|
let msg = parts ? parts.map((p)=>"string" == typeof p ? p : safeStringify(p)).join(" ") : ev.message || ev.text || "";
|
|
10498
11532
|
if (!msg && ev.errorName) msg = ev.stack ? `${ev.errorName}: ${ev.stack}` : ev.errorName;
|
|
@@ -10528,6 +11562,17 @@ async function doctor_handler(args) {
|
|
|
10528
11562
|
], {
|
|
10529
11563
|
cwd: projectPath
|
|
10530
11564
|
});
|
|
11565
|
+
if (refusedTheOutputFlag(stderr ?? "")) return envelope_envelope({
|
|
11566
|
+
ok: false,
|
|
11567
|
+
command: doctor_schema.name,
|
|
11568
|
+
status: "engine-too-old",
|
|
11569
|
+
error: {
|
|
11570
|
+
code: "E_ENGINE_TOO_OLD",
|
|
11571
|
+
name: "CliError",
|
|
11572
|
+
message: await outputFlagRefusalMessage("doctor", "doctor", projectPath)
|
|
11573
|
+
},
|
|
11574
|
+
hint: "Until then, extension_logs without follow still reads this project's log file, and the session's ready.json still records how the last build ended."
|
|
11575
|
+
});
|
|
10531
11576
|
const out = stdout.trim();
|
|
10532
11577
|
try {
|
|
10533
11578
|
const parsed = JSON.parse(out);
|
|
@@ -10538,7 +11583,7 @@ async function doctor_handler(args) {
|
|
|
10538
11583
|
if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
|
|
10539
11584
|
}
|
|
10540
11585
|
let healthy = 0 === code;
|
|
10541
|
-
const contract =
|
|
11586
|
+
const contract = readContractForDiagnosis(projectPath, browser);
|
|
10542
11587
|
if (contract?.status === "error") {
|
|
10543
11588
|
healthy = false;
|
|
10544
11589
|
const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
|
|
@@ -10574,7 +11619,7 @@ async function doctor_handler(args) {
|
|
|
10574
11619
|
} : {}
|
|
10575
11620
|
});
|
|
10576
11621
|
}
|
|
10577
|
-
return
|
|
11622
|
+
return envelope_envelope({
|
|
10578
11623
|
ok: healthy,
|
|
10579
11624
|
command: doctor_schema.name,
|
|
10580
11625
|
status: healthy ? "healthy" : "unhealthy",
|
|
@@ -10588,7 +11633,8 @@ async function doctor_handler(args) {
|
|
|
10588
11633
|
});
|
|
10589
11634
|
} catch {
|
|
10590
11635
|
const message = stderr.trim() || `extension exited with code ${code}`;
|
|
10591
|
-
|
|
11636
|
+
const cliReport = toMcpSpeak(out).trim().slice(0, 4000);
|
|
11637
|
+
return envelope_envelope({
|
|
10592
11638
|
ok: false,
|
|
10593
11639
|
command: doctor_schema.name,
|
|
10594
11640
|
status: "cli-failed",
|
|
@@ -10597,7 +11643,13 @@ async function doctor_handler(args) {
|
|
|
10597
11643
|
name: "CliError",
|
|
10598
11644
|
message: toMcpSpeak(message)
|
|
10599
11645
|
},
|
|
10600
|
-
|
|
11646
|
+
value: {
|
|
11647
|
+
browser,
|
|
11648
|
+
...cliReport ? {
|
|
11649
|
+
cliReport
|
|
11650
|
+
} : {}
|
|
11651
|
+
},
|
|
11652
|
+
hint: cliReport ? "cliReport is the doctor's own output, unparsed: read the failing checks and remediations there." : "extension doctor requires a recent extension CLI, the project's local install may predate it."
|
|
10601
11653
|
});
|
|
10602
11654
|
}
|
|
10603
11655
|
}
|
|
@@ -10641,7 +11693,7 @@ async function wait_handler(args) {
|
|
|
10641
11693
|
const requested = args.timeoutMs ?? args.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
10642
11694
|
const budgetMs = Math.min(Math.max(requested, MIN_TIMEOUT_MS), SAFE_CEILING_MS);
|
|
10643
11695
|
const clamped = requested > SAFE_CEILING_MS;
|
|
10644
|
-
const readyPath =
|
|
11696
|
+
const readyPath = bridge_readyContractPath(args.projectPath, browser);
|
|
10645
11697
|
const buildOnly = findSessionInfo(args.projectPath, browser)?.noBrowser === true;
|
|
10646
11698
|
const since = sessionSinceMs(args.projectPath, browser);
|
|
10647
11699
|
const start = Date.now();
|
|
@@ -10663,7 +11715,7 @@ async function wait_handler(args) {
|
|
|
10663
11715
|
}
|
|
10664
11716
|
lastContractStatus = contract.status;
|
|
10665
11717
|
if ("ready" === contract.status) {
|
|
10666
|
-
if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return
|
|
11718
|
+
if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return envelope_envelope({
|
|
10667
11719
|
ok: false,
|
|
10668
11720
|
command: wait_schema.name,
|
|
10669
11721
|
status: "stale",
|
|
@@ -10679,7 +11731,7 @@ async function wait_handler(args) {
|
|
|
10679
11731
|
}
|
|
10680
11732
|
});
|
|
10681
11733
|
const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
|
|
10682
|
-
if (!attached && buildOnly) return
|
|
11734
|
+
if (!attached && buildOnly) return envelope_envelope({
|
|
10683
11735
|
ok: true,
|
|
10684
11736
|
command: wait_schema.name,
|
|
10685
11737
|
status: "ready",
|
|
@@ -10710,7 +11762,7 @@ async function wait_handler(args) {
|
|
|
10710
11762
|
const warnings = [];
|
|
10711
11763
|
if (runtimeErrors.length) warnings.push(`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.`);
|
|
10712
11764
|
if (guestCheck.checked && !guestCheck.loaded) warnings.push("The engine reports the runtime attached, but the browser's own target list shows no chrome-extension:// target for your extension, only the engine companion. This is the signature of a silently rejected --load-extension (extension.js BUGS_TO_FIX §83): the CLI and ready.json cannot see it, and the control verbs will fail against a guest that is not there. Check the manifest and extension_logs.");
|
|
10713
|
-
return
|
|
11765
|
+
return envelope_envelope({
|
|
10714
11766
|
ok: true,
|
|
10715
11767
|
command: wait_schema.name,
|
|
10716
11768
|
status: "ready",
|
|
@@ -10740,7 +11792,7 @@ async function wait_handler(args) {
|
|
|
10740
11792
|
warnings
|
|
10741
11793
|
});
|
|
10742
11794
|
}
|
|
10743
|
-
if ("error" === contract.status) return
|
|
11795
|
+
if ("error" === contract.status) return envelope_envelope({
|
|
10744
11796
|
ok: false,
|
|
10745
11797
|
command: wait_schema.name,
|
|
10746
11798
|
status: "contract-error",
|
|
@@ -10759,7 +11811,7 @@ async function wait_handler(args) {
|
|
|
10759
11811
|
} catch {}
|
|
10760
11812
|
await new Promise((resolve)=>setTimeout(resolve, pollInterval));
|
|
10761
11813
|
}
|
|
10762
|
-
if (sawCompiledButUnattached) return
|
|
11814
|
+
if (sawCompiledButUnattached) return envelope_envelope({
|
|
10763
11815
|
ok: false,
|
|
10764
11816
|
command: wait_schema.name,
|
|
10765
11817
|
status: "compiled-not-attached",
|
|
@@ -10776,7 +11828,7 @@ async function wait_handler(args) {
|
|
|
10776
11828
|
},
|
|
10777
11829
|
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."
|
|
10778
11830
|
});
|
|
10779
|
-
return
|
|
11831
|
+
return envelope_envelope({
|
|
10780
11832
|
ok: false,
|
|
10781
11833
|
command: wait_schema.name,
|
|
10782
11834
|
status: "timeout",
|
|
@@ -10954,7 +12006,7 @@ async function add_feature_handler(args) {
|
|
|
10954
12006
|
const projectPath = node_path.resolve(args.projectPath);
|
|
10955
12007
|
const srcDir = node_path.join(projectPath, "src");
|
|
10956
12008
|
const manifestPath = node_path.join(srcDir, "manifest.json");
|
|
10957
|
-
if (!node_fs.existsSync(manifestPath)) return
|
|
12009
|
+
if (!node_fs.existsSync(manifestPath)) return envelope_envelope({
|
|
10958
12010
|
ok: false,
|
|
10959
12011
|
command: add_feature_COMMAND,
|
|
10960
12012
|
status: "manifest-not-found",
|
|
@@ -10965,7 +12017,7 @@ async function add_feature_handler(args) {
|
|
|
10965
12017
|
hint: "Ensure projectPath points to an extension project root with src/manifest.json"
|
|
10966
12018
|
});
|
|
10967
12019
|
const templateSlug = FEATURE_TEMPLATE_MAP[args.feature]?.[framework];
|
|
10968
|
-
if (!templateSlug) return
|
|
12020
|
+
if (!templateSlug) return envelope_envelope({
|
|
10969
12021
|
ok: false,
|
|
10970
12022
|
command: add_feature_COMMAND,
|
|
10971
12023
|
status: "no-reference-template",
|
|
@@ -11048,7 +12100,7 @@ async function add_feature_handler(args) {
|
|
|
11048
12100
|
});
|
|
11049
12101
|
const conflicts = filesToCreate.filter((f)=>node_fs.existsSync(node_path.join(projectPath, f.path)));
|
|
11050
12102
|
const conflictHint = `Warning: ${conflicts.length} file(s) already exist and would be overwritten.`;
|
|
11051
|
-
return
|
|
12103
|
+
return envelope_envelope({
|
|
11052
12104
|
ok: true,
|
|
11053
12105
|
command: add_feature_COMMAND,
|
|
11054
12106
|
status: conflicts.length ? "planned-with-conflicts" : "planned",
|
|
@@ -11191,7 +12243,7 @@ const FIRST_CALL_BUDGET_MS = 8000;
|
|
|
11191
12243
|
const RESUME_BUDGET_MS = 22000;
|
|
11192
12244
|
const PENDING_TTL_NOTE = "Once authorized, the minted token lives at most 7 days (server-enforced); CI must re-mint before expiry (console: project settings -> Access tokens).";
|
|
11193
12245
|
function login_fail(name, message, status, code) {
|
|
11194
|
-
return
|
|
12246
|
+
return envelope_envelope({
|
|
11195
12247
|
ok: false,
|
|
11196
12248
|
command: "extension_auth",
|
|
11197
12249
|
status,
|
|
@@ -11204,7 +12256,7 @@ function login_fail(name, message, status, code) {
|
|
|
11204
12256
|
}
|
|
11205
12257
|
function success(creds) {
|
|
11206
12258
|
const expiresAt = creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null;
|
|
11207
|
-
return
|
|
12259
|
+
return envelope_envelope({
|
|
11208
12260
|
ok: true,
|
|
11209
12261
|
command: "extension_auth",
|
|
11210
12262
|
status: "logged-in",
|
|
@@ -11223,7 +12275,7 @@ function login_pending(start) {
|
|
|
11223
12275
|
const complete = String(start.verificationUriComplete || "").trim();
|
|
11224
12276
|
const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
|
|
11225
12277
|
const message = hasCompleteLink ? `Open ${complete} and approve (code ${start.userCode} is pre-filled), then call extension_auth (action: login) again with this deviceCode and the same project. If the page asks for a code, enter ${start.userCode} at ${start.verificationUri}.` : `Open ${start.verificationUri} and enter code ${start.userCode}, then call extension_auth (action: login) again with this deviceCode and the same project.`;
|
|
11226
|
-
return
|
|
12278
|
+
return envelope_envelope({
|
|
11227
12279
|
ok: false,
|
|
11228
12280
|
command: "extension_auth",
|
|
11229
12281
|
status: "authorization-pending",
|
|
@@ -11248,7 +12300,7 @@ function login_pending(start) {
|
|
|
11248
12300
|
}
|
|
11249
12301
|
function resumePending(deviceCode, verificationUri) {
|
|
11250
12302
|
const message = `Still waiting for authorization. The one-click link and code from the previous response are still valid: open that link (or enter the code at ${verificationUri}), then call extension_auth (action: login) again with this same deviceCode and the same project.`;
|
|
11251
|
-
return
|
|
12303
|
+
return envelope_envelope({
|
|
11252
12304
|
ok: false,
|
|
11253
12305
|
command: "extension_auth",
|
|
11254
12306
|
status: "authorization-pending",
|
|
@@ -11270,7 +12322,7 @@ function resumePending(deviceCode, verificationUri) {
|
|
|
11270
12322
|
async function loginToProject(args) {
|
|
11271
12323
|
const project = String(args.project || "").trim();
|
|
11272
12324
|
if (!/^[^/]+\/[^/]+$/.test(project)) return login_fail("BadRequest", "project must be in the form '<workspace>/<project>'.", "bad-request", "E_BAD_REQUEST");
|
|
11273
|
-
const apiCheck = safeApiBase(resolveApiBase(args.api));
|
|
12325
|
+
const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
|
|
11274
12326
|
if (!apiCheck.ok) return login_fail("LoginConfigError", apiCheck.message, "login-failed", "E_AUTH_FAILED");
|
|
11275
12327
|
const apiBase = apiCheck.base;
|
|
11276
12328
|
let config;
|
|
@@ -11323,9 +12375,92 @@ async function loginToProject(args) {
|
|
|
11323
12375
|
verificationUriComplete: start.verificationUriComplete
|
|
11324
12376
|
});
|
|
11325
12377
|
}
|
|
11326
|
-
|
|
12378
|
+
const server_identity_DEFAULT_TIMEOUT_MS = 5000;
|
|
12379
|
+
async function askServerIdentity(args) {
|
|
12380
|
+
const fetchImpl = args.fetchImpl ?? fetch;
|
|
12381
|
+
const controller = new AbortController();
|
|
12382
|
+
const timer = setTimeout(()=>controller.abort(), args.timeoutMs ?? server_identity_DEFAULT_TIMEOUT_MS);
|
|
12383
|
+
try {
|
|
12384
|
+
const res = await fetchImpl(`${args.apiBase.replace(/\/+$/, "")}/api/cli/whoami`, {
|
|
12385
|
+
headers: {
|
|
12386
|
+
authorization: `Bearer ${args.token}`,
|
|
12387
|
+
accept: "application/json"
|
|
12388
|
+
},
|
|
12389
|
+
signal: controller.signal
|
|
12390
|
+
});
|
|
12391
|
+
if (200 === res.status) {
|
|
12392
|
+
const data = await res.json().catch(()=>null);
|
|
12393
|
+
const login = String(data?.login || "").trim();
|
|
12394
|
+
if (!login) return {
|
|
12395
|
+
kind: "unavailable",
|
|
12396
|
+
detail: "the server answered 200 without a login"
|
|
12397
|
+
};
|
|
12398
|
+
return {
|
|
12399
|
+
kind: "confirmed",
|
|
12400
|
+
login,
|
|
12401
|
+
live: data?.live === true
|
|
12402
|
+
};
|
|
12403
|
+
}
|
|
12404
|
+
if (401 === res.status) return {
|
|
12405
|
+
kind: "refused"
|
|
12406
|
+
};
|
|
12407
|
+
return {
|
|
12408
|
+
kind: "unavailable",
|
|
12409
|
+
detail: `the server answered ${res.status}, which is not a verdict on this credential`
|
|
12410
|
+
};
|
|
12411
|
+
} catch (error) {
|
|
12412
|
+
return {
|
|
12413
|
+
kind: "unavailable",
|
|
12414
|
+
detail: error?.message || "network error"
|
|
12415
|
+
};
|
|
12416
|
+
} finally{
|
|
12417
|
+
clearTimeout(timer);
|
|
12418
|
+
}
|
|
12419
|
+
}
|
|
12420
|
+
function describeServer(check, api) {
|
|
12421
|
+
if ("confirmed" === check.kind) return {
|
|
12422
|
+
status: "logged-in",
|
|
12423
|
+
note: `The server at ${api} confirms this token: it resolves to ${check.login} and is live there.`,
|
|
12424
|
+
warning: null,
|
|
12425
|
+
value: {
|
|
12426
|
+
verdict: "confirmed",
|
|
12427
|
+
api,
|
|
12428
|
+
login: check.login,
|
|
12429
|
+
live: check.live
|
|
12430
|
+
}
|
|
12431
|
+
};
|
|
12432
|
+
if ("refused" === check.kind) return {
|
|
12433
|
+
status: "refused-by-server",
|
|
12434
|
+
note: `The server at ${api} refused this credential: it does not resolve to an identity there (expired, revoked, or minted for another environment). The workspace/project above is only what the local file claims. Run extension_auth (action: login) to re-authenticate.`,
|
|
12435
|
+
warning: null,
|
|
12436
|
+
value: {
|
|
12437
|
+
verdict: "refused",
|
|
12438
|
+
api
|
|
12439
|
+
}
|
|
12440
|
+
};
|
|
12441
|
+
if ("unavailable" === check.kind) return {
|
|
12442
|
+
status: "logged-in",
|
|
12443
|
+
note: null,
|
|
12444
|
+
warning: `Could not verify this login with the server at ${api} (${check.detail}). The identity above is the local file's claim only, not server-confirmed.`,
|
|
12445
|
+
value: {
|
|
12446
|
+
verdict: "unavailable",
|
|
12447
|
+
api,
|
|
12448
|
+
detail: check.detail
|
|
12449
|
+
}
|
|
12450
|
+
};
|
|
12451
|
+
return {
|
|
12452
|
+
status: "expired",
|
|
12453
|
+
note: null,
|
|
12454
|
+
warning: null,
|
|
12455
|
+
value: {
|
|
12456
|
+
verdict: "not-asked",
|
|
12457
|
+
detail: check.detail
|
|
12458
|
+
}
|
|
12459
|
+
};
|
|
12460
|
+
}
|
|
12461
|
+
async function readIdentity(deps) {
|
|
11327
12462
|
const creds = readCredentials();
|
|
11328
|
-
if (!creds) return
|
|
12463
|
+
if (!creds) return envelope_envelope({
|
|
11329
12464
|
ok: true,
|
|
11330
12465
|
command: "extension_auth",
|
|
11331
12466
|
status: "logged-out",
|
|
@@ -11337,19 +12472,39 @@ async function readIdentity() {
|
|
|
11337
12472
|
const recordedApi = String(creds.api || "").trim();
|
|
11338
12473
|
const effectiveDefaultApi = resolveApiBase();
|
|
11339
12474
|
const apiDiverges = Boolean(recordedApi) && recordedApi !== effectiveDefaultApi;
|
|
12475
|
+
const askApi = recordedApi || effectiveDefaultApi;
|
|
11340
12476
|
const envTokenSet = Boolean(String(process.env.EXTENSION_DEV_TOKEN || "").trim());
|
|
12477
|
+
let check;
|
|
12478
|
+
if (expired) check = {
|
|
12479
|
+
kind: "not-asked",
|
|
12480
|
+
detail: "the stored token has already expired locally"
|
|
12481
|
+
};
|
|
12482
|
+
else {
|
|
12483
|
+
const safe = safeApiBase(askApi);
|
|
12484
|
+
check = safe.ok ? await askServerIdentity({
|
|
12485
|
+
apiBase: safe.base,
|
|
12486
|
+
token: creds.token,
|
|
12487
|
+
fetchImpl: deps?.fetchImpl
|
|
12488
|
+
}) : {
|
|
12489
|
+
kind: "unavailable",
|
|
12490
|
+
detail: safe.message
|
|
12491
|
+
};
|
|
12492
|
+
}
|
|
12493
|
+
const server = describeServer(check, askApi);
|
|
11341
12494
|
const identityNote = expired ? "The stored token has expired. Run extension_auth (action: login) to refresh it." : `Logged in as ${creds.workspaceSlug}/${creds.projectSlug}, per the token extension_auth stored on this machine. That token is what scopes the identity: it does not follow the current working directory or project folder.`;
|
|
11342
12495
|
const apiDivergesNote = apiDiverges ? `This login was minted via ${recordedApi}: access grants for private registry reads use that recorded base when no api argument is given, while other authenticated tools target ${effectiveDefaultApi} unless given one.` : null;
|
|
11343
12496
|
const envTokenNote = envTokenSet ? "EXTENSION_DEV_TOKEN is set and takes precedence over this stored login for authenticated tools; this report describes only the stored login." : null;
|
|
11344
12497
|
const message = [
|
|
11345
12498
|
identityNote,
|
|
12499
|
+
server.note,
|
|
12500
|
+
server.warning,
|
|
11346
12501
|
apiDivergesNote,
|
|
11347
12502
|
envTokenNote
|
|
11348
12503
|
].filter(Boolean).join(" ");
|
|
11349
|
-
return
|
|
12504
|
+
return envelope_envelope({
|
|
11350
12505
|
ok: true,
|
|
11351
12506
|
command: "extension_auth",
|
|
11352
|
-
status: expired ? "expired" :
|
|
12507
|
+
status: expired ? "expired" : server.status,
|
|
11353
12508
|
value: {
|
|
11354
12509
|
workspaceSlug: creds.workspaceSlug,
|
|
11355
12510
|
projectSlug: creds.projectSlug,
|
|
@@ -11360,11 +12515,13 @@ async function readIdentity() {
|
|
|
11360
12515
|
provider: creds.provider ?? "extensiondev",
|
|
11361
12516
|
expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
|
|
11362
12517
|
expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
|
|
11363
|
-
expired
|
|
12518
|
+
expired,
|
|
12519
|
+
server: server.value
|
|
11364
12520
|
},
|
|
11365
12521
|
hint: message,
|
|
11366
12522
|
warnings: [
|
|
11367
12523
|
tokenTtlNote(creds.workspaceSlug, creds.projectSlug),
|
|
12524
|
+
server.warning,
|
|
11368
12525
|
apiDivergesNote,
|
|
11369
12526
|
envTokenNote
|
|
11370
12527
|
]
|
|
@@ -11377,7 +12534,7 @@ async function clearLocalCredentials() {
|
|
|
11377
12534
|
project: creds.projectSlug
|
|
11378
12535
|
}, "settings/access-tokens") : null;
|
|
11379
12536
|
const result = clearCredentials();
|
|
11380
|
-
return
|
|
12537
|
+
return envelope_envelope({
|
|
11381
12538
|
ok: true,
|
|
11382
12539
|
command: "extension_auth",
|
|
11383
12540
|
status: result.cleared ? "logged-out" : "nothing-to-clear",
|
|
@@ -11390,7 +12547,7 @@ async function clearLocalCredentials() {
|
|
|
11390
12547
|
}
|
|
11391
12548
|
const auth_schema = {
|
|
11392
12549
|
name: "extension_auth",
|
|
11393
|
-
description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only; the token stays valid server-side until it is revoked at the URL the response returns.",
|
|
12550
|
+
description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Status also asks the platform's whoami endpoint whether that credential actually resolves there: the answer is reported as confirmed, refused-by-server, or unverified when the server cannot be reached, so a local file claiming a login the server would refuse is never reported as simply logged in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only; the token stays valid server-side until it is revoked at the URL the response returns.",
|
|
11394
12551
|
inputSchema: {
|
|
11395
12552
|
type: "object",
|
|
11396
12553
|
properties: {
|
|
@@ -11710,7 +12867,7 @@ async function detectBrowsers(browsers) {
|
|
|
11710
12867
|
}
|
|
11711
12868
|
const available = detected.filter((d)=>"not_found" !== d.source);
|
|
11712
12869
|
const missing = detected.filter((d)=>"not_found" === d.source);
|
|
11713
|
-
return
|
|
12870
|
+
return envelope_envelope({
|
|
11714
12871
|
ok: true,
|
|
11715
12872
|
command: "extension_browsers",
|
|
11716
12873
|
status: "detected",
|
|
@@ -11767,7 +12924,7 @@ async function listManagedBrowsers() {
|
|
|
11767
12924
|
});
|
|
11768
12925
|
}
|
|
11769
12926
|
}
|
|
11770
|
-
return
|
|
12927
|
+
return envelope_envelope({
|
|
11771
12928
|
ok: true,
|
|
11772
12929
|
command: "extension_browsers",
|
|
11773
12930
|
status: "listed",
|
|
@@ -11786,7 +12943,7 @@ async function installManagedBrowser(browser) {
|
|
|
11786
12943
|
await extensionInstall({
|
|
11787
12944
|
browser
|
|
11788
12945
|
});
|
|
11789
|
-
return
|
|
12946
|
+
return envelope_envelope({
|
|
11790
12947
|
ok: true,
|
|
11791
12948
|
command: "extension_browsers",
|
|
11792
12949
|
status: "installed",
|
|
@@ -11797,7 +12954,7 @@ async function installManagedBrowser(browser) {
|
|
|
11797
12954
|
hint: `Browser "${browser}" is now available. Use extension_dev or extension_start with browser: "${browser}".`
|
|
11798
12955
|
});
|
|
11799
12956
|
} catch (err) {
|
|
11800
|
-
return
|
|
12957
|
+
return envelope_envelope({
|
|
11801
12958
|
ok: false,
|
|
11802
12959
|
command: "extension_browsers",
|
|
11803
12960
|
status: "install-failed",
|
|
@@ -11815,7 +12972,7 @@ async function installManagedBrowser(browser) {
|
|
|
11815
12972
|
}
|
|
11816
12973
|
async function uninstallManagedBrowser(args) {
|
|
11817
12974
|
const start = Date.now();
|
|
11818
|
-
if (!args.browser && !args.all) return
|
|
12975
|
+
if (!args.browser && !args.all) return envelope_envelope({
|
|
11819
12976
|
ok: false,
|
|
11820
12977
|
command: "extension_browsers",
|
|
11821
12978
|
status: "bad-request",
|
|
@@ -11829,7 +12986,7 @@ async function uninstallManagedBrowser(args) {
|
|
|
11829
12986
|
browser: args.browser,
|
|
11830
12987
|
all: args.all
|
|
11831
12988
|
});
|
|
11832
|
-
return
|
|
12989
|
+
return envelope_envelope({
|
|
11833
12990
|
ok: true,
|
|
11834
12991
|
command: "extension_browsers",
|
|
11835
12992
|
status: "uninstalled",
|
|
@@ -11840,7 +12997,7 @@ async function uninstallManagedBrowser(args) {
|
|
|
11840
12997
|
hint: 'Use extension_browsers with action: "list" to confirm what remains in the managed cache.'
|
|
11841
12998
|
});
|
|
11842
12999
|
} catch (err) {
|
|
11843
|
-
return
|
|
13000
|
+
return envelope_envelope({
|
|
11844
13001
|
ok: false,
|
|
11845
13002
|
command: "extension_browsers",
|
|
11846
13003
|
status: "uninstall-failed",
|
|
@@ -11897,7 +13054,7 @@ async function browsers_handler(args) {
|
|
|
11897
13054
|
const action = args.action ?? "detect";
|
|
11898
13055
|
if ("list" === action) return listManagedBrowsers();
|
|
11899
13056
|
if ("install" === action) {
|
|
11900
|
-
if (!args.browser) return
|
|
13057
|
+
if (!args.browser) return envelope_envelope({
|
|
11901
13058
|
ok: false,
|
|
11902
13059
|
command: "extension_browsers",
|
|
11903
13060
|
status: "bad-request",
|
|
@@ -12066,7 +13223,7 @@ function describeToolArgs(inputSchema) {
|
|
|
12066
13223
|
};
|
|
12067
13224
|
}
|
|
12068
13225
|
function inputValidationError(toolName, issues, inputSchema) {
|
|
12069
|
-
return
|
|
13226
|
+
return envelope_envelope({
|
|
12070
13227
|
ok: false,
|
|
12071
13228
|
command: toolName,
|
|
12072
13229
|
status: "invalid-arguments",
|
|
@@ -12113,9 +13270,30 @@ const tools = [
|
|
|
12113
13270
|
browsers_namespaceObject,
|
|
12114
13271
|
doctor_namespaceObject
|
|
12115
13272
|
];
|
|
13273
|
+
function toolResultFrame(result) {
|
|
13274
|
+
let refused = false;
|
|
13275
|
+
try {
|
|
13276
|
+
const parsed = JSON.parse(result);
|
|
13277
|
+
refused = isEnvelope(parsed) && false === parsed.ok;
|
|
13278
|
+
} catch {
|
|
13279
|
+
refused = false;
|
|
13280
|
+
}
|
|
13281
|
+
return {
|
|
13282
|
+
content: [
|
|
13283
|
+
{
|
|
13284
|
+
type: "text",
|
|
13285
|
+
text: result
|
|
13286
|
+
}
|
|
13287
|
+
],
|
|
13288
|
+
...refused ? {
|
|
13289
|
+
isError: true
|
|
13290
|
+
} : {}
|
|
13291
|
+
};
|
|
13292
|
+
}
|
|
12116
13293
|
const toolMap = new Map();
|
|
12117
13294
|
for (const tool of tools)toolMap.set(tool.schema.name, tool);
|
|
12118
13295
|
async function startServer() {
|
|
13296
|
+
installCarrierExitCleanup();
|
|
12119
13297
|
const server = new Server({
|
|
12120
13298
|
name: "extension-dev",
|
|
12121
13299
|
version: package_namespaceObject.rE
|
|
@@ -12138,7 +13316,7 @@ async function startServer() {
|
|
|
12138
13316
|
content: [
|
|
12139
13317
|
{
|
|
12140
13318
|
type: "text",
|
|
12141
|
-
text:
|
|
13319
|
+
text: envelope_envelope({
|
|
12142
13320
|
ok: false,
|
|
12143
13321
|
command: name,
|
|
12144
13322
|
status: "unknown-tool",
|
|
@@ -12167,20 +13345,13 @@ async function startServer() {
|
|
|
12167
13345
|
};
|
|
12168
13346
|
try {
|
|
12169
13347
|
const result = await tool.handler(normalizedArgs);
|
|
12170
|
-
return
|
|
12171
|
-
content: [
|
|
12172
|
-
{
|
|
12173
|
-
type: "text",
|
|
12174
|
-
text: result
|
|
12175
|
-
}
|
|
12176
|
-
]
|
|
12177
|
-
};
|
|
13348
|
+
return toolResultFrame(result);
|
|
12178
13349
|
} catch (err) {
|
|
12179
13350
|
return {
|
|
12180
13351
|
content: [
|
|
12181
13352
|
{
|
|
12182
13353
|
type: "text",
|
|
12183
|
-
text:
|
|
13354
|
+
text: envelope_envelope({
|
|
12184
13355
|
ok: false,
|
|
12185
13356
|
command: name,
|
|
12186
13357
|
status: "internal-error",
|