@extension.dev/mcp 10.10.8 → 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
@@ -243,7 +243,7 @@ __webpack_require__.d(workspace_create_namespaceObject, {
243
243
  handler: ()=>workspace_create_handler,
244
244
  schema: ()=>workspace_create_schema
245
245
  });
246
- var package_namespaceObject = JSON.parse('{"rE":"10.10.8","El":{"OP":"4.1.31"}}');
246
+ var package_namespaceObject = JSON.parse('{"rE":"10.10.9","El":{"OP":"4.1.31"}}');
247
247
  const ENVELOPE_SCHEMA = 1;
248
248
  const collectWarnings = (warnings)=>{
249
249
  if (!warnings) return [];
@@ -579,6 +579,21 @@ function writeCredentials(creds) {
579
579
  entries
580
580
  });
581
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
+ }
582
597
  function clearCredentials(selector) {
583
598
  const file = credentialsPath();
584
599
  const store = readCredentialStore();
@@ -660,6 +675,91 @@ const PROJECT_TOKEN_INPUT = {
660
675
  function noStoredLoginHint(project) {
661
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.`;
662
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
+ }
663
763
  const DEFAULT_API = PROD_ORIGINS.www;
664
764
  function tokenTtlNote(workspaceSlug, projectSlug) {
665
765
  const tokensUrl = workspaceSlug && projectSlug ? consoleProjectUrl({
@@ -711,7 +811,8 @@ async function fetchLoginConfig(apiBase, fetchImpl = fetch) {
711
811
  return {
712
812
  deviceCodeUrl: String(data.deviceCodeUrl || "/api/cli/device/code"),
713
813
  deviceTokenUrl: String(data.deviceTokenUrl || "/api/cli/device/token"),
714
- verificationUri: String(data.verificationUri || `${apiBase.replace(/\/+$/, "")}/device`)
814
+ verificationUri: String(data.verificationUri || `${apiBase.replace(/\/+$/, "")}/device`),
815
+ batch: readBatchCapability(data)
715
816
  };
716
817
  }
717
818
  function persistTokenResponse(args) {
@@ -735,6 +836,30 @@ function persistTokenResponse(args) {
735
836
  writeCredentials(creds);
736
837
  return creds;
737
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
+ }
738
863
  const INSTALL_HEADER = "x-extensiondev-install";
739
864
  const SESSION_HEADER = "x-extensiondev-session";
740
865
  const TOOL_HEADER = "x-extensiondev-tool";
@@ -885,7 +1010,9 @@ class RegistryAccessTokens {
885
1010
  return result;
886
1011
  }
887
1012
  async mint(ref, apiHint) {
888
- const creds = readValidCredentials();
1013
+ const creds = readValidCredentials(void 0, {
1014
+ project: `${ref.workspace}/${ref.project}`
1015
+ });
889
1016
  const token = String(process.env.EXTENSION_DEV_TOKEN || creds?.token || "").trim();
890
1017
  if (!token) return {
891
1018
  status: "no-credential"
@@ -1065,14 +1192,33 @@ function consoleBase(apiHint) {
1065
1192
  function registryBase() {
1066
1193
  return mcpOrigins().registry;
1067
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
+ }
1068
1205
  function resolveProjectRef(overrides) {
1069
- const workspace = String(overrides?.workspace || "").trim();
1070
- 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
+ }
1071
1215
  if (workspace && project) return {
1072
1216
  workspace,
1073
1217
  project
1074
1218
  };
1075
- const creds = readCredentials();
1219
+ const creds = (project ? readCredentials({
1220
+ project
1221
+ }) : null) ?? readCredentials();
1076
1222
  const ws = workspace || String(creds?.workspaceSlug || "").trim();
1077
1223
  const proj = project || String(creds?.projectSlug || "").trim();
1078
1224
  if (!ws || !proj) return null;
@@ -1081,6 +1227,20 @@ function resolveProjectRef(overrides) {
1081
1227
  project: proj
1082
1228
  };
1083
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
+ }
1084
1244
  function registryFileUrl(ref, file) {
1085
1245
  return `${registryBase()}/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.project)}/_extension-dev/${file}`;
1086
1246
  }
@@ -1744,6 +1904,7 @@ const LAUNCH_BROWSER = {
1744
1904
  };
1745
1905
  async function requestDeviceCode(args) {
1746
1906
  const doFetch = args.fetchImpl ?? fetch;
1907
+ const batch = Array.isArray(args.projects) && args.projects.length > 0;
1747
1908
  const res = await doFetch(`${args.apiBase}${args.path}`, {
1748
1909
  method: "POST",
1749
1910
  headers: {
@@ -1751,7 +1912,10 @@ async function requestDeviceCode(args) {
1751
1912
  accept: "application/json"
1752
1913
  },
1753
1914
  body: JSON.stringify({
1754
- ...args.project ? {
1915
+ ...batch ? {
1916
+ projects: args.projects
1917
+ } : {},
1918
+ ...!batch && args.project ? {
1755
1919
  project: args.project
1756
1920
  } : {},
1757
1921
  ...args.workspace ? {
@@ -1775,6 +1939,8 @@ async function requestDeviceCode(args) {
1775
1939
  if (!res.ok) {
1776
1940
  const error = new Error(`Device code request failed (${res.status}): ${data.message || "unknown error"}`);
1777
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;
1778
1944
  throw error;
1779
1945
  }
1780
1946
  const deviceCode = String(data.device_code || "").trim();
@@ -1793,6 +1959,7 @@ async function pollDeviceGrant(args) {
1793
1959
  const doFetch = args.fetchImpl ?? fetch;
1794
1960
  const deadline = Date.now() + args.budgetMs;
1795
1961
  let interval = Math.max(1, args.interval);
1962
+ const batch = Array.isArray(args.projects) && args.projects.length > 0;
1796
1963
  for(;;){
1797
1964
  const res = await doFetch(`${args.apiBase}${args.path}`, {
1798
1965
  method: "POST",
@@ -1802,7 +1969,10 @@ async function pollDeviceGrant(args) {
1802
1969
  },
1803
1970
  body: JSON.stringify({
1804
1971
  device_code: args.deviceCode,
1805
- ...args.project ? {
1972
+ ...batch ? {
1973
+ projects: args.projects
1974
+ } : {},
1975
+ ...!batch && args.project ? {
1806
1976
  project: args.project
1807
1977
  } : {},
1808
1978
  ...args.workspace ? {
@@ -1824,9 +1994,18 @@ async function pollDeviceGrant(args) {
1824
1994
  data
1825
1995
  };
1826
1996
  const error = String(data.error || "");
1997
+ const code = String(data.code || "").trim();
1998
+ const refusal = code ? {
1999
+ code,
2000
+ body: data
2001
+ } : {};
1827
2002
  if ("access_denied" === error) return {
1828
2003
  ok: false,
1829
- reason: "denied"
2004
+ reason: "denied",
2005
+ ...data.message ? {
2006
+ message: String(data.message)
2007
+ } : {},
2008
+ ...refusal
1830
2009
  };
1831
2010
  if ("expired_token" === error) return {
1832
2011
  ok: false,
@@ -1836,12 +2015,14 @@ async function pollDeviceGrant(args) {
1836
2015
  else if (error && "authorization_pending" !== error) return {
1837
2016
  ok: false,
1838
2017
  reason: "error",
1839
- message: String(data.message || error)
2018
+ message: String(data.message || error),
2019
+ ...refusal
1840
2020
  };
1841
2021
  else if (!error && !res.ok) return {
1842
2022
  ok: false,
1843
2023
  reason: "error",
1844
- 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
1845
2026
  };
1846
2027
  if (Date.now() + 1000 * interval >= deadline) return {
1847
2028
  ok: false,
@@ -1870,49 +2051,710 @@ async function pollDeviceToken(args) {
1870
2051
  message: err?.message ? String(err.message) : String(err)
1871
2052
  };
1872
2053
  }
1873
- }
1874
- const FREE_ALLOWANCE_PHRASE = "counts against your free allowance";
1875
- function allowanceWallUrl(apiHint) {
1876
- return `${mcpOrigins(apiHint).www.replace(/\/+$/, "")}/pricing`;
1877
- }
1878
- function allowance_asRecord(value) {
1879
- return value && "object" == typeof value && !Array.isArray(value) ? value : null;
1880
- }
1881
- function asCount(value) {
1882
- return "number" == typeof value && Number.isFinite(value) && value >= 0 ? value : null;
1883
- }
1884
- function readPlatformAllowance(body) {
1885
- const record = allowance_asRecord(body);
1886
- if (!record) return null;
1887
- for (const key of [
1888
- "allowance",
1889
- "quota"
1890
- ]){
1891
- const nested = allowance_asRecord(record[key]);
1892
- if (!nested) continue;
1893
- const used = asCount(nested.used);
1894
- const limit = asCount(nested.limit);
1895
- if (null !== used && null !== limit) return {
1896
- used,
1897
- limit
1898
- };
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");
2656
+ }
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;
2695
+ }
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
+ });
1899
2728
  }
1900
- return null;
1901
- }
1902
- function spendNarration(options) {
1903
- const counted = readPlatformAllowance(options.body);
1904
- return {
1905
- spent: `${options.what} ran on extension.dev's machines and ${FREE_ALLOWANCE_PHRASE}.`,
1906
- 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.",
1907
- wall: `What the free allowance covers and when the paid plan starts are published at ${allowanceWallUrl(options.api)}.`
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
1908
2748
  };
2749
+ sessions.set(deviceCode, session);
2750
+ return runSlice(session, deviceCode, installationId || void 0);
1909
2751
  }
1910
2752
  const project_create_COMMAND = "extension_project_create";
1911
- const FIRST_CALL_BUDGET_MS = 8000;
1912
- const RESUME_BUDGET_MS = 22000;
2753
+ const project_create_FIRST_CALL_BUDGET_MS = 8000;
2754
+ const project_create_RESUME_BUDGET_MS = 22000;
1913
2755
  const project_create_schema = {
1914
2756
  name: "extension_project_create",
1915
- 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.",
1916
2758
  inputSchema: {
1917
2759
  type: "object",
1918
2760
  properties: {
@@ -1970,19 +2812,23 @@ const project_create_schema = {
1970
2812
  type: "object",
1971
2813
  description: "Per-browser output directory overrides, for example {\"edge\": \"build/manifestv3\"}. A browser named here wins over `outputDirectory`."
1972
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
+ },
1973
2822
  deviceCode: {
1974
2823
  type: "string",
1975
- 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."
1976
2825
  },
1977
2826
  api: API_BASE
1978
2827
  },
1979
- required: [
1980
- "project",
1981
- "repo"
1982
- ]
2828
+ required: []
1983
2829
  }
1984
2830
  };
1985
- function fail(name, message, status, code, hint) {
2831
+ function project_create_fail(name, message, status, code, hint) {
1986
2832
  return envelope_envelope({
1987
2833
  ok: false,
1988
2834
  command: project_create_COMMAND,
@@ -1997,10 +2843,10 @@ function fail(name, message, status, code, hint) {
1997
2843
  } : {}
1998
2844
  });
1999
2845
  }
2000
- function laneClosedHint() {
2846
+ function project_create_laneClosedHint() {
2001
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.`;
2002
2848
  }
2003
- function pendingEnvelope(start) {
2849
+ function project_create_pendingEnvelope(start) {
2004
2850
  const complete = String(start.verificationUriComplete || "").trim();
2005
2851
  const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
2006
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.`;
@@ -2019,79 +2865,26 @@ function pendingEnvelope(start) {
2019
2865
  hint: message
2020
2866
  });
2021
2867
  }
2022
- function buildCreateBody(args) {
2023
- const installCommand = String(args.installCommand || "npm install").trim();
2024
- const buildCommand = String(args.buildCommand || "npm run build").trim();
2025
- const wanted = new Set((Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [
2026
- "chrome"
2027
- ]).map((name)=>String(name).trim().toLowerCase()));
2028
- const several = wanted.size > 1;
2029
- const outputFor = (name)=>{
2030
- const override = args.outputDirectories?.[name];
2031
- if ("string" == typeof override && override.trim()) return override.trim();
2032
- const pattern = String(args.outputDirectory || "").trim();
2033
- if (pattern.includes("<browser>")) return pattern.replaceAll("<browser>", name);
2034
- if (pattern && !several) return pattern;
2035
- return `dist/${name}`;
2036
- };
2037
- const browser = (name)=>({
2038
- enabled: wanted.has(name),
2039
- installCommand,
2040
- buildCommand,
2041
- outputDirectory: outputFor(name)
2042
- });
2043
- return {
2044
- info: {
2045
- id: "",
2046
- name: args.projectSlug,
2047
- displayName: String(args.displayName || args.projectSlug).trim(),
2048
- description: String(args.description || `Browser extension project for ${args.owner}/${args.repoName}.`).trim()
2049
- },
2050
- build: {
2051
- chrome: browser("chrome"),
2052
- edge: browser("edge"),
2053
- firefox: browser("firefox")
2054
- },
2055
- deployment: {
2056
- branch: "",
2057
- nodeVersion: "",
2058
- runWhatsNew: false,
2059
- runExtensionExecutables: false
2060
- },
2061
- github: {
2062
- owner: args.owner,
2063
- repo: args.repoName,
2064
- installationId: args.installationId,
2065
- createdAt: new Date().toISOString(),
2066
- pullRequestComments: true,
2067
- commitComments: false
2068
- },
2069
- workspaceSlug: args.workspace,
2070
- createdFrom: {
2071
- kind: "repository",
2072
- ref: `${args.owner}/${args.repoName}`
2073
- }
2074
- };
2075
- }
2076
2868
  async function project_create_handler(args) {
2869
+ if (void 0 !== args.projects && null !== args.projects) return createProjectBatch(args);
2077
2870
  const project = String(args.project || "").trim();
2078
- 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");
2079
2872
  const repo = String(args.repo || "").trim();
2080
- 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");
2081
2874
  const installationId = String(args.installationId || "").trim();
2082
- 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");
2083
2876
  const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
2084
- 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");
2085
2878
  const apiBase = apiCheck.base;
2086
2879
  let config;
2087
2880
  try {
2088
2881
  config = await fetchLoginConfig(apiBase);
2089
2882
  } catch (err) {
2090
- 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");
2091
2884
  }
2092
2885
  let deviceCode = String(args.deviceCode || "").trim();
2093
2886
  let interval = 5;
2094
- let budgetMs = RESUME_BUDGET_MS;
2887
+ let budgetMs = project_create_RESUME_BUDGET_MS;
2095
2888
  if (!deviceCode) {
2096
2889
  let start;
2097
2890
  try {
@@ -2105,11 +2898,11 @@ async function project_create_handler(args) {
2105
2898
  const message = err?.message || "Could not start the device flow.";
2106
2899
  const laneClosed = /CLI_PROJECT_CREATE_DISABLED|403/.test(String(message));
2107
2900
  const serverMessage = "string" == typeof err?.serverMessage ? err.serverMessage.trim() : "";
2108
- 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);
2109
2902
  }
2110
2903
  deviceCode = start.deviceCode;
2111
2904
  interval = start.interval;
2112
- budgetMs = FIRST_CALL_BUDGET_MS;
2905
+ budgetMs = project_create_FIRST_CALL_BUDGET_MS;
2113
2906
  const early = await pollDeviceGrant({
2114
2907
  apiBase,
2115
2908
  path: config.deviceTokenUrl,
@@ -2118,7 +2911,7 @@ async function project_create_handler(args) {
2118
2911
  interval,
2119
2912
  budgetMs
2120
2913
  });
2121
- if (!early.ok && "pending" === early.reason) return pendingEnvelope(start);
2914
+ if (!early.ok && "pending" === early.reason) return project_create_pendingEnvelope(start);
2122
2915
  return finishFromPoll(early, {
2123
2916
  apiBase,
2124
2917
  project,
@@ -2155,16 +2948,16 @@ async function finishFromPoll(poll, ctx) {
2155
2948
  },
2156
2949
  hint: `Still waiting for approval at ${ctx.verificationUri}. Approve there, then call extension_project_create again with this same deviceCode.`
2157
2950
  });
2158
- if ("denied" === poll.reason) return fail("CreateDenied", "Creating the project was denied at extension.dev/device.", "create-denied", "E_AUTH_DENIED");
2159
- if ("expired" === poll.reason) return fail("CreateExpired", "The device code expired. Run extension_project_create again to restart.", "create-expired", "E_AUTH_EXPIRED");
2160
- 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");
2161
2954
  }
2162
2955
  const grant = poll.data;
2163
2956
  const token = String(grant.token || "").trim();
2164
2957
  const workspaceSlug = String(grant.workspaceSlug || "").trim();
2165
2958
  const projectSlug = String(grant.projectSlug || "").trim();
2166
2959
  const [wantWorkspace = "", wantProject = ""] = ctx.project.split("/");
2167
- 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");
2168
2961
  if ("provisioning" !== String(grant.tokenKind || "")) return envelope_envelope({
2169
2962
  ok: false,
2170
2963
  command: project_create_COMMAND,
@@ -2175,7 +2968,7 @@ async function finishFromPoll(poll, ctx) {
2175
2968
  },
2176
2969
  hint: `Run extension_auth (action: login) with project '${ctx.project}' instead; the login lane mints the project token this tool deliberately does not.`
2177
2970
  });
2178
- const [owner = "", repoName = ""] = ctx.args.repo.split("/");
2971
+ const [owner = "", repoName = ""] = String(ctx.args.repo || "").split("/");
2179
2972
  const body = buildCreateBody({
2180
2973
  workspace: wantWorkspace,
2181
2974
  projectSlug: wantProject,
@@ -2203,7 +2996,7 @@ async function finishFromPoll(poll, ctx) {
2203
2996
  body: JSON.stringify(body)
2204
2997
  });
2205
2998
  } catch (err) {
2206
- 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");
2207
3000
  }
2208
3001
  const text = await res.text();
2209
3002
  let data;
@@ -2225,7 +3018,7 @@ async function finishFromPoll(poll, ctx) {
2225
3018
  project: wantProject
2226
3019
  }
2227
3020
  });
2228
- 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());
2229
3022
  const connectUrl = String(data.connectUrl || "").trim();
2230
3023
  if ("INSTALLATION_ABSENT" === code || "INSTALLATION_ORG_UNSUPPORTED" === code || "INSTALLATION_AMBIGUOUS" === code) return envelope_envelope({
2231
3024
  ok: false,
@@ -2253,7 +3046,7 @@ async function finishFromPoll(poll, ctx) {
2253
3046
  },
2254
3047
  hint: `Run extension_auth (action: login) with project '${ctx.project}'.`
2255
3048
  });
2256
- 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");
2257
3050
  }
2258
3051
  const finalWorkspace = String(data.workspaceSlug || wantWorkspace);
2259
3052
  const finalProject = String(data.projectSlug || wantProject);
@@ -3054,7 +3847,7 @@ async function outputFlagRefusalMessage(command, cliName, projectPath) {
3054
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.`;
3055
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.`;
3056
3849
  }
3057
- const sessions = new Map();
3850
+ const process_manager_sessions = new Map();
3058
3851
  const registrationStamps = new Map();
3059
3852
  function sessionKey(projectPath, browser) {
3060
3853
  return `${node_path.resolve(projectPath)}::${browser}`;
@@ -3115,7 +3908,7 @@ function registerSession(info) {
3115
3908
  pid: info.pid,
3116
3909
  at
3117
3910
  });
3118
- sessions.set(key, info);
3911
+ process_manager_sessions.set(key, info);
3119
3912
  writeMarkerBestEffort(info, at);
3120
3913
  }
3121
3914
  function sessionSinceMs(projectPath, browser) {
@@ -3133,19 +3926,19 @@ function sessionSinceMs(projectPath, browser) {
3133
3926
  return null;
3134
3927
  }
3135
3928
  function findSessionInfo(projectPath, browser) {
3136
- const inMemory = sessions.get(sessionKey(projectPath, browser));
3929
+ const inMemory = process_manager_sessions.get(sessionKey(projectPath, browser));
3137
3930
  if (inMemory) return inMemory;
3138
3931
  const resolved = node_path.resolve(projectPath);
3139
3932
  for (const marker of listSessionMarkers())if (node_path.resolve(marker.projectPath) === resolved && marker.browser === browser) return marker;
3140
3933
  }
3141
3934
  function removeSession(projectPath, browser, pid) {
3142
3935
  const key = sessionKey(projectPath, browser);
3143
- if (void 0 !== pid && sessions.get(key)?.pid !== pid) return;
3144
- sessions.delete(key);
3936
+ if (void 0 !== pid && process_manager_sessions.get(key)?.pid !== pid) return;
3937
+ process_manager_sessions.delete(key);
3145
3938
  registrationStamps.delete(key);
3146
3939
  }
3147
3940
  function listSessions() {
3148
- return Array.from(sessions.values());
3941
+ return Array.from(process_manager_sessions.values());
3149
3942
  }
3150
3943
  const PROJECT_MANIFEST_FILENAMES = [
3151
3944
  "package.json",
@@ -15940,7 +16733,7 @@ async function publish_handler(args) {
15940
16733
  let note = null;
15941
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.";
15942
16735
  let buildNote = null;
15943
- const ref = resolveProjectRef();
16736
+ const ref = loginProjectRef(args.project);
15944
16737
  if (ref) {
15945
16738
  const buildsUrl = registryFileUrl(ref, "builds/index.json");
15946
16739
  const buildsRes = await fetchRegistryJson(buildsUrl, fetch, {
@@ -16144,7 +16937,7 @@ async function release_promote_handler(args) {
16144
16937
  const code = "string" == typeof data?.code ? data.code : void 0;
16145
16938
  const enrich = {};
16146
16939
  let hint = "";
16147
- const ref = resolveProjectRef();
16940
+ const ref = loginProjectRef(args.project);
16148
16941
  if (404 === res.status || "UNKNOWN_BUILD" === code) {
16149
16942
  enrich.buildsPageUrl = consoleProjectUrl(ref, "builds", args.api);
16150
16943
  hint = "Run extension_release_status to see this project's channels, their promoted shas, and recent builds.";
@@ -16180,7 +16973,7 @@ async function release_promote_handler(args) {
16180
16973
  hint
16181
16974
  });
16182
16975
  }
16183
- const promotedRef = resolveProjectRef();
16976
+ const promotedRef = loginProjectRef(args.project);
16184
16977
  const publicChannelUrl = userlandProjectUrl(promotedRef, UserlandProjectPage.channel(channel), args.api);
16185
16978
  const publicBuildUrl = userlandProjectUrl(promotedRef, UserlandProjectPage.build(buildId), args.api);
16186
16979
  const allowance = spendNarration({
@@ -16854,7 +17647,7 @@ async function submit_handler(args) {
16854
17647
  let channelNote = null;
16855
17648
  let statusNote = null;
16856
17649
  if (dryRun) {
16857
- const ref = resolveProjectRef();
17650
+ const ref = loginProjectRef(args.project);
16858
17651
  const consoleStoresUrl = consoleProjectUrl(ref, ConsoleProjectPage.submissions, args.api);
16859
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}.`;
16860
17653
  let health = null;
@@ -17449,7 +18242,7 @@ async function add_feature_handler(args) {
17449
18242
  const login_FIRST_CALL_BUDGET_MS = 8000;
17450
18243
  const login_RESUME_BUDGET_MS = 22000;
17451
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).";
17452
- function login_fail(name, message, status, code) {
18245
+ function login_fail(name, message, status, code, extra) {
17453
18246
  return envelope_envelope({
17454
18247
  ok: false,
17455
18248
  command: "extension_auth",
@@ -17458,7 +18251,13 @@ function login_fail(name, message, status, code) {
17458
18251
  code,
17459
18252
  name,
17460
18253
  message
17461
- }
18254
+ },
18255
+ ...extra?.value ? {
18256
+ value: extra.value
18257
+ } : {},
18258
+ ...extra?.hint ? {
18259
+ hint: extra.hint
18260
+ } : {}
17462
18261
  });
17463
18262
  }
17464
18263
  function success(creds) {
@@ -17574,6 +18373,156 @@ async function loginToProject(args) {
17574
18373
  verificationUriComplete: start.verificationUriComplete
17575
18374
  });
17576
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
+ }
17577
18526
  const server_identity_DEFAULT_TIMEOUT_MS = 5000;
17578
18527
  async function askServerIdentity(args) {
17579
18528
  const fetchImpl = args.fetchImpl ?? fetch;
@@ -17763,7 +18712,7 @@ async function clearLocalCredentials(project) {
17763
18712
  }
17764
18713
  const auth_schema = {
17765
18714
  name: "extension_auth",
17766
- 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.",
17767
18716
  inputSchema: {
17768
18717
  type: "object",
17769
18718
  properties: {
@@ -17780,6 +18729,13 @@ const auth_schema = {
17780
18729
  type: "string",
17781
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)."
17782
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
+ },
17783
18739
  deviceCode: {
17784
18740
  type: "string",
17785
18741
  description: "login: resume token from the prior call's `deviceCode`; omit on the first call."
@@ -17789,9 +18745,31 @@ const auth_schema = {
17789
18745
  required: []
17790
18746
  }
17791
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
+ }
17792
18760
  async function auth_handler(args) {
17793
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.`);
17794
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
+ }
17795
18773
  if ("login" === action) return loginToProject({
17796
18774
  project: String(args.project ?? ""),
17797
18775
  deviceCode: args.deviceCode,
@@ -18985,6 +19963,35 @@ function pinProjectArgs(name, args, inputSchema, options) {
18985
19963
  if (!pinned) return {
18986
19964
  args
18987
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
+ }
18988
19995
  const named = "string" == typeof args.project ? args.project : "";
18989
19996
  if (named && !sameProject(named, pinned)) return {
18990
19997
  refused: envelope_envelope({