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/README.md +22 -25
- package/bin/commands/dev.mjs +2 -2
- package/bin/sunpeak.js +1 -53
- package/dist/cli/commands.test.new.d.ts +1 -0
- package/dist/mcp/favicon.d.ts +1 -1
- package/dist/mcp/index.cjs +5 -5
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.js +5 -5
- package/dist/mcp/index.js.map +1 -1
- package/package.json +2 -2
- package/template/README.md +10 -8
- package/bin/commands/deploy.mjs +0 -141
- package/bin/commands/login.mjs +0 -235
- package/bin/commands/logout.mjs +0 -101
- package/bin/commands/pull.mjs +0 -279
- package/bin/commands/push.mjs +0 -465
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sunpeak",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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",
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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)
|
package/bin/commands/deploy.mjs
DELETED
|
@@ -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
|
-
}
|
package/bin/commands/login.mjs
DELETED
|
@@ -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
|
-
}
|
package/bin/commands/logout.mjs
DELETED
|
@@ -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
|
-
}
|