sunpeak 0.13.12 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sunpeak",
3
- "version": "0.13.12",
4
- "description": "The ChatGPT App framework. Quickstart, build, & test your ChatGPT App locally!",
3
+ "version": "0.14.3",
4
+ "description": "Local-first MCP Apps framework. Quickstart, build, test, and ship your Claude or ChatGPT App!",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
@@ -15,16 +15,11 @@ That's it! Edit the resource files in [./src/resources/](./src/resources/) to bu
15
15
  ## Commands
16
16
 
17
17
  ```bash
18
- # Core commands:
19
18
  pnpm test # Run tests with Vitest.
20
19
  pnpm test:e2e # Run end-to-end tests with Playwright.
21
20
  sunpeak dev # Start dev server + MCP endpoint.
22
21
  sunpeak build # Build all resources for production.
23
-
24
- # sunpeak repository (think ECR for MCP Apps):
25
- sunpeak login # Authenticate with the sunpeak repository.
26
- sunpeak push # Push built resources to the sunpeak repository.
27
- sunpeak pull # Pull built resources from the sunpeak repository (for your prod MCP server).
22
+ sunpeak upgrade # Upgrade sunpeak to latest version.
28
23
  ```
29
24
 
30
25
  The template includes a minimal test setup with Vitest. You can add additional tooling (linting, formatting, type-checking) as needed for your project.
@@ -54,7 +49,7 @@ Test your app directly in ChatGPT using the built-in MCP endpoint (starts automa
54
49
  sunpeak dev
55
50
 
56
51
  # In another terminal, run a tunnel. For example:
57
- ngrok http 6766
52
+ ngrok http 8000
58
53
  ```
59
54
 
60
55
  You can then connect to the tunnel forwarding URL at the `/mcp` path from ChatGPT **in developer mode** to see your UI in action: `User > Settings > Apps & Connectors > Create`
@@ -88,7 +83,6 @@ Each resource folder contains:
88
83
  - **`.json` file**: Resource metadata (extracted from the `resource` export in your `.tsx` file) with a generated `uri` for cache-busting
89
84
 
90
85
  Host these files and reference them as resources in your production MCP server.
91
- Use the sunpeak resource repository for built-in resource hosting.
92
86
 
93
87
  ## Add a new UI (Resource)
94
88
 
@@ -105,6 +99,14 @@ Only the resource file (`.tsx`) is required to generate a production build and s
105
99
 
106
100
  Create the simulation file(s) in `tests/simulations/` if you want to preview your resource in `sunpeak dev`.
107
101
 
102
+ ## Coding Agent Skill
103
+
104
+ Install the `create-sunpeak-app` skill to give your coding agent built-in knowledge of sunpeak patterns, hooks, simulation files, and testing conventions:
105
+
106
+ ```bash
107
+ npx skills add Sunpeak-AI/sunpeak@create-sunpeak-app
108
+ ```
109
+
108
110
  ## Resources
109
111
 
110
112
  - [sunpeak](https://github.com/Sunpeak-AI/sunpeak)
@@ -1,141 +0,0 @@
1
- #!/usr/bin/env node
2
- import { join } from 'path';
3
- import { push, findResources, defaultDeps as pushDefaultDeps } from './push.mjs';
4
-
5
- /**
6
- * Default dependencies (real implementations)
7
- */
8
- export const defaultDeps = {
9
- ...pushDefaultDeps,
10
- };
11
-
12
- /**
13
- * Deploy command - same as push but with tag="prod"
14
- * @param {string} projectRoot - Project root directory
15
- * @param {Object} options - Command options (same as push, but tag defaults to "prod")
16
- * @param {Object} deps - Dependencies (for testing). Uses defaultDeps if not provided.
17
- */
18
- export async function deploy(projectRoot = process.cwd(), options = {}, deps = defaultDeps) {
19
- const d = { ...defaultDeps, ...deps };
20
-
21
- // Handle help flag
22
- if (options.help) {
23
- d.console.log(`
24
- sunpeak deploy - Push resources to production (push with "prod" tag)
25
-
26
- Usage:
27
- sunpeak deploy [directory] [options]
28
-
29
- Options:
30
- -r, --repository <owner/repo> Repository name (defaults to git remote origin)
31
- -t, --tag <name> Additional tag(s) to assign (always includes "prod")
32
- --no-simulations Skip pushing simulations, only push resources
33
- -h, --help Show this help message
34
-
35
- Arguments:
36
- directory Optional resource directory to deploy (e.g., dist/carousel)
37
- If not provided, looks for resources in current
38
- directory first, then falls back to dist/
39
-
40
- Examples:
41
- sunpeak deploy Deploy all resources with "prod" tag
42
- sunpeak deploy dist/carousel Deploy a single resource
43
- sunpeak deploy -r myorg/my-app Deploy to "myorg/my-app" repository
44
- sunpeak deploy -t v1.0 Deploy with "prod" and "v1.0" tags
45
- sunpeak deploy --no-simulations Deploy without simulations
46
-
47
- This command is equivalent to: sunpeak push --tag prod
48
- `);
49
- return;
50
- }
51
-
52
- // Always include "prod" tag, supplemented by any additional tags
53
- const additionalTags = options.tags?.filter((t) => t !== 'prod') ?? [];
54
- const deployOptions = {
55
- ...options,
56
- tags: ['prod', ...additionalTags],
57
- };
58
-
59
- d.console.log('Deploying to production...');
60
- d.console.log();
61
-
62
- // If no specific directory provided, check current directory first, then dist/
63
- if (!deployOptions.dir) {
64
- const cwdResources = findResources(projectRoot, join(projectRoot, 'tests/simulations'), d);
65
- if (cwdResources.length > 0) {
66
- // Found resources in current directory, push each one
67
- for (const resource of cwdResources) {
68
- await push(projectRoot, { ...deployOptions, dir: resource.dir }, d);
69
- }
70
- return;
71
- }
72
- // Fall back to dist/ directory (handled by push)
73
- }
74
-
75
- await push(projectRoot, deployOptions, d);
76
- }
77
-
78
- /**
79
- * Parse command line arguments
80
- */
81
- export function parseArgs(args) {
82
- const options = { tags: [] };
83
- let i = 0;
84
-
85
- while (i < args.length) {
86
- const arg = args[i];
87
-
88
- if (arg === '--repository' || arg === '-r') {
89
- options.repository = args[++i];
90
- } else if (arg === '--tag' || arg === '-t') {
91
- options.tags.push(args[++i]);
92
- } else if (arg === '--no-simulations') {
93
- options.noSimulations = true;
94
- } else if (arg === '--help' || arg === '-h') {
95
- console.log(`
96
- sunpeak deploy - Deploy resources to production (push with "prod" tag)
97
-
98
- Usage:
99
- sunpeak deploy [directory] [options]
100
-
101
- Options:
102
- -r, --repository <owner/repo> Repository name (defaults to git remote origin)
103
- -t, --tag <name> Additional tag(s) to assign (always includes "prod")
104
- --no-simulations Skip pushing simulations, only push resources
105
- -h, --help Show this help message
106
-
107
- Arguments:
108
- directory Optional resource directory to deploy (e.g., dist/carousel)
109
- If not provided, looks for resources in current
110
- directory first, then falls back to dist/
111
-
112
- Examples:
113
- sunpeak deploy Deploy all resources with "prod" tag
114
- sunpeak deploy dist/carousel Deploy a single resource
115
- sunpeak deploy -r myorg/my-app Deploy to "myorg/my-app" repository
116
- sunpeak deploy -t v1.0 Deploy with "prod" and "v1.0" tags
117
- sunpeak deploy --no-simulations Deploy without simulations
118
-
119
- This command is equivalent to: sunpeak push --tag prod
120
- `);
121
- process.exit(0);
122
- } else if (!arg.startsWith('-')) {
123
- // Positional argument - treat as directory path
124
- options.dir = arg;
125
- }
126
-
127
- i++;
128
- }
129
-
130
- return options;
131
- }
132
-
133
- // Allow running directly
134
- if (import.meta.url === `file://${process.argv[1]}`) {
135
- const args = process.argv.slice(2);
136
- const options = parseArgs(args);
137
- deploy(process.cwd(), options).catch((error) => {
138
- console.error('Error:', error.message);
139
- process.exit(1);
140
- });
141
- }
@@ -1,235 +0,0 @@
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 loadCredentialsImpl() {
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 saveCredentialsImpl(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
- * Open a URL in the default browser
41
- * Returns true if successful, false otherwise
42
- */
43
- function openBrowserImpl(url) {
44
- return new Promise((resolve) => {
45
- const os = platform();
46
- let command;
47
-
48
- // Platform-specific commands to open URLs
49
- if (os === 'darwin') {
50
- command = `open "${url}"`;
51
- } else if (os === 'win32') {
52
- command = `start "" "${url}"`;
53
- } else {
54
- // Linux and other Unix-like systems
55
- command = `xdg-open "${url}"`;
56
- }
57
-
58
- exec(command, (error) => {
59
- resolve(!error);
60
- });
61
- });
62
- }
63
-
64
- function sleep(ms) {
65
- return new Promise((resolve) => setTimeout(resolve, ms));
66
- }
67
-
68
- /**
69
- * Default dependencies (real implementations)
70
- */
71
- export const defaultDeps = {
72
- fetch: globalThis.fetch,
73
- loadCredentials: loadCredentialsImpl,
74
- saveCredentials: saveCredentialsImpl,
75
- openBrowser: openBrowserImpl,
76
- console,
77
- sleep,
78
- apiUrl: SUNPEAK_API_URL,
79
- pollIntervalMs: POLL_INTERVAL_MS,
80
- maxPollDurationMs: MAX_POLL_DURATION_MS,
81
- };
82
-
83
- /**
84
- * Request a device code from the authorization server
85
- */
86
- async function requestDeviceCode(deps) {
87
- const response = await deps.fetch(`${deps.apiUrl}/oauth/device_authorization`, {
88
- method: 'POST',
89
- headers: { 'Content-Type': 'application/json' },
90
- });
91
-
92
- if (!response.ok) {
93
- const text = await response.text();
94
- throw new Error(`Failed to request device code: ${response.status} ${text}`);
95
- }
96
-
97
- return response.json();
98
- }
99
-
100
- /**
101
- * Poll for the access token
102
- */
103
- async function pollForToken(deviceCode, deps) {
104
- const startTime = Date.now();
105
-
106
- while (Date.now() - startTime < deps.maxPollDurationMs) {
107
- let response;
108
- try {
109
- response = await deps.fetch(`${deps.apiUrl}/oauth/token`, {
110
- method: 'POST',
111
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
112
- body: new URLSearchParams({
113
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
114
- device_code: deviceCode,
115
- }),
116
- });
117
- } catch (err) {
118
- // Network error - wait and retry
119
- await deps.sleep(deps.pollIntervalMs);
120
- continue;
121
- }
122
-
123
- let data;
124
- let responseText;
125
- try {
126
- responseText = await response.text();
127
- data = JSON.parse(responseText);
128
- } catch {
129
- // Non-JSON response - this is unexpected, throw with details
130
- throw new Error(
131
- `Server returned unexpected response (${response.status}): ${responseText?.slice(0, 200) || 'empty response'}`
132
- );
133
- }
134
-
135
- if (response.ok && data.access_token) {
136
- return data;
137
- }
138
-
139
- // Handle standard OAuth 2.0 device flow errors (expected during polling)
140
- if (data.error === 'authorization_pending') {
141
- // User hasn't authorized yet, keep polling
142
- await deps.sleep(deps.pollIntervalMs);
143
- continue;
144
- }
145
-
146
- if (data.error === 'slow_down') {
147
- // Server asking us to slow down, increase interval
148
- await deps.sleep(deps.pollIntervalMs * 2);
149
- continue;
150
- }
151
-
152
- if (data.error === 'access_denied') {
153
- throw new Error('Authorization denied by user');
154
- }
155
-
156
- if (data.error === 'expired_token') {
157
- throw new Error('Device code expired. Please try again.');
158
- }
159
-
160
- // If response was OK but no access_token, something is wrong with the response
161
- if (response.ok) {
162
- throw new Error('Invalid token response from server');
163
- }
164
-
165
- // Unknown error - include status code for debugging
166
- const errorMessage = data.error_description || data.error || 'Unknown error';
167
- throw new Error(`Authorization failed (${response.status}): ${errorMessage}`);
168
- }
169
-
170
- throw new Error('Authorization timed out. Please try again.');
171
- }
172
-
173
- /**
174
- * Main login command
175
- * @param {Object} deps - Dependencies (for testing). Uses defaultDeps if not provided.
176
- */
177
- export async function login(deps = defaultDeps) {
178
- const d = { ...defaultDeps, ...deps };
179
-
180
- // Check if already logged in
181
- const existing = d.loadCredentials();
182
- if (existing?.access_token) {
183
- d.console.log('Already logged in. Run "sunpeak logout" first to switch accounts.');
184
- return;
185
- }
186
-
187
- d.console.log('Starting device authorization flow...\n');
188
-
189
- // Step 1: Request device code
190
- const deviceAuth = await requestDeviceCode(d);
191
-
192
- // Step 2: Open browser and display instructions
193
- // Prefer verification_uri_complete which has the code pre-filled
194
- const authUrl =
195
- deviceAuth.verification_uri_complete ||
196
- `${deviceAuth.verification_uri}?user_code=${encodeURIComponent(deviceAuth.user_code)}`;
197
-
198
- const browserOpened = await d.openBrowser(authUrl);
199
-
200
- if (browserOpened) {
201
- d.console.log('Opening browser for authentication...\n');
202
- d.console.log(`If the browser didn't open, visit: ${deviceAuth.verification_uri}`);
203
- d.console.log(`And enter code: ${deviceAuth.user_code}`);
204
- } else {
205
- d.console.log('To complete login, please:');
206
- d.console.log(` 1. Visit: ${deviceAuth.verification_uri}`);
207
- d.console.log(` 2. Enter code: ${deviceAuth.user_code}`);
208
- }
209
- d.console.log('\nWaiting for authorization...');
210
-
211
- // Step 3: Poll for token
212
- const tokenResponse = await pollForToken(deviceAuth.device_code, d);
213
-
214
- // Step 4: Save credentials
215
- const credentials = {
216
- access_token: tokenResponse.access_token,
217
- token_type: tokenResponse.token_type || 'Bearer',
218
- expires_at: tokenResponse.expires_in
219
- ? Date.now() + tokenResponse.expires_in * 1000
220
- : null,
221
- created_at: Date.now(),
222
- };
223
-
224
- d.saveCredentials(credentials);
225
-
226
- d.console.log('\n✓ Successfully logged in to Sunpeak!');
227
- }
228
-
229
- // Allow running directly
230
- if (import.meta.url === `file://${process.argv[1]}`) {
231
- login().catch((error) => {
232
- console.error('Error:', error.message);
233
- process.exit(1);
234
- });
235
- }
@@ -1,101 +0,0 @@
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 loadCredentialsImpl() {
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 deleteCredentialsImpl() {
28
- if (existsSync(CREDENTIALS_FILE)) {
29
- unlinkSync(CREDENTIALS_FILE);
30
- }
31
- }
32
-
33
- /**
34
- * Default dependencies (real implementations)
35
- */
36
- export const defaultDeps = {
37
- fetch: globalThis.fetch,
38
- loadCredentials: loadCredentialsImpl,
39
- deleteCredentials: deleteCredentialsImpl,
40
- console,
41
- apiUrl: SUNPEAK_API_URL,
42
- };
43
-
44
- /**
45
- * Revoke the access token on the server
46
- */
47
- async function revokeToken(accessToken, deps) {
48
- try {
49
- const response = await deps.fetch(`${deps.apiUrl}/oauth/revoke`, {
50
- method: 'POST',
51
- headers: {
52
- Authorization: `Bearer ${accessToken}`,
53
- 'Content-Type': 'application/json',
54
- },
55
- });
56
-
57
- // 200 OK means success, but we also accept other success codes
58
- return response.ok;
59
- } catch {
60
- // Network error - token may still be valid on server
61
- // but we'll clean up locally anyway
62
- return false;
63
- }
64
- }
65
-
66
- /**
67
- * Main logout command
68
- * @param {Object} deps - Dependencies (for testing). Uses defaultDeps if not provided.
69
- */
70
- export async function logout(deps = defaultDeps) {
71
- const d = { ...defaultDeps, ...deps };
72
-
73
- const credentials = d.loadCredentials();
74
-
75
- if (!credentials?.access_token) {
76
- d.console.log('Not logged in.');
77
- return;
78
- }
79
-
80
- d.console.log('Logging out...');
81
-
82
- // Revoke token on server
83
- const revoked = await revokeToken(credentials.access_token, d);
84
-
85
- // Always delete local credentials regardless of revocation result
86
- d.deleteCredentials();
87
-
88
- if (revoked) {
89
- d.console.log('✓ Successfully logged out of Sunpeak.');
90
- } else {
91
- d.console.log('✓ Logged out locally. (Server token revocation may have failed)');
92
- }
93
- }
94
-
95
- // Allow running directly
96
- if (import.meta.url === `file://${process.argv[1]}`) {
97
- logout().catch((error) => {
98
- console.error('Error:', error.message);
99
- process.exit(1);
100
- });
101
- }