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.
Files changed (42) hide show
  1. package/README.md +128 -0
  2. package/bin/_cli/attach.mjs +357 -0
  3. package/bin/_cli/auth.mjs +696 -0
  4. package/bin/_cli/cli-update.mjs +119 -0
  5. package/bin/_cli/credentials.mjs +723 -0
  6. package/bin/_cli/index.mjs +170 -0
  7. package/bin/_cli/launcher.mjs +474 -0
  8. package/bin/_cli/new-ticket.mjs +306 -0
  9. package/bin/_cli/postinstall.mjs +43 -0
  10. package/bin/_cli/protocol.mjs +1799 -0
  11. package/bin/_cli/setup.mjs +1634 -0
  12. package/bin/_cli/ticket.mjs +55 -0
  13. package/bin/_cli/tickets.mjs +121 -0
  14. package/bin/_cli/version.mjs +7 -0
  15. package/bin/ovld.mjs +13 -0
  16. package/package.json +36 -0
  17. package/plugins/claude/.claude-plugin/plugin.json +34 -0
  18. package/plugins/claude/README.md +42 -0
  19. package/plugins/claude/commands/connect.md +19 -0
  20. package/plugins/claude/commands/create.md +17 -0
  21. package/plugins/claude/commands/load.md +19 -0
  22. package/plugins/claude/commands/spawn.md +17 -0
  23. package/plugins/claude/hooks/hooks.json +15 -0
  24. package/plugins/claude/scripts/permission-hook.sh +21 -0
  25. package/plugins/claude/skills/overlord-ticket/SKILL.md +164 -0
  26. package/plugins/cursor/.cursor-plugin/plugin.json +14 -0
  27. package/plugins/cursor/commands/connect.md +3 -0
  28. package/plugins/cursor/commands/create.md +11 -0
  29. package/plugins/cursor/commands/load.md +3 -0
  30. package/plugins/cursor/commands/spawn.md +3 -0
  31. package/plugins/cursor/mcp.json +8 -0
  32. package/plugins/cursor/rules/overlord-local.mdc +10 -0
  33. package/plugins/cursor/scripts/overlord-mcp.mjs +150 -0
  34. package/plugins/cursor/skills/overlord-ticket/SKILL.md +49 -0
  35. package/plugins/overlord/.codex-plugin/plugin.json +35 -0
  36. package/plugins/overlord/.mcp.json +11 -0
  37. package/plugins/overlord/README.md +42 -0
  38. package/plugins/overlord/assets/icon.png +0 -0
  39. package/plugins/overlord/assets/logo.png +0 -0
  40. package/plugins/overlord/assets/screenshot.svg +28 -0
  41. package/plugins/overlord/scripts/overlord-mcp.mjs +688 -0
  42. 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
+ }