@extension.dev/mcp 5.1.2 → 5.3.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/dist/module.js CHANGED
@@ -7,11 +7,11 @@ import node_path, { join } from "node:path";
7
7
  import { extensionCreate } from "extension-create";
8
8
  import node_os from "node:os";
9
9
  import cross_spawn from "cross-spawn";
10
+ import node_crypto from "node:crypto";
10
11
  import { execFile, execFileSync } from "node:child_process";
11
12
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
12
13
  import ws_0 from "ws";
13
14
  import node_http from "node:http";
14
- import node_crypto from "node:crypto";
15
15
  import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
16
16
  import { promisify } from "node:util";
17
17
  var add_feature_namespaceObject = {};
@@ -149,6 +149,12 @@ __webpack_require__.d(tools_publish_namespaceObject, {
149
149
  handler: ()=>publish_handler,
150
150
  schema: ()=>publish_schema
151
151
  });
152
+ var release_list_namespaceObject = {};
153
+ __webpack_require__.r(release_list_namespaceObject);
154
+ __webpack_require__.d(release_list_namespaceObject, {
155
+ handler: ()=>release_list_handler,
156
+ schema: ()=>release_list_schema
157
+ });
152
158
  var release_promote_namespaceObject = {};
153
159
  __webpack_require__.r(release_promote_namespaceObject);
154
160
  __webpack_require__.d(release_promote_namespaceObject, {
@@ -177,7 +183,8 @@ var stop_namespaceObject = {};
177
183
  __webpack_require__.r(stop_namespaceObject);
178
184
  __webpack_require__.d(stop_namespaceObject, {
179
185
  handler: ()=>stop_handler,
180
- schema: ()=>stop_schema
186
+ schema: ()=>stop_schema,
187
+ stopOne: ()=>stopOne
181
188
  });
182
189
  var storage_namespaceObject = {};
183
190
  __webpack_require__.r(storage_namespaceObject);
@@ -203,7 +210,7 @@ __webpack_require__.d(whoami_namespaceObject, {
203
210
  handler: ()=>whoami_handler,
204
211
  schema: ()=>whoami_schema
205
212
  });
206
- var package_namespaceObject = JSON.parse('{"rE":"5.1.1","El":{"OP":"^4.0.13"}}');
213
+ var package_namespaceObject = JSON.parse('{"rE":"5.3.0","El":{"OP":"^4.0.13"}}');
207
214
  function scaffoldEnginePin(projectPath) {
208
215
  try {
209
216
  const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
@@ -241,17 +248,17 @@ function detectPackageManager(projectPath) {
241
248
  }
242
249
  const create_schema = {
243
250
  name: "extension_create",
244
- description: "Create a new browser extension project from a template in the extension.dev template catalog. Use extension_list_templates to see available options.",
251
+ description: "Create a new browser extension project from a template in the extension.dev template catalog. Use extension_list_templates to see available options. The scaffolder may initialize a git repository in the new project; the result's defaultsApplied block reports whether it did, along with every other decision made without being asked.",
245
252
  inputSchema: {
246
253
  type: "object",
247
254
  properties: {
248
255
  projectName: {
249
256
  type: "string",
250
- description: "Name of the extension project (used as directory name)"
257
+ description: "Name of the extension project (used as directory name). Alias: name."
251
258
  },
252
259
  parentDir: {
253
260
  type: "string",
254
- description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect, pass this explicitly when you care where the project lands."
261
+ description: "Directory to create the project inside. Defaults to the MCP server process cwd (NOT the caller's cwd), which may not be where you expect; pass this explicitly when you care where the project lands. Aliases: parent, into."
255
262
  },
256
263
  template: {
257
264
  type: "string",
@@ -338,12 +345,21 @@ async function create_handler(args) {
338
345
  const scaffoldPin = scaffoldEnginePin(result.projectPath);
339
346
  const pinMatches = null !== scaffoldPin && "" !== pin && scaffoldPin.includes(pin);
340
347
  const engineWarning = pin && "latest" !== pin && !pinMatches ? `The scaffold pins "extension": "${scaffoldPin ?? "unknown"}"; the project-local engine wins over EXTENSION_MCP_CLI_VERSION=${pin}. Run \`(cd ${result.projectPath} && ${addDev(`extension@${pin}`)})\` to match the pinned engine.` : void 0;
348
+ const resolvedParent = args.parentDir ? node_path.resolve(args.parentDir) : process.cwd();
349
+ const gitInit = node_fs.existsSync(node_path.join(result.projectPath, ".git"));
341
350
  return JSON.stringify({
351
+ resolvedPath: result.projectPath,
342
352
  projectPath: result.projectPath,
343
353
  projectName: result.projectName,
344
354
  template: result.template,
345
355
  depsInstalled: result.depsInstalled,
346
356
  packageManager: result.depsInstalled ? packageManager : null,
357
+ defaultsApplied: {
358
+ parentDir: args.parentDir ? `${resolvedParent} (explicit)` : `${resolvedParent} (default: the MCP server process cwd, not yours; pass parentDir to choose)`,
359
+ packageManager: `${packageManager} (auto-detected by the scaffolder, not asked)`,
360
+ browser: "chrome (default: extension_dev and extension_build target chrome unless you pass browser)",
361
+ gitInit
362
+ },
347
363
  duration: Date.now() - start,
348
364
  nextSteps: result.depsInstalled ? [
349
365
  `cd ${result.projectPath}`,
@@ -882,21 +898,325 @@ async function build_handler(args) {
882
898
  hint: "Check that the project has a valid src/manifest.json and its dependencies are installed (extension_dev auto-installs; build does not)."
883
899
  });
884
900
  }
885
- const process_manager_sessions = new Map();
901
+ const sessions = new Map();
886
902
  function sessionKey(projectPath, browser) {
887
903
  return `${node_path.resolve(projectPath)}::${browser}`;
888
904
  }
905
+ function markerDir() {
906
+ return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
907
+ }
908
+ function markerPath(projectPath, browser) {
909
+ const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
910
+ return node_path.join(markerDir(), `${digest}.json`);
911
+ }
912
+ function removeSessionMarker(projectPath, browser) {
913
+ try {
914
+ node_fs.rmSync(markerPath(projectPath, browser), {
915
+ force: true
916
+ });
917
+ } catch {}
918
+ }
919
+ function listSessionMarkers() {
920
+ let files;
921
+ try {
922
+ files = node_fs.readdirSync(markerDir());
923
+ } catch {
924
+ return [];
925
+ }
926
+ const out = [];
927
+ for (const file of files)if (file.endsWith(".json")) try {
928
+ const parsed = JSON.parse(node_fs.readFileSync(node_path.join(markerDir(), file), "utf8"));
929
+ if ("string" == typeof parsed?.projectPath && "string" == typeof parsed?.browser) out.push(parsed);
930
+ } catch {}
931
+ return out;
932
+ }
889
933
  function registerSession(info) {
890
- process_manager_sessions.set(sessionKey(info.projectPath, info.browser), info);
934
+ sessions.set(sessionKey(info.projectPath, info.browser), info);
935
+ try {
936
+ node_fs.mkdirSync(markerDir(), {
937
+ recursive: true
938
+ });
939
+ node_fs.writeFileSync(markerPath(info.projectPath, info.browser), JSON.stringify({
940
+ ...info,
941
+ projectPath: node_path.resolve(info.projectPath),
942
+ registeredAt: new Date().toISOString()
943
+ }));
944
+ } catch {}
891
945
  }
892
946
  function getSession(projectPath, browser) {
893
- return process_manager_sessions.get(sessionKey(projectPath, browser));
947
+ return sessions.get(sessionKey(projectPath, browser));
894
948
  }
895
949
  function removeSession(projectPath, browser) {
896
- process_manager_sessions.delete(sessionKey(projectPath, browser));
950
+ sessions.delete(sessionKey(projectPath, browser));
897
951
  }
898
952
  function listSessions() {
899
- return Array.from(process_manager_sessions.values());
953
+ return Array.from(sessions.values());
954
+ }
955
+ function contractSightings(projectPath) {
956
+ const root = node_path.resolve(projectPath, "dist", "extension-js");
957
+ let dirs;
958
+ try {
959
+ dirs = node_fs.readdirSync(root);
960
+ } catch {
961
+ return [];
962
+ }
963
+ const sightings = [];
964
+ for (const dir of dirs){
965
+ const readyPath = node_path.join(root, dir, "ready.json");
966
+ try {
967
+ const stat = node_fs.statSync(readyPath);
968
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
969
+ if (contract?.status !== "ready") continue;
970
+ sightings.push({
971
+ browser: dir,
972
+ mtimeMs: stat.mtimeMs,
973
+ pid: "number" == typeof contract.pid ? contract.pid : void 0
974
+ });
975
+ } catch {}
976
+ }
977
+ return sightings;
978
+ }
979
+ function pidAlive(pid) {
980
+ try {
981
+ process.kill(pid, 0);
982
+ return true;
983
+ } catch {
984
+ return false;
985
+ }
986
+ }
987
+ function knownSessionBrowsers(projectPath) {
988
+ const resolved = node_path.resolve(projectPath);
989
+ const browsers = [];
990
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
991
+ for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
992
+ return Array.from(new Set(browsers));
993
+ }
994
+ function liveProjectSessions(projectPath) {
995
+ const resolved = node_path.resolve(projectPath);
996
+ const out = new Map();
997
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) {
998
+ if (pidAlive(session.pid)) out.set(session.browser, {
999
+ browser: session.browser,
1000
+ pid: session.pid,
1001
+ source: "registry"
1002
+ });
1003
+ }
1004
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && pidAlive(sighting.pid)) {
1005
+ if (!out.has(sighting.browser)) out.set(sighting.browser, {
1006
+ browser: sighting.browser,
1007
+ pid: sighting.pid,
1008
+ source: "contract"
1009
+ });
1010
+ }
1011
+ return [
1012
+ ...out.values()
1013
+ ];
1014
+ }
1015
+ function deadReadySession(projectPath) {
1016
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1017
+ browser: sighting.browser,
1018
+ pid: sighting.pid
1019
+ };
1020
+ return null;
1021
+ }
1022
+ function browserExitStamp(projectPath, browser, since) {
1023
+ const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1024
+ try {
1025
+ const stat = node_fs.statSync(readyPath);
1026
+ if (stat.mtimeMs < since) return null;
1027
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1028
+ const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
1029
+ if (contract?.status === "error" && exited) return {
1030
+ code: contract.code,
1031
+ browserExitCode: contract.browserExitCode ?? null,
1032
+ browserExitedAt: contract.browserExitedAt
1033
+ };
1034
+ } catch {}
1035
+ return null;
1036
+ }
1037
+ function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
1038
+ if (explicit) return {
1039
+ browser: explicit,
1040
+ source: "explicit"
1041
+ };
1042
+ const resolved = node_path.resolve(projectPath);
1043
+ const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1044
+ if (mine.length > 0) return {
1045
+ browser: mine[mine.length - 1].browser,
1046
+ source: "session"
1047
+ };
1048
+ const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1049
+ const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
1050
+ if (live.length > 0) return {
1051
+ browser: live[0].browser,
1052
+ source: "contract"
1053
+ };
1054
+ if (sightings.length > 0) return {
1055
+ browser: sightings[0].browser,
1056
+ source: "stale"
1057
+ };
1058
+ return {
1059
+ browser: fallback,
1060
+ source: "fallback"
1061
+ };
1062
+ }
1063
+ const stop_schema = {
1064
+ name: "extension_stop",
1065
+ description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
1066
+ inputSchema: {
1067
+ type: "object",
1068
+ properties: {
1069
+ projectPath: {
1070
+ type: "string",
1071
+ description: "Path to the extension project root"
1072
+ },
1073
+ browser: {
1074
+ type: "string",
1075
+ description: "Browser of the session to stop (matches the browser passed to extension_dev/extension_start). Defaults to the one live session for this project when omitted, instead of assuming chrome."
1076
+ },
1077
+ all: {
1078
+ type: "boolean",
1079
+ default: false,
1080
+ description: "Stop every known session, across projects and browsers. Discovers sessions from this server's registry AND the on-disk session markers earlier runs left, so it still finds sessions after an MCP restart or after a dev child exited out from under the registry. When true, projectPath/browser are ignored."
1081
+ }
1082
+ },
1083
+ required: []
1084
+ }
1085
+ };
1086
+ function pgrepPids(pattern) {
1087
+ try {
1088
+ const out = execFileSync("pgrep", [
1089
+ "-f",
1090
+ pattern
1091
+ ], {
1092
+ encoding: "utf8"
1093
+ });
1094
+ return out.split("\n").map((s)=>parseInt(s.trim(), 10)).filter((n)=>Number.isInteger(n) && n > 0 && n !== process.pid);
1095
+ } catch {
1096
+ return [];
1097
+ }
1098
+ }
1099
+ function sessionProcessPids(projectPath) {
1100
+ const resolved = node_path.resolve(projectPath);
1101
+ const pids = new Set();
1102
+ for (const marker of [
1103
+ `extension dev ${resolved}`,
1104
+ node_path.join(resolved, "dist")
1105
+ ])for (const pid of pgrepPids(marker))pids.add(pid);
1106
+ return [
1107
+ ...pids
1108
+ ];
1109
+ }
1110
+ function reapSessionProcesses(projectPath) {
1111
+ const pids = sessionProcessPids(projectPath);
1112
+ for (const pid of pids)try {
1113
+ process.kill(pid, "SIGKILL");
1114
+ } catch {}
1115
+ return pids;
1116
+ }
1117
+ function isAlive(pid) {
1118
+ try {
1119
+ process.kill(pid, 0);
1120
+ return true;
1121
+ } catch {
1122
+ return false;
1123
+ }
1124
+ }
1125
+ function stop_signal(pid, sig) {
1126
+ try {
1127
+ process.kill(-pid, sig);
1128
+ return true;
1129
+ } catch {
1130
+ try {
1131
+ process.kill(pid, sig);
1132
+ return true;
1133
+ } catch {
1134
+ return false;
1135
+ }
1136
+ }
1137
+ }
1138
+ function readyJsonPath(projectPath, browser) {
1139
+ return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1140
+ }
1141
+ function pidFromReadyContract(projectPath, browser) {
1142
+ try {
1143
+ const raw = node_fs.readFileSync(readyJsonPath(projectPath, browser), "utf8");
1144
+ const contract = JSON.parse(raw);
1145
+ return "number" == typeof contract.pid ? contract.pid : null;
1146
+ } catch {
1147
+ return null;
1148
+ }
1149
+ }
1150
+ async function stopOne(projectPath, browser) {
1151
+ const session = getSession(projectPath, browser);
1152
+ const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
1153
+ if (null == pid) {
1154
+ const reaped = reapSessionProcesses(projectPath);
1155
+ removeSessionMarker(projectPath, browser);
1156
+ return {
1157
+ projectPath,
1158
+ browser,
1159
+ pid: null,
1160
+ stopped: 0 !== reaped.length,
1161
+ reaped,
1162
+ detail: 0 === reaped.length ? "No known session for this project/browser (nothing registered in this server and no ready.json contract found)." : `No dev pid on record, but reaped ${reaped.length} orphaned browser process(es) from the profile dir.`
1163
+ };
1164
+ }
1165
+ let detail;
1166
+ if (isAlive(pid)) {
1167
+ stop_signal(pid, "SIGTERM");
1168
+ await new Promise((resolve)=>setTimeout(resolve, 1500));
1169
+ if (isAlive(pid)) {
1170
+ stop_signal(pid, "SIGKILL");
1171
+ await new Promise((resolve)=>setTimeout(resolve, 250));
1172
+ }
1173
+ detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
1174
+ } else detail = "Process was already gone; cleaned up session records.";
1175
+ const reaped = reapSessionProcesses(projectPath);
1176
+ removeSession(projectPath, browser);
1177
+ removeSessionMarker(projectPath, browser);
1178
+ try {
1179
+ node_fs.rmSync(readyJsonPath(projectPath, browser), {
1180
+ force: true
1181
+ });
1182
+ } catch {}
1183
+ const survivors = sessionProcessPids(projectPath);
1184
+ const stopped = !isAlive(pid) && 0 === survivors.length;
1185
+ if (survivors.length) detail += ` Warning: ${survivors.length} browser process(es) still alive after reap (pids ${survivors.join(", ")}).`;
1186
+ else if (reaped.length) detail += ` Reaped ${reaped.length} browser process(es).`;
1187
+ return {
1188
+ projectPath,
1189
+ browser,
1190
+ pid,
1191
+ stopped,
1192
+ reaped,
1193
+ detail
1194
+ };
1195
+ }
1196
+ async function stop_handler(args) {
1197
+ if (args.all) {
1198
+ const candidates = new Map();
1199
+ for (const s of listSessions())candidates.set(`${node_path.resolve(s.projectPath)}::${s.browser}`, s);
1200
+ for (const m of listSessionMarkers()){
1201
+ const key = `${node_path.resolve(m.projectPath)}::${m.browser}`;
1202
+ if (!candidates.has(key)) candidates.set(key, m);
1203
+ }
1204
+ if (0 === candidates.size) return JSON.stringify({
1205
+ stopped: [],
1206
+ message: "No sessions registered in this server and no session markers on disk. Nothing to stop."
1207
+ });
1208
+ const outcomes = [];
1209
+ for (const c of candidates.values())outcomes.push(await stopOne(c.projectPath, c.browser));
1210
+ return JSON.stringify({
1211
+ stopped: outcomes
1212
+ });
1213
+ }
1214
+ if (!args.projectPath) return JSON.stringify({
1215
+ error: "projectPath is required unless all=true. Pass the same projectPath used with extension_dev/extension_start."
1216
+ });
1217
+ const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
1218
+ const outcome = await stopOne(args.projectPath, browser);
1219
+ return JSON.stringify(outcome);
900
1220
  }
901
1221
  const LAUNCH_FLAG_SCHEMA = {
902
1222
  profile: {
@@ -988,6 +1308,11 @@ const dev_schema = {
988
1308
  description: "Apply cross-browser polyfill"
989
1309
  },
990
1310
  ...LAUNCH_FLAG_SCHEMA,
1311
+ replace: {
1312
+ type: "boolean",
1313
+ default: false,
1314
+ description: "Stop any live session already running for this projectPath before starting; the result then reports it as replacedSession. Without it, extension_dev refuses to start over a live session instead of silently forking it (two sessions fight over the browser profile and the newer browser dies on the profile lock)."
1315
+ },
991
1316
  allowControl: {
992
1317
  type: "boolean",
993
1318
  default: false,
@@ -1006,6 +1331,31 @@ const dev_schema = {
1006
1331
  };
1007
1332
  async function dev_handler(args) {
1008
1333
  const browser = args.browser ?? "chrome";
1334
+ const existing = liveProjectSessions(args.projectPath);
1335
+ const replaced = [];
1336
+ if (existing.length > 0) {
1337
+ if (!args.replace) {
1338
+ const listed = existing.map((s)=>`pid ${s.pid} (${s.browser})`).join(", ");
1339
+ return JSON.stringify({
1340
+ ok: false,
1341
+ status: "session-exists",
1342
+ projectPath: args.projectPath,
1343
+ sessions: existing.map((s)=>({
1344
+ pid: s.pid,
1345
+ browser: s.browser
1346
+ })),
1347
+ error: `A dev session is already running for this project (${listed}). Starting another would fork the session: both browsers contend for the same profile and the new one dies on the profile lock.`,
1348
+ hint: "Call extension_stop with this projectPath first, or pass replace: true to have extension_dev stop the old session before starting the new one."
1349
+ });
1350
+ }
1351
+ for (const s of existing){
1352
+ await stopOne(args.projectPath, s.browser);
1353
+ replaced.push({
1354
+ pid: s.pid,
1355
+ browser: s.browser
1356
+ });
1357
+ }
1358
+ }
1009
1359
  const allowControl = Boolean(args.allowControl || args.allowEval);
1010
1360
  const cliArgs = [
1011
1361
  "dev",
@@ -1019,6 +1369,7 @@ async function dev_handler(args) {
1019
1369
  cliArgs.push(...launchFlagArgs(args));
1020
1370
  if (allowControl) cliArgs.push("--allow-control");
1021
1371
  if (args.allowEval) cliArgs.push("--allow-eval");
1372
+ const spawnedAt = Date.now();
1022
1373
  const spawned = spawnExtensionCli(cliArgs, {
1023
1374
  projectDir: args.projectPath
1024
1375
  });
@@ -1063,6 +1414,23 @@ async function dev_handler(args) {
1063
1414
  logPath,
1064
1415
  hint: "Fix the compile error in `output` above and save: the dev server is still running and will recompile. Do not call extension_wait yet, it will report ready for a build that failed."
1065
1416
  });
1417
+ const exitStamp = args.noBrowser ? null : browserExitStamp(args.projectPath, browser, spawnedAt);
1418
+ const profileLockHit = !args.noBrowser && /SingletonLock|ProcessSingleton|profile[^\n]*(in use|locked)|already (open|running)/i.test(earlyOutput);
1419
+ if (exitStamp || profileLockHit) {
1420
+ const profileDir = node_path.join(args.projectPath, "dist", `extension-profile-${browser}`);
1421
+ return JSON.stringify({
1422
+ ok: false,
1423
+ status: "browser-exited",
1424
+ projectPath: args.projectPath,
1425
+ browser,
1426
+ pid,
1427
+ ...exitStamp ?? {},
1428
+ error: `The dev server is running but the ${browser} browser it launched died during startup` + (profileLockHit ? " because its profile is locked by another browser instance." : "."),
1429
+ output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1430
+ logPath,
1431
+ 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, remove ${profileDir} manually before retrying.`
1432
+ });
1433
+ }
1066
1434
  const controlVerbs = "storage, reload, open, dom_inspect";
1067
1435
  const capabilities = {
1068
1436
  allowControl,
@@ -1076,8 +1444,14 @@ async function dev_handler(args) {
1076
1444
  port: args.port ?? 8080,
1077
1445
  projectPath: args.projectPath,
1078
1446
  status: "started",
1447
+ ...replaced.length > 0 ? {
1448
+ replacedSession: replaced[0],
1449
+ ...replaced.length > 1 ? {
1450
+ replacedSessions: replaced
1451
+ } : {}
1452
+ } : {},
1079
1453
  capabilities,
1080
- hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). Restart extension_dev with the flag you need.") + " When you are done, call extension_stop to shut down the dev server and browser.",
1454
+ hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
1081
1455
  earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
1082
1456
  logPath
1083
1457
  });
@@ -1087,98 +1461,11 @@ function denoiseEarlyOutput(raw) {
1087
1461
  /^npm warn Unknown project config/i,
1088
1462
  /This will stop working in the next major version of npm/i,
1089
1463
  /^npm warn config/i,
1090
- /V8: .*Invalid asm\.js/i,
1091
- /^\(node:\d+\) V8:/i,
1092
- /Use `node --trace-warnings/i
1093
- ];
1094
- return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
1095
- }
1096
- function contractSightings(projectPath) {
1097
- const root = node_path.resolve(projectPath, "dist", "extension-js");
1098
- let dirs;
1099
- try {
1100
- dirs = node_fs.readdirSync(root);
1101
- } catch {
1102
- return [];
1103
- }
1104
- const sightings = [];
1105
- for (const dir of dirs){
1106
- const readyPath = node_path.join(root, dir, "ready.json");
1107
- try {
1108
- const stat = node_fs.statSync(readyPath);
1109
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1110
- if (contract?.status !== "ready") continue;
1111
- sightings.push({
1112
- browser: dir,
1113
- mtimeMs: stat.mtimeMs,
1114
- pid: "number" == typeof contract.pid ? contract.pid : void 0
1115
- });
1116
- } catch {}
1117
- }
1118
- return sightings;
1119
- }
1120
- function pidAlive(pid) {
1121
- try {
1122
- process.kill(pid, 0);
1123
- return true;
1124
- } catch {
1125
- return false;
1126
- }
1127
- }
1128
- function knownSessionBrowsers(projectPath) {
1129
- const resolved = node_path.resolve(projectPath);
1130
- const browsers = [];
1131
- for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
1132
- for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1133
- return Array.from(new Set(browsers));
1134
- }
1135
- function deadReadySession(projectPath) {
1136
- for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1137
- browser: sighting.browser,
1138
- pid: sighting.pid
1139
- };
1140
- return null;
1141
- }
1142
- function browserExitStamp(projectPath, browser, since) {
1143
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1144
- try {
1145
- const stat = node_fs.statSync(readyPath);
1146
- if (stat.mtimeMs < since) return null;
1147
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1148
- const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
1149
- if (contract?.status === "error" && exited) return {
1150
- code: contract.code,
1151
- browserExitCode: contract.browserExitCode ?? null,
1152
- browserExitedAt: contract.browserExitedAt
1153
- };
1154
- } catch {}
1155
- return null;
1156
- }
1157
- function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
1158
- if (explicit) return {
1159
- browser: explicit,
1160
- source: "explicit"
1161
- };
1162
- const resolved = node_path.resolve(projectPath);
1163
- const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1164
- if (mine.length > 0) return {
1165
- browser: mine[mine.length - 1].browser,
1166
- source: "session"
1167
- };
1168
- const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1169
- const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
1170
- if (live.length > 0) return {
1171
- browser: live[0].browser,
1172
- source: "contract"
1173
- };
1174
- if (sightings.length > 0) return {
1175
- browser: sightings[0].browser,
1176
- source: "stale"
1177
- };
1178
- return {
1179
- browser: fallback,
1180
- source: "fallback"
1181
- };
1464
+ /V8: .*Invalid asm\.js/i,
1465
+ /^\(node:\d+\) V8:/i,
1466
+ /Use `node --trace-warnings/i
1467
+ ];
1468
+ return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
1182
1469
  }
1183
1470
  const start_schema = {
1184
1471
  name: "extension_start",
@@ -1414,157 +1701,6 @@ async function preview_handler(args) {
1414
1701
  logPath
1415
1702
  });
1416
1703
  }
1417
- const stop_schema = {
1418
- name: "extension_stop",
1419
- description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
1420
- inputSchema: {
1421
- type: "object",
1422
- properties: {
1423
- projectPath: {
1424
- type: "string",
1425
- description: "Path to the extension project root"
1426
- },
1427
- browser: {
1428
- type: "string",
1429
- description: "Browser of the session to stop (matches the browser passed to extension_dev/extension_start). Defaults to the one live session for this project when omitted, instead of assuming chrome."
1430
- },
1431
- all: {
1432
- type: "boolean",
1433
- default: false,
1434
- description: "Stop every session this server started, across projects and browsers. When true, projectPath/browser are ignored."
1435
- }
1436
- },
1437
- required: []
1438
- }
1439
- };
1440
- function pgrepPids(pattern) {
1441
- try {
1442
- const out = execFileSync("pgrep", [
1443
- "-f",
1444
- pattern
1445
- ], {
1446
- encoding: "utf8"
1447
- });
1448
- return out.split("\n").map((s)=>parseInt(s.trim(), 10)).filter((n)=>Number.isInteger(n) && n > 0 && n !== process.pid);
1449
- } catch {
1450
- return [];
1451
- }
1452
- }
1453
- function sessionProcessPids(projectPath) {
1454
- const resolved = node_path.resolve(projectPath);
1455
- const pids = new Set();
1456
- for (const marker of [
1457
- `extension dev ${resolved}`,
1458
- node_path.join(resolved, "dist")
1459
- ])for (const pid of pgrepPids(marker))pids.add(pid);
1460
- return [
1461
- ...pids
1462
- ];
1463
- }
1464
- function reapSessionProcesses(projectPath) {
1465
- const pids = sessionProcessPids(projectPath);
1466
- for (const pid of pids)try {
1467
- process.kill(pid, "SIGKILL");
1468
- } catch {}
1469
- return pids;
1470
- }
1471
- function isAlive(pid) {
1472
- try {
1473
- process.kill(pid, 0);
1474
- return true;
1475
- } catch {
1476
- return false;
1477
- }
1478
- }
1479
- function stop_signal(pid, sig) {
1480
- try {
1481
- process.kill(-pid, sig);
1482
- return true;
1483
- } catch {
1484
- try {
1485
- process.kill(pid, sig);
1486
- return true;
1487
- } catch {
1488
- return false;
1489
- }
1490
- }
1491
- }
1492
- function readyJsonPath(projectPath, browser) {
1493
- return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1494
- }
1495
- function pidFromReadyContract(projectPath, browser) {
1496
- try {
1497
- const raw = node_fs.readFileSync(readyJsonPath(projectPath, browser), "utf8");
1498
- const contract = JSON.parse(raw);
1499
- return "number" == typeof contract.pid ? contract.pid : null;
1500
- } catch {
1501
- return null;
1502
- }
1503
- }
1504
- async function stopOne(projectPath, browser) {
1505
- const session = getSession(projectPath, browser);
1506
- const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
1507
- if (null == pid) {
1508
- const reaped = reapSessionProcesses(projectPath);
1509
- return {
1510
- projectPath,
1511
- browser,
1512
- pid: null,
1513
- stopped: 0 !== reaped.length,
1514
- reaped,
1515
- detail: 0 === reaped.length ? "No known session for this project/browser (nothing registered in this server and no ready.json contract found)." : `No dev pid on record, but reaped ${reaped.length} orphaned browser process(es) from the profile dir.`
1516
- };
1517
- }
1518
- let detail;
1519
- if (isAlive(pid)) {
1520
- stop_signal(pid, "SIGTERM");
1521
- await new Promise((resolve)=>setTimeout(resolve, 1500));
1522
- if (isAlive(pid)) {
1523
- stop_signal(pid, "SIGKILL");
1524
- await new Promise((resolve)=>setTimeout(resolve, 250));
1525
- }
1526
- detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
1527
- } else detail = "Process was already gone; cleaned up session records.";
1528
- const reaped = reapSessionProcesses(projectPath);
1529
- removeSession(projectPath, browser);
1530
- try {
1531
- node_fs.rmSync(readyJsonPath(projectPath, browser), {
1532
- force: true
1533
- });
1534
- } catch {}
1535
- const survivors = sessionProcessPids(projectPath);
1536
- const stopped = !isAlive(pid) && 0 === survivors.length;
1537
- if (survivors.length) detail += ` Warning: ${survivors.length} browser process(es) still alive after reap (pids ${survivors.join(", ")}).`;
1538
- else if (reaped.length) detail += ` Reaped ${reaped.length} browser process(es).`;
1539
- return {
1540
- projectPath,
1541
- browser,
1542
- pid,
1543
- stopped,
1544
- reaped,
1545
- detail
1546
- };
1547
- }
1548
- async function stop_handler(args) {
1549
- if (args.all) {
1550
- const sessions = listSessions();
1551
- if (0 === sessions.length) return JSON.stringify({
1552
- stopped: [],
1553
- message: "No sessions registered in this server."
1554
- });
1555
- const outcomes = [];
1556
- for (const s of sessions)outcomes.push(await stopOne(s.projectPath, s.browser));
1557
- return JSON.stringify({
1558
- stopped: outcomes
1559
- });
1560
- }
1561
- if (!args.projectPath) return JSON.stringify({
1562
- error: "projectPath is required unless all=true. Pass the same projectPath used with extension_dev/extension_start."
1563
- });
1564
- const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
1565
- const outcome = await stopOne(args.projectPath, browser);
1566
- return JSON.stringify(outcome);
1567
- }
1568
1704
  const RAW_BASE = "https://raw.githubusercontent.com/extension-js/examples/main/examples";
1569
1705
  const get_template_source_schema = {
1570
1706
  name: "extension_get_template_source",
@@ -1657,6 +1793,12 @@ function isChromiumFamily(browser) {
1657
1793
  function isGeckoFamily(browser) {
1658
1794
  return GECKO_FAMILY.has(browser);
1659
1795
  }
1796
+ const CHROME_DESKTOP_ONLY_KEYS = [
1797
+ "file_browser_handlers",
1798
+ "file_system_provider_capabilities",
1799
+ "input_components",
1800
+ "chrome_os_system_extension"
1801
+ ];
1660
1802
  const KNOWN_PERMISSIONS = new Set([
1661
1803
  "activeTab",
1662
1804
  "alarms",
@@ -2026,6 +2168,9 @@ async function manifest_validate_handler(args) {
2026
2168
  if (!perms.includes("sidePanel")) issues.push('Side panel declared but "sidePanel" permission is missing.');
2027
2169
  }
2028
2170
  if (manifest["firefox:browser_action"] && !effective.action) issues.push('Firefox browser_action found but no chromium:action. Chromium MV3 uses "action" instead of "browser_action".');
2171
+ if ("edge" === browser) {
2172
+ for (const key of CHROME_DESKTOP_ONLY_KEYS)if (void 0 !== effective[key]) result.warnings.push(`Manifest key "${key}" works on Chrome but is inert on Edge (it is a Chrome-only surface). The edge build ships it as a no-op; move it under "chromium:${key}" only if you also target Chrome, or remove it.`);
2173
+ }
2029
2174
  }
2030
2175
  if (isFirefox) {
2031
2176
  const contentScripts = effective.content_scripts;
@@ -4318,6 +4463,109 @@ async function publish(options = {}) {
4318
4463
  data
4319
4464
  };
4320
4465
  }
4466
+ const REGISTRY_BASE_DEFAULT = "https://registry.extension.land";
4467
+ const CONSOLE_BASE = "https://console.extension.dev";
4468
+ function registryBase() {
4469
+ const fromEnv = String(process.env.EXTENSION_DEV_REGISTRY_URL || "").trim();
4470
+ return (fromEnv || REGISTRY_BASE_DEFAULT).replace(/\/+$/, "");
4471
+ }
4472
+ function resolveProjectRef(overrides) {
4473
+ const workspace = String(overrides?.workspace || "").trim();
4474
+ const project = String(overrides?.project || "").trim();
4475
+ if (workspace && project) return {
4476
+ workspace,
4477
+ project
4478
+ };
4479
+ const creds = readCredentials();
4480
+ const ws = workspace || String(creds?.workspaceSlug || "").trim();
4481
+ const proj = project || String(creds?.projectSlug || "").trim();
4482
+ if (!ws || !proj) return null;
4483
+ return {
4484
+ workspace: ws,
4485
+ project: proj
4486
+ };
4487
+ }
4488
+ function registryFileUrl(ref, file) {
4489
+ return `${registryBase()}/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.project)}/_extension-dev/${file}`;
4490
+ }
4491
+ function consoleProjectUrl(ref, page) {
4492
+ if (!ref) return `${CONSOLE_BASE}`;
4493
+ return `${CONSOLE_BASE}/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.project)}/${page}`;
4494
+ }
4495
+ async function fetchRegistryJson(url, fetchImpl = fetch) {
4496
+ let res;
4497
+ try {
4498
+ res = await fetchImpl(url);
4499
+ } catch (err) {
4500
+ return {
4501
+ ok: false,
4502
+ message: `Could not reach ${url}: ${err?.message || err}`
4503
+ };
4504
+ }
4505
+ if (!res.ok) return {
4506
+ ok: false,
4507
+ status: res.status,
4508
+ message: `${url} returned ${res.status}`
4509
+ };
4510
+ try {
4511
+ const text = await res.text();
4512
+ return {
4513
+ ok: true,
4514
+ json: JSON.parse(text)
4515
+ };
4516
+ } catch {
4517
+ return {
4518
+ ok: false,
4519
+ message: `${url} did not return valid JSON`
4520
+ };
4521
+ }
4522
+ }
4523
+ function parseChannels(json) {
4524
+ if (!json || "object" != typeof json || Array.isArray(json)) return [];
4525
+ const out = [];
4526
+ for (const [channel, raw] of Object.entries(json)){
4527
+ if (!raw || "object" != typeof raw) continue;
4528
+ const row = raw;
4529
+ const description = "string" == typeof row.description ? row.description : void 0;
4530
+ const promotedAtField = "string" == typeof row.promotedAt && row.promotedAt ? row.promotedAt : void 0;
4531
+ const fromDescription = description?.match(/\bon (\d{4}-\d{2}-\d{2}T[0-9:.]+Z?)/)?.[1];
4532
+ const entry = {
4533
+ channel,
4534
+ sha: String(row.sha ?? "")
4535
+ };
4536
+ if (row.buildId) entry.buildId = String(row.buildId);
4537
+ if (row.version) entry.version = String(row.version);
4538
+ const promotedAt = promotedAtField || fromDescription;
4539
+ if (promotedAt) entry.promotedAt = promotedAt;
4540
+ if (description) entry.description = description;
4541
+ out.push(entry);
4542
+ }
4543
+ return out;
4544
+ }
4545
+ function parseBuildIndex(json) {
4546
+ const items = json?.items;
4547
+ if (!Array.isArray(items)) return [];
4548
+ const out = [];
4549
+ for (const raw of items){
4550
+ if (!raw || "object" != typeof raw) continue;
4551
+ const row = raw;
4552
+ const sha = String(row.shortSha ?? row.sha ?? row.id ?? row.buildId ?? "").trim();
4553
+ if (!sha) continue;
4554
+ const entry = {
4555
+ sha
4556
+ };
4557
+ if (row.commit) entry.commit = String(row.commit);
4558
+ if (row.channel) entry.channel = String(row.channel);
4559
+ if (row.buildEnv) entry.buildEnv = String(row.buildEnv);
4560
+ if (row.status) entry.status = String(row.status);
4561
+ if (row.version) entry.version = String(row.version);
4562
+ if ("string" == typeof row.message) entry.message = row.message.split("\n", 1)[0];
4563
+ if (row.timestamp) entry.timestamp = String(row.timestamp);
4564
+ if (Array.isArray(row.browsers)) entry.browsers = row.browsers.map((b)=>String(b)).filter(Boolean);
4565
+ out.push(entry);
4566
+ }
4567
+ return out;
4568
+ }
4321
4569
  const publish_schema = {
4322
4570
  name: "extension_publish",
4323
4571
  description: "Publish the project your stored token is scoped to (from extension_login, or EXTENSION_DEV_TOKEN) to extension.dev and return its shareable URL. The publish target is the token's project -- there is no projectPath, the local files are not uploaded. For a PUBLIC project the URL is the canonical public page and ttlHours does not apply; for a PRIVATE project it is a fresh time-limited share link (?share=) whose lifetime is ttlHours. Posts to the platform's CLI publish endpoint. Besides extension_login this is the only tool that talks to the hosted platform.",
@@ -4368,6 +4616,28 @@ async function publish_handler(args) {
4368
4616
  if (!result.ok) return JSON.stringify(result);
4369
4617
  const data = result.data;
4370
4618
  if (null != args.ttlHours && "public" === data.visibility) data.note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
4619
+ const ref = resolveProjectRef();
4620
+ if (ref) {
4621
+ const buildsUrl = registryFileUrl(ref, "builds/index.json");
4622
+ const buildsRes = await fetchRegistryJson(buildsUrl);
4623
+ if (buildsRes.ok) {
4624
+ const items = parseBuildIndex(buildsRes.json);
4625
+ const pinned = args.buildSha ? items.find((item)=>{
4626
+ const short = String(args.buildSha).slice(0, 7).toLowerCase();
4627
+ return item.sha.toLowerCase() === short || String(item.commit ?? "").toLowerCase().startsWith(short);
4628
+ }) : void 0;
4629
+ const newestSuccess = items.filter((item)=>"success" === item.status).sort((a, b)=>String(b.timestamp ?? "").localeCompare(String(a.timestamp ?? "")))[0];
4630
+ const served = pinned ?? newestSuccess;
4631
+ if (served) {
4632
+ if (null == data.buildSha) data.buildSha = served.sha;
4633
+ if (null == data.builtAt && served.timestamp) data.builtAt = served.timestamp;
4634
+ if (null == data.version && served.version) data.version = served.version;
4635
+ if (null == data.channel && served.channel) data.channel = served.channel;
4636
+ data.registryUrl = buildsUrl;
4637
+ if (!pinned && null == args.buildSha) data.buildNote = "buildSha/builtAt/version describe the newest successful build in the project's registry index, which is what the share link serves. Pin buildSha to serve a specific build.";
4638
+ }
4639
+ }
4640
+ }
4371
4641
  return JSON.stringify(data);
4372
4642
  }
4373
4643
  const release_promote_DEFAULT_API = "https://www.extension.dev";
@@ -4463,9 +4733,111 @@ async function release_promote_handler(args) {
4463
4733
  message: text
4464
4734
  };
4465
4735
  }
4466
- if (!res.ok) return release_promote_fail("ReleaseError", `promote failed (${res.status}): ${data?.message || text || "unknown error"}`);
4736
+ if (!res.ok) {
4737
+ const code = "string" == typeof data?.code ? data.code : void 0;
4738
+ const enrich = {};
4739
+ const ref = resolveProjectRef();
4740
+ if (404 === res.status || "UNKNOWN_BUILD" === code) {
4741
+ enrich.buildsPageUrl = consoleProjectUrl(ref, "builds");
4742
+ enrich.hint = "Run extension_release_list to see this project's channels, their promoted shas, and recent builds.";
4743
+ if (ref) {
4744
+ const channelsUrl = registryFileUrl(ref, "channels.json");
4745
+ const channelsRes = await fetchRegistryJson(channelsUrl);
4746
+ if (channelsRes.ok) {
4747
+ const rows = parseChannels(channelsRes.json).filter((c)=>c.sha);
4748
+ enrich.validChannelShas = Object.fromEntries(rows.map((c)=>[
4749
+ c.channel,
4750
+ c.sha
4751
+ ]));
4752
+ enrich.registryChannelsUrl = channelsUrl;
4753
+ }
4754
+ }
4755
+ }
4756
+ return JSON.stringify({
4757
+ ok: false,
4758
+ error: {
4759
+ name: "ReleaseError",
4760
+ message: `promote failed (${res.status}): ${data?.message || text || "unknown error"}`,
4761
+ ...code ? {
4762
+ code
4763
+ } : {}
4764
+ },
4765
+ ...enrich
4766
+ });
4767
+ }
4467
4768
  return JSON.stringify(data);
4468
4769
  }
4770
+ const release_list_schema = {
4771
+ name: "extension_release_list",
4772
+ description: "List the project's release channels (channel -> promoted build sha) and recent builds from the public registry (registry.extension.land), so you can pick a valid buildSha for extension_release_promote, extension_deploy, or extension_publish. Read-only, no dispatch. Defaults to the logged-in project (extension_login); pass workspace + project to inspect another public project. Also returns the registry URLs it read and the console Builds page URL.",
4773
+ inputSchema: {
4774
+ type: "object",
4775
+ properties: {
4776
+ workspace: {
4777
+ type: "string",
4778
+ description: "Workspace slug override (defaults to the stored login's workspace)."
4779
+ },
4780
+ project: {
4781
+ type: "string",
4782
+ description: "Project slug override (defaults to the stored login's project)."
4783
+ }
4784
+ },
4785
+ required: []
4786
+ }
4787
+ };
4788
+ function release_list_fail(name, message, extra) {
4789
+ return JSON.stringify({
4790
+ ok: false,
4791
+ error: {
4792
+ name,
4793
+ message
4794
+ },
4795
+ ...extra ?? {}
4796
+ });
4797
+ }
4798
+ async function release_list_handler(args) {
4799
+ const ref = resolveProjectRef(args);
4800
+ if (!ref) return release_list_fail("ReleaseListInputError", "No project to list. Run extension_login (the stored login names the project), or pass workspace + project explicitly.");
4801
+ const channelsUrl = registryFileUrl(ref, "channels.json");
4802
+ const metaUrl = registryFileUrl(ref, "meta.json");
4803
+ const buildsUrl = registryFileUrl(ref, "builds/index.json");
4804
+ const [channelsRes, metaRes, buildsRes] = await Promise.all([
4805
+ fetchRegistryJson(channelsUrl),
4806
+ fetchRegistryJson(metaUrl),
4807
+ fetchRegistryJson(buildsUrl)
4808
+ ]);
4809
+ const buildsPageUrl = consoleProjectUrl(ref, "builds");
4810
+ if (!channelsRes.ok && !metaRes.ok && !buildsRes.ok) return release_list_fail("ReleaseListNotFound", `No registry data for ${ref.workspace}/${ref.project} (${channelsUrl} returned ${channelsRes.status ?? "no response"}). The project may have no builds yet, be private (private registry data needs a share token), or the workspace/project slugs may be wrong. The console Builds page is the authoritative view: ${buildsPageUrl}`, {
4811
+ workspace: ref.workspace,
4812
+ project: ref.project,
4813
+ registryUrl: channelsUrl,
4814
+ buildsPageUrl
4815
+ });
4816
+ const channels = channelsRes.ok ? parseChannels(channelsRes.json) : [];
4817
+ const recentBuilds = buildsRes.ok ? parseBuildIndex(buildsRes.json) : [];
4818
+ recentBuilds.sort((a, b)=>String(b.timestamp ?? "").localeCompare(String(a.timestamp ?? "")));
4819
+ const meta = metaRes.ok ? metaRes.json : void 0;
4820
+ const promotable = Array.from(new Set(channels.map((c)=>c.sha).filter(Boolean)));
4821
+ const result = {
4822
+ ok: true,
4823
+ workspace: ref.workspace,
4824
+ project: ref.project,
4825
+ ...meta?.name ? {
4826
+ name: meta.name
4827
+ } : {},
4828
+ ...meta?.visibility ? {
4829
+ visibility: meta.visibility
4830
+ } : {},
4831
+ channels,
4832
+ recentBuilds,
4833
+ registryUrl: channelsUrl,
4834
+ buildsPageUrl,
4835
+ message: promotable.length > 0 || recentBuilds.length > 0 ? `Promotable shas: channels currently pin ${promotable.length > 0 ? promotable.join(", ") : "none"}; recent builds add ${recentBuilds.filter((b)=>"success" === b.status).map((b)=>b.sha).join(", ") || "none"}. Use one of these as buildId/buildSha for promote/deploy/publish.` : `No channels or builds are recorded on the registry yet for ${ref.workspace}/${ref.project}. Push a commit to produce a build, then check ${buildsPageUrl}.`
4836
+ };
4837
+ if (!channelsRes.ok) result.channelsUnavailable = `channels.json unreadable: ${channelsRes.message}`;
4838
+ if (!buildsRes.ok) result.buildsUnavailable = `builds/index.json unreadable: ${buildsRes.message}`;
4839
+ return JSON.stringify(result);
4840
+ }
4469
4841
  const deploy_DEFAULT_API = "https://www.extension.dev";
4470
4842
  function storeMdWarnings(browsers, cwd) {
4471
4843
  const wantsFirefox = browsers.includes("firefox");
@@ -4589,13 +4961,86 @@ async function deploy_handler(args) {
4589
4961
  message: text
4590
4962
  };
4591
4963
  }
4592
- if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
4593
- const warnings = storeMdWarnings(browsers, process.cwd());
4964
+ if (!res.ok) return deploy_fail("DeployError", `${dryRun ? "preflight" : "submit"} failed (${res.status}): ${data?.message || text || "unknown error"}`);
4965
+ const warnings = Array.isArray(data?.warnings) ? [
4966
+ ...data.warnings
4967
+ ] : [];
4968
+ warnings.push(...storeMdWarnings(browsers, process.cwd()));
4594
4969
  const result = {
4595
4970
  mode: "platform",
4596
4971
  dryRun,
4597
4972
  ...data
4598
4973
  };
4974
+ if (dryRun) {
4975
+ const ref = resolveProjectRef();
4976
+ const consoleStoresUrl = consoleProjectUrl(ref, "stores");
4977
+ const storeModeNote = `Store publish mode (draft / skip-publish / live) is not readable with the CLI token, so it cannot be verified from here; check per-store settings at ${consoleStoresUrl}.`;
4978
+ let health = null;
4979
+ let healthUnreadable = null;
4980
+ let channelRows = null;
4981
+ if (ref) {
4982
+ const [healthRes, channelsRes] = await Promise.all([
4983
+ fetchRegistryJson(registryFileUrl(ref, "stores/health.json")),
4984
+ fetchRegistryJson(registryFileUrl(ref, "channels.json"))
4985
+ ]);
4986
+ if (healthRes.ok) {
4987
+ const stores = healthRes.json?.stores;
4988
+ health = stores && "object" == typeof stores ? stores : null;
4989
+ if (!health) healthUnreadable = "stores/health.json had no stores map";
4990
+ } else healthUnreadable = healthRes.message;
4991
+ if (channelsRes.ok) channelRows = parseChannels(channelsRes.json);
4992
+ } else healthUnreadable = "no stored workspace/project to look up (run extension_login)";
4993
+ const preflight = browsers.map((browser)=>{
4994
+ if (!health) return {
4995
+ browser,
4996
+ ok: false,
4997
+ configured: "unknown",
4998
+ publishMode: "unknown",
4999
+ reason: `Store configuration could not be read (${healthUnreadable}); verify the ${browser} store in the console before submitting.`
5000
+ };
5001
+ const row = health[browser];
5002
+ if (!row) return {
5003
+ browser,
5004
+ ok: false,
5005
+ configured: false,
5006
+ publishMode: "unknown",
5007
+ reason: `No ${browser} store is configured on this project; a real submission for ${browser} would fail. Configure it at ${consoleStoresUrl}.`
5008
+ };
5009
+ if (true !== row.ok) return {
5010
+ browser,
5011
+ ok: false,
5012
+ configured: false,
5013
+ publishMode: "unknown",
5014
+ reason: String(row.message || "").trim() || `The ${browser} store failed its last credential health check.`
5015
+ };
5016
+ return {
5017
+ browser,
5018
+ ok: true,
5019
+ configured: true,
5020
+ publishMode: "unknown"
5021
+ };
5022
+ });
5023
+ const actionable = preflight.filter((p)=>p.ok).map((p)=>p.browser);
5024
+ const blocked = preflight.filter((p)=>!p.ok);
5025
+ const channelDefaulted = !String(args.channel || "").trim();
5026
+ const resolvedChannel = String(data?.channel || "").trim() || (channelDefaulted ? "stable" : String(args.channel).trim());
5027
+ if (channelRows) {
5028
+ const exists = channelRows.some((r)=>r.channel === resolvedChannel || r.channel.endsWith(`-${resolvedChannel}`));
5029
+ if (!exists) warnings.push(`Channel "${resolvedChannel}"${channelDefaulted ? " (the default)" : ""} does not exist in this project's channels.json (existing: ${channelRows.map((r)=>r.channel).join(", ") || "none"}), so a real submission from it has no promoted build to serve. Promote a build there first (extension_release_promote) or pass an existing channel.`);
5030
+ }
5031
+ const summaryParts = [];
5032
+ if (actionable.length > 0) summaryParts.push(`Preflight passed for ${actionable.join(", ")}: the platform verified auth, the project, build ${data?.buildId ?? buildSha}, and the store workflow, and the store credentials passed their last health check.`);
5033
+ for (const p of blocked)summaryParts.push(`${p.browser}: ${"unknown" === p.configured ? "cannot be verified" : "NOT actionable"} - ${p.reason}`);
5034
+ summaryParts.push(storeModeNote);
5035
+ result.ok = actionable.length > 0;
5036
+ result.preflight = preflight;
5037
+ result.channel = resolvedChannel;
5038
+ result.channelDefaulted = channelDefaulted;
5039
+ if (channelDefaulted) result.channelNote = `channel: ${resolvedChannel} (default)`;
5040
+ result.consoleStoresUrl = consoleStoresUrl;
5041
+ if ("string" == typeof data?.message) result.platformMessage = data.message;
5042
+ result.message = summaryParts.join(" ");
5043
+ }
4599
5044
  if (warnings.length > 0) result.warnings = warnings;
4600
5045
  return JSON.stringify(result);
4601
5046
  }
@@ -5513,18 +5958,23 @@ async function whoami_handler() {
5513
5958
  }
5514
5959
  const logout_schema = {
5515
5960
  name: "extension_logout",
5516
- description: "Delete the locally stored extension.dev credentials. Does not revoke the token server-side (revoke from the dashboard if needed); only removes it from this machine.",
5961
+ description: "Delete the locally stored extension.dev credentials. Does not revoke the token server-side (the response includes the dashboard URL where the token can be revoked); only removes it from this machine.",
5517
5962
  inputSchema: {
5518
5963
  type: "object",
5519
5964
  properties: {}
5520
5965
  }
5521
5966
  };
5522
5967
  async function logout_handler() {
5968
+ const creds = readCredentials();
5969
+ const revokeUrl = creds?.workspaceSlug && creds?.projectSlug ? `https://console.extension.dev/${creds.workspaceSlug}/${creds.projectSlug}/settings/access-tokens` : null;
5523
5970
  const result = clearCredentials();
5524
5971
  return JSON.stringify({
5525
5972
  ok: true,
5526
5973
  cleared: result.cleared,
5527
- message: result.cleared ? "Local credentials removed." : "No stored credentials to remove."
5974
+ ...result.cleared && revokeUrl ? {
5975
+ revokeUrl
5976
+ } : {},
5977
+ message: result.cleared ? revokeUrl ? `Local credentials removed. The token stays valid server-side until it expires; revoke it now at ${revokeUrl} (takes about a minute to propagate).` : "Local credentials removed. The token stays valid server-side until it expires; revoke it from the project's access-tokens page if needed." : "No stored credentials to remove."
5528
5978
  });
5529
5979
  }
5530
5980
  const install_browser_schema = {
@@ -6131,13 +6581,33 @@ function validateToolInput(inputSchema, args) {
6131
6581
  }
6132
6582
  return issues;
6133
6583
  }
6134
- function inputValidationError(toolName, issues) {
6584
+ function describeToolArgs(inputSchema) {
6585
+ const schema = inputSchema;
6586
+ const props = schema.properties ?? {};
6587
+ const required = schema.required ?? [];
6588
+ const optional = Object.keys(props).filter((k)=>!required.includes(k));
6589
+ const aliases = {};
6590
+ for (const [canonical, list] of Object.entries(ARG_ALIASES)){
6591
+ if (!(canonical in props)) continue;
6592
+ const usable = list.filter((alias)=>!(alias in props));
6593
+ if (usable.length) aliases[canonical] = usable;
6594
+ }
6595
+ return {
6596
+ required,
6597
+ optional,
6598
+ aliases
6599
+ };
6600
+ }
6601
+ function inputValidationError(toolName, issues, inputSchema) {
6135
6602
  return JSON.stringify({
6136
6603
  ok: false,
6137
6604
  error: {
6138
6605
  name: "InputValidationError",
6139
6606
  message: `Invalid arguments for ${toolName}: ${issues.map((i)=>`${i.path}: ${i.message}`).join("; ")}`,
6140
- issues
6607
+ issues,
6608
+ ...inputSchema ? {
6609
+ args: describeToolArgs(inputSchema)
6610
+ } : {}
6141
6611
  }
6142
6612
  });
6143
6613
  }
@@ -6161,6 +6631,7 @@ const tools = [
6161
6631
  open_namespaceObject,
6162
6632
  dom_inspect_namespaceObject,
6163
6633
  tools_publish_namespaceObject,
6634
+ release_list_namespaceObject,
6164
6635
  release_promote_namespaceObject,
6165
6636
  deploy_namespaceObject,
6166
6637
  wait_namespaceObject,
@@ -6213,7 +6684,7 @@ async function startServer() {
6213
6684
  content: [
6214
6685
  {
6215
6686
  type: "text",
6216
- text: inputValidationError(name, issues)
6687
+ text: inputValidationError(name, issues, tool.schema.inputSchema)
6217
6688
  }
6218
6689
  ],
6219
6690
  isError: true