sunpeak 0.11.1 → 0.12.1

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 (100) hide show
  1. package/README.md +84 -12
  2. package/bin/commands/build.mjs +1 -11
  3. package/bin/commands/deploy.mjs +7 -1
  4. package/bin/commands/dev.mjs +3 -1
  5. package/bin/commands/new.mjs +211 -0
  6. package/bin/commands/pull.mjs +1 -1
  7. package/bin/commands/push.mjs +36 -13
  8. package/bin/lib/patterns.mjs +25 -0
  9. package/bin/sunpeak.js +13 -222
  10. package/package.json +1 -1
  11. package/template/.sunpeak/dev.tsx +4 -4
  12. package/template/README.md +21 -22
  13. package/template/dist/albums/albums.json +1 -1
  14. package/template/dist/carousel/carousel.json +1 -1
  15. package/template/dist/map/map.json +1 -1
  16. package/template/dist/review/review.json +1 -1
  17. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Button.js +7 -7
  18. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_SegmentedControl.js +1 -1
  19. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Select.js +7 -7
  20. package/template/node_modules/.vite/deps/_metadata.json +30 -30
  21. package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  22. package/template/vitest.config.ts +1 -1
  23. package/template/src/resources/albums/albums-show-simulation.json +0 -131
  24. package/template/src/resources/carousel/carousel-show-simulation.json +0 -68
  25. package/template/src/resources/map/map-show-simulation.json +0 -123
  26. package/template/src/resources/review/review-diff-simulation.json +0 -80
  27. package/template/src/resources/review/review-post-simulation.json +0 -56
  28. package/template/src/resources/review/review-purchase-simulation.json +0 -88
  29. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Avatar.js +0 -0
  30. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Avatar.js.map +0 -0
  31. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Button.js +10 -10
  32. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Button.js.map +0 -0
  33. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Checkbox.js +0 -0
  34. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Checkbox.js.map +0 -0
  35. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Icon.js +0 -0
  36. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Icon.js.map +0 -0
  37. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Input.js +1 -1
  38. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Input.js.map +0 -0
  39. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_SegmentedControl.js +4 -4
  40. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_SegmentedControl.js.map +0 -0
  41. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Select.js +9 -9
  42. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Select.js.map +0 -0
  43. package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Textarea.js +1 -1
  44. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_components_Textarea.js.map +0 -0
  45. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_theme.js +0 -0
  46. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/@openai_apps-sdk-ui_theme.js.map +0 -0
  47. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-2UDYPUBJ.js +0 -0
  48. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-2UDYPUBJ.js.map +0 -0
  49. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-6QVG4F2X.js +0 -0
  50. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-6QVG4F2X.js.map +0 -0
  51. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-BUOVMFCD.js +0 -0
  52. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-BUOVMFCD.js.map +0 -0
  53. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-CNYJBM5F.js +0 -0
  54. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-CNYJBM5F.js.map +0 -0
  55. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-EGRHWZRV.js +0 -0
  56. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-EGRHWZRV.js.map +0 -0
  57. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-ILHRZGIS.js +0 -0
  58. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-ILHRZGIS.js.map +0 -0
  59. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-JAGHY6H6.js +0 -0
  60. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-JAGHY6H6.js.map +0 -0
  61. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-JGVISENQ.js +0 -0
  62. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-JGVISENQ.js.map +0 -0
  63. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-P5LK4A7U.js +0 -0
  64. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-P5LK4A7U.js.map +0 -0
  65. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-QPJAV452.js +0 -0
  66. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-QPJAV452.js.map +0 -0
  67. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-RYYR2YMB.js +0 -0
  68. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-RYYR2YMB.js.map +0 -0
  69. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-UM3ZGDFR.js +0 -0
  70. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-UM3ZGDFR.js.map +0 -0
  71. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-XZTIOEPG.js +0 -0
  72. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/chunk-XZTIOEPG.js.map +0 -0
  73. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/clsx.js +0 -0
  74. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/clsx.js.map +0 -0
  75. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/embla-carousel-react.js +0 -0
  76. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/embla-carousel-react.js.map +0 -0
  77. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/embla-carousel-wheel-gestures.js +0 -0
  78. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/embla-carousel-wheel-gestures.js.map +0 -0
  79. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/mapbox-gl.js +0 -0
  80. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/mapbox-gl.js.map +0 -0
  81. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/package.json +0 -0
  82. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react-dom.js +0 -0
  83. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react-dom.js.map +0 -0
  84. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react-dom_client.js +0 -0
  85. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react-dom_client.js.map +0 -0
  86. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react.js +0 -0
  87. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react.js.map +0 -0
  88. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react_jsx-dev-runtime.js +0 -0
  89. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react_jsx-dev-runtime.js.map +0 -0
  90. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react_jsx-runtime.js +0 -0
  91. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/react_jsx-runtime.js.map +0 -0
  92. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/tailwind-merge.js +0 -0
  93. /package/template/node_modules/.vite-mcp/{deps_temp_c8c8077a → deps_temp_992accd8}/tailwind-merge.js.map +0 -0
  94. /package/template/{src/test → tests}/setup.ts +0 -0
  95. /package/template/{dist → tests/simulations}/albums/albums-show-simulation.json +0 -0
  96. /package/template/{dist → tests/simulations}/carousel/carousel-show-simulation.json +0 -0
  97. /package/template/{dist → tests/simulations}/map/map-show-simulation.json +0 -0
  98. /package/template/{dist → tests/simulations}/review/review-diff-simulation.json +0 -0
  99. /package/template/{dist → tests/simulations}/review/review-post-simulation.json +0 -0
  100. /package/template/{dist → tests/simulations}/review/review-purchase-simulation.json +0 -0
package/README.md CHANGED
@@ -57,26 +57,32 @@ sunpeak is an npm package that helps you build ChatGPT Apps (MCP Resources) whil
57
57
 
58
58
  ### The `sunpeak` framework (`./template`)
59
59
 
60
- Next.js for ChatGPT Apps. This templated npm package includes:
60
+ Next.js for ChatGPT Apps. Using a Review page as an example, sunpeak projects look like:
61
61
 
62
62
  ```bash
63
63
  my-app/
64
- ├── src/
65
- │ └── resources/
66
- └── review/ # My-app's Review UI.
67
- ├── review-resource.tsx # Review UI component.
68
- ├── review-resource.json # Review UI MCP metadata.
69
- └── review-{scenario}-simulation.json # Mock state for testing.
64
+ ├── src/resources/
65
+ │ └── review/
66
+ ├── review-resource.tsx # Review UI component.
67
+ └── review-resource.json # Review UI MCP metadata.
68
+ ├── tests/simulations/
69
+ └── review/
70
+ │ ├── review-{scenario1}-simulation.json # Mock state for testing.
71
+ │ └── review-{scenario2}-simulation.json # Mock state for testing.
70
72
  └── package.json
71
73
  ```
72
74
 
73
75
  1. Project scaffold: Complete development setup with the sunpeak library.
74
76
  2. UI components: Production-ready components following ChatGPT design guidelines and using OpenAI apps-sdk-ui React components.
75
- 3. Convention over configuration: Create UIs (resources) by simply creating a `src/resources/NAME/NAME-resource.tsx` React file and `src/resources/NAME/NAME-resource.json` metadata file.
77
+ 3. Convention over configuration:
78
+ 1. Create UIs by creating a `-resource.tsx` component file ([example](#resource-component)) and `-resource.json` MCP metadata file ([example](#resource-mcp-metadata)).
79
+ 2. Create test state (simulations) for local dev, ChatGPT dev, automated testing, and demos by creating a `-simulation.json` file. ([example](#simulation))
76
80
 
77
81
  ### The `sunpeak` CLI (`./bin`)
78
82
 
79
- Commands for managing ChatGPT Apps. Includes a client for the [sunpeak Resource Repository](https://app.sunpeak.ai/). The repository helps you & your CI/CD decouple your App from your client-agnostic MCP server while also providing a hosted runtime to collaborate, demo, and share your ChatGPT Apps:
83
+ Commands for managing ChatGPT Apps. Includes a client for the [sunpeak Resource Repository](https://app.sunpeak.ai/). The repository helps you & your CI/CD decouple your App from your client-agnostic MCP server while also providing a hosted runtime to collaborate, demo, and share your ChatGPT Apps.
84
+
85
+ Think Docker Hub for ChatGPT Apps:
80
86
 
81
87
  <div align="center">
82
88
  <a href="https://docs.sunpeak.ai/library/chatgpt-simulator">
@@ -88,14 +94,22 @@ Commands for managing ChatGPT Apps. Includes a client for the [sunpeak Resource
88
94
 
89
95
  1. Tag your app builds with version numbers and environment names (like `v1.0.0` and `prod`)
90
96
  2. `push` built Apps to a central location
91
- 3. `pull` built Apps to be run in different environments
97
+ 3. `pull` built Apps to be run in different environments, like your production MCP server.
98
+ 4. Share your fully-functional demo Apps with teammates, prospects, and strangers!
99
+
100
+ ## Examples
101
+
102
+ Example sunpeak resource & simulation files for an MCP Resource called "Review".
92
103
 
93
- ## Example Resource Component
104
+ ### Resource Component
105
+
106
+ React component defining a UI (MCP Resource) in your ChatGPT App.
94
107
 
95
108
  ```tsx
109
+ // src/resources/review-resource.tsx
96
110
  import { Card } from './components';
97
111
 
98
- export function MCPResource() {
112
+ export function ReviewResource() {
99
113
  return (
100
114
  <Card
101
115
  image="https://images.unsplash.com/photo-1520950237264-dfe336995c34"
@@ -111,6 +125,64 @@ export function MCPResource() {
111
125
  }
112
126
  ```
113
127
 
128
+ ### Resource MCP Metadata
129
+
130
+ MCP metadata for your UI. Version your resource metadata alongside the resource itself.
131
+
132
+ This is just an official [MCP Resource object](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource).
133
+
134
+ ```json
135
+ {
136
+ // src/resources/review-resource.json
137
+ "name": "review",
138
+ "title": "Review",
139
+ "description": "Visualize and review a proposed set of changes or actions",
140
+ "mimeType": "text/html+skybridge",
141
+ "_meta": {
142
+ "openai/widgetDomain": "https://sunpeak.ai",
143
+ "openai/widgetCSP": {
144
+ "resource_domains": ["https://cdn.openai.com"]
145
+ }
146
+ }
147
+ }
148
+ ```
149
+
150
+ ### Simulation
151
+
152
+ `sunpeak` object. Testing a ChatGPT App require setting a lot of state: state in your backend (accessed via MCP tool), the stored widget runtime, and ChatGPT itself.
153
+
154
+ Simulation files let you define key App states for development, automated testing, and demo purposes.
155
+
156
+ Simulation files contain an [official MCP Tool object](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool) and an [official MCP CallToolResult object](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#structured-content). ChatGPT state (like light/dark mode) is not set on the simulation, but rather on the sunpeak `ChatGPTSimulator` itself via UI, props, or URL params.
157
+
158
+ ```json
159
+ {
160
+ // tests/simulations/review-diff-simulation.json
161
+ "userMessage": "Refactor the authentication module", // Simulator styling.
162
+ "tool": {
163
+ // Official MCP Tool object.
164
+ "name": "diff-review",
165
+ "description": "Show a review dialog for a proposed code diff",
166
+ "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
167
+ "title": "Diff Review",
168
+ "annotations": { "readOnlyHint": false },
169
+ "_meta": {
170
+ "openai/toolInvocation/invoking": "Preparing changes",
171
+ "openai/toolInvocation/invoked": "Changes ready for review",
172
+ "openai/widgetAccessible": true,
173
+ "openai/resultCanProduceWidget": true
174
+ }
175
+ },
176
+ "callToolResult": {
177
+ // Official MCP CallToolResult object.
178
+ "structuredContent": {
179
+ // ...
180
+ },
181
+ "_meta": {}
182
+ }
183
+ }
184
+ ```
185
+
114
186
  ## Resources
115
187
 
116
188
  - [ChatGPT Apps SDK Design Guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines)
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react';
4
4
  import tailwindcss from '@tailwindcss/vite';
5
5
  import { existsSync, rmSync, readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs';
6
6
  import path from 'path';
7
- import { toPascalCase, isSimulationFile } from '../lib/patterns.mjs';
7
+ import { toPascalCase } from '../lib/patterns.mjs';
8
8
 
9
9
  /**
10
10
  * Build all resources for a Sunpeak project
@@ -241,16 +241,6 @@ export async function build(projectRoot = process.cwd()) {
241
241
  process.exit(1);
242
242
  }
243
243
 
244
- // Copy affiliated simulation files (matching {resource}-*-simulation.json pattern)
245
- const simulationFiles = readdirSync(resourceDir)
246
- .filter(file => isSimulationFile(file, kebabName));
247
-
248
- for (const simFile of simulationFiles) {
249
- const srcSim = path.join(resourceDir, simFile);
250
- const destSim = path.join(distOutDir, simFile);
251
- copyFileSync(srcSim, destSim);
252
- console.log(`✓ Copied ${kebabName}/${simFile}`);
253
- }
254
244
  }
255
245
 
256
246
  // Clean up temp and build directories
@@ -28,6 +28,7 @@ Usage:
28
28
  Options:
29
29
  -r, --repository <owner/repo> Repository name (defaults to git remote origin)
30
30
  -t, --tag <name> Additional tag(s) to assign (always includes "prod")
31
+ --no-simulations Skip pushing simulations, only push resources
31
32
  -h, --help Show this help message
32
33
 
33
34
  Arguments:
@@ -40,6 +41,7 @@ Examples:
40
41
  sunpeak deploy dist/carousel Deploy a single resource
41
42
  sunpeak deploy -r myorg/my-app Deploy to "myorg/my-app" repository
42
43
  sunpeak deploy -t v1.0 Deploy with "prod" and "v1.0" tags
44
+ sunpeak deploy --no-simulations Deploy without simulations
43
45
 
44
46
  This command is equivalent to: sunpeak push --tag prod
45
47
  `);
@@ -75,7 +77,7 @@ This command is equivalent to: sunpeak push --tag prod
75
77
  /**
76
78
  * Parse command line arguments
77
79
  */
78
- function parseArgs(args) {
80
+ export function parseArgs(args) {
79
81
  const options = { tags: [] };
80
82
  let i = 0;
81
83
 
@@ -86,6 +88,8 @@ function parseArgs(args) {
86
88
  options.repository = args[++i];
87
89
  } else if (arg === '--tag' || arg === '-t') {
88
90
  options.tags.push(args[++i]);
91
+ } else if (arg === '--no-simulations') {
92
+ options.noSimulations = true;
89
93
  } else if (arg === '--help' || arg === '-h') {
90
94
  console.log(`
91
95
  sunpeak deploy - Deploy resources to production (push with "prod" tag)
@@ -96,6 +100,7 @@ Usage:
96
100
  Options:
97
101
  -r, --repository <owner/repo> Repository name (defaults to git remote origin)
98
102
  -t, --tag <name> Additional tag(s) to assign (always includes "prod")
103
+ --no-simulations Skip pushing simulations, only push resources
99
104
  -h, --help Show this help message
100
105
 
101
106
  Arguments:
@@ -108,6 +113,7 @@ Examples:
108
113
  sunpeak deploy dist/carousel Deploy a single resource
109
114
  sunpeak deploy -r myorg/my-app Deploy to "myorg/my-app" repository
110
115
  sunpeak deploy -t v1.0 Deploy with "prod" and "v1.0" tags
116
+ sunpeak deploy --no-simulations Deploy without simulations
111
117
 
112
118
  This command is equivalent to: sunpeak push --tag prod
113
119
  `);
@@ -154,12 +154,14 @@ export async function dev(projectRoot = process.cwd(), args = []) {
154
154
 
155
155
  // Discover simulations using sunpeak's discovery utilities
156
156
  const resourcesDir = join(projectRoot, 'src/resources');
157
+ const simulationsDir = join(projectRoot, 'tests/simulations');
157
158
  const resourceDirs = findResourceDirs(resourcesDir, (key) => `${key}-resource.json`, fs);
158
159
 
159
160
  const simulations = [];
160
161
  for (const { key: resourceKey, dir: resourceDir, resourcePath } of resourceDirs) {
161
162
  const resource = JSON.parse(readFileSync(resourcePath, 'utf-8'));
162
- const simulationFiles = findSimulationFiles(resourceDir, resourceKey, fs);
163
+ const resourceSimDir = join(simulationsDir, resourceKey);
164
+ const simulationFiles = findSimulationFiles(resourceSimDir, resourceKey, fs);
163
165
 
164
166
  for (const { filename, path: simPath } of simulationFiles) {
165
167
  const simulationKey = filename.replace(/-simulation\.json$/, '');
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync, renameSync } from 'fs';
3
+ import { join, dirname, basename } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { createInterface } from 'readline';
6
+ import { discoverResources } from '../lib/patterns.mjs';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+
10
+ /**
11
+ * Default prompt implementation using readline
12
+ * @param {string} question
13
+ * @returns {Promise<string>}
14
+ */
15
+ function defaultPrompt(question) {
16
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
17
+ return new Promise((resolve) => {
18
+ rl.question(question, (answer) => {
19
+ rl.close();
20
+ resolve(answer.trim());
21
+ });
22
+ });
23
+ }
24
+
25
+ /**
26
+ * Default dependencies (real implementations)
27
+ */
28
+ export const defaultDeps = {
29
+ discoverResources,
30
+ existsSync,
31
+ mkdirSync,
32
+ cpSync,
33
+ readFileSync,
34
+ writeFileSync,
35
+ renameSync,
36
+ prompt: defaultPrompt,
37
+ console,
38
+ process,
39
+ cwd: () => process.cwd(),
40
+ templateDir: join(__dirname, '..', '..', 'template'),
41
+ rootPkgPath: join(__dirname, '..', '..', 'package.json'),
42
+ };
43
+
44
+ /**
45
+ * Parse and validate resources input
46
+ * @param {string} input - Comma or space separated resource names
47
+ * @param {string[]} validResources - List of valid resource names
48
+ * @param {Object} deps - Dependencies for testing
49
+ * @returns {string[]} - Validated and deduplicated resource names
50
+ */
51
+ export function parseResourcesInput(input, validResources, deps = defaultDeps) {
52
+ const d = { ...defaultDeps, ...deps };
53
+
54
+ // If no input, return all resources
55
+ if (!input || input.trim() === '') {
56
+ return validResources;
57
+ }
58
+
59
+ // Split by comma or space and trim
60
+ const tokens = input
61
+ .toLowerCase()
62
+ .split(/[,\s]+/)
63
+ .map((s) => s.trim())
64
+ .filter((s) => s.length > 0);
65
+
66
+ // Validate tokens
67
+ const invalid = tokens.filter((t) => !validResources.includes(t));
68
+ if (invalid.length > 0) {
69
+ d.console.error(`Error: Invalid resource(s): ${invalid.join(', ')}`);
70
+ d.console.error(`Valid resources are: ${validResources.join(', ')}`);
71
+ d.process.exit(1);
72
+ }
73
+
74
+ // Remove duplicates
75
+ return [...new Set(tokens)];
76
+ }
77
+
78
+ /**
79
+ * Create a new Sunpeak project
80
+ * @param {string} projectName - Name of the project directory
81
+ * @param {string} resourcesArg - Optional comma/space separated resources to include
82
+ * @param {Object} deps - Dependencies for testing
83
+ */
84
+ export async function init(projectName, resourcesArg, deps = defaultDeps) {
85
+ const d = { ...defaultDeps, ...deps };
86
+
87
+ // Discover available resources from template
88
+ const availableResources = d.discoverResources();
89
+ if (availableResources.length === 0) {
90
+ d.console.error('Error: No resources found in template/src/resources/');
91
+ d.process.exit(1);
92
+ }
93
+
94
+ if (!projectName) {
95
+ projectName = await d.prompt('☀️ 🏔️ Project name [my-app]: ');
96
+ if (!projectName) {
97
+ projectName = 'my-app';
98
+ }
99
+ }
100
+
101
+ if (projectName === 'template') {
102
+ d.console.error('Error: "template" is a reserved name. Please choose another name.');
103
+ d.process.exit(1);
104
+ }
105
+
106
+ // Use resources from args or ask for them
107
+ let resourcesInput;
108
+ if (resourcesArg) {
109
+ resourcesInput = resourcesArg;
110
+ d.console.log(`☀️ 🏔️ Resources: ${resourcesArg}`);
111
+ } else {
112
+ resourcesInput = await d.prompt(
113
+ `☀️ 🏔️ Resources (UIs) to include [${availableResources.join(', ')}]: `
114
+ );
115
+ }
116
+ const selectedResources = parseResourcesInput(resourcesInput, availableResources, d);
117
+
118
+ const targetDir = join(d.cwd(), projectName);
119
+
120
+ if (d.existsSync(targetDir)) {
121
+ d.console.error(`Error: Directory "${projectName}" already exists`);
122
+ d.process.exit(1);
123
+ }
124
+
125
+ d.console.log(`☀️ 🏔️ Creating ${projectName}...`);
126
+
127
+ d.mkdirSync(targetDir, { recursive: true });
128
+
129
+ // Filter resource directories based on selection
130
+ const excludedResources = availableResources.filter((r) => !selectedResources.includes(r));
131
+
132
+ d.cpSync(d.templateDir, targetDir, {
133
+ recursive: true,
134
+ filter: (src) => {
135
+ const name = basename(src);
136
+
137
+ // Skip node_modules and lock file
138
+ if (name === 'node_modules' || name === 'pnpm-lock.yaml') {
139
+ return false;
140
+ }
141
+
142
+ for (const resource of excludedResources) {
143
+ // Skip entire resource directory: src/resources/{resource}/
144
+ if (src.includes('/resources/') && name === resource) {
145
+ return false;
146
+ }
147
+ // Skip simulation directories for excluded resources: tests/simulations/{resource}/
148
+ if (src.includes('/tests/simulations/') && name === resource) {
149
+ return false;
150
+ }
151
+ // Skip e2e test files for excluded resources
152
+ if (src.includes('/tests/e2e/') && name === `${resource}.spec.ts`) {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ return true;
158
+ },
159
+ });
160
+
161
+ // Rename underscore-prefixed files to dotfiles
162
+ const dotfiles = ['_gitignore', '_prettierignore', '_prettierrc'];
163
+ for (const file of dotfiles) {
164
+ const srcPath = join(targetDir, file);
165
+ const destPath = join(targetDir, file.replace(/^_/, '.'));
166
+ if (d.existsSync(srcPath)) {
167
+ d.renameSync(srcPath, destPath);
168
+ }
169
+ }
170
+
171
+ // Read sunpeak version from root package.json
172
+ const rootPkg = JSON.parse(d.readFileSync(d.rootPkgPath, 'utf-8'));
173
+ const sunpeakVersion = `^${rootPkg.version}`;
174
+
175
+ // Update project package.json
176
+ const pkgPath = join(targetDir, 'package.json');
177
+ const pkg = JSON.parse(d.readFileSync(pkgPath, 'utf-8'));
178
+ pkg.name = projectName;
179
+
180
+ // Replace workspace:* with actual version
181
+ if (pkg.dependencies?.sunpeak === 'workspace:*') {
182
+ pkg.dependencies.sunpeak = sunpeakVersion;
183
+ }
184
+
185
+ d.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
186
+
187
+ d.console.log(`
188
+ Done! To get started:
189
+
190
+ cd ${projectName}
191
+ pnpm install
192
+ sunpeak dev
193
+
194
+ That's it! Your project commands:
195
+
196
+ sunpeak dev # Start dev server + MCP endpoint
197
+ sunpeak build # Build for production
198
+ pnpm test # Run tests
199
+
200
+ See README.md for more details.
201
+ `);
202
+ }
203
+
204
+ // Allow running directly
205
+ if (import.meta.url === `file://${process.argv[1]}`) {
206
+ const [projectName, resources] = process.argv.slice(2);
207
+ init(projectName, resources).catch((error) => {
208
+ console.error('Error:', error.message);
209
+ process.exit(1);
210
+ });
211
+ }
@@ -216,7 +216,7 @@ Examples:
216
216
  /**
217
217
  * Parse command line arguments
218
218
  */
219
- function parseArgs(args) {
219
+ export function parseArgs(args) {
220
220
  const options = {};
221
221
  let i = 0;
222
222
 
@@ -61,23 +61,24 @@ export const defaultDeps = {
61
61
  };
62
62
 
63
63
  /**
64
- * Find simulation files for a resource in its directory
65
- * Expects files matching: {resourceName}-*-simulation.json
64
+ * Find simulation files for a resource in the simulations directory
65
+ * Expects files in: tests/simulations/{resourceName}/{resourceName}-*-simulation.json
66
66
  * Returns array of parsed simulation objects
67
67
  */
68
- function findSimulations(resourceDir, resourceName, deps = defaultDeps) {
68
+ function findSimulations(simulationsDir, resourceName, deps = defaultDeps) {
69
69
  const d = { ...defaultDeps, ...deps };
70
70
 
71
- if (!d.existsSync(resourceDir)) {
71
+ const resourceSimDir = join(simulationsDir, resourceName);
72
+ if (!d.existsSync(resourceSimDir)) {
72
73
  return [];
73
74
  }
74
75
 
75
- const entries = d.readdirSync(resourceDir);
76
+ const entries = d.readdirSync(resourceSimDir);
76
77
  const simulations = [];
77
78
 
78
79
  for (const entry of entries) {
79
80
  if (isSimulationFile(entry, resourceName)) {
80
- const simPath = join(resourceDir, entry);
81
+ const simPath = join(resourceSimDir, entry);
81
82
  try {
82
83
  const simData = JSON.parse(d.readFileSync(simPath, 'utf-8'));
83
84
  const simName = extractSimulationName(entry, resourceName);
@@ -94,9 +95,10 @@ function findSimulations(resourceDir, resourceName, deps = defaultDeps) {
94
95
  /**
95
96
  * Find all resources in a directory
96
97
  * Expects folder structure: dist/{resource}/{resource}.js
98
+ * Simulations are loaded from tests/simulations/{resource}/
97
99
  * Returns array of { name, jsPath, metaPath, meta, simulations }
98
100
  */
99
- export function findResources(distDir, deps = defaultDeps) {
101
+ export function findResources(distDir, simulationsDir, deps = defaultDeps) {
100
102
  const d = { ...defaultDeps, ...deps };
101
103
 
102
104
  if (!d.existsSync(distDir)) {
@@ -120,7 +122,7 @@ export function findResources(distDir, deps = defaultDeps) {
120
122
  } catch {
121
123
  d.console.warn(`Warning: Could not parse ${resourceName}.json`);
122
124
  }
123
- const simulations = findSimulations(resourceDir, resourceName, d);
125
+ const simulations = findSimulations(simulationsDir, resourceName, d);
124
126
  resources.push({ name: resourceName, dir: resourceDir, jsPath, metaPath, meta, simulations });
125
127
  }
126
128
  }
@@ -132,9 +134,10 @@ export function findResources(distDir, deps = defaultDeps) {
132
134
  /**
133
135
  * Build a resource from a resource directory path
134
136
  * Expects structure: dir/{name}.js, dir/{name}.json
137
+ * Simulations are loaded from tests/simulations/{name}/
135
138
  * Returns { name, jsPath, metaPath, meta, simulations }
136
139
  */
137
- function buildResourceFromDir(resourceDir, deps = defaultDeps) {
140
+ function buildResourceFromDir(resourceDir, simulationsDir, deps = defaultDeps) {
138
141
  const d = { ...defaultDeps, ...deps };
139
142
 
140
143
  // Remove trailing slash if present
@@ -167,7 +170,7 @@ function buildResourceFromDir(resourceDir, deps = defaultDeps) {
167
170
  d.console.warn(`Warning: Could not parse ${name}.json`);
168
171
  }
169
172
 
170
- const simulations = findSimulations(dir, name, d);
173
+ const simulations = findSimulations(simulationsDir, name, d);
171
174
 
172
175
  return { name, jsPath, metaPath, meta, simulations };
173
176
  }
@@ -281,6 +284,7 @@ Usage:
281
284
  Options:
282
285
  -r, --repository <owner/repo> Repository name (defaults to git remote origin)
283
286
  -t, --tag <name> Tag to assign (can be specified multiple times)
287
+ --no-simulations Skip pushing simulations, only push resources
284
288
  -h, --help Show this help message
285
289
 
286
290
  Arguments:
@@ -293,6 +297,7 @@ Examples:
293
297
  sunpeak push -r myorg/my-app Push to "myorg/my-app" repository
294
298
  sunpeak push -t v1.0.0 Push with a version tag
295
299
  sunpeak push -t v1.0.0 -t prod Push with multiple tags
300
+ sunpeak push --no-simulations Push resources without simulations
296
301
  `);
297
302
  return;
298
303
  }
@@ -314,10 +319,11 @@ Examples:
314
319
  }
315
320
 
316
321
  // Find resources - either a specific directory or all from dist directory
322
+ const simulationsDir = join(projectRoot, 'tests/simulations');
317
323
  let resources;
318
324
  if (options.dir) {
319
325
  // Push a single specific resource from directory
320
- resources = [buildResourceFromDir(options.dir, d)];
326
+ resources = [buildResourceFromDir(options.dir, simulationsDir, d)];
321
327
  } else {
322
328
  // Default: find all resources in dist directory
323
329
  const distDir = join(projectRoot, 'dist');
@@ -327,7 +333,7 @@ Examples:
327
333
  d.process.exit(1);
328
334
  }
329
335
 
330
- resources = findResources(distDir, d);
336
+ resources = findResources(distDir, simulationsDir, d);
331
337
  if (resources.length === 0) {
332
338
  d.console.error(`Error: No resources found in dist/`);
333
339
  d.console.error('Run "sunpeak build" first to build your resources.');
@@ -335,10 +341,20 @@ Examples:
335
341
  }
336
342
  }
337
343
 
344
+ // Clear simulations if --no-simulations flag is set
345
+ if (options.noSimulations) {
346
+ for (const resource of resources) {
347
+ resource.simulations = [];
348
+ }
349
+ }
350
+
338
351
  d.console.log(`Pushing ${resources.length} resource(s) to repository "${repository}"...`);
339
352
  if (options.tags && options.tags.length > 0) {
340
353
  d.console.log(`Tags: ${options.tags.join(', ')}`);
341
354
  }
355
+ if (options.noSimulations) {
356
+ d.console.log('Simulations: skipped');
357
+ }
342
358
  d.console.log();
343
359
 
344
360
  // Push each resource
@@ -354,6 +370,9 @@ Examples:
354
370
  successCount++;
355
371
  } catch (error) {
356
372
  d.console.error(`✗ Failed to push ${resource.name}: ${error.message}`);
373
+ if (error.message.includes('Uri must be unique')) {
374
+ d.console.error(' You are trying to push a build that has already been pushed.');
375
+ }
357
376
  }
358
377
  }
359
378
 
@@ -369,7 +388,7 @@ Examples:
369
388
  /**
370
389
  * Parse command line arguments
371
390
  */
372
- function parseArgs(args) {
391
+ export function parseArgs(args) {
373
392
  const options = { tags: [] };
374
393
  let i = 0;
375
394
 
@@ -380,6 +399,8 @@ function parseArgs(args) {
380
399
  options.repository = args[++i];
381
400
  } else if (arg === '--tag' || arg === '-t') {
382
401
  options.tags.push(args[++i]);
402
+ } else if (arg === '--no-simulations') {
403
+ options.noSimulations = true;
383
404
  } else if (arg === '--help' || arg === '-h') {
384
405
  console.log(`
385
406
  sunpeak push - Push resources to the Sunpeak repository
@@ -390,6 +411,7 @@ Usage:
390
411
  Options:
391
412
  -r, --repository <owner/repo> Repository name (defaults to git remote origin)
392
413
  -t, --tag <name> Tag to assign (can be specified multiple times)
414
+ --no-simulations Skip pushing simulations, only push resources
393
415
  -h, --help Show this help message
394
416
 
395
417
  Arguments:
@@ -402,6 +424,7 @@ Examples:
402
424
  sunpeak push -r myorg/my-app Push to "myorg/my-app" repository
403
425
  sunpeak push -t v1.0.0 Push with a version tag
404
426
  sunpeak push -t v1.0.0 -t prod Push with multiple tags
427
+ sunpeak push --no-simulations Push resources without simulations
405
428
  `);
406
429
  process.exit(0);
407
430
  } else if (!arg.startsWith('-')) {
@@ -3,6 +3,31 @@
3
3
  * These mirror the patterns in src/lib/discovery.ts for consistency.
4
4
  */
5
5
 
6
+ import { existsSync, readdirSync } from 'fs';
7
+ import { join, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+
12
+ /**
13
+ * Auto-discover available resources from template/src/resources directories.
14
+ * Each subdirectory containing a {name}-resource.tsx file is a valid resource.
15
+ * @returns {string[]} Array of resource names
16
+ */
17
+ export function discoverResources() {
18
+ const resourcesDir = join(__dirname, '..', '..', 'template', 'src', 'resources');
19
+ if (!existsSync(resourcesDir)) {
20
+ return [];
21
+ }
22
+ return readdirSync(resourcesDir, { withFileTypes: true })
23
+ .filter((entry) => entry.isDirectory())
24
+ .filter((entry) => {
25
+ const resourceFile = join(resourcesDir, entry.name, `${entry.name}-resource.tsx`);
26
+ return existsSync(resourceFile);
27
+ })
28
+ .map((entry) => entry.name);
29
+ }
30
+
6
31
  /**
7
32
  * Convert a kebab-case string to PascalCase
8
33
  * @param {string} str