@omen.foundation/node-microservice-runtime 0.1.65 → 0.1.68

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/src/cli/index.ts DELETED
@@ -1,725 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
5
- import os from 'node:os';
6
- import { spawn } from 'node:child_process';
7
- import readline from 'node:readline';
8
- import { stdin, stdout, exit } from 'node:process';
9
- import { fileURLToPath } from 'node:url';
10
- import dotenv from 'dotenv';
11
-
12
- type KeytarModule = typeof import('keytar');
13
-
14
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
-
16
- interface TokenRecord {
17
- accessToken: string;
18
- refreshToken: string;
19
- expiresAt?: number;
20
- }
21
-
22
- interface ProfileConfig {
23
- id: string;
24
- cid: string;
25
- pid: string;
26
- host: string;
27
- apiHost: string;
28
- email: string;
29
- lastUsed?: number;
30
- }
31
-
32
- interface ConfigFile {
33
- profiles: Record<string, ProfileConfig>;
34
- lastUsed?: string;
35
- }
36
-
37
- interface LoginOptions {
38
- cid: string;
39
- pid: string;
40
- host: string;
41
- email: string;
42
- password: string;
43
- }
44
-
45
- interface ProfileSelectionOptions {
46
- cid?: string;
47
- pid?: string;
48
- host?: string;
49
- profileId?: string;
50
- }
51
-
52
- const SERVICE_NAME = 'beamo-node';
53
- const CONFIG_DIR = path.join(os.homedir(), '.beamo-node');
54
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
55
- const TOKEN_FALLBACK_DIR = path.join(CONFIG_DIR, 'tokens');
56
- const DEFAULT_SOCKET_HOST = 'wss://api.beamable.com/socket';
57
- const MIN_TOKEN_BUFFER_MS = 60_000;
58
-
59
- class CredentialStore {
60
- private keytar?: KeytarModule;
61
-
62
- private constructor(keytar?: KeytarModule) {
63
- this.keytar = keytar;
64
- }
65
-
66
- static async create(): Promise<CredentialStore> {
67
- try {
68
- // eslint-disable-next-line @typescript-eslint/consistent-type-imports
69
- const keytarModule: KeytarModule = (await import('keytar')).default;
70
- return new CredentialStore(keytarModule);
71
- } catch (error) {
72
- console.warn('[beamo-node] keytar not available, falling back to file-based credential storage.');
73
- return new CredentialStore();
74
- }
75
- }
76
-
77
- async get(account: string): Promise<TokenRecord | undefined> {
78
- if (this.keytar) {
79
- const payload = await this.keytar.getPassword(SERVICE_NAME, account);
80
- if (!payload) {
81
- return undefined;
82
- }
83
- return this.parseTokenPayload(payload);
84
- }
85
-
86
- const filePath = this.fallbackPath(account);
87
- try {
88
- const payload = await fs.readFile(filePath, 'utf8');
89
- return this.parseTokenPayload(payload);
90
- } catch (error) {
91
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
92
- return undefined;
93
- }
94
- throw error;
95
- }
96
- }
97
-
98
- async set(account: string, record: TokenRecord): Promise<void> {
99
- const payload = JSON.stringify(record);
100
- if (this.keytar) {
101
- await this.keytar.setPassword(SERVICE_NAME, account, payload);
102
- return;
103
- }
104
-
105
- await fs.mkdir(TOKEN_FALLBACK_DIR, { recursive: true });
106
- const filePath = this.fallbackPath(account);
107
- await fs.writeFile(filePath, payload, { mode: 0o600, encoding: 'utf8' });
108
- }
109
-
110
- async clear(account: string): Promise<void> {
111
- if (this.keytar) {
112
- await this.keytar.deletePassword(SERVICE_NAME, account);
113
- return;
114
- }
115
- try {
116
- await fs.unlink(this.fallbackPath(account));
117
- } catch (error) {
118
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
119
- throw error;
120
- }
121
- }
122
- }
123
-
124
- private parseTokenPayload(payload: string): TokenRecord {
125
- const parsed = JSON.parse(payload) as Partial<TokenRecord>;
126
- if (!parsed || typeof parsed !== 'object') {
127
- throw new Error('Stored credentials are malformed.');
128
- }
129
- if (typeof parsed.accessToken !== 'string' || typeof parsed.refreshToken !== 'string') {
130
- throw new Error('Stored credentials missing access or refresh token.');
131
- }
132
- return {
133
- accessToken: parsed.accessToken,
134
- refreshToken: parsed.refreshToken,
135
- expiresAt: typeof parsed.expiresAt === 'number' ? parsed.expiresAt : undefined,
136
- };
137
- }
138
-
139
- private fallbackPath(account: string): string {
140
- const safe = Buffer.from(account).toString('base64url');
141
- return path.join(TOKEN_FALLBACK_DIR, `${safe}.json`);
142
- }
143
- }
144
-
145
- class ConfigStore {
146
- async load(): Promise<ConfigFile> {
147
- try {
148
- const payload = await fs.readFile(CONFIG_FILE, 'utf8');
149
- const data = JSON.parse(payload) as ConfigFile;
150
- return {
151
- profiles: data?.profiles ?? {},
152
- lastUsed: data?.lastUsed,
153
- };
154
- } catch (error) {
155
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
156
- return { profiles: {} };
157
- }
158
- throw error;
159
- }
160
- }
161
-
162
- async save(config: ConfigFile): Promise<void> {
163
- await fs.mkdir(CONFIG_DIR, { recursive: true });
164
- const payload = JSON.stringify(config, null, 2);
165
- await fs.writeFile(CONFIG_FILE, `${payload}\n`, { encoding: 'utf8', mode: 0o600 });
166
- }
167
- }
168
-
169
- class TokenManager {
170
- constructor(private readonly store: CredentialStore) {}
171
-
172
- async login(options: LoginOptions): Promise<TokenRecord> {
173
- const apiHost = normalizeApiHost(options.host);
174
- // Scope should be cid.pid if both are provided (matches official CLI behavior)
175
- const scope = options.pid ? `${options.cid}.${options.pid}` : options.cid;
176
- // Admin accounts require customerScoped: true (matches C# CLI behavior)
177
- // The registry will accept customer-scoped tokens when proper headers are used
178
- const customerScoped = true;
179
- const response = await fetch(new URL('/basic/auth/token', apiHost), {
180
- method: 'POST',
181
- headers: {
182
- Accept: 'application/json',
183
- 'Content-Type': 'application/json',
184
- ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
185
- },
186
- body: JSON.stringify({
187
- grant_type: 'password',
188
- username: options.email,
189
- password: options.password,
190
- customerScoped,
191
- }),
192
- });
193
-
194
- if (!response.ok) {
195
- const message = await safeReadError(response);
196
- throw new Error(`Login failed (${response.status}): ${message}`);
197
- }
198
-
199
- const body = (await response.json()) as Record<string, unknown>;
200
- const accessToken = typeof body.access_token === 'string' ? body.access_token : undefined;
201
- const refreshToken = typeof body.refresh_token === 'string' ? body.refresh_token : undefined;
202
- const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : Number(body.expires_in ?? 0);
203
-
204
- if (!accessToken || !refreshToken) {
205
- throw new Error('Login response did not contain access and refresh tokens.');
206
- }
207
-
208
- const expiresAt = Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1000 : undefined;
209
- const record: TokenRecord = { accessToken, refreshToken, expiresAt };
210
- const account = profileKey({ cid: options.cid, pid: options.pid, apiHost });
211
-
212
- await this.store.set(account, record);
213
- return record;
214
- }
215
-
216
- async getValidTokens(cid: string, pid: string, host: string, forceRealmScoped = false): Promise<TokenRecord> {
217
- const apiHost = normalizeApiHost(host);
218
- const account = profileKey({ cid, pid, apiHost });
219
-
220
- const existing = await this.store.get(account);
221
- if (!existing) {
222
- throw new Error(`No stored credentials for ${cid}.${pid} (${apiHost}). Run "beamo-node login" first.`);
223
- }
224
-
225
- // Refresh token if expired or if explicitly requested
226
- // Note: If login was done with PID, tokens are already realm-scoped
227
- if (forceRealmScoped || isTokenExpired(existing.expiresAt)) {
228
- if (process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1') {
229
- console.log(`[beamo-node] Refreshing token (forceRealmScoped=${forceRealmScoped}, expired=${isTokenExpired(existing.expiresAt)})`);
230
- }
231
- const refreshed = await this.refreshToken(existing.refreshToken, cid, pid, apiHost);
232
- await this.store.set(account, refreshed);
233
- return refreshed;
234
- }
235
-
236
- return existing;
237
- }
238
-
239
- private async refreshToken(refreshToken: string, cid: string, pid: string, apiHost: string): Promise<TokenRecord> {
240
- const scope = pid ? `${cid}.${pid}` : cid;
241
- const response = await fetch(new URL('/basic/auth/token', apiHost), {
242
- method: 'POST',
243
- headers: {
244
- Accept: 'application/json',
245
- 'Content-Type': 'application/json',
246
- ...(scope ? { 'X-BEAM-SCOPE': scope } : {}),
247
- },
248
- body: JSON.stringify({
249
- grant_type: 'refresh_token',
250
- refresh_token: refreshToken,
251
- // Don't include customerScoped - refresh tokens inherit scope from the refresh token itself
252
- // Setting X-BEAM-SCOPE header should be enough to get a realm-scoped token
253
- }),
254
- });
255
-
256
- if (!response.ok) {
257
- const message = await safeReadError(response);
258
- throw new Error(`Failed to refresh token (${response.status}): ${message}`);
259
- }
260
-
261
- const body = (await response.json()) as Record<string, unknown>;
262
- const accessToken = typeof body.access_token === 'string' ? body.access_token : undefined;
263
- const newRefreshToken = typeof body.refresh_token === 'string' ? body.refresh_token : refreshToken;
264
- const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : Number(body.expires_in ?? 0);
265
-
266
- if (!accessToken) {
267
- throw new Error('Refresh token response missing access token.');
268
- }
269
-
270
- const expiresAt = Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1000 : undefined;
271
-
272
- // Verify the refreshed token works with realm scope
273
- if (pid && process.env.BEAMO_DEBUG === '1') {
274
- try {
275
- const testUrl = new URL('/basic/accounts/me', apiHost);
276
- const testResponse = await fetch(testUrl, {
277
- headers: {
278
- Authorization: `Bearer ${accessToken}`,
279
- Accept: 'application/json',
280
- 'X-BEAM-SCOPE': scope,
281
- },
282
- });
283
- if (!testResponse.ok) {
284
- console.warn(`[beamo-node] Warning: Refreshed token validation failed (${testResponse.status}). Token may still be customer-scoped.`);
285
- }
286
- } catch {
287
- // Ignore validation errors in debug mode
288
- }
289
- }
290
-
291
- return { accessToken, refreshToken: newRefreshToken, expiresAt };
292
- }
293
- }
294
-
295
- function parseCommandLine(argv: string[]): { command?: string; args: string[] } {
296
- const [command, ...rest] = argv;
297
- return { command, args: rest };
298
- }
299
-
300
- function splitArgs(args: string[], recognizedFlags: Set<string>): { flags: Record<string, string | boolean>; forward: string[] } {
301
- const flags: Record<string, string | boolean> = {};
302
- const forward: string[] = [];
303
-
304
- for (let index = 0; index < args.length; index += 1) {
305
- const token = args[index];
306
- if (!token.startsWith('-')) {
307
- forward.push(token);
308
- continue;
309
- }
310
-
311
- const key = token.replace(/^--/, '');
312
-
313
- if (recognizedFlags.has(key)) {
314
- const next = args[index + 1];
315
- if (next !== undefined && !next.startsWith('-')) {
316
- flags[key] = next;
317
- index += 1;
318
- } else {
319
- flags[key] = true;
320
- }
321
- } else {
322
- forward.push(token);
323
- const next = args[index + 1];
324
- if (next !== undefined && !next.startsWith('-')) {
325
- forward.push(next);
326
- index += 1;
327
- }
328
- }
329
- }
330
-
331
- return { flags, forward };
332
- }
333
-
334
- async function promptInput(question: string, defaultValue?: string): Promise<string> {
335
- const rl = readline.createInterface({ input: stdin, output: stdout });
336
- return new Promise<string>((resolve) => {
337
- rl.question(defaultValue ? `${question} (${defaultValue}): ` : `${question}: `, (answer) => {
338
- rl.close();
339
- const value = answer.trim();
340
- resolve(value === '' && defaultValue !== undefined ? defaultValue : value);
341
- });
342
- });
343
- }
344
-
345
- async function promptHidden(question: string): Promise<string> {
346
- const rl = readline.createInterface({ input: stdin, output: stdout } as readline.ReadLineOptions & { stdoutMuted?: boolean });
347
-
348
- return new Promise<string>((resolve) => {
349
- const mutable = rl as readline.Interface & {
350
- stdoutMuted?: boolean;
351
- _writeToOutput?: (stringToWrite: string) => void;
352
- output?: typeof stdout;
353
- };
354
- mutable.stdoutMuted = true;
355
- const originalWrite = mutable._writeToOutput?.bind(mutable);
356
- const outputStream = mutable.output ?? stdout;
357
- mutable._writeToOutput = (stringToWrite: string) => {
358
- if (mutable.stdoutMuted) {
359
- outputStream.write('*');
360
- } else if (originalWrite) {
361
- originalWrite(stringToWrite);
362
- } else {
363
- outputStream.write(stringToWrite);
364
- }
365
- };
366
-
367
- mutable.question(`${question}: `, (answer) => {
368
- mutable.close();
369
- stdout.write(os.EOL);
370
- resolve(answer);
371
- });
372
- });
373
- }
374
-
375
- async function handleLogin(args: string[], configStore: ConfigStore, tokenManager: TokenManager): Promise<void> {
376
- const { flags } = splitArgs(args, new Set(['cid', 'pid', 'host', 'email']));
377
- const envDefaults = await loadEnvDefaults();
378
-
379
- const cid = typeof flags.cid === 'string' ? flags.cid : await promptInput('CID', envDefaults.cid);
380
- const pid = typeof flags.pid === 'string' ? flags.pid : await promptInput('PID', envDefaults.pid);
381
- const hostDefault = envDefaults.host ?? DEFAULT_SOCKET_HOST;
382
- const host = typeof flags.host === 'string' ? flags.host : await promptInput('Host', hostDefault);
383
- const email = typeof flags.email === 'string' ? flags.email : await promptInput('Email');
384
- const password = await promptHidden('Password');
385
-
386
- if (!cid || !pid || !host || !email || !password) {
387
- throw new Error('CID, PID, host, email, and password are required.');
388
- }
389
-
390
- const record = await tokenManager.login({ cid, pid, host, email, password });
391
- const config = await configStore.load();
392
- const apiHost = normalizeApiHost(host);
393
- const id = profileKey({ cid, pid, apiHost });
394
-
395
- config.profiles[id] = {
396
- id,
397
- cid,
398
- pid,
399
- host,
400
- apiHost,
401
- email,
402
- lastUsed: Date.now(),
403
- };
404
- config.lastUsed = id;
405
-
406
- await configStore.save(config);
407
-
408
- console.log(`Login successful for ${email} (${cid}.${pid}).`);
409
- if (!record.expiresAt) {
410
- console.warn('[beamo-node] Access token does not include an expiration. You may need to re-login if publish fails.');
411
- }
412
- }
413
-
414
- async function handlePublish(args: string[], configStore: ConfigStore, tokenManager: TokenManager): Promise<void> {
415
- const { flags, forward } = splitArgs(args, new Set(['cid', 'pid', 'host', 'profile']));
416
- const profile = await resolveProfile(flags, configStore);
417
-
418
- // Refresh token if expired (customer-scoped tokens work with registry when proper headers are used)
419
- // The C# CLI uses the same access token for both registry API calls and Docker registry uploads
420
- const tokens = await tokenManager.getValidTokens(profile.cid, profile.pid, profile.host, false);
421
- const env = buildCommandEnv(profile, tokens);
422
-
423
- const scriptArgs = buildScriptArgs(forward, profile, true); // publish needs --api-host
424
-
425
- // Automatically add --env-file .env if .env exists and --env-file wasn't explicitly provided
426
- const hasEnvFileFlag = forward.some((arg, i) => arg === '--env-file' || (i > 0 && forward[i - 1] === '--env-file'));
427
- if (!hasEnvFileFlag) {
428
- const envPath = path.resolve('.env');
429
- try {
430
- await fs.access(envPath);
431
- scriptArgs.push('--env-file', '.env');
432
- } catch {
433
- // .env file doesn't exist, that's okay
434
- }
435
- }
436
-
437
- const scriptPath = path.resolve(__dirname, '../../scripts/publish-service.mjs');
438
- await runScript(scriptPath, scriptArgs, env);
439
- }
440
-
441
- async function handleValidate(args: string[], configStore: ConfigStore, tokenManager: TokenManager): Promise<void> {
442
- const { flags, forward } = splitArgs(args, new Set(['cid', 'pid', 'host', 'profile']));
443
- const profile = await resolveProfile(flags, configStore);
444
-
445
- // Validation does not require tokens, but downstream scripts expect CID/PID/HOST env values.
446
- const tokens = await tokenManager.getValidTokens(profile.cid, profile.pid, profile.host);
447
- const env = buildCommandEnv(profile, tokens);
448
-
449
- const scriptArgs = buildScriptArgs(forward, profile, false); // validate doesn't need --api-host
450
-
451
- // Automatically add --env-file .env if .env exists and --env-file wasn't explicitly provided
452
- const hasEnvFileFlag = forward.some((arg, i) => arg === '--env-file' || (i > 0 && forward[i - 1] === '--env-file'));
453
- if (!hasEnvFileFlag) {
454
- const envPath = path.resolve('.env');
455
- try {
456
- await fs.access(envPath);
457
- scriptArgs.push('--env-file', '.env');
458
- } catch {
459
- // .env file doesn't exist, that's okay
460
- }
461
- }
462
-
463
- const scriptPath = path.resolve(__dirname, '../../scripts/validate-service.mjs');
464
- await runScript(scriptPath, scriptArgs, env);
465
- }
466
-
467
- async function resolveProfile(flags: Record<string, string | boolean>, configStore: ConfigStore): Promise<ProfileConfig> {
468
- const config = await configStore.load();
469
-
470
- if (typeof flags.profile === 'string') {
471
- const explicit = config.profiles[flags.profile];
472
- if (!explicit) {
473
- throw new Error(`Profile "${flags.profile}" not found. Run "beamo-node login" to create it.`);
474
- }
475
- config.lastUsed = explicit.id;
476
- await configStore.save(config);
477
- return explicit;
478
- }
479
-
480
- const directOptions: ProfileSelectionOptions = {
481
- cid: typeof flags.cid === 'string' ? flags.cid : undefined,
482
- pid: typeof flags.pid === 'string' ? flags.pid : undefined,
483
- host: typeof flags.host === 'string' ? flags.host : undefined,
484
- };
485
-
486
- if (directOptions.cid && directOptions.pid && directOptions.host) {
487
- const apiHost = normalizeApiHost(directOptions.host);
488
- const id = profileKey({ cid: directOptions.cid, pid: directOptions.pid, apiHost });
489
- const profile = config.profiles[id];
490
- if (!profile) {
491
- throw new Error(`No stored credentials for ${directOptions.cid}.${directOptions.pid}. Run "beamo-node login" first.`);
492
- }
493
- profile.lastUsed = Date.now();
494
- config.lastUsed = profile.id;
495
- await configStore.save(config);
496
- return profile;
497
- }
498
-
499
- if (!config.lastUsed) {
500
- if (Object.keys(config.profiles).length === 0) {
501
- throw new Error('No stored credentials found. Run "beamo-node login" first.');
502
- }
503
- throw new Error('Multiple profiles found. Specify one with "--profile <id>" or run "beamo-node login" first.');
504
- }
505
-
506
- const profile = config.profiles[config.lastUsed];
507
- if (!profile) {
508
- throw new Error(`Last used profile "${config.lastUsed}" is missing. Run "beamo-node login" to refresh credentials.`);
509
- }
510
-
511
- profile.lastUsed = Date.now();
512
- config.lastUsed = profile.id;
513
- await configStore.save(config);
514
- return profile;
515
- }
516
-
517
- function buildScriptArgs(original: string[], profile: ProfileConfig, includeApiHost = true): string[] {
518
- const hasFlag = (flag: string) => original.some((token) => token === flag);
519
- const args: string[] = [...original];
520
- if (!hasFlag('--cid')) {
521
- args.push('--cid', profile.cid);
522
- }
523
- if (!hasFlag('--pid')) {
524
- args.push('--pid', profile.pid);
525
- }
526
- if (!hasFlag('--host')) {
527
- args.push('--host', profile.host);
528
- }
529
- if (includeApiHost && !hasFlag('--api-host')) {
530
- args.push('--api-host', profile.apiHost);
531
- }
532
- return args;
533
- }
534
-
535
- function buildCommandEnv(profile: ProfileConfig, tokens: TokenRecord): NodeJS.ProcessEnv {
536
- return {
537
- ...process.env,
538
- BEAMABLE_TOKEN: tokens.accessToken,
539
- ACCESS_TOKEN: tokens.accessToken,
540
- BEAMABLE_REFRESH_TOKEN: tokens.refreshToken,
541
- REFRESH_TOKEN: tokens.refreshToken,
542
- BEAMABLE_CID: profile.cid,
543
- CID: profile.cid,
544
- BEAMABLE_PID: profile.pid,
545
- PID: profile.pid,
546
- BEAMABLE_HOST: profile.host,
547
- HOST: profile.host,
548
- BEAMABLE_API_HOST: profile.apiHost,
549
- };
550
- }
551
-
552
- async function runScript(command: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> {
553
- const isNodeScript = /\.(mjs|cjs|js)$/i.test(command);
554
- const executable = isNodeScript ? process.execPath : command;
555
- const finalArgs = isNodeScript ? [command, ...args] : args;
556
- const shouldUseShell = process.platform === 'win32' && !isNodeScript;
557
- const debug = process.env.BEAMO_DEBUG === '1' || process.env.BEAMO_NODE_DEBUG === '1';
558
- if (debug) {
559
- console.log(
560
- `[beamo-node] command="${command}" exec="${executable}" args=${JSON.stringify(
561
- finalArgs,
562
- )} shell=${shouldUseShell} cwd=${process.cwd()}`,
563
- );
564
- }
565
-
566
- await new Promise<void>((resolve, reject) => {
567
- const child = spawn(executable, finalArgs, {
568
- stdio: 'inherit',
569
- env,
570
- shell: shouldUseShell,
571
- });
572
-
573
- child.on('error', (error) => reject(error));
574
- child.on('exit', (code) => {
575
- if (code === 0) {
576
- resolve();
577
- } else {
578
- reject(new Error(`Command failed with exit code ${code ?? 'unknown'}.`));
579
- }
580
- });
581
- });
582
- }
583
-
584
- function normalizeApiHost(host: string): string {
585
- const trimmed = host.trim();
586
- if (trimmed.startsWith('wss://')) {
587
- return `https://${trimmed.substring('wss://'.length).replace(/\/socket$/, '')}`;
588
- }
589
- if (trimmed.startsWith('ws://')) {
590
- return `http://${trimmed.substring('ws://'.length).replace(/\/socket$/, '')}`;
591
- }
592
- let normalized = trimmed.replace(/\/socket$/, '').replace(/\/$/, '');
593
- if (!/^https?:\/\//i.test(normalized)) {
594
- normalized = `https://${normalized}`;
595
- }
596
- return normalized;
597
- }
598
-
599
- function profileKey(input: { cid: string; pid: string; apiHost: string }): string {
600
- return `${input.cid}.${input.pid}@${input.apiHost}`;
601
- }
602
-
603
- function isTokenExpired(expiresAt?: number): boolean {
604
- if (!expiresAt || !Number.isFinite(expiresAt)) {
605
- return true;
606
- }
607
- return Date.now() + MIN_TOKEN_BUFFER_MS >= expiresAt;
608
- }
609
-
610
- async function safeReadError(response: Response): Promise<string> {
611
- try {
612
- const body = await response.text();
613
- if (!body) {
614
- return 'No response body';
615
- }
616
- try {
617
- const parsed = JSON.parse(body) as Record<string, unknown>;
618
- return typeof parsed.error === 'string'
619
- ? parsed.error
620
- : typeof parsed.message === 'string'
621
- ? parsed.message
622
- : body;
623
- } catch {
624
- return body;
625
- }
626
- } catch {
627
- return 'No response body';
628
- }
629
- }
630
-
631
- async function main(): Promise<void> {
632
- const [, , ...argv] = process.argv;
633
- const { command, args } = parseCommandLine(argv);
634
-
635
- if (!command || command === '--help' || command === '-h') {
636
- printHelp();
637
- return;
638
- }
639
-
640
- const credentialStore = await CredentialStore.create();
641
- const tokenManager = new TokenManager(credentialStore);
642
- const configStore = new ConfigStore();
643
-
644
- switch (command) {
645
- case 'login':
646
- await handleLogin(args, configStore, tokenManager);
647
- break;
648
- case 'publish':
649
- await handlePublish(args, configStore, tokenManager);
650
- break;
651
- case 'validate':
652
- await handleValidate(args, configStore, tokenManager);
653
- break;
654
- case 'profiles':
655
- await listProfiles(configStore);
656
- break;
657
- default:
658
- console.error(`Unknown command: ${command}`);
659
- printHelp();
660
- exit(1);
661
- }
662
- }
663
-
664
- async function listProfiles(configStore: ConfigStore): Promise<void> {
665
- const config = await configStore.load();
666
- const entries = Object.values(config.profiles).sort((a, b) => (b.lastUsed ?? 0) - (a.lastUsed ?? 0));
667
- if (entries.length === 0) {
668
- console.log('No stored profiles. Run "beamo-node login" to create one.');
669
- return;
670
- }
671
- console.log('Stored profiles:');
672
- for (const profile of entries) {
673
- const marker = config.lastUsed === profile.id ? '*' : ' ';
674
- console.log(` ${marker} ${profile.id}`);
675
- console.log(` CID: ${profile.cid}`);
676
- console.log(` PID: ${profile.pid}`);
677
- console.log(` Host: ${profile.host}`);
678
- console.log(` Email: ${profile.email}`);
679
- }
680
- }
681
-
682
- function printHelp(): void {
683
- console.log(`beamo-node CLI
684
-
685
- Usage:
686
- beamo-node login [--cid CID] [--pid PID] [--host HOST] [--email EMAIL]
687
- beamo-node publish [--profile ID] [--cid CID --pid PID --host HOST] [-- ...publish flags...]
688
- beamo-node validate [--profile ID] [--cid CID --pid PID --host HOST] [-- ...validate flags...]
689
- beamo-node profiles
690
-
691
- Commands:
692
- login Authenticate with Beamable and store credentials securely.
693
- publish Run the publish pipeline using stored credentials (forwards extra flags to the publish script).
694
- validate Generate OpenAPI docs and validate the project (forwards extra flags).
695
- profiles List stored profiles and indicate the default selection.
696
- `);
697
- }
698
-
699
- main().catch((error) => {
700
- console.error(error instanceof Error ? error.message : error);
701
- exit(1);
702
- });
703
-
704
- async function loadEnvDefaults(): Promise<ProfileSelectionOptions> {
705
- const envPath = path.resolve('.env');
706
- try {
707
- const content = await fs.readFile(envPath, 'utf8');
708
- const parsed = dotenv.parse(content);
709
- const cid = parsed.BEAMABLE_CID ?? parsed.CID ?? undefined;
710
- const pid = parsed.BEAMABLE_PID ?? parsed.PID ?? undefined;
711
- const host = parsed.BEAMABLE_HOST ?? parsed.HOST ?? undefined;
712
- return {
713
- cid: cid?.trim() || undefined,
714
- pid: pid?.trim() || undefined,
715
- host: host?.trim() || undefined,
716
- };
717
- } catch (error) {
718
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
719
- return {};
720
- }
721
- throw error;
722
- }
723
- }
724
-
725
-