overlord-cli 0.2605101225.0
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 +128 -0
- package/bin/_cli/attach.mjs +357 -0
- package/bin/_cli/auth.mjs +696 -0
- package/bin/_cli/cli-update.mjs +119 -0
- package/bin/_cli/credentials.mjs +723 -0
- package/bin/_cli/index.mjs +170 -0
- package/bin/_cli/launcher.mjs +474 -0
- package/bin/_cli/new-ticket.mjs +306 -0
- package/bin/_cli/postinstall.mjs +43 -0
- package/bin/_cli/protocol.mjs +1799 -0
- package/bin/_cli/setup.mjs +1634 -0
- package/bin/_cli/ticket.mjs +55 -0
- package/bin/_cli/tickets.mjs +121 -0
- package/bin/_cli/version.mjs +7 -0
- package/bin/ovld.mjs +13 -0
- package/package.json +36 -0
- package/plugins/claude/.claude-plugin/plugin.json +34 -0
- package/plugins/claude/README.md +42 -0
- package/plugins/claude/commands/connect.md +19 -0
- package/plugins/claude/commands/create.md +17 -0
- package/plugins/claude/commands/load.md +19 -0
- package/plugins/claude/commands/spawn.md +17 -0
- package/plugins/claude/hooks/hooks.json +15 -0
- package/plugins/claude/scripts/permission-hook.sh +21 -0
- package/plugins/claude/skills/overlord-ticket/SKILL.md +164 -0
- package/plugins/cursor/.cursor-plugin/plugin.json +14 -0
- package/plugins/cursor/commands/connect.md +3 -0
- package/plugins/cursor/commands/create.md +11 -0
- package/plugins/cursor/commands/load.md +3 -0
- package/plugins/cursor/commands/spawn.md +3 -0
- package/plugins/cursor/mcp.json +8 -0
- package/plugins/cursor/rules/overlord-local.mdc +10 -0
- package/plugins/cursor/scripts/overlord-mcp.mjs +150 -0
- package/plugins/cursor/skills/overlord-ticket/SKILL.md +49 -0
- package/plugins/overlord/.codex-plugin/plugin.json +35 -0
- package/plugins/overlord/.mcp.json +11 -0
- package/plugins/overlord/README.md +42 -0
- package/plugins/overlord/assets/icon.png +0 -0
- package/plugins/overlord/assets/logo.png +0 -0
- package/plugins/overlord/assets/screenshot.svg +28 -0
- package/plugins/overlord/scripts/overlord-mcp.mjs +688 -0
- package/plugins/overlord/skills/overlord-ticket/SKILL.md +146 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* global console, fetch, process, setTimeout, URL, URLSearchParams */
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
buildAuthHeaders,
|
|
10
|
+
clearCredentials,
|
|
11
|
+
getDefaultOverlordUrl,
|
|
12
|
+
getAuthStatus,
|
|
13
|
+
loadCredentials,
|
|
14
|
+
loadRuntime,
|
|
15
|
+
repairCredentials,
|
|
16
|
+
saveCredentials
|
|
17
|
+
} from './credentials.mjs';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_CLI_REDIRECT_URI = 'http://127.0.0.1:45619/callback';
|
|
20
|
+
const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS = 5;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// PKCE helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function generateCodeVerifier() {
|
|
27
|
+
return crypto.randomBytes(96).toString('base64url');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function generateCodeChallenge(verifier) {
|
|
31
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function generateState() {
|
|
35
|
+
return crypto.randomBytes(16).toString('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Redirect + callback listener
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function parseLoopbackRedirectUri(rawValue) {
|
|
43
|
+
const value = String(rawValue ?? '').trim();
|
|
44
|
+
if (!value) {
|
|
45
|
+
throw new Error('OAuth redirect URI is missing.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = new URL(value);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error(`Invalid OAuth redirect URI: ${value}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (parsed.protocol !== 'http:') {
|
|
56
|
+
throw new Error('OAuth redirect URI must use http:// for loopback callbacks.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
|
|
60
|
+
throw new Error('OAuth redirect URI host must be 127.0.0.1 or localhost.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const port = Number(parsed.port);
|
|
64
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
65
|
+
throw new Error(`OAuth redirect URI must include a valid port: ${value}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const callbackPath = parsed.pathname || '/';
|
|
69
|
+
return {
|
|
70
|
+
callbackPath,
|
|
71
|
+
host: parsed.hostname,
|
|
72
|
+
port,
|
|
73
|
+
redirectUri: `${parsed.origin}${callbackPath}`
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function waitForOAuthCallback(host, port, callbackPath, expectedState) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const server = http.createServer((req, res) => {
|
|
80
|
+
const url = new URL(req.url, `http://${host}:${port}`);
|
|
81
|
+
if (url.pathname !== callbackPath) {
|
|
82
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
83
|
+
res.end('Not found');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const code = url.searchParams.get('code');
|
|
88
|
+
const returnedState = url.searchParams.get('state');
|
|
89
|
+
const errorParam = url.searchParams.get('error');
|
|
90
|
+
|
|
91
|
+
const html = (title, body) =>
|
|
92
|
+
`<html><body style="font-family:sans-serif;text-align:center;padding:60px"><h2>${title}</h2><p>${body}</p></body></html>`;
|
|
93
|
+
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
95
|
+
|
|
96
|
+
if (errorParam) {
|
|
97
|
+
res.end(html('Authorization Denied', 'You can close this window and return to the terminal.'));
|
|
98
|
+
server.close();
|
|
99
|
+
reject(new Error(`Authorization denied: ${errorParam}`));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (returnedState !== expectedState) {
|
|
104
|
+
res.end(html('Error', 'State mismatch. Please try again.'));
|
|
105
|
+
server.close();
|
|
106
|
+
reject(new Error('State mismatch — possible CSRF. Please try again.'));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!code) {
|
|
111
|
+
res.end(html('Error', 'No authorization code received.'));
|
|
112
|
+
server.close();
|
|
113
|
+
reject(new Error('No authorization code in callback.'));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
res.end(html('Authorization Complete', 'You can close this window and return to the terminal.'));
|
|
118
|
+
server.close();
|
|
119
|
+
resolve(code);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
server.listen(port, host);
|
|
123
|
+
server.on('error', (err) => {
|
|
124
|
+
if (err.code === 'EADDRINUSE') {
|
|
125
|
+
reject(
|
|
126
|
+
new Error(
|
|
127
|
+
`OAuth callback port ${port} is already in use. ` +
|
|
128
|
+
'Close the application using that port or check for firewall/proxy interference, then try again.'
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
reject(err);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Network helpers
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function snippet(value, max = 180) {
|
|
143
|
+
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
144
|
+
return normalized.length <= max ? normalized : `${normalized.slice(0, max)}...`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function describeNetworkError(error, context) {
|
|
148
|
+
const cause = error?.cause;
|
|
149
|
+
const details = [cause?.code, cause?.message].filter(Boolean).join(': ');
|
|
150
|
+
if (details) {
|
|
151
|
+
return new Error(`${context}: ${details}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
+
return new Error(`${context}: ${message}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readJsonOrThrow(res, context, baseUrl) {
|
|
159
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
160
|
+
const bodyText = await res.text();
|
|
161
|
+
|
|
162
|
+
if (!contentType.toLowerCase().includes('application/json')) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`${context} returned non-JSON content (${res.status}, ${contentType || 'unknown'}). ` +
|
|
165
|
+
`Response: ${snippet(bodyText)}\nCheck that Overlord is running at ${baseUrl}.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(bodyText);
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${context} returned invalid JSON (${res.status}). Response: ${snippet(bodyText)}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function fetchAuthConfig(platformUrl, localSecret) {
|
|
179
|
+
let res;
|
|
180
|
+
try {
|
|
181
|
+
res = await fetch(`${platformUrl}/api/auth/config`, {
|
|
182
|
+
headers: buildAuthHeaders('', localSecret)
|
|
183
|
+
});
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw describeNetworkError(error, `Failed to fetch auth config from ${platformUrl}`);
|
|
186
|
+
}
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Failed to fetch auth config (${res.status}). Check that Overlord is running at ${platformUrl}.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const config = await readJsonOrThrow(res, 'Auth config', platformUrl);
|
|
193
|
+
return {
|
|
194
|
+
...config,
|
|
195
|
+
platform_url: new URL(res.url).origin
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function requestDeviceAuthorization(platformUrl, localSecret) {
|
|
200
|
+
let res;
|
|
201
|
+
try {
|
|
202
|
+
res = await fetch(`${platformUrl}/api/auth/device/request`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: buildAuthHeaders('', localSecret)
|
|
205
|
+
});
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw describeNetworkError(
|
|
208
|
+
error,
|
|
209
|
+
`Device authorization request failed for ${platformUrl}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!res.ok) {
|
|
214
|
+
const text = await res.text();
|
|
215
|
+
throw new Error(`Device authorization request failed (${res.status}): ${snippet(text)}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return readJsonOrThrow(res, 'Device authorization request', platformUrl);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function pollDeviceAuthorization(platformUrl, deviceCode, localSecret) {
|
|
222
|
+
let res;
|
|
223
|
+
try {
|
|
224
|
+
res = await fetch(`${platformUrl}/api/auth/device/poll`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: {
|
|
227
|
+
...buildAuthHeaders('', localSecret),
|
|
228
|
+
'Content-Type': 'application/json'
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify({ device_code: deviceCode })
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
throw describeNetworkError(
|
|
234
|
+
error,
|
|
235
|
+
`Device authorization poll failed for ${platformUrl}`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const body = await readJsonOrThrow(res, 'Device authorization poll', platformUrl);
|
|
240
|
+
|
|
241
|
+
if (res.ok) return body;
|
|
242
|
+
|
|
243
|
+
if (res.status === 400 || res.status === 404 || res.status === 429) {
|
|
244
|
+
return body;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
throw new Error(`Device authorization poll failed (${res.status}): ${snippet(JSON.stringify(body))}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sleep(ms) {
|
|
251
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function exchangeCodeForSupabaseTokens(supabaseUrl, clientId, code, codeVerifier, redirectUri) {
|
|
255
|
+
let res;
|
|
256
|
+
try {
|
|
257
|
+
res = await fetch(`${supabaseUrl}/auth/v1/oauth/token`, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
260
|
+
body: new URLSearchParams({
|
|
261
|
+
grant_type: 'authorization_code',
|
|
262
|
+
code,
|
|
263
|
+
client_id: clientId,
|
|
264
|
+
redirect_uri: redirectUri,
|
|
265
|
+
code_verifier: codeVerifier
|
|
266
|
+
})
|
|
267
|
+
});
|
|
268
|
+
} catch (error) {
|
|
269
|
+
throw describeNetworkError(
|
|
270
|
+
error,
|
|
271
|
+
`Token exchange failed for ${new URL(supabaseUrl).host}`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
const text = await res.text();
|
|
277
|
+
throw new Error(`Token exchange failed (${res.status}): ${snippet(text)}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return readJsonOrThrow(res, 'Token exchange', supabaseUrl);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function openBrowser(url) {
|
|
284
|
+
try {
|
|
285
|
+
const platform = process.platform;
|
|
286
|
+
if (platform === 'darwin') execFileSync('open', [url]);
|
|
287
|
+
else if (platform === 'win32') execFileSync('cmd', ['/c', 'start', '', url]);
|
|
288
|
+
else execFileSync('xdg-open', [url]);
|
|
289
|
+
} catch {
|
|
290
|
+
// Best-effort; user sees the URL in stdout anyway
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function printDeviceAuthorizationInstructions(verificationUri, userCode, logger = console) {
|
|
295
|
+
logger.log(' Verification URL:', verificationUri);
|
|
296
|
+
logger.log(' Authorization code:', userCode);
|
|
297
|
+
logger.log('\nOpen the verification URL in any browser to approve this CLI login.\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function authLoginViaDeviceFlow(
|
|
301
|
+
platformUrl,
|
|
302
|
+
localSecret,
|
|
303
|
+
{
|
|
304
|
+
browserOpener = openBrowser,
|
|
305
|
+
logger = console,
|
|
306
|
+
sleepFn = sleep,
|
|
307
|
+
stdout = process.stdout
|
|
308
|
+
} = {}
|
|
309
|
+
) {
|
|
310
|
+
const deviceAuth = await requestDeviceAuthorization(platformUrl, localSecret);
|
|
311
|
+
const verificationUri = String(deviceAuth.verification_uri ?? '').trim();
|
|
312
|
+
const userCode = String(deviceAuth.user_code ?? '').trim();
|
|
313
|
+
const deviceCode = String(deviceAuth.device_code ?? '').trim();
|
|
314
|
+
|
|
315
|
+
if (!verificationUri || !userCode || !deviceCode) {
|
|
316
|
+
throw new Error('Device authorization response was missing required fields.');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const initialPollIntervalSeconds = Number(deviceAuth.interval);
|
|
320
|
+
let pollIntervalSeconds =
|
|
321
|
+
Number.isFinite(initialPollIntervalSeconds) && initialPollIntervalSeconds > 0
|
|
322
|
+
? initialPollIntervalSeconds
|
|
323
|
+
: DEFAULT_DEVICE_POLL_INTERVAL_SECONDS;
|
|
324
|
+
|
|
325
|
+
printDeviceAuthorizationInstructions(verificationUri, userCode, logger);
|
|
326
|
+
logger.log('Opening browser...\n');
|
|
327
|
+
browserOpener(verificationUri);
|
|
328
|
+
|
|
329
|
+
stdout.write('Waiting for browser authorization');
|
|
330
|
+
|
|
331
|
+
for (;;) {
|
|
332
|
+
await sleepFn(pollIntervalSeconds * 1000);
|
|
333
|
+
|
|
334
|
+
const result = await pollDeviceAuthorization(platformUrl, deviceCode, localSecret);
|
|
335
|
+
const status = String(result?.status ?? '');
|
|
336
|
+
|
|
337
|
+
if (status === 'pending') {
|
|
338
|
+
stdout.write('.');
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (status === 'slow_down') {
|
|
343
|
+
stdout.write('.');
|
|
344
|
+
const nextInterval = Number(result?.interval);
|
|
345
|
+
if (Number.isFinite(nextInterval) && nextInterval > 0) {
|
|
346
|
+
pollIntervalSeconds = nextInterval;
|
|
347
|
+
} else {
|
|
348
|
+
pollIntervalSeconds += 1;
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (status === 'authorized') {
|
|
354
|
+
logger.log('\n');
|
|
355
|
+
return {
|
|
356
|
+
access_token: result.access_token,
|
|
357
|
+
access_token_expires_at: result.access_token_expires_at ?? null,
|
|
358
|
+
refresh_token: result.refresh_token,
|
|
359
|
+
platform_url: result.platform_url ?? platformUrl
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (status === 'expired') {
|
|
364
|
+
throw new Error('Authorization request expired. Please run `ovld auth login` again.');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (result?.error) {
|
|
368
|
+
throw new Error(String(result.error));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
throw new Error(`Unexpected device authorization status: ${status || 'unknown'}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function authLoginViaOAuthLoopback(platformUrl, localSecret) {
|
|
376
|
+
// 1. Discover OAuth config from the platform
|
|
377
|
+
let supabaseUrl, cliClientId, cliRedirectUri, resolvedPlatformUrl;
|
|
378
|
+
const config = await fetchAuthConfig(platformUrl, localSecret);
|
|
379
|
+
supabaseUrl = config.supabase_url;
|
|
380
|
+
cliClientId = config.cli_client_id;
|
|
381
|
+
cliRedirectUri = config.cli_redirect_uri;
|
|
382
|
+
resolvedPlatformUrl = config.platform_url ?? platformUrl;
|
|
383
|
+
|
|
384
|
+
if (!supabaseUrl || !cliClientId) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
'OAuth is not configured for CLI login. Set SUPABASE_OAUTH_CLI_CLIENT_ID on the Overlord server.'
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 2. PKCE parameters + state
|
|
391
|
+
const codeVerifier = generateCodeVerifier();
|
|
392
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
393
|
+
const state = generateState();
|
|
394
|
+
|
|
395
|
+
// 3. Use exact loopback redirect URI (Supabase does not support wildcard callback URLs)
|
|
396
|
+
const redirectTarget = parseLoopbackRedirectUri(cliRedirectUri ?? DEFAULT_CLI_REDIRECT_URI);
|
|
397
|
+
const { host, port, callbackPath, redirectUri } = redirectTarget;
|
|
398
|
+
|
|
399
|
+
// 4. Build the Supabase OAuth authorization URL
|
|
400
|
+
const authorizeUrl = new URL(`${supabaseUrl}/auth/v1/oauth/authorize`);
|
|
401
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
402
|
+
authorizeUrl.searchParams.set('client_id', cliClientId);
|
|
403
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
404
|
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
|
|
405
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
406
|
+
authorizeUrl.searchParams.set('state', state);
|
|
407
|
+
authorizeUrl.searchParams.set('scope', 'openid email');
|
|
408
|
+
|
|
409
|
+
console.log(` Authorization URL: ${authorizeUrl.toString()}`);
|
|
410
|
+
console.log('\nOpening browser...\n');
|
|
411
|
+
|
|
412
|
+
// 5. Start listener before opening browser so we don't miss the redirect
|
|
413
|
+
const callbackPromise = waitForOAuthCallback(host, port, callbackPath, state);
|
|
414
|
+
openBrowser(authorizeUrl.toString());
|
|
415
|
+
|
|
416
|
+
// 6. Wait for the auth code
|
|
417
|
+
let authCode;
|
|
418
|
+
process.stdout.write('Waiting for browser authorization');
|
|
419
|
+
authCode = await callbackPromise;
|
|
420
|
+
console.log('\n');
|
|
421
|
+
|
|
422
|
+
// 7. Exchange auth code → Supabase tokens
|
|
423
|
+
const supabaseTokens = await exchangeCodeForSupabaseTokens(
|
|
424
|
+
supabaseUrl,
|
|
425
|
+
cliClientId,
|
|
426
|
+
authCode,
|
|
427
|
+
codeVerifier,
|
|
428
|
+
redirectUri
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
access_token: supabaseTokens.access_token,
|
|
433
|
+
access_token_expires_at:
|
|
434
|
+
typeof supabaseTokens.expires_in === 'number' && supabaseTokens.expires_in > 0
|
|
435
|
+
? new Date(Date.now() + supabaseTokens.expires_in * 1000).toISOString()
|
|
436
|
+
: null,
|
|
437
|
+
refresh_token: supabaseTokens.refresh_token,
|
|
438
|
+
platform_url: resolvedPlatformUrl
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function fetchOrganizations(platformUrl, accessToken, localSecret) {
|
|
443
|
+
const res = await fetch(`${platformUrl}/api/auth/organizations`, {
|
|
444
|
+
headers: {
|
|
445
|
+
...buildAuthHeaders('', localSecret),
|
|
446
|
+
Authorization: `Bearer ${accessToken}`
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
if (!res.ok) {
|
|
451
|
+
const text = await res.text().catch(() => '');
|
|
452
|
+
throw new Error(`Failed to load organizations (${res.status}): ${snippet(text)}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const data = await readJsonOrThrow(res, 'Organizations', platformUrl);
|
|
456
|
+
return Array.isArray(data.organizations) ? data.organizations : [];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function promptForOrganization(organizations, preselectedId = null) {
|
|
460
|
+
if (!organizations.length) {
|
|
461
|
+
throw new Error('No organizations found. Please complete onboarding first.');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (preselectedId !== null) {
|
|
465
|
+
const match = organizations.find(org => org.id === preselectedId);
|
|
466
|
+
if (!match) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`Organization ${preselectedId} is not available to this account. ` +
|
|
469
|
+
`Available: ${organizations.map(o => o.id).join(', ')}`
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
return match;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (organizations.length === 1) {
|
|
476
|
+
return organizations[0];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!process.stdin.isTTY) {
|
|
480
|
+
throw new Error(
|
|
481
|
+
'Multiple organizations available but stdin is not a TTY. ' +
|
|
482
|
+
'Pass --organization-id <id> to select non-interactively.'
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const rl = (await import('node:readline')).createInterface({
|
|
487
|
+
input: process.stdin,
|
|
488
|
+
output: process.stdout
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
for (;;) {
|
|
493
|
+
console.log('\nOrganizations');
|
|
494
|
+
organizations.forEach((organization, index) => {
|
|
495
|
+
console.log(` ${index + 1}. ${organization.name} (${organization.id})`);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const answer = await new Promise(resolve => {
|
|
499
|
+
rl.question('\nSelect an organization by number: ', resolve);
|
|
500
|
+
});
|
|
501
|
+
const selected = Number.parseInt(String(answer).trim(), 10);
|
|
502
|
+
if (Number.isFinite(selected) && selected >= 1 && selected <= organizations.length) {
|
|
503
|
+
return organizations[selected - 1];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
console.log(`Enter a number between 1 and ${organizations.length}.`);
|
|
507
|
+
}
|
|
508
|
+
} finally {
|
|
509
|
+
rl.close();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Public auth commands
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
export function resolveLoginPlatformUrl(runtime = null, storedPlatformUrl = null) {
|
|
518
|
+
return process.env.OVERLORD_URL ?? storedPlatformUrl ?? runtime?.platform_url ?? getDefaultOverlordUrl();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function parseOrganizationFlag(args) {
|
|
522
|
+
const index = args.findIndex(arg => arg === '--organization-id' || arg.startsWith('--organization-id='));
|
|
523
|
+
if (index === -1) return null;
|
|
524
|
+
const raw = args[index].includes('=') ? args[index].split('=')[1] : args[index + 1];
|
|
525
|
+
const parsed = Number.parseInt(String(raw ?? ''), 10);
|
|
526
|
+
if (!Number.isFinite(parsed)) {
|
|
527
|
+
throw new Error('--organization-id must be a numeric id');
|
|
528
|
+
}
|
|
529
|
+
return parsed;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export async function authLogin(args = []) {
|
|
533
|
+
const preselectedOrganizationId = parseOrganizationFlag(args);
|
|
534
|
+
const storedCredentials = loadCredentials();
|
|
535
|
+
const platformUrl = resolveLoginPlatformUrl(null, storedCredentials?.platform_url ?? null);
|
|
536
|
+
const runtime = loadRuntime(platformUrl);
|
|
537
|
+
const localSecret = runtime?.local_secret ?? process.env.OVERLORD_LOCAL_SECRET ?? '';
|
|
538
|
+
|
|
539
|
+
console.log('Starting Overlord CLI authorization...\n');
|
|
540
|
+
let credentials;
|
|
541
|
+
try {
|
|
542
|
+
credentials = await authLoginViaDeviceFlow(platformUrl, localSecret);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
545
|
+
const canFallbackToLoopback =
|
|
546
|
+
message.includes('Device authorization request failed (404)') ||
|
|
547
|
+
message.includes('Device authorization request failed (405)');
|
|
548
|
+
|
|
549
|
+
if (!canFallbackToLoopback) {
|
|
550
|
+
console.error(`\nAuthorization failed: ${message}`);
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
console.log('Device authorization is unavailable on this server. Falling back to loopback OAuth.\n');
|
|
555
|
+
|
|
556
|
+
try {
|
|
557
|
+
credentials = await authLoginViaOAuthLoopback(platformUrl, localSecret);
|
|
558
|
+
} catch (fallbackErr) {
|
|
559
|
+
console.error(`\nAuthorization failed: ${fallbackErr.message}`);
|
|
560
|
+
process.exit(1);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const resolvedPlatformUrl = credentials.platform_url ?? platformUrl;
|
|
565
|
+
const organizations = await fetchOrganizations(
|
|
566
|
+
resolvedPlatformUrl,
|
|
567
|
+
credentials.access_token,
|
|
568
|
+
localSecret
|
|
569
|
+
);
|
|
570
|
+
const selectedOrganization = await promptForOrganization(organizations, preselectedOrganizationId);
|
|
571
|
+
|
|
572
|
+
saveCredentials({
|
|
573
|
+
access_token: credentials.access_token,
|
|
574
|
+
access_token_expires_at: credentials.access_token_expires_at ?? undefined,
|
|
575
|
+
refresh_token: credentials.refresh_token,
|
|
576
|
+
organization_id: selectedOrganization.id,
|
|
577
|
+
platform_url: resolvedPlatformUrl
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
console.log('Logged in successfully!');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function printVerboseAuthStatus() {
|
|
584
|
+
const status = await getAuthStatus();
|
|
585
|
+
if (!status.isLoggedIn) {
|
|
586
|
+
const hasStoredAuth =
|
|
587
|
+
status.credentialsFileExists || status.legacyCredentialsFileExists || status.electronCredentialsFileExists;
|
|
588
|
+
console.log(
|
|
589
|
+
hasStoredAuth
|
|
590
|
+
? 'Not logged in. Run: ovld auth repair, then ovld auth login if needed.'
|
|
591
|
+
: 'Not logged in. Run: ovld auth login'
|
|
592
|
+
);
|
|
593
|
+
} else {
|
|
594
|
+
console.log('Logged in');
|
|
595
|
+
}
|
|
596
|
+
console.log(` Platform URL: ${status.platformUrl}`);
|
|
597
|
+
console.log(` Platform source: ${status.platformUrlSource}`);
|
|
598
|
+
console.log(` Token source: ${status.tokenSource}`);
|
|
599
|
+
console.log(` Token present: ${status.tokenPresent ? 'yes' : 'no'}`);
|
|
600
|
+
console.log(` Auth mode: ${status.authMode}`);
|
|
601
|
+
console.log(` Organization ID: ${status.organizationId ?? 'none'}`);
|
|
602
|
+
if (status.error) {
|
|
603
|
+
console.log(` Error: ${status.error}`);
|
|
604
|
+
}
|
|
605
|
+
console.log(` Local secret: ${status.hasLocalSecret ? 'yes' : 'no'}`);
|
|
606
|
+
console.log(` credentials.cli.json: ${status.credentialsFileExists ? 'present' : 'missing'}`);
|
|
607
|
+
if (status.legacyCredentialsFileExists) {
|
|
608
|
+
console.log(` credentials.json (legacy): present`);
|
|
609
|
+
}
|
|
610
|
+
if (status.electronCredentialsFileExists) {
|
|
611
|
+
console.log(` electron-credentials.json (legacy): present`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export async function authStatus(args = []) {
|
|
616
|
+
if (args.includes('--verbose') || args.includes('-v')) {
|
|
617
|
+
await printVerboseAuthStatus();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const status = await getAuthStatus();
|
|
622
|
+
if (!status.isLoggedIn) {
|
|
623
|
+
const hasStoredAuth =
|
|
624
|
+
status.credentialsFileExists || status.legacyCredentialsFileExists || status.electronCredentialsFileExists;
|
|
625
|
+
console.log(
|
|
626
|
+
hasStoredAuth
|
|
627
|
+
? 'Not logged in. Run: ovld auth repair, then ovld auth login if needed.'
|
|
628
|
+
: 'Not logged in. Run: ovld auth login'
|
|
629
|
+
);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
console.log('Logged in');
|
|
633
|
+
const creds = loadCredentials();
|
|
634
|
+
if (creds) {
|
|
635
|
+
console.log(` Platform URL: ${creds.platform_url}`);
|
|
636
|
+
if (creds.user_email) {
|
|
637
|
+
console.log(` Email: ${creds.user_email}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function authLogout() {
|
|
643
|
+
clearCredentials();
|
|
644
|
+
console.log('Logged out.');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function authRepair() {
|
|
648
|
+
const result = repairCredentials();
|
|
649
|
+
if (result.repaired) {
|
|
650
|
+
console.log('Credentials repaired.');
|
|
651
|
+
} else {
|
|
652
|
+
console.log(`Credentials not repaired: ${result.reason}`);
|
|
653
|
+
}
|
|
654
|
+
void printVerboseAuthStatus();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export async function runAuthCommand(subcommand, args = []) {
|
|
658
|
+
if (!subcommand || subcommand === 'help' || subcommand === '--help') {
|
|
659
|
+
console.log(`ovld auth <subcommand>
|
|
660
|
+
|
|
661
|
+
Subcommands:
|
|
662
|
+
login Authorize the CLI via browser (works locally or over SSH)
|
|
663
|
+
--organization-id <id> Pre-select organization non-interactively
|
|
664
|
+
(required when stdin is not a TTY and
|
|
665
|
+
multiple organizations are available)
|
|
666
|
+
status Show current login status (use --verbose for redacted diagnostics)
|
|
667
|
+
repair Mirror and chmod shared Desktop/CLI credentials when possible
|
|
668
|
+
logout Remove stored credentials
|
|
669
|
+
`);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (subcommand === 'login') {
|
|
674
|
+
await authLogin(args);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (subcommand === 'status') {
|
|
679
|
+
await authStatus(args);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (subcommand === 'repair') {
|
|
684
|
+
authRepair();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (subcommand === 'logout') {
|
|
689
|
+
authLogout();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
console.error(`Unknown auth subcommand: ${subcommand}\n`);
|
|
694
|
+
console.log('Run: ovld auth help');
|
|
695
|
+
process.exit(1);
|
|
696
|
+
}
|