@extension.dev/mcp 5.2.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.2.0","El":{"OP":"^4.0.13"}}');
213
+ var package_namespaceObject = JSON.parse('{"rE":"5.3.1","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,216 +898,59 @@ 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
  }
889
- function registerSession(info) {
890
- process_manager_sessions.set(sessionKey(info.projectPath, info.browser), info);
891
- }
892
- function getSession(projectPath, browser) {
893
- return process_manager_sessions.get(sessionKey(projectPath, browser));
905
+ function markerDir() {
906
+ return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
894
907
  }
895
- function removeSession(projectPath, browser) {
896
- process_manager_sessions.delete(sessionKey(projectPath, browser));
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`);
897
911
  }
898
- function listSessions() {
899
- return Array.from(process_manager_sessions.values());
912
+ function removeSessionMarker(projectPath, browser) {
913
+ try {
914
+ node_fs.rmSync(markerPath(projectPath, browser), {
915
+ force: true
916
+ });
917
+ } catch {}
900
918
  }
901
- const LAUNCH_FLAG_SCHEMA = {
902
- profile: {
903
- type: "string",
904
- description: 'Browser profile path, or "false" to reuse the default user profile. Omit for a fresh throwaway profile.'
905
- },
906
- startingUrl: {
907
- type: "string",
908
- description: "URL the browser opens on launch"
909
- },
910
- chromiumBinary: {
911
- type: "string",
912
- description: "Path to a custom Chromium-based binary (overrides browser)"
913
- },
914
- geckoBinary: {
915
- type: "string",
916
- description: "Path to a custom Gecko/Firefox binary (overrides browser)"
917
- },
918
- host: {
919
- type: "string",
920
- description: "Dev server bind host. Use 0.0.0.0 for Docker or devcontainers. Defaults to 127.0.0.1"
921
- },
922
- publicHost: {
923
- type: "string",
924
- description: "Connectable host the browser dials for HMR and reload when it differs from the bind host"
925
- },
926
- extensions: {
927
- type: "array",
928
- items: {
929
- type: "string"
930
- },
931
- description: "Companion extension paths or store URLs to load alongside the project"
919
+ function listSessionMarkers() {
920
+ let files;
921
+ try {
922
+ files = node_fs.readdirSync(markerDir());
923
+ } catch {
924
+ return [];
932
925
  }
933
- };
934
- function launchFlagArgs(args) {
935
- const cli = [];
936
- if (void 0 !== args.profile) cli.push("--profile", args.profile);
937
- if (args.startingUrl) cli.push("--starting-url", args.startingUrl);
938
- if (args.chromiumBinary) cli.push("--chromium-binary", args.chromiumBinary);
939
- if (args.geckoBinary) cli.push("--gecko-binary", args.geckoBinary);
940
- if (args.host) cli.push("--host", args.host);
941
- if (args.publicHost) cli.push("--public-host", args.publicHost);
942
- if (args.extensions?.length) cli.push("--extensions", args.extensions.join(","));
943
- return cli;
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;
944
932
  }
945
- const dev_schema = {
946
- name: "extension_dev",
947
- description: "Start the extension development server with hot module replacement. Launches a browser with the extension loaded. Returns process info for use with extension_wait and extension_source_inspect.",
948
- inputSchema: {
949
- type: "object",
950
- properties: {
951
- projectPath: {
952
- type: "string",
953
- description: "Path to the extension project root"
954
- },
955
- browser: {
956
- type: "string",
957
- enum: [
958
- "chrome",
959
- "chromium",
960
- "edge",
961
- "brave",
962
- "opera",
963
- "vivaldi",
964
- "yandex",
965
- "firefox",
966
- "waterfox",
967
- "librewolf",
968
- "safari",
969
- "chromium-based",
970
- "gecko-based",
971
- "firefox-based",
972
- "webkit-based"
973
- ],
974
- default: "chrome"
975
- },
976
- port: {
977
- type: "number",
978
- description: "Dev server port (0 for auto-assign)"
979
- },
980
- noBrowser: {
981
- type: "boolean",
982
- default: false,
983
- description: "Start dev server without launching browser"
984
- },
985
- polyfill: {
986
- type: "boolean",
987
- default: true,
988
- description: "Apply cross-browser polyfill"
989
- },
990
- ...LAUNCH_FLAG_SCHEMA,
991
- allowControl: {
992
- type: "boolean",
993
- default: false,
994
- description: "Enable the agent-bridge control channel so extension_storage/reload/open/dom_inspect work against this session"
995
- },
996
- allowEval: {
997
- type: "boolean",
998
- default: false,
999
- description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect. You do not need to pass both."
1000
- }
1001
- },
1002
- required: [
1003
- "projectPath"
1004
- ]
1005
- }
1006
- };
1007
- async function dev_handler(args) {
1008
- const browser = args.browser ?? "chrome";
1009
- const allowControl = Boolean(args.allowControl || args.allowEval);
1010
- const cliArgs = [
1011
- "dev",
1012
- args.projectPath,
1013
- "--browser",
1014
- browser
1015
- ];
1016
- if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
1017
- if (args.noBrowser) cliArgs.push("--no-browser");
1018
- if (false === args.polyfill) cliArgs.push("--polyfill", "false");
1019
- cliArgs.push(...launchFlagArgs(args));
1020
- if (allowControl) cliArgs.push("--allow-control");
1021
- if (args.allowEval) cliArgs.push("--allow-eval");
1022
- const spawned = spawnExtensionCli(cliArgs, {
1023
- projectDir: args.projectPath
1024
- });
1025
- const { child, logPath } = spawned;
1026
- const pid = child.pid;
1027
- registerSession({
1028
- pid,
1029
- browser,
1030
- port: args.port,
1031
- projectPath: args.projectPath,
1032
- command: "dev"
1033
- });
1034
- child.on("exit", ()=>removeSession(args.projectPath, browser));
1035
- await new Promise((resolve)=>setTimeout(resolve, 3000));
1036
- const earlyOutput = spawned.readOutput();
1037
- if (null !== child.exitCode || null !== child.signalCode) {
1038
- const code = child.exitCode;
1039
- const signal = child.signalCode;
1040
- return JSON.stringify({
1041
- ok: false,
1042
- status: "exited",
1043
- projectPath: args.projectPath,
1044
- browser,
1045
- pid,
1046
- exitCode: code,
1047
- signal,
1048
- error: `The dev server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running, so extension_logs/wait/eval and the control verbs have nothing to attach to.`,
1049
- output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1050
- logPath,
1051
- hint: "Read `output` above for the cause: a port already in use, a manifest the build rejects, or a missing browser binary are the common ones. Fix it and call extension_dev again; extension_doctor with this projectPath will also report what the last session recorded."
933
+ function registerSession(info) {
934
+ sessions.set(sessionKey(info.projectPath, info.browser), info);
935
+ try {
936
+ node_fs.mkdirSync(markerDir(), {
937
+ recursive: true
1052
938
  });
1053
- }
1054
- const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(earlyOutput);
1055
- if (compileFailed) return JSON.stringify({
1056
- ok: false,
1057
- status: "compile-failed",
1058
- projectPath: args.projectPath,
1059
- browser,
1060
- pid,
1061
- error: "The dev server started but the FIRST COMPILE FAILED, so the browser has nothing usable to load. The session is running; the extension is not.",
1062
- output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1063
- logPath,
1064
- hint: "Fix the compile error in `output` above and save: the dev server is still running and will recompile. Do not call extension_wait yet, it will report ready for a build that failed."
1065
- });
1066
- const controlVerbs = "storage, reload, open, dom_inspect";
1067
- const capabilities = {
1068
- allowControl,
1069
- allowEval: Boolean(args.allowEval),
1070
- unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
1071
- };
1072
- return JSON.stringify({
1073
- ok: true,
1074
- pid,
1075
- browser,
1076
- port: args.port ?? 8080,
1077
- projectPath: args.projectPath,
1078
- status: "started",
1079
- 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.",
1081
- earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
1082
- logPath
1083
- });
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 {}
1084
945
  }
1085
- function denoiseEarlyOutput(raw) {
1086
- const NOISE = [
1087
- /^npm warn Unknown project config/i,
1088
- /This will stop working in the next major version of npm/i,
1089
- /^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();
946
+ function getSession(projectPath, browser) {
947
+ return sessions.get(sessionKey(projectPath, browser));
948
+ }
949
+ function removeSession(projectPath, browser) {
950
+ sessions.delete(sessionKey(projectPath, browser));
951
+ }
952
+ function listSessions() {
953
+ return Array.from(sessions.values());
1095
954
  }
1096
955
  function contractSightings(projectPath) {
1097
956
  const root = node_path.resolve(projectPath, "dist", "extension-js");
@@ -1132,6 +991,27 @@ function knownSessionBrowsers(projectPath) {
1132
991
  for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1133
992
  return Array.from(new Set(browsers));
1134
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
+ }
1135
1015
  function deadReadySession(projectPath) {
1136
1016
  for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1137
1017
  browser: sighting.browser,
@@ -1180,9 +1060,9 @@ function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
1180
1060
  source: "fallback"
1181
1061
  };
1182
1062
  }
1183
- const start_schema = {
1184
- name: "extension_start",
1185
- description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
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.",
1186
1066
  inputSchema: {
1187
1067
  type: "object",
1188
1068
  properties: {
@@ -1192,58 +1072,303 @@ const start_schema = {
1192
1072
  },
1193
1073
  browser: {
1194
1074
  type: "string",
1195
- enum: [
1196
- "chrome",
1197
- "chromium",
1198
- "edge",
1199
- "brave",
1200
- "opera",
1201
- "vivaldi",
1202
- "yandex",
1203
- "firefox",
1204
- "waterfox",
1205
- "librewolf",
1206
- "safari",
1207
- "chromium-based",
1208
- "gecko-based",
1209
- "firefox-based",
1210
- "webkit-based"
1211
- ],
1212
- default: "chrome"
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."
1213
1076
  },
1214
- polyfill: {
1077
+ all: {
1215
1078
  type: "boolean",
1216
- default: true,
1217
- description: "Apply cross-browser polyfill"
1218
- },
1219
- port: {
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);
1220
+ }
1221
+ const LAUNCH_FLAG_SCHEMA = {
1222
+ profile: {
1223
+ type: "string",
1224
+ description: 'Browser profile path, or "false" to reuse the default user profile. Omit for a fresh throwaway profile.'
1225
+ },
1226
+ startingUrl: {
1227
+ type: "string",
1228
+ description: "URL the browser opens on launch"
1229
+ },
1230
+ chromiumBinary: {
1231
+ type: "string",
1232
+ description: "Path to a custom Chromium-based binary (overrides browser)"
1233
+ },
1234
+ geckoBinary: {
1235
+ type: "string",
1236
+ description: "Path to a custom Gecko/Firefox binary (overrides browser)"
1237
+ },
1238
+ host: {
1239
+ type: "string",
1240
+ description: "Dev server bind host. Use 0.0.0.0 for Docker or devcontainers. Defaults to 127.0.0.1"
1241
+ },
1242
+ publicHost: {
1243
+ type: "string",
1244
+ description: "Connectable host the browser dials for HMR and reload when it differs from the bind host"
1245
+ },
1246
+ extensions: {
1247
+ type: "array",
1248
+ items: {
1249
+ type: "string"
1250
+ },
1251
+ description: "Companion extension paths or store URLs to load alongside the project"
1252
+ }
1253
+ };
1254
+ function launchFlagArgs(args) {
1255
+ const cli = [];
1256
+ if (void 0 !== args.profile) cli.push("--profile", args.profile);
1257
+ if (args.startingUrl) cli.push("--starting-url", args.startingUrl);
1258
+ if (args.chromiumBinary) cli.push("--chromium-binary", args.chromiumBinary);
1259
+ if (args.geckoBinary) cli.push("--gecko-binary", args.geckoBinary);
1260
+ if (args.host) cli.push("--host", args.host);
1261
+ if (args.publicHost) cli.push("--public-host", args.publicHost);
1262
+ if (args.extensions?.length) cli.push("--extensions", args.extensions.join(","));
1263
+ return cli;
1264
+ }
1265
+ const dev_schema = {
1266
+ name: "extension_dev",
1267
+ description: "Start the extension development server with hot module replacement. Launches a browser with the extension loaded. Returns process info for use with extension_wait and extension_source_inspect.",
1268
+ inputSchema: {
1269
+ type: "object",
1270
+ properties: {
1271
+ projectPath: {
1272
+ type: "string",
1273
+ description: "Path to the extension project root"
1274
+ },
1275
+ browser: {
1276
+ type: "string",
1277
+ enum: [
1278
+ "chrome",
1279
+ "chromium",
1280
+ "edge",
1281
+ "brave",
1282
+ "opera",
1283
+ "vivaldi",
1284
+ "yandex",
1285
+ "firefox",
1286
+ "waterfox",
1287
+ "librewolf",
1288
+ "safari",
1289
+ "chromium-based",
1290
+ "gecko-based",
1291
+ "firefox-based",
1292
+ "webkit-based"
1293
+ ],
1294
+ default: "chrome"
1295
+ },
1296
+ port: {
1220
1297
  type: "number",
1221
- description: "Server port (0 for auto-assign)"
1298
+ description: "Dev server port (0 for auto-assign)"
1222
1299
  },
1223
1300
  noBrowser: {
1224
1301
  type: "boolean",
1225
1302
  default: false,
1226
- description: "Build and serve without launching a browser"
1303
+ description: "Start dev server without launching browser"
1227
1304
  },
1228
- ...LAUNCH_FLAG_SCHEMA
1305
+ polyfill: {
1306
+ type: "boolean",
1307
+ default: true,
1308
+ description: "Apply cross-browser polyfill"
1309
+ },
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
+ },
1316
+ allowControl: {
1317
+ type: "boolean",
1318
+ default: false,
1319
+ description: "Enable the agent-bridge control channel so extension_storage/reload/open/dom_inspect work against this session"
1320
+ },
1321
+ allowEval: {
1322
+ type: "boolean",
1323
+ default: false,
1324
+ description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect. You do not need to pass both."
1325
+ }
1229
1326
  },
1230
1327
  required: [
1231
1328
  "projectPath"
1232
1329
  ]
1233
1330
  }
1234
1331
  };
1235
- async function start_handler(args) {
1332
+ async function dev_handler(args) {
1236
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
+ }
1359
+ const allowControl = Boolean(args.allowControl || args.allowEval);
1237
1360
  const cliArgs = [
1238
- "start",
1361
+ "dev",
1239
1362
  args.projectPath,
1240
1363
  "--browser",
1241
1364
  browser
1242
1365
  ];
1243
- if (false === args.polyfill) cliArgs.push("--polyfill", "false");
1244
1366
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
1245
1367
  if (args.noBrowser) cliArgs.push("--no-browser");
1368
+ if (false === args.polyfill) cliArgs.push("--polyfill", "false");
1246
1369
  cliArgs.push(...launchFlagArgs(args));
1370
+ if (allowControl) cliArgs.push("--allow-control");
1371
+ if (args.allowEval) cliArgs.push("--allow-eval");
1247
1372
  const spawnedAt = Date.now();
1248
1373
  const spawned = spawnExtensionCli(cliArgs, {
1249
1374
  projectDir: args.projectPath
@@ -1253,11 +1378,12 @@ async function start_handler(args) {
1253
1378
  registerSession({
1254
1379
  pid,
1255
1380
  browser,
1381
+ port: args.port,
1256
1382
  projectPath: args.projectPath,
1257
- command: "start"
1383
+ command: "dev"
1258
1384
  });
1259
1385
  child.on("exit", ()=>removeSession(args.projectPath, browser));
1260
- await new Promise((resolve)=>setTimeout(resolve, 5000));
1386
+ await new Promise((resolve)=>setTimeout(resolve, 3000));
1261
1387
  const earlyOutput = spawned.readOutput();
1262
1388
  if (null !== child.exitCode || null !== child.signalCode) {
1263
1389
  const code = child.exitCode;
@@ -1270,39 +1396,80 @@ async function start_handler(args) {
1270
1396
  pid,
1271
1397
  exitCode: code,
1272
1398
  signal,
1273
- error: `The preview server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`,
1274
- output: earlyOutput.slice(0, 2000),
1399
+ error: `The dev server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running, so extension_logs/wait/eval and the control verbs have nothing to attach to.`,
1400
+ output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1275
1401
  logPath,
1276
- hint: "Read `output` above for the cause: a failed production build, a port already in use, or a missing browser binary are the common ones. extension_build will surface a build error on its own."
1402
+ hint: "Read `output` above for the cause: a port already in use, a manifest the build rejects, or a missing browser binary are the common ones. Fix it and call extension_dev again; extension_doctor with this projectPath will also report what the last session recorded."
1277
1403
  });
1278
1404
  }
1279
- const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
1280
- if (exitStamp) return JSON.stringify({
1405
+ const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(earlyOutput);
1406
+ if (compileFailed) return JSON.stringify({
1281
1407
  ok: false,
1282
- status: "browser-exited",
1408
+ status: "compile-failed",
1283
1409
  projectPath: args.projectPath,
1284
1410
  browser,
1285
1411
  pid,
1286
- ...exitStamp,
1287
- error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
1288
- output: earlyOutput.slice(0, 2000),
1412
+ error: "The dev server started but the FIRST COMPILE FAILED, so the browser has nothing usable to load. The session is running; the extension is not.",
1413
+ output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1289
1414
  logPath,
1290
- hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
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."
1291
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
+ }
1434
+ const controlVerbs = "storage, reload, open, dom_inspect";
1435
+ const capabilities = {
1436
+ allowControl,
1437
+ allowEval: Boolean(args.allowEval),
1438
+ unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
1439
+ };
1292
1440
  return JSON.stringify({
1293
1441
  ok: true,
1294
1442
  pid,
1295
1443
  browser,
1444
+ port: args.port ?? 8080,
1296
1445
  projectPath: args.projectPath,
1297
1446
  status: "started",
1298
- hint: "Use extension_wait to check when the build and browser launch are complete. When you are done, call extension_stop to shut down the session.",
1299
- earlyOutput: earlyOutput.slice(0, 500),
1447
+ ...replaced.length > 0 ? {
1448
+ replacedSession: replaced[0],
1449
+ ...replaced.length > 1 ? {
1450
+ replacedSessions: replaced
1451
+ } : {}
1452
+ } : {},
1453
+ capabilities,
1454
+ hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
1455
+ earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
1300
1456
  logPath
1301
1457
  });
1302
1458
  }
1303
- const preview_schema = {
1304
- name: "extension_preview",
1305
- description: "Preview a production-built extension in a browser. Uses dist/ output directly. The extension must be built first with extension_build.",
1459
+ function denoiseEarlyOutput(raw) {
1460
+ const NOISE = [
1461
+ /^npm warn Unknown project config/i,
1462
+ /This will stop working in the next major version of npm/i,
1463
+ /^npm warn config/i,
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();
1469
+ }
1470
+ const start_schema = {
1471
+ name: "extension_start",
1472
+ description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
1306
1473
  inputSchema: {
1307
1474
  type: "object",
1308
1475
  properties: {
@@ -1331,6 +1498,11 @@ const preview_schema = {
1331
1498
  ],
1332
1499
  default: "chrome"
1333
1500
  },
1501
+ polyfill: {
1502
+ type: "boolean",
1503
+ default: true,
1504
+ description: "Apply cross-browser polyfill"
1505
+ },
1334
1506
  port: {
1335
1507
  type: "number",
1336
1508
  description: "Server port (0 for auto-assign)"
@@ -1338,7 +1510,7 @@ const preview_schema = {
1338
1510
  noBrowser: {
1339
1511
  type: "boolean",
1340
1512
  default: false,
1341
- description: "Serve the preview without launching a browser"
1513
+ description: "Build and serve without launching a browser"
1342
1514
  },
1343
1515
  ...LAUNCH_FLAG_SCHEMA
1344
1516
  },
@@ -1347,14 +1519,15 @@ const preview_schema = {
1347
1519
  ]
1348
1520
  }
1349
1521
  };
1350
- async function preview_handler(args) {
1522
+ async function start_handler(args) {
1351
1523
  const browser = args.browser ?? "chrome";
1352
1524
  const cliArgs = [
1353
- "preview",
1525
+ "start",
1354
1526
  args.projectPath,
1355
1527
  "--browser",
1356
1528
  browser
1357
1529
  ];
1530
+ if (false === args.polyfill) cliArgs.push("--polyfill", "false");
1358
1531
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
1359
1532
  if (args.noBrowser) cliArgs.push("--no-browser");
1360
1533
  cliArgs.push(...launchFlagArgs(args));
@@ -1368,7 +1541,7 @@ async function preview_handler(args) {
1368
1541
  pid,
1369
1542
  browser,
1370
1543
  projectPath: args.projectPath,
1371
- command: "preview"
1544
+ command: "start"
1372
1545
  });
1373
1546
  child.on("exit", ()=>removeSession(args.projectPath, browser));
1374
1547
  await new Promise((resolve)=>setTimeout(resolve, 5000));
@@ -1384,186 +1557,149 @@ async function preview_handler(args) {
1384
1557
  pid,
1385
1558
  exitCode: code,
1386
1559
  signal,
1387
- error: `The preview process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). Nothing is running.`,
1560
+ error: `The preview server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`,
1388
1561
  output: earlyOutput.slice(0, 2000),
1389
1562
  logPath,
1390
- hint: "Read `output` above for the cause: a missing or broken dist/ (run extension_build first), or a missing browser binary are the common ones."
1563
+ hint: "Read `output` above for the cause: a failed production build, a port already in use, or a missing browser binary are the common ones. extension_build will surface a build error on its own."
1391
1564
  });
1392
1565
  }
1393
1566
  const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
1394
1567
  if (exitStamp) return JSON.stringify({
1395
1568
  ok: false,
1396
- status: "browser-exited",
1397
- projectPath: args.projectPath,
1398
- browser,
1399
- pid,
1400
- ...exitStamp,
1401
- error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
1402
- output: earlyOutput.slice(0, 2000),
1403
- logPath,
1404
- hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
1405
- });
1406
- return JSON.stringify({
1407
- ok: true,
1408
- pid,
1409
- browser,
1410
- projectPath: args.projectPath,
1411
- status: "launched",
1412
- hint: "Call extension_stop when you are done to close the preview browser.",
1413
- earlyOutput: earlyOutput.slice(0, 500),
1414
- logPath
1415
- });
1416
- }
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,
1569
+ status: "browser-exited",
1570
+ projectPath: args.projectPath,
1541
1571
  browser,
1542
1572
  pid,
1543
- stopped,
1544
- reaped,
1545
- detail
1546
- };
1573
+ ...exitStamp,
1574
+ error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
1575
+ output: earlyOutput.slice(0, 2000),
1576
+ logPath,
1577
+ hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
1578
+ });
1579
+ return JSON.stringify({
1580
+ ok: true,
1581
+ pid,
1582
+ browser,
1583
+ projectPath: args.projectPath,
1584
+ status: "started",
1585
+ hint: "Use extension_wait to check when the build and browser launch are complete. When you are done, call extension_stop to shut down the session.",
1586
+ earlyOutput: earlyOutput.slice(0, 500),
1587
+ logPath
1588
+ });
1547
1589
  }
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));
1590
+ const preview_schema = {
1591
+ name: "extension_preview",
1592
+ description: "Preview a production-built extension in a browser. Uses dist/ output directly. The extension must be built first with extension_build.",
1593
+ inputSchema: {
1594
+ type: "object",
1595
+ properties: {
1596
+ projectPath: {
1597
+ type: "string",
1598
+ description: "Path to the extension project root"
1599
+ },
1600
+ browser: {
1601
+ type: "string",
1602
+ enum: [
1603
+ "chrome",
1604
+ "chromium",
1605
+ "edge",
1606
+ "brave",
1607
+ "opera",
1608
+ "vivaldi",
1609
+ "yandex",
1610
+ "firefox",
1611
+ "waterfox",
1612
+ "librewolf",
1613
+ "safari",
1614
+ "chromium-based",
1615
+ "gecko-based",
1616
+ "firefox-based",
1617
+ "webkit-based"
1618
+ ],
1619
+ default: "chrome"
1620
+ },
1621
+ port: {
1622
+ type: "number",
1623
+ description: "Server port (0 for auto-assign)"
1624
+ },
1625
+ noBrowser: {
1626
+ type: "boolean",
1627
+ default: false,
1628
+ description: "Serve the preview without launching a browser"
1629
+ },
1630
+ ...LAUNCH_FLAG_SCHEMA
1631
+ },
1632
+ required: [
1633
+ "projectPath"
1634
+ ]
1635
+ }
1636
+ };
1637
+ async function preview_handler(args) {
1638
+ const browser = args.browser ?? "chrome";
1639
+ const cliArgs = [
1640
+ "preview",
1641
+ args.projectPath,
1642
+ "--browser",
1643
+ browser
1644
+ ];
1645
+ if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
1646
+ if (args.noBrowser) cliArgs.push("--no-browser");
1647
+ cliArgs.push(...launchFlagArgs(args));
1648
+ const spawnedAt = Date.now();
1649
+ const spawned = spawnExtensionCli(cliArgs, {
1650
+ projectDir: args.projectPath
1651
+ });
1652
+ const { child, logPath } = spawned;
1653
+ const pid = child.pid;
1654
+ registerSession({
1655
+ pid,
1656
+ browser,
1657
+ projectPath: args.projectPath,
1658
+ command: "preview"
1659
+ });
1660
+ child.on("exit", ()=>removeSession(args.projectPath, browser));
1661
+ await new Promise((resolve)=>setTimeout(resolve, 5000));
1662
+ const earlyOutput = spawned.readOutput();
1663
+ if (null !== child.exitCode || null !== child.signalCode) {
1664
+ const code = child.exitCode;
1665
+ const signal = child.signalCode;
1557
1666
  return JSON.stringify({
1558
- stopped: outcomes
1667
+ ok: false,
1668
+ status: "exited",
1669
+ projectPath: args.projectPath,
1670
+ browser,
1671
+ pid,
1672
+ exitCode: code,
1673
+ signal,
1674
+ error: `The preview process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). Nothing is running.`,
1675
+ output: earlyOutput.slice(0, 2000),
1676
+ logPath,
1677
+ hint: "Read `output` above for the cause: a missing or broken dist/ (run extension_build first), or a missing browser binary are the common ones."
1559
1678
  });
1560
1679
  }
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."
1680
+ const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
1681
+ if (exitStamp) return JSON.stringify({
1682
+ ok: false,
1683
+ status: "browser-exited",
1684
+ projectPath: args.projectPath,
1685
+ browser,
1686
+ pid,
1687
+ ...exitStamp,
1688
+ error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
1689
+ output: earlyOutput.slice(0, 2000),
1690
+ logPath,
1691
+ hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
1692
+ });
1693
+ return JSON.stringify({
1694
+ ok: true,
1695
+ pid,
1696
+ browser,
1697
+ projectPath: args.projectPath,
1698
+ status: "launched",
1699
+ hint: "Call extension_stop when you are done to close the preview browser.",
1700
+ earlyOutput: earlyOutput.slice(0, 500),
1701
+ logPath
1563
1702
  });
1564
- const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
1565
- const outcome = await stopOne(args.projectPath, browser);
1566
- return JSON.stringify(outcome);
1567
1703
  }
1568
1704
  const RAW_BASE = "https://raw.githubusercontent.com/extension-js/examples/main/examples";
1569
1705
  const get_template_source_schema = {
@@ -4327,6 +4463,109 @@ async function publish(options = {}) {
4327
4463
  data
4328
4464
  };
4329
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
+ }
4330
4569
  const publish_schema = {
4331
4570
  name: "extension_publish",
4332
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.",
@@ -4377,6 +4616,28 @@ async function publish_handler(args) {
4377
4616
  if (!result.ok) return JSON.stringify(result);
4378
4617
  const data = result.data;
4379
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
+ }
4380
4641
  return JSON.stringify(data);
4381
4642
  }
4382
4643
  const release_promote_DEFAULT_API = "https://www.extension.dev";
@@ -4472,9 +4733,111 @@ async function release_promote_handler(args) {
4472
4733
  message: text
4473
4734
  };
4474
4735
  }
4475
- 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
+ }
4476
4768
  return JSON.stringify(data);
4477
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
+ }
4478
4841
  const deploy_DEFAULT_API = "https://www.extension.dev";
4479
4842
  function storeMdWarnings(browsers, cwd) {
4480
4843
  const wantsFirefox = browsers.includes("firefox");
@@ -4598,13 +4961,86 @@ async function deploy_handler(args) {
4598
4961
  message: text
4599
4962
  };
4600
4963
  }
4601
- if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
4602
- 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()));
4603
4969
  const result = {
4604
4970
  mode: "platform",
4605
4971
  dryRun,
4606
4972
  ...data
4607
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
+ }
4608
5044
  if (warnings.length > 0) result.warnings = warnings;
4609
5045
  return JSON.stringify(result);
4610
5046
  }
@@ -5370,13 +5806,28 @@ function success(creds) {
5370
5806
  });
5371
5807
  }
5372
5808
  function login_pending(start) {
5809
+ const complete = String(start.verificationUriComplete || "").trim();
5810
+ const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
5811
+ const message = hasCompleteLink ? `Open ${complete} and approve (code ${start.userCode} is pre-filled), then call extension_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_login again with this deviceCode (and the same project).`;
5373
5812
  return JSON.stringify({
5374
5813
  ok: false,
5375
5814
  status: "authorization_pending",
5376
5815
  userCode: start.userCode,
5377
5816
  verificationUri: start.verificationUri,
5817
+ ...hasCompleteLink ? {
5818
+ verificationUriComplete: complete
5819
+ } : {},
5378
5820
  deviceCode: start.deviceCode,
5379
- message: `Open ${start.verificationUri} and enter code ${start.userCode}, then call extension_login again with this deviceCode (and the same project).`
5821
+ message
5822
+ });
5823
+ }
5824
+ function resumePending(deviceCode, verificationUri) {
5825
+ return JSON.stringify({
5826
+ ok: false,
5827
+ status: "authorization_pending",
5828
+ verificationUri,
5829
+ deviceCode,
5830
+ 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_login again with this same deviceCode (and the same project).`
5380
5831
  });
5381
5832
  }
5382
5833
  async function login_handler(args) {
@@ -5403,11 +5854,7 @@ async function login_handler(args) {
5403
5854
  if ("expired" === poll.reason) return login_fail("LoginExpired", "The device code expired. Run extension_login again to restart.");
5404
5855
  if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.");
5405
5856
  if ("error" === poll.reason) return login_fail("LoginError", poll.message || "Device login failed.");
5406
- return login_pending({
5407
- deviceCode: String(args.deviceCode),
5408
- userCode: "(see the previous response)",
5409
- verificationUri: config.verificationUri
5410
- });
5857
+ return resumePending(String(args.deviceCode), config.verificationUri);
5411
5858
  }
5412
5859
  let start;
5413
5860
  try {
@@ -5432,7 +5879,8 @@ async function login_handler(args) {
5432
5879
  return login_pending({
5433
5880
  deviceCode: start.deviceCode,
5434
5881
  userCode: start.userCode,
5435
- verificationUri: start.verificationUri
5882
+ verificationUri: start.verificationUri,
5883
+ verificationUriComplete: start.verificationUriComplete
5436
5884
  });
5437
5885
  }
5438
5886
  if (args.deviceCode) {
@@ -5445,11 +5893,7 @@ async function login_handler(args) {
5445
5893
  if (!poll.ok) {
5446
5894
  if ("expired" === poll.reason) return login_fail("LoginExpired", "The device code expired. Run extension_login again to restart.");
5447
5895
  if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied on GitHub.");
5448
- return login_pending({
5449
- deviceCode: String(args.deviceCode),
5450
- userCode: "(see the previous response)",
5451
- verificationUri: "https://github.com/login/device"
5452
- });
5896
+ return resumePending(String(args.deviceCode), "https://github.com/login/device");
5453
5897
  }
5454
5898
  try {
5455
5899
  const creds = await exchangeAndPersist({
@@ -6102,6 +6546,12 @@ const ARG_ALIASES = {
6102
6546
  ],
6103
6547
  browser: [
6104
6548
  "browserName"
6549
+ ],
6550
+ buildSha: [
6551
+ "buildId"
6552
+ ],
6553
+ buildId: [
6554
+ "buildSha"
6105
6555
  ]
6106
6556
  };
6107
6557
  function normalizeArgAliases(inputSchema, args) {
@@ -6145,13 +6595,33 @@ function validateToolInput(inputSchema, args) {
6145
6595
  }
6146
6596
  return issues;
6147
6597
  }
6148
- function inputValidationError(toolName, issues) {
6598
+ function describeToolArgs(inputSchema) {
6599
+ const schema = inputSchema;
6600
+ const props = schema.properties ?? {};
6601
+ const required = schema.required ?? [];
6602
+ const optional = Object.keys(props).filter((k)=>!required.includes(k));
6603
+ const aliases = {};
6604
+ for (const [canonical, list] of Object.entries(ARG_ALIASES)){
6605
+ if (!(canonical in props)) continue;
6606
+ const usable = list.filter((alias)=>!(alias in props));
6607
+ if (usable.length) aliases[canonical] = usable;
6608
+ }
6609
+ return {
6610
+ required,
6611
+ optional,
6612
+ aliases
6613
+ };
6614
+ }
6615
+ function inputValidationError(toolName, issues, inputSchema) {
6149
6616
  return JSON.stringify({
6150
6617
  ok: false,
6151
6618
  error: {
6152
6619
  name: "InputValidationError",
6153
6620
  message: `Invalid arguments for ${toolName}: ${issues.map((i)=>`${i.path}: ${i.message}`).join("; ")}`,
6154
- issues
6621
+ issues,
6622
+ ...inputSchema ? {
6623
+ args: describeToolArgs(inputSchema)
6624
+ } : {}
6155
6625
  }
6156
6626
  });
6157
6627
  }
@@ -6175,6 +6645,7 @@ const tools = [
6175
6645
  open_namespaceObject,
6176
6646
  dom_inspect_namespaceObject,
6177
6647
  tools_publish_namespaceObject,
6648
+ release_list_namespaceObject,
6178
6649
  release_promote_namespaceObject,
6179
6650
  deploy_namespaceObject,
6180
6651
  wait_namespaceObject,
@@ -6227,7 +6698,7 @@ async function startServer() {
6227
6698
  content: [
6228
6699
  {
6229
6700
  type: "text",
6230
- text: inputValidationError(name, issues)
6701
+ text: inputValidationError(name, issues, tool.schema.inputSchema)
6231
6702
  }
6232
6703
  ],
6233
6704
  isError: true