@extension.dev/mcp 4.6.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.js CHANGED
@@ -2,11 +2,10 @@ import { __webpack_require__ } from "./rslib-runtime.js";
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import node_fs, { existsSync, readdirSync, statSync } from "node:fs";
5
6
  import node_path, { join } from "node:path";
6
7
  import { extensionCreate } from "extension-create";
7
- import node_fs, { existsSync, readdirSync, statSync } from "node:fs";
8
8
  import node_os from "node:os";
9
- import { extensionBuild } from "extension-develop";
10
9
  import cross_spawn from "cross-spawn";
11
10
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
12
11
  import ws from "ws";
@@ -35,7 +34,6 @@ __webpack_require__.d(create_namespaceObject, {
35
34
  var deploy_namespaceObject = {};
36
35
  __webpack_require__.r(deploy_namespaceObject);
37
36
  __webpack_require__.d(deploy_namespaceObject, {
38
- buildDeployArgs: ()=>buildDeployArgs,
39
37
  handler: ()=>deploy_handler,
40
38
  schema: ()=>deploy_schema
41
39
  });
@@ -201,7 +199,33 @@ __webpack_require__.d(whoami_namespaceObject, {
201
199
  handler: ()=>whoami_handler,
202
200
  schema: ()=>whoami_schema
203
201
  });
204
- var package_namespaceObject = JSON.parse('{"rE":"4.5.0","El":{"OP":"^4.0.11"}}');
202
+ var package_namespaceObject = JSON.parse('{"rE":"4.6.0","El":{"OP":"^4.0.11"}}');
203
+ function detectPackageManager(projectPath) {
204
+ const byLockfile = [
205
+ [
206
+ "bun.lock",
207
+ "bun"
208
+ ],
209
+ [
210
+ "bun.lockb",
211
+ "bun"
212
+ ],
213
+ [
214
+ "pnpm-lock.yaml",
215
+ "pnpm"
216
+ ],
217
+ [
218
+ "yarn.lock",
219
+ "yarn"
220
+ ],
221
+ [
222
+ "package-lock.json",
223
+ "npm"
224
+ ]
225
+ ];
226
+ for (const [lockfile, pm] of byLockfile)if (node_fs.existsSync(node_path.join(projectPath, lockfile))) return pm;
227
+ return "npm";
228
+ }
205
229
  const create_schema = {
206
230
  name: "extension_create",
207
231
  description: "Create a new browser extension project from a template in the extension.dev template catalog. Use extension_list_templates to see available options.",
@@ -219,7 +243,7 @@ const create_schema = {
219
243
  template: {
220
244
  type: "string",
221
245
  default: "typescript",
222
- description: "Template slug from the extension.dev template catalog (e.g. 'react', 'sidebar-claude', 'content-vue'). Use extension_list_templates to discover options."
246
+ description: "Template slug from the extension.dev template catalog (e.g. 'react', 'ai-claude', 'content-vue'). Use extension_list_templates to discover options."
223
247
  },
224
248
  install: {
225
249
  type: "boolean",
@@ -235,30 +259,47 @@ const create_schema = {
235
259
  async function create_handler(args) {
236
260
  const start = Date.now();
237
261
  const projectInput = args.parentDir ? node_path.resolve(args.parentDir, args.projectName) : args.projectName;
262
+ const logLines = [];
263
+ const capture = (stream)=>(...parts)=>{
264
+ const line = parts.map((p)=>"string" == typeof p ? p : String(p)).join(" ").trim();
265
+ if (line) logLines.push("error" === stream ? `[error] ${line}` : line);
266
+ };
267
+ const logTail = (max = 20)=>logLines.slice(-max);
238
268
  try {
239
269
  const result = await extensionCreate(projectInput, {
240
270
  template: args.template ?? "typescript",
241
271
  install: args.install ?? true,
242
272
  logger: {
243
- log: ()=>{},
244
- error: ()=>{}
273
+ log: capture("log"),
274
+ error: capture("error")
245
275
  }
246
276
  });
277
+ const packageManager = result.depsInstalled ? detectPackageManager(result.projectPath) : "npm";
278
+ const runDev = `${packageManager} run dev`;
247
279
  return JSON.stringify({
248
280
  projectPath: result.projectPath,
249
281
  projectName: result.projectName,
250
282
  template: result.template,
251
283
  depsInstalled: result.depsInstalled,
284
+ packageManager: result.depsInstalled ? packageManager : null,
252
285
  duration: Date.now() - start,
253
- nextSteps: [
286
+ nextSteps: result.depsInstalled ? [
254
287
  `cd ${result.projectPath}`,
288
+ runDev
289
+ ] : [
290
+ `cd ${result.projectPath}`,
291
+ "npm install",
255
292
  "npm run dev"
256
- ]
293
+ ],
294
+ ...result.depsInstalled ? {} : {
295
+ warnings: logTail()
296
+ }
257
297
  });
258
298
  } catch (err) {
259
299
  return JSON.stringify({
260
300
  error: err instanceof Error ? err.message : String(err),
261
- duration: Date.now() - start
301
+ duration: Date.now() - start,
302
+ log: logTail()
262
303
  });
263
304
  }
264
305
  }
@@ -299,8 +340,49 @@ async function listTemplates(filters) {
299
340
  if (filters?.tags?.length) templates = templates.filter((t)=>filters.tags.some((tag)=>t.tags?.includes(tag) || t.aiRecommendFor?.includes(tag)));
300
341
  if (filters?.featured) templates = templates.filter((t)=>t.featured);
301
342
  if (filters?.query) {
302
- const q = filters.query.toLowerCase();
303
- templates = templates.filter((t)=>t.slug.includes(q) || t.description.toLowerCase().includes(q) || t.tags?.some((tag)=>tag.includes(q)) || t.useCases?.some((uc)=>uc.toLowerCase().includes(q)) || t.aiPromptExamples?.some((ex)=>ex.toLowerCase().includes(q)));
343
+ const phrase = filters.query.toLowerCase().trim();
344
+ const STOP = new Set([
345
+ "the",
346
+ "a",
347
+ "an",
348
+ "and",
349
+ "or",
350
+ "for",
351
+ "with",
352
+ "that",
353
+ "this",
354
+ "to",
355
+ "of",
356
+ "on",
357
+ "in",
358
+ "into",
359
+ "your",
360
+ "my",
361
+ "me",
362
+ "it",
363
+ "is"
364
+ ]);
365
+ const tokens = phrase.split(/\s+/).filter((tok)=>tok.length >= 2 && !STOP.has(tok));
366
+ const bodyOf = (t)=>[
367
+ t.description,
368
+ ...t.tags ?? [],
369
+ ...t.useCases ?? [],
370
+ ...t.aiPromptExamples ?? []
371
+ ].join(" ").toLowerCase();
372
+ const scored = templates.map((t)=>{
373
+ const slug = t.slug.toLowerCase();
374
+ const body = bodyOf(t);
375
+ const hay = `${slug} ${body}`;
376
+ let score = 0;
377
+ if (phrase && hay.includes(phrase)) score += 100;
378
+ for (const tok of tokens)if (slug.includes(tok)) score += 3;
379
+ else if (body.includes(tok)) score += 1;
380
+ return {
381
+ t,
382
+ score
383
+ };
384
+ }).filter((entry)=>entry.score > 0).sort((a, b)=>b.score - a.score);
385
+ templates = scored.map((entry)=>entry.t);
304
386
  }
305
387
  return templates;
306
388
  }
@@ -310,7 +392,7 @@ async function getTemplateBySlug(slug) {
310
392
  }
311
393
  const list_templates_schema = {
312
394
  name: "extension_list_templates",
313
- description: "List available extension templates from the extension.dev template catalog. Filter by surface, framework, or tags. Returns structured metadata from templates-meta.json.",
395
+ description: "List available extension templates from the extension.dev template catalog. Filter by surface, framework, or tags. Returns structured metadata from templates-meta.json. Note: 'framework' is the UI framework only (react/vue/svelte/preact/vanilla) - it is not the language. TypeScript and JavaScript templates live under slugs (e.g. 'typescript', 'content-typescript'); shadcn is a React variant ('sidebar-shadcn') and provider AIs are tagged 'ai' ('ai-chatgpt', 'ai-claude'). Reach those with query/tags/slug, not framework.",
314
396
  inputSchema: {
315
397
  type: "object",
316
398
  properties: {
@@ -319,13 +401,10 @@ const list_templates_schema = {
319
401
  enum: [
320
402
  "content",
321
403
  "sidebar",
322
- "action",
323
404
  "newtab",
324
- "devtools",
325
- "options",
326
405
  "background"
327
406
  ],
328
- description: "Filter by extension surface type"
407
+ description: "Filter by extension surface type. For a popup/action starter use the 'action' slug (query:'action'), not a surface filter."
329
408
  },
330
409
  framework: {
331
410
  type: "string",
@@ -336,7 +415,7 @@ const list_templates_schema = {
336
415
  "preact",
337
416
  ""
338
417
  ],
339
- description: "Filter by UI framework (empty string = vanilla JS)"
418
+ description: "Filter by UI framework only (empty string = vanilla JS). Not a language filter - for TypeScript/JavaScript use query or slug."
340
419
  },
341
420
  tags: {
342
421
  type: "array",
@@ -351,7 +430,7 @@ const list_templates_schema = {
351
430
  },
352
431
  query: {
353
432
  type: "string",
354
- description: "Free-text search across slug, description, tags, and useCases"
433
+ description: "Keyword search across slug, description, tags, and useCases. Ranks by how many query words match, so a natural phrase works; single keywords are fine too."
355
434
  }
356
435
  }
357
436
  }
@@ -375,112 +454,6 @@ async function list_templates_handler(args) {
375
454
  templates: results
376
455
  });
377
456
  }
378
- const build_schema = {
379
- name: "extension_build",
380
- description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
381
- inputSchema: {
382
- type: "object",
383
- properties: {
384
- projectPath: {
385
- type: "string",
386
- description: "Path to the extension project root"
387
- },
388
- browser: {
389
- type: "string",
390
- enum: [
391
- "chrome",
392
- "chromium",
393
- "edge",
394
- "brave",
395
- "opera",
396
- "vivaldi",
397
- "yandex",
398
- "firefox",
399
- "waterfox",
400
- "librewolf",
401
- "safari",
402
- "chromium-based",
403
- "gecko-based",
404
- "firefox-based",
405
- "webkit-based"
406
- ],
407
- default: "chrome",
408
- description: "Target browser"
409
- },
410
- zip: {
411
- type: "boolean",
412
- default: false,
413
- description: "Create a .zip file for store distribution"
414
- },
415
- zipSource: {
416
- type: "boolean",
417
- default: false,
418
- description: "Include source code zip (required by some stores)"
419
- },
420
- zipFilename: {
421
- type: "string",
422
- description: "Custom .zip file name (defaults to name and version)"
423
- },
424
- polyfill: {
425
- type: "boolean",
426
- default: false,
427
- description: "Apply cross-browser polyfill"
428
- },
429
- silent: {
430
- type: "boolean",
431
- default: false,
432
- description: "Suppress build output"
433
- },
434
- mode: {
435
- type: "string",
436
- enum: [
437
- "development",
438
- "production",
439
- "none"
440
- ],
441
- default: "production",
442
- description: "Bundler mode override (also sets NODE_ENV)"
443
- }
444
- },
445
- required: [
446
- "projectPath"
447
- ]
448
- }
449
- };
450
- async function build_handler(args) {
451
- const start = Date.now();
452
- try {
453
- const summary = await extensionBuild(args.projectPath, {
454
- browser: args.browser ?? "chrome",
455
- zip: args.zip ?? false,
456
- zipSource: args.zipSource ?? false,
457
- ...args.zipFilename ? {
458
- zipFilename: args.zipFilename
459
- } : {},
460
- ...void 0 !== args.polyfill ? {
461
- polyfill: args.polyfill
462
- } : {},
463
- ...void 0 !== args.silent ? {
464
- silent: args.silent
465
- } : {},
466
- ...args.mode ? {
467
- mode: args.mode
468
- } : {},
469
- exitOnError: false
470
- });
471
- return JSON.stringify({
472
- success: true,
473
- ...summary,
474
- duration: Date.now() - start
475
- });
476
- } catch (err) {
477
- return JSON.stringify({
478
- error: err instanceof Error ? err.message : String(err),
479
- duration: Date.now() - start,
480
- hint: "Check that the project has a valid src/manifest.json and all dependencies are installed."
481
- });
482
- }
483
- }
484
457
  const PINNED_CLI_VERSION = String(package_namespaceObject.El.OP ?? "latest").replace(/^[\^~]/, "");
485
458
  function pinnedCliVersion() {
486
459
  const override = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
@@ -562,6 +535,126 @@ function spawnExtensionCli(args, options) {
562
535
  child.unref();
563
536
  return child;
564
537
  }
538
+ const build_schema = {
539
+ name: "extension_build",
540
+ description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
541
+ inputSchema: {
542
+ type: "object",
543
+ properties: {
544
+ projectPath: {
545
+ type: "string",
546
+ description: "Path to the extension project root"
547
+ },
548
+ browser: {
549
+ type: "string",
550
+ enum: [
551
+ "chrome",
552
+ "chromium",
553
+ "edge",
554
+ "brave",
555
+ "opera",
556
+ "vivaldi",
557
+ "yandex",
558
+ "firefox",
559
+ "waterfox",
560
+ "librewolf",
561
+ "safari",
562
+ "chromium-based",
563
+ "gecko-based",
564
+ "firefox-based",
565
+ "webkit-based"
566
+ ],
567
+ default: "chrome",
568
+ description: "Target browser"
569
+ },
570
+ zip: {
571
+ type: "boolean",
572
+ default: false,
573
+ description: "Create a .zip file for store distribution"
574
+ },
575
+ zipSource: {
576
+ type: "boolean",
577
+ default: false,
578
+ description: "Include source code zip (required by some stores)"
579
+ },
580
+ zipFilename: {
581
+ type: "string",
582
+ description: "Custom .zip file name (defaults to name and version)"
583
+ },
584
+ polyfill: {
585
+ type: "boolean",
586
+ default: false,
587
+ description: "Apply cross-browser polyfill"
588
+ },
589
+ silent: {
590
+ type: "boolean",
591
+ default: false,
592
+ description: "Suppress build output"
593
+ },
594
+ mode: {
595
+ type: "string",
596
+ enum: [
597
+ "development",
598
+ "production",
599
+ "none"
600
+ ],
601
+ default: "production",
602
+ description: "Bundler mode override (also sets NODE_ENV)"
603
+ }
604
+ },
605
+ required: [
606
+ "projectPath"
607
+ ]
608
+ }
609
+ };
610
+ async function build_handler(args) {
611
+ const start = Date.now();
612
+ const browser = args.browser ?? "chrome";
613
+ const cliArgs = [
614
+ "build",
615
+ args.projectPath,
616
+ "--browser",
617
+ browser
618
+ ];
619
+ if (args.zip) cliArgs.push("--zip");
620
+ if (args.zipSource) cliArgs.push("--zip-source");
621
+ if (args.zipFilename) cliArgs.push("--zip-filename", args.zipFilename);
622
+ if (args.polyfill) cliArgs.push("--polyfill");
623
+ if (args.silent) cliArgs.push("--silent");
624
+ if (args.mode) cliArgs.push("--mode", args.mode);
625
+ const { code, stdout, stderr } = await runExtensionCli(cliArgs, {
626
+ cwd: args.projectPath,
627
+ timeoutMs: 180000
628
+ });
629
+ const duration = Date.now() - start;
630
+ const out = (stdout ?? "").trim();
631
+ const lastLines = (text, n)=>text.split("\n").slice(-n).join("\n");
632
+ if (0 === code) {
633
+ const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
634
+ const status = out.match(/Build Status:\s*(\w+)/)?.[1];
635
+ return JSON.stringify({
636
+ success: true,
637
+ browser,
638
+ ...size ? {
639
+ size
640
+ } : {},
641
+ ...status ? {
642
+ status
643
+ } : {},
644
+ zip: args.zip ?? false,
645
+ duration,
646
+ output: lastLines(out, 12)
647
+ });
648
+ }
649
+ const message = stderr.trim() || out || `extension build exited with code ${code}`;
650
+ return JSON.stringify({
651
+ success: false,
652
+ browser,
653
+ error: message.slice(0, 1200),
654
+ duration,
655
+ hint: "Check that the project has a valid src/manifest.json and its dependencies are installed (extension_dev auto-installs; build does not)."
656
+ });
657
+ }
565
658
  const process_manager_sessions = new Map();
566
659
  function sessionKey(projectPath, browser) {
567
660
  return `${node_path.resolve(projectPath)}::${browser}`;
@@ -726,9 +819,20 @@ async function dev_handler(args) {
726
819
  projectPath: args.projectPath,
727
820
  status: "started",
728
821
  hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. When you are done, call extension_stop to shut down the dev server and browser.",
729
- earlyOutput: earlyOutput.slice(0, 500)
822
+ earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500)
730
823
  });
731
824
  }
825
+ function denoiseEarlyOutput(raw) {
826
+ const NOISE = [
827
+ /^npm warn Unknown project config/i,
828
+ /This will stop working in the next major version of npm/i,
829
+ /^npm warn config/i,
830
+ /V8: .*Invalid asm\.js/i,
831
+ /^\(node:\d+\) V8:/i,
832
+ /Use `node --trace-warnings/i
833
+ ];
834
+ return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
835
+ }
732
836
  const start_schema = {
733
837
  name: "extension_start",
734
838
  description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
@@ -1018,7 +1122,7 @@ const get_template_source_schema = {
1018
1122
  properties: {
1019
1123
  slug: {
1020
1124
  type: "string",
1021
- description: "Template slug (e.g. 'sidebar-claude', 'content-react')"
1125
+ description: "Template slug (e.g. 'ai-claude', 'content-react')"
1022
1126
  },
1023
1127
  files: {
1024
1128
  type: "array",
@@ -1101,6 +1205,91 @@ function isChromiumFamily(browser) {
1101
1205
  function isGeckoFamily(browser) {
1102
1206
  return GECKO_FAMILY.has(browser);
1103
1207
  }
1208
+ const KNOWN_PERMISSIONS = new Set([
1209
+ "activeTab",
1210
+ "alarms",
1211
+ "background",
1212
+ "bookmarks",
1213
+ "browsingData",
1214
+ "certificateProvider",
1215
+ "clipboardRead",
1216
+ "clipboardWrite",
1217
+ "contentSettings",
1218
+ "contextMenus",
1219
+ "cookies",
1220
+ "debugger",
1221
+ "declarativeContent",
1222
+ "declarativeNetRequest",
1223
+ "declarativeNetRequestWithHostAccess",
1224
+ "declarativeNetRequestFeedback",
1225
+ "desktopCapture",
1226
+ "dns",
1227
+ "documentScan",
1228
+ "downloads",
1229
+ "downloads.open",
1230
+ "downloads.ui",
1231
+ "enterprise.deviceAttributes",
1232
+ "enterprise.hardwarePlatform",
1233
+ "enterprise.networkingAttributes",
1234
+ "enterprise.platformKeys",
1235
+ "favicon",
1236
+ "fileBrowserHandler",
1237
+ "fileSystemProvider",
1238
+ "fontSettings",
1239
+ "gcm",
1240
+ "geolocation",
1241
+ "history",
1242
+ "identity",
1243
+ "identity.email",
1244
+ "idle",
1245
+ "loginState",
1246
+ "management",
1247
+ "nativeMessaging",
1248
+ "notifications",
1249
+ "offscreen",
1250
+ "pageCapture",
1251
+ "power",
1252
+ "printerProvider",
1253
+ "printing",
1254
+ "printingMetrics",
1255
+ "privacy",
1256
+ "processes",
1257
+ "proxy",
1258
+ "readingList",
1259
+ "runtime",
1260
+ "scripting",
1261
+ "search",
1262
+ "sessions",
1263
+ "sidePanel",
1264
+ "storage",
1265
+ "system.cpu",
1266
+ "system.display",
1267
+ "system.memory",
1268
+ "system.storage",
1269
+ "tabCapture",
1270
+ "tabGroups",
1271
+ "tabs",
1272
+ "topSites",
1273
+ "tts",
1274
+ "ttsEngine",
1275
+ "unlimitedStorage",
1276
+ "vpnProvider",
1277
+ "wallpaper",
1278
+ "webAuthenticationProxy",
1279
+ "webNavigation",
1280
+ "webRequest",
1281
+ "webRequestBlocking",
1282
+ "webRequestAuthProvider",
1283
+ "browserSettings",
1284
+ "captivePortal",
1285
+ "contextualIdentities",
1286
+ "dns",
1287
+ "menus",
1288
+ "menus.overrideContext",
1289
+ "pkcs11",
1290
+ "theme",
1291
+ "webRequestFilterResponse"
1292
+ ]);
1104
1293
  const manifest_validate_schema = {
1105
1294
  name: "extension_manifest_validate",
1106
1295
  description: "Validate a manifest.json file for correctness across browsers. Reports missing fields, invalid permissions, and cross-browser compatibility issues.",
@@ -1159,6 +1348,13 @@ async function manifest_validate_handler(args) {
1159
1348
  if (!manifest.version) result.warnings.push("Missing field: version (required for store submission)");
1160
1349
  const chromiumManifest = filterKeysForThisBrowser(manifest, "chrome");
1161
1350
  if (!chromiumManifest.manifest_version) result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
1351
+ const declaredPerms = [
1352
+ ...chromiumManifest.permissions ?? [],
1353
+ ...chromiumManifest.optional_permissions ?? []
1354
+ ].filter((p)=>"string" == typeof p);
1355
+ for (const perm of declaredPerms)if (!(perm.includes("://") || perm.includes("*")) && "<all_urls>" !== perm) {
1356
+ if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}" — check for a typo (host/match patterns belong in host_permissions, not permissions).`);
1357
+ }
1162
1358
  for (const browser of browsers){
1163
1359
  const isChromium = isChromiumFamily(browser);
1164
1360
  const isFirefox = isGeckoFamily(browser);
@@ -1196,9 +1392,15 @@ async function manifest_validate_handler(args) {
1196
1392
  if (chromiumManifest.action || manifest["firefox:browser_action"]) surfaces.push("action");
1197
1393
  if (chromiumManifest.chrome_url_overrides?.newtab) surfaces.push("newtab");
1198
1394
  if (chromiumManifest.background) surfaces.push("background");
1199
- if (surfaces.length) try {
1395
+ const distinctive = surfaces.filter((s)=>"background" !== s);
1396
+ const matchOn = distinctive.length ? distinctive : surfaces;
1397
+ if (matchOn.length) try {
1200
1398
  const templates = await listTemplates();
1201
- result.similarTemplates = templates.filter((t)=>t.surfaces.some((s)=>surfaces.includes(s))).slice(0, 5).map((t)=>({
1399
+ result.similarTemplates = templates.map((t)=>({
1400
+ slug: t.slug,
1401
+ surfaces: t.surfaces,
1402
+ score: t.surfaces.filter((s)=>matchOn.includes(s)).length
1403
+ })).filter((t)=>t.score > 0).sort((a, b)=>b.score - a.score).slice(0, 5).map((t)=>({
1202
1404
  slug: t.slug,
1203
1405
  surfaces: t.surfaces
1204
1406
  }));
@@ -1320,12 +1522,21 @@ async function inspect_handler(args) {
1320
1522
  byType[f.type].count++;
1321
1523
  byType[f.type].size += f.size;
1322
1524
  }
1525
+ const sourcemapSize = byType.sourcemap?.size ?? 0;
1526
+ const buildType = sourcemapSize > 0 ? "development" : "production";
1527
+ const shippableSize = totalSize - sourcemapSize;
1323
1528
  const result = {
1324
1529
  browser,
1325
1530
  distPath,
1531
+ buildType,
1326
1532
  totalSize,
1327
1533
  totalSizeFormatted: formatBytes(totalSize),
1534
+ shippableSize,
1535
+ shippableSizeFormatted: formatBytes(shippableSize),
1328
1536
  fileCount: files.length,
1537
+ ..."development" === buildType ? {
1538
+ note: `This dist contains ${formatBytes(sourcemapSize)} of sourcemaps and looks like a dev build; run extension_build for production sizes. shippableSize excludes sourcemaps.`
1539
+ } : {},
1329
1540
  manifest: {
1330
1541
  name: manifest.name,
1331
1542
  version: manifest.version,
@@ -1770,13 +1981,16 @@ class CDPClient extends CDPConnection {
1770
1981
  }
1771
1982
  async function resolveCdpPort(projectPath, browser, options) {
1772
1983
  const waitMs = options?.waitMs ?? 20000;
1984
+ const graceMs = options?.graceMs ?? 2500;
1773
1985
  const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1774
1986
  const deadline = Date.now() + waitMs;
1775
1987
  let contractSeen = false;
1988
+ let contractSeenAt = null;
1776
1989
  for(;;){
1777
1990
  try {
1778
1991
  const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1779
1992
  contractSeen = true;
1993
+ if (null == contractSeenAt) contractSeenAt = Date.now();
1780
1994
  if ("number" == typeof contract.cdpPort) return {
1781
1995
  port: contract.cdpPort,
1782
1996
  source: "contract"
@@ -1784,7 +1998,8 @@ async function resolveCdpPort(projectPath, browser, options) {
1784
1998
  } catch {
1785
1999
  if (!contractSeen) break;
1786
2000
  }
1787
- if (Date.now() >= deadline) break;
2001
+ const effectiveDeadline = null != contractSeenAt ? Math.min(deadline, contractSeenAt + graceMs) : deadline;
2002
+ if (Date.now() >= effectiveDeadline) break;
1788
2003
  await new Promise((resolve)=>setTimeout(resolve, 500));
1789
2004
  }
1790
2005
  if (!contractSeen && await isCdpEndpoint(9222)) return {
@@ -1954,16 +2169,26 @@ async function source_inspect_handler(args) {
1954
2169
  const cdp = new CDPClient();
1955
2170
  try {
1956
2171
  const allTargets = await CDPClient.discoverTargets(cdpPort);
1957
- const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("chrome://") && !t.url.startsWith("devtools://"));
1958
- if (0 === pageTargets.length) return JSON.stringify({
1959
- cdpPort,
1960
- browser,
1961
- warning: "No inspectable page targets found. The extension may not have opened a page yet.",
1962
- allTargets: allTargets.map((t)=>({
1963
- type: t.type,
1964
- url: t.url?.slice(0, 100)
1965
- }))
1966
- });
2172
+ const OVERRIDE_PAGES = [
2173
+ "chrome://newtab/",
2174
+ "chrome://new-tab-page/",
2175
+ "chrome://bookmarks/",
2176
+ "chrome://history/"
2177
+ ];
2178
+ const isOverridePage = (url)=>OVERRIDE_PAGES.some((p)=>url.startsWith(p));
2179
+ const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("devtools://") && (!t.url.startsWith("chrome://") || isOverridePage(t.url)));
2180
+ if (0 === pageTargets.length) {
2181
+ const chromeOnly = allTargets.some((t)=>"page" === t.type && t.url.startsWith("chrome://"));
2182
+ return JSON.stringify({
2183
+ cdpPort,
2184
+ browser,
2185
+ warning: chromeOnly ? "No inspectable page targets found. Only internal chrome:// pages are open; open the extension's surface (or pass a url to navigate a tab) first." : "No inspectable page targets found. The extension may not have opened a page yet.",
2186
+ allTargets: allTargets.map((t)=>({
2187
+ type: t.type,
2188
+ url: t.url?.slice(0, 100)
2189
+ }))
2190
+ });
2191
+ }
1967
2192
  const target = args.url ? pageTargets.find((t)=>t.url.includes(args.url)) ?? pageTargets[0] : pageTargets[0];
1968
2193
  const browserWsUrl = await CDPClient.discoverBrowserWsUrl(cdpPort);
1969
2194
  await cdp.connect(browserWsUrl);
@@ -2459,7 +2684,7 @@ function commonFlags(args) {
2459
2684
  }
2460
2685
  const eval_schema = {
2461
2686
  name: "extension_eval",
2462
- description: "Evaluate an expression in a running extension context (service worker, content script, popup, options, sidebar). Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Wraps `extension eval`.",
2687
+ description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Chromium caveats: eval in the MV3 background/service_worker is blocked by CSP (use an MV2/Firefox build for that context), and context content/page/popup require a numeric `tab` id (a chrome.tabs id) — the `url` arg alone does not target a tab. To read a content-script DOM without a tab id, prefer extension_source_inspect (it auto-selects the active page and can navigate by url). Wraps `extension eval`.",
2463
2688
  inputSchema: {
2464
2689
  type: "object",
2465
2690
  properties: {
@@ -2487,11 +2712,11 @@ const eval_schema = {
2487
2712
  },
2488
2713
  url: {
2489
2714
  type: "string",
2490
- description: "For content/page: document(s) to target"
2715
+ description: "For content/page: filters which matching document(s) to target, but does NOT by itself select a tab — a numeric `tab` id is still required on Chromium."
2491
2716
  },
2492
2717
  tab: {
2493
2718
  type: "number",
2494
- description: "For content/page: a specific tab id"
2719
+ description: "Numeric chrome.tabs id. Required for context content/page/popup on Chromium (the `url` arg does not substitute for it)."
2495
2720
  },
2496
2721
  browser: {
2497
2722
  type: "string",
@@ -2703,7 +2928,21 @@ async function open_handler(args) {
2703
2928
  if ("command" === args.surface && args.name) cli.push("--name", args.name);
2704
2929
  cli.push("--browser", browser);
2705
2930
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
2706
- return runActVerb(cli, args.projectPath, args.timeout);
2931
+ const raw = await runActVerb(cli, args.projectPath, args.timeout);
2932
+ const headless = /^(1|true)$/i.test(process.env.EXTENSION_HEADLESS ?? "");
2933
+ if (headless && [
2934
+ "popup",
2935
+ "action",
2936
+ "sidebar"
2937
+ ].includes(args.surface)) try {
2938
+ const parsed = JSON.parse(raw);
2939
+ const msg = String(parsed?.error?.message ?? "");
2940
+ if (parsed?.ok === false && !parsed.hint && /active browser window|no active|headless/i.test(msg)) {
2941
+ parsed.hint = "The dev browser is running headless (EXTENSION_HEADLESS), which has no visible window to attach a popup/sidebar to. Relaunch a headed dev session to open UI surfaces.";
2942
+ return JSON.stringify(parsed);
2943
+ }
2944
+ } catch {}
2945
+ return raw;
2707
2946
  }
2708
2947
  const dom_inspect_schema = {
2709
2948
  name: "extension_dom_inspect",
@@ -2780,8 +3019,9 @@ async function dom_inspect_handler(args) {
2780
3019
  ok: false,
2781
3020
  error: {
2782
3021
  name: "BadRequest",
2783
- message: "content/page inspect requires a tab id"
2784
- }
3022
+ message: "content/page inspect requires a numeric tab id (chrome.tabs id, not a CDP target id)."
3023
+ },
3024
+ hint: "To inspect a content script's DOM without hunting for a tab id, use extension_source_inspect (it auto-selects the active page and can navigate via its url arg). Use extension_dom_inspect with context popup/options/sidebar/devtools for an open surface (no tab needed)."
2785
3025
  });
2786
3026
  const cli = [
2787
3027
  "inspect",
@@ -2813,13 +3053,17 @@ function readCredentials() {
2813
3053
  if (1 !== data.version) return null;
2814
3054
  const token = String(data.token || "").trim();
2815
3055
  if (!token) return null;
3056
+ const provider = "extensiondev" === data.provider || "github" === data.provider ? data.provider : void 0;
2816
3057
  return {
2817
3058
  version: 1,
2818
3059
  token,
2819
3060
  workspaceSlug: String(data.workspaceSlug || ""),
2820
3061
  projectSlug: String(data.projectSlug || ""),
2821
3062
  expiresAt: Number(data.expiresAt || 0),
2822
- api: String(data.api || "")
3063
+ api: String(data.api || ""),
3064
+ ...provider ? {
3065
+ provider
3066
+ } : {}
2823
3067
  };
2824
3068
  } catch {
2825
3069
  return null;
@@ -2891,8 +3135,12 @@ function safeApiBase(raw) {
2891
3135
  async function fetchLoginConfig(apiBase, fetchImpl = fetch) {
2892
3136
  const override = String(process.env.EXTENSION_DEV_GITHUB_CLIENT_ID || "").trim();
2893
3137
  if (override) return {
3138
+ provider: "github",
2894
3139
  clientId: override,
2895
- scope: "read:user"
3140
+ scope: "read:user",
3141
+ deviceCodeUrl: "/api/cli/device/code",
3142
+ deviceTokenUrl: "/api/cli/device/token",
3143
+ verificationUri: "https://github.com/login/device"
2896
3144
  };
2897
3145
  const res = await fetchImpl(`${apiBase}/api/cli/login/config`, {
2898
3146
  headers: {
@@ -2901,12 +3149,32 @@ async function fetchLoginConfig(apiBase, fetchImpl = fetch) {
2901
3149
  });
2902
3150
  if (!res.ok) throw new Error(`Could not fetch login config from ${apiBase} (${res.status}).`);
2903
3151
  const data = await res.json().catch(()=>({}));
3152
+ const provider = "extensiondev" === data.provider ? "extensiondev" : "github";
2904
3153
  const clientId = String(data.githubClientId || "").trim();
2905
- if (!clientId) throw new Error("Login is not configured on the server (no GitHub client id). Set EXTENSION_DEV_GITHUB_CLIENT_ID to override.");
3154
+ if ("github" === provider && !clientId) throw new Error("Login is not configured on the server (no GitHub client id). Set EXTENSION_DEV_GITHUB_CLIENT_ID to override.");
2906
3155
  return {
3156
+ provider,
2907
3157
  clientId,
2908
- scope: String(data.scope || "read:user")
3158
+ scope: String(data.scope || "read:user"),
3159
+ deviceCodeUrl: String(data.deviceCodeUrl || "/api/cli/device/code"),
3160
+ deviceTokenUrl: String(data.deviceTokenUrl || "/api/cli/device/token"),
3161
+ verificationUri: String(data.verificationUri || "https://github.com/login/device")
3162
+ };
3163
+ }
3164
+ function persistTokenResponse(args) {
3165
+ const token = String(args.data.token || "").trim();
3166
+ if (!token) throw new Error("Login returned no token.");
3167
+ const creds = {
3168
+ version: 1,
3169
+ token,
3170
+ workspaceSlug: String(args.data.workspaceSlug || ""),
3171
+ projectSlug: String(args.data.projectSlug || ""),
3172
+ expiresAt: Number(args.data.expiresAt || 0),
3173
+ api: args.apiBase,
3174
+ provider: args.provider
2909
3175
  };
3176
+ writeCredentials(creds);
3177
+ return creds;
2910
3178
  }
2911
3179
  async function exchangeAndPersist(args) {
2912
3180
  const doFetch = args.fetchImpl ?? fetch;
@@ -2931,18 +3199,11 @@ async function exchangeAndPersist(args) {
2931
3199
  };
2932
3200
  }
2933
3201
  if (!res.ok) throw new Error(`Login exchange failed (${res.status}): ${data.message || "unknown error"}`);
2934
- const token = String(data.token || "").trim();
2935
- if (!token) throw new Error("Login exchange returned no token.");
2936
- const creds = {
2937
- version: 1,
2938
- token,
2939
- workspaceSlug: String(data.workspaceSlug || ""),
2940
- projectSlug: String(data.projectSlug || ""),
2941
- expiresAt: Number(data.expiresAt || 0),
2942
- api: args.apiBase
2943
- };
2944
- writeCredentials(creds);
2945
- return creds;
3202
+ return persistTokenResponse({
3203
+ apiBase: args.apiBase,
3204
+ data,
3205
+ provider: "github"
3206
+ });
2946
3207
  }
2947
3208
  function resolveToken() {
2948
3209
  const fromEnv = String(process.env.EXTENSION_DEV_TOKEN || "").trim();
@@ -3014,53 +3275,60 @@ async function publish(options = {}) {
3014
3275
  }
3015
3276
  const publish_schema = {
3016
3277
  name: "extension_publish",
3017
- description: "Publish a project to extension.dev and return a shareable URL. Auth-gated: requires EXTENSION_DEV_TOKEN (a workspace/project access token) in the environment. Posts to the platform's CLI publish endpoint directly. This is the only tool that talks to the hosted platform rather than the local browser.",
3278
+ description: "Publish the project your stored token is scoped to (from extension_login, or EXTENSION_DEV_TOKEN) to extension.dev and return its shareable URL. The publish target is the token's project -- there is no projectPath, the local files are not uploaded. For a PUBLIC project the URL is the canonical public page and ttlHours does not apply; for a PRIVATE project it is a fresh time-limited share link (?share=) whose lifetime is ttlHours. Posts to the platform's CLI publish endpoint. Besides extension_login this is the only tool that talks to the hosted platform.",
3018
3279
  inputSchema: {
3019
3280
  type: "object",
3020
3281
  properties: {
3021
- projectPath: {
3022
- type: "string",
3023
- description: "Path to the extension project root"
3024
- },
3025
3282
  ttlHours: {
3026
3283
  type: "number",
3027
- description: "Share-link lifetime in hours (1168, default 24)"
3284
+ description: "Private-project share-link lifetime in hours, 1-168 (default 24). Ignored for public projects."
3028
3285
  },
3029
3286
  buildSha: {
3030
3287
  type: "string",
3031
- description: "Pin the share URL to a specific build sha"
3288
+ description: "Pin the share URL to a specific build sha (7-40 hex chars). The platform verifies the build exists in the project's build index and rejects an unknown sha, so the returned URL always points at a real build."
3032
3289
  },
3033
3290
  api: {
3034
3291
  type: "string",
3035
3292
  description: "Platform base URL (defaults to https://www.extension.dev or EXTENSION_DEV_API_URL)"
3036
3293
  }
3037
3294
  },
3038
- required: [
3039
- "projectPath"
3040
- ]
3295
+ required: []
3041
3296
  }
3042
3297
  };
3043
- async function publish_handler(args) {
3044
- const token = resolveToken();
3045
- if (!token) return JSON.stringify({
3298
+ function fail(name, message) {
3299
+ return JSON.stringify({
3046
3300
  ok: false,
3047
3301
  error: {
3048
- name: "PublishAuthError",
3049
- message: "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
3302
+ name,
3303
+ message
3050
3304
  }
3051
3305
  });
3306
+ }
3307
+ async function publish_handler(args) {
3308
+ const token = resolveToken();
3309
+ if (!token) return fail("PublishAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).");
3310
+ if (null != args.ttlHours) {
3311
+ const t = Number(args.ttlHours);
3312
+ if (!Number.isInteger(t) || t < 1 || t > 168) return fail("PublishBadRequest", "ttlHours must be an integer between 1 and 168.");
3313
+ }
3314
+ if (null != args.buildSha && "" !== args.buildSha) {
3315
+ if (!/^[0-9a-f]{7,40}$/i.test(args.buildSha)) return fail("PublishBadRequest", "buildSha must be a 7-40 character hex git sha.");
3316
+ }
3052
3317
  const result = await publish({
3053
3318
  ttlHours: args.ttlHours,
3054
3319
  buildSha: args.buildSha,
3055
3320
  api: args.api,
3056
3321
  token
3057
3322
  });
3058
- return result.ok ? JSON.stringify(result.data) : JSON.stringify(result);
3323
+ if (!result.ok) return JSON.stringify(result);
3324
+ const data = result.data;
3325
+ if (null != args.ttlHours && "public" === data.visibility) data.note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
3326
+ return JSON.stringify(data);
3059
3327
  }
3060
3328
  const release_promote_DEFAULT_API = "https://www.extension.dev";
3061
3329
  const release_promote_schema = {
3062
3330
  name: "extension_release_promote",
3063
- description: "Promote a built extension to a release channel (e.g. stable, preview, beta) on extension.dev, headless. Auth-gated: requires a release token in EXTENSION_DEV_TOKEN (mint and revoke it in the dashboard under project settings -> Access tokens). Posts to the platform's CLI release endpoint; the project is identified by the token. Cutting a version-bump PR is not available headlessly (it writes to your source repo and needs an interactive login).",
3331
+ description: "Promote a built extension to a release channel (e.g. stable, preview, beta) on extension.dev, headless. Auth-gated: uses your stored login (extension_login) or a release token in EXTENSION_DEV_TOKEN (mint and revoke it in the dashboard under project settings -> Access tokens). Posts to the platform's CLI release endpoint; the project is identified by the token. Cutting a version-bump PR is not available headlessly (it writes to your source repo and needs an interactive login).",
3064
3332
  inputSchema: {
3065
3333
  type: "object",
3066
3334
  properties: {
@@ -3102,7 +3370,7 @@ const release_promote_schema = {
3102
3370
  ]
3103
3371
  }
3104
3372
  };
3105
- function fail(name, message) {
3373
+ function release_promote_fail(name, message) {
3106
3374
  return JSON.stringify({
3107
3375
  ok: false,
3108
3376
  error: {
@@ -3113,12 +3381,12 @@ function fail(name, message) {
3113
3381
  }
3114
3382
  async function release_promote_handler(args) {
3115
3383
  const token = resolveToken();
3116
- if (!token) return fail("ReleaseAuthError", "No token. Set EXTENSION_DEV_TOKEN to a release token (create one in the extension.dev dashboard under project settings -> Access tokens), or run extension_login.");
3384
+ if (!token) return release_promote_fail("ReleaseAuthError", "No token. Set EXTENSION_DEV_TOKEN to a release token (create one in the extension.dev dashboard under project settings -> Access tokens), or run extension_login.");
3117
3385
  const buildId = String(args.buildId || "").trim();
3118
3386
  const channel = String(args.channel || "").trim();
3119
- if (!buildId || !channel) return fail("ReleaseInputError", "buildId and channel are required.");
3387
+ if (!buildId || !channel) return release_promote_fail("ReleaseInputError", "buildId and channel are required.");
3120
3388
  const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || release_promote_DEFAULT_API));
3121
- if (!apiCheck.ok) return fail("ReleaseConfigError", apiCheck.message);
3389
+ if (!apiCheck.ok) return release_promote_fail("ReleaseConfigError", apiCheck.message);
3122
3390
  const url = `${apiCheck.base}/api/cli/release/promote`;
3123
3391
  const body = {
3124
3392
  buildId,
@@ -3139,7 +3407,7 @@ async function release_promote_handler(args) {
3139
3407
  body: JSON.stringify(body)
3140
3408
  });
3141
3409
  } catch (err) {
3142
- return fail("ReleaseNetworkError", `Could not reach ${url}: ${err?.message || err}`);
3410
+ return release_promote_fail("ReleaseNetworkError", `Could not reach ${url}: ${err?.message || err}`);
3143
3411
  }
3144
3412
  const text = await res.text();
3145
3413
  let data;
@@ -3150,175 +3418,117 @@ async function release_promote_handler(args) {
3150
3418
  message: text
3151
3419
  };
3152
3420
  }
3153
- if (!res.ok) return fail("ReleaseError", `promote failed (${res.status}): ${data?.message || text || "unknown error"}`);
3421
+ if (!res.ok) return release_promote_fail("ReleaseError", `promote failed (${res.status}): ${data?.message || text || "unknown error"}`);
3154
3422
  return JSON.stringify(data);
3155
3423
  }
3156
- const DEFAULT_DEPLOY_VERSION = "1.2.1";
3157
- function pinnedDeployVersion() {
3158
- return String(process.env.EXTENSION_DEPLOY_VERSION || "").trim() || DEFAULT_DEPLOY_VERSION;
3159
- }
3160
- function buildDeployArgs(args) {
3161
- const argv = [];
3162
- const pushValue = (flag, value)=>{
3163
- if (void 0 === value || "" === value) return;
3164
- argv.push(flag, String(value));
3165
- };
3166
- if (false !== args.dryRun) argv.push("--dry-run");
3167
- pushValue("--chrome-zip", args.chromeZip);
3168
- pushValue("--chrome-extension-id", args.chromeExtensionId);
3169
- pushValue("--chrome-publisher-id", args.chromePublisherId);
3170
- if (args.stagedPublish) argv.push("--chrome-staged-publish");
3171
- pushValue("--chrome-deploy-percentage", args.chromeDeployPercentage);
3172
- if (args.chromeSkipSubmitReview) argv.push("--chrome-skip-submit-review");
3173
- pushValue("--firefox-zip", args.firefoxZip);
3174
- pushValue("--firefox-sources-zip", args.firefoxSourcesZip);
3175
- pushValue("--firefox-extension-id", args.firefoxExtensionId);
3176
- pushValue("--firefox-channel", args.firefoxChannel);
3177
- pushValue("--edge-zip", args.edgeZip);
3178
- pushValue("--edge-product-id", args.edgeProductId);
3179
- if (args.edgeSkipSubmitReview) argv.push("--edge-skip-submit-review");
3180
- pushValue("--output-json", args.outputJson);
3181
- return argv;
3182
- }
3424
+ const deploy_DEFAULT_API = "https://www.extension.dev";
3183
3425
  const deploy_schema = {
3184
3426
  name: "extension_deploy",
3185
- description: "Submit a built extension to the Chrome Web Store, Firefox AMO, and/or Edge Add-ons by driving the standalone @extension.dev/deploy CLI. Provide the built .zip path(s); the target stores are inferred from which zips you pass. DEFAULTS TO A DRY RUN (verifies auth and inputs, uploads nothing) - pass dryRun:false to actually submit, which is irreversible and enters store review. Store CREDENTIALS are NOT arguments: set them in the environment or a .env.submit file in projectPath (CHROME_CLIENT_ID/CHROME_CLIENT_SECRET/CHROME_REFRESH_TOKEN or CHROME_SERVICE_ACCOUNT_JSON; FIREFOX_JWT_ISSUER/FIREFOX_JWT_SECRET; EDGE_CLIENT_ID/EDGE_API_KEY). Runs `npx @extension.dev/deploy`.",
3427
+ description: "Submit a built extension to the Chrome Web Store, Firefox AMO, and/or Edge Add-ons THROUGH extension.dev, which holds your store credentials and dispatches the release from your project's mirror CI. DEFAULTS TO A DRY RUN (preflight: verifies auth, the project, that the build exists, and the store workflow - dispatches nothing); pass dryRun:false to actually submit, which is irreversible and enters store review. The target project is identified by your token (extension_login or a release token in EXTENSION_DEV_TOKEN); store credentials are never tool arguments and local files are not uploaded. Pass browsers + buildSha. Posts to the platform's CLI store-submission endpoint.",
3186
3428
  inputSchema: {
3187
3429
  type: "object",
3188
3430
  properties: {
3189
- projectPath: {
3190
- type: "string",
3191
- description: "Working directory. Relative zip paths and a .env.submit credential file are resolved from here."
3192
- },
3193
- chromeZip: {
3194
- type: "string",
3195
- description: "Path to the built Chrome extension .zip"
3196
- },
3197
- chromeExtensionId: {
3198
- type: "string",
3199
- description: "Chrome extension ID (or set CHROME_EXTENSION_ID)"
3200
- },
3201
- chromePublisherId: {
3202
- type: "string",
3203
- description: "Chrome publisher UUID (or set CHROME_PUBLISHER_ID)"
3204
- },
3205
- firefoxZip: {
3206
- type: "string",
3207
- description: "Path to the built Firefox extension .zip"
3208
- },
3209
- firefoxSourcesZip: {
3210
- type: "string",
3211
- description: "Path to the Firefox sources .zip (optional)"
3212
- },
3213
- firefoxExtensionId: {
3214
- type: "string",
3215
- description: "Firefox add-on GUID or email-style ID"
3431
+ browsers: {
3432
+ type: "array",
3433
+ items: {
3434
+ type: "string",
3435
+ enum: [
3436
+ "chrome",
3437
+ "firefox",
3438
+ "edge",
3439
+ "safari"
3440
+ ]
3441
+ },
3442
+ description: "Stores to submit to."
3216
3443
  },
3217
- firefoxChannel: {
3444
+ buildSha: {
3218
3445
  type: "string",
3219
- enum: [
3220
- "listed",
3221
- "unlisted"
3222
- ],
3223
- description: "Firefox channel (unlisted is required for a first submission)"
3446
+ description: "The built commit SHA to submit. It must have a completed build in the project's build index; an unknown sha is rejected."
3224
3447
  },
3225
- edgeZip: {
3448
+ channel: {
3226
3449
  type: "string",
3227
- description: "Path to the built Edge extension .zip"
3450
+ description: "Release channel to submit from (default stable)."
3228
3451
  },
3229
- edgeProductId: {
3452
+ version: {
3230
3453
  type: "string",
3231
- description: "Edge Partner Center product ID (or set EDGE_PRODUCT_ID)"
3454
+ description: "Version label for the submission record (optional)."
3232
3455
  },
3233
3456
  dryRun: {
3234
3457
  type: "boolean",
3235
3458
  default: true,
3236
- description: "Verify auth and inputs without uploading or publishing. Pass false to actually submit (irreversible)."
3459
+ description: "Preflight only (verify auth, project, build, and store workflow). Pass false to actually dispatch the submission (irreversible, enters store review)."
3237
3460
  },
3238
- stagedPublish: {
3239
- type: "boolean",
3240
- default: false,
3241
- description: "Chrome: publish as STAGED_PUBLISH so the rollout can be raised review-free later."
3242
- },
3243
- chromeDeployPercentage: {
3244
- type: "number",
3245
- description: "Chrome staged rollout percentage (1-100)."
3246
- },
3247
- chromeSkipSubmitReview: {
3248
- type: "boolean",
3249
- default: false,
3250
- description: "Chrome: upload only, skip the publish/submit-for-review step."
3251
- },
3252
- edgeSkipSubmitReview: {
3253
- type: "boolean",
3254
- default: false,
3255
- description: "Edge: upload only, skip the publish/submit-for-review step."
3256
- },
3257
- outputJson: {
3461
+ api: {
3258
3462
  type: "string",
3259
- description: "Write the machine-readable DeployResult JSON to this path."
3463
+ description: "Platform base URL (defaults to https://www.extension.dev or EXTENSION_DEV_API_URL)"
3260
3464
  }
3261
3465
  },
3262
3466
  required: [
3263
- "projectPath"
3467
+ "browsers",
3468
+ "buildSha"
3264
3469
  ]
3265
3470
  }
3266
3471
  };
3267
- async function deploy_handler(args) {
3268
- if (!args.chromeZip && !args.firefoxZip && !args.edgeZip) return JSON.stringify({
3472
+ function deploy_fail(name, message) {
3473
+ return JSON.stringify({
3269
3474
  ok: false,
3270
3475
  error: {
3271
- name: "DeployInputError",
3272
- message: "No store targets. Provide at least one of chromeZip, firefoxZip, or edgeZip."
3476
+ name,
3477
+ message
3273
3478
  }
3274
3479
  });
3275
- const version = pinnedDeployVersion();
3276
- const argv = [
3277
- "--yes",
3278
- `@extension.dev/deploy@${version}`,
3279
- ...buildDeployArgs(args)
3280
- ];
3281
- return new Promise((resolve)=>{
3282
- const child = cross_spawn("npx", argv, {
3283
- cwd: args.projectPath,
3284
- stdio: [
3285
- "ignore",
3286
- "pipe",
3287
- "pipe"
3288
- ],
3289
- env: {
3290
- ...process.env,
3291
- FORCE_COLOR: "0",
3292
- NO_COLOR: "1"
3293
- }
3294
- });
3295
- let stdout = "";
3296
- let stderr = "";
3297
- child.stdout?.on("data", (d)=>stdout += d.toString());
3298
- child.stderr?.on("data", (d)=>stderr += d.toString());
3299
- child.on("close", (code)=>{
3300
- resolve(JSON.stringify({
3301
- ok: 0 === code,
3302
- dryRun: false !== args.dryRun,
3303
- code,
3304
- stdout: stdout.trim(),
3305
- stderr: stderr.trim()
3306
- }));
3307
- });
3308
- child.on("error", (err)=>{
3309
- resolve(JSON.stringify({
3310
- ok: false,
3311
- error: {
3312
- name: "DeploySpawnError",
3313
- message: `Could not run extension-deploy: ${err instanceof Error ? err.message : String(err)}`
3314
- }
3315
- }));
3480
+ }
3481
+ async function deploy_handler(args) {
3482
+ const token = resolveToken();
3483
+ if (!token) return deploy_fail("DeployAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens).");
3484
+ const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
3485
+ if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge"]).');
3486
+ const buildSha = String(args.buildSha || "").trim();
3487
+ if (!buildSha) return deploy_fail("DeployInputError", "buildSha is required (the built commit to submit).");
3488
+ const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || deploy_DEFAULT_API));
3489
+ if (!apiCheck.ok) return deploy_fail("DeployConfigError", apiCheck.message);
3490
+ const url = `${apiCheck.base}/api/cli/stores/submit`;
3491
+ const dryRun = false !== args.dryRun;
3492
+ const body = {
3493
+ browsers,
3494
+ buildSha,
3495
+ dryRun
3496
+ };
3497
+ if (args.channel) body.channel = String(args.channel).trim();
3498
+ if (args.version) body.version = String(args.version).trim();
3499
+ let res;
3500
+ try {
3501
+ res = await fetch(url, {
3502
+ method: "POST",
3503
+ headers: {
3504
+ authorization: `Bearer ${token}`,
3505
+ "content-type": "application/json"
3506
+ },
3507
+ body: JSON.stringify(body)
3316
3508
  });
3509
+ } catch (err) {
3510
+ return deploy_fail("DeployNetworkError", `Could not reach ${url}: ${err?.message || err}`);
3511
+ }
3512
+ const text = await res.text();
3513
+ let data;
3514
+ try {
3515
+ data = JSON.parse(text);
3516
+ } catch {
3517
+ data = {
3518
+ message: text
3519
+ };
3520
+ }
3521
+ if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
3522
+ return JSON.stringify({
3523
+ mode: "platform",
3524
+ dryRun,
3525
+ ...data
3317
3526
  });
3318
3527
  }
3528
+ const SAFE_CEILING_MS = 50000;
3319
3529
  const wait_schema = {
3320
3530
  name: "extension_wait",
3321
- description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status.",
3531
+ description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status. A single call is bounded to ~50s to stay under the MCP client request timeout; if it returns status:'timeout', call it again to keep waiting (polling resumes on the same contract).",
3322
3532
  inputSchema: {
3323
3533
  type: "object",
3324
3534
  properties: {
@@ -3332,8 +3542,8 @@ const wait_schema = {
3332
3542
  },
3333
3543
  timeout: {
3334
3544
  type: "number",
3335
- default: 60000,
3336
- description: "Timeout in milliseconds"
3545
+ default: 45000,
3546
+ description: "Requested wait in milliseconds. Clamped to ~50s per call so it never exceeds the MCP client request timeout; call again to keep waiting for slower builds."
3337
3547
  }
3338
3548
  },
3339
3549
  required: [
@@ -3343,7 +3553,9 @@ const wait_schema = {
3343
3553
  };
3344
3554
  async function wait_handler(args) {
3345
3555
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
3346
- const timeout = args.timeout ?? 60000;
3556
+ const requested = args.timeout ?? 45000;
3557
+ const timeout = Math.min(Math.max(requested, 1000), SAFE_CEILING_MS);
3558
+ const clamped = requested > SAFE_CEILING_MS;
3347
3559
  const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
3348
3560
  const start = Date.now();
3349
3561
  const pollInterval = 1000;
@@ -3376,9 +3588,11 @@ async function wait_handler(args) {
3376
3588
  }
3377
3589
  return JSON.stringify({
3378
3590
  status: "timeout",
3379
- message: `Extension did not become ready within ${timeout}ms`,
3591
+ message: `Extension not ready after ${timeout}ms this call`,
3380
3592
  readyPath,
3381
- hint: "The dev session may still be building. Try increasing the timeout, or check if the dev process is still running."
3593
+ waitDuration: Date.now() - start,
3594
+ clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
3595
+ hint: "Still building — call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
3382
3596
  });
3383
3597
  }
3384
3598
  const add_feature_schema = {
@@ -3548,7 +3762,23 @@ async function add_feature_handler(args) {
3548
3762
  });
3549
3763
  const template = await getTemplateBySlug(templateSlug);
3550
3764
  const referenceFiles = template?.keyFiles ?? template?.files ?? [];
3551
- const featureDir = "content-script" === args.feature ? "content" : args.feature;
3765
+ const FEATURE_DIR = {
3766
+ "content-script": "content",
3767
+ popup: "action"
3768
+ };
3769
+ const featureDir = FEATURE_DIR[args.feature] ?? args.feature;
3770
+ const scriptExt = "react" === framework || "preact" === framework ? "tsx" : "ts";
3771
+ const componentExt = "vue" === framework ? "vue" : "svelte" === framework ? "svelte" : "tsx";
3772
+ const COMPONENT_BASE = {
3773
+ content: "Content",
3774
+ sidebar: "Sidebar",
3775
+ action: "Action",
3776
+ newtab: "NewTab",
3777
+ options: "Options",
3778
+ devtools: "DevTools",
3779
+ background: "Background"
3780
+ };
3781
+ const componentBase = COMPONENT_BASE[featureDir] ?? featureDir.charAt(0).toUpperCase() + featureDir.slice(1);
3552
3782
  const filesToCreate = [];
3553
3783
  const manifestUpdates = MANIFEST_ADDITIONS[args.feature] ?? {};
3554
3784
  if ([
@@ -3562,24 +3792,42 @@ async function add_feature_handler(args) {
3562
3792
  path: `src/${featureDir}/index.html`,
3563
3793
  hint: "HTML entry point"
3564
3794
  }, {
3565
- path: `src/${featureDir}/scripts.${"vanilla" === framework ? "ts" : "tsx"}`,
3795
+ path: `src/${featureDir}/scripts.${scriptExt}`,
3566
3796
  hint: "vanilla" === framework ? "Script entry point" : `${framework} mount point`
3567
3797
  }, {
3568
3798
  path: `src/${featureDir}/styles.css`,
3569
3799
  hint: "Stylesheet"
3570
3800
  });
3571
- if ("vanilla" !== framework) filesToCreate.push({
3572
- path: `src/${featureDir}/${featureDir.charAt(0).toUpperCase() + featureDir.slice(1)}App.tsx`,
3573
- hint: `Main ${framework} component`
3801
+ if ("vanilla" !== framework) {
3802
+ filesToCreate.push({
3803
+ path: `src/${featureDir}/${componentBase}App.${componentExt}`,
3804
+ hint: `Main ${framework} component`
3805
+ });
3806
+ if ("vue" === framework) filesToCreate.push({
3807
+ path: `src/${featureDir}/shims-vue.d.ts`,
3808
+ hint: "Vue SFC type shim (lets TS import .vue components)"
3809
+ });
3810
+ }
3811
+ }
3812
+ if ("content-script" === args.feature) {
3813
+ filesToCreate.push({
3814
+ path: "src/content/scripts.ts",
3815
+ hint: "vanilla" === framework ? "Content script entry point" : `${framework} content-script mount point`
3816
+ }, {
3817
+ path: "src/content/styles.css",
3818
+ hint: "Content script styles"
3574
3819
  });
3820
+ if ("vanilla" !== framework) {
3821
+ filesToCreate.push({
3822
+ path: `src/content/ContentApp.${componentExt}`,
3823
+ hint: `Main ${framework} component mounted by the content script`
3824
+ });
3825
+ if ("vue" === framework) filesToCreate.push({
3826
+ path: "src/content/shims-vue.d.ts",
3827
+ hint: "Vue SFC type shim (lets TS import .vue components)"
3828
+ });
3829
+ }
3575
3830
  }
3576
- if ("content-script" === args.feature) filesToCreate.push({
3577
- path: "src/content/scripts.ts",
3578
- hint: "Content script entry point"
3579
- }, {
3580
- path: "src/content/styles.css",
3581
- hint: "Content script styles"
3582
- });
3583
3831
  if ("background" === args.feature) filesToCreate.push({
3584
3832
  path: "src/background.ts",
3585
3833
  hint: "Background service worker / script"
@@ -3682,9 +3930,102 @@ async function pollForToken(args) {
3682
3930
  reason: "pending"
3683
3931
  };
3684
3932
  }
3933
+ async function requestDeviceCode(args) {
3934
+ const doFetch = args.fetchImpl ?? fetch;
3935
+ const res = await doFetch(`${args.apiBase}${args.path}`, {
3936
+ method: "POST",
3937
+ headers: {
3938
+ "content-type": "application/json",
3939
+ accept: "application/json"
3940
+ },
3941
+ body: JSON.stringify({
3942
+ project: args.project,
3943
+ clientName: args.clientName ?? "extension-mcp"
3944
+ })
3945
+ });
3946
+ const text = await res.text();
3947
+ let data;
3948
+ try {
3949
+ data = JSON.parse(text);
3950
+ } catch {
3951
+ data = {
3952
+ message: text
3953
+ };
3954
+ }
3955
+ if (!res.ok) throw new Error(`Device code request failed (${res.status}): ${data.message || "unknown error"}`);
3956
+ const deviceCode = String(data.device_code || "").trim();
3957
+ const userCode = String(data.user_code || "").trim();
3958
+ if (!deviceCode || !userCode) throw new Error("Device code response missing device_code/user_code.");
3959
+ return {
3960
+ deviceCode,
3961
+ userCode,
3962
+ verificationUri: String(data.verification_uri || "https://extension.dev/device"),
3963
+ verificationUriComplete: String(data.verification_uri_complete || data.verification_uri || ""),
3964
+ interval: Number(data.interval || 5),
3965
+ expiresIn: Number(data.expires_in || 900)
3966
+ };
3967
+ }
3968
+ async function pollDeviceToken(args) {
3969
+ const doFetch = args.fetchImpl ?? fetch;
3970
+ const deadline = Date.now() + args.budgetMs;
3971
+ let interval = Math.max(1, args.interval);
3972
+ for(;;){
3973
+ const res = await doFetch(`${args.apiBase}${args.path}`, {
3974
+ method: "POST",
3975
+ headers: {
3976
+ "content-type": "application/json",
3977
+ accept: "application/json"
3978
+ },
3979
+ body: JSON.stringify({
3980
+ device_code: args.deviceCode,
3981
+ project: args.project
3982
+ })
3983
+ });
3984
+ const text = await res.text();
3985
+ let data;
3986
+ try {
3987
+ data = JSON.parse(text);
3988
+ } catch {
3989
+ data = {
3990
+ message: text
3991
+ };
3992
+ }
3993
+ if (res.ok && data.token) {
3994
+ const creds = persistTokenResponse({
3995
+ apiBase: args.apiBase,
3996
+ data,
3997
+ provider: "extensiondev"
3998
+ });
3999
+ return {
4000
+ ok: true,
4001
+ creds
4002
+ };
4003
+ }
4004
+ const error = String(data.error || "");
4005
+ if ("access_denied" === error) return {
4006
+ ok: false,
4007
+ reason: "denied"
4008
+ };
4009
+ if ("expired_token" === error) return {
4010
+ ok: false,
4011
+ reason: "expired"
4012
+ };
4013
+ if ("slow_down" === error) interval += 5;
4014
+ else if (error && "authorization_pending" !== error) return {
4015
+ ok: false,
4016
+ reason: "error",
4017
+ message: String(data.message || error)
4018
+ };
4019
+ if (Date.now() + 1000 * interval >= deadline) return {
4020
+ ok: false,
4021
+ reason: "pending"
4022
+ };
4023
+ await new Promise((r)=>setTimeout(r, 1000 * interval));
4024
+ }
4025
+ }
3685
4026
  const login_schema = {
3686
4027
  name: "extension_login",
3687
- description: "Authenticate to extension.dev via GitHub device-code and store a project-scoped access token locally so extension_publish can use it. Two-phase: call with `project` to get a code + URL for the user to authorize, then call again with the returned `deviceCode` to finish. Never returns the token. This is the only tool besides extension_publish that talks to the hosted platform.",
4028
+ description: "Authenticate to extension.dev and store a project-scoped access token locally so extension_publish can use it. Two-phase: call with `project` to get a code + URL for the user to authorize, then call again with the returned `deviceCode` to finish. The server picks the flow: the extension.dev-gated device flow (you authorize at extension.dev/device; GitHub federation happens server-side, no GitHub token on this machine) or, as a fallback, the legacy GitHub device flow. Never returns the token. This is the only tool besides extension_publish that talks to the hosted platform.",
3688
4029
  inputSchema: {
3689
4030
  type: "object",
3690
4031
  properties: {
@@ -3747,6 +4088,52 @@ async function login_handler(args) {
3747
4088
  } catch (err) {
3748
4089
  return login_fail("LoginConfigError", err?.message || "Could not load login config.");
3749
4090
  }
4091
+ if ("extensiondev" === config.provider) {
4092
+ if (args.deviceCode) {
4093
+ const poll = await pollDeviceToken({
4094
+ apiBase,
4095
+ path: config.deviceTokenUrl,
4096
+ project,
4097
+ deviceCode: String(args.deviceCode),
4098
+ interval: 5,
4099
+ budgetMs: RESUME_BUDGET_MS
4100
+ });
4101
+ if (poll.ok) return success(poll.creds);
4102
+ if ("expired" === poll.reason) return login_fail("LoginExpired", "The device code expired. Run extension_login again to restart.");
4103
+ if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.");
4104
+ if ("error" === poll.reason) return login_fail("LoginError", poll.message || "Device login failed.");
4105
+ return login_pending({
4106
+ deviceCode: String(args.deviceCode),
4107
+ userCode: "(see the previous response)",
4108
+ verificationUri: config.verificationUri
4109
+ });
4110
+ }
4111
+ let start;
4112
+ try {
4113
+ start = await requestDeviceCode({
4114
+ apiBase,
4115
+ path: config.deviceCodeUrl,
4116
+ project
4117
+ });
4118
+ } catch (err) {
4119
+ return login_fail("LoginStartError", err?.message || "Could not start the device flow.");
4120
+ }
4121
+ const poll = await pollDeviceToken({
4122
+ apiBase,
4123
+ path: config.deviceTokenUrl,
4124
+ project,
4125
+ deviceCode: start.deviceCode,
4126
+ interval: start.interval,
4127
+ budgetMs: FIRST_CALL_BUDGET_MS
4128
+ });
4129
+ if (poll.ok) return success(poll.creds);
4130
+ if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.");
4131
+ return login_pending({
4132
+ deviceCode: start.deviceCode,
4133
+ userCode: start.userCode,
4134
+ verificationUri: start.verificationUri
4135
+ });
4136
+ }
3750
4137
  if (args.deviceCode) {
3751
4138
  const poll = await pollForToken({
3752
4139
  clientId: config.clientId,
@@ -3825,6 +4212,7 @@ async function whoami_handler() {
3825
4212
  workspaceSlug: creds.workspaceSlug,
3826
4213
  projectSlug: creds.projectSlug,
3827
4214
  api: creds.api,
4215
+ provider: creds.provider ?? "github",
3828
4216
  expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
3829
4217
  expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
3830
4218
  expired,
@@ -3849,7 +4237,7 @@ async function logout_handler() {
3849
4237
  }
3850
4238
  const install_browser_schema = {
3851
4239
  name: "extension_install_browser",
3852
- description: "Install a managed browser binary for extension testing. Useful in CI, Docker, or fresh environments where browsers are not pre-installed.",
4240
+ description: "Install a managed browser binary for extension testing. Useful in CI, Docker, or fresh environments where browsers are not pre-installed. This downloads ~580-625 MB in a single blocking call (30s+ on a fast link); on a slow network it can exceed a client's default request timeout, so allow a generous timeout when calling it.",
3853
4241
  inputSchema: {
3854
4242
  type: "object",
3855
4243
  properties: {
@@ -4325,35 +4713,70 @@ async function detect_browsers_handler(args) {
4325
4713
  }
4326
4714
  const doctor_schema = {
4327
4715
  name: "extension_doctor",
4328
- description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order — a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`.",
4716
+ description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order — a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
4329
4717
  inputSchema: {
4330
4718
  type: "object",
4331
4719
  properties: {
4332
4720
  projectPath: {
4333
4721
  type: "string",
4334
- description: "Path to the extension project root"
4722
+ description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
4335
4723
  },
4336
4724
  browser: {
4337
4725
  type: "string",
4338
4726
  description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
4339
4727
  }
4340
- },
4341
- required: [
4342
- "projectPath"
4343
- ]
4728
+ }
4344
4729
  }
4345
4730
  };
4731
+ async function environmentPreflight() {
4732
+ const checks = [];
4733
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
4734
+ checks.push({
4735
+ check: "node",
4736
+ status: nodeMajor >= 20 ? "pass" : "fail",
4737
+ detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
4738
+ remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
4739
+ });
4740
+ const { code, stdout, stderr } = await runExtensionCli([
4741
+ "--version"
4742
+ ], {
4743
+ timeoutMs: 60000
4744
+ });
4745
+ const cliVersion = stdout.trim() || stderr.trim();
4746
+ checks.push({
4747
+ check: "extension-cli",
4748
+ status: 0 === code ? "pass" : "fail",
4749
+ detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
4750
+ remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
4751
+ });
4752
+ const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
4753
+ const cacheExists = node_fs.existsSync(cacheFile);
4754
+ checks.push({
4755
+ check: "template-cache",
4756
+ status: cacheExists ? "pass" : "warn",
4757
+ detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
4758
+ });
4759
+ const healthy = checks.every((c)=>"fail" !== c.status);
4760
+ return JSON.stringify({
4761
+ mode: "environment",
4762
+ healthy,
4763
+ checks,
4764
+ hint: "Pass projectPath to diagnose a live dev session end-to-end."
4765
+ });
4766
+ }
4346
4767
  async function doctor_handler(args) {
4347
- const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
4768
+ if (!args.projectPath) return environmentPreflight();
4769
+ const projectPath = args.projectPath;
4770
+ const { browser } = resolveSessionBrowser(projectPath, args.browser);
4348
4771
  const { code, stdout, stderr } = await runExtensionCli([
4349
4772
  "doctor",
4350
- args.projectPath,
4773
+ projectPath,
4351
4774
  "--browser",
4352
4775
  browser,
4353
4776
  "--output",
4354
4777
  "json"
4355
4778
  ], {
4356
- cwd: args.projectPath
4779
+ cwd: projectPath
4357
4780
  });
4358
4781
  const out = stdout.trim();
4359
4782
  try {
@@ -4599,6 +5022,31 @@ async function runCli(cmd, args) {
4599
5022
  const apiBase = resolveApiBase(flag("api"));
4600
5023
  try {
4601
5024
  const config = await fetchLoginConfig(apiBase);
5025
+ if ("extensiondev" === config.provider) {
5026
+ const start = await requestDeviceCode({
5027
+ apiBase,
5028
+ path: config.deviceCodeUrl,
5029
+ project
5030
+ });
5031
+ log("");
5032
+ log(` Open ${start.verificationUri} and enter code: ${start.userCode}`);
5033
+ log("");
5034
+ log(" Waiting for authorization...");
5035
+ const poll = await pollDeviceToken({
5036
+ apiBase,
5037
+ path: config.deviceTokenUrl,
5038
+ project,
5039
+ deviceCode: start.deviceCode,
5040
+ interval: start.interval,
5041
+ budgetMs: 1000 * start.expiresIn
5042
+ });
5043
+ if (!poll.ok) {
5044
+ log("denied" === poll.reason ? "Authorization was denied at extension.dev/device." : "expired" === poll.reason ? "The device code expired. Run login again." : "Timed out waiting for authorization. Run login again.");
5045
+ return 1;
5046
+ }
5047
+ log(`Logged in to ${poll.creds.workspaceSlug}/${poll.creds.projectSlug}.`);
5048
+ return 0;
5049
+ }
4602
5050
  const start = await startDeviceCode({
4603
5051
  clientId: config.clientId,
4604
5052
  scope: config.scope