@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +81 -0
- package/README.md +4 -2
- package/dist/module.js +1147 -140
- package/dist/src/lib/credentials.d.ts +1 -0
- package/dist/src/lib/device-flow.d.ts +4 -0
- package/dist/src/lib/login-flow.d.ts +7 -0
- package/dist/src/lib/project-batch.d.ts +28 -0
- package/dist/src/lib/project-create-batch.d.ts +42 -0
- package/dist/src/lib/project-create-body.d.ts +14 -0
- package/dist/src/lib/registry.d.ts +1 -0
- package/dist/src/tools/auth.d.ts +8 -0
- package/dist/src/tools/login.d.ts +5 -0
- package/dist/src/tools/project-create.d.ts +10 -2
- package/package.json +1 -1
- package/server.json +2 -2
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.
|
|
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
|
-
|
|
1070
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
const
|
|
1904
|
-
return {
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
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
|
|
1912
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
2159
|
-
if ("expired" === poll.reason) return
|
|
2160
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 &&
|
|
3144
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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({
|