sunpeak 0.7.11 → 0.8.4

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 (76) hide show
  1. package/README.md +2 -1
  2. package/bin/commands/deploy.mjs +18 -8
  3. package/bin/commands/dev.mjs +60 -4
  4. package/bin/commands/login.mjs +73 -55
  5. package/bin/commands/logout.mjs +26 -12
  6. package/bin/commands/mcp.mjs +1 -1
  7. package/bin/commands/pull.mjs +60 -39
  8. package/bin/commands/push.mjs +73 -49
  9. package/bin/commands/upgrade.mjs +203 -0
  10. package/bin/sunpeak.js +68 -35
  11. package/dist/chatgpt/chatgpt-simulator.d.ts +2 -1
  12. package/dist/index.cjs +13 -14
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.js +13 -14
  15. package/dist/index.js.map +1 -1
  16. package/dist/mcp/entry.cjs +41 -9
  17. package/dist/mcp/entry.cjs.map +1 -1
  18. package/dist/mcp/entry.js +42 -10
  19. package/dist/mcp/entry.js.map +1 -1
  20. package/dist/mcp/index.cjs +1 -1
  21. package/dist/mcp/index.js +1 -1
  22. package/dist/{server-CziiHU7V.cjs → server-B9YgCQdS.cjs} +3 -2
  23. package/dist/{server-CziiHU7V.cjs.map → server-B9YgCQdS.cjs.map} +1 -1
  24. package/dist/{server-D8kyzuiq.js → server-DVmTC-SF.js} +3 -2
  25. package/dist/{server-D8kyzuiq.js.map → server-DVmTC-SF.js.map} +1 -1
  26. package/dist/style.css +62 -0
  27. package/dist/types/simulation.d.ts +1 -1
  28. package/package.json +1 -1
  29. package/template/.sunpeak/dev.tsx +78 -15
  30. package/template/.sunpeak/vite-env.d.ts +1 -0
  31. package/template/README.md +35 -20
  32. package/template/dist/albums.js +1 -1
  33. package/template/dist/albums.json +3 -2
  34. package/template/dist/carousel.js +1 -1
  35. package/template/dist/carousel.json +3 -2
  36. package/template/dist/confirmation.js +49 -0
  37. package/template/dist/confirmation.json +16 -0
  38. package/template/dist/counter.js +1 -1
  39. package/template/dist/counter.json +7 -2
  40. package/template/dist/map.js +1 -1
  41. package/template/dist/map.json +6 -3
  42. package/template/node_modules/.vite/deps/_metadata.json +19 -19
  43. package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  44. package/template/src/components/map/map-view.test.tsx +1 -1
  45. package/template/src/components/map/map-view.tsx +1 -1
  46. package/template/src/components/map/map.tsx +1 -1
  47. package/template/src/components/map/place-card.test.tsx +1 -1
  48. package/template/src/components/map/place-card.tsx +1 -1
  49. package/template/src/components/map/place-carousel.test.tsx +1 -1
  50. package/template/src/components/map/place-carousel.tsx +1 -1
  51. package/template/src/components/map/place-inspector.test.tsx +1 -1
  52. package/template/src/components/map/place-inspector.tsx +1 -1
  53. package/template/src/components/map/place-list.test.tsx +1 -1
  54. package/template/src/components/map/place-list.tsx +1 -1
  55. package/template/src/components/map/types.ts +18 -0
  56. package/template/src/resources/albums-resource.json +1 -1
  57. package/template/src/resources/carousel-resource.json +1 -1
  58. package/template/src/resources/confirmation-resource.json +12 -0
  59. package/template/src/resources/confirmation-resource.tsx +479 -0
  60. package/template/src/resources/counter-resource.json +4 -1
  61. package/template/src/resources/index.ts +39 -4
  62. package/template/src/resources/map-resource.json +7 -2
  63. package/template/src/simulations/albums-show-simulation.json +131 -0
  64. package/template/src/simulations/carousel-show-simulation.json +68 -0
  65. package/template/src/simulations/confirmation-diff-simulation.json +80 -0
  66. package/template/src/simulations/confirmation-post-simulation.json +56 -0
  67. package/template/src/simulations/confirmation-purchase-simulation.json +88 -0
  68. package/template/src/simulations/counter-show-simulation.json +20 -0
  69. package/template/src/simulations/index.ts +17 -12
  70. package/template/src/simulations/map-show-simulation.json +123 -0
  71. package/template/src/vite-env.d.ts +1 -0
  72. package/template/tsconfig.json +1 -1
  73. package/template/src/simulations/albums-simulation.ts +0 -147
  74. package/template/src/simulations/carousel-simulation.ts +0 -84
  75. package/template/src/simulations/counter-simulation.ts +0 -34
  76. package/template/src/simulations/map-simulation.ts +0 -154
package/README.md CHANGED
@@ -52,10 +52,11 @@ sunpeak is an npm package consisting of:
52
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
- 3. MCP server - Mock data MCP server for testing local Resources in the real ChatGPT.
55
+ 3. MCP server - Mock data MCP server for testing local UIs (resources) in the real ChatGPT.
56
56
  2. **The `sunpeak` framework** (`./template`). Next.js for ChatGPT Apps. This templated npm package includes:
57
57
  1. Project scaffold - Complete development setup with build, test, and mcp tooling, including the sunpeak library.
58
58
  2. UI components - Production-ready components following ChatGPT design guidelines and using OpenAI apps-sdk-ui React components.
59
+ 3. Convention over configuration - Create UIs (resources) by simply creating a `resources/NAME-resource.tsx` React file and `resources/NAME-resource.json` metadata file.
59
60
  3. **The `sunpeak` CLI** (`./bin`). Commands for managing ChatGPT Apps. Includes a client for the [sunpeak Resource Repository](https://app.sunpeak.ai/) (ECR for ChatGPT Apps). The repository helps you & your CI/CD decouple your App from your client-agnostic MCP server:
60
61
  1. Tag your app builds with version numbers and environment names (like `v1.0.0` and `prod`)
61
62
  2. `push` built Apps to a central location
@@ -1,15 +1,25 @@
1
1
  #!/usr/bin/env node
2
- import { push, findResources } from './push.mjs';
2
+ import { push, findResources, defaultDeps as pushDefaultDeps } from './push.mjs';
3
+
4
+ /**
5
+ * Default dependencies (real implementations)
6
+ */
7
+ export const defaultDeps = {
8
+ ...pushDefaultDeps,
9
+ };
3
10
 
4
11
  /**
5
12
  * Deploy command - same as push but with tag="prod"
6
13
  * @param {string} projectRoot - Project root directory
7
14
  * @param {Object} options - Command options (same as push, but tag defaults to "prod")
15
+ * @param {Object} deps - Dependencies (for testing). Uses defaultDeps if not provided.
8
16
  */
9
- export async function deploy(projectRoot = process.cwd(), options = {}) {
17
+ export async function deploy(projectRoot = process.cwd(), options = {}, deps = defaultDeps) {
18
+ const d = { ...defaultDeps, ...deps };
19
+
10
20
  // Handle help flag
11
21
  if (options.help) {
12
- console.log(`
22
+ d.console.log(`
13
23
  sunpeak deploy - Push resources to production (push with "prod" tag)
14
24
 
15
25
  Usage:
@@ -43,23 +53,23 @@ This command is equivalent to: sunpeak push --tag prod
43
53
  tags: ['prod', ...additionalTags],
44
54
  };
45
55
 
46
- console.log('Deploying to production...');
47
- console.log();
56
+ d.console.log('Deploying to production...');
57
+ d.console.log();
48
58
 
49
59
  // If no specific file provided, check current directory first, then dist/
50
60
  if (!deployOptions.file) {
51
- const cwdResources = findResources(projectRoot);
61
+ const cwdResources = findResources(projectRoot, d);
52
62
  if (cwdResources.length > 0) {
53
63
  // Found resources in current directory, push each one
54
64
  for (const resource of cwdResources) {
55
- await push(projectRoot, { ...deployOptions, file: resource.jsPath });
65
+ await push(projectRoot, { ...deployOptions, file: resource.jsPath }, d);
56
66
  }
57
67
  return;
58
68
  }
59
69
  // Fall back to dist/ directory (handled by push)
60
70
  }
61
71
 
62
- await push(projectRoot, deployOptions);
72
+ await push(projectRoot, deployOptions, d);
63
73
  }
64
74
 
65
75
  /**
@@ -1,9 +1,56 @@
1
1
  #!/usr/bin/env node
2
- import { createServer } from 'vite';
3
- import react from '@vitejs/plugin-react';
4
- import tailwindcss from '@tailwindcss/vite';
5
2
  import { existsSync } from 'fs';
6
- import { join, resolve, basename } from 'path';
3
+ import { join, resolve, basename, dirname } from 'path';
4
+ import { createRequire } from 'module';
5
+ import { pathToFileURL } from 'url';
6
+
7
+ /**
8
+ * Import a module from the project's node_modules using ESM resolution
9
+ */
10
+ async function importFromProject(require, moduleName) {
11
+ // Resolve the module's main entry to find its location
12
+ const resolvedPath = require.resolve(moduleName);
13
+
14
+ // Walk up to find package.json
15
+ const { readFileSync } = await import('fs');
16
+ let pkgDir = dirname(resolvedPath);
17
+ let pkg;
18
+ while (pkgDir !== dirname(pkgDir)) {
19
+ try {
20
+ const pkgJsonPath = join(pkgDir, 'package.json');
21
+ pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
22
+ if (pkg.name === moduleName || moduleName.startsWith(pkg.name + '/')) {
23
+ break;
24
+ }
25
+ } catch {
26
+ // No package.json at this level, keep looking
27
+ }
28
+ pkgDir = dirname(pkgDir);
29
+ }
30
+
31
+ if (!pkg) {
32
+ // Fallback to CJS resolution if we can't find package.json
33
+ return import(resolvedPath);
34
+ }
35
+
36
+ // Determine ESM entry: exports.import > exports.default > module > main
37
+ let entry = pkg.main || 'index.js';
38
+ if (pkg.exports) {
39
+ const exp = pkg.exports['.'] || pkg.exports;
40
+ if (typeof exp === 'string') {
41
+ entry = exp;
42
+ } else if (exp.import) {
43
+ entry = typeof exp.import === 'string' ? exp.import : exp.import.default;
44
+ } else if (exp.default) {
45
+ entry = exp.default;
46
+ }
47
+ } else if (pkg.module) {
48
+ entry = pkg.module;
49
+ }
50
+
51
+ const entryPath = join(pkgDir, entry);
52
+ return import(pathToFileURL(entryPath).href);
53
+ }
7
54
 
8
55
  /**
9
56
  * Start the Vite development server
@@ -18,6 +65,15 @@ export async function dev(projectRoot = process.cwd(), args = []) {
18
65
  process.exit(1);
19
66
  }
20
67
 
68
+ // Import vite and plugins from the project's node_modules (ESM)
69
+ const require = createRequire(join(projectRoot, 'package.json'));
70
+ const vite = await importFromProject(require, 'vite');
71
+ const createServer = vite.createServer;
72
+ const reactPlugin = await importFromProject(require, '@vitejs/plugin-react');
73
+ const react = reactPlugin.default;
74
+ const tailwindPlugin = await importFromProject(require, '@tailwindcss/vite');
75
+ const tailwindcss = tailwindPlugin.default;
76
+
21
77
  // Parse port from args or use default
22
78
  let port = parseInt(process.env.PORT || '6767');
23
79
  const portArgIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
@@ -15,7 +15,7 @@ const MAX_POLL_DURATION_MS = 2 * 60 * 1000; // 2 minutes max.
15
15
  /**
16
16
  * Load existing credentials if present
17
17
  */
18
- function loadCredentials() {
18
+ function loadCredentialsImpl() {
19
19
  if (!existsSync(CREDENTIALS_FILE)) {
20
20
  return null;
21
21
  }
@@ -29,18 +29,62 @@ function loadCredentials() {
29
29
  /**
30
30
  * Save credentials to disk
31
31
  */
32
- function saveCredentials(credentials) {
32
+ function saveCredentialsImpl(credentials) {
33
33
  if (!existsSync(CREDENTIALS_DIR)) {
34
34
  mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
35
35
  }
36
36
  writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 });
37
37
  }
38
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
+
39
83
  /**
40
84
  * Request a device code from the authorization server
41
85
  */
42
- async function requestDeviceCode() {
43
- const response = await fetch(`${SUNPEAK_API_URL}/oauth/device_authorization`, {
86
+ async function requestDeviceCode(deps) {
87
+ const response = await deps.fetch(`${deps.apiUrl}/oauth/device_authorization`, {
44
88
  method: 'POST',
45
89
  headers: { 'Content-Type': 'application/json' },
46
90
  });
@@ -56,13 +100,13 @@ async function requestDeviceCode() {
56
100
  /**
57
101
  * Poll for the access token
58
102
  */
59
- async function pollForToken(deviceCode) {
103
+ async function pollForToken(deviceCode, deps) {
60
104
  const startTime = Date.now();
61
105
 
62
- while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
106
+ while (Date.now() - startTime < deps.maxPollDurationMs) {
63
107
  let response;
64
108
  try {
65
- response = await fetch(`${SUNPEAK_API_URL}/oauth/token`, {
109
+ response = await deps.fetch(`${deps.apiUrl}/oauth/token`, {
66
110
  method: 'POST',
67
111
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
68
112
  body: new URLSearchParams({
@@ -72,7 +116,7 @@ async function pollForToken(deviceCode) {
72
116
  });
73
117
  } catch (err) {
74
118
  // Network error - wait and retry
75
- await sleep(POLL_INTERVAL_MS);
119
+ await deps.sleep(deps.pollIntervalMs);
76
120
  continue;
77
121
  }
78
122
 
@@ -95,13 +139,13 @@ async function pollForToken(deviceCode) {
95
139
  // Handle standard OAuth 2.0 device flow errors (expected during polling)
96
140
  if (data.error === 'authorization_pending') {
97
141
  // User hasn't authorized yet, keep polling
98
- await sleep(POLL_INTERVAL_MS);
142
+ await deps.sleep(deps.pollIntervalMs);
99
143
  continue;
100
144
  }
101
145
 
102
146
  if (data.error === 'slow_down') {
103
147
  // Server asking us to slow down, increase interval
104
- await sleep(POLL_INTERVAL_MS * 2);
148
+ await deps.sleep(deps.pollIntervalMs * 2);
105
149
  continue;
106
150
  }
107
151
 
@@ -126,50 +170,24 @@ async function pollForToken(deviceCode) {
126
170
  throw new Error('Authorization timed out. Please try again.');
127
171
  }
128
172
 
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
173
  /**
159
174
  * Main login command
175
+ * @param {Object} deps - Dependencies (for testing). Uses defaultDeps if not provided.
160
176
  */
161
- export async function login() {
177
+ export async function login(deps = defaultDeps) {
178
+ const d = { ...defaultDeps, ...deps };
179
+
162
180
  // Check if already logged in
163
- const existing = loadCredentials();
181
+ const existing = d.loadCredentials();
164
182
  if (existing?.access_token) {
165
- console.log('Already logged in. Run "sunpeak logout" first to switch accounts.');
183
+ d.console.log('Already logged in. Run "sunpeak logout" first to switch accounts.');
166
184
  return;
167
185
  }
168
186
 
169
- console.log('Starting device authorization flow...\n');
187
+ d.console.log('Starting device authorization flow...\n');
170
188
 
171
189
  // Step 1: Request device code
172
- const deviceAuth = await requestDeviceCode();
190
+ const deviceAuth = await requestDeviceCode(d);
173
191
 
174
192
  // Step 2: Open browser and display instructions
175
193
  // Prefer verification_uri_complete which has the code pre-filled
@@ -177,21 +195,21 @@ export async function login() {
177
195
  deviceAuth.verification_uri_complete ||
178
196
  `${deviceAuth.verification_uri}?user_code=${encodeURIComponent(deviceAuth.user_code)}`;
179
197
 
180
- const browserOpened = await openBrowser(authUrl);
198
+ const browserOpened = await d.openBrowser(authUrl);
181
199
 
182
200
  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}`);
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}`);
186
204
  } 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}`);
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}`);
190
208
  }
191
- console.log('\nWaiting for authorization...');
209
+ d.console.log('\nWaiting for authorization...');
192
210
 
193
211
  // Step 3: Poll for token
194
- const tokenResponse = await pollForToken(deviceAuth.device_code);
212
+ const tokenResponse = await pollForToken(deviceAuth.device_code, d);
195
213
 
196
214
  // Step 4: Save credentials
197
215
  const credentials = {
@@ -203,9 +221,9 @@ export async function login() {
203
221
  created_at: Date.now(),
204
222
  };
205
223
 
206
- saveCredentials(credentials);
224
+ d.saveCredentials(credentials);
207
225
 
208
- console.log('\n✓ Successfully logged in to Sunpeak!');
226
+ d.console.log('\n✓ Successfully logged in to Sunpeak!');
209
227
  }
210
228
 
211
229
  // Allow running directly
@@ -10,7 +10,7 @@ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'credentials.json');
10
10
  /**
11
11
  * Load existing credentials if present
12
12
  */
13
- function loadCredentials() {
13
+ function loadCredentialsImpl() {
14
14
  if (!existsSync(CREDENTIALS_FILE)) {
15
15
  return null;
16
16
  }
@@ -24,18 +24,29 @@ function loadCredentials() {
24
24
  /**
25
25
  * Delete credentials file
26
26
  */
27
- function deleteCredentials() {
27
+ function deleteCredentialsImpl() {
28
28
  if (existsSync(CREDENTIALS_FILE)) {
29
29
  unlinkSync(CREDENTIALS_FILE);
30
30
  }
31
31
  }
32
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
+
33
44
  /**
34
45
  * Revoke the access token on the server
35
46
  */
36
- async function revokeToken(accessToken) {
47
+ async function revokeToken(accessToken, deps) {
37
48
  try {
38
- const response = await fetch(`${SUNPEAK_API_URL}/oauth/revoke`, {
49
+ const response = await deps.fetch(`${deps.apiUrl}/oauth/revoke`, {
39
50
  method: 'POST',
40
51
  headers: {
41
52
  Authorization: `Bearer ${accessToken}`,
@@ -54,27 +65,30 @@ async function revokeToken(accessToken) {
54
65
 
55
66
  /**
56
67
  * Main logout command
68
+ * @param {Object} deps - Dependencies (for testing). Uses defaultDeps if not provided.
57
69
  */
58
- export async function logout() {
59
- const credentials = loadCredentials();
70
+ export async function logout(deps = defaultDeps) {
71
+ const d = { ...defaultDeps, ...deps };
72
+
73
+ const credentials = d.loadCredentials();
60
74
 
61
75
  if (!credentials?.access_token) {
62
- console.log('Not logged in.');
76
+ d.console.log('Not logged in.');
63
77
  return;
64
78
  }
65
79
 
66
- console.log('Logging out...');
80
+ d.console.log('Logging out...');
67
81
 
68
82
  // Revoke token on server
69
- const revoked = await revokeToken(credentials.access_token);
83
+ const revoked = await revokeToken(credentials.access_token, d);
70
84
 
71
85
  // Always delete local credentials regardless of revocation result
72
- deleteCredentials();
86
+ d.deleteCredentials();
73
87
 
74
88
  if (revoked) {
75
- console.log('✓ Successfully logged out of Sunpeak.');
89
+ d.console.log('✓ Successfully logged out of Sunpeak.');
76
90
  } else {
77
- console.log('✓ Logged out locally. (Server token revocation may have failed)');
91
+ d.console.log('✓ Logged out locally. (Server token revocation may have failed)');
78
92
  }
79
93
  }
80
94
 
@@ -29,7 +29,7 @@ export async function mcp(projectRoot = process.cwd(), args = []) {
29
29
  // Add inline nodemon configuration
30
30
  nodemonArgs.push(
31
31
  '--watch', 'src',
32
- '--ext', 'ts,tsx,js,jsx,css',
32
+ '--ext', 'ts,tsx,js,jsx,css,json',
33
33
  '--ignore', 'dist',
34
34
  '--ignore', 'node_modules',
35
35
  '--ignore', '.tmp',
@@ -10,7 +10,7 @@ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'credentials.json');
10
10
  /**
11
11
  * Load credentials from disk
12
12
  */
13
- function loadCredentials() {
13
+ function loadCredentialsImpl() {
14
14
  if (!existsSync(CREDENTIALS_FILE)) {
15
15
  return null;
16
16
  }
@@ -21,16 +21,32 @@ function loadCredentials() {
21
21
  }
22
22
  }
23
23
 
24
+ /**
25
+ * Default dependencies (real implementations)
26
+ */
27
+ export const defaultDeps = {
28
+ fetch: globalThis.fetch,
29
+ loadCredentials: loadCredentialsImpl,
30
+ existsSync,
31
+ mkdirSync,
32
+ writeFileSync,
33
+ console,
34
+ process,
35
+ apiUrl: SUNPEAK_API_URL,
36
+ };
37
+
24
38
  /**
25
39
  * Lookup resources by tag and repository
26
40
  * @returns {Promise<Array>} Array of matching resources
27
41
  */
28
- async function lookupResources(tag, repository, accessToken, name = null) {
42
+ async function lookupResources(tag, repository, accessToken, name = null, deps = defaultDeps) {
43
+ const d = { ...defaultDeps, ...deps };
44
+
29
45
  const params = new URLSearchParams({ tag, repository });
30
46
  if (name) {
31
47
  params.set('name', name);
32
48
  }
33
- const response = await fetch(`${SUNPEAK_API_URL}/api/v1/resources/lookup?${params}`, {
49
+ const response = await d.fetch(`${d.apiUrl}/api/v1/resources/lookup?${params}`, {
34
50
  headers: {
35
51
  Authorization: `Bearer ${accessToken}`,
36
52
  },
@@ -48,13 +64,15 @@ async function lookupResources(tag, repository, accessToken, name = null) {
48
64
  /**
49
65
  * Download the JS file for a resource
50
66
  */
51
- async function downloadJsFile(resource) {
67
+ async function downloadJsFile(resource, deps = defaultDeps) {
68
+ const d = { ...defaultDeps, ...deps };
69
+
52
70
  if (!resource.js_file?.url) {
53
71
  throw new Error('Resource has no JS file attached');
54
72
  }
55
73
 
56
74
  // The URL is a pre-signed S3 URL, no additional auth needed
57
- const response = await fetch(resource.js_file.url);
75
+ const response = await d.fetch(resource.js_file.url);
58
76
 
59
77
  if (!response.ok) {
60
78
  throw new Error(`Failed to download JS file: HTTP ${response.status}`);
@@ -71,11 +89,14 @@ async function downloadJsFile(resource) {
71
89
  * @param {string} options.tag - Tag name to pull (required)
72
90
  * @param {string} options.name - Resource name to filter by (optional)
73
91
  * @param {string} options.output - Output directory (optional, defaults to current directory)
92
+ * @param {Object} deps - Dependencies (for testing). Uses defaultDeps if not provided.
74
93
  */
75
- export async function pull(projectRoot = process.cwd(), options = {}) {
94
+ export async function pull(projectRoot = process.cwd(), options = {}, deps = defaultDeps) {
95
+ const d = { ...defaultDeps, ...deps };
96
+
76
97
  // Handle help flag
77
98
  if (options.help) {
78
- console.log(`
99
+ d.console.log(`
79
100
  sunpeak pull - Pull resources from the Sunpeak repository
80
101
 
81
102
  Usage:
@@ -97,74 +118,74 @@ Examples:
97
118
  }
98
119
 
99
120
  // Check credentials
100
- const credentials = loadCredentials();
121
+ const credentials = d.loadCredentials();
101
122
  if (!credentials?.access_token) {
102
- console.error('Error: Not logged in. Run "sunpeak login" first.');
103
- process.exit(1);
123
+ d.console.error('Error: Not logged in. Run "sunpeak login" first.');
124
+ d.process.exit(1);
104
125
  }
105
126
 
106
127
  // Require repository
107
128
  if (!options.repository) {
108
- console.error('Error: Repository is required. Use --repository or -r to specify a repository.');
109
- console.error('Example: sunpeak pull -r myorg/my-app -t prod');
110
- process.exit(1);
129
+ d.console.error('Error: Repository is required. Use --repository or -r to specify a repository.');
130
+ d.console.error('Example: sunpeak pull -r myorg/my-app -t prod');
131
+ d.process.exit(1);
111
132
  }
112
133
 
113
134
  // Require tag
114
135
  if (!options.tag) {
115
- console.error('Error: Tag is required. Use --tag or -t to specify a tag.');
116
- console.error('Example: sunpeak pull -r myorg/my-app -t prod');
117
- process.exit(1);
136
+ d.console.error('Error: Tag is required. Use --tag or -t to specify a tag.');
137
+ d.console.error('Example: sunpeak pull -r myorg/my-app -t prod');
138
+ d.process.exit(1);
118
139
  }
119
140
 
120
141
  const repository = options.repository;
121
142
 
122
143
  const nameFilter = options.name ? ` with name "${options.name}"` : '';
123
- console.log(`Pulling resources from repository "${repository}" with tag "${options.tag}"${nameFilter}...`);
124
- console.log();
144
+ d.console.log(`Pulling resources from repository "${repository}" with tag "${options.tag}"${nameFilter}...`);
145
+ d.console.log();
125
146
 
126
147
  try {
127
148
  // Lookup resources
128
- const resources = await lookupResources(options.tag, repository, credentials.access_token, options.name);
149
+ const resources = await lookupResources(options.tag, repository, credentials.access_token, options.name, d);
129
150
 
130
151
  if (!resources || resources.length === 0) {
131
- console.error('Error: No resources found matching the criteria.');
132
- process.exit(1);
152
+ d.console.error('Error: No resources found matching the criteria.');
153
+ d.process.exit(1);
133
154
  }
134
155
 
135
- console.log(`Found ${resources.length} resource(s):\n`);
156
+ d.console.log(`Found ${resources.length} resource(s):\n`);
136
157
 
137
158
  // Determine output directory
138
159
  const outputDir = options.output || projectRoot;
139
160
 
140
161
  // Create output directory if it doesn't exist
141
- if (!existsSync(outputDir)) {
142
- mkdirSync(outputDir, { recursive: true });
162
+ if (!d.existsSync(outputDir)) {
163
+ d.mkdirSync(outputDir, { recursive: true });
143
164
  }
144
165
 
145
166
  // Process each resource
146
167
  for (const resource of resources) {
147
- console.log(`Resource: ${resource.name}`);
148
- console.log(` Title: ${resource.title}`);
149
- console.log(` URI: ${resource.uri}`);
150
- console.log(` Tags: ${resource.tags?.join(', ') || 'none'}`);
151
- console.log(` Created: ${resource.created_at}`);
168
+ d.console.log(`Resource: ${resource.name}`);
169
+ d.console.log(` Title: ${resource.title}`);
170
+ d.console.log(` URI: ${resource.uri}`);
171
+ d.console.log(` Tags: ${resource.tags?.join(', ') || 'none'}`);
172
+ d.console.log(` Created: ${resource.created_at}`);
152
173
 
153
174
  if (!resource.js_file) {
154
- console.log(` ⚠ Skipping: No JS file attached.\n`);
175
+ d.console.log(` ⚠ Skipping: No JS file attached.\n`);
155
176
  continue;
156
177
  }
157
178
 
158
179
  // Download the JS file
159
- console.log(` Downloading JS file...`);
160
- const jsContent = await downloadJsFile(resource);
180
+ d.console.log(` Downloading JS file...`);
181
+ const jsContent = await downloadJsFile(resource, d);
161
182
 
162
183
  const outputFile = join(outputDir, `${resource.name}.js`);
163
184
  const metaFile = join(outputDir, `${resource.name}.json`);
164
185
 
165
186
  // Write the JS file
166
- writeFileSync(outputFile, jsContent);
167
- console.log(` ✓ Saved ${resource.name}.js`);
187
+ d.writeFileSync(outputFile, jsContent);
188
+ d.console.log(` ✓ Saved ${resource.name}.js`);
168
189
 
169
190
  // Write metadata JSON
170
191
  const meta = {
@@ -181,14 +202,14 @@ Examples:
181
202
  },
182
203
  },
183
204
  };
184
- writeFileSync(metaFile, JSON.stringify(meta, null, 2));
185
- console.log(` ✓ Saved ${resource.name}.json\n`);
205
+ d.writeFileSync(metaFile, JSON.stringify(meta, null, 2));
206
+ d.console.log(` ✓ Saved ${resource.name}.json\n`);
186
207
  }
187
208
 
188
- console.log(`✓ Successfully pulled ${resources.length} resource(s) to ${outputDir}`);
209
+ d.console.log(`✓ Successfully pulled ${resources.length} resource(s) to ${outputDir}`);
189
210
  } catch (error) {
190
- console.error(`Error: ${error.message}`);
191
- process.exit(1);
211
+ d.console.error(`Error: ${error.message}`);
212
+ d.process.exit(1);
192
213
  }
193
214
  }
194
215