overlord-cli 3.5.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.
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from 'node:child_process';
4
+ import crypto from 'node:crypto';
5
+ import http from 'node:http';
6
+
7
+ import { buildAuthHeaders, clearCredentials, loadCredentials, loadRuntime, saveCredentials } from './credentials.mjs';
8
+
9
+ const DEFAULT_OVERLORD_URL = process.env.OVERLORD_URL ?? 'http://localhost:3000';
10
+ const DEFAULT_CLI_REDIRECT_URI = 'http://127.0.0.1:45619/callback';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // PKCE helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function generateCodeVerifier() {
17
+ return crypto.randomBytes(96).toString('base64url');
18
+ }
19
+
20
+ function generateCodeChallenge(verifier) {
21
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
22
+ }
23
+
24
+ function generateState() {
25
+ return crypto.randomBytes(16).toString('hex');
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Redirect + callback listener
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function parseLoopbackRedirectUri(rawValue) {
33
+ const value = String(rawValue ?? '').trim();
34
+ if (!value) {
35
+ throw new Error('OAuth redirect URI is missing.');
36
+ }
37
+
38
+ let parsed;
39
+ try {
40
+ parsed = new URL(value);
41
+ } catch {
42
+ throw new Error(`Invalid OAuth redirect URI: ${value}`);
43
+ }
44
+
45
+ if (parsed.protocol !== 'http:') {
46
+ throw new Error('OAuth redirect URI must use http:// for loopback callbacks.');
47
+ }
48
+
49
+ if (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
50
+ throw new Error('OAuth redirect URI host must be 127.0.0.1 or localhost.');
51
+ }
52
+
53
+ const port = Number(parsed.port);
54
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
55
+ throw new Error(`OAuth redirect URI must include a valid port: ${value}`);
56
+ }
57
+
58
+ const callbackPath = parsed.pathname || '/';
59
+ return {
60
+ callbackPath,
61
+ host: parsed.hostname,
62
+ port,
63
+ redirectUri: `${parsed.origin}${callbackPath}`
64
+ };
65
+ }
66
+
67
+ function waitForOAuthCallback(host, port, callbackPath, expectedState) {
68
+ return new Promise((resolve, reject) => {
69
+ const server = http.createServer((req, res) => {
70
+ const url = new URL(req.url, `http://${host}:${port}`);
71
+ if (url.pathname !== callbackPath) {
72
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
73
+ res.end('Not found');
74
+ return;
75
+ }
76
+
77
+ const code = url.searchParams.get('code');
78
+ const returnedState = url.searchParams.get('state');
79
+ const errorParam = url.searchParams.get('error');
80
+
81
+ const html = (title, body) =>
82
+ `<html><body style="font-family:sans-serif;text-align:center;padding:60px"><h2>${title}</h2><p>${body}</p></body></html>`;
83
+
84
+ res.writeHead(200, { 'Content-Type': 'text/html' });
85
+
86
+ if (errorParam) {
87
+ res.end(html('Authorization Denied', 'You can close this window and return to the terminal.'));
88
+ server.close();
89
+ reject(new Error(`Authorization denied: ${errorParam}`));
90
+ return;
91
+ }
92
+
93
+ if (returnedState !== expectedState) {
94
+ res.end(html('Error', 'State mismatch. Please try again.'));
95
+ server.close();
96
+ reject(new Error('State mismatch — possible CSRF. Please try again.'));
97
+ return;
98
+ }
99
+
100
+ if (!code) {
101
+ res.end(html('Error', 'No authorization code received.'));
102
+ server.close();
103
+ reject(new Error('No authorization code in callback.'));
104
+ return;
105
+ }
106
+
107
+ res.end(html('Authorization Complete', 'You can close this window and return to the terminal.'));
108
+ server.close();
109
+ resolve(code);
110
+ });
111
+
112
+ server.listen(port, host);
113
+ server.on('error', (err) => {
114
+ if (err.code === 'EADDRINUSE') {
115
+ reject(
116
+ new Error(
117
+ `OAuth callback port ${port} is already in use. ` +
118
+ 'Close the application using that port or check for firewall/proxy interference, then try again.'
119
+ )
120
+ );
121
+ } else {
122
+ reject(err);
123
+ }
124
+ });
125
+ });
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Network helpers
130
+ // ---------------------------------------------------------------------------
131
+
132
+ function snippet(value, max = 180) {
133
+ const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
134
+ return normalized.length <= max ? normalized : `${normalized.slice(0, max)}...`;
135
+ }
136
+
137
+ async function readJsonOrThrow(res, context, baseUrl) {
138
+ const contentType = res.headers.get('content-type') ?? '';
139
+ const bodyText = await res.text();
140
+
141
+ if (!contentType.toLowerCase().includes('application/json')) {
142
+ throw new Error(
143
+ `${context} returned non-JSON content (${res.status}, ${contentType || 'unknown'}). ` +
144
+ `Response: ${snippet(bodyText)}\nCheck that Overlord is running at ${baseUrl}.`
145
+ );
146
+ }
147
+
148
+ try {
149
+ return JSON.parse(bodyText);
150
+ } catch {
151
+ throw new Error(
152
+ `${context} returned invalid JSON (${res.status}). Response: ${snippet(bodyText)}`
153
+ );
154
+ }
155
+ }
156
+
157
+ async function fetchAuthConfig(platformUrl, localSecret) {
158
+ const res = await fetch(`${platformUrl}/api/auth/config`, {
159
+ headers: buildAuthHeaders('', localSecret)
160
+ });
161
+ if (!res.ok) {
162
+ throw new Error(
163
+ `Failed to fetch auth config (${res.status}). Check that Overlord is running at ${platformUrl}.`
164
+ );
165
+ }
166
+ const config = await readJsonOrThrow(res, 'Auth config', platformUrl);
167
+ return {
168
+ ...config,
169
+ platform_url: new URL(res.url).origin
170
+ };
171
+ }
172
+
173
+ async function exchangeCodeForSupabaseTokens(supabaseUrl, clientId, code, codeVerifier, redirectUri) {
174
+ const res = await fetch(`${supabaseUrl}/auth/v1/oauth/token`, {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
177
+ body: new URLSearchParams({
178
+ grant_type: 'authorization_code',
179
+ code,
180
+ client_id: clientId,
181
+ redirect_uri: redirectUri,
182
+ code_verifier: codeVerifier
183
+ })
184
+ });
185
+
186
+ if (!res.ok) {
187
+ const text = await res.text();
188
+ throw new Error(`Token exchange failed (${res.status}): ${snippet(text)}`);
189
+ }
190
+
191
+ return readJsonOrThrow(res, 'Token exchange', supabaseUrl);
192
+ }
193
+
194
+ async function exchangeForAgentToken(platformUrl, supabaseAccessToken, localSecret) {
195
+ const res = await fetch(`${platformUrl}/api/auth/token`, {
196
+ method: 'POST',
197
+ headers: {
198
+ ...buildAuthHeaders('', localSecret),
199
+ Authorization: `Bearer ${supabaseAccessToken}`
200
+ }
201
+ });
202
+
203
+ if (!res.ok) {
204
+ const text = await res.text();
205
+ throw new Error(`Agent token exchange failed (${res.status}): ${snippet(text)}`);
206
+ }
207
+
208
+ return readJsonOrThrow(res, 'Agent token exchange', platformUrl);
209
+ }
210
+
211
+ function openBrowser(url) {
212
+ try {
213
+ const platform = process.platform;
214
+ if (platform === 'darwin') execFileSync('open', [url]);
215
+ else if (platform === 'win32') execFileSync('cmd', ['/c', 'start', '', url]);
216
+ else execFileSync('xdg-open', [url]);
217
+ } catch {
218
+ // Best-effort; user sees the URL in stdout anyway
219
+ }
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Public auth commands
224
+ // ---------------------------------------------------------------------------
225
+
226
+ export async function authLogin() {
227
+ const runtime = loadRuntime();
228
+ const platformUrl = process.env.OVERLORD_URL ?? runtime?.platform_url ?? DEFAULT_OVERLORD_URL;
229
+ const localSecret = runtime?.local_secret ?? process.env.OVERLORD_LOCAL_SECRET ?? '';
230
+
231
+ console.log('Starting Overlord CLI authorization...\n');
232
+
233
+ // 1. Discover OAuth config from the platform
234
+ let supabaseUrl, cliClientId, cliRedirectUri, resolvedPlatformUrl;
235
+ try {
236
+ const config = await fetchAuthConfig(platformUrl, localSecret);
237
+ supabaseUrl = config.supabase_url;
238
+ cliClientId = config.cli_client_id;
239
+ cliRedirectUri = config.cli_redirect_uri;
240
+ resolvedPlatformUrl = config.platform_url ?? platformUrl;
241
+ } catch (err) {
242
+ console.error(`\nError: ${err.message}`);
243
+ process.exit(1);
244
+ }
245
+
246
+ if (!supabaseUrl || !cliClientId) {
247
+ console.error(
248
+ '\nError: OAuth is not configured for CLI login. Set SUPABASE_OAUTH_CLI_CLIENT_ID on the Overlord server.'
249
+ );
250
+ process.exit(1);
251
+ }
252
+
253
+ // 2. PKCE parameters + state
254
+ const codeVerifier = generateCodeVerifier();
255
+ const codeChallenge = generateCodeChallenge(codeVerifier);
256
+ const state = generateState();
257
+
258
+ // 3. Use exact loopback redirect URI (Supabase does not support wildcard callback URLs)
259
+ let redirectTarget;
260
+ try {
261
+ redirectTarget = parseLoopbackRedirectUri(cliRedirectUri ?? DEFAULT_CLI_REDIRECT_URI);
262
+ } catch (err) {
263
+ console.error(`\nError: ${err.message}`);
264
+ process.exit(1);
265
+ }
266
+
267
+ const { host, port, callbackPath, redirectUri } = redirectTarget;
268
+
269
+ // 4. Build the Supabase OAuth authorization URL
270
+ const authorizeUrl = new URL(`${supabaseUrl}/auth/v1/oauth/authorize`);
271
+ authorizeUrl.searchParams.set('response_type', 'code');
272
+ authorizeUrl.searchParams.set('client_id', cliClientId);
273
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri);
274
+ authorizeUrl.searchParams.set('code_challenge', codeChallenge);
275
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256');
276
+ authorizeUrl.searchParams.set('state', state);
277
+ authorizeUrl.searchParams.set('scope', 'openid email');
278
+
279
+ console.log(` Authorization URL: ${authorizeUrl.toString()}`);
280
+ console.log('\nOpening browser...\n');
281
+
282
+ // 5. Start listener before opening browser so we don't miss the redirect
283
+ const callbackPromise = waitForOAuthCallback(host, port, callbackPath, state);
284
+ openBrowser(authorizeUrl.toString());
285
+
286
+ // 6. Wait for the auth code
287
+ let authCode;
288
+ try {
289
+ process.stdout.write('Waiting for browser authorization');
290
+ authCode = await callbackPromise;
291
+ console.log('\n');
292
+ } catch (err) {
293
+ console.error(`\n\nAuthorization failed: ${err.message}`);
294
+ process.exit(1);
295
+ }
296
+
297
+ // 7. Exchange auth code → Supabase tokens
298
+ let supabaseTokens;
299
+ try {
300
+ supabaseTokens = await exchangeCodeForSupabaseTokens(
301
+ supabaseUrl,
302
+ cliClientId,
303
+ authCode,
304
+ codeVerifier,
305
+ redirectUri
306
+ );
307
+ } catch (err) {
308
+ console.error(`\nError exchanging code for tokens: ${err.message}`);
309
+ process.exit(1);
310
+ }
311
+
312
+ // 8. Exchange Supabase access token → Overlord agent_token
313
+ let agentTokenData;
314
+ try {
315
+ agentTokenData = await exchangeForAgentToken(
316
+ resolvedPlatformUrl,
317
+ supabaseTokens.access_token,
318
+ localSecret
319
+ );
320
+ } catch (err) {
321
+ console.error(`\nError obtaining agent token: ${err.message}`);
322
+ process.exit(1);
323
+ }
324
+
325
+ // 9. Persist credentials (same format — backward-compatible)
326
+ saveCredentials({
327
+ access_token: agentTokenData.access_token,
328
+ platform_url: agentTokenData.platform_url ?? resolvedPlatformUrl
329
+ });
330
+
331
+ console.log('Logged in successfully!');
332
+ }
333
+
334
+ export function authStatus() {
335
+ const creds = loadCredentials();
336
+ if (!creds) {
337
+ console.log('Not logged in. Run: ovld auth login');
338
+ return;
339
+ }
340
+ console.log('Logged in');
341
+ console.log(` Platform URL: ${creds.platform_url}`);
342
+ if (creds.user_email) {
343
+ console.log(` Email: ${creds.user_email}`);
344
+ }
345
+ }
346
+
347
+ export function authLogout() {
348
+ clearCredentials();
349
+ console.log('Logged out.');
350
+ }
351
+
352
+ export async function runAuthCommand(subcommand) {
353
+ if (!subcommand || subcommand === 'help' || subcommand === '--help') {
354
+ console.log(`ovld auth <subcommand>
355
+
356
+ Subcommands:
357
+ login Authorize the CLI via browser (OAuth PKCE flow)
358
+ status Show current login status
359
+ logout Remove stored credentials
360
+ `);
361
+ return;
362
+ }
363
+
364
+ if (subcommand === 'login') {
365
+ await authLogin();
366
+ return;
367
+ }
368
+
369
+ if (subcommand === 'status') {
370
+ authStatus();
371
+ return;
372
+ }
373
+
374
+ if (subcommand === 'logout') {
375
+ authLogout();
376
+ return;
377
+ }
378
+
379
+ console.error(`Unknown auth subcommand: ${subcommand}\n`);
380
+ console.log('Run: ovld auth help');
381
+ process.exit(1);
382
+ }
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+
3
+ /* global process, URL */
4
+
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const CREDENTIALS_DIR = path.join(os.homedir(), '.ovld');
10
+ const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
11
+ const RUNTIME_FILE_PATTERN = /^runtime\..+\.json$/;
12
+ const DEFAULT_OVERLORD_URL = 'http://localhost:3000';
13
+ const LOCAL_SECRET_HEADER = 'X-Overlord-Local-Secret';
14
+
15
+ /**
16
+ * @typedef {{ access_token: string, platform_url: string, user_email?: string }} Credentials
17
+ */
18
+
19
+ /** @returns {Credentials | null} */
20
+ export function loadCredentials() {
21
+ try {
22
+ const raw = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
23
+ return JSON.parse(raw);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /** @param {Credentials} data */
30
+ export function saveCredentials(data) {
31
+ fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
32
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
33
+ }
34
+
35
+ export function clearCredentials() {
36
+ try {
37
+ fs.unlinkSync(CREDENTIALS_FILE);
38
+ } catch {
39
+ // Already gone
40
+ }
41
+ }
42
+
43
+ function getRuntimeFilePath(targetUrl) {
44
+ try {
45
+ const parsed = new URL(targetUrl);
46
+ const normalized = parsed.origin
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9]+/g, '-')
49
+ .replace(/^-+|-+$/g, '');
50
+ return path.join(CREDENTIALS_DIR, `runtime.${normalized || 'unknown'}.json`);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function getLegacyRuntimeFilePath(targetUrl) {
57
+ try {
58
+ const port = new URL(targetUrl).port || '80';
59
+ return path.join(CREDENTIALS_DIR, `runtime.${port}.json`);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function getAllRuntimeFiles() {
66
+ try {
67
+ return fs
68
+ .readdirSync(CREDENTIALS_DIR)
69
+ .filter(f => RUNTIME_FILE_PATTERN.test(f))
70
+ .map(f => path.join(CREDENTIALS_DIR, f))
71
+ .sort((left, right) => {
72
+ try {
73
+ return fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs;
74
+ } catch {
75
+ return 0;
76
+ }
77
+ });
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function getRuntimeStatIfSecure(filePath) {
84
+ try {
85
+ const stat = fs.statSync(filePath);
86
+ if (!stat.isFile()) return null;
87
+
88
+ const mode = stat.mode & 0o777;
89
+ if ((mode & 0o077) !== 0) return null;
90
+
91
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
92
+ return null;
93
+ }
94
+
95
+ return stat;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function loadRuntimeFromFile(filePath) {
102
+ if (!getRuntimeStatIfSecure(filePath)) return null;
103
+
104
+ try {
105
+ const raw = fs.readFileSync(filePath, 'utf8');
106
+ const parsed = JSON.parse(raw);
107
+
108
+ if (
109
+ !parsed ||
110
+ typeof parsed !== 'object' ||
111
+ typeof parsed.platform_url !== 'string' ||
112
+ typeof parsed.pid !== 'number' ||
113
+ !isRunningPid(parsed.pid) ||
114
+ !isSupportedPlatformUrl(parsed.platform_url)
115
+ ) {
116
+ return null;
117
+ }
118
+
119
+ if (parsed.local_secret !== undefined && typeof parsed.local_secret !== 'string') {
120
+ return null;
121
+ }
122
+
123
+ return {
124
+ platform_url: parsed.platform_url,
125
+ local_secret: parsed.local_secret
126
+ };
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function isLocalhostUrl(value) {
133
+ try {
134
+ const parsed = new URL(value);
135
+ if (parsed.protocol !== 'http:') return false;
136
+ return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ function isSupportedPlatformUrl(value) {
143
+ try {
144
+ const parsed = new URL(value);
145
+ if (isLocalhostUrl(value)) return true;
146
+ return parsed.protocol === 'https:';
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ function isRunningPid(pid) {
153
+ if (!Number.isInteger(pid) || pid <= 0) return false;
154
+ try {
155
+ process.kill(pid, 0);
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /** @returns {{ platform_url?: string, local_secret?: string } | null} */
163
+ export function loadRuntime(targetUrl) {
164
+ if (targetUrl) {
165
+ const candidatePaths = [getRuntimeFilePath(targetUrl), getLegacyRuntimeFilePath(targetUrl)]
166
+ .filter(Boolean);
167
+ for (const filePath of candidatePaths) {
168
+ const runtime = loadRuntimeFromFile(filePath);
169
+ if (runtime) return runtime;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ for (const filePath of getAllRuntimeFiles()) {
175
+ const result = loadRuntimeFromFile(filePath);
176
+ if (result) return result;
177
+ }
178
+
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * @param {string} token
184
+ * @param {string} [localSecret]
185
+ * @returns {Record<string, string>}
186
+ */
187
+ export function buildAuthHeaders(token, localSecret) {
188
+ const headers = {};
189
+
190
+ if (token) {
191
+ headers.Authorization = `Bearer ${token}`;
192
+ }
193
+
194
+ if (localSecret) {
195
+ headers[LOCAL_SECRET_HEADER] = localSecret;
196
+ }
197
+
198
+ return headers;
199
+ }
200
+
201
+ /**
202
+ * Resolve the overlord URL and agent token from credentials file or env vars.
203
+ * @returns {{ platformUrl: string, agentToken: string }}
204
+ */
205
+ export function resolveAuth() {
206
+ const creds = loadCredentials();
207
+ const connectorUrlFromEnv = normalizePlatformUrl(process.env.OVERLORD_CONNECTOR_URL);
208
+ const overlordUrlFromEnv = normalizePlatformUrl(process.env.OVERLORD_URL);
209
+ const overlordUrlFromCreds = normalizePlatformUrl(creds?.platform_url);
210
+
211
+ const runtimeTarget =
212
+ connectorUrlFromEnv || isLocalhostUrl(overlordUrlFromEnv)
213
+ ? (connectorUrlFromEnv || overlordUrlFromEnv)
214
+ : null;
215
+ const targetedRuntime = loadRuntime(runtimeTarget ?? null);
216
+ const fallbackRuntime = targetedRuntime ?? loadRuntime(null);
217
+ const runtime =
218
+ targetedRuntime && isLocalhostUrl(targetedRuntime.platform_url)
219
+ ? targetedRuntime
220
+ : fallbackRuntime && isLocalhostUrl(fallbackRuntime.platform_url)
221
+ ? fallbackRuntime
222
+ : targetedRuntime;
223
+ const runtimeOverlordUrl = runtime?.platform_url;
224
+
225
+ const platformUrl =
226
+ connectorUrlFromEnv ??
227
+ (overlordUrlFromEnv && isLocalhostUrl(overlordUrlFromEnv) ? overlordUrlFromEnv : undefined) ??
228
+ runtimeOverlordUrl ??
229
+ overlordUrlFromEnv ??
230
+ overlordUrlFromCreds ??
231
+ DEFAULT_OVERLORD_URL;
232
+ const localSecret =
233
+ runtime &&
234
+ runtime.local_secret &&
235
+ runtimeOverlordUrl &&
236
+ runtimeOverlordUrl === platformUrl &&
237
+ isLocalhostUrl(platformUrl)
238
+ ? runtime.local_secret
239
+ : '';
240
+
241
+ return {
242
+ platformUrl,
243
+ agentToken:
244
+ normalizeAgentToken(process.env.AGENT_TOKEN) ||
245
+ normalizeAgentToken(creds?.access_token) ||
246
+ 'overlord-local-dev-token',
247
+ localSecret
248
+ };
249
+ }
250
+
251
+ function normalizeAgentToken(value) {
252
+ if (typeof value !== 'string') return '';
253
+ return value.trim();
254
+ }
255
+
256
+ function normalizePlatformUrl(value) {
257
+ if (typeof value !== 'string') return undefined;
258
+ const trimmed = value.trim();
259
+ if (!trimmed) return undefined;
260
+ try {
261
+ const parsed = new URL(trimmed);
262
+ if (!isSupportedPlatformUrl(parsed.toString())) return undefined;
263
+ return parsed.origin;
264
+ } catch {
265
+ return undefined;
266
+ }
267
+ }