@extension.dev/mcp 10.10.7 → 10.10.9

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
@@ -9,7 +9,7 @@ import node_os from "node:os";
9
9
  import cross_spawn from "cross-spawn";
10
10
  import { PROD_ORIGINS, resolveOrigins } from "@extension.dev/urls/origins";
11
11
  import node_crypto, { createHash, randomUUID } from "node:crypto";
12
- import { consoleProjectPath, wwwNewPath } from "@extension.dev/urls/paths";
12
+ import { ConsoleProjectPage, consoleProjectPath, wwwNewPath } from "@extension.dev/urls/paths";
13
13
  import { UserlandProjectPage, userlandUrl } from "@extension.dev/urls/userland";
14
14
  import { CLOSE_BAD_HELLO, CLOSE_BAD_INSTANCE, CLOSE_CONTROL_UNAVAILABLE, CLOSE_SLOW_CONSUMER, CONTROL_ENVELOPE_VERSION, CONTROL_WS_PATH, actionsPath, browserArtifactsDir, buildSummaryPath, eventsPath, logsPath, matchesLogQuery, readLogEvents, readReadyContract, readyContractPath as bridge_readyContractPath, sessionArtifactsRootDir, sessionStateDir as bridge_sessionStateDir } from "extension-develop/bridge";
15
15
  import { fileURLToPath } from "node:url";
@@ -92,6 +92,7 @@ var dom_snapshot_namespaceObject = {};
92
92
  __webpack_require__.r(dom_snapshot_namespaceObject);
93
93
  __webpack_require__.d(dom_snapshot_namespaceObject, {
94
94
  handler: ()=>dom_snapshot_handler,
95
+ protocolSnapshotExpression: ()=>protocolSnapshotExpression,
95
96
  schema: ()=>dom_snapshot_schema
96
97
  });
97
98
  var eval_namespaceObject = {};
@@ -242,7 +243,7 @@ __webpack_require__.d(workspace_create_namespaceObject, {
242
243
  handler: ()=>workspace_create_handler,
243
244
  schema: ()=>workspace_create_schema
244
245
  });
245
- var package_namespaceObject = JSON.parse('{"rE":"10.10.7","El":{"OP":"4.1.31"}}');
246
+ var package_namespaceObject = JSON.parse('{"rE":"10.10.9","El":{"OP":"4.1.31"}}');
246
247
  const ENVELOPE_SCHEMA = 1;
247
248
  const collectWarnings = (warnings)=>{
248
249
  if (!warnings) return [];
@@ -578,6 +579,21 @@ function writeCredentials(creds) {
578
579
  entries
579
580
  });
580
581
  }
582
+ function writeCredentialBatch(batch) {
583
+ if (0 === batch.length) return null;
584
+ const existing = readCredentialStore();
585
+ const entries = {
586
+ ...existing?.entries ?? {}
587
+ };
588
+ for (const creds of batch)entries[credentialKey(creds.workspaceSlug, creds.projectSlug)] = creds;
589
+ const first = batch[0];
590
+ const active = existing?.active && entries[existing.active] ? existing.active : credentialKey(first.workspaceSlug, first.projectSlug);
591
+ return writeStore({
592
+ version: 2,
593
+ active,
594
+ entries
595
+ });
596
+ }
581
597
  function clearCredentials(selector) {
582
598
  const file = credentialsPath();
583
599
  const store = readCredentialStore();
@@ -659,6 +675,91 @@ const PROJECT_TOKEN_INPUT = {
659
675
  function noStoredLoginHint(project) {
660
676
  return `No stored login for ${project}. extension_auth (action: status) lists the logins on this machine; extension_auth (action: login, project: "${project}") adds one. Logging in to another project no longer replaces this one.`;
661
677
  }
678
+ const MAX_BATCH_PROJECTS = 20;
679
+ const MAX_PROJECT_SLUG_LENGTH = 48;
680
+ const MAX_WORKSPACE_SLUG_LENGTH = 64;
681
+ const PLATFORM_CREATES_PER_HOUR = 10;
682
+ const DEFAULT_CREATE_PROJECTS_PER_APPROVAL = 10;
683
+ function capFrom(value, fallback) {
684
+ const count = Number(value);
685
+ if (!Number.isInteger(count) || count < 1) return fallback;
686
+ return Math.min(count, MAX_BATCH_PROJECTS);
687
+ }
688
+ function readBatchCapability(config) {
689
+ const raw = config?.batchOnboarding;
690
+ if (!raw || "object" != typeof raw || Array.isArray(raw)) return null;
691
+ const record = raw;
692
+ return {
693
+ createProjectsPerApproval: capFrom(record.createProjectsPerApproval, DEFAULT_CREATE_PROJECTS_PER_APPROVAL),
694
+ loginProjectsPerApproval: capFrom(record.loginProjectsPerApproval, MAX_BATCH_PROJECTS)
695
+ };
696
+ }
697
+ const BATCH_UNSUPPORTED_MESSAGE = "This platform does not advertise batch onboarding, so a list of projects was not sent and no device code was spent.";
698
+ const PROJECT_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
699
+ function isExactProjectSlug(slug) {
700
+ return slug.length <= MAX_PROJECT_SLUG_LENGTH && PROJECT_SLUG.test(slug);
701
+ }
702
+ function parseProjectBatch(input) {
703
+ if (!Array.isArray(input)) return {
704
+ ok: false,
705
+ message: "projects must be an array of '<workspace>/<project>' names."
706
+ };
707
+ if (input.length < 1 || input.length > MAX_BATCH_PROJECTS) return {
708
+ ok: false,
709
+ message: `projects must name between 1 and ${MAX_BATCH_PROJECTS} projects; got ${input.length}. One approval covers at most ${MAX_BATCH_PROJECTS}, so split a longer list into separate calls.`
710
+ };
711
+ const refs = [];
712
+ const slugs = [];
713
+ let workspace = "";
714
+ for (const entry of input){
715
+ const raw = "string" == typeof entry ? entry.trim() : "";
716
+ if (!/^[^/]+\/[^/]+$/.test(raw)) return {
717
+ ok: false,
718
+ message: `Every entry in projects must be '<workspace>/<project>'; got ${JSON.stringify(entry)}.`
719
+ };
720
+ const slash = raw.indexOf("/");
721
+ const entryWorkspace = raw.slice(0, slash).trim().toLowerCase();
722
+ const slug = raw.slice(slash + 1).trim().toLowerCase();
723
+ if (!entryWorkspace || entryWorkspace.length > MAX_WORKSPACE_SLUG_LENGTH) return {
724
+ ok: false,
725
+ message: `The workspace in '${raw}' must be a slug of at most ${MAX_WORKSPACE_SLUG_LENGTH} characters.`
726
+ };
727
+ if (!isExactProjectSlug(slug)) return {
728
+ ok: false,
729
+ message: `'${raw}' does not name a project by its exact slug. A batch takes each project slug exactly as the platform registers it: lowercase letters and digits joined by single dashes, at most ${MAX_PROJECT_SLUG_LENGTH} characters. The console address bar shows it as console.extension.dev/<workspace>/<project>.`
730
+ };
731
+ if (workspace && entryWorkspace !== workspace) return {
732
+ ok: false,
733
+ message: `One approval covers one workspace: '${raw}' is in '${entryWorkspace}' but the list started in '${workspace}'. Make one call per workspace.`
734
+ };
735
+ workspace = entryWorkspace;
736
+ if (slugs.includes(slug)) return {
737
+ ok: false,
738
+ message: `'${entryWorkspace}/${slug}' is named twice in projects. Name each project once.`
739
+ };
740
+ slugs.push(slug);
741
+ refs.push(`${entryWorkspace}/${slug}`);
742
+ }
743
+ return {
744
+ ok: true,
745
+ batch: {
746
+ workspace,
747
+ slugs,
748
+ refs
749
+ }
750
+ };
751
+ }
752
+ function sameProjectSet(a, b) {
753
+ if (a.length !== b.length) return false;
754
+ const wanted = new Set(a.map((value)=>value.toLowerCase()));
755
+ return b.every((value)=>wanted.has(value.toLowerCase()));
756
+ }
757
+ function createRateLimitNote() {
758
+ return `The platform creates at most ${PLATFORM_CREATES_PER_HOUR} projects per hour for one approving account, and a provisioning grant lives 15 minutes. Creations the same account already made in the last hour count against it.`;
759
+ }
760
+ function createCapNote(cap) {
761
+ return `One approval creates at most ${cap} projects on this platform. Send the first ${cap}; the next ${cap} can start in a new call, with its own approval, once the hourly creation limit allows.`;
762
+ }
662
763
  const DEFAULT_API = PROD_ORIGINS.www;
663
764
  function tokenTtlNote(workspaceSlug, projectSlug) {
664
765
  const tokensUrl = workspaceSlug && projectSlug ? consoleProjectUrl({
@@ -710,7 +811,8 @@ async function fetchLoginConfig(apiBase, fetchImpl = fetch) {
710
811
  return {
711
812
  deviceCodeUrl: String(data.deviceCodeUrl || "/api/cli/device/code"),
712
813
  deviceTokenUrl: String(data.deviceTokenUrl || "/api/cli/device/token"),
713
- verificationUri: String(data.verificationUri || `${apiBase.replace(/\/+$/, "")}/device`)
814
+ verificationUri: String(data.verificationUri || `${apiBase.replace(/\/+$/, "")}/device`),
815
+ batch: readBatchCapability(data)
714
816
  };
715
817
  }
716
818
  function persistTokenResponse(args) {
@@ -734,6 +836,30 @@ function persistTokenResponse(args) {
734
836
  writeCredentials(creds);
735
837
  return creds;
736
838
  }
839
+ function persistBatchTokenResponse(args) {
840
+ const raw = Array.isArray(args.data.tokens) ? args.data.tokens : null;
841
+ if (!raw) throw new Error("The platform answered this batch login with a single token and no per-project list, so nothing was stored. It may predate batch login: sign in to each project with its own extension_auth (action: login, project) call.");
842
+ const batch = raw.map((entry)=>{
843
+ const record = entry ?? {};
844
+ const token = String(record.token || "").trim();
845
+ const workspaceSlug = String(record.workspaceSlug || "").trim();
846
+ const projectSlug = String(record.projectSlug || "").trim();
847
+ if (!token || !workspaceSlug || !projectSlug) throw new Error("The batch login returned an entry without a token or a workspace/project scope; nothing was stored. Run extension_auth (action: login) again.");
848
+ return {
849
+ version: 1,
850
+ token,
851
+ workspaceSlug,
852
+ projectSlug,
853
+ expiresAt: Number(record.expiresAt || 0),
854
+ api: args.apiBase,
855
+ provider: "extensiondev"
856
+ };
857
+ });
858
+ const returned = batch.map((creds)=>`${creds.workspaceSlug}/${creds.projectSlug}`);
859
+ if (new Set(returned.map((ref)=>ref.toLowerCase())).size !== returned.length || !sameProjectSet(args.projects, returned)) throw new Error(`The batch login returned tokens scoped to [${returned.join(", ")}], not the requested [${args.projects.join(", ")}]; nothing was stored. Run extension_auth (action: login) again with the intended projects.`);
860
+ writeCredentialBatch(batch);
861
+ return batch;
862
+ }
737
863
  const INSTALL_HEADER = "x-extensiondev-install";
738
864
  const SESSION_HEADER = "x-extensiondev-session";
739
865
  const TOOL_HEADER = "x-extensiondev-tool";
@@ -884,7 +1010,9 @@ class RegistryAccessTokens {
884
1010
  return result;
885
1011
  }
886
1012
  async mint(ref, apiHint) {
887
- const creds = readValidCredentials();
1013
+ const creds = readValidCredentials(void 0, {
1014
+ project: `${ref.workspace}/${ref.project}`
1015
+ });
888
1016
  const token = String(process.env.EXTENSION_DEV_TOKEN || creds?.token || "").trim();
889
1017
  if (!token) return {
890
1018
  status: "no-credential"
@@ -1064,14 +1192,33 @@ function consoleBase(apiHint) {
1064
1192
  function registryBase() {
1065
1193
  return mcpOrigins().registry;
1066
1194
  }
1195
+ function splitProjectName(name) {
1196
+ const parts = name.split("/");
1197
+ const workspace = String(parts[0] ?? "").trim();
1198
+ const project = String(parts[1] ?? "").trim();
1199
+ if (2 !== parts.length || !workspace || !project) return null;
1200
+ return {
1201
+ workspace,
1202
+ project
1203
+ };
1204
+ }
1067
1205
  function resolveProjectRef(overrides) {
1068
- const workspace = String(overrides?.workspace || "").trim();
1069
- const project = String(overrides?.project || "").trim();
1206
+ let workspace = String(overrides?.workspace || "").trim();
1207
+ let project = String(overrides?.project || "").trim();
1208
+ if (project.includes("/")) {
1209
+ const named = splitProjectName(project);
1210
+ if (!named) return null;
1211
+ if (workspace && workspace.toLowerCase() !== named.workspace.toLowerCase()) return null;
1212
+ workspace = named.workspace;
1213
+ project = named.project;
1214
+ }
1070
1215
  if (workspace && project) return {
1071
1216
  workspace,
1072
1217
  project
1073
1218
  };
1074
- const creds = readCredentials();
1219
+ const creds = (project ? readCredentials({
1220
+ project
1221
+ }) : null) ?? readCredentials();
1075
1222
  const ws = workspace || String(creds?.workspaceSlug || "").trim();
1076
1223
  const proj = project || String(creds?.projectSlug || "").trim();
1077
1224
  if (!ws || !proj) return null;
@@ -1080,6 +1227,20 @@ function resolveProjectRef(overrides) {
1080
1227
  project: proj
1081
1228
  };
1082
1229
  }
1230
+ function loginProjectRef(selector) {
1231
+ const named = String(selector ?? "").trim();
1232
+ if (!named) return resolveProjectRef();
1233
+ const creds = readCredentials({
1234
+ project: named
1235
+ });
1236
+ const workspace = String(creds?.workspaceSlug || "").trim();
1237
+ const project = String(creds?.projectSlug || "").trim();
1238
+ if (workspace && project) return {
1239
+ workspace,
1240
+ project
1241
+ };
1242
+ return splitProjectName(named);
1243
+ }
1083
1244
  function registryFileUrl(ref, file) {
1084
1245
  return `${registryBase()}/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.project)}/_extension-dev/${file}`;
1085
1246
  }
@@ -1743,6 +1904,7 @@ const LAUNCH_BROWSER = {
1743
1904
  };
1744
1905
  async function requestDeviceCode(args) {
1745
1906
  const doFetch = args.fetchImpl ?? fetch;
1907
+ const batch = Array.isArray(args.projects) && args.projects.length > 0;
1746
1908
  const res = await doFetch(`${args.apiBase}${args.path}`, {
1747
1909
  method: "POST",
1748
1910
  headers: {
@@ -1750,7 +1912,10 @@ async function requestDeviceCode(args) {
1750
1912
  accept: "application/json"
1751
1913
  },
1752
1914
  body: JSON.stringify({
1753
- ...args.project ? {
1915
+ ...batch ? {
1916
+ projects: args.projects
1917
+ } : {},
1918
+ ...!batch && args.project ? {
1754
1919
  project: args.project
1755
1920
  } : {},
1756
1921
  ...args.workspace ? {
@@ -1774,6 +1939,8 @@ async function requestDeviceCode(args) {
1774
1939
  if (!res.ok) {
1775
1940
  const error = new Error(`Device code request failed (${res.status}): ${data.message || "unknown error"}`);
1776
1941
  if ("string" == typeof data.message && data.message.trim()) error.serverMessage = data.message.trim();
1942
+ if ("string" == typeof data.code && data.code.trim()) error.serverCode = data.code.trim();
1943
+ error.httpStatus = res.status;
1777
1944
  throw error;
1778
1945
  }
1779
1946
  const deviceCode = String(data.device_code || "").trim();
@@ -1792,6 +1959,7 @@ async function pollDeviceGrant(args) {
1792
1959
  const doFetch = args.fetchImpl ?? fetch;
1793
1960
  const deadline = Date.now() + args.budgetMs;
1794
1961
  let interval = Math.max(1, args.interval);
1962
+ const batch = Array.isArray(args.projects) && args.projects.length > 0;
1795
1963
  for(;;){
1796
1964
  const res = await doFetch(`${args.apiBase}${args.path}`, {
1797
1965
  method: "POST",
@@ -1801,7 +1969,10 @@ async function pollDeviceGrant(args) {
1801
1969
  },
1802
1970
  body: JSON.stringify({
1803
1971
  device_code: args.deviceCode,
1804
- ...args.project ? {
1972
+ ...batch ? {
1973
+ projects: args.projects
1974
+ } : {},
1975
+ ...!batch && args.project ? {
1805
1976
  project: args.project
1806
1977
  } : {},
1807
1978
  ...args.workspace ? {
@@ -1823,9 +1994,18 @@ async function pollDeviceGrant(args) {
1823
1994
  data
1824
1995
  };
1825
1996
  const error = String(data.error || "");
1997
+ const code = String(data.code || "").trim();
1998
+ const refusal = code ? {
1999
+ code,
2000
+ body: data
2001
+ } : {};
1826
2002
  if ("access_denied" === error) return {
1827
2003
  ok: false,
1828
- reason: "denied"
2004
+ reason: "denied",
2005
+ ...data.message ? {
2006
+ message: String(data.message)
2007
+ } : {},
2008
+ ...refusal
1829
2009
  };
1830
2010
  if ("expired_token" === error) return {
1831
2011
  ok: false,
@@ -1835,12 +2015,14 @@ async function pollDeviceGrant(args) {
1835
2015
  else if (error && "authorization_pending" !== error) return {
1836
2016
  ok: false,
1837
2017
  reason: "error",
1838
- message: String(data.message || error)
2018
+ message: String(data.message || error),
2019
+ ...refusal
1839
2020
  };
1840
2021
  else if (!error && !res.ok) return {
1841
2022
  ok: false,
1842
2023
  reason: "error",
1843
- message: `Device token poll failed (${res.status}): ${String(data.message || text || "no response body").slice(0, 200)}`
2024
+ message: `Device token poll failed (${res.status}): ${String(data.message || text || "no response body").slice(0, 200)}`,
2025
+ ...refusal
1844
2026
  };
1845
2027
  if (Date.now() + 1000 * interval >= deadline) return {
1846
2028
  ok: false,
@@ -1863,55 +2045,716 @@ async function pollDeviceToken(args) {
1863
2045
  creds
1864
2046
  };
1865
2047
  } catch (err) {
1866
- return {
1867
- ok: false,
1868
- reason: "error",
1869
- message: err?.message ? String(err.message) : String(err)
1870
- };
2048
+ return {
2049
+ ok: false,
2050
+ reason: "error",
2051
+ message: err?.message ? String(err.message) : String(err)
2052
+ };
2053
+ }
2054
+ }
2055
+ const FREE_ALLOWANCE_PHRASE = "counts against your free allowance";
2056
+ function allowanceWallUrl(apiHint) {
2057
+ return `${mcpOrigins(apiHint).www.replace(/\/+$/, "")}/pricing`;
2058
+ }
2059
+ function allowance_asRecord(value) {
2060
+ return value && "object" == typeof value && !Array.isArray(value) ? value : null;
2061
+ }
2062
+ function asCount(value) {
2063
+ return "number" == typeof value && Number.isFinite(value) && value >= 0 ? value : null;
2064
+ }
2065
+ function readPlatformAllowance(body) {
2066
+ const record = allowance_asRecord(body);
2067
+ if (!record) return null;
2068
+ for (const key of [
2069
+ "allowance",
2070
+ "quota"
2071
+ ]){
2072
+ const nested = allowance_asRecord(record[key]);
2073
+ if (!nested) continue;
2074
+ const used = asCount(nested.used);
2075
+ const limit = asCount(nested.limit);
2076
+ if (null !== used && null !== limit) return {
2077
+ used,
2078
+ limit
2079
+ };
2080
+ }
2081
+ return null;
2082
+ }
2083
+ function spendNarration(options) {
2084
+ const counted = readPlatformAllowance(options.body);
2085
+ return {
2086
+ spent: `${options.what} ran on extension.dev's machines and ${FREE_ALLOWANCE_PHRASE}.`,
2087
+ remains: counted ? `The platform reports ${counted.used} of ${counted.limit} used.` : "The platform sent no remaining count on this call, and this client never invents one. When the allowance runs out, the platform refuses with its own numbers.",
2088
+ wall: `What the free allowance covers and when the paid plan starts are published at ${allowanceWallUrl(options.api)}.`
2089
+ };
2090
+ }
2091
+ function buildCreateBody(args) {
2092
+ const installCommand = String(args.installCommand || "npm install").trim();
2093
+ const buildCommand = String(args.buildCommand || "npm run build").trim();
2094
+ const wanted = new Set((Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [
2095
+ "chrome"
2096
+ ]).map((name)=>String(name).trim().toLowerCase()));
2097
+ const several = wanted.size > 1;
2098
+ const outputFor = (name)=>{
2099
+ const override = args.outputDirectories?.[name];
2100
+ if ("string" == typeof override && override.trim()) return override.trim();
2101
+ const pattern = String(args.outputDirectory || "").trim();
2102
+ if (pattern.includes("<browser>")) return pattern.replaceAll("<browser>", name);
2103
+ if (pattern && !several) return pattern;
2104
+ return `dist/${name}`;
2105
+ };
2106
+ const browser = (name)=>({
2107
+ enabled: wanted.has(name),
2108
+ installCommand,
2109
+ buildCommand,
2110
+ outputDirectory: outputFor(name)
2111
+ });
2112
+ return {
2113
+ info: {
2114
+ id: "",
2115
+ name: args.projectSlug,
2116
+ displayName: String(args.displayName || args.projectSlug).trim(),
2117
+ description: String(args.description || `Browser extension project for ${args.owner}/${args.repoName}.`).trim()
2118
+ },
2119
+ build: {
2120
+ chrome: browser("chrome"),
2121
+ edge: browser("edge"),
2122
+ firefox: browser("firefox")
2123
+ },
2124
+ deployment: {
2125
+ branch: "",
2126
+ nodeVersion: "",
2127
+ runWhatsNew: false,
2128
+ runExtensionExecutables: false
2129
+ },
2130
+ github: {
2131
+ owner: args.owner,
2132
+ repo: args.repoName,
2133
+ installationId: args.installationId,
2134
+ createdAt: new Date().toISOString(),
2135
+ pullRequestComments: true,
2136
+ commitComments: false
2137
+ },
2138
+ workspaceSlug: args.workspace,
2139
+ createdFrom: {
2140
+ kind: "repository",
2141
+ ref: `${args.owner}/${args.repoName}`
2142
+ }
2143
+ };
2144
+ }
2145
+ const project_create_batch_COMMAND = "extension_project_create";
2146
+ const FIRST_CALL_BUDGET_MS = 8000;
2147
+ const RESUME_BUDGET_MS = 22000;
2148
+ const SLICE_BUDGET_MS = 20000;
2149
+ const GRANT_EXPIRY_MARGIN_SECONDS = 5;
2150
+ const ENTRY_KEYS = [
2151
+ "project",
2152
+ "repo",
2153
+ "displayName",
2154
+ "description",
2155
+ "installCommand",
2156
+ "buildCommand",
2157
+ "outputDirectory",
2158
+ "browsers",
2159
+ "outputDirectories"
2160
+ ];
2161
+ const ENTRY_STRING_KEYS = [
2162
+ "displayName",
2163
+ "description",
2164
+ "installCommand",
2165
+ "buildCommand",
2166
+ "outputDirectory"
2167
+ ];
2168
+ const BROWSERS = [
2169
+ "chrome",
2170
+ "edge",
2171
+ "firefox"
2172
+ ];
2173
+ const SINGLE_ONLY_KEYS = [
2174
+ "project",
2175
+ "repo",
2176
+ "displayName",
2177
+ "description"
2178
+ ];
2179
+ const sessions = new Map();
2180
+ const SESSION_KEEP_SECONDS = 3600;
2181
+ function sweepSessions(nowSeconds) {
2182
+ for (const [key, session] of sessions)if (!session.busy) {
2183
+ if (session.grantExpiresAt <= nowSeconds) session.grant = "";
2184
+ if (session.grantExpiresAt + SESSION_KEEP_SECONDS <= nowSeconds) sessions.delete(key);
2185
+ }
2186
+ }
2187
+ function fail(name, message, status, code, extra) {
2188
+ return envelope_envelope({
2189
+ ok: false,
2190
+ command: project_create_batch_COMMAND,
2191
+ status,
2192
+ error: {
2193
+ code,
2194
+ name,
2195
+ message
2196
+ },
2197
+ ...extra?.value ? {
2198
+ value: extra.value
2199
+ } : {},
2200
+ ...extra?.hint ? {
2201
+ hint: extra.hint
2202
+ } : {}
2203
+ });
2204
+ }
2205
+ function laneClosedHint() {
2206
+ return `Create the projects in the console at ${consoleBase()} (workspace page, New project), then run extension_auth (action: login) against them. While the public hold is on, headless creation is open only to workspaces on the platform's allowlist (WWW_MCP_ACTION_ALLOWED_WORKSPACES), the same list that already lets publish, submit and promote run headlessly; this workspace is not on it, so the console is the route.`;
2207
+ }
2208
+ function isRecord(value) {
2209
+ return Boolean(value) && "object" == typeof value && !Array.isArray(value);
2210
+ }
2211
+ function parseBatchCreateArgs(args) {
2212
+ for (const key of SINGLE_ONLY_KEYS){
2213
+ const value = args[key];
2214
+ if (null != value && "" !== String(value).trim()) return {
2215
+ ok: false,
2216
+ message: `With projects, ${key} belongs inside each entry, not beside the list. Pass either the single form (project, repo) or projects, not both.`
2217
+ };
2218
+ }
2219
+ if (!Array.isArray(args.projects)) return {
2220
+ ok: false,
2221
+ message: "projects must be an array of entries shaped { project: '<workspace>/<project>', repo: '<owner>/<repo>' }."
2222
+ };
2223
+ const refs = [];
2224
+ for (const entry of args.projects){
2225
+ if (!isRecord(entry)) return {
2226
+ ok: false,
2227
+ message: `Every entry in projects must be an object shaped { project, repo }; got ${JSON.stringify(entry)}. Each new project needs its own source repository, so a bare name is not enough.`
2228
+ };
2229
+ const unknown = Object.keys(entry).filter((key)=>!ENTRY_KEYS.includes(key));
2230
+ if (unknown.length) return {
2231
+ ok: false,
2232
+ message: `Unknown key ${unknown.map((key)=>`'${key}'`).join(", ")} in a projects entry. An entry takes: ${ENTRY_KEYS.join(", ")}.`
2233
+ };
2234
+ refs.push(entry.project);
2235
+ }
2236
+ const parsed = parseProjectBatch(refs);
2237
+ if (!parsed.ok) return parsed;
2238
+ const entries = [];
2239
+ for (const [index, raw] of args.projects.entries()){
2240
+ const ref = parsed.batch.refs[index];
2241
+ const slug = parsed.batch.slugs[index];
2242
+ const repo = "string" == typeof raw.repo ? raw.repo.trim() : "";
2243
+ if (!/^[^/]+\/[^/]+$/.test(repo)) return {
2244
+ ok: false,
2245
+ message: `The entry for '${ref}' needs repo as '<owner>/<repo>', the GitHub repository that project's source is pushed to.`
2246
+ };
2247
+ for (const key of ENTRY_STRING_KEYS)if (void 0 !== raw[key] && "string" != typeof raw[key]) return {
2248
+ ok: false,
2249
+ message: `In the entry for '${ref}', ${key} must be a string.`
2250
+ };
2251
+ if (void 0 !== raw.browsers) {
2252
+ const list = raw.browsers;
2253
+ if (!Array.isArray(list) || 0 === list.length || list.some((name)=>!BROWSERS.includes(String(name)))) return {
2254
+ ok: false,
2255
+ message: `In the entry for '${ref}', browsers must be a non-empty array of: ${BROWSERS.join(", ")}.`
2256
+ };
2257
+ }
2258
+ if (void 0 !== raw.outputDirectories && !isRecord(raw.outputDirectories)) return {
2259
+ ok: false,
2260
+ message: `In the entry for '${ref}', outputDirectories must be an object such as {"edge": "build/edge"}.`
2261
+ };
2262
+ const [owner = "", repoName = ""] = repo.split("/");
2263
+ entries.push({
2264
+ ref,
2265
+ workspace: parsed.batch.workspace,
2266
+ slug,
2267
+ repo,
2268
+ owner,
2269
+ repoName,
2270
+ displayName: raw.displayName,
2271
+ description: raw.description,
2272
+ installCommand: raw.installCommand ?? args.installCommand,
2273
+ buildCommand: raw.buildCommand ?? args.buildCommand,
2274
+ outputDirectory: raw.outputDirectory ?? args.outputDirectory,
2275
+ browsers: raw.browsers ?? args.browsers,
2276
+ outputDirectories: raw.outputDirectories ?? args.outputDirectories
2277
+ });
2278
+ }
2279
+ return {
2280
+ ok: true,
2281
+ workspace: parsed.batch.workspace,
2282
+ entries
2283
+ };
2284
+ }
2285
+ function pendingEnvelope(args) {
2286
+ const complete = String(args.verificationUriComplete || "").trim();
2287
+ const hasCompleteLink = complete.length > 0 && complete !== args.verificationUri;
2288
+ const again = "then call extension_project_create again with this deviceCode and the same arguments";
2289
+ const page = `The page lists all ${args.refs.length} projects this one approval creates and says this device is also signed in to each for 7 days`;
2290
+ const hint = args.userCode ? hasCompleteLink ? `Open ${complete} and approve creating the projects (code ${args.userCode} is pre-filled). ${page}; ${again}. If the page asks for a code, enter ${args.userCode} at ${args.verificationUri}.` : `Open ${args.verificationUri}, enter code ${args.userCode} and approve creating the projects. ${page}; ${again}.` : `Still waiting for approval at ${args.verificationUri}. Approve there, ${again}.`;
2291
+ return envelope_envelope({
2292
+ ok: true,
2293
+ command: project_create_batch_COMMAND,
2294
+ status: "authorization-pending",
2295
+ value: {
2296
+ ...args.userCode ? {
2297
+ userCode: args.userCode
2298
+ } : {},
2299
+ verificationUri: args.verificationUri,
2300
+ ...args.userCode && hasCompleteLink ? {
2301
+ verificationUriComplete: complete
2302
+ } : {},
2303
+ deviceCode: args.deviceCode,
2304
+ projects: args.refs
2305
+ },
2306
+ hint
2307
+ });
2308
+ }
2309
+ const STOP_CODES = new Set([
2310
+ "TOKEN_EXPIRED",
2311
+ "BAD_TOKEN",
2312
+ "AUTH_REQUIRED",
2313
+ "NOT_A_PROVISIONING_GRANT",
2314
+ "MEMBERSHIP_REVOKED",
2315
+ "WORKSPACE_NOT_FOUND",
2316
+ "CLI_PROJECT_CREATE_DISABLED",
2317
+ "PLATFORM_NOT_OPEN",
2318
+ "RATE_LIMITED",
2319
+ "PROJECT_LIMIT_EXCEEDED",
2320
+ "INSTALLATION_ABSENT",
2321
+ "INSTALLATION_ORG_UNSUPPORTED",
2322
+ "INSTALLATION_AMBIGUOUS"
2323
+ ]);
2324
+ function refusalHint(code, ref, retryAfterSeconds) {
2325
+ if ("PROJECT_EXISTS" === code) return `${ref} already exists, so there was nothing to create and no token came back. Sign in to it with extension_auth (action: login).`;
2326
+ if ("RATE_LIMITED" === code) return `${createRateLimitNote()}${retryAfterSeconds ? ` The platform says to wait ${retryAfterSeconds} seconds.` : ""} The grant will have expired by then, so the projects not created need a new extension_project_create call, and its approval, after the wait.`;
2327
+ if ("TOKEN_EXPIRED" === code) return "The provisioning grant lives 15 minutes and ran out. Start a new extension_project_create call for the projects not created.";
2328
+ if ("PROJECT_LIMIT_EXCEEDED" === code) return "The workspace is at its plan's project limit. Nothing more can be created in it until a project is removed or the plan changes.";
2329
+ if ("RESERVED_PROJECT_SLUG" === code) return "That slug is reserved on extension.dev. Pick another name for this project.";
2330
+ }
2331
+ async function createOne(session, entry, installationId) {
2332
+ const url = `${session.apiBase}/api/cli/projects/create`;
2333
+ const body = {
2334
+ ...buildCreateBody({
2335
+ workspace: entry.workspace,
2336
+ projectSlug: entry.slug,
2337
+ owner: entry.owner,
2338
+ repoName: entry.repoName,
2339
+ installationId,
2340
+ displayName: entry.displayName,
2341
+ description: entry.description,
2342
+ installCommand: entry.installCommand,
2343
+ buildCommand: entry.buildCommand,
2344
+ outputDirectory: entry.outputDirectory,
2345
+ browsers: entry.browsers,
2346
+ outputDirectories: entry.outputDirectories
2347
+ }),
2348
+ project: entry.ref
2349
+ };
2350
+ let res;
2351
+ try {
2352
+ res = await fetch(url, {
2353
+ method: "POST",
2354
+ headers: {
2355
+ authorization: `Bearer ${session.grant}`,
2356
+ "content-type": "application/json",
2357
+ ...identityHeaders(project_create_batch_COMMAND)
2358
+ },
2359
+ body: JSON.stringify(body)
2360
+ });
2361
+ } catch (err) {
2362
+ session.unanswered += 1;
2363
+ const message = `Could not reach ${url}: ${err?.message || err}`;
2364
+ return {
2365
+ row: {
2366
+ project: entry.ref,
2367
+ status: "unconfirmed",
2368
+ message,
2369
+ hint: `The request for ${entry.ref} left this machine and no answer came back, so the project may or may not exist. It is not retried, because a second create racing the first would be two builds claiming one name. Check the console at ${consoleBase()}; if it exists, sign in with extension_auth (action: login).`
2370
+ },
2371
+ halt: true,
2372
+ ...session.unanswered >= 2 ? {
2373
+ stop: {
2374
+ code: "PLATFORM_UNREACHABLE",
2375
+ message
2376
+ }
2377
+ } : {}
2378
+ };
2379
+ }
2380
+ session.unanswered = 0;
2381
+ const text = await res.text();
2382
+ let data;
2383
+ try {
2384
+ data = JSON.parse(text);
2385
+ } catch {
2386
+ data = {
2387
+ message: text
2388
+ };
2389
+ }
2390
+ if (!res.ok) {
2391
+ const held = sawPlatformHold(res, data);
2392
+ const code = held ? "PLATFORM_NOT_OPEN" : String(data.code || "").trim() || `HTTP_${res.status}`;
2393
+ const message = String(data.message || data.error || text || "unknown error").slice(0, 500);
2394
+ const connectUrl = String(data.connectUrl || "").trim();
2395
+ const retryAfterSeconds = Number(data.retryAfterSeconds || res.headers.get("retry-after") || 0) || void 0;
2396
+ const hint = refusalHint(code, entry.ref, retryAfterSeconds);
2397
+ const row = {
2398
+ project: entry.ref,
2399
+ status: "refused",
2400
+ httpStatus: res.status,
2401
+ code,
2402
+ message,
2403
+ ...connectUrl ? {
2404
+ connectUrl
2405
+ } : {},
2406
+ ...retryAfterSeconds ? {
2407
+ retryAfterSeconds
2408
+ } : {},
2409
+ ...hint ? {
2410
+ hint
2411
+ } : {}
2412
+ };
2413
+ return STOP_CODES.has(code) ? {
2414
+ row,
2415
+ stop: {
2416
+ code,
2417
+ message,
2418
+ body: data,
2419
+ held
2420
+ }
2421
+ } : {
2422
+ row
2423
+ };
2424
+ }
2425
+ session.lastBody = data;
2426
+ const finalWorkspace = String(data.workspaceSlug || entry.workspace).trim();
2427
+ const finalProject = String(data.projectSlug || entry.slug).trim();
2428
+ const consoleUrl = consoleProjectUrl({
2429
+ workspace: finalWorkspace,
2430
+ project: finalProject
2431
+ }, "");
2432
+ const token = String(data.token || "").trim();
2433
+ const scoped = finalWorkspace.toLowerCase() === entry.workspace && finalProject.toLowerCase() === entry.slug;
2434
+ if (true === data.tokenIssued && token && scoped) {
2435
+ const expiresAt = Number(data.expiresAt || 0);
2436
+ writeCredentialBatch([
2437
+ {
2438
+ version: 1,
2439
+ token,
2440
+ workspaceSlug: finalWorkspace,
2441
+ projectSlug: finalProject,
2442
+ expiresAt,
2443
+ api: session.apiBase,
2444
+ provider: "extensiondev"
2445
+ }
2446
+ ]);
2447
+ return {
2448
+ row: {
2449
+ project: entry.ref,
2450
+ status: "created",
2451
+ loggedIn: true,
2452
+ projectId: data.projectId ?? null,
2453
+ consoleUrl,
2454
+ expiresAt: expiresAt ? new Date(1000 * expiresAt).toISOString() : null
2455
+ }
2456
+ };
2457
+ }
2458
+ const tokenCode = scoped ? String(data.tokenCode || "").trim() || "NO_TOKEN_IN_RESPONSE" : "PROJECT_NAME_MISMATCH";
2459
+ return {
2460
+ row: {
2461
+ project: entry.ref,
2462
+ status: "created",
2463
+ loggedIn: false,
2464
+ projectId: data.projectId ?? null,
2465
+ consoleUrl,
2466
+ tokenCode,
2467
+ hint: scoped ? `${entry.ref} was created but the platform issued no token for it. Sign in with extension_auth (action: login).` : `The platform registered this project as ${finalWorkspace}/${finalProject}, not the ${entry.ref} that was asked for, so no login was stored. Sign in to the name it has with extension_auth (action: login).`
2468
+ }
2469
+ };
2470
+ }
2471
+ function rowsInOrder(session) {
2472
+ return session.entries.map((entry)=>session.rows.get(entry.ref) ?? {
2473
+ project: entry.ref,
2474
+ status: "pending"
2475
+ });
2476
+ }
2477
+ function quoteList(refs) {
2478
+ return `[${refs.map((ref)=>`'${ref}'`).join(", ")}]`;
2479
+ }
2480
+ function finalEnvelope(session) {
2481
+ const stop = session.stop;
2482
+ for (const entry of session.entries)if (!session.rows.has(entry.ref)) session.rows.set(entry.ref, {
2483
+ project: entry.ref,
2484
+ status: "not-attempted",
2485
+ code: stop?.code ?? "NOT_ATTEMPTED",
2486
+ message: stop ? `Not attempted: an earlier project in this list was refused with ${stop.code}, which applies to the whole approval.` : "Not attempted."
2487
+ });
2488
+ const rows = rowsInOrder(session);
2489
+ const created = rows.filter((row)=>"created" === row.status);
2490
+ const loggedIn = created.filter((row)=>"created" === row.status && row.loggedIn);
2491
+ const needLogin = [
2492
+ ...created.filter((row)=>"created" === row.status && !row.loggedIn),
2493
+ ...rows.filter((row)=>"refused" === row.status && "PROJECT_EXISTS" === row.code)
2494
+ ].map((row)=>row.project);
2495
+ const retry = rows.filter((row)=>"not-attempted" === row.status || "refused" === row.status && "PROJECT_EXISTS" !== row.code).map((row)=>row.project);
2496
+ const unconfirmed = rows.filter((row)=>"unconfirmed" === row.status).map((row)=>row.project);
2497
+ if (0 === created.length && stop?.held) return platformHoldEnvelope({
2498
+ command: project_create_batch_COMMAND,
2499
+ name: "CreateHeld",
2500
+ body: stop.body,
2501
+ value: {
2502
+ workspace: session.workspace,
2503
+ projects: session.refs,
2504
+ results: rows
2505
+ }
2506
+ });
2507
+ if (0 === created.length && stop?.code === "CLI_PROJECT_CREATE_DISABLED") return fail("CreateClosed", stop.message, "lane-closed", "E_PLATFORM", {
2508
+ hint: laneClosedHint(),
2509
+ value: {
2510
+ workspaceSlug: session.workspace,
2511
+ results: rows
2512
+ }
2513
+ });
2514
+ const nextSteps = [
2515
+ ...needLogin.length ? [
2516
+ `extension_auth (action: login, projects: ${quoteList(needLogin)})`
2517
+ ] : [],
2518
+ ...retry.length ? [
2519
+ `extension_project_create with projects limited to ${quoteList(retry)}, once what refused them is fixed (a new approval; a create list may only name projects that do not exist yet)`
2520
+ ] : [],
2521
+ ...unconfirmed.length ? [
2522
+ `check ${quoteList(unconfirmed)} in the console at ${consoleBase()} before creating them again`
2523
+ ] : [],
2524
+ ...loggedIn.length ? [
2525
+ "extension_publish (project: one of the logged-in projects)"
2526
+ ] : []
2527
+ ];
2528
+ const value = {
2529
+ workspaceSlug: session.workspace,
2530
+ results: rows,
2531
+ counts: {
2532
+ listed: rows.length,
2533
+ created: created.length,
2534
+ loggedIn: loggedIn.length,
2535
+ refused: rows.filter((row)=>"refused" === row.status).length,
2536
+ unconfirmed: unconfirmed.length,
2537
+ notAttempted: rows.filter((row)=>"not-attempted" === row.status).length
2538
+ },
2539
+ ...created.length ? {
2540
+ allowance: spendNarration({
2541
+ what: `Creating ${created.length} project${1 === created.length ? "" : "s"}, including each first build,`,
2542
+ body: session.lastBody ?? void 0,
2543
+ api: session.apiBase
2544
+ })
2545
+ } : {},
2546
+ nextSteps
2547
+ };
2548
+ const spent = "The provisioning grant is spent or discarded and was never written to this machine.";
2549
+ if (created.length === rows.length) return envelope_envelope({
2550
+ ok: true,
2551
+ command: project_create_batch_COMMAND,
2552
+ status: "created",
2553
+ value,
2554
+ hint: 0 === needLogin.length ? `All ${rows.length} projects exist, each first build was dispatched, and this machine is signed in to every one of them for 7 days. ${spent} Token-scoped tools take \`project\` to pick one.` : `All ${rows.length} projects exist and each first build was dispatched, but no token was stored for ${needLogin.join(", ")}. ${spent} Sign in to those with one batch login: ${nextSteps[0]}.`,
2555
+ ...needLogin.length ? {
2556
+ warnings: [
2557
+ `Created without a stored login: ${needLogin.join(", ")}. Run ${nextSteps[0]}.`
2558
+ ]
2559
+ } : {}
2560
+ });
2561
+ return envelope_envelope({
2562
+ ok: false,
2563
+ command: project_create_batch_COMMAND,
2564
+ status: "batch-incomplete",
2565
+ error: {
2566
+ code: "E_PLATFORM",
2567
+ name: "BatchIncomplete",
2568
+ message: `${created.length} of ${rows.length} projects were created. Each project has its own row in results with the platform's code; nothing is retried automatically.${stop ? ` The list stopped at ${stop.code}: ${stop.message}` : ""}`
2569
+ },
2570
+ value,
2571
+ hint: `${spent} ${stop?.code === "RATE_LIMITED" ? `${createRateLimitNote()} ` : ""}Next: ${nextSteps.join("; then ") || "read results"}.`
2572
+ });
2573
+ }
2574
+ async function runSlice(session, deviceCode, installationId) {
2575
+ if (session.busy) return envelope_envelope({
2576
+ ok: true,
2577
+ command: project_create_batch_COMMAND,
2578
+ status: "creating",
2579
+ value: {
2580
+ workspaceSlug: session.workspace,
2581
+ deviceCode,
2582
+ results: rowsInOrder(session)
2583
+ },
2584
+ hint: "Another call with this deviceCode is creating projects right now. Wait for it to answer, then call again with the same deviceCode and arguments if projects are still pending."
2585
+ });
2586
+ session.busy = true;
2587
+ let halted = false;
2588
+ try {
2589
+ const started = Date.now();
2590
+ let attempted = 0;
2591
+ for (const entry of session.entries){
2592
+ if (session.rows.has(entry.ref)) continue;
2593
+ if (session.stop) break;
2594
+ if (Math.floor(Date.now() / 1000) >= session.grantExpiresAt - GRANT_EXPIRY_MARGIN_SECONDS) {
2595
+ session.stop = {
2596
+ code: "TOKEN_EXPIRED",
2597
+ message: "The provisioning grant expired; it lives fifteen minutes."
2598
+ };
2599
+ break;
2600
+ }
2601
+ if (attempted > 0 && Date.now() - started >= SLICE_BUDGET_MS) break;
2602
+ attempted += 1;
2603
+ const outcome = await createOne(session, entry, installationId);
2604
+ session.rows.set(entry.ref, outcome.row);
2605
+ if (outcome.stop) session.stop = outcome.stop;
2606
+ if (outcome.halt) {
2607
+ halted = true;
2608
+ break;
2609
+ }
2610
+ }
2611
+ } finally{
2612
+ session.busy = false;
2613
+ }
2614
+ const remaining = session.entries.filter((entry)=>!session.rows.has(entry.ref));
2615
+ if (remaining.length > 0 && !session.stop) {
2616
+ const done = session.entries.length - remaining.length;
2617
+ return envelope_envelope({
2618
+ ok: true,
2619
+ command: project_create_batch_COMMAND,
2620
+ status: "creating",
2621
+ value: {
2622
+ workspaceSlug: session.workspace,
2623
+ deviceCode,
2624
+ results: rowsInOrder(session),
2625
+ remaining: remaining.map((entry)=>entry.ref)
2626
+ },
2627
+ hint: `${done} of ${session.entries.length} projects handled so far; ${remaining.length} still to create. Call extension_project_create again with this same deviceCode and the same arguments to continue. No new approval is needed: the grant is held in this server's memory until ${new Date(1000 * session.grantExpiresAt).toISOString()} and is lost if the server restarts.${halted ? " The last request got no answer, so its project is marked unconfirmed and is not retried." : ""}`
2628
+ });
2629
+ }
2630
+ sessions.delete(deviceCode);
2631
+ return finalEnvelope(session);
2632
+ }
2633
+ async function createProjectBatch(args) {
2634
+ const parsed = parseBatchCreateArgs(args);
2635
+ if (!parsed.ok) return fail("BadRequest", parsed.message, "bad-request", "E_BAD_REQUEST", {
2636
+ hint: parsed.hint
2637
+ });
2638
+ const installationId = String(args.installationId || "").trim();
2639
+ if (installationId && !/^\d+$/.test(installationId)) return fail("BadRequest", "installationId is optional, and when given it must be the numeric extension.dev GitHub App installation id. Omit it and the platform resolves it from the approving account.", "bad-request", "E_BAD_REQUEST");
2640
+ const refs = parsed.entries.map((entry)=>entry.ref);
2641
+ const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
2642
+ if (!apiCheck.ok) return fail("ConfigError", apiCheck.message, "bad-request", "E_BAD_REQUEST");
2643
+ const apiBase = apiCheck.base;
2644
+ sweepSessions(Math.floor(Date.now() / 1000));
2645
+ let deviceCode = String(args.deviceCode || "").trim();
2646
+ const held = deviceCode ? sessions.get(deviceCode) : void 0;
2647
+ if (held) {
2648
+ if (held.apiBase !== apiBase || !sameProjectSet(held.refs, refs)) return fail("BadRequest", `This deviceCode belongs to an approval for ${quoteList(held.refs)}. Call again with that same list, or start a new call without deviceCode for a different one.`, "bad-request", "E_BAD_REQUEST");
2649
+ return runSlice(held, deviceCode, installationId || void 0);
2650
+ }
2651
+ let config;
2652
+ try {
2653
+ config = await fetchLoginConfig(apiBase);
2654
+ } catch (err) {
2655
+ return fail("ConfigError", err?.message || "Could not load login config.", "create-failed", "E_PLATFORM");
1871
2656
  }
1872
- }
1873
- const FREE_ALLOWANCE_PHRASE = "counts against your free allowance";
1874
- function allowanceWallUrl(apiHint) {
1875
- return `${mcpOrigins(apiHint).www.replace(/\/+$/, "")}/pricing`;
1876
- }
1877
- function allowance_asRecord(value) {
1878
- return value && "object" == typeof value && !Array.isArray(value) ? value : null;
1879
- }
1880
- function asCount(value) {
1881
- return "number" == typeof value && Number.isFinite(value) && value >= 0 ? value : null;
1882
- }
1883
- function readPlatformAllowance(body) {
1884
- const record = allowance_asRecord(body);
1885
- if (!record) return null;
1886
- for (const key of [
1887
- "allowance",
1888
- "quota"
1889
- ]){
1890
- const nested = allowance_asRecord(record[key]);
1891
- if (!nested) continue;
1892
- const used = asCount(nested.used);
1893
- const limit = asCount(nested.limit);
1894
- if (null !== used && null !== limit) return {
1895
- used,
1896
- limit
1897
- };
2657
+ if (!config.batch) return fail("CreateBatchUnsupported", BATCH_UNSUPPORTED_MESSAGE, "batch-unsupported", "E_PLATFORM", {
2658
+ value: {
2659
+ projects: refs
2660
+ },
2661
+ hint: "Create the projects one extension_project_create call each (project, repo)."
2662
+ });
2663
+ const createCap = config.batch.createProjectsPerApproval;
2664
+ if (refs.length > createCap) return fail("BadRequest", `projects names ${refs.length} projects to create, and one approval creates at most ${createCap} on this platform.`, "bad-request", "E_BAD_REQUEST", {
2665
+ value: {
2666
+ maxProjects: createCap,
2667
+ listed: refs.length
2668
+ },
2669
+ hint: `${createCapNote(createCap)} ${createRateLimitNote()} Nothing was started and no device code was spent.`
2670
+ });
2671
+ let interval = 5;
2672
+ let budgetMs = RESUME_BUDGET_MS;
2673
+ let start = null;
2674
+ if (!deviceCode) {
2675
+ try {
2676
+ start = await requestDeviceCode({
2677
+ apiBase,
2678
+ path: config.deviceCodeUrl,
2679
+ projects: refs,
2680
+ intent: "create"
2681
+ });
2682
+ } catch (err) {
2683
+ const serverCode = String(err?.serverCode || "");
2684
+ const serverMessage = "string" == typeof err?.serverMessage ? err.serverMessage.trim() : "";
2685
+ if ("CLI_PROJECT_CREATE_DISABLED" === serverCode) return fail("CreateStartError", serverMessage || "Headless project creation is not open on this host yet.", "lane-closed", "E_PLATFORM", {
2686
+ hint: laneClosedHint()
2687
+ });
2688
+ return fail("CreateStartError", String(err?.message || "Could not start the device flow."), "create-failed", "E_PLATFORM", {
2689
+ hint: "No device code was issued and nothing was created. Fix what the platform refused and try the list again, or create the projects one extension_project_create call each (project, repo)."
2690
+ });
2691
+ }
2692
+ deviceCode = start.deviceCode;
2693
+ interval = start.interval;
2694
+ budgetMs = FIRST_CALL_BUDGET_MS;
1898
2695
  }
1899
- return null;
1900
- }
1901
- function spendNarration(options) {
1902
- const counted = readPlatformAllowance(options.body);
1903
- return {
1904
- spent: `${options.what} ran on extension.dev's machines and ${FREE_ALLOWANCE_PHRASE}.`,
1905
- remains: counted ? `The platform reports ${counted.used} of ${counted.limit} used.` : "The platform sent no remaining count on this call, and this client never invents one. When the allowance runs out, the platform refuses with its own numbers.",
1906
- wall: `What the free allowance covers and when the paid plan starts are published at ${allowanceWallUrl(options.api)}.`
2696
+ const poll = await pollDeviceGrant({
2697
+ apiBase,
2698
+ path: config.deviceTokenUrl,
2699
+ projects: refs,
2700
+ deviceCode,
2701
+ interval,
2702
+ budgetMs
2703
+ });
2704
+ if (!poll.ok) {
2705
+ if ("pending" === poll.reason) return pendingEnvelope({
2706
+ deviceCode,
2707
+ refs,
2708
+ verificationUri: start?.verificationUri ?? config.verificationUri,
2709
+ userCode: start?.userCode,
2710
+ verificationUriComplete: start?.verificationUriComplete
2711
+ });
2712
+ const code = String(poll.code || "");
2713
+ if ("CLI_PROJECT_CREATE_DISABLED" === code) return fail("CreateClosed", poll.message || "Headless project creation is not open yet.", "lane-closed", "E_PLATFORM", {
2714
+ hint: laneClosedHint()
2715
+ });
2716
+ if ("denied" === poll.reason) return fail("CreateDenied", "Creating the projects was denied at extension.dev/device.", "create-denied", "E_AUTH_DENIED");
2717
+ if ("expired" === poll.reason) return fail("CreateExpired", "The device code expired or was already redeemed. If an earlier call with this deviceCode had started creating projects and this server restarted since, the grant it held is gone: the projects it created still exist.", "create-expired", "E_AUTH_EXPIRED", {
2718
+ hint: `Check which of ${quoteList(refs)} exist in the console at ${consoleBase()}. Sign in to those with extension_auth (action: login, projects), and start a new extension_project_create call naming only the ones that do not exist yet.`
2719
+ });
2720
+ return fail("CreateAuthError", poll.message || "Device authorization failed.", "create-failed", "E_AUTH_FAILED", {
2721
+ value: {
2722
+ ...code ? {
2723
+ code
2724
+ } : {},
2725
+ projects: refs
2726
+ }
2727
+ });
2728
+ }
2729
+ const grant = poll.data;
2730
+ const token = String(grant.token || "").trim();
2731
+ const grantWorkspace = String(grant.workspaceSlug || "").trim().toLowerCase();
2732
+ const grantSlugs = Array.isArray(grant.projectSlugs) ? grant.projectSlugs.map((slug)=>String(slug).toLowerCase()) : null;
2733
+ if ("provisioning" !== String(grant.tokenKind || "") || !grantSlugs || grantWorkspace !== parsed.workspace || !sameProjectSet(grantSlugs, parsed.entries.map((entry)=>entry.slug))) return fail("CreateScopeError", `The approval did not come back as a provisioning grant for exactly ${quoteList(refs)}, so it was discarded and nothing was created. Run extension_project_create again.`, "create-failed", "E_AUTH_FAILED", {
2734
+ hint: "If one of these projects already exists, a create list is refused: sign in to existing projects with extension_auth (action: login, projects) and list only new ones here."
2735
+ });
2736
+ const session = {
2737
+ apiBase,
2738
+ workspace: parsed.workspace,
2739
+ refs,
2740
+ entries: parsed.entries,
2741
+ grant: token,
2742
+ grantExpiresAt: Number(grant.expiresAt || 0) || Math.floor(Date.now() / 1000) + 900,
2743
+ rows: new Map(),
2744
+ busy: false,
2745
+ stop: null,
2746
+ lastBody: null,
2747
+ unanswered: 0
1907
2748
  };
2749
+ sessions.set(deviceCode, session);
2750
+ return runSlice(session, deviceCode, installationId || void 0);
1908
2751
  }
1909
2752
  const project_create_COMMAND = "extension_project_create";
1910
- const FIRST_CALL_BUDGET_MS = 8000;
1911
- const RESUME_BUDGET_MS = 22000;
2753
+ const project_create_FIRST_CALL_BUDGET_MS = 8000;
2754
+ const project_create_RESUME_BUDGET_MS = 22000;
1912
2755
  const project_create_schema = {
1913
2756
  name: "extension_project_create",
1914
- description: "Create an extension.dev project for an extension that does not have one yet, without opening the console. Use it right after extension_create and extension_build, once the extension's source is pushed to a GitHub repository, and BEFORE extension_auth: extension_auth can only log in to a project that already exists, and this tool is what brings that project into existence. Ask for nothing but the project slug and the repo; the platform finds the GitHub App installation on the approving account itself, and if there is none it returns a connect link to open. Two-phase, like login: the first call returns a code and a URL where the signed-in workspace owner approves creating exactly this project; call again with the returned deviceCode to finish. The approval mints a provisioning grant that lives minutes, can only create the one named project, and is never stored on this machine. On success the platform creates the project, its mirror repository, and dispatches the first build. Then run extension_auth (action: login) against the new project, and extension_publish to share it.",
2757
+ description: "Create an extension.dev project for an extension that does not have one yet, without opening the console. Use it right after extension_create and extension_build, once the extension's source is pushed to a GitHub repository, and BEFORE extension_auth: extension_auth can only log in to a project that already exists, and this tool is what brings that project into existence. Ask for nothing but the project slug and the repo; the platform finds the GitHub App installation on the approving account itself, and if there is none it returns a connect link to open. Two-phase, like login: the first call returns a code and a URL where the signed-in workspace owner approves creating exactly this project; call again with the returned deviceCode to finish. The approval mints a provisioning grant that lives minutes, can only create the one named project, and is never stored on this machine. On success the platform creates the project, its mirror repository, and dispatches the first build. Then run extension_auth (action: login) against the new project, and extension_publish to share it. To create several projects in one workspace under one approval, pass `projects` instead of `project` and `repo`: the approval page lists every name, each project is created by its own request, and each one's 7-day token is stored as that project's login, so no extension_auth call is needed afterwards. A list takes a few calls to finish: while projects remain the answer is status 'creating' with the same deviceCode to call again, and the grant is held in this server's memory only. One approval creates at most 10 projects, the cap the platform states in its login config, because it creates at most 10 per hour for one approving account; the next 10 can start in a new call once that limit allows. A longer list is refused before any approval is asked for, never split silently, and so is any list on a platform that does not advertise batch onboarding.",
1915
2758
  inputSchema: {
1916
2759
  type: "object",
1917
2760
  properties: {
@@ -1969,19 +2812,23 @@ const project_create_schema = {
1969
2812
  type: "object",
1970
2813
  description: "Per-browser output directory overrides, for example {\"edge\": \"build/manifestv3\"}. A browser named here wins over `outputDirectory`."
1971
2814
  },
2815
+ projects: {
2816
+ type: "array",
2817
+ items: {
2818
+ type: "object"
2819
+ },
2820
+ description: "Create several projects in one workspace under one approval, instead of `project` and `repo`. 1 to 10 entries (the platform's cap per approval; never more than 20), each { project: '<workspace>/<project>', repo: '<owner>/<repo>' }, all in the same workspace, each project named by its exact slug (lowercase letters and digits joined by single dashes, at most 48 characters), none twice and none existing yet. An entry may also carry displayName, description, installCommand, buildCommand, outputDirectory, browsers and outputDirectories for that project alone; the same inputs at the top level are the shared default for every entry that leaves them out. The answer carries one row per project: created and logged in, created with no token (run a batch extension_auth login), refused with the platform's code, or not attempted. A refusal on one project never hides the others."
2821
+ },
1972
2822
  deviceCode: {
1973
2823
  type: "string",
1974
- description: "Resume token from the prior call's `deviceCode`; omit on the first call."
2824
+ description: "Resume token from the prior call's `deviceCode`; omit on the first call. A batch returns the same deviceCode until every listed project has an answer."
1975
2825
  },
1976
2826
  api: API_BASE
1977
2827
  },
1978
- required: [
1979
- "project",
1980
- "repo"
1981
- ]
2828
+ required: []
1982
2829
  }
1983
2830
  };
1984
- function fail(name, message, status, code, hint) {
2831
+ function project_create_fail(name, message, status, code, hint) {
1985
2832
  return envelope_envelope({
1986
2833
  ok: false,
1987
2834
  command: project_create_COMMAND,
@@ -1996,10 +2843,10 @@ function fail(name, message, status, code, hint) {
1996
2843
  } : {}
1997
2844
  });
1998
2845
  }
1999
- function laneClosedHint() {
2846
+ function project_create_laneClosedHint() {
2000
2847
  return `Create the project in the console at ${consoleBase()} (workspace page, New project), then run extension_auth (action: login) against it. While the public hold is on, headless creation is open only to workspaces on the platform's allowlist (WWW_MCP_ACTION_ALLOWED_WORKSPACES), the same list that already lets publish, submit and promote run headlessly; this workspace is not on it, so the console is the route.`;
2001
2848
  }
2002
- function pendingEnvelope(start) {
2849
+ function project_create_pendingEnvelope(start) {
2003
2850
  const complete = String(start.verificationUriComplete || "").trim();
2004
2851
  const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
2005
2852
  const message = hasCompleteLink ? `Open ${complete} and approve creating the project (code ${start.userCode} is pre-filled), then call extension_project_create again with this deviceCode and the same arguments. If the page asks for a code, enter ${start.userCode} at ${start.verificationUri}.` : `Open ${start.verificationUri}, enter code ${start.userCode}, approve creating the project, then call extension_project_create again with this deviceCode and the same arguments.`;
@@ -2018,79 +2865,26 @@ function pendingEnvelope(start) {
2018
2865
  hint: message
2019
2866
  });
2020
2867
  }
2021
- function buildCreateBody(args) {
2022
- const installCommand = String(args.installCommand || "npm install").trim();
2023
- const buildCommand = String(args.buildCommand || "npm run build").trim();
2024
- const wanted = new Set((Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [
2025
- "chrome"
2026
- ]).map((name)=>String(name).trim().toLowerCase()));
2027
- const several = wanted.size > 1;
2028
- const outputFor = (name)=>{
2029
- const override = args.outputDirectories?.[name];
2030
- if ("string" == typeof override && override.trim()) return override.trim();
2031
- const pattern = String(args.outputDirectory || "").trim();
2032
- if (pattern.includes("<browser>")) return pattern.replaceAll("<browser>", name);
2033
- if (pattern && !several) return pattern;
2034
- return `dist/${name}`;
2035
- };
2036
- const browser = (name)=>({
2037
- enabled: wanted.has(name),
2038
- installCommand,
2039
- buildCommand,
2040
- outputDirectory: outputFor(name)
2041
- });
2042
- return {
2043
- info: {
2044
- id: "",
2045
- name: args.projectSlug,
2046
- displayName: String(args.displayName || args.projectSlug).trim(),
2047
- description: String(args.description || `Browser extension project for ${args.owner}/${args.repoName}.`).trim()
2048
- },
2049
- build: {
2050
- chrome: browser("chrome"),
2051
- edge: browser("edge"),
2052
- firefox: browser("firefox")
2053
- },
2054
- deployment: {
2055
- branch: "",
2056
- nodeVersion: "",
2057
- runWhatsNew: false,
2058
- runExtensionExecutables: false
2059
- },
2060
- github: {
2061
- owner: args.owner,
2062
- repo: args.repoName,
2063
- installationId: args.installationId,
2064
- createdAt: new Date().toISOString(),
2065
- pullRequestComments: true,
2066
- commitComments: false
2067
- },
2068
- workspaceSlug: args.workspace,
2069
- createdFrom: {
2070
- kind: "repository",
2071
- ref: `${args.owner}/${args.repoName}`
2072
- }
2073
- };
2074
- }
2075
2868
  async function project_create_handler(args) {
2869
+ if (void 0 !== args.projects && null !== args.projects) return createProjectBatch(args);
2076
2870
  const project = String(args.project || "").trim();
2077
- if (!/^[^/]+\/[^/]+$/.test(project)) return fail("BadRequest", "project must be in the form '<workspace>/<project>'.", "bad-request", "E_BAD_REQUEST");
2871
+ if (!/^[^/]+\/[^/]+$/.test(project)) return project_create_fail("BadRequest", "project must be in the form '<workspace>/<project>'.", "bad-request", "E_BAD_REQUEST");
2078
2872
  const repo = String(args.repo || "").trim();
2079
- if (!/^[^/]+\/[^/]+$/.test(repo)) return fail("BadRequest", "repo must be in the form '<owner>/<repo>', a GitHub repository the extension's source is pushed to.", "bad-request", "E_BAD_REQUEST");
2873
+ if (!/^[^/]+\/[^/]+$/.test(repo)) return project_create_fail("BadRequest", "repo must be in the form '<owner>/<repo>', a GitHub repository the extension's source is pushed to.", "bad-request", "E_BAD_REQUEST");
2080
2874
  const installationId = String(args.installationId || "").trim();
2081
- if (installationId && !/^\d+$/.test(installationId)) return fail("BadRequest", "installationId is optional, and when given it must be the numeric extension.dev GitHub App installation id. Omit it and the platform resolves it from the approving account.", "bad-request", "E_BAD_REQUEST");
2875
+ if (installationId && !/^\d+$/.test(installationId)) return project_create_fail("BadRequest", "installationId is optional, and when given it must be the numeric extension.dev GitHub App installation id. Omit it and the platform resolves it from the approving account.", "bad-request", "E_BAD_REQUEST");
2082
2876
  const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
2083
- if (!apiCheck.ok) return fail("ConfigError", apiCheck.message, "bad-request", "E_BAD_REQUEST");
2877
+ if (!apiCheck.ok) return project_create_fail("ConfigError", apiCheck.message, "bad-request", "E_BAD_REQUEST");
2084
2878
  const apiBase = apiCheck.base;
2085
2879
  let config;
2086
2880
  try {
2087
2881
  config = await fetchLoginConfig(apiBase);
2088
2882
  } catch (err) {
2089
- return fail("ConfigError", err?.message || "Could not load login config.", "create-failed", "E_PLATFORM");
2883
+ return project_create_fail("ConfigError", err?.message || "Could not load login config.", "create-failed", "E_PLATFORM");
2090
2884
  }
2091
2885
  let deviceCode = String(args.deviceCode || "").trim();
2092
2886
  let interval = 5;
2093
- let budgetMs = RESUME_BUDGET_MS;
2887
+ let budgetMs = project_create_RESUME_BUDGET_MS;
2094
2888
  if (!deviceCode) {
2095
2889
  let start;
2096
2890
  try {
@@ -2104,11 +2898,11 @@ async function project_create_handler(args) {
2104
2898
  const message = err?.message || "Could not start the device flow.";
2105
2899
  const laneClosed = /CLI_PROJECT_CREATE_DISABLED|403/.test(String(message));
2106
2900
  const serverMessage = "string" == typeof err?.serverMessage ? err.serverMessage.trim() : "";
2107
- return fail("CreateStartError", laneClosed ? serverMessage || `Headless project creation is not open on this host yet. Create the project in the console instead, then run extension_auth (action: login) against it. (${message})` : String(message), laneClosed ? "lane-closed" : "create-failed", "E_PLATFORM", laneClosed ? laneClosedHint() : void 0);
2901
+ return project_create_fail("CreateStartError", laneClosed ? serverMessage || `Headless project creation is not open on this host yet. Create the project in the console instead, then run extension_auth (action: login) against it. (${message})` : String(message), laneClosed ? "lane-closed" : "create-failed", "E_PLATFORM", laneClosed ? project_create_laneClosedHint() : void 0);
2108
2902
  }
2109
2903
  deviceCode = start.deviceCode;
2110
2904
  interval = start.interval;
2111
- budgetMs = FIRST_CALL_BUDGET_MS;
2905
+ budgetMs = project_create_FIRST_CALL_BUDGET_MS;
2112
2906
  const early = await pollDeviceGrant({
2113
2907
  apiBase,
2114
2908
  path: config.deviceTokenUrl,
@@ -2117,7 +2911,7 @@ async function project_create_handler(args) {
2117
2911
  interval,
2118
2912
  budgetMs
2119
2913
  });
2120
- if (!early.ok && "pending" === early.reason) return pendingEnvelope(start);
2914
+ if (!early.ok && "pending" === early.reason) return project_create_pendingEnvelope(start);
2121
2915
  return finishFromPoll(early, {
2122
2916
  apiBase,
2123
2917
  project,
@@ -2154,16 +2948,16 @@ async function finishFromPoll(poll, ctx) {
2154
2948
  },
2155
2949
  hint: `Still waiting for approval at ${ctx.verificationUri}. Approve there, then call extension_project_create again with this same deviceCode.`
2156
2950
  });
2157
- if ("denied" === poll.reason) return fail("CreateDenied", "Creating the project was denied at extension.dev/device.", "create-denied", "E_AUTH_DENIED");
2158
- if ("expired" === poll.reason) return fail("CreateExpired", "The device code expired. Run extension_project_create again to restart.", "create-expired", "E_AUTH_EXPIRED");
2159
- return fail("CreateAuthError", poll.message || "Device authorization failed.", "create-failed", "E_AUTH_FAILED");
2951
+ if ("denied" === poll.reason) return project_create_fail("CreateDenied", "Creating the project was denied at extension.dev/device.", "create-denied", "E_AUTH_DENIED");
2952
+ if ("expired" === poll.reason) return project_create_fail("CreateExpired", "The device code expired. Run extension_project_create again to restart.", "create-expired", "E_AUTH_EXPIRED");
2953
+ return project_create_fail("CreateAuthError", poll.message || "Device authorization failed.", "create-failed", "E_AUTH_FAILED");
2160
2954
  }
2161
2955
  const grant = poll.data;
2162
2956
  const token = String(grant.token || "").trim();
2163
2957
  const workspaceSlug = String(grant.workspaceSlug || "").trim();
2164
2958
  const projectSlug = String(grant.projectSlug || "").trim();
2165
2959
  const [wantWorkspace = "", wantProject = ""] = ctx.project.split("/");
2166
- if (workspaceSlug.toLowerCase() !== wantWorkspace.toLowerCase() || projectSlug.toLowerCase() !== wantProject.toLowerCase()) return fail("CreateScopeError", `The approval was scoped to ${workspaceSlug}/${projectSlug}, not the requested ${ctx.project}. Nothing was created. Run extension_project_create again with the intended project.`, "create-failed", "E_AUTH_FAILED");
2960
+ if (workspaceSlug.toLowerCase() !== wantWorkspace.toLowerCase() || projectSlug.toLowerCase() !== wantProject.toLowerCase()) return project_create_fail("CreateScopeError", `The approval was scoped to ${workspaceSlug}/${projectSlug}, not the requested ${ctx.project}. Nothing was created. Run extension_project_create again with the intended project.`, "create-failed", "E_AUTH_FAILED");
2167
2961
  if ("provisioning" !== String(grant.tokenKind || "")) return envelope_envelope({
2168
2962
  ok: false,
2169
2963
  command: project_create_COMMAND,
@@ -2174,7 +2968,7 @@ async function finishFromPoll(poll, ctx) {
2174
2968
  },
2175
2969
  hint: `Run extension_auth (action: login) with project '${ctx.project}' instead; the login lane mints the project token this tool deliberately does not.`
2176
2970
  });
2177
- const [owner = "", repoName = ""] = ctx.args.repo.split("/");
2971
+ const [owner = "", repoName = ""] = String(ctx.args.repo || "").split("/");
2178
2972
  const body = buildCreateBody({
2179
2973
  workspace: wantWorkspace,
2180
2974
  projectSlug: wantProject,
@@ -2202,7 +2996,7 @@ async function finishFromPoll(poll, ctx) {
2202
2996
  body: JSON.stringify(body)
2203
2997
  });
2204
2998
  } catch (err) {
2205
- return fail("CreateNetworkError", `Could not reach ${url}: ${err?.message || err}`, "create-failed", "E_NETWORK");
2999
+ return project_create_fail("CreateNetworkError", `Could not reach ${url}: ${err?.message || err}`, "create-failed", "E_NETWORK");
2206
3000
  }
2207
3001
  const text = await res.text();
2208
3002
  let data;
@@ -2224,7 +3018,7 @@ async function finishFromPoll(poll, ctx) {
2224
3018
  project: wantProject
2225
3019
  }
2226
3020
  });
2227
- if ("CLI_PROJECT_CREATE_DISABLED" === code) return fail("CreateClosed", String(data.message || "Headless project creation is not open yet."), "lane-closed", "E_PLATFORM", laneClosedHint());
3021
+ if ("CLI_PROJECT_CREATE_DISABLED" === code) return project_create_fail("CreateClosed", String(data.message || "Headless project creation is not open yet."), "lane-closed", "E_PLATFORM", project_create_laneClosedHint());
2228
3022
  const connectUrl = String(data.connectUrl || "").trim();
2229
3023
  if ("INSTALLATION_ABSENT" === code || "INSTALLATION_ORG_UNSUPPORTED" === code || "INSTALLATION_AMBIGUOUS" === code) return envelope_envelope({
2230
3024
  ok: false,
@@ -2252,7 +3046,7 @@ async function finishFromPoll(poll, ctx) {
2252
3046
  },
2253
3047
  hint: `Run extension_auth (action: login) with project '${ctx.project}'.`
2254
3048
  });
2255
- return fail("CreateError", `create failed (${res.status}): ${String(data.message || data.error || text || "unknown error").slice(0, 500)}`, "create-failed", "E_PLATFORM");
3049
+ return project_create_fail("CreateError", `create failed (${res.status}): ${String(data.message || data.error || text || "unknown error").slice(0, 500)}`, "create-failed", "E_PLATFORM");
2256
3050
  }
2257
3051
  const finalWorkspace = String(data.workspaceSlug || wantWorkspace);
2258
3052
  const finalProject = String(data.projectSlug || wantProject);
@@ -3053,7 +3847,7 @@ async function outputFlagRefusalMessage(command, cliName, projectPath) {
3053
3847
  if (true === verdict.supported) return `${preamble} It reports ${verdict.version}, which is at or above ${verdict.floor}, the release where that flag reached \`extension ${cliName}\`, so the binary being run is not the version it claims to be. ${why} Check the project's node_modules/.bin/extension and reinstall it, rather than upgrading a version that already looks new enough.`;
3054
3848
  return `${preamble} Its version could not be read, so the cause cannot be confirmed, but that flag only reached \`extension ${cliName}\` in ${verdict.floor} and a refusal is what an engine below that does. ${why} Check the project's Extension.js install and bring it to ${verdict.floor} or newer.`;
3055
3849
  }
3056
- const sessions = new Map();
3850
+ const process_manager_sessions = new Map();
3057
3851
  const registrationStamps = new Map();
3058
3852
  function sessionKey(projectPath, browser) {
3059
3853
  return `${node_path.resolve(projectPath)}::${browser}`;
@@ -3114,7 +3908,7 @@ function registerSession(info) {
3114
3908
  pid: info.pid,
3115
3909
  at
3116
3910
  });
3117
- sessions.set(key, info);
3911
+ process_manager_sessions.set(key, info);
3118
3912
  writeMarkerBestEffort(info, at);
3119
3913
  }
3120
3914
  function sessionSinceMs(projectPath, browser) {
@@ -3132,19 +3926,19 @@ function sessionSinceMs(projectPath, browser) {
3132
3926
  return null;
3133
3927
  }
3134
3928
  function findSessionInfo(projectPath, browser) {
3135
- const inMemory = sessions.get(sessionKey(projectPath, browser));
3929
+ const inMemory = process_manager_sessions.get(sessionKey(projectPath, browser));
3136
3930
  if (inMemory) return inMemory;
3137
3931
  const resolved = node_path.resolve(projectPath);
3138
3932
  for (const marker of listSessionMarkers())if (node_path.resolve(marker.projectPath) === resolved && marker.browser === browser) return marker;
3139
3933
  }
3140
3934
  function removeSession(projectPath, browser, pid) {
3141
3935
  const key = sessionKey(projectPath, browser);
3142
- if (void 0 !== pid && sessions.get(key)?.pid !== pid) return;
3143
- sessions.delete(key);
3936
+ if (void 0 !== pid && process_manager_sessions.get(key)?.pid !== pid) return;
3937
+ process_manager_sessions.delete(key);
3144
3938
  registrationStamps.delete(key);
3145
3939
  }
3146
3940
  function listSessions() {
3147
- return Array.from(sessions.values());
3941
+ return Array.from(process_manager_sessions.values());
3148
3942
  }
3149
3943
  const PROJECT_MANIFEST_FILENAMES = [
3150
3944
  "package.json",
@@ -13567,46 +14361,263 @@ async function rdpCollectConsoleMessages(port, options) {
13567
14361
  if (!watcherActor) throw new Error("tab descriptor returned no watcher");
13568
14362
  const messages = [];
13569
14363
  const untap = session.tap((packet)=>{
13570
- if ("resources-available-array" !== packet.type) return;
13571
- const array = Array.isArray(packet.array) ? packet.array : [];
13572
- for (const entry of array){
13573
- if (!Array.isArray(entry) || entry.length < 2) continue;
13574
- const [resourceType, resources] = entry;
13575
- if (Array.isArray(resources)) for (const raw of resources){
13576
- const resource = raw;
13577
- if ("console-message" === resourceType) {
13578
- const args = Array.isArray(resource.arguments) ? resource.arguments : [];
13579
- messages.push({
13580
- level: String(resource.level ?? "log"),
13581
- text: args.map(formatConsoleArg).join(" ")
13582
- });
13583
- } else if ("error-message" === resourceType) {
13584
- const pageError = resource.pageError ?? {};
13585
- messages.push({
13586
- level: true === pageError.warning ? "warn" : "error",
13587
- text: String(pageError.errorMessage ?? "")
13588
- });
13589
- }
13590
- }
13591
- }
14364
+ if ("resources-available-array" !== packet.type) return;
14365
+ const array = Array.isArray(packet.array) ? packet.array : [];
14366
+ for (const entry of array){
14367
+ if (!Array.isArray(entry) || entry.length < 2) continue;
14368
+ const [resourceType, resources] = entry;
14369
+ if (Array.isArray(resources)) for (const raw of resources){
14370
+ const resource = raw;
14371
+ if ("console-message" === resourceType) {
14372
+ const args = Array.isArray(resource.arguments) ? resource.arguments : [];
14373
+ messages.push({
14374
+ level: String(resource.level ?? "log"),
14375
+ text: args.map(formatConsoleArg).join(" ")
14376
+ });
14377
+ } else if ("error-message" === resourceType) {
14378
+ const pageError = resource.pageError ?? {};
14379
+ messages.push({
14380
+ level: true === pageError.warning ? "warn" : "error",
14381
+ text: String(pageError.errorMessage ?? "")
14382
+ });
14383
+ }
14384
+ }
14385
+ }
14386
+ });
14387
+ try {
14388
+ await session.request(watcherActor, {
14389
+ type: "watchTargets",
14390
+ targetType: "frame"
14391
+ }, timeoutMs);
14392
+ await session.request(watcherActor, {
14393
+ type: "watchResources",
14394
+ resourceTypes: [
14395
+ "console-message",
14396
+ "error-message"
14397
+ ]
14398
+ }, timeoutMs);
14399
+ await new Promise((resolve)=>setTimeout(resolve, settleMs));
14400
+ } finally{
14401
+ untap();
14402
+ }
14403
+ return messages;
14404
+ });
14405
+ }
14406
+ const RDP_EVAL_SLOT = "__extensionDevRdpEval";
14407
+ const RDP_FRAME_WAIT_MS = 5000;
14408
+ const RDP_FRAME_POLL_MS = 50;
14409
+ const RDP_RESULT_POLL_MS = 100;
14410
+ function rdpStartExpression(expression, token) {
14411
+ const key = JSON.stringify(token);
14412
+ return `(function () {
14413
+ var store = globalThis.${RDP_EVAL_SLOT} = globalThis.${RDP_EVAL_SLOT} || {};
14414
+ store[${key}] = { state: "pending" };
14415
+ function settle(next) { store[${key}] = next; }
14416
+ function encode(value) {
14417
+ if (value === undefined) return { state: "value" };
14418
+ var json;
14419
+ try { json = JSON.stringify(value); } catch (error) { json = undefined; }
14420
+ return { state: "value", json: json === undefined ? JSON.stringify(String(value)) : json };
14421
+ }
14422
+ function thrown(error) {
14423
+ return { state: "throw", name: (error && error.name) || "EvalError", message: (error && error.message) || String(error) };
14424
+ }
14425
+ try {
14426
+ Promise.resolve((${expression}
14427
+ )).then(function (value) { settle(encode(value)); }, function (error) { settle(thrown(error)); });
14428
+ } catch (error) {
14429
+ settle(thrown(error));
14430
+ }
14431
+ return "started";
14432
+ })()`;
14433
+ }
14434
+ function rdpPollExpression(token) {
14435
+ const key = JSON.stringify(token);
14436
+ return `(function () {
14437
+ var store = globalThis.${RDP_EVAL_SLOT};
14438
+ var entry = (store && store[${key}]) || null;
14439
+ if (entry && entry.state !== "pending") {
14440
+ delete store[${key}];
14441
+ if (Object.keys(store).length === 0) { try { delete globalThis.${RDP_EVAL_SLOT}; } catch (error) {} }
14442
+ }
14443
+ var encoded = JSON.stringify(entry);
14444
+ return encoded;
14445
+ })()`;
14446
+ }
14447
+ function readRdpEvalSlot(raw) {
14448
+ if ("string" != typeof raw) return "lost";
14449
+ let slot;
14450
+ try {
14451
+ slot = JSON.parse(raw);
14452
+ } catch {
14453
+ return "lost";
14454
+ }
14455
+ if (!slot || "object" != typeof slot) return "lost";
14456
+ if ("pending" === slot.state) return "pending";
14457
+ if ("throw" === slot.state) return {
14458
+ ok: false,
14459
+ name: "string" == typeof slot.name && slot.name ? slot.name : "EvalError",
14460
+ message: "string" == typeof slot.message && slot.message ? slot.message : "the expression threw inside the document"
14461
+ };
14462
+ if ("value" !== slot.state) return "lost";
14463
+ if ("string" != typeof slot.json) return {
14464
+ ok: true,
14465
+ value: null
14466
+ };
14467
+ try {
14468
+ return {
14469
+ ok: true,
14470
+ value: JSON.parse(slot.json)
14471
+ };
14472
+ } catch {
14473
+ return {
14474
+ ok: true,
14475
+ value: slot.json
14476
+ };
14477
+ }
14478
+ }
14479
+ function exceptionText(packet) {
14480
+ const message = packet.exceptionMessage;
14481
+ if ("string" == typeof message) return message;
14482
+ const initial = message?.initial;
14483
+ if ("string" == typeof initial) return initial;
14484
+ return true === packet.hasException || void 0 !== packet.exception && null !== packet.exception ? "the expression threw inside the document" : "";
14485
+ }
14486
+ async function readStringResult(session, result, timeoutMs) {
14487
+ if (!result || "object" != typeof result) return result;
14488
+ const grip = result;
14489
+ if ("longString" !== grip.type || "string" != typeof grip.actor) return result;
14490
+ const length = "number" == typeof grip.length ? grip.length : 0;
14491
+ const reply = await session.request(grip.actor, {
14492
+ type: "substring",
14493
+ start: 0,
14494
+ end: length
14495
+ }, timeoutMs);
14496
+ return "string" == typeof reply.substring ? reply.substring : grip.initial;
14497
+ }
14498
+ async function consoleEvaluate(session, consoleActor, text, timeoutMs) {
14499
+ const results = [];
14500
+ let wake = null;
14501
+ const untap = session.tap((packet)=>{
14502
+ if (packet.from !== consoleActor || "evaluationResult" !== packet.type) return;
14503
+ results.push(packet);
14504
+ wake?.();
14505
+ });
14506
+ try {
14507
+ const reply = await session.request(consoleActor, {
14508
+ type: "evaluateJSAsync",
14509
+ text
14510
+ }, timeoutMs);
14511
+ const resultId = String(reply.resultID ?? "");
14512
+ const deadline = Date.now() + timeoutMs;
14513
+ for(;;){
14514
+ const hit = results.find((p)=>String(p.resultID ?? "") === resultId);
14515
+ if (hit) return {
14516
+ result: await readStringResult(session, hit.result, timeoutMs),
14517
+ threw: exceptionText(hit)
14518
+ };
14519
+ const left = deadline - Date.now();
14520
+ if (left <= 0) throw new Error(`Firefox sent no evaluation result within ${timeoutMs}ms`);
14521
+ await new Promise((resolve)=>{
14522
+ const timer = setTimeout(resolve, Math.min(left, 250));
14523
+ wake = ()=>{
14524
+ clearTimeout(timer);
14525
+ resolve();
14526
+ };
14527
+ });
14528
+ wake = null;
14529
+ }
14530
+ } finally{
14531
+ untap();
14532
+ }
14533
+ }
14534
+ function topFrameConsoleActor(frames) {
14535
+ const usable = frames.filter((frame)=>"string" == typeof frame.consoleActor);
14536
+ const top = usable.find((frame)=>true === frame.isTopLevelTarget) ?? usable[0];
14537
+ return top ? String(top.consoleActor) : void 0;
14538
+ }
14539
+ async function rdpEvaluateInTab(port, options) {
14540
+ const timeoutMs = options.timeoutMs ?? 10000;
14541
+ return withSession(port, timeoutMs, async (session)=>{
14542
+ const listed = await session.request("root", {
14543
+ type: "listTabs"
14544
+ }, timeoutMs);
14545
+ const tab = options.select(listed.tabs ?? []);
14546
+ if (!tab?.actor) return {
14547
+ ok: false,
14548
+ name: "TargetNotFound",
14549
+ message: "no open tab matches"
14550
+ };
14551
+ const watcher = await session.request(String(tab.actor), {
14552
+ type: "getWatcher",
14553
+ isServerTargetSwitchingEnabled: true
14554
+ }, timeoutMs);
14555
+ const watcherActor = String(watcher.actor ?? "");
14556
+ if (!watcherActor) return {
14557
+ ok: false,
14558
+ name: "Unsupported",
14559
+ message: "this Firefox build exposes no watcher actor for a tab, so the tab cannot be evaluated over the debugger protocol"
14560
+ };
14561
+ const frames = [];
14562
+ const untap = session.tap((packet)=>{
14563
+ if ("target-available-form" !== packet.type) return;
14564
+ const target = packet.target;
14565
+ if (target && "object" == typeof target) frames.push(target);
13592
14566
  });
13593
14567
  try {
13594
14568
  await session.request(watcherActor, {
13595
14569
  type: "watchTargets",
13596
14570
  targetType: "frame"
13597
14571
  }, timeoutMs);
13598
- await session.request(watcherActor, {
13599
- type: "watchResources",
13600
- resourceTypes: [
13601
- "console-message",
13602
- "error-message"
13603
- ]
13604
- }, timeoutMs);
13605
- await new Promise((resolve)=>setTimeout(resolve, settleMs));
14572
+ const frameDeadline = Date.now() + Math.min(timeoutMs, RDP_FRAME_WAIT_MS);
14573
+ let consoleActor = topFrameConsoleActor(frames);
14574
+ while(!consoleActor && Date.now() < frameDeadline){
14575
+ await new Promise((resolve)=>setTimeout(resolve, RDP_FRAME_POLL_MS));
14576
+ consoleActor = topFrameConsoleActor(frames);
14577
+ }
14578
+ if (!consoleActor) return {
14579
+ ok: false,
14580
+ name: "TargetNotFound",
14581
+ message: "the tab announced no document to evaluate in (it may still be loading)"
14582
+ };
14583
+ const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
14584
+ const started = await consoleEvaluate(session, consoleActor, rdpStartExpression(options.expression, token), timeoutMs);
14585
+ if (started.threw) return {
14586
+ ok: false,
14587
+ name: "EvalError",
14588
+ message: started.threw
14589
+ };
14590
+ const resultDeadline = Date.now() + timeoutMs;
14591
+ for(;;){
14592
+ const polled = await consoleEvaluate(session, consoleActor, rdpPollExpression(token), timeoutMs);
14593
+ const settled = readRdpEvalSlot(polled.result);
14594
+ if ("lost" === settled) return {
14595
+ ok: false,
14596
+ name: "EvalLost",
14597
+ message: "the document reloaded or navigated before the expression settled, so its result is gone"
14598
+ };
14599
+ if ("pending" !== settled) return settled.ok ? {
14600
+ ok: true,
14601
+ value: settled.value,
14602
+ tab: {
14603
+ url: String(tab.url ?? ""),
14604
+ title: String(tab.title ?? "")
14605
+ }
14606
+ } : settled;
14607
+ if (Date.now() >= resultDeadline) return {
14608
+ ok: false,
14609
+ name: "Timeout",
14610
+ message: `the expression did not settle within ${timeoutMs}ms`
14611
+ };
14612
+ await new Promise((resolve)=>setTimeout(resolve, RDP_RESULT_POLL_MS));
14613
+ }
13606
14614
  } finally{
13607
14615
  untap();
14616
+ session.request(watcherActor, {
14617
+ type: "unwatchTargets",
14618
+ targetType: "frame"
14619
+ }, 1000).catch(()=>{});
13608
14620
  }
13609
- return messages;
13610
14621
  });
13611
14622
  }
13612
14623
  const inspect_schema_schema = {
@@ -14443,7 +15454,7 @@ function readRelayFrame(value) {
14443
15454
  }
14444
15455
  const eval_schema = {
14445
15456
  name: "extension_eval",
14446
- description: "Evaluate an expression in a running extension context. Start the session with allowEval:true (extension_dev), which writes a 0600 session token. Context defaults to 'background', except on a Chromium MV3 session (the default template) where it defaults to 'page', the active tab, because the MV3 service worker CSP blocks eval; pass context:'background' to target the worker anyway and get that explanation back. For content and page, pass `url` to pick the tab, or omit both `url` and `tab` for the active tab; a numeric `tab` only disambiguates. Extension surfaces (popup, options, sidebar, devtools) and override pages (newtab, history, bookmarks) need no tab id but must already be open: open one with extension_open first, because a closed one returns an explicit error. On a Chromium MV3 session those pages, and context:'page' with a chrome-extension:// url, evaluate over CDP, the inspector path the extension page CSP does not govern; elsewhere they evaluate over the in-bundle relay. Call extension_dom_snapshot with listTabs:true to enumerate {tabId, url, title}.",
15457
+ description: "Evaluate an expression in a running extension context. Start the session with allowEval:true (extension_dev), which writes a 0600 session token. Context defaults to 'background', except on a Chromium MV3 session (the default template) where it defaults to 'page', the active tab, because the MV3 service worker CSP blocks eval; pass context:'background' to target the worker anyway and get that explanation back. For content and page, pass `url` to pick the tab, or omit both `url` and `tab` for the active tab; a numeric `tab` only disambiguates. Extension surfaces (popup, options, sidebar, devtools) and override pages (newtab, history, bookmarks) need no tab id but must already be open: open one with extension_open first, because a closed one returns an explicit error. On a Chromium MV3 session those pages, and context:'page' with a chrome-extension:// url, evaluate over CDP, the inspector path the extension page CSP does not govern; elsewhere they evaluate over the in-bundle relay. On Firefox a document whose content security policy forbids eval (the extension's own pages, or a site's) is evaluated over the debugger protocol instead, which takes one expression; a page inside the extension that is no declared surface (pages/*) is reached the same way by context:'page' and its moz-extension:// url once a tab shows it. Call extension_dom_snapshot with listTabs:true to enumerate {tabId, url, title}.",
14447
15458
  inputSchema: {
14448
15459
  type: "object",
14449
15460
  properties: {
@@ -14764,6 +15775,7 @@ async function evaluateThroughRelay(args, browser, context) {
14764
15775
  ], args.projectPath, args.timeout, eval_schema.name);
14765
15776
  let raw = await run(relaySafeExpression(args.expression, token));
14766
15777
  let parsed = tryParseFrame(raw);
15778
+ if (isGeckoFamily(browser) && cspRefusedFrame(parsed)) return evaluatePastSurfaceCsp(args, context, run);
14767
15779
  if (!parsed || true !== parsed.ok) return raw;
14768
15780
  let frame = readRelayFrame(parsed.value);
14769
15781
  if (!frame) return raw;
@@ -14810,21 +15822,28 @@ async function evaluateThroughRelay(args, browser, context) {
14810
15822
  return actFrameJson(parsed);
14811
15823
  }
14812
15824
  const CSP_EVAL_REFUSAL = /blocked by CSP|call to eval|unsafe-eval|Content Security Policy/i;
15825
+ function cspRefusedFrame(parsed) {
15826
+ if (!parsed || false !== parsed.ok) return false;
15827
+ const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
15828
+ if ("E_CSP_BLOCKS_EVAL" === code) return true;
15829
+ return "E_EVAL" === code && CSP_EVAL_REFUSAL.test(String(parsed.error?.message ?? ""));
15830
+ }
15831
+ function cspRefusalHint(context) {
15832
+ if ("page" === context) return "This page's own Content-Security-Policy forbids eval, so the in-page executor cannot run any expression in it; this is the site's policy, not a fault in the expression. Pass url so the tab is evaluated through the browser's debugger, which the policy does not govern (CDP on Chromium, the debugger protocol on Firefox), or read the page with extension_dom_snapshot or extension_inspect.";
15833
+ if ("content" === context) return "The extension's content_security_policy forbids eval in its content-script world on this engine, so no string runs there; this is the extension's policy, not a fault in the expression. Evaluate the page itself with context: \"page\" and a url, or read the DOM the content script sees with extension_dom_snapshot or extension_inspect.";
15834
+ return `The extension's content_security_policy (or the MV3 default) forbids eval in its own ${context ?? "background"} context, so the in-page executor cannot run any expression there; this is the extension's policy, not a fault in the expression. Read the page instead with extension_dom_snapshot or extension_inspect, evaluate a web page with context: \"page\" and a url, or read the extension's console with extension_logs. On a Chromium MV3 session the extension's own pages evaluate over CDP, which the page policy does not govern. On Firefox, Extension.js 4.1.31 and later evaluate them over the debugger protocol instead: if this session runs an older engine, upgrade the project's extension dependency and restart it.`;
15835
+ }
14813
15836
  function explainCspRefusal(raw, context) {
14814
15837
  const parsed = tryParseFrame(raw);
14815
- if (!parsed || false !== parsed.ok) return null;
14816
- const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
14817
- const message = String(parsed.error?.message ?? "");
14818
- if ("E_EVAL" !== code || !CSP_EVAL_REFUSAL.test(message)) return null;
15838
+ if (!parsed || !cspRefusedFrame(parsed)) return null;
14819
15839
  parsed.error.name = "CspBlocksEval";
14820
- parsed.hint = `The extension's content_security_policy (or the MV3 default) forbids eval in its own ${context ?? "background"} context, so the in-page executor cannot run any expression there; this is the extension's policy, not a fault in the expression. Read the page instead with extension_dom_snapshot or extension_inspect, evaluate a web page with context: \"page\" and a url, or read the extension's console with extension_logs. On a Chromium MV3 session the extension's own pages evaluate over CDP, which the page policy does not govern.`;
15840
+ parsed.hint = cspRefusalHint(context);
14821
15841
  if ("string" == typeof parsed.error.hint) delete parsed.error.hint;
14822
15842
  return actFrameJson(parsed);
14823
15843
  }
14824
15844
  const NO_SCRIPTING_API = /scripting is not available/i;
14825
15845
  function pageEvalRefusedByCsp(parsed) {
14826
- const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
14827
- return "E_EVAL" === code && CSP_EVAL_REFUSAL.test(String(parsed.error?.message ?? ""));
15846
+ return cspRefusedFrame(parsed);
14828
15847
  }
14829
15848
  function isSingleExpression(source) {
14830
15849
  try {
@@ -14834,6 +15853,110 @@ function isSingleExpression(source) {
14834
15853
  return false;
14835
15854
  }
14836
15855
  }
15856
+ function notOneExpression() {
15857
+ return envelope_envelope({
15858
+ ok: false,
15859
+ command: eval_schema.name,
15860
+ status: "bad-request",
15861
+ error: {
15862
+ code: "E_BAD_REQUEST",
15863
+ name: "NotOneExpression",
15864
+ message: "This document's content security policy forbids eval, so Firefox evaluates it over the debugger protocol, which takes one expression, and this input is a statement list."
15865
+ },
15866
+ hint: "Pass one expression. Wrap statements in a function that returns the value, for example (() => { const a = 20; return a + 1; })()."
15867
+ });
15868
+ }
15869
+ async function evaluatePastSurfaceCsp(args, context, run) {
15870
+ if (!isSingleExpression(args.expression)) return notOneExpression();
15871
+ const direct = await run(args.expression);
15872
+ const frame = tryParseFrame(direct);
15873
+ if (!frame) return direct;
15874
+ if (cspRefusedFrame(frame)) return explainCspRefusal(direct, context) ?? direct;
15875
+ if (true === frame.ok && void 0 === frame.value) {
15876
+ frame.value = null;
15877
+ return actFrameJson(frame);
15878
+ }
15879
+ return direct;
15880
+ }
15881
+ const sameDocumentUrl = (a, b)=>String(a ?? "").replace(/[?#].*$/, "") === b.replace(/[?#].*$/, "");
15882
+ function tabByUrl(tabs, url) {
15883
+ const exact = tabs.filter((tab)=>sameDocumentUrl(tab.url, url));
15884
+ const covered = exact.length ? exact : tabs.filter((tab)=>matchPatternCovers(url, String(tab.url ?? "")));
15885
+ const candidates = covered.length ? covered : tabs.filter((tab)=>String(tab.url ?? "").includes(url));
15886
+ return candidates.find((tab)=>true === tab.selected) ?? candidates[0];
15887
+ }
15888
+ async function evaluateInGeckoTab(args, browser, select, missing) {
15889
+ const resolved = await resolveRdpPort(args.projectPath, browser, {
15890
+ waitMs: 3000,
15891
+ graceMs: 1000
15892
+ });
15893
+ if (!resolved) return null;
15894
+ let outcome;
15895
+ try {
15896
+ outcome = await rdpEvaluateInTab(resolved.port, {
15897
+ select,
15898
+ expression: args.expression,
15899
+ timeoutMs: args.timeout ?? 10000
15900
+ });
15901
+ } catch {
15902
+ return null;
15903
+ }
15904
+ if (outcome.ok) return envelope_envelope({
15905
+ ok: true,
15906
+ command: eval_schema.name,
15907
+ status: "ok",
15908
+ value: void 0 === outcome.value ? null : outcome.value
15909
+ });
15910
+ if ("TargetNotFound" === outcome.name) return envelope_envelope({
15911
+ ok: false,
15912
+ command: eval_schema.name,
15913
+ status: "no-matching-target",
15914
+ error: {
15915
+ code: "E_NO_MATCHING_TARGET",
15916
+ name: "NoMatchingTarget",
15917
+ message: missing
15918
+ },
15919
+ hint: "Open the page first with extension_open and its url, then retry; extension_dom_snapshot with listTabs: true lists what is open."
15920
+ });
15921
+ if ("Timeout" === outcome.name) return envelope_envelope({
15922
+ ok: false,
15923
+ command: eval_schema.name,
15924
+ status: "eval-pending",
15925
+ error: {
15926
+ code: "E_WAIT_TIMEOUT",
15927
+ name: "EvalPending",
15928
+ message: outcome.message
15929
+ },
15930
+ hint: "The expression returned a promise that is still pending; pass a larger timeout to wait for it."
15931
+ });
15932
+ return envelope_envelope({
15933
+ ok: false,
15934
+ command: eval_schema.name,
15935
+ status: "eval-failed",
15936
+ error: {
15937
+ code: "E_EVAL",
15938
+ name: outcome.name,
15939
+ message: outcome.message
15940
+ },
15941
+ hint: "The expression threw inside the document. It ran over the debugger protocol, which the document's content security policy does not govern, so this is the expression's own error."
15942
+ });
15943
+ }
15944
+ function backgroundRouteRefusedStatements(args, browser, context, raw) {
15945
+ if (!isGeckoFamily(browser) || (context ?? "background") !== "background") return false;
15946
+ const failed = tryParseFrame(raw);
15947
+ if (!failed || false !== failed.ok) return false;
15948
+ if (failed.error?.name !== "Unavailable") return false;
15949
+ if (!/SyntaxError/.test(String(failed.error?.message ?? ""))) return false;
15950
+ return !isSingleExpression(args.expression);
15951
+ }
15952
+ async function evaluatePagePastSiteCsp(args, browser, context, raw) {
15953
+ if ("page" !== context || !isGeckoFamily(browser)) return null;
15954
+ if (!cspRefusedFrame(tryParseFrame(raw))) return null;
15955
+ if (!args.url && null != args.tab) return null;
15956
+ if (!isSingleExpression(args.expression)) return notOneExpression();
15957
+ const url = args.url;
15958
+ return evaluateInGeckoTab(args, browser, url ? (tabs)=>tabByUrl(tabs, url) : (tabs)=>tabs.find((tab)=>true === tab.selected), url ? `No open tab matches url: ${url}` : "No tab is selected in the dev browser.");
15959
+ }
14837
15960
  async function evaluateThroughExecuteScript(args, browser, context, raw) {
14838
15961
  if ("page" !== context && "content" !== context) return null;
14839
15962
  if (isChromiumFamily(browser)) return null;
@@ -14928,6 +16051,12 @@ async function eval_handler(args) {
14928
16051
  }
14929
16052
  return raw;
14930
16053
  }
16054
+ if (isGeckoFamily(browser)) {
16055
+ if (!isSingleExpression(args.expression)) return notOneExpression();
16056
+ const url = args.url;
16057
+ const overProtocol = await evaluateInGeckoTab(args, browser, (tabs)=>tabs.find((tab)=>sameDocumentUrl(tab.url, url)), `No open tab shows ${url}.`);
16058
+ if (null !== overProtocol) return overProtocol;
16059
+ }
14931
16060
  const declared = declaredSurfaces(args.projectPath, browser) ?? [];
14932
16061
  return envelope_envelope({
14933
16062
  ok: false,
@@ -14955,6 +16084,9 @@ async function eval_handler(args) {
14955
16084
  ], args.projectPath, args.timeout, eval_schema.name);
14956
16085
  const mv2Fallback = await evaluateThroughExecuteScript(args, browser, context, raw);
14957
16086
  if (null !== mv2Fallback) return mv2Fallback;
16087
+ const pastSiteCsp = await evaluatePagePastSiteCsp(args, browser, context, raw);
16088
+ if (null !== pastSiteCsp) return pastSiteCsp;
16089
+ if (backgroundRouteRefusedStatements(args, browser, context, raw)) return notOneExpression();
14958
16090
  const cspRefusal = explainCspRefusal(raw, context);
14959
16091
  if (null !== cspRefusal) return cspRefusal;
14960
16092
  const trustedTypes = tryParseFrame(raw);
@@ -15191,6 +16323,72 @@ const dom_snapshot_schema = {
15191
16323
  ]
15192
16324
  }
15193
16325
  };
16326
+ const GECKO_EXTENSION_URL = /^moz-extension:\/\//;
16327
+ const dom_snapshot_sameDocumentUrl = (a, b)=>String(a ?? "").replace(/[?#].*$/, "") === b.replace(/[?#].*$/, "");
16328
+ function protocolSnapshotExpression(wantHtml, maxBytes) {
16329
+ return `(function () {
16330
+ var doc = document;
16331
+ function countShadow(root) {
16332
+ var n = 0, els = root.querySelectorAll("*");
16333
+ for (var i = 0; i < els.length; i++) { if (els[i].shadowRoot) { n++; n += countShadow(els[i].shadowRoot); } }
16334
+ return n;
16335
+ }
16336
+ var html = doc.documentElement.outerHTML;
16337
+ var snap = {
16338
+ context: "page",
16339
+ url: String(location.href),
16340
+ title: doc.title,
16341
+ summary: {
16342
+ htmlLength: html.length,
16343
+ scriptCount: doc.querySelectorAll("script").length,
16344
+ styleCount: doc.querySelectorAll("style,link[rel=stylesheet]").length,
16345
+ extensionRootCount: doc.querySelectorAll("#extension-root,[data-extension-root]").length,
16346
+ openShadowRoots: countShadow(doc),
16347
+ bodyChildCount: doc.body ? doc.body.children.length : 0
16348
+ }
16349
+ };
16350
+ if (${wantHtml ? "true" : "false"}) {
16351
+ var cap = ${Math.max(0, Math.floor(maxBytes))};
16352
+ if (cap > 0 && html.length > cap) { snap.html = html.slice(0, cap); snap.htmlTruncated = true; }
16353
+ else { snap.html = html; }
16354
+ }
16355
+ return snap;
16356
+ })()`;
16357
+ }
16358
+ async function snapshotExtensionPageOverProtocol(args, browser, url, raw, consoleAsked) {
16359
+ if (!url || !isGeckoFamily(browser) || !GECKO_EXTENSION_URL.test(url)) return null;
16360
+ try {
16361
+ const refused = JSON.parse(raw);
16362
+ if (!refused || false !== refused.ok) return null;
16363
+ } catch {
16364
+ return null;
16365
+ }
16366
+ const resolved = await resolveRdpPort(args.projectPath, browser, {
16367
+ waitMs: 3000,
16368
+ graceMs: 1000
16369
+ });
16370
+ if (!resolved) return null;
16371
+ const wantHtml = args.include?.includes("html") === true;
16372
+ try {
16373
+ const outcome = await rdpEvaluateInTab(resolved.port, {
16374
+ select: (tabs)=>tabs.find((tab)=>dom_snapshot_sameDocumentUrl(tab.url, url)),
16375
+ expression: protocolSnapshotExpression(wantHtml, args.maxBytes ?? 262144),
16376
+ timeoutMs: args.timeout ?? 10000
16377
+ });
16378
+ if (!outcome.ok) return null;
16379
+ return envelope_envelope({
16380
+ ok: true,
16381
+ command: dom_snapshot_schema.name,
16382
+ status: "ok",
16383
+ value: outcome.value,
16384
+ warnings: [
16385
+ `${url} is a page inside the extension, which Firefox lets no script be injected into, so this snapshot was read over the debugger protocol from the tab showing it.` + (consoleAsked ? " Console lines are not collected on this path; read them with extension_logs." : "")
16386
+ ]
16387
+ });
16388
+ } catch {
16389
+ return null;
16390
+ }
16391
+ }
15194
16392
  async function cdpPortOrError(projectPath, browser, feature) {
15195
16393
  if (!isChromiumFamily(browser)) return {
15196
16394
  error: envelope_envelope({
@@ -15433,7 +16631,10 @@ async function dom_snapshot_handler(args) {
15433
16631
  if (null != withConsole) cli.push("--with-console", String(withConsole));
15434
16632
  cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
15435
16633
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
15436
- const raw = await runActVerb(cli, args.projectPath, args.timeout, dom_snapshot_schema.name);
16634
+ const sessionBrowser = resolveSessionBrowser(args.projectPath, args.browser).browser;
16635
+ const asked = await runActVerb(cli, args.projectPath, args.timeout, dom_snapshot_schema.name);
16636
+ const overProtocol = await snapshotExtensionPageOverProtocol(args, sessionBrowser, targetUrl ?? ("string" == typeof resolvedTarget?.url ? resolvedTarget.url : void 0), asked, null != withConsole);
16637
+ const raw = overProtocol ?? asked;
15437
16638
  if (!resolvedTarget) return raw;
15438
16639
  try {
15439
16640
  const parsed = JSON.parse(raw);
@@ -15532,7 +16733,7 @@ async function publish_handler(args) {
15532
16733
  let note = null;
15533
16734
  if (null != args.ttlHours && "public" === data.visibility) note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
15534
16735
  let buildNote = null;
15535
- const ref = resolveProjectRef();
16736
+ const ref = loginProjectRef(args.project);
15536
16737
  if (ref) {
15537
16738
  const buildsUrl = registryFileUrl(ref, "builds/index.json");
15538
16739
  const buildsRes = await fetchRegistryJson(buildsUrl, fetch, {
@@ -15736,7 +16937,7 @@ async function release_promote_handler(args) {
15736
16937
  const code = "string" == typeof data?.code ? data.code : void 0;
15737
16938
  const enrich = {};
15738
16939
  let hint = "";
15739
- const ref = resolveProjectRef();
16940
+ const ref = loginProjectRef(args.project);
15740
16941
  if (404 === res.status || "UNKNOWN_BUILD" === code) {
15741
16942
  enrich.buildsPageUrl = consoleProjectUrl(ref, "builds", args.api);
15742
16943
  hint = "Run extension_release_status to see this project's channels, their promoted shas, and recent builds.";
@@ -15772,7 +16973,7 @@ async function release_promote_handler(args) {
15772
16973
  hint
15773
16974
  });
15774
16975
  }
15775
- const promotedRef = resolveProjectRef();
16976
+ const promotedRef = loginProjectRef(args.project);
15776
16977
  const publicChannelUrl = userlandProjectUrl(promotedRef, UserlandProjectPage.channel(channel), args.api);
15777
16978
  const publicBuildUrl = userlandProjectUrl(promotedRef, UserlandProjectPage.build(buildId), args.api);
15778
16979
  const allowance = spendNarration({
@@ -16005,7 +17206,7 @@ async function readStores(args) {
16005
17206
  const healthUrl = registryFileUrl(ref, "stores/health.json");
16006
17207
  const statusUrl = registryFileUrl(ref, "stores/status.json");
16007
17208
  const submissionsUrl = registryFileUrl(ref, "stores/submissions.json");
16008
- const consoleStoresUrl = consoleProjectUrl(ref, "stores", args.api);
17209
+ const consoleStoresUrl = consoleProjectUrl(ref, ConsoleProjectPage.submissions, args.api);
16009
17210
  const [healthRes, statusRes, submissionsRes] = await Promise.all([
16010
17211
  fetchRegistryJson(healthUrl, fetch, {
16011
17212
  ref,
@@ -16035,7 +17236,7 @@ async function readStores(args) {
16035
17236
  project: ref.project
16036
17237
  }
16037
17238
  });
16038
- if (!healthRes.ok && !statusRes.ok && !submissionsRes.ok) return store_status_fail("StoreStatusNotFound", `No store data on the registry for ${ref.workspace}/${ref.project} (${healthRes.message}). The project may have no stores configured yet, be private (private registry data needs a share token), or the workspace/project slugs may be wrong. Configure stores at ${consoleStoresUrl}/new; the console Stores page is the authoritative view: ${consoleStoresUrl}`, "unavailable", "E_PLATFORM", {
17239
+ if (!healthRes.ok && !statusRes.ok && !submissionsRes.ok) return store_status_fail("StoreStatusNotFound", `No store data on the registry for ${ref.workspace}/${ref.project} (${healthRes.message}). The project may have no stores configured yet, be private (private registry data needs a share token), or the workspace/project slugs may be wrong. Configure stores at ${consoleStoresUrl}/new; the console Submissions page is the authoritative view: ${consoleStoresUrl}`, "unavailable", "E_PLATFORM", {
16039
17240
  workspace: ref.workspace,
16040
17241
  project: ref.project,
16041
17242
  registryUrls: {
@@ -16446,8 +17647,8 @@ async function submit_handler(args) {
16446
17647
  let channelNote = null;
16447
17648
  let statusNote = null;
16448
17649
  if (dryRun) {
16449
- const ref = resolveProjectRef();
16450
- const consoleStoresUrl = consoleProjectUrl(ref, "stores", args.api);
17650
+ const ref = loginProjectRef(args.project);
17651
+ const consoleStoresUrl = consoleProjectUrl(ref, ConsoleProjectPage.submissions, args.api);
16451
17652
  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}.`;
16452
17653
  let health = null;
16453
17654
  let healthUnreadable = null;
@@ -17041,7 +18242,7 @@ async function add_feature_handler(args) {
17041
18242
  const login_FIRST_CALL_BUDGET_MS = 8000;
17042
18243
  const login_RESUME_BUDGET_MS = 22000;
17043
18244
  const PENDING_TTL_NOTE = "Once authorized, the minted token lives at most 7 days (server-enforced); CI must re-mint before expiry (console: project settings -> Access tokens).";
17044
- function login_fail(name, message, status, code) {
18245
+ function login_fail(name, message, status, code, extra) {
17045
18246
  return envelope_envelope({
17046
18247
  ok: false,
17047
18248
  command: "extension_auth",
@@ -17050,7 +18251,13 @@ function login_fail(name, message, status, code) {
17050
18251
  code,
17051
18252
  name,
17052
18253
  message
17053
- }
18254
+ },
18255
+ ...extra?.value ? {
18256
+ value: extra.value
18257
+ } : {},
18258
+ ...extra?.hint ? {
18259
+ hint: extra.hint
18260
+ } : {}
17054
18261
  });
17055
18262
  }
17056
18263
  function success(creds) {
@@ -17166,6 +18373,156 @@ async function loginToProject(args) {
17166
18373
  verificationUriComplete: start.verificationUriComplete
17167
18374
  });
17168
18375
  }
18376
+ const BATCH_FINAL_POLL_NOTE = "The platform records one token per project before it answers, so the call that completes a batch login can take up to a minute. Let it finish: the device code is spent the moment minting starts, and a call abandoned midway cannot be resumed.";
18377
+ function batchPending(args) {
18378
+ const complete = String(args.verificationUriComplete || "").trim();
18379
+ const hasCompleteLink = complete.length > 0 && complete !== args.verificationUri;
18380
+ const again = "then call extension_auth (action: login) again with this deviceCode and the same projects";
18381
+ const hint = args.userCode ? hasCompleteLink ? `Open ${complete} and approve (code ${args.userCode} is pre-filled). The page lists all ${args.projects.length} projects this one approval signs in to; ${again}. If the page asks for a code, enter ${args.userCode} at ${args.verificationUri}.` : `Open ${args.verificationUri} and enter code ${args.userCode}. The page lists all ${args.projects.length} projects this one approval signs in to; ${again}.` : `Still waiting for authorization. The link and code from the previous response are still valid: approve at ${args.verificationUri}, ${again}.`;
18382
+ return envelope_envelope({
18383
+ ok: true,
18384
+ command: "extension_auth",
18385
+ status: "authorization-pending",
18386
+ value: {
18387
+ ...args.userCode ? {
18388
+ userCode: args.userCode
18389
+ } : {},
18390
+ verificationUri: args.verificationUri,
18391
+ ...args.userCode && hasCompleteLink ? {
18392
+ verificationUriComplete: complete
18393
+ } : {},
18394
+ deviceCode: args.deviceCode,
18395
+ projects: args.projects,
18396
+ legacyStatus: "authorization_pending"
18397
+ },
18398
+ hint,
18399
+ warnings: [
18400
+ PENDING_TTL_NOTE,
18401
+ BATCH_FINAL_POLL_NOTE
18402
+ ]
18403
+ });
18404
+ }
18405
+ function batchRefusal(poll, projects) {
18406
+ if ("expired" === poll.reason) return login_fail("LoginExpired", "The device code expired or was already spent. Run extension_auth (action: login) again with the same projects to restart.", "login-expired", "E_AUTH_EXPIRED");
18407
+ const code = String(poll.code || "");
18408
+ if ("PROJECT_NOT_FOUND" === code) {
18409
+ const missing = Array.isArray(poll.body?.missingProjects) ? (poll.body?.missingProjects).map(String) : [];
18410
+ return login_fail("LoginProjectNotFound", poll.message || "At least one listed project does not exist, so no token was minted for any of them.", "login-failed", "E_AUTH_FAILED", {
18411
+ value: {
18412
+ code,
18413
+ missingProjects: missing,
18414
+ projects
18415
+ },
18416
+ hint: `A batch login is all or nothing and covers only projects that already exist. ${missing.length ? `Drop or fix ${missing.join(", ")} and run extension_auth (action: login) again with the rest` : "Check each name against the console and run extension_auth (action: login) again"}; create missing projects with extension_project_create or at ${consoleBase()}.`
18417
+ });
18418
+ }
18419
+ if ("MEMBERSHIP_REVOKED" === code) return login_fail("LoginMembershipRevoked", poll.message || "The member who approved this login is no longer in the workspace, so no token was minted.", "login-failed", "E_AUTH_FAILED", {
18420
+ value: {
18421
+ code,
18422
+ projects
18423
+ }
18424
+ });
18425
+ if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.", "login-denied", "E_AUTH_DENIED");
18426
+ return login_fail("LoginError", poll.message || "Device login failed.", "login-failed", "E_AUTH_FAILED", {
18427
+ value: {
18428
+ ...code ? {
18429
+ code
18430
+ } : {},
18431
+ projects
18432
+ },
18433
+ hint: "No token from this batch was stored. Run extension_auth (action: login) again with the same projects; a batch that was refused or failed midway is closed on the platform and needs a fresh approval."
18434
+ });
18435
+ }
18436
+ async function loginToProjects(args) {
18437
+ const parsed = parseProjectBatch(args.projects);
18438
+ if (!parsed.ok) return login_fail("BadRequest", parsed.message, "bad-request", "E_BAD_REQUEST");
18439
+ const projects = parsed.batch.refs;
18440
+ const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
18441
+ if (!apiCheck.ok) return login_fail("LoginConfigError", apiCheck.message, "login-failed", "E_AUTH_FAILED");
18442
+ const apiBase = apiCheck.base;
18443
+ let config;
18444
+ try {
18445
+ config = await fetchLoginConfig(apiBase);
18446
+ } catch (err) {
18447
+ return login_fail("LoginConfigError", err?.message || "Could not load login config.", "login-failed", "E_AUTH_FAILED");
18448
+ }
18449
+ if (!config.batch) return login_fail("LoginBatchUnsupported", BATCH_UNSUPPORTED_MESSAGE, "batch-unsupported", "E_PLATFORM", {
18450
+ value: {
18451
+ projects
18452
+ },
18453
+ hint: "Sign in to each project with its own extension_auth (action: login, project) call."
18454
+ });
18455
+ if (projects.length > config.batch.loginProjectsPerApproval) return login_fail("BadRequest", `projects names ${projects.length} projects, and one approval signs in to at most ${config.batch.loginProjectsPerApproval} on this platform.`, "bad-request", "E_BAD_REQUEST", {
18456
+ hint: `Split the list into calls of ${config.batch.loginProjectsPerApproval} or fewer; each call is its own approval. No device code was spent.`
18457
+ });
18458
+ let deviceCode = String(args.deviceCode || "").trim();
18459
+ let interval = 5;
18460
+ let budgetMs = login_RESUME_BUDGET_MS;
18461
+ let start = null;
18462
+ if (!deviceCode) {
18463
+ try {
18464
+ start = await requestDeviceCode({
18465
+ apiBase,
18466
+ path: config.deviceCodeUrl,
18467
+ projects
18468
+ });
18469
+ } catch (err) {
18470
+ return login_fail("LoginStartError", err?.message || "Could not start the device flow.", "login-failed", "E_AUTH_FAILED", {
18471
+ hint: "No device code was issued. Sign in to each project with its own extension_auth (action: login, project) call, or fix what the platform refused and try the list again."
18472
+ });
18473
+ }
18474
+ deviceCode = start.deviceCode;
18475
+ interval = start.interval;
18476
+ budgetMs = login_FIRST_CALL_BUDGET_MS;
18477
+ }
18478
+ const poll = await pollDeviceGrant({
18479
+ apiBase,
18480
+ path: config.deviceTokenUrl,
18481
+ projects,
18482
+ deviceCode,
18483
+ interval,
18484
+ budgetMs
18485
+ });
18486
+ if (!poll.ok) {
18487
+ if ("pending" === poll.reason) return batchPending({
18488
+ deviceCode,
18489
+ projects,
18490
+ verificationUri: start?.verificationUri ?? config.verificationUri,
18491
+ userCode: start?.userCode,
18492
+ verificationUriComplete: start?.verificationUriComplete
18493
+ });
18494
+ return batchRefusal(poll, projects);
18495
+ }
18496
+ let stored;
18497
+ try {
18498
+ stored = persistBatchTokenResponse({
18499
+ apiBase,
18500
+ projects,
18501
+ data: poll.data
18502
+ });
18503
+ } catch (err) {
18504
+ return login_fail("LoginError", err?.message ? String(err.message) : String(err), "login-failed", "E_AUTH_FAILED");
18505
+ }
18506
+ const logins = stored.map((creds)=>({
18507
+ project: `${creds.workspaceSlug}/${creds.projectSlug}`,
18508
+ workspaceSlug: creds.workspaceSlug,
18509
+ projectSlug: creds.projectSlug,
18510
+ expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null
18511
+ }));
18512
+ return envelope_envelope({
18513
+ ok: true,
18514
+ command: "extension_auth",
18515
+ status: "logged-in",
18516
+ value: {
18517
+ workspaceSlug: parsed.batch.workspace,
18518
+ logins
18519
+ },
18520
+ hint: `Logged in to ${logins.length} project${1 === logins.length ? "" : "s"} in ${parsed.batch.workspace}: ${logins.map((login)=>login.projectSlug).join(", ")}. Each has its own stored token; token-scoped tools take \`project\` to pick one, and extension_auth (action: status) lists them. The default login was left as it was unless none existed. Every token expires within 7 days, so one batch login renews them together.`,
18521
+ warnings: [
18522
+ tokenTtlNote()
18523
+ ]
18524
+ });
18525
+ }
17169
18526
  const server_identity_DEFAULT_TIMEOUT_MS = 5000;
17170
18527
  async function askServerIdentity(args) {
17171
18528
  const fetchImpl = args.fetchImpl ?? fetch;
@@ -17355,7 +18712,7 @@ async function clearLocalCredentials(project) {
17355
18712
  }
17356
18713
  const auth_schema = {
17357
18714
  name: "extension_auth",
17358
- description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Status also asks the platform's whoami endpoint whether that credential actually resolves there: the answer is reported as confirmed, refused-by-server, or unverified when the server cannot be reached, so a local file claiming a login the server would refuse is never reported as simply logged in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only (with `project`, just that project's login); the token stays valid server-side until it is revoked at the URL the response returns. Several logins live side by side on one machine, one per workspace/project, and status lists them all under `logins`.",
18715
+ description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Status also asks the platform's whoami endpoint whether that credential actually resolves there: the answer is reported as confirmed, refused-by-server, or unverified when the server cannot be reached, so a local file claiming a login the server would refuse is never reported as simply logged in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only (with `project`, just that project's login); the token stays valid server-side until it is revoked at the URL the response returns. Several logins live side by side on one machine, one per workspace/project, and status lists them all under `logins`. To sign in to several existing projects of one workspace at once, pass `projects` instead of `project`: one approval, one stored token per project.",
17359
18716
  inputSchema: {
17360
18717
  type: "object",
17361
18718
  properties: {
@@ -17372,6 +18729,13 @@ const auth_schema = {
17372
18729
  type: "string",
17373
18730
  description: "login: target project as '<workspace>/<project>'; the token is scoped to it. The slug pair is the console address bar: an existing project's page is console.extension.dev/<workspace>/<project>. Create one at extension.dev/new if none exists yet. Logins to several projects are all kept, the latest is the default; token-scoped tools take `project` to pick another. logout: remove only this project's login (omitted, every stored login goes)."
17374
18731
  },
18732
+ projects: {
18733
+ type: "array",
18734
+ items: {
18735
+ type: "string"
18736
+ },
18737
+ description: "login: sign in to several existing projects of one workspace with one approval, instead of one approval each. 1 to 20 names as '<workspace>/<project>', all in the same workspace, each by its exact slug (lowercase letters and digits joined by single dashes, at most 48 characters), none twice. Pass it instead of `project`, never beside it. The approval page lists every name; one missing project refuses the whole list and mints nothing. Resume with the returned `deviceCode` and the same list. Every token is stored as that project's own login and all expire within 7 days, so the same call renews them together."
18738
+ },
17375
18739
  deviceCode: {
17376
18740
  type: "string",
17377
18741
  description: "login: resume token from the prior call's `deviceCode`; omit on the first call."
@@ -17381,9 +18745,31 @@ const auth_schema = {
17381
18745
  required: []
17382
18746
  }
17383
18747
  };
18748
+ function listMisuse(message) {
18749
+ return envelope_envelope({
18750
+ ok: false,
18751
+ command: "extension_auth",
18752
+ status: "bad-request",
18753
+ error: {
18754
+ code: "E_BAD_REQUEST",
18755
+ name: "BadRequest",
18756
+ message
18757
+ }
18758
+ });
18759
+ }
17384
18760
  async function auth_handler(args) {
17385
18761
  const action = args.action ?? "status";
18762
+ const hasList = void 0 !== args.projects && null !== args.projects;
18763
+ if (hasList && "login" !== action) return listMisuse(`projects is a login input: action '${action}' does not take a list. Use project to name one login, or call once per project.`);
17386
18764
  if ("logout" === action) return clearLocalCredentials(args.project);
18765
+ if ("login" === action && hasList) {
18766
+ if (String(args.project ?? "").trim()) return listMisuse("Pass either project (one login) or projects (one approval for several), not both.");
18767
+ return loginToProjects({
18768
+ projects: args.projects,
18769
+ deviceCode: args.deviceCode,
18770
+ api: args.api
18771
+ });
18772
+ }
17387
18773
  if ("login" === action) return loginToProject({
17388
18774
  project: String(args.project ?? ""),
17389
18775
  deviceCode: args.deviceCode,
@@ -18577,6 +19963,35 @@ function pinProjectArgs(name, args, inputSchema, options) {
18577
19963
  if (!pinned) return {
18578
19964
  args
18579
19965
  };
19966
+ if (void 0 !== args.projects && null !== args.projects) {
19967
+ const entries = Array.isArray(args.projects) ? args.projects : [
19968
+ args.projects
19969
+ ];
19970
+ const names = entries.map((entry)=>"string" == typeof entry ? entry.trim() : entry && "object" == typeof entry && !Array.isArray(entry) ? String(entry.project ?? "").trim() : "");
19971
+ const others = names.filter((entry)=>!entry.includes("/") || !sameProject(entry, pinned));
19972
+ if (others.length > 0 || 0 === entries.length) {
19973
+ const shown = others.map((entry)=>entry || "(an unnamed entry)");
19974
+ return {
19975
+ refused: envelope_envelope({
19976
+ ok: false,
19977
+ command: name,
19978
+ status: "project-pinned",
19979
+ error: {
19980
+ code: "E_TOOL_DISABLED",
19981
+ message: `This server is pinned to ${pinned} and was asked to act on a list naming ${shown.length ? shown.join(", ") : "no project"}.`
19982
+ },
19983
+ value: {
19984
+ pinned,
19985
+ named: shown
19986
+ },
19987
+ hint: `A pinned server takes a batch only when every entry is ${pinned}. Work on other projects through an MCP server configured without --project, or with its own --project (or ${PROJECT_PIN_ENV}).`
19988
+ })
19989
+ };
19990
+ }
19991
+ return {
19992
+ args
19993
+ };
19994
+ }
18580
19995
  const named = "string" == typeof args.project ? args.project : "";
18581
19996
  if (named && !sameProject(named, pinned)) return {
18582
19997
  refused: envelope_envelope({