sunpeak 0.6.7 → 0.7.10

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 (47) hide show
  1. package/README.md +3 -3
  2. package/bin/commands/build.mjs +22 -5
  3. package/bin/commands/deploy.mjs +125 -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 +352 -0
  8. package/bin/sunpeak.js +101 -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/@openai_apps-sdk-ui_components_Button.js +3 -3
  28. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_SegmentedControl.js +1 -1
  29. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Select.js +16 -16
  30. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Textarea.js +3 -3
  31. package/template/node_modules/.vite/deps/_metadata.json +32 -32
  32. package/template/node_modules/.vite/deps/{chunk-DQAZDQU3.js → chunk-LR7NKCX5.js} +8 -8
  33. package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  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
  47. /package/template/node_modules/.vite/deps/{chunk-DQAZDQU3.js.map → chunk-LR7NKCX5.js.map} +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,125 @@
1
+ #!/usr/bin/env node
2
+ import { join } from 'path';
3
+ import { push, findResources } from './push.mjs';
4
+
5
+ /**
6
+ * Deploy command - same as push but with tag="prod"
7
+ * @param {string} projectRoot - Project root directory
8
+ * @param {Object} options - Command options (same as push, but tag defaults to "prod")
9
+ */
10
+ export async function deploy(projectRoot = process.cwd(), options = {}) {
11
+ // Handle help flag
12
+ if (options.help) {
13
+ console.log(`
14
+ sunpeak deploy - Push resources to production (push with "prod" tag)
15
+
16
+ Usage:
17
+ sunpeak deploy [file] [options]
18
+
19
+ Options:
20
+ -r, --repository <owner/repo> Repository name (defaults to git remote origin)
21
+ -t, --tag <name> Additional tag(s) to assign (always includes "prod")
22
+ -h, --help Show this help message
23
+
24
+ Arguments:
25
+ file Optional JS file to deploy (e.g., dist/carousel.js)
26
+ If not provided, looks for resources in current
27
+ directory first, then falls back to dist/
28
+
29
+ Examples:
30
+ sunpeak deploy Push all resources with "prod" tag
31
+ sunpeak deploy dist/carousel.js Deploy a single resource
32
+ sunpeak deploy -r myorg/my-app Deploy to "myorg/my-app" repository
33
+ sunpeak deploy -t v1.0 Deploy with "prod" and "v1.0" tags
34
+
35
+ This command is equivalent to: sunpeak push --tag prod
36
+ `);
37
+ return;
38
+ }
39
+
40
+ // Always include "prod" tag, supplemented by any additional tags
41
+ const additionalTags = options.tags?.filter((t) => t !== 'prod') ?? [];
42
+ const deployOptions = {
43
+ ...options,
44
+ tags: ['prod', ...additionalTags],
45
+ };
46
+
47
+ console.log('Deploying to production...');
48
+ console.log();
49
+
50
+ // If no specific file provided, check current directory first, then dist/
51
+ if (!deployOptions.file) {
52
+ const cwdResources = findResources(projectRoot);
53
+ if (cwdResources.length > 0) {
54
+ // Found resources in current directory, push each one
55
+ for (const resource of cwdResources) {
56
+ await push(projectRoot, { ...deployOptions, file: resource.jsPath });
57
+ }
58
+ return;
59
+ }
60
+ // Fall back to dist/ directory (handled by push)
61
+ }
62
+
63
+ await push(projectRoot, deployOptions);
64
+ }
65
+
66
+ /**
67
+ * Parse command line arguments
68
+ */
69
+ function parseArgs(args) {
70
+ const options = { tags: [] };
71
+ let i = 0;
72
+
73
+ while (i < args.length) {
74
+ const arg = args[i];
75
+
76
+ if (arg === '--repository' || arg === '-r') {
77
+ options.repository = args[++i];
78
+ } else if (arg === '--tag' || arg === '-t') {
79
+ options.tags.push(args[++i]);
80
+ } else if (arg === '--help' || arg === '-h') {
81
+ console.log(`
82
+ sunpeak deploy - Deploy resources to production (push with "prod" tag)
83
+
84
+ Usage:
85
+ sunpeak deploy [file] [options]
86
+
87
+ Options:
88
+ -r, --repository <owner/repo> Repository name (defaults to git remote origin)
89
+ -t, --tag <name> Additional tag(s) to assign (always includes "prod")
90
+ -h, --help Show this help message
91
+
92
+ Arguments:
93
+ file Optional JS file to deploy (e.g., dist/carousel.js)
94
+ If not provided, looks for resources in current
95
+ directory first, then falls back to dist/
96
+
97
+ Examples:
98
+ sunpeak deploy Deploy all resources with "prod" tag
99
+ sunpeak deploy dist/carousel.js Deploy a single resource
100
+ sunpeak deploy -r myorg/my-app Deploy to "myorg/my-app" repository
101
+ sunpeak deploy -t v1.0 Deploy with "prod" and "v1.0" tags
102
+
103
+ This command is equivalent to: sunpeak push --tag prod
104
+ `);
105
+ process.exit(0);
106
+ } else if (!arg.startsWith('-')) {
107
+ // Positional argument - treat as file path
108
+ options.file = arg;
109
+ }
110
+
111
+ i++;
112
+ }
113
+
114
+ return options;
115
+ }
116
+
117
+ // Allow running directly
118
+ if (import.meta.url === `file://${process.argv[1]}`) {
119
+ const args = process.argv.slice(2);
120
+ const options = parseArgs(args);
121
+ deploy(process.cwd(), options).catch((error) => {
122
+ console.error('Error:', error.message);
123
+ process.exit(1);
124
+ });
125
+ }
@@ -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
+ }