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.
- package/README.md +3 -3
- package/bin/commands/build.mjs +22 -5
- package/bin/commands/deploy.mjs +108 -0
- package/bin/commands/login.mjs +217 -0
- package/bin/commands/logout.mjs +87 -0
- package/bin/commands/pull.mjs +254 -0
- package/bin/commands/push.mjs +347 -0
- package/bin/sunpeak.js +85 -2
- package/dist/mcp/entry.cjs +2 -2
- package/dist/mcp/entry.cjs.map +1 -1
- package/dist/mcp/entry.js +2 -2
- package/dist/mcp/entry.js.map +1 -1
- package/dist/mcp/index.cjs +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/{server-CQGbJWbk.cjs → server-BOYwNazb.cjs} +25 -26
- package/dist/{server-CQGbJWbk.cjs.map → server-BOYwNazb.cjs.map} +1 -1
- package/dist/{server-DGCvp1RA.js → server-C6vMGV6H.js} +25 -26
- package/dist/{server-DGCvp1RA.js.map → server-C6vMGV6H.js.map} +1 -1
- package/package.json +1 -1
- package/template/.sunpeak/dev.tsx +8 -10
- package/template/README.md +4 -4
- package/template/dist/albums.json +15 -0
- package/template/dist/carousel.json +15 -0
- package/template/dist/counter.json +10 -0
- package/template/dist/map.json +19 -0
- package/template/index.html +1 -1
- package/template/node_modules/.vite/deps/_metadata.json +19 -19
- package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/template/src/components/map/map-view.test.tsx +146 -0
- package/template/src/components/map/place-card.test.tsx +76 -0
- package/template/src/components/map/place-carousel.test.tsx +84 -0
- package/template/src/components/map/place-inspector.test.tsx +91 -0
- package/template/src/components/map/place-list.test.tsx +97 -0
- package/template/src/resources/albums-resource.json +12 -0
- package/template/src/resources/carousel-resource.json +12 -0
- package/template/src/resources/counter-resource.json +9 -0
- package/template/src/resources/map-resource.json +13 -0
- package/template/src/simulations/albums-simulation.ts +3 -15
- package/template/src/simulations/carousel-simulation.ts +3 -15
- package/template/src/simulations/counter-simulation.ts +3 -15
- package/template/src/simulations/map-simulation.ts +5 -28
- package/template/src/simulations/widget-config.ts +0 -42
- /package/template/dist/{chatgpt/albums.js → albums.js} +0 -0
- /package/template/dist/{chatgpt/carousel.js → carousel.js} +0 -0
- /package/template/dist/{chatgpt/counter.js → counter.js} +0 -0
- /package/template/dist/{chatgpt/map.js → map.js} +0 -0
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
[](https://www.typescriptlang.org/)
|
|
16
16
|
[](https://reactjs.org/)
|
|
17
17
|
|
|
18
|
-
The
|
|
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
|
|
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
|
|
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.
|
package/bin/commands/build.mjs
CHANGED
|
@@ -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
|
|
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/
|
|
198
|
-
console.log('\nCopying built files to dist
|
|
199
|
-
|
|
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
|
+
}
|