sunpeak 0.20.62 → 0.20.67

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.
Files changed (67) hide show
  1. package/README.md +3 -0
  2. package/bin/commands/dev.mjs +88 -46
  3. package/bin/commands/inspect.mjs +8 -6
  4. package/bin/lib/inspect/inspect-config.mjs +6 -6
  5. package/bin/lib/live/chatgpt-page.mjs +40 -25
  6. package/bin/lib/live/global-setup.mjs +2 -2
  7. package/bin/lib/live/host-page.mjs +30 -24
  8. package/bin/lib/live/live-config.mjs +8 -6
  9. package/bin/lib/test/test-config.mjs +7 -10
  10. package/bin/lib/test/test-fixtures.mjs +12 -3
  11. package/dist/chatgpt/index.cjs +1 -1
  12. package/dist/chatgpt/index.js +1 -1
  13. package/dist/claude/index.cjs +1 -1
  14. package/dist/claude/index.js +1 -1
  15. package/dist/embed.css +2 -2
  16. package/dist/host/chatgpt/index.cjs +1 -1
  17. package/dist/host/chatgpt/index.js +1 -1
  18. package/dist/index.cjs +14 -8
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.js +12 -7
  21. package/dist/index.js.map +1 -1
  22. package/dist/inspector/index.cjs +1 -1
  23. package/dist/inspector/index.js +1 -1
  24. package/dist/{inspector-DPJeSQAd.js → inspector-BQx_pKAK.js} +31 -15
  25. package/dist/{inspector-DPJeSQAd.js.map → inspector-BQx_pKAK.js.map} +1 -1
  26. package/dist/{inspector-Cl5w_7yQ.cjs → inspector-wz-9eEPo.cjs} +33 -16
  27. package/dist/{inspector-Cl5w_7yQ.cjs.map → inspector-wz-9eEPo.cjs.map} +1 -1
  28. package/dist/mcp/index.cjs +14 -7
  29. package/dist/mcp/index.cjs.map +1 -1
  30. package/dist/mcp/index.js +12 -6
  31. package/dist/mcp/index.js.map +1 -1
  32. package/dist/style.css +7 -6
  33. package/dist/{use-app-D7-OtXBl.js → use-app-BUrAWLg8.js} +76 -28
  34. package/dist/{use-app-D7-OtXBl.js.map → use-app-BUrAWLg8.js.map} +1 -1
  35. package/dist/{use-app-BcSuulay.cjs → use-app-QDHDymli.cjs} +78 -29
  36. package/dist/{use-app-BcSuulay.cjs.map → use-app-QDHDymli.cjs.map} +1 -1
  37. package/package.json +16 -16
  38. package/template/README.md +12 -4
  39. package/template/dist/albums/albums.html +2 -2
  40. package/template/dist/albums/albums.json +1 -1
  41. package/template/dist/carousel/carousel.html +2 -2
  42. package/template/dist/carousel/carousel.json +1 -1
  43. package/template/dist/map/map.html +755 -750
  44. package/template/dist/map/map.json +1 -1
  45. package/template/dist/review/review.html +1 -1
  46. package/template/dist/review/review.json +1 -1
  47. package/template/node_modules/.bin/vite +2 -2
  48. package/template/node_modules/.bin/vitest +2 -2
  49. package/template/node_modules/.vite/deps/_metadata.json +4 -4
  50. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js +82 -30
  51. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js.map +1 -1
  52. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js +82 -30
  53. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js.map +1 -1
  54. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js +82 -30
  55. package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js.map +1 -1
  56. package/template/node_modules/.vite-mcp/deps/@testing-library_react.js.map +1 -1
  57. package/template/node_modules/.vite-mcp/deps/_metadata.json +24 -24
  58. package/template/node_modules/.vite-mcp/deps/{dist-CO4R4Zw4.js → dist-Bd8a6mlN.js} +17 -5
  59. package/template/node_modules/.vite-mcp/deps/{dist-CO4R4Zw4.js.map → dist-Bd8a6mlN.js.map} +1 -1
  60. package/template/node_modules/.vite-mcp/deps/embla-carousel-react.js.map +1 -1
  61. package/template/node_modules/.vite-mcp/deps/mapbox-gl.js +14765 -13820
  62. package/template/node_modules/.vite-mcp/deps/mapbox-gl.js.map +1 -1
  63. package/template/node_modules/.vite-mcp/deps/vitest.js +57 -36
  64. package/template/node_modules/.vite-mcp/deps/vitest.js.map +1 -1
  65. package/template/package.json +6 -6
  66. package/template/tests/e2e/visual.spec.ts-snapshots/albums-fullscreen-chatgpt-linux.png +0 -0
  67. package/template/tests/e2e/visual.spec.ts-snapshots/albums-fullscreen-claude-darwin.png +0 -0
package/README.md CHANGED
@@ -20,6 +20,8 @@ Server-agnostic MCP testing framework and full-stack MCP App framework.
20
20
 
21
21
  MCP Apps are cross-platform, meaning sunpeak is a ChatGPT App framework, Claude Connector framework, and more.
22
22
 
23
+ ChatGPT apps are now submitted and published as plugins. The app remains an MCP-backed app, so sunpeak's architecture and runtime do not change. The plugin is the package used for local installation, review, and public distribution.
24
+
23
25
  ```bash
24
26
  npx sunpeak new
25
27
  ```
@@ -149,4 +151,5 @@ Toggle between hosts, themes, display modes, and device types from the sidebar.
149
151
  - [MCP Overview](https://sunpeak.ai/docs/mcp-apps/mcp/overview) · [Tools](https://sunpeak.ai/docs/mcp-apps/mcp/tools) · [Resources](https://sunpeak.ai/docs/mcp-apps/mcp/resources)
150
152
  - [MCP Apps SDK](https://github.com/modelcontextprotocol/ext-apps)
151
153
  - [ChatGPT Apps SDK Design Guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines)
154
+ - [Build ChatGPT Plugins](https://learn.chatgpt.com/docs/build-plugins) · [Submit ChatGPT Plugins](https://learn.chatgpt.com/docs/submit-plugins)
152
155
  - [Troubleshooting](https://sunpeak.ai/docs/app-framework/guides/troubleshooting)
@@ -66,7 +66,12 @@ async function importFromProject(require, moduleName) {
66
66
  *
67
67
  * When a file changes during a build, the current build is killed and restarted.
68
68
  */
69
- function startBuildWatcher(projectRoot, resourcesDir, mcpHandle, { skipInitialBuild = false } = {}) {
69
+ function startBuildWatcher(
70
+ projectRoot,
71
+ resourcesDir,
72
+ mcpHandle,
73
+ { skipInitialBuild = false } = {}
74
+ ) {
70
75
  let activeChild = null;
71
76
  const sunpeakBin = join(dirname(fileURLToPath(import.meta.url)), '..', 'sunpeak.js');
72
77
 
@@ -120,7 +125,9 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle, { skipInitialBu
120
125
  });
121
126
  console.log('[build] Watching src/resources/ for changes...');
122
127
  } catch {
123
- console.warn('[build] Could not start file watcher — run "sunpeak build" manually after changes');
128
+ console.warn(
129
+ '[build] Could not start file watcher — run "sunpeak build" manually after changes'
130
+ );
124
131
  }
125
132
  }
126
133
 
@@ -153,7 +160,7 @@ export async function dev(projectRoot = process.cwd(), args = []) {
153
160
  // inspectServer auto-discovers a free port (and doesn't use strictPort,
154
161
  // which would crash instead of falling back when port 3000 is busy).
155
162
  let port = undefined;
156
- const portArgIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
163
+ const portArgIndex = args.findIndex((arg) => arg === '--port' || arg === '-p');
157
164
  if (portArgIndex !== -1 && args[portArgIndex + 1]) {
158
165
  port = parseInt(args[portArgIndex + 1]);
159
166
  } else if (process.env.PORT) {
@@ -167,8 +174,10 @@ export async function dev(projectRoot = process.cwd(), args = []) {
167
174
  const isProdTools = args.includes('--prod-tools');
168
175
  const isProdResources = args.includes('--prod-resources');
169
176
 
170
- if (isProdTools) console.log('Prod Tools: MCP tool calls will use real handlers instead of simulation mocks');
171
- if (isProdResources) console.log('Prod Resources: resources will use production-built HTML from dist/');
177
+ if (isProdTools)
178
+ console.log('Prod Tools: MCP tool calls will use real handlers instead of simulation mocks');
179
+ if (isProdResources)
180
+ console.log('Prod Resources: resources will use production-built HTML from dist/');
172
181
 
173
182
  console.log(`Starting dev server${port ? ` on port ${port}` : ''}...`);
174
183
 
@@ -195,10 +204,18 @@ export async function dev(projectRoot = process.cwd(), args = []) {
195
204
  // Import from installed sunpeak package
196
205
  const sunpeakBase = require.resolve('sunpeak').replace(/dist\/index\.(c)?js$/, '');
197
206
  sunpeakMcp = await import(pathToFileURL(join(sunpeakBase, 'dist/mcp/index.js')).href);
198
- sunpeakDiscovery = await import(pathToFileURL(join(sunpeakBase, 'dist/lib/discovery-cli.js')).href);
207
+ sunpeakDiscovery = await import(
208
+ pathToFileURL(join(sunpeakBase, 'dist/lib/discovery-cli.js')).href
209
+ );
199
210
  }
200
211
  const { runMCPServer } = sunpeakMcp;
201
- const { findResourceDirs, findSimulationFilesFlat, findToolFiles, extractResourceExport, extractToolExport } = sunpeakDiscovery;
212
+ const {
213
+ findResourceDirs,
214
+ findSimulationFilesFlat,
215
+ findToolFiles,
216
+ extractResourceExport,
217
+ extractToolExport,
218
+ } = sunpeakDiscovery;
202
219
 
203
220
  const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
204
221
 
@@ -243,7 +260,9 @@ export async function dev(projectRoot = process.cwd(), args = []) {
243
260
  const { tool } = await extractToolExport(toolPath);
244
261
  toolMap.set(toolName, { tool, path: toolPath });
245
262
  } catch (err) {
246
- console.warn(`Warning: Could not extract metadata from tool "${toolName}" (${toolPath}):\n ${err.message}\n Expected: export const tool: AppToolConfig = { ... }`);
263
+ console.warn(
264
+ `Warning: Could not extract metadata from tool "${toolName}" (${toolPath}):\n ${err.message}\n Expected: export const tool: AppToolConfig = { ... }`
265
+ );
247
266
  }
248
267
  }
249
268
 
@@ -285,7 +304,9 @@ export async function dev(projectRoot = process.cwd(), args = []) {
285
304
  });
286
305
  }
287
306
  } catch (err) {
288
- console.warn(`Warning: Could not load handler for tool "${toolName}" (${relativePath}):\n ${err.message}`);
307
+ console.warn(
308
+ `Warning: Could not load handler for tool "${toolName}" (${relativePath}):\n ${err.message}`
309
+ );
289
310
  }
290
311
  }
291
312
 
@@ -301,7 +322,9 @@ export async function dev(projectRoot = process.cwd(), args = []) {
301
322
  const toolEntry = toolMap.get(toolName);
302
323
  const tool = toolEntry?.tool;
303
324
  if (!tool) {
304
- console.warn(`Warning: Tool "${toolName}" not found for simulation "${simName}". Expected file: src/tools/${toolName}.ts`);
325
+ console.warn(
326
+ `Warning: Tool "${toolName}" not found for simulation "${simName}". Expected file: src/tools/${toolName}.ts`
327
+ );
305
328
  continue;
306
329
  }
307
330
 
@@ -312,7 +335,9 @@ export async function dev(projectRoot = process.cwd(), args = []) {
312
335
  : undefined;
313
336
 
314
337
  if (resourceName && !resourceKey) {
315
- console.warn(`Warning: No resource found for tool "${toolName}" in simulation "${simName}". Skipping.`);
338
+ console.warn(
339
+ `Warning: No resource found for tool "${toolName}" in simulation "${simName}". Skipping.`
340
+ );
316
341
  continue;
317
342
  }
318
343
 
@@ -326,25 +351,33 @@ export async function dev(projectRoot = process.cwd(), args = []) {
326
351
  ...simulation,
327
352
  ...(typeof simulation.tool === 'string' ? { tool: { name: toolName, ...tool } } : {}),
328
353
  name: simName,
329
- ...(resourceKey ? {
330
- distPath: join(projectRoot, `dist/${resourceKey}/${resourceKey}.html`),
331
- srcPath,
332
- resource: resourceMap.get(resourceKey),
333
- } : {}),
354
+ ...(resourceKey
355
+ ? {
356
+ distPath: join(projectRoot, `dist/${resourceKey}/${resourceKey}.html`),
357
+ srcPath,
358
+ resource: resourceMap.get(resourceKey),
359
+ }
360
+ : {}),
334
361
  // Attach output schema from the tool module (if present)
335
- ...(toolHandlerMap.has(toolName) && toolHandlerMap.get(toolName).outputSchema ? {
336
- outputSchema: toolHandlerMap.get(toolName).outputSchema,
337
- } : {}),
362
+ ...(toolHandlerMap.has(toolName) && toolHandlerMap.get(toolName).outputSchema
363
+ ? {
364
+ outputSchema: toolHandlerMap.get(toolName).outputSchema,
365
+ }
366
+ : {}),
338
367
  // Attach real handler so Prod Tools mode works at runtime.
339
368
  // The --prod-tools flag only sets the default checkbox state; the handler
340
369
  // must always be available for when the user toggles it in the sidebar.
341
- ...(toolHandlerMap.has(toolName) ? {
342
- handler: toolHandlerMap.get(toolName).handler,
343
- } : {}),
370
+ ...(toolHandlerMap.has(toolName)
371
+ ? {
372
+ handler: toolHandlerMap.get(toolName).handler,
373
+ }
374
+ : {}),
344
375
  // Attach the raw Zod shape so the MCP server registers tools with real schemas.
345
- ...(toolHandlerMap.has(toolName) && toolHandlerMap.get(toolName).schema ? {
346
- inputSchema: toolHandlerMap.get(toolName).schema,
347
- } : {}),
376
+ ...(toolHandlerMap.has(toolName) && toolHandlerMap.get(toolName).schema
377
+ ? {
378
+ inputSchema: toolHandlerMap.get(toolName).schema,
379
+ }
380
+ : {}),
348
381
  });
349
382
  }
350
383
 
@@ -354,8 +387,8 @@ export async function dev(projectRoot = process.cwd(), args = []) {
354
387
  // callServerTool calls to the MCP server because the tool isn't registered.
355
388
  for (const [toolName, { tool }] of toolMap) {
356
389
  if (tool.resource) continue; // UI tools need simulations for their resource
357
- const alreadyCovered = simulations.some(s =>
358
- (s.tool?.name === toolName) || (typeof s.tool === 'string' && s.tool === toolName)
390
+ const alreadyCovered = simulations.some(
391
+ (s) => s.tool?.name === toolName || (typeof s.tool === 'string' && s.tool === toolName)
359
392
  );
360
393
  if (alreadyCovered) continue;
361
394
  const handlerInfo = toolHandlerMap.get(toolName);
@@ -405,12 +438,16 @@ export async function dev(projectRoot = process.cwd(), args = []) {
405
438
  return 'console.error("Missing src or component param");';
406
439
  }
407
440
 
441
+ const safeSrcPath = JSON.stringify(srcPath);
442
+ const safeComponentName = JSON.stringify(componentName);
443
+
408
444
  return `
409
- import { createElement } from 'react';
410
- import { createRoot } from 'react-dom/client';
411
- import { AppProvider } from 'sunpeak';
412
- import '/src/styles/globals.css';
413
- import * as ResourceModule from '${srcPath}';
445
+ import { createElement } from 'react';
446
+ import { createRoot } from 'react-dom/client';
447
+ import { AppProvider } from 'sunpeak';
448
+ import '/src/styles/globals.css';
449
+ const ResourceModule = await import(${safeSrcPath});
450
+ const componentName = ${safeComponentName};
414
451
 
415
452
  // Reuse React root across HMR updates to preserve the AppProvider host connection.
416
453
  // The host renders this HTML inline (not at a fetchable URL), so location.reload()
@@ -418,10 +455,13 @@ import * as ResourceModule from '${srcPath}';
418
455
  const root = import.meta.hot?.data?.root ?? createRoot(document.getElementById('root'));
419
456
  if (import.meta.hot) import.meta.hot.data.root = root;
420
457
 
421
- const Component = ResourceModule.default || ResourceModule['${componentName}'];
422
- if (!Component) {
423
- document.getElementById('root').innerHTML = '<pre style="color:red;padding:16px">Component not found: ${componentName}\\nExports: ' + Object.keys(ResourceModule).join(', ') + '</pre>';
424
- } else {
458
+ const Component = ResourceModule.default || ResourceModule[componentName];
459
+ if (!Component) {
460
+ const error = document.createElement('pre');
461
+ error.style.cssText = 'color:red;padding:16px';
462
+ error.textContent = 'Component not found: ' + componentName + '\\nExports: ' + Object.keys(ResourceModule).join(', ');
463
+ document.getElementById('root').replaceChildren(error);
464
+ } else {
425
465
  const appInfo = { name: ${JSON.stringify(pkg.name || 'sunpeak-app')}, version: ${JSON.stringify(pkg.version || '0.1.0')} };
426
466
  root.render(
427
467
  createElement(AppProvider, { appInfo }, createElement(Component))
@@ -474,10 +514,7 @@ if (import.meta.hot) {
474
514
  // resource load discovers new deps (e.g., mapbox-gl, embla-carousel),
475
515
  // triggers re-optimization, and reloads all connections — killing
476
516
  // any active ChatGPT/Claude iframe connections with ECONNRESET.
477
- entries: [
478
- 'src/resources/**/*.{ts,tsx}',
479
- 'src/tools/**/*.ts',
480
- ],
517
+ entries: ['src/resources/**/*.{ts,tsx}', 'src/tools/**/*.ts'],
481
518
  include: ['react', 'react-dom/client'],
482
519
  },
483
520
  appType: 'custom',
@@ -491,8 +528,8 @@ if (import.meta.hot) {
491
528
  // Skipped under --prod-resources where mcpViteServer is created but unused.
492
529
  if (!isProdResources && (resourceDirs.length > 0 || toolFiles.length > 0)) {
493
530
  const warmupTargets = [
494
- ...resourceDirs.map(({ key, resourcePath }) =>
495
- `/src/resources/${key}/${basename(resourcePath)}`
531
+ ...resourceDirs.map(
532
+ ({ key, resourcePath }) => `/src/resources/${key}/${basename(resourcePath)}`
496
533
  ),
497
534
  ...toolFiles.map(({ name: toolName }) => `/src/tools/${toolName}.ts`),
498
535
  ];
@@ -505,7 +542,9 @@ if (import.meta.hot) {
505
542
  )
506
543
  );
507
544
  await mcpViteServer.waitForRequestsIdle();
508
- console.log(`Warmed Vite (${warmupTargets.length} ${warmupTargets.length === 1 ? 'entry' : 'entries'}) in ${Date.now() - warmStart}ms`);
545
+ console.log(
546
+ `Warmed Vite (${warmupTargets.length} ${warmupTargets.length === 1 ? 'entry' : 'entries'}) in ${Date.now() - warmStart}ms`
547
+ );
509
548
  }
510
549
 
511
550
  // Load server config from src/server.ts (if present) for server identity
@@ -522,7 +561,7 @@ if (import.meta.hot) {
522
561
  // Extract a display icon from the icons array (first non-dark icon, or first icon)
523
562
  const icons = serverMod.server.icons;
524
563
  if (Array.isArray(icons) && icons.length > 0) {
525
- const lightIcon = icons.find(i => !i.theme || i.theme === 'light') ?? icons[0];
564
+ const lightIcon = icons.find((i) => !i.theme || i.theme === 'light') ?? icons[0];
526
565
  serverDisplayIcon = lightIcon?.src;
527
566
  }
528
567
  }
@@ -598,7 +637,10 @@ if (import.meta.hot) {
598
637
  const result = await mod.default(args, {});
599
638
  const durationMs = Math.round((performance.now() - startTime) * 10) / 10;
600
639
  if (typeof result === 'string') {
601
- return { content: [{ type: 'text', text: result }], _meta: { _sunpeak: { requestTimeMs: durationMs } } };
640
+ return {
641
+ content: [{ type: 'text', text: result }],
642
+ _meta: { _sunpeak: { requestTimeMs: durationMs } },
643
+ };
602
644
  }
603
645
  const typed = result ?? {};
604
646
  return { ...typed, _meta: { ...typed._meta, _sunpeak: { requestTimeMs: durationMs } } };
@@ -617,7 +659,7 @@ if (import.meta.hot) {
617
659
  // Allow running directly
618
660
  if (import.meta.url === `file://${process.argv[1]}`) {
619
661
  const args = process.argv.slice(2);
620
- dev(process.cwd(), args).catch(error => {
662
+ dev(process.cwd(), args).catch((error) => {
621
663
  console.error('Error:', error.message);
622
664
  process.exit(1);
623
665
  });
@@ -1032,6 +1032,12 @@ function appendInspectorRequestToken(resourceUrl, requestToken) {
1032
1032
  }
1033
1033
  }
1034
1034
 
1035
+ function isInspectorRequestTokenValid(req, requestToken) {
1036
+ if (!requestToken) return true;
1037
+ const url = new URL(req.url, 'http://localhost');
1038
+ return url.searchParams.get('__sunpeak_token') === requestToken;
1039
+ }
1040
+
1035
1041
  function addInspectorRequestTokenToSimulations(simulations, requestToken) {
1036
1042
  if (!requestToken || !simulations || typeof simulations !== 'object') return simulations;
1037
1043
  return Object.fromEntries(
@@ -2073,12 +2079,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
2073
2079
  }
2074
2080
 
2075
2081
  function requireInspectorRequestToken(req, res) {
2076
- if (!pluginOpts.requestToken) return true;
2077
- const fetchSiteHeader = req.headers['sec-fetch-site'];
2078
- const fetchSite = Array.isArray(fetchSiteHeader) ? fetchSiteHeader[0] : fetchSiteHeader;
2079
- if (fetchSite !== 'cross-site') return true;
2080
- const url = new URL(req.url, 'http://localhost');
2081
- if (url.searchParams.get('__sunpeak_token') === pluginOpts.requestToken) return true;
2082
+ if (isInspectorRequestTokenValid(req, pluginOpts.requestToken)) return true;
2082
2083
  res.writeHead(403, { 'Content-Type': 'text/plain' });
2083
2084
  res.end('Forbidden: missing or invalid inspector request token');
2084
2085
  return false;
@@ -3090,6 +3091,7 @@ export const _securityTestExports = {
3090
3091
  formatSharedAppContextForModel,
3091
3092
  addInspectorRequestTokenToSimulations,
3092
3093
  appendInspectorRequestToken,
3094
+ isInspectorRequestTokenValid,
3093
3095
  normalizeApiKey,
3094
3096
  normalizeModelChatMessages,
3095
3097
  normalizeModelAppContext,
@@ -55,24 +55,24 @@ export function defineInspectConfig(options) {
55
55
  const { port, sandboxPort } = resolvePorts();
56
56
 
57
57
  // Build the sunpeak inspect command
58
- const serverArg = server.includes(' ') ? `"${server}"` : server;
58
+ const serverArg = shellQuote(server);
59
59
  const command = [
60
60
  `SUNPEAK_SANDBOX_PORT=${sandboxPort}`,
61
- `${resolveSunpeakBin()} inspect`,
61
+ `${shellQuote(resolveSunpeakBin())} inspect`,
62
62
  `--server ${serverArg}`,
63
63
  ...(env
64
64
  ? Object.entries(env).map(([k, v]) => {
65
65
  const pair = `${k}=${v}`;
66
- return pair.includes(' ') ? `--env "${pair}"` : `--env ${pair}`;
66
+ return `--env ${shellQuote(pair)}`;
67
67
  })
68
68
  : []),
69
- ...(cwd ? [cwd.includes(' ') ? `--cwd "${cwd}"` : `--cwd ${cwd}`] : []),
69
+ ...(cwd ? [`--cwd ${shellQuote(cwd)}`] : []),
70
70
  ...(headers
71
71
  ? Object.entries(headers).map(([k, v]) => `--header ${shellQuote(`${k}: ${v}`)}`)
72
72
  : []),
73
- ...(simulationsDir ? [`--simulations ${simulationsDir}`] : []),
73
+ ...(simulationsDir ? [`--simulations ${shellQuote(simulationsDir)}`] : []),
74
74
  `--port ${port}`,
75
- ...(name ? [`--name "${name}"`] : []),
75
+ ...(name ? [`--name ${shellQuote(name)}`] : []),
76
76
  ].join(' ');
77
77
 
78
78
  return createBaseConfig({
@@ -11,7 +11,7 @@ import { HostPage } from './host-page.mjs';
11
11
  /**
12
12
  * All ChatGPT DOM selectors in one place for easy maintenance.
13
13
  *
14
- * Last verified: 2026-03-24 via live Playwright inspection.
14
+ * Updated: 2026-07-10 for the documented ChatGPT Plugins flow.
15
15
  */
16
16
  const SELECTORS = {
17
17
  // Chat interface
@@ -23,8 +23,9 @@ const SELECTORS = {
23
23
  loggedInIndicator: '[data-testid="accounts-profile-button"]',
24
24
  loginPage: 'button:has-text("Log in")',
25
25
 
26
- // Settings navigation
27
- appsTab: '[role="tab"]:has-text("Apps")',
26
+ // Plugin directory navigation
27
+ createdByYou:
28
+ 'button:has-text("Created by you"), [role="tab"]:has-text("Created by you"), a:has-text("Created by you")',
28
29
  refreshButton: 'button:has-text("Refresh")',
29
30
  reconnectButton: 'button:has-text("Reconnect")',
30
31
 
@@ -40,40 +41,51 @@ const SELECTORS = {
40
41
 
41
42
  const URLS = {
42
43
  base: 'https://chatgpt.com',
43
- settings: 'https://chatgpt.com/#settings/Connectors',
44
+ plugins: 'https://chatgpt.com/plugins',
44
45
  };
45
46
 
47
+ const PLUGIN_SETUP_HINT =
48
+ 'Enable Developer mode from the bottom-left user menu under Settings > Security and login > Developer mode, then add the app from Plugins > +.';
49
+
46
50
  export { SELECTORS as CHATGPT_SELECTORS, URLS as CHATGPT_URLS };
47
51
 
48
52
  export class ChatGPTPage extends HostPage {
49
- get hostId() { return 'chatgpt'; }
50
- get hostName() { return 'ChatGPT'; }
51
- get selectors() { return SELECTORS; }
52
- get urls() { return URLS; }
53
+ get hostId() {
54
+ return 'chatgpt';
55
+ }
56
+ get hostName() {
57
+ return 'ChatGPT';
58
+ }
59
+ get selectors() {
60
+ return SELECTORS;
61
+ }
62
+ get urls() {
63
+ return URLS;
64
+ }
53
65
 
54
66
  /**
55
- * Refresh the MCP server connection in ChatGPT settings.
56
- * Navigates to Settings > Apps, clicks the app entry, clicks Refresh,
67
+ * Refresh the MCP server connection from the ChatGPT Plugins directory.
68
+ * Opens Plugins, finds the developer-mode app, clicks Refresh,
57
69
  * and waits for the success/error toast.
58
70
  */
59
71
  async refreshMcpServer({ tunnelUrl, appName } = {}) {
60
- await this.page.goto(URLS.settings, { waitUntil: 'domcontentloaded' });
72
+ await this.page.goto(URLS.plugins, { waitUntil: 'domcontentloaded' });
61
73
  await this.page.waitForTimeout(3_000);
62
74
 
63
75
  const found = await this._findAndClickRefresh(appName);
64
76
 
65
77
  if (!found) {
66
- const appsTab = this.page.locator(SELECTORS.appsTab);
67
- const hasAppsTab = await appsTab.isVisible().catch(() => false);
68
- if (hasAppsTab) {
69
- await appsTab.click();
78
+ const createdByYou = this.page.locator(SELECTORS.createdByYou).first();
79
+ const hasCreatedByYou = await createdByYou.isVisible().catch(() => false);
80
+ if (hasCreatedByYou) {
81
+ await createdByYou.click();
70
82
  await this.page.waitForTimeout(2_000);
71
83
  const retryFound = await this._findAndClickRefresh(appName);
72
84
  if (!retryFound) {
73
- await this._screenshotAndThrow('refresh-mcp-settings', tunnelUrl);
85
+ await this._screenshotAndThrow('refresh-mcp-plugin', tunnelUrl, PLUGIN_SETUP_HINT);
74
86
  }
75
87
  } else {
76
- await this._screenshotAndThrow('no-apps-tab', tunnelUrl);
88
+ await this._screenshotAndThrow('developer-plugin-not-found', tunnelUrl, PLUGIN_SETUP_HINT);
77
89
  }
78
90
  }
79
91
 
@@ -82,7 +94,7 @@ export class ChatGPTPage extends HostPage {
82
94
  if (hasError) {
83
95
  throw new Error(
84
96
  `MCP server refresh failed in ChatGPT:\n${errorText.trim()}\n\n` +
85
- `Make sure your MCP dev server is running (pnpm dev) and your tunnel is active.`
97
+ `Make sure your MCP dev server is running (pnpm dev) and your tunnel is active.`
86
98
  );
87
99
  }
88
100
 
@@ -107,13 +119,18 @@ export class ChatGPTPage extends HostPage {
107
119
  }
108
120
 
109
121
  // Wait for the outer sandbox iframe
110
- await this.page.locator(SELECTORS.mcpAppOuterIframe).first().waitFor({ state: 'attached', timeout: 30_000 });
122
+ await this.page
123
+ .locator(SELECTORS.mcpAppOuterIframe)
124
+ .first()
125
+ .waitFor({ state: 'attached', timeout: 30_000 });
111
126
 
112
127
  // Wait for the inner frame to appear inside the sandboxed outer iframe.
113
128
  // waitForFunction can't cross the sandbox boundary, so use Playwright's frameLocator instead.
114
129
  const outerFrame = this.page.frameLocator(SELECTORS.mcpAppOuterIframe).first();
115
130
  await outerFrame
116
- .locator(`iframe[name="${SELECTORS.mcpAppInnerFrameName}"], iframe#${SELECTORS.mcpAppInnerFrameName}`)
131
+ .locator(
132
+ `iframe[name="${SELECTORS.mcpAppInnerFrameName}"], iframe#${SELECTORS.mcpAppInnerFrameName}`
133
+ )
117
134
  .waitFor({ state: 'attached', timeout: 15_000 });
118
135
 
119
136
  const appFrame = this.getAppIframe();
@@ -182,13 +199,11 @@ export class ChatGPTPage extends HostPage {
182
199
  return false;
183
200
  };
184
201
 
185
- if (await tryClickRefresh()) return true;
186
-
187
202
  if (appName) {
188
203
  const strategies = [
189
204
  () => this.page.getByText(appName, { exact: true }).first(),
190
- () => this.page.locator(`text=${appName}`).first(),
191
- () => this.page.locator(`a:has-text("${appName}"), [role="button"]:has-text("${appName}")`).first(),
205
+ () => this.page.getByRole('link', { name: appName, exact: true }).first(),
206
+ () => this.page.getByRole('button', { name: appName, exact: true }).first(),
192
207
  ];
193
208
 
194
209
  for (const getLocator of strategies) {
@@ -205,6 +220,6 @@ export class ChatGPTPage extends HostPage {
205
220
  }
206
221
  }
207
222
 
208
- return false;
223
+ return appName ? false : tryClickRefresh();
209
224
  }
210
225
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Runs exactly once before all workers. Two responsibilities:
5
5
  * 1. Authenticate — launch a browser, verify login, wait for user if needed.
6
- * 2. Refresh MCP server navigate to host settings and click Refresh so
6
+ * 2. Refresh MCP server: open the host's app management UI and click Refresh so
7
7
  * all workers start with pre-loaded resources.
8
8
  *
9
9
  * Auth approach:
@@ -150,7 +150,7 @@ export default async function globalSetup() {
150
150
  // Refresh MCP server in the SAME browser session.
151
151
  // This is critical — Cloudflare's cf_clearance cookie is HttpOnly and
152
152
  // won't be in the saved storageState. By refreshing here, the cookie
153
- // is still valid for navigating to settings.
153
+ // is still valid for navigating to the plugin directory.
154
154
  //
155
155
  // This MUST succeed — if the MCP server isn't reachable or the refresh
156
156
  // fails, tests will fail with confusing iframe/timeout errors.
@@ -23,8 +23,7 @@
23
23
  /**
24
24
  * @typedef {Object} HostUrls
25
25
  * @property {string} base - Host base URL (e.g., 'https://chatgpt.com')
26
- * @property {string} settings - MCP settings URL
27
- * @property {string} loginTestId - Test ID for login detection (if using getByTestId)
26
+ * @property {string} [plugins] - Host plugin or app management URL
28
27
  */
29
28
 
30
29
  export class HostPage {
@@ -80,7 +79,8 @@ export class HostPage {
80
79
  if (warnings.length > 0) {
81
80
  console.warn(
82
81
  `\n⚠️ ${this.hostName} DOM may have changed — update selectors in ${this.hostId}-page.mjs:\n` +
83
- warnings.join('\n') + '\n'
82
+ warnings.join('\n') +
83
+ '\n'
84
84
  );
85
85
  }
86
86
 
@@ -135,14 +135,14 @@ export class HostPage {
135
135
  // Not logged in. Wait for the user to authenticate in this browser window.
136
136
  console.log(
137
137
  `\n` +
138
- `╔══════════════════════════════════════════════════════════════╗\n` +
139
- `║ Not logged into ${this.hostName.padEnd(42)}║\n` +
140
- `║ ║\n` +
141
- `║ Please log in at: ${this.urls.base.padEnd(39)}║\n` +
142
- `║ in the browser window that just opened. ║\n` +
143
- `║ ║\n` +
144
- `║ Waiting up to 3 minutes... ║\n` +
145
- `╚══════════════════════════════════════════════════════════════╝\n`
138
+ `╔══════════════════════════════════════════════════════════════╗\n` +
139
+ `║ Not logged into ${this.hostName.padEnd(42)}║\n` +
140
+ `║ ║\n` +
141
+ `║ Please log in at: ${this.urls.base.padEnd(39)}║\n` +
142
+ `║ in the browser window that just opened. ║\n` +
143
+ `║ ║\n` +
144
+ `║ Waiting up to 3 minutes... ║\n` +
145
+ `╚══════════════════════════════════════════════════════════════╝\n`
146
146
  );
147
147
 
148
148
  // Poll for login — the user may need to pass Cloudflare + enter credentials
@@ -160,14 +160,14 @@ export class HostPage {
160
160
 
161
161
  throw new Error(
162
162
  `Login to ${this.hostName} timed out after 3 minutes.\n` +
163
- `Please log in at ${this.urls.base} in the browser window and try again.\n` +
164
- 'If the session expired, delete the .auth/ directory and try again.'
163
+ `Please log in at ${this.urls.base} in the browser window and try again.\n` +
164
+ 'If the session expired, delete the .auth/ directory and try again.'
165
165
  );
166
166
  }
167
167
 
168
168
  /**
169
- * Refresh the MCP server connection in host settings.
170
- * Subclasses must implement this each host has different settings UI.
169
+ * Refresh the MCP server connection in the host's app management UI.
170
+ * Subclasses must implement this because each host has a different flow.
171
171
  *
172
172
  * @param {Object} [options]
173
173
  * @param {string} [options.tunnelUrl] - Tunnel URL for error messages
@@ -228,9 +228,10 @@ export class HostPage {
228
228
  * Capture a debug screenshot and throw with helpful message.
229
229
  * @param {string} context - Context label for the screenshot filename
230
230
  * @param {string} [tunnelUrl] - Tunnel URL for the error message
231
+ * @param {string} [setupHint] - Host-specific setup steps
231
232
  * @protected
232
233
  */
233
- async _screenshotAndThrow(context, tunnelUrl) {
234
+ async _screenshotAndThrow(context, tunnelUrl, setupHint) {
234
235
  const screenshotPath = `/tmp/sunpeak-live-debug-${this.hostId}-${context}.png`;
235
236
  try {
236
237
  await this.page.screenshot({ path: screenshotPath, fullPage: true });
@@ -241,29 +242,34 @@ export class HostPage {
241
242
 
242
243
  try {
243
244
  const buttons = await this.page.locator('button').allTextContents();
244
- console.error('Visible buttons on page:', buttons.filter(t => t.trim()).join(', '));
245
+ console.error('Visible buttons on page:', buttons.filter((t) => t.trim()).join(', '));
245
246
  } catch {
246
247
  // Best effort
247
248
  }
248
249
 
249
250
  throw new Error(
250
- `Could not find Refresh/Reconnect button in ${this.hostName} settings.\n` +
251
- `Make sure your MCP server is added in ${this.hostName} settings` +
252
- (tunnelUrl ? ` with URL: ${tunnelUrl}/mcp` : '') +
253
- `\n\nDebug screenshot: ${screenshotPath}`
251
+ `Could not find Refresh/Reconnect in ${this.hostName} app management.\n` +
252
+ `Make sure your MCP server is added to ${this.hostName}` +
253
+ (tunnelUrl ? ` with URL: ${tunnelUrl}/mcp` : '') +
254
+ (setupHint ? `\n${setupHint}` : '') +
255
+ `\n\nDebug screenshot: ${screenshotPath}`
254
256
  );
255
257
  }
256
258
 
257
259
  /**
258
260
  * Wait for a toast/alert banner and check for errors.
259
- * Many hosts show success/error toasts after settings actions.
261
+ * Many hosts show success/error toasts after app management actions.
260
262
  * @param {Object} [options]
261
263
  * @param {string} [options.alertSelector='[role="alert"]'] - Selector for toast elements
262
264
  * @param {number} [options.timeout=30000] - Max time to wait
263
265
  * @param {number} [options.minTextLength=5] - Minimum text length to consider as a real toast
264
266
  * @protected
265
267
  */
266
- async _waitForToast({ alertSelector = '[role="alert"]', timeout = 30_000, minTextLength = 5 } = {}) {
268
+ async _waitForToast({
269
+ alertSelector = '[role="alert"]',
270
+ timeout = 30_000,
271
+ minTextLength = 5,
272
+ } = {}) {
267
273
  try {
268
274
  await this.page.waitForFunction(
269
275
  ({ selector, minLen }) => {
@@ -275,7 +281,7 @@ export class HostPage {
275
281
  return false;
276
282
  },
277
283
  { selector: alertSelector, minLen: minTextLength },
278
- { timeout },
284
+ { timeout }
279
285
  );
280
286
  } catch {
281
287
  console.warn('No toast detected — assuming success.');