sunpeak 0.6.6 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +3 -3
  2. package/bin/commands/build.mjs +22 -5
  3. package/bin/commands/deploy.mjs +108 -0
  4. package/bin/commands/login.mjs +217 -0
  5. package/bin/commands/logout.mjs +87 -0
  6. package/bin/commands/pull.mjs +254 -0
  7. package/bin/commands/push.mjs +347 -0
  8. package/bin/sunpeak.js +85 -2
  9. package/dist/mcp/entry.cjs +2 -2
  10. package/dist/mcp/entry.cjs.map +1 -1
  11. package/dist/mcp/entry.js +2 -2
  12. package/dist/mcp/entry.js.map +1 -1
  13. package/dist/mcp/index.cjs +1 -1
  14. package/dist/mcp/index.js +1 -1
  15. package/dist/{server-CQGbJWbk.cjs → server-BOYwNazb.cjs} +25 -26
  16. package/dist/{server-CQGbJWbk.cjs.map → server-BOYwNazb.cjs.map} +1 -1
  17. package/dist/{server-DGCvp1RA.js → server-C6vMGV6H.js} +25 -26
  18. package/dist/{server-DGCvp1RA.js.map → server-C6vMGV6H.js.map} +1 -1
  19. package/package.json +1 -1
  20. package/template/.sunpeak/dev.tsx +8 -10
  21. package/template/README.md +4 -4
  22. package/template/dist/albums.json +15 -0
  23. package/template/dist/carousel.json +15 -0
  24. package/template/dist/counter.json +10 -0
  25. package/template/dist/map.json +19 -0
  26. package/template/index.html +1 -1
  27. package/template/node_modules/.vite/deps/_metadata.json +19 -19
  28. package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  29. package/template/src/components/map/map-view.test.tsx +146 -0
  30. package/template/src/components/map/place-card.test.tsx +76 -0
  31. package/template/src/components/map/place-carousel.test.tsx +84 -0
  32. package/template/src/components/map/place-inspector.test.tsx +91 -0
  33. package/template/src/components/map/place-list.test.tsx +97 -0
  34. package/template/src/resources/albums-resource.json +12 -0
  35. package/template/src/resources/carousel-resource.json +12 -0
  36. package/template/src/resources/counter-resource.json +9 -0
  37. package/template/src/resources/map-resource.json +13 -0
  38. package/template/src/simulations/albums-simulation.ts +3 -15
  39. package/template/src/simulations/carousel-simulation.ts +3 -15
  40. package/template/src/simulations/counter-simulation.ts +3 -15
  41. package/template/src/simulations/map-simulation.ts +5 -28
  42. package/template/src/simulations/widget-config.ts +0 -42
  43. /package/template/dist/{chatgpt/albums.js → albums.js} +0 -0
  44. /package/template/dist/{chatgpt/carousel.js → carousel.js} +0 -0
  45. /package/template/dist/{chatgpt/counter.js → counter.js} +0 -0
  46. /package/template/dist/{chatgpt/map.js → map.js} +0 -0
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
16
16
  [![React](https://img.shields.io/badge/React-19-blue?style=flat-square&logo=react)](https://reactjs.org/)
17
17
 
18
- The MCP App framework.
18
+ The ChatGPT App framework.
19
19
 
20
20
  Quickstart, build, test, and ship your ChatGPT App locally!
21
21
 
@@ -49,11 +49,11 @@ To add sunpeak to an existing project, refer to the [documentation](https://docs
49
49
 
50
50
  sunpeak is an npm package consisting of:
51
51
 
52
- 1. **The `sunpeak` library** (`./src`). An npm package for running & testing MCP Apps. This standalone library contains:
52
+ 1. **The `sunpeak` library** (`./src`). An npm package for running & testing ChatGPT Apps. This standalone library contains:
53
53
  1. Runtime APIs - Strongly typed, multi-platform APIs for interacting with the ChatGPT runtime, architected to support future platforms (Gemini, Claude).
54
54
  2. ChatGPT simulator - React component replicating ChatGPT's runtime.
55
55
  3. MCP server - Mock data MCP server for testing local Resources in the real ChatGPT.
56
- 2. **The `sunpeak` framework** (`./template`). An end-to-end framework MCP Apps, from quickstart to shipped. This templated npm package includes:
56
+ 2. **The `sunpeak` framework** (`./template`). An end-to-end framework for ChatGPT Apps, from quickstart to shipped. This templated npm package includes:
57
57
  1. Project scaffold - Complete development setup with build, test, and mcp tooling.
58
58
  2. UI components - Production-ready components following ChatGPT design guidelines and using OpenAI apps-sdk-ui React components.
59
59
  3. CLI utility (`./bin`) - Commands for working with the sunpeak framework.
@@ -23,7 +23,7 @@ export async function build(projectRoot = process.cwd()) {
23
23
  const isTemplate = path.basename(projectRoot) === 'template';
24
24
  const parentSrc = path.resolve(projectRoot, '../src');
25
25
 
26
- const distDir = path.join(projectRoot, 'dist/chatgpt');
26
+ const distDir = path.join(projectRoot, 'dist');
27
27
  const buildDir = path.join(projectRoot, 'dist/build-output');
28
28
  const tempDir = path.join(projectRoot, '.tmp');
29
29
  const resourcesDir = path.join(projectRoot, 'src/resources');
@@ -194,9 +194,9 @@ export async function build(projectRoot = process.cwd()) {
194
194
  }
195
195
  }
196
196
 
197
- // Now copy all files from build-output to dist/chatgpt
198
- console.log('\nCopying built files to dist/chatgpt...');
199
- resourceFiles.forEach(({ output, buildOutDir }) => {
197
+ // Now copy all files from build-output to dist/
198
+ console.log('\nCopying built files to dist/...');
199
+ for (const { output, buildOutDir } of resourceFiles) {
200
200
  const builtFile = path.join(buildOutDir, output);
201
201
  const destFile = path.join(distDir, output);
202
202
 
@@ -212,7 +212,24 @@ export async function build(projectRoot = process.cwd()) {
212
212
  }
213
213
  process.exit(1);
214
214
  }
215
- });
215
+ }
216
+
217
+ // Generate resource metadata JSON files with URIs
218
+ console.log('\nGenerating resource metadata JSON files...');
219
+ const timestamp = Date.now().toString(36);
220
+ for (const { componentFile } of resourceFiles) {
221
+ const kebabName = componentFile.replace('-resource', '');
222
+ const srcJson = path.join(resourcesDir, `${componentFile}.json`);
223
+ const destJson = path.join(distDir, `${kebabName}.json`);
224
+
225
+ if (existsSync(srcJson)) {
226
+ const meta = JSON.parse(readFileSync(srcJson, 'utf-8'));
227
+ // Generate URI using resource name and build timestamp
228
+ meta.uri = `ui://${meta.name}-${timestamp}`;
229
+ writeFileSync(destJson, JSON.stringify(meta, null, 2));
230
+ console.log(`✓ Generated ${kebabName}.json (uri: ${meta.uri})`);
231
+ }
232
+ }
216
233
 
217
234
  // Clean up temp and build directories
218
235
  if (existsSync(tempDir)) {
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ import { push } from './push.mjs';
3
+
4
+ /**
5
+ * Deploy command - same as push but with tag="prod"
6
+ * @param {string} projectRoot - Project root directory
7
+ * @param {Object} options - Command options (same as push, but tag defaults to "prod")
8
+ */
9
+ export async function deploy(projectRoot = process.cwd(), options = {}) {
10
+ // Handle help flag
11
+ if (options.help) {
12
+ console.log(`
13
+ sunpeak deploy - Push resources to production (push with "prod" tag)
14
+
15
+ Usage:
16
+ sunpeak deploy [file] [options]
17
+
18
+ Options:
19
+ -r, --repository <owner/repo> Repository name (defaults to git remote origin)
20
+ -t, --tag <name> Tag to assign (defaults to "prod")
21
+ -h, --help Show this help message
22
+
23
+ Arguments:
24
+ file Optional JS file to deploy (e.g., dist/carousel.js)
25
+ If not provided, deploys all resources from dist/
26
+
27
+ Examples:
28
+ sunpeak deploy Push all resources with "prod" tag
29
+ sunpeak deploy dist/carousel.js Deploy a single resource
30
+ sunpeak deploy -r myorg/my-app Deploy to "myorg/my-app" repository
31
+ sunpeak deploy -t production Deploy with custom tag
32
+
33
+ This command is equivalent to: sunpeak push --tag prod
34
+ `);
35
+ return;
36
+ }
37
+
38
+ // Default tag to "prod" for deploy
39
+ const deployOptions = {
40
+ ...options,
41
+ tags: options.tags && options.tags.length > 0 ? options.tags : ['prod'],
42
+ };
43
+
44
+ console.log('Deploying to production...');
45
+ console.log();
46
+
47
+ await push(projectRoot, deployOptions);
48
+ }
49
+
50
+ /**
51
+ * Parse command line arguments
52
+ */
53
+ function parseArgs(args) {
54
+ const options = { tags: [] };
55
+ let i = 0;
56
+
57
+ while (i < args.length) {
58
+ const arg = args[i];
59
+
60
+ if (arg === '--repository' || arg === '-r') {
61
+ options.repository = args[++i];
62
+ } else if (arg === '--tag' || arg === '-t') {
63
+ options.tags.push(args[++i]);
64
+ } else if (arg === '--help' || arg === '-h') {
65
+ console.log(`
66
+ sunpeak deploy - Deploy resources to production (push with "prod" tag)
67
+
68
+ Usage:
69
+ sunpeak deploy [file] [options]
70
+
71
+ Options:
72
+ -r, --repository <owner/repo> Repository name (defaults to git remote origin)
73
+ -t, --tag <name> Tag to assign (defaults to "prod")
74
+ -h, --help Show this help message
75
+
76
+ Arguments:
77
+ file Optional JS file to deploy (e.g., dist/carousel.js)
78
+ If not provided, deploys all resources from dist/
79
+
80
+ Examples:
81
+ sunpeak deploy Deploy all resources with "prod" tag
82
+ sunpeak deploy dist/carousel.js Deploy a single resource
83
+ sunpeak deploy -r myorg/my-app Deploy to "myorg/my-app" repository
84
+ sunpeak deploy -t production Deploy with custom tag
85
+
86
+ This command is equivalent to: sunpeak push --tag prod
87
+ `);
88
+ process.exit(0);
89
+ } else if (!arg.startsWith('-')) {
90
+ // Positional argument - treat as file path
91
+ options.file = arg;
92
+ }
93
+
94
+ i++;
95
+ }
96
+
97
+ return options;
98
+ }
99
+
100
+ // Allow running directly
101
+ if (import.meta.url === `file://${process.argv[1]}`) {
102
+ const args = process.argv.slice(2);
103
+ const options = parseArgs(args);
104
+ deploy(process.cwd(), options).catch((error) => {
105
+ console.error('Error:', error.message);
106
+ process.exit(1);
107
+ });
108
+ }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir, platform } from 'os';
5
+ import { exec } from 'child_process';
6
+
7
+ const SUNPEAK_API_URL = process.env.SUNPEAK_API_URL || 'https://app.sunpeak.ai';
8
+ const CREDENTIALS_DIR = join(homedir(), '.sunpeak');
9
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'credentials.json');
10
+
11
+ // Polling configuration
12
+ const POLL_INTERVAL_MS = 2500; // 2.5 seconds between polls.
13
+ const MAX_POLL_DURATION_MS = 2 * 60 * 1000; // 2 minutes max.
14
+
15
+ /**
16
+ * Load existing credentials if present
17
+ */
18
+ function loadCredentials() {
19
+ if (!existsSync(CREDENTIALS_FILE)) {
20
+ return null;
21
+ }
22
+ try {
23
+ return JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Save credentials to disk
31
+ */
32
+ function saveCredentials(credentials) {
33
+ if (!existsSync(CREDENTIALS_DIR)) {
34
+ mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
35
+ }
36
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 });
37
+ }
38
+
39
+ /**
40
+ * Request a device code from the authorization server
41
+ */
42
+ async function requestDeviceCode() {
43
+ const response = await fetch(`${SUNPEAK_API_URL}/oauth/device_authorization`, {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ });
47
+
48
+ if (!response.ok) {
49
+ const text = await response.text();
50
+ throw new Error(`Failed to request device code: ${response.status} ${text}`);
51
+ }
52
+
53
+ return response.json();
54
+ }
55
+
56
+ /**
57
+ * Poll for the access token
58
+ */
59
+ async function pollForToken(deviceCode) {
60
+ const startTime = Date.now();
61
+
62
+ while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
63
+ let response;
64
+ try {
65
+ response = await fetch(`${SUNPEAK_API_URL}/oauth/token`, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
68
+ body: new URLSearchParams({
69
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
70
+ device_code: deviceCode,
71
+ }),
72
+ });
73
+ } catch (err) {
74
+ // Network error - wait and retry
75
+ await sleep(POLL_INTERVAL_MS);
76
+ continue;
77
+ }
78
+
79
+ let data;
80
+ let responseText;
81
+ try {
82
+ responseText = await response.text();
83
+ data = JSON.parse(responseText);
84
+ } catch {
85
+ // Non-JSON response - this is unexpected, throw with details
86
+ throw new Error(
87
+ `Server returned unexpected response (${response.status}): ${responseText?.slice(0, 200) || 'empty response'}`
88
+ );
89
+ }
90
+
91
+ if (response.ok && data.access_token) {
92
+ return data;
93
+ }
94
+
95
+ // Handle standard OAuth 2.0 device flow errors (expected during polling)
96
+ if (data.error === 'authorization_pending') {
97
+ // User hasn't authorized yet, keep polling
98
+ await sleep(POLL_INTERVAL_MS);
99
+ continue;
100
+ }
101
+
102
+ if (data.error === 'slow_down') {
103
+ // Server asking us to slow down, increase interval
104
+ await sleep(POLL_INTERVAL_MS * 2);
105
+ continue;
106
+ }
107
+
108
+ if (data.error === 'access_denied') {
109
+ throw new Error('Authorization denied by user');
110
+ }
111
+
112
+ if (data.error === 'expired_token') {
113
+ throw new Error('Device code expired. Please try again.');
114
+ }
115
+
116
+ // If response was OK but no access_token, something is wrong with the response
117
+ if (response.ok) {
118
+ throw new Error('Invalid token response from server');
119
+ }
120
+
121
+ // Unknown error - include status code for debugging
122
+ const errorMessage = data.error_description || data.error || 'Unknown error';
123
+ throw new Error(`Authorization failed (${response.status}): ${errorMessage}`);
124
+ }
125
+
126
+ throw new Error('Authorization timed out. Please try again.');
127
+ }
128
+
129
+ function sleep(ms) {
130
+ return new Promise((resolve) => setTimeout(resolve, ms));
131
+ }
132
+
133
+ /**
134
+ * Open a URL in the default browser
135
+ * Returns true if successful, false otherwise
136
+ */
137
+ function openBrowser(url) {
138
+ return new Promise((resolve) => {
139
+ const os = platform();
140
+ let command;
141
+
142
+ // Platform-specific commands to open URLs
143
+ if (os === 'darwin') {
144
+ command = `open "${url}"`;
145
+ } else if (os === 'win32') {
146
+ command = `start "" "${url}"`;
147
+ } else {
148
+ // Linux and other Unix-like systems
149
+ command = `xdg-open "${url}"`;
150
+ }
151
+
152
+ exec(command, (error) => {
153
+ resolve(!error);
154
+ });
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Main login command
160
+ */
161
+ export async function login() {
162
+ // Check if already logged in
163
+ const existing = loadCredentials();
164
+ if (existing?.access_token) {
165
+ console.log('Already logged in. Run "sunpeak logout" first to switch accounts.');
166
+ return;
167
+ }
168
+
169
+ console.log('Starting device authorization flow...\n');
170
+
171
+ // Step 1: Request device code
172
+ const deviceAuth = await requestDeviceCode();
173
+
174
+ // Step 2: Open browser and display instructions
175
+ // Prefer verification_uri_complete which has the code pre-filled
176
+ const authUrl =
177
+ deviceAuth.verification_uri_complete ||
178
+ `${deviceAuth.verification_uri}?user_code=${encodeURIComponent(deviceAuth.user_code)}`;
179
+
180
+ const browserOpened = await openBrowser(authUrl);
181
+
182
+ if (browserOpened) {
183
+ console.log('Opening browser for authentication...\n');
184
+ console.log(`If the browser didn't open, visit: ${deviceAuth.verification_uri}`);
185
+ console.log(`And enter code: ${deviceAuth.user_code}`);
186
+ } else {
187
+ console.log('To complete login, please:');
188
+ console.log(` 1. Visit: ${deviceAuth.verification_uri}`);
189
+ console.log(` 2. Enter code: ${deviceAuth.user_code}`);
190
+ }
191
+ console.log('\nWaiting for authorization...');
192
+
193
+ // Step 3: Poll for token
194
+ const tokenResponse = await pollForToken(deviceAuth.device_code);
195
+
196
+ // Step 4: Save credentials
197
+ const credentials = {
198
+ access_token: tokenResponse.access_token,
199
+ token_type: tokenResponse.token_type || 'Bearer',
200
+ expires_at: tokenResponse.expires_in
201
+ ? Date.now() + tokenResponse.expires_in * 1000
202
+ : null,
203
+ created_at: Date.now(),
204
+ };
205
+
206
+ saveCredentials(credentials);
207
+
208
+ console.log('\n✓ Successfully logged in to Sunpeak!');
209
+ }
210
+
211
+ // Allow running directly
212
+ if (import.meta.url === `file://${process.argv[1]}`) {
213
+ login().catch((error) => {
214
+ console.error('Error:', error.message);
215
+ process.exit(1);
216
+ });
217
+ }
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, unlinkSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+
6
+ const SUNPEAK_API_URL = process.env.SUNPEAK_API_URL || 'https://app.sunpeak.ai';
7
+ const CREDENTIALS_DIR = join(homedir(), '.sunpeak');
8
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'credentials.json');
9
+
10
+ /**
11
+ * Load existing credentials if present
12
+ */
13
+ function loadCredentials() {
14
+ if (!existsSync(CREDENTIALS_FILE)) {
15
+ return null;
16
+ }
17
+ try {
18
+ return JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Delete credentials file
26
+ */
27
+ function deleteCredentials() {
28
+ if (existsSync(CREDENTIALS_FILE)) {
29
+ unlinkSync(CREDENTIALS_FILE);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Revoke the access token on the server
35
+ */
36
+ async function revokeToken(accessToken) {
37
+ try {
38
+ const response = await fetch(`${SUNPEAK_API_URL}/oauth/revoke`, {
39
+ method: 'POST',
40
+ headers: {
41
+ Authorization: `Bearer ${accessToken}`,
42
+ 'Content-Type': 'application/json',
43
+ },
44
+ });
45
+
46
+ // 200 OK means success, but we also accept other success codes
47
+ return response.ok;
48
+ } catch {
49
+ // Network error - token may still be valid on server
50
+ // but we'll clean up locally anyway
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Main logout command
57
+ */
58
+ export async function logout() {
59
+ const credentials = loadCredentials();
60
+
61
+ if (!credentials?.access_token) {
62
+ console.log('Not logged in.');
63
+ return;
64
+ }
65
+
66
+ console.log('Logging out...');
67
+
68
+ // Revoke token on server
69
+ const revoked = await revokeToken(credentials.access_token);
70
+
71
+ // Always delete local credentials regardless of revocation result
72
+ deleteCredentials();
73
+
74
+ if (revoked) {
75
+ console.log('✓ Successfully logged out of Sunpeak.');
76
+ } else {
77
+ console.log('✓ Logged out locally. (Server token revocation may have failed)');
78
+ }
79
+ }
80
+
81
+ // Allow running directly
82
+ if (import.meta.url === `file://${process.argv[1]}`) {
83
+ logout().catch((error) => {
84
+ console.error('Error:', error.message);
85
+ process.exit(1);
86
+ });
87
+ }