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,723 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/* global Buffer, fetch, process, URL, URLSearchParams */
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const CREDENTIALS_DIR = path.join(os.homedir(), '.ovld');
|
|
11
|
+
const CLI_CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.cli.json');
|
|
12
|
+
const DESKTOP_CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.desktop.json');
|
|
13
|
+
const LEGACY_CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
|
|
14
|
+
const LEGACY_ELECTRON_CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'electron-credentials.json');
|
|
15
|
+
const LEGACY_MIGRATION_MARKER = path.join(CREDENTIALS_DIR, '.cli-migrated');
|
|
16
|
+
const RUNTIME_FILE_PATTERN = /^runtime\..+\.json$/;
|
|
17
|
+
const HOSTED_OVERLORD_URL = 'https://www.ovld.ai';
|
|
18
|
+
const LOCAL_DEV_OVERLORD_URL = 'http://localhost:3000';
|
|
19
|
+
const LOCAL_SECRET_HEADER = 'X-Overlord-Local-Secret';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {{
|
|
23
|
+
* access_token?: string,
|
|
24
|
+
* access_token_expires_at?: string,
|
|
25
|
+
* refresh_token?: string,
|
|
26
|
+
* organization_id?: number | null,
|
|
27
|
+
* platform_url: string,
|
|
28
|
+
* user_email?: string,
|
|
29
|
+
* updated_at?: string
|
|
30
|
+
* }} Credentials
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
function ensureCredentialsDir() {
|
|
34
|
+
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
|
|
35
|
+
try {
|
|
36
|
+
fs.chmodSync(CREDENTIALS_DIR, 0o700);
|
|
37
|
+
} catch {
|
|
38
|
+
// Best-effort hardening for existing directories.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJsonFile(filePath) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
45
|
+
return JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fileExists(filePath) {
|
|
52
|
+
try {
|
|
53
|
+
return fs.statSync(filePath).isFile();
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeJsonFileAtomic(filePath, data) {
|
|
60
|
+
ensureCredentialsDir();
|
|
61
|
+
const tempFile = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
62
|
+
fs.writeFileSync(tempFile, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
63
|
+
fs.renameSync(tempFile, filePath);
|
|
64
|
+
fs.chmodSync(filePath, 0o600);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseStoredCredentialsData(parsed, { requireAuthData = false } = {}) {
|
|
68
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
69
|
+
|
|
70
|
+
const platformUrl = typeof parsed.platform_url === 'string' ? parsed.platform_url.trim() : '';
|
|
71
|
+
const refreshToken =
|
|
72
|
+
typeof parsed.refresh_token === 'string'
|
|
73
|
+
? parsed.refresh_token.trim()
|
|
74
|
+
: typeof parsed.supabase_refresh_token === 'string'
|
|
75
|
+
? parsed.supabase_refresh_token.trim()
|
|
76
|
+
: '';
|
|
77
|
+
const accessToken = typeof parsed.access_token === 'string' ? parsed.access_token.trim() : '';
|
|
78
|
+
const accessTokenExpiresAt =
|
|
79
|
+
typeof parsed.access_token_expires_at === 'string'
|
|
80
|
+
? parsed.access_token_expires_at.trim()
|
|
81
|
+
: '';
|
|
82
|
+
const organizationId =
|
|
83
|
+
typeof parsed.organization_id === 'number' && Number.isFinite(parsed.organization_id)
|
|
84
|
+
? parsed.organization_id
|
|
85
|
+
: null;
|
|
86
|
+
|
|
87
|
+
if (!platformUrl) return null;
|
|
88
|
+
if (requireAuthData && !refreshToken) return null;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
platform_url: platformUrl,
|
|
92
|
+
...(refreshToken ? { refresh_token: refreshToken } : {}),
|
|
93
|
+
...(accessToken ? { access_token: accessToken } : {}),
|
|
94
|
+
...(accessTokenExpiresAt ? { access_token_expires_at: accessTokenExpiresAt } : {}),
|
|
95
|
+
...(organizationId ? { organization_id: organizationId } : {}),
|
|
96
|
+
...(typeof parsed.user_email === 'string' && parsed.user_email.trim()
|
|
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() }
|
|
101
|
+
: {})
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeCredentialsForSave(data) {
|
|
106
|
+
const parsed = parseStoredCredentialsData(data, { requireAuthData: true });
|
|
107
|
+
if (!parsed) return null;
|
|
108
|
+
|
|
109
|
+
const platformUrl = normalizePlatformUrl(parsed.platform_url);
|
|
110
|
+
if (!platformUrl) return null;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
platform_url: platformUrl,
|
|
114
|
+
...(parsed.refresh_token ? { refresh_token: parsed.refresh_token } : {}),
|
|
115
|
+
...(parsed.access_token ? { access_token: parsed.access_token } : {}),
|
|
116
|
+
...(parsed.access_token_expires_at
|
|
117
|
+
? { access_token_expires_at: parsed.access_token_expires_at }
|
|
118
|
+
: {}),
|
|
119
|
+
...(parsed.organization_id ? { organization_id: parsed.organization_id } : {}),
|
|
120
|
+
...(parsed.user_email ? { user_email: parsed.user_email } : {}),
|
|
121
|
+
...(parsed.updated_at ? { updated_at: parsed.updated_at } : {})
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function migrateLegacyCredentials() {
|
|
126
|
+
if (fileExists(LEGACY_MIGRATION_MARKER)) return null;
|
|
127
|
+
|
|
128
|
+
const legacyShared = parseStoredCredentialsData(readJsonFile(LEGACY_CREDENTIALS_FILE), {
|
|
129
|
+
requireAuthData: true
|
|
130
|
+
});
|
|
131
|
+
const legacyElectron = parseStoredCredentialsData(readJsonFile(LEGACY_ELECTRON_CREDENTIALS_FILE), {
|
|
132
|
+
requireAuthData: true
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const source = legacyShared ?? legacyElectron;
|
|
136
|
+
if (!source) return null;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
writeJsonFileAtomic(CLI_CREDENTIALS_FILE, { ...source, updated_at: new Date().toISOString() });
|
|
140
|
+
ensureCredentialsDir();
|
|
141
|
+
fs.writeFileSync(LEGACY_MIGRATION_MARKER, new Date().toISOString(), { mode: 0o600 });
|
|
142
|
+
} catch {
|
|
143
|
+
// Best-effort migration
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return source;
|
|
147
|
+
}
|
|
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
|
+
|
|
196
|
+
/** @returns {Credentials | null} */
|
|
197
|
+
export function loadCredentials() {
|
|
198
|
+
const selected = selectStoredCredentials();
|
|
199
|
+
if (selected?.credentials) return selected.credentials;
|
|
200
|
+
|
|
201
|
+
return migrateLegacyCredentials();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** @param {Credentials} data */
|
|
205
|
+
export function saveCredentials(data) {
|
|
206
|
+
const credentials = normalizeCredentialsForSave(data);
|
|
207
|
+
if (!credentials) {
|
|
208
|
+
throw new Error('Cannot save empty Overlord credentials.');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
writeJsonFileAtomic(CLI_CREDENTIALS_FILE, { ...credentials, updated_at: new Date().toISOString() });
|
|
212
|
+
}
|
|
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
|
+
|
|
224
|
+
export function clearCredentials() {
|
|
225
|
+
try {
|
|
226
|
+
fs.unlinkSync(CLI_CREDENTIALS_FILE);
|
|
227
|
+
} catch {
|
|
228
|
+
// Already gone
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function getCredentialFileSource() {
|
|
233
|
+
const selected = selectStoredCredentials();
|
|
234
|
+
if (selected) return selected.source;
|
|
235
|
+
|
|
236
|
+
if (fileExists(LEGACY_CREDENTIALS_FILE)) {
|
|
237
|
+
const legacyShared = parseStoredCredentialsData(readJsonFile(LEGACY_CREDENTIALS_FILE), {
|
|
238
|
+
requireAuthData: true
|
|
239
|
+
});
|
|
240
|
+
if (legacyShared?.refresh_token) return 'credentials.json (legacy)';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (fileExists(LEGACY_ELECTRON_CREDENTIALS_FILE)) {
|
|
244
|
+
const legacyElectron = parseStoredCredentialsData(readJsonFile(LEGACY_ELECTRON_CREDENTIALS_FILE), {
|
|
245
|
+
requireAuthData: true
|
|
246
|
+
});
|
|
247
|
+
if (legacyElectron?.refresh_token) return 'electron-credentials.json (legacy)';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return 'none';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getRuntimeFilePath(targetUrl) {
|
|
254
|
+
try {
|
|
255
|
+
const parsed = new URL(targetUrl);
|
|
256
|
+
const normalized = parsed.origin
|
|
257
|
+
.toLowerCase()
|
|
258
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
259
|
+
.replace(/^-+|-+$/g, '');
|
|
260
|
+
return path.join(CREDENTIALS_DIR, `runtime.${normalized || 'unknown'}.json`);
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function getLegacyRuntimeFilePath(targetUrl) {
|
|
267
|
+
try {
|
|
268
|
+
const port = new URL(targetUrl).port || '80';
|
|
269
|
+
return path.join(CREDENTIALS_DIR, `runtime.${port}.json`);
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function getAllRuntimeFiles() {
|
|
276
|
+
try {
|
|
277
|
+
return fs
|
|
278
|
+
.readdirSync(CREDENTIALS_DIR)
|
|
279
|
+
.filter(f => RUNTIME_FILE_PATTERN.test(f))
|
|
280
|
+
.map(f => path.join(CREDENTIALS_DIR, f))
|
|
281
|
+
.sort((left, right) => {
|
|
282
|
+
try {
|
|
283
|
+
return fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs;
|
|
284
|
+
} catch {
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
} catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getRuntimeStatIfSecure(filePath) {
|
|
294
|
+
try {
|
|
295
|
+
const stat = fs.statSync(filePath);
|
|
296
|
+
if (!stat.isFile()) return null;
|
|
297
|
+
|
|
298
|
+
const mode = stat.mode & 0o777;
|
|
299
|
+
if ((mode & 0o077) !== 0) return null;
|
|
300
|
+
|
|
301
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return stat;
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function loadRuntimeFromFile(filePath) {
|
|
312
|
+
if (!getRuntimeStatIfSecure(filePath)) return null;
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
316
|
+
const parsed = JSON.parse(raw);
|
|
317
|
+
|
|
318
|
+
if (
|
|
319
|
+
!parsed ||
|
|
320
|
+
typeof parsed !== 'object' ||
|
|
321
|
+
typeof parsed.platform_url !== 'string' ||
|
|
322
|
+
typeof parsed.pid !== 'number' ||
|
|
323
|
+
!isRunningPid(parsed.pid) ||
|
|
324
|
+
!isSupportedPlatformUrl(parsed.platform_url)
|
|
325
|
+
) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (parsed.local_secret !== undefined && typeof parsed.local_secret !== 'string') {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
platform_url: parsed.platform_url,
|
|
335
|
+
local_secret: parsed.local_secret
|
|
336
|
+
};
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isLocalhostUrl(value) {
|
|
343
|
+
try {
|
|
344
|
+
const parsed = new URL(value);
|
|
345
|
+
if (parsed.protocol !== 'http:') return false;
|
|
346
|
+
return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
|
|
347
|
+
} catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isLocalDevOverlordUrl(value) {
|
|
353
|
+
try {
|
|
354
|
+
return new URL(value).origin === LOCAL_DEV_OVERLORD_URL;
|
|
355
|
+
} catch {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isSupportedPlatformUrl(value) {
|
|
361
|
+
try {
|
|
362
|
+
const parsed = new URL(value);
|
|
363
|
+
if (isLocalhostUrl(value)) return true;
|
|
364
|
+
return parsed.protocol === 'https:';
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function isRunningPid(pid) {
|
|
371
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
372
|
+
try {
|
|
373
|
+
process.kill(pid, 0);
|
|
374
|
+
return true;
|
|
375
|
+
} catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function decodeJwtExpiry(accessToken) {
|
|
381
|
+
try {
|
|
382
|
+
const payload = JSON.parse(Buffer.from(accessToken.split('.')[1] ?? '', 'base64url').toString('utf8'));
|
|
383
|
+
return typeof payload.exp === 'number' ? payload.exp : null;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function computeAccessTokenExpiry(data) {
|
|
390
|
+
const expiresIn = Number.parseInt(String(data?.expires_in ?? ''), 10);
|
|
391
|
+
if (Number.isFinite(expiresIn) && expiresIn > 0) {
|
|
392
|
+
return new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const jwtExp = typeof data?.access_token === 'string' ? decodeJwtExpiry(data.access_token) : null;
|
|
396
|
+
return jwtExp ? new Date(jwtExp * 1000).toISOString() : null;
|
|
397
|
+
}
|
|
398
|
+
|
|
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}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
407
|
+
return new Error(`${context}: ${message}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const authConfigCache = new Map();
|
|
411
|
+
|
|
412
|
+
async function fetchAuthConfig(platformUrl, localSecret) {
|
|
413
|
+
if (authConfigCache.has(platformUrl)) return authConfigCache.get(platformUrl);
|
|
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
|
+
}
|
|
422
|
+
if (!res.ok) {
|
|
423
|
+
throw new Error(`Failed to fetch auth config (${res.status}).`);
|
|
424
|
+
}
|
|
425
|
+
const data = await res.json();
|
|
426
|
+
authConfigCache.set(platformUrl, data);
|
|
427
|
+
return data;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function refreshOAuthAccessToken(platformUrl, refreshToken, localSecret) {
|
|
431
|
+
const config = await fetchAuthConfig(platformUrl, localSecret);
|
|
432
|
+
const clientId = config.cli_client_id ?? config.electron_client_id;
|
|
433
|
+
const supabaseUrl = config.supabase_url;
|
|
434
|
+
|
|
435
|
+
if (!supabaseUrl || !clientId) {
|
|
436
|
+
throw new Error('OAuth is not configured for Overlord CLI auth.');
|
|
437
|
+
}
|
|
438
|
+
|
|
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
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!res.ok) {
|
|
458
|
+
const text = await res.text().catch(() => '');
|
|
459
|
+
throw new Error(`OAuth token refresh failed (${res.status}): ${text}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const data = await res.json();
|
|
463
|
+
return {
|
|
464
|
+
access_token: data.access_token,
|
|
465
|
+
refresh_token: data.refresh_token,
|
|
466
|
+
access_token_expires_at: computeAccessTokenExpiry(data)
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** @returns {{ platform_url?: string, local_secret?: string } | null} */
|
|
471
|
+
export function loadRuntime(targetUrl) {
|
|
472
|
+
if (targetUrl) {
|
|
473
|
+
const candidatePaths = [getRuntimeFilePath(targetUrl), getLegacyRuntimeFilePath(targetUrl)]
|
|
474
|
+
.filter(Boolean);
|
|
475
|
+
for (const filePath of candidatePaths) {
|
|
476
|
+
const runtime = loadRuntimeFromFile(filePath);
|
|
477
|
+
if (runtime) return runtime;
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const filePath of getAllRuntimeFiles()) {
|
|
483
|
+
const result = loadRuntimeFromFile(filePath);
|
|
484
|
+
if (result) return result;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* @param {string} token
|
|
492
|
+
* @param {string} [localSecret]
|
|
493
|
+
* @param {number | null | undefined} [organizationId]
|
|
494
|
+
* @returns {Record<string, string>}
|
|
495
|
+
*/
|
|
496
|
+
export function buildAuthHeaders(token, localSecret, organizationId) {
|
|
497
|
+
const headers = {};
|
|
498
|
+
|
|
499
|
+
if (token) {
|
|
500
|
+
headers.Authorization = `Bearer ${token}`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (localSecret) {
|
|
504
|
+
headers[LOCAL_SECRET_HEADER] = localSecret;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (organizationId && Number.isFinite(organizationId)) {
|
|
508
|
+
headers['x-organization-id'] = String(organizationId);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return headers;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function getDefaultOverlordUrl() {
|
|
515
|
+
return isLocalDevCli() ? LOCAL_DEV_OVERLORD_URL : HOSTED_OVERLORD_URL;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function isLocalDevCli() {
|
|
519
|
+
const sourcePath = fileURLToPath(import.meta.url);
|
|
520
|
+
return !sourcePath.split(path.sep).includes('node_modules');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Resolve the Overlord auth session from env vars or shared credentials.
|
|
525
|
+
* Refreshes OAuth access tokens when possible.
|
|
526
|
+
*/
|
|
527
|
+
export async function resolveAuth() {
|
|
528
|
+
const selectedCredentials = selectStoredCredentials();
|
|
529
|
+
const creds = selectedCredentials?.credentials ?? migrateLegacyCredentials();
|
|
530
|
+
const overlordUrlFromEnv = normalizePlatformUrl(process.env.OVERLORD_URL);
|
|
531
|
+
const overlordUrlFromCreds = normalizeStoredPlatformUrl(creds?.platform_url);
|
|
532
|
+
|
|
533
|
+
const platformUrl = overlordUrlFromEnv ?? overlordUrlFromCreds ?? getDefaultOverlordUrl();
|
|
534
|
+
const runtime = isLocalhostUrl(platformUrl) ? loadRuntime(platformUrl) : null;
|
|
535
|
+
const runtimeOverlordUrl = runtime?.platform_url;
|
|
536
|
+
const localSecret =
|
|
537
|
+
runtime &&
|
|
538
|
+
runtime.local_secret &&
|
|
539
|
+
runtimeOverlordUrl &&
|
|
540
|
+
runtimeOverlordUrl === platformUrl &&
|
|
541
|
+
isLocalhostUrl(platformUrl)
|
|
542
|
+
? runtime.local_secret
|
|
543
|
+
: '';
|
|
544
|
+
|
|
545
|
+
const envAccessToken = normalizeAccessToken(process.env.OVERLORD_ACCESS_TOKEN);
|
|
546
|
+
if (envAccessToken) {
|
|
547
|
+
const envOrganizationId =
|
|
548
|
+
typeof process.env.OVERLORD_ORGANIZATION_ID === 'string'
|
|
549
|
+
? Number.parseInt(process.env.OVERLORD_ORGANIZATION_ID, 10)
|
|
550
|
+
: null;
|
|
551
|
+
if (!Number.isFinite(envOrganizationId)) {
|
|
552
|
+
throw new Error(
|
|
553
|
+
'OVERLORD_ACCESS_TOKEN requires OVERLORD_ORGANIZATION_ID so protocol requests stay scoped.'
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (isEnvAccessTokenUsable(envAccessToken)) {
|
|
558
|
+
return {
|
|
559
|
+
platformUrl,
|
|
560
|
+
bearerToken: envAccessToken,
|
|
561
|
+
localSecret,
|
|
562
|
+
organizationId: envOrganizationId,
|
|
563
|
+
authMode: 'oauth_env'
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!creds) {
|
|
569
|
+
return {
|
|
570
|
+
platformUrl,
|
|
571
|
+
bearerToken: 'overlord-local-dev-token',
|
|
572
|
+
localSecret,
|
|
573
|
+
organizationId: null,
|
|
574
|
+
authMode: 'local_fallback'
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (creds.refresh_token) {
|
|
579
|
+
let nextCredentials = creds;
|
|
580
|
+
if (!isAccessTokenFresh(creds)) {
|
|
581
|
+
try {
|
|
582
|
+
const refreshed = await refreshOAuthAccessToken(platformUrl, creds.refresh_token, localSecret);
|
|
583
|
+
nextCredentials = {
|
|
584
|
+
...creds,
|
|
585
|
+
access_token: refreshed.access_token,
|
|
586
|
+
access_token_expires_at: refreshed.access_token_expires_at,
|
|
587
|
+
refresh_token: refreshed.refresh_token || creds.refresh_token
|
|
588
|
+
};
|
|
589
|
+
saveCredentialsToSource(nextCredentials, selectedCredentials?.source);
|
|
590
|
+
} catch (refreshError) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`Stored Overlord session expired and refresh failed. ${refreshError instanceof Error ? refreshError.message : String(refreshError)} Run \`ovld auth login\` again.`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (!Number.isFinite(nextCredentials.organization_id)) {
|
|
598
|
+
throw new Error('Overlord login is missing an organization selection. Run `ovld auth login` again.');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (!nextCredentials.access_token) {
|
|
602
|
+
throw new Error('No OAuth access token is available. Run `ovld auth login` again.');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
platformUrl,
|
|
607
|
+
bearerToken: nextCredentials.access_token,
|
|
608
|
+
localSecret,
|
|
609
|
+
organizationId: nextCredentials.organization_id,
|
|
610
|
+
authMode: 'oauth'
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
platformUrl,
|
|
616
|
+
bearerToken: 'overlord-local-dev-token',
|
|
617
|
+
localSecret,
|
|
618
|
+
organizationId: null,
|
|
619
|
+
authMode: 'local_fallback'
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export async function getAuthStatus() {
|
|
624
|
+
const creds = loadCredentials();
|
|
625
|
+
let resolved;
|
|
626
|
+
let error = null;
|
|
627
|
+
try {
|
|
628
|
+
resolved = await resolveAuth();
|
|
629
|
+
} catch (resolveError) {
|
|
630
|
+
error = resolveError instanceof Error ? resolveError.message : String(resolveError);
|
|
631
|
+
resolved = {
|
|
632
|
+
platformUrl:
|
|
633
|
+
normalizePlatformUrl(process.env.OVERLORD_URL) ??
|
|
634
|
+
normalizeStoredPlatformUrl(creds?.platform_url) ??
|
|
635
|
+
getDefaultOverlordUrl(),
|
|
636
|
+
localSecret: '',
|
|
637
|
+
organizationId: creds?.organization_id ?? null,
|
|
638
|
+
authMode: 'error'
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const envAccessToken = normalizeAccessToken(process.env.OVERLORD_ACCESS_TOKEN);
|
|
643
|
+
let tokenSource = 'fallback';
|
|
644
|
+
if (envAccessToken && isEnvAccessTokenUsable(envAccessToken)) {
|
|
645
|
+
tokenSource = 'OVERLORD_ACCESS_TOKEN';
|
|
646
|
+
} else if (creds?.refresh_token) {
|
|
647
|
+
tokenSource = getCredentialFileSource();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let platformUrlSource = 'default';
|
|
651
|
+
if (normalizePlatformUrl(process.env.OVERLORD_URL)) {
|
|
652
|
+
platformUrlSource = 'OVERLORD_URL';
|
|
653
|
+
} else if (normalizeStoredPlatformUrl(creds?.platform_url)) {
|
|
654
|
+
platformUrlSource = getCredentialFileSource();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
isLoggedIn: tokenSource !== 'fallback' && !error,
|
|
659
|
+
platformUrl: resolved.platformUrl,
|
|
660
|
+
platformUrlSource,
|
|
661
|
+
tokenPresent: tokenSource !== 'fallback',
|
|
662
|
+
tokenSource,
|
|
663
|
+
hasLocalSecret: Boolean(resolved.localSecret),
|
|
664
|
+
organizationId: resolved.organizationId ?? null,
|
|
665
|
+
authMode: resolved.authMode,
|
|
666
|
+
error,
|
|
667
|
+
credentialsFileExists: fileExists(CLI_CREDENTIALS_FILE),
|
|
668
|
+
legacyCredentialsFileExists: fileExists(LEGACY_CREDENTIALS_FILE),
|
|
669
|
+
electronCredentialsFileExists: fileExists(LEGACY_ELECTRON_CREDENTIALS_FILE)
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export function repairCredentials() {
|
|
674
|
+
const creds = loadCredentials();
|
|
675
|
+
if (!creds) {
|
|
676
|
+
ensureCredentialsDir();
|
|
677
|
+
return {
|
|
678
|
+
repaired: false,
|
|
679
|
+
reason: 'No valid stored credentials were found.',
|
|
680
|
+
status: null
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
saveCredentials(creds);
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
repaired: true,
|
|
688
|
+
status: null
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function normalizeAccessToken(value) {
|
|
693
|
+
if (typeof value !== 'string') return '';
|
|
694
|
+
return value.trim();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function isEnvAccessTokenUsable(accessToken) {
|
|
698
|
+
const expiresAt = decodeJwtExpiry(accessToken);
|
|
699
|
+
return expiresAt === null || expiresAt * 1000 - Date.now() > 60_000;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function normalizePlatformUrl(value) {
|
|
703
|
+
if (typeof value !== 'string') return undefined;
|
|
704
|
+
const trimmed = value.trim();
|
|
705
|
+
if (!trimmed) return undefined;
|
|
706
|
+
try {
|
|
707
|
+
const parsed = new URL(trimmed);
|
|
708
|
+
if (!isSupportedPlatformUrl(parsed.toString())) return undefined;
|
|
709
|
+
if (parsed.protocol === 'https:' && parsed.hostname === 'ovld.ai') {
|
|
710
|
+
return HOSTED_OVERLORD_URL;
|
|
711
|
+
}
|
|
712
|
+
return parsed.origin;
|
|
713
|
+
} catch {
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function normalizeStoredPlatformUrl(value) {
|
|
719
|
+
const normalized = normalizePlatformUrl(value);
|
|
720
|
+
if (!normalized) return undefined;
|
|
721
|
+
if (isLocalhostUrl(normalized) && !isLocalDevOverlordUrl(normalized)) return undefined;
|
|
722
|
+
return normalized;
|
|
723
|
+
}
|