overlord-cli 5.0.0 → 5.1.1

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 CHANGED
@@ -25,6 +25,14 @@ overlord help
25
25
  The CLI exposes the same command set under both names.
26
26
  `ovld auth login` opens a browser when possible and also prints a verification URL/code so login can be completed from another machine over SSH.
27
27
 
28
+ Desktop-installed wrappers default `OVERLORD_URL` to `https://www.ovld.ai` unless you override it explicitly.
29
+ For local dev against the web app on port 3000, export the override before running auth or protocol commands:
30
+
31
+ ```bash
32
+ export OVERLORD_URL=http://localhost:3000
33
+ ovld auth login
34
+ ```
35
+
28
36
  Common commands:
29
37
 
30
38
  ```bash
package/bin/_cli/auth.mjs CHANGED
@@ -144,6 +144,17 @@ function snippet(value, max = 180) {
144
144
  return normalized.length <= max ? normalized : `${normalized.slice(0, max)}...`;
145
145
  }
146
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
+
147
158
  async function readJsonOrThrow(res, context, baseUrl) {
148
159
  const contentType = res.headers.get('content-type') ?? '';
149
160
  const bodyText = await res.text();
@@ -165,9 +176,14 @@ async function readJsonOrThrow(res, context, baseUrl) {
165
176
  }
166
177
 
167
178
  async function fetchAuthConfig(platformUrl, localSecret) {
168
- const res = await fetch(`${platformUrl}/api/auth/config`, {
169
- headers: buildAuthHeaders('', localSecret)
170
- });
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
+ }
171
187
  if (!res.ok) {
172
188
  throw new Error(
173
189
  `Failed to fetch auth config (${res.status}). Check that Overlord is running at ${platformUrl}.`
@@ -181,10 +197,18 @@ async function fetchAuthConfig(platformUrl, localSecret) {
181
197
  }
182
198
 
183
199
  async function requestDeviceAuthorization(platformUrl, localSecret) {
184
- const res = await fetch(`${platformUrl}/api/auth/device/request`, {
185
- method: 'POST',
186
- headers: buildAuthHeaders('', localSecret)
187
- });
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
+ }
188
212
 
189
213
  if (!res.ok) {
190
214
  const text = await res.text();
@@ -195,14 +219,22 @@ async function requestDeviceAuthorization(platformUrl, localSecret) {
195
219
  }
196
220
 
197
221
  async function pollDeviceAuthorization(platformUrl, deviceCode, localSecret) {
198
- const res = await fetch(`${platformUrl}/api/auth/device/poll`, {
199
- method: 'POST',
200
- headers: {
201
- ...buildAuthHeaders('', localSecret),
202
- 'Content-Type': 'application/json'
203
- },
204
- body: JSON.stringify({ device_code: deviceCode })
205
- });
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
+ }
206
238
 
207
239
  const body = await readJsonOrThrow(res, 'Device authorization poll', platformUrl);
208
240
 
@@ -220,17 +252,25 @@ function sleep(ms) {
220
252
  }
221
253
 
222
254
  async function exchangeCodeForSupabaseTokens(supabaseUrl, clientId, code, codeVerifier, redirectUri) {
223
- const res = await fetch(`${supabaseUrl}/auth/v1/oauth/token`, {
224
- method: 'POST',
225
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
226
- body: new URLSearchParams({
227
- grant_type: 'authorization_code',
228
- code,
229
- client_id: clientId,
230
- redirect_uri: redirectUri,
231
- code_verifier: codeVerifier
232
- })
233
- });
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
+ }
234
274
 
235
275
  if (!res.ok) {
236
276
  const text = await res.text();
@@ -474,8 +514,8 @@ async function promptForOrganization(organizations, preselectedId = null) {
474
514
  // Public auth commands
475
515
  // ---------------------------------------------------------------------------
476
516
 
477
- export function resolveLoginPlatformUrl(runtime = null) {
478
- return process.env.OVERLORD_URL ?? runtime?.platform_url ?? getDefaultOverlordUrl();
517
+ export function resolveLoginPlatformUrl(runtime = null, storedPlatformUrl = null) {
518
+ return process.env.OVERLORD_URL ?? storedPlatformUrl ?? runtime?.platform_url ?? getDefaultOverlordUrl();
479
519
  }
480
520
 
481
521
  function parseOrganizationFlag(args) {
@@ -491,7 +531,8 @@ function parseOrganizationFlag(args) {
491
531
 
492
532
  export async function authLogin(args = []) {
493
533
  const preselectedOrganizationId = parseOrganizationFlag(args);
494
- const platformUrl = resolveLoginPlatformUrl();
534
+ const storedCredentials = loadCredentials();
535
+ const platformUrl = resolveLoginPlatformUrl(null, storedCredentials?.platform_url ?? null);
495
536
  const runtime = loadRuntime(platformUrl);
496
537
  const localSecret = runtime?.local_secret ?? process.env.OVERLORD_LOCAL_SECRET ?? '';
497
538
 
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
 
10
10
  const CREDENTIALS_DIR = path.join(os.homedir(), '.ovld');
11
11
  const CLI_CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.cli.json');
12
+ const DESKTOP_CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.desktop.json');
12
13
  const LEGACY_CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
13
14
  const LEGACY_ELECTRON_CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'electron-credentials.json');
14
15
  const LEGACY_MIGRATION_MARKER = path.join(CREDENTIALS_DIR, '.cli-migrated');
@@ -24,7 +25,8 @@ const LOCAL_SECRET_HEADER = 'X-Overlord-Local-Secret';
24
25
  * refresh_token?: string,
25
26
  * organization_id?: number | null,
26
27
  * platform_url: string,
27
- * user_email?: string
28
+ * user_email?: string,
29
+ * updated_at?: string
28
30
  * }} Credentials
29
31
  */
30
32
 
@@ -93,6 +95,9 @@ function parseStoredCredentialsData(parsed, { requireAuthData = false } = {}) {
93
95
  ...(organizationId ? { organization_id: organizationId } : {}),
94
96
  ...(typeof parsed.user_email === 'string' && parsed.user_email.trim()
95
97
  ? { user_email: parsed.user_email.trim() }
98
+ : {}),
99
+ ...(typeof parsed.updated_at === 'string' && parsed.updated_at.trim()
100
+ ? { updated_at: parsed.updated_at.trim() }
96
101
  : {})
97
102
  };
98
103
  }
@@ -112,7 +117,8 @@ function normalizeCredentialsForSave(data) {
112
117
  ? { access_token_expires_at: parsed.access_token_expires_at }
113
118
  : {}),
114
119
  ...(parsed.organization_id ? { organization_id: parsed.organization_id } : {}),
115
- ...(parsed.user_email ? { user_email: parsed.user_email } : {})
120
+ ...(parsed.user_email ? { user_email: parsed.user_email } : {}),
121
+ ...(parsed.updated_at ? { updated_at: parsed.updated_at } : {})
116
122
  };
117
123
  }
118
124
 
@@ -140,13 +146,57 @@ function migrateLegacyCredentials() {
140
146
  return source;
141
147
  }
142
148
 
149
+ function resolveAccessTokenExpiry(credentials) {
150
+ if (!credentials?.access_token) return null;
151
+ if (credentials.access_token_expires_at) {
152
+ const parsed = Date.parse(credentials.access_token_expires_at);
153
+ if (Number.isFinite(parsed)) return parsed;
154
+ }
155
+ const jwtExp = decodeJwtExpiry(credentials.access_token);
156
+ return jwtExp ? jwtExp * 1000 : null;
157
+ }
158
+
159
+ function isAccessTokenFresh(credentials) {
160
+ const expiresAt = resolveAccessTokenExpiry(credentials);
161
+ if (expiresAt === null) return false;
162
+ return expiresAt - Date.now() > 60_000;
163
+ }
164
+
165
+ function credentialsUpdatedAt(credentials) {
166
+ const parsed = Date.parse(credentials?.updated_at ?? '');
167
+ return Number.isFinite(parsed) ? parsed : 0;
168
+ }
169
+
170
+ function selectStoredCredentials() {
171
+ const candidates = [
172
+ {
173
+ source: 'credentials.cli.json',
174
+ credentials: parseStoredCredentialsData(readJsonFile(CLI_CREDENTIALS_FILE), {
175
+ requireAuthData: true
176
+ })
177
+ },
178
+ {
179
+ source: 'credentials.desktop.json',
180
+ credentials: parseStoredCredentialsData(readJsonFile(DESKTOP_CREDENTIALS_FILE), {
181
+ requireAuthData: true
182
+ })
183
+ }
184
+ ].filter(candidate => candidate.credentials?.refresh_token);
185
+
186
+ if (candidates.length === 0) return null;
187
+
188
+ const fresh = candidates.filter(candidate => isAccessTokenFresh(candidate.credentials));
189
+ const pool = fresh.length > 0 ? fresh : candidates;
190
+ return pool.sort(
191
+ (left, right) =>
192
+ credentialsUpdatedAt(right.credentials) - credentialsUpdatedAt(left.credentials)
193
+ )[0];
194
+ }
195
+
143
196
  /** @returns {Credentials | null} */
144
197
  export function loadCredentials() {
145
- const cliCredentials = parseStoredCredentialsData(readJsonFile(CLI_CREDENTIALS_FILE), {
146
- requireAuthData: true
147
- });
148
-
149
- if (cliCredentials?.refresh_token) return cliCredentials;
198
+ const selected = selectStoredCredentials();
199
+ if (selected?.credentials) return selected.credentials;
150
200
 
151
201
  return migrateLegacyCredentials();
152
202
  }
@@ -161,6 +211,16 @@ export function saveCredentials(data) {
161
211
  writeJsonFileAtomic(CLI_CREDENTIALS_FILE, { ...credentials, updated_at: new Date().toISOString() });
162
212
  }
163
213
 
214
+ function saveCredentialsToSource(data, source) {
215
+ const credentials = normalizeCredentialsForSave(data);
216
+ if (!credentials) {
217
+ throw new Error('Cannot save empty Overlord credentials.');
218
+ }
219
+
220
+ const filePath = source === 'credentials.desktop.json' ? DESKTOP_CREDENTIALS_FILE : CLI_CREDENTIALS_FILE;
221
+ writeJsonFileAtomic(filePath, { ...credentials, updated_at: new Date().toISOString() });
222
+ }
223
+
164
224
  export function clearCredentials() {
165
225
  try {
166
226
  fs.unlinkSync(CLI_CREDENTIALS_FILE);
@@ -170,10 +230,8 @@ export function clearCredentials() {
170
230
  }
171
231
 
172
232
  function getCredentialFileSource() {
173
- const cliCredentials = parseStoredCredentialsData(readJsonFile(CLI_CREDENTIALS_FILE), {
174
- requireAuthData: true
175
- });
176
- if (cliCredentials?.refresh_token) return 'credentials.cli.json';
233
+ const selected = selectStoredCredentials();
234
+ if (selected) return selected.source;
177
235
 
178
236
  if (fileExists(LEGACY_CREDENTIALS_FILE)) {
179
237
  const legacyShared = parseStoredCredentialsData(readJsonFile(LEGACY_CREDENTIALS_FILE), {
@@ -338,29 +396,29 @@ function computeAccessTokenExpiry(data) {
338
396
  return jwtExp ? new Date(jwtExp * 1000).toISOString() : null;
339
397
  }
340
398
 
341
- function resolveAccessTokenExpiry(credentials) {
342
- if (!credentials?.access_token) return null;
343
- if (credentials.access_token_expires_at) {
344
- const parsed = Date.parse(credentials.access_token_expires_at);
345
- if (Number.isFinite(parsed)) return parsed;
399
+ function describeNetworkError(error, context) {
400
+ const cause = error?.cause;
401
+ const details = [cause?.code, cause?.message].filter(Boolean).join(': ');
402
+ if (details) {
403
+ return new Error(`${context}: ${details}`);
346
404
  }
347
- const jwtExp = decodeJwtExpiry(credentials.access_token);
348
- return jwtExp ? jwtExp * 1000 : null;
349
- }
350
405
 
351
- function isAccessTokenFresh(credentials) {
352
- const expiresAt = resolveAccessTokenExpiry(credentials);
353
- if (expiresAt === null) return false;
354
- return expiresAt - Date.now() > 60_000;
406
+ const message = error instanceof Error ? error.message : String(error);
407
+ return new Error(`${context}: ${message}`);
355
408
  }
356
409
 
357
410
  const authConfigCache = new Map();
358
411
 
359
412
  async function fetchAuthConfig(platformUrl, localSecret) {
360
413
  if (authConfigCache.has(platformUrl)) return authConfigCache.get(platformUrl);
361
- const res = await fetch(`${platformUrl}/api/auth/config`, {
362
- headers: buildAuthHeaders('', localSecret)
363
- });
414
+ let res;
415
+ try {
416
+ res = await fetch(`${platformUrl}/api/auth/config`, {
417
+ headers: buildAuthHeaders('', localSecret)
418
+ });
419
+ } catch (error) {
420
+ throw describeNetworkError(error, `Failed to fetch auth config from ${platformUrl}`);
421
+ }
364
422
  if (!res.ok) {
365
423
  throw new Error(`Failed to fetch auth config (${res.status}).`);
366
424
  }
@@ -378,15 +436,23 @@ async function refreshOAuthAccessToken(platformUrl, refreshToken, localSecret) {
378
436
  throw new Error('OAuth is not configured for Overlord CLI auth.');
379
437
  }
380
438
 
381
- const res = await fetch(`${supabaseUrl}/auth/v1/oauth/token`, {
382
- method: 'POST',
383
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
384
- body: new URLSearchParams({
385
- grant_type: 'refresh_token',
386
- refresh_token: refreshToken,
387
- client_id: clientId
388
- })
389
- });
439
+ let res;
440
+ try {
441
+ res = await fetch(`${supabaseUrl}/auth/v1/oauth/token`, {
442
+ method: 'POST',
443
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
444
+ body: new URLSearchParams({
445
+ grant_type: 'refresh_token',
446
+ refresh_token: refreshToken,
447
+ client_id: clientId
448
+ })
449
+ });
450
+ } catch (error) {
451
+ throw describeNetworkError(
452
+ error,
453
+ `OAuth token refresh failed for ${new URL(supabaseUrl).host}`
454
+ );
455
+ }
390
456
 
391
457
  if (!res.ok) {
392
458
  const text = await res.text().catch(() => '');
@@ -459,7 +525,8 @@ function isLocalDevCli() {
459
525
  * Refreshes OAuth access tokens when possible.
460
526
  */
461
527
  export async function resolveAuth() {
462
- const creds = loadCredentials();
528
+ const selectedCredentials = selectStoredCredentials();
529
+ const creds = selectedCredentials?.credentials ?? migrateLegacyCredentials();
463
530
  const overlordUrlFromEnv = normalizePlatformUrl(process.env.OVERLORD_URL);
464
531
  const overlordUrlFromCreds = normalizeStoredPlatformUrl(creds?.platform_url);
465
532
 
@@ -516,11 +583,11 @@ export async function resolveAuth() {
516
583
  access_token_expires_at: refreshed.access_token_expires_at,
517
584
  refresh_token: refreshed.refresh_token || creds.refresh_token
518
585
  };
519
- saveCredentials(nextCredentials);
586
+ saveCredentialsToSource(nextCredentials, selectedCredentials?.source);
520
587
  } catch (refreshError) {
521
- if (!creds.access_token) throw refreshError;
522
- // Transient refresh failure keep the existing access token and let the server
523
- // reject it if it's truly expired/revoked.
588
+ throw new Error(
589
+ `Stored Overlord session expired and refresh failed. ${refreshError instanceof Error ? refreshError.message : String(refreshError)} Run \`ovld auth login\` again.`
590
+ );
524
591
  }
525
592
  }
526
593
 
@@ -584,7 +651,7 @@ export async function getAuthStatus() {
584
651
  }
585
652
 
586
653
  return {
587
- isLoggedIn: tokenSource !== 'fallback',
654
+ isLoggedIn: tokenSource !== 'fallback' && !error,
588
655
  platformUrl: resolved.platformUrl,
589
656
  platformUrlSource,
590
657
  tokenPresent: tokenSource !== 'fallback',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overlord-cli",
3
- "version": "5.0.0",
3
+ "version": "5.1.1",
4
4
  "description": "Overlord CLI — launch AI agents on tickets from anywhere",
5
5
  "type": "module",
6
6
  "bin": {