@syncular/cli 0.0.0-44

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,514 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { createServer } from 'node:http';
5
+ import process from 'node:process';
6
+ import {
7
+ clearStoredControlPlaneToken,
8
+ readStoredControlPlaneToken,
9
+ writeStoredControlPlaneToken,
10
+ } from '../auth-storage';
11
+ import { CLI_NAME } from '../constants';
12
+ import { normalizeControlPlaneBaseUrl } from '../control-plane';
13
+ import { printError, printInfo } from '../output';
14
+ import { DEFAULT_CONTROL_PLANE_API_BASE } from '../spaces-config';
15
+
16
+ interface AuthMeResponse {
17
+ actorId?: string;
18
+ source?: 'clerk' | 'insecure-header' | 'insecure-query';
19
+ clerkUserId?: string | null;
20
+ error?: string;
21
+ message?: string;
22
+ }
23
+
24
+ interface WhoAmIOutput {
25
+ controlPlane: string;
26
+ actorId: string | null;
27
+ source: AuthMeResponse['source'] | null;
28
+ clerkUserId: string | null;
29
+ }
30
+
31
+ function optionalFlag(
32
+ flagValues: Map<string, string>,
33
+ flag: string,
34
+ fallback: string
35
+ ): string {
36
+ const value = flagValues.get(flag)?.trim();
37
+ return value && value.length > 0 ? value : fallback;
38
+ }
39
+
40
+ function optionalIntegerFlag(
41
+ flagValues: Map<string, string>,
42
+ flag: string,
43
+ fallback: number
44
+ ): number {
45
+ const raw = flagValues.get(flag)?.trim();
46
+ if (!raw) {
47
+ return fallback;
48
+ }
49
+ const parsed = Number.parseInt(raw, 10);
50
+ if (!Number.isFinite(parsed) || parsed < 0) {
51
+ throw new Error(`Invalid ${flag} value: ${raw}`);
52
+ }
53
+ return parsed;
54
+ }
55
+
56
+ function optionalBooleanFlag(
57
+ flagValues: Map<string, string>,
58
+ flag: string,
59
+ fallback: boolean
60
+ ): boolean {
61
+ const value = flagValues.get(flag);
62
+ if (!value) {
63
+ return fallback;
64
+ }
65
+
66
+ const normalized = value.trim().toLowerCase();
67
+ if (
68
+ normalized === '1' ||
69
+ normalized === 'true' ||
70
+ normalized === 'yes' ||
71
+ normalized === 'on'
72
+ ) {
73
+ return true;
74
+ }
75
+ if (
76
+ normalized === '0' ||
77
+ normalized === 'false' ||
78
+ normalized === 'no' ||
79
+ normalized === 'off'
80
+ ) {
81
+ return false;
82
+ }
83
+
84
+ throw new Error(
85
+ `Invalid ${flag} value "${value}". Use true/false, yes/no, on/off, or 1/0.`
86
+ );
87
+ }
88
+
89
+ function parseBearerToken(token: string): string {
90
+ const trimmed = token.trim();
91
+ if (trimmed.toLowerCase().startsWith('bearer ')) {
92
+ return trimmed.slice('bearer '.length).trim();
93
+ }
94
+ return trimmed;
95
+ }
96
+
97
+ function resolveControlPlaneBase(flagValues: Map<string, string>): string {
98
+ const value = optionalFlag(
99
+ flagValues,
100
+ '--control-plane',
101
+ process.env.SPACES_CONTROL_PLANE_BASE_URL?.trim() ||
102
+ DEFAULT_CONTROL_PLANE_API_BASE
103
+ );
104
+ return normalizeControlPlaneBaseUrl(value);
105
+ }
106
+
107
+ function defaultCliAuthUrl(controlPlaneBase: string): string {
108
+ return `${controlPlaneBase.replace(/\/$/, '')}/cli-login`;
109
+ }
110
+
111
+ async function resolveProvidedToken(
112
+ flagValues: Map<string, string>
113
+ ): Promise<string | null> {
114
+ const explicitToken =
115
+ flagValues.get('--token')?.trim() ||
116
+ flagValues.get('--control-token')?.trim() ||
117
+ process.env.SPACES_CONTROL_PLANE_TOKEN?.trim() ||
118
+ process.env.SPACES_CONTROL_TOKEN?.trim() ||
119
+ null;
120
+ if (explicitToken && explicitToken.length > 0) {
121
+ return parseBearerToken(explicitToken);
122
+ }
123
+
124
+ if (process.argv.includes('--token-stdin')) {
125
+ try {
126
+ const stdinRaw = readFileSync(0, 'utf8');
127
+ const stdinToken = parseBearerToken(stdinRaw);
128
+ if (stdinToken.length > 0) {
129
+ return stdinToken;
130
+ }
131
+ } catch {
132
+ // ignore and fall through to browser flow
133
+ }
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ function openBrowser(url: string): boolean {
140
+ try {
141
+ if (process.platform === 'darwin') {
142
+ const child = spawn('open', [url], {
143
+ detached: true,
144
+ stdio: 'ignore',
145
+ });
146
+ child.unref();
147
+ return true;
148
+ }
149
+
150
+ if (process.platform === 'win32') {
151
+ const child = spawn('cmd', ['/c', 'start', '', url], {
152
+ detached: true,
153
+ stdio: 'ignore',
154
+ windowsHide: true,
155
+ });
156
+ child.unref();
157
+ return true;
158
+ }
159
+
160
+ const child = spawn('xdg-open', [url], {
161
+ detached: true,
162
+ stdio: 'ignore',
163
+ });
164
+ child.unref();
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ function isAllowedCallbackHost(hostname: string): boolean {
172
+ const normalized = hostname.trim().toLowerCase();
173
+ return normalized === '127.0.0.1' || normalized === 'localhost';
174
+ }
175
+
176
+ async function waitForTokenViaBrowser(args: {
177
+ callbackHost: string;
178
+ callbackPort: number;
179
+ authUrl: string;
180
+ timeoutSeconds: number;
181
+ }): Promise<string> {
182
+ if (!isAllowedCallbackHost(args.callbackHost)) {
183
+ throw new Error(
184
+ `Invalid --callback-host "${args.callbackHost}". Use 127.0.0.1 or localhost.`
185
+ );
186
+ }
187
+
188
+ const state = randomUUID();
189
+ let settled = false;
190
+ let resolveToken: ((token: string) => void) | null = null;
191
+ let rejectToken: ((error: Error) => void) | null = null;
192
+
193
+ const tokenPromise = new Promise<string>((resolve, reject) => {
194
+ resolveToken = resolve;
195
+ rejectToken = reject;
196
+ });
197
+
198
+ const server = createServer((req, res) => {
199
+ try {
200
+ const requestUrl = new URL(
201
+ req.url || '/',
202
+ `http://${args.callbackHost}:${listeningPort}`
203
+ );
204
+ if (requestUrl.pathname !== '/callback') {
205
+ res.statusCode = 404;
206
+ res.end('Not found');
207
+ return;
208
+ }
209
+
210
+ const responseHeaders = {
211
+ 'Content-Type': 'text/html; charset=utf-8',
212
+ };
213
+
214
+ const callbackState = requestUrl.searchParams.get('state')?.trim() || '';
215
+ if (callbackState !== state) {
216
+ res.writeHead(400, responseHeaders);
217
+ res.end(
218
+ '<h2>CLI Login Failed</h2><p>Invalid state. Return to terminal.</p>'
219
+ );
220
+ if (!settled) {
221
+ settled = true;
222
+ rejectToken?.(new Error('CLI login state mismatch.'));
223
+ }
224
+ return;
225
+ }
226
+
227
+ const error = requestUrl.searchParams.get('error')?.trim();
228
+ if (error && error.length > 0) {
229
+ res.writeHead(400, responseHeaders);
230
+ res.end(
231
+ `<h2>CLI Login Failed</h2><p>${error}</p><p>Return to terminal.</p>`
232
+ );
233
+ if (!settled) {
234
+ settled = true;
235
+ rejectToken?.(new Error(`CLI login failed: ${error}`));
236
+ }
237
+ return;
238
+ }
239
+
240
+ const token = parseBearerToken(
241
+ requestUrl.searchParams.get('token')?.trim() || ''
242
+ );
243
+ if (token.length === 0) {
244
+ res.writeHead(400, responseHeaders);
245
+ res.end(
246
+ '<h2>CLI Login Failed</h2><p>Missing token in callback.</p><p>Return to terminal.</p>'
247
+ );
248
+ if (!settled) {
249
+ settled = true;
250
+ rejectToken?.(new Error('CLI login callback did not include token.'));
251
+ }
252
+ return;
253
+ }
254
+
255
+ res.writeHead(200, responseHeaders);
256
+ res.end(
257
+ '<h2>CLI Login Complete</h2><p>You can close this tab and return to the terminal.</p>'
258
+ );
259
+ if (!settled) {
260
+ settled = true;
261
+ resolveToken?.(token);
262
+ }
263
+ } catch (error: unknown) {
264
+ res.statusCode = 500;
265
+ res.end('Unexpected callback error');
266
+ if (!settled) {
267
+ settled = true;
268
+ rejectToken?.(
269
+ error instanceof Error
270
+ ? error
271
+ : new Error('Unexpected callback parsing error.')
272
+ );
273
+ }
274
+ }
275
+ });
276
+
277
+ let listeningPort = args.callbackPort;
278
+ await new Promise<void>((resolve, reject) => {
279
+ server.once('error', (error) =>
280
+ reject(error instanceof Error ? error : new Error(String(error)))
281
+ );
282
+ server.listen(args.callbackPort, args.callbackHost, () => {
283
+ const address = server.address();
284
+ if (!address || typeof address === 'string') {
285
+ reject(new Error('Failed to allocate callback server port.'));
286
+ return;
287
+ }
288
+ listeningPort = address.port;
289
+ resolve();
290
+ });
291
+ });
292
+
293
+ const callbackUrl = `http://${args.callbackHost}:${listeningPort}/callback`;
294
+ const loginUrl = new URL(args.authUrl);
295
+ loginUrl.searchParams.set('callback', callbackUrl);
296
+ loginUrl.searchParams.set('state', state);
297
+
298
+ const opened = openBrowser(loginUrl.toString());
299
+ if (opened) {
300
+ printInfo(`Opening browser for CLI authentication: ${loginUrl}`);
301
+ } else {
302
+ printInfo('Failed to auto-open browser. Open this URL manually:');
303
+ console.log(loginUrl.toString());
304
+ }
305
+
306
+ try {
307
+ const timeoutMs = args.timeoutSeconds * 1000;
308
+ const timeoutPromise = new Promise<string>((_, reject) => {
309
+ setTimeout(() => {
310
+ reject(
311
+ new Error(
312
+ `Timed out waiting for browser login callback after ${args.timeoutSeconds}s.`
313
+ )
314
+ );
315
+ }, timeoutMs);
316
+ });
317
+ return await Promise.race([tokenPromise, timeoutPromise]);
318
+ } finally {
319
+ server.close();
320
+ }
321
+ }
322
+
323
+ async function fetchAuthMe(args: {
324
+ controlPlaneBase: string;
325
+ token: string;
326
+ }): Promise<{
327
+ ok: boolean;
328
+ status: number;
329
+ payload: AuthMeResponse | null;
330
+ }> {
331
+ const response = await fetch(
332
+ `${args.controlPlaneBase.replace(/\/$/, '')}/auth/me`,
333
+ {
334
+ method: 'GET',
335
+ headers: {
336
+ Authorization: `Bearer ${args.token}`,
337
+ },
338
+ }
339
+ );
340
+
341
+ let payload: AuthMeResponse | null = null;
342
+ try {
343
+ payload = (await response.json()) as AuthMeResponse;
344
+ } catch {
345
+ payload = null;
346
+ }
347
+
348
+ return {
349
+ ok: response.ok,
350
+ status: response.status,
351
+ payload,
352
+ };
353
+ }
354
+
355
+ function printWhoAmIHuman(payload: WhoAmIOutput): void {
356
+ const rows: Array<{ label: string; value: string }> = [
357
+ { label: 'Status', value: 'authenticated' },
358
+ { label: 'Control Plane', value: payload.controlPlane },
359
+ { label: 'Actor ID', value: payload.actorId ?? '(missing)' },
360
+ { label: 'Auth Source', value: payload.source ?? '(unknown)' },
361
+ ];
362
+
363
+ if (payload.clerkUserId) {
364
+ rows.push({ label: 'Clerk User ID', value: payload.clerkUserId });
365
+ }
366
+
367
+ const keyWidth = rows.reduce(
368
+ (width, row) => Math.max(width, row.label.length),
369
+ 0
370
+ );
371
+ const contentLines = rows.map(
372
+ (row) => `${row.label.padEnd(keyWidth)} : ${row.value}`
373
+ );
374
+ const title = 'syncular whoami';
375
+ const contentWidth = Math.max(
376
+ title.length,
377
+ ...contentLines.map((line) => line.length)
378
+ );
379
+ const horizontal = '─'.repeat(contentWidth + 2);
380
+
381
+ console.log(`╭${horizontal}╮`);
382
+ console.log(`│ ${title.padEnd(contentWidth)} │`);
383
+ console.log(`├${horizontal}┤`);
384
+ for (const line of contentLines) {
385
+ console.log(`│ ${line.padEnd(contentWidth)} │`);
386
+ }
387
+ console.log(`╰${horizontal}╯`);
388
+ }
389
+
390
+ export async function runLogin(
391
+ flagValues: Map<string, string>
392
+ ): Promise<number> {
393
+ try {
394
+ const controlPlaneBase = resolveControlPlaneBase(flagValues);
395
+ const providedToken = await resolveProvidedToken(flagValues);
396
+
397
+ let token = providedToken;
398
+ if (!token) {
399
+ const callbackHost = optionalFlag(
400
+ flagValues,
401
+ '--callback-host',
402
+ '127.0.0.1'
403
+ );
404
+ const callbackPort = optionalIntegerFlag(
405
+ flagValues,
406
+ '--callback-port',
407
+ 0
408
+ );
409
+ const timeoutSeconds = optionalIntegerFlag(
410
+ flagValues,
411
+ '--timeout-seconds',
412
+ 180
413
+ );
414
+ if (timeoutSeconds <= 0) {
415
+ throw new Error('--timeout-seconds must be greater than 0.');
416
+ }
417
+ const authUrl = optionalFlag(
418
+ flagValues,
419
+ '--auth-url',
420
+ defaultCliAuthUrl(controlPlaneBase)
421
+ );
422
+ token = await waitForTokenViaBrowser({
423
+ callbackHost,
424
+ callbackPort,
425
+ authUrl,
426
+ timeoutSeconds,
427
+ });
428
+ }
429
+
430
+ const me = await fetchAuthMe({ controlPlaneBase, token });
431
+ if (!me.ok) {
432
+ throw new Error(
433
+ `Login failed (${me.status}): ${me.payload?.message || me.payload?.error || 'UNAUTHENTICATED'}`
434
+ );
435
+ }
436
+
437
+ await writeStoredControlPlaneToken({ controlPlaneBase, token });
438
+ console.log('CLI login saved.');
439
+ console.log(`Control plane: ${controlPlaneBase}`);
440
+ if (me.payload?.actorId) {
441
+ console.log(`Actor: ${me.payload.actorId}`);
442
+ }
443
+ if (me.payload?.source) {
444
+ console.log(`Auth source: ${me.payload.source}`);
445
+ }
446
+ return 0;
447
+ } catch (error: unknown) {
448
+ printError(
449
+ error instanceof Error ? error.message : 'Login failed unexpectedly.'
450
+ );
451
+ return 1;
452
+ }
453
+ }
454
+
455
+ export async function runLogout(
456
+ flagValues: Map<string, string>
457
+ ): Promise<number> {
458
+ try {
459
+ const controlPlaneBase = resolveControlPlaneBase(flagValues);
460
+ await clearStoredControlPlaneToken(controlPlaneBase);
461
+ console.log(`Cleared stored CLI token for ${controlPlaneBase}`);
462
+ return 0;
463
+ } catch (error: unknown) {
464
+ printError(
465
+ error instanceof Error ? error.message : 'Logout failed unexpectedly.'
466
+ );
467
+ return 1;
468
+ }
469
+ }
470
+
471
+ export async function runWhoAmI(
472
+ flagValues: Map<string, string>
473
+ ): Promise<number> {
474
+ try {
475
+ const jsonOutput = optionalBooleanFlag(flagValues, '--json', false);
476
+ const controlPlaneBase = resolveControlPlaneBase(flagValues);
477
+ const token =
478
+ (await resolveProvidedToken(flagValues)) ||
479
+ (await readStoredControlPlaneToken(controlPlaneBase));
480
+
481
+ if (!token) {
482
+ printError(
483
+ `No control-plane token available. Run \`${CLI_NAME} login\` first (or pass --token).`
484
+ );
485
+ return 1;
486
+ }
487
+
488
+ const me = await fetchAuthMe({ controlPlaneBase, token });
489
+ if (!me.ok) {
490
+ throw new Error(
491
+ `whoami failed (${me.status}): ${me.payload?.message || me.payload?.error || 'UNAUTHENTICATED'}`
492
+ );
493
+ }
494
+
495
+ const output: WhoAmIOutput = {
496
+ controlPlane: controlPlaneBase,
497
+ actorId: me.payload?.actorId ?? null,
498
+ source: me.payload?.source ?? null,
499
+ clerkUserId: me.payload?.clerkUserId ?? null,
500
+ };
501
+
502
+ if (jsonOutput) {
503
+ console.log(JSON.stringify(output, null, 2));
504
+ } else {
505
+ printWhoAmIHuman(output);
506
+ }
507
+ return 0;
508
+ } catch (error: unknown) {
509
+ printError(
510
+ error instanceof Error ? error.message : 'whoami failed unexpectedly.'
511
+ );
512
+ return 1;
513
+ }
514
+ }