@oml/cli 0.14.2 → 0.14.3

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.ts CHANGED
@@ -6,17 +6,15 @@ import * as fs from 'node:fs/promises';
6
6
  import * as path from 'node:path';
7
7
  import * as url from 'node:url';
8
8
  import { OmlCliAuthService } from './auth/auth.js';
9
- import { exchangeApiToken } from '@oml/platform';
10
- import { DEFAULT_API_BASE_URL } from './auth/constants.js';
11
9
  import { exportAction } from './commands/export.js';
12
10
  import { lintAction } from './commands/lint.js';
13
11
  import { renderAction } from './commands/render.js';
14
- import { serverStartAction, serverRunAction, serverStatusAction, serverStopAction } from './commands/server/actions.js';
12
+ import { serverStartAction, serverRunAction, serverStatusAction, serverStopAction, serverListAction } from './commands/server/actions.js';
15
13
  import { assertServerRunning } from './commands/server/require.js';
16
14
  import { notifyIfCliUpdateAvailable } from './update.js';
17
15
  import { validateAction } from './commands/validate.js';
18
16
  import { CliExitError } from './cli-error.js';
19
- import { initializePlatform, disposePlatform, trackCommand } from './auth/platform.js';
17
+ import { trackCommand } from './auth/platform.js';
20
18
 
21
19
  const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
22
20
  let debugEnabled = false;
@@ -71,32 +69,70 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
71
69
 
72
70
  program
73
71
  .command('login')
74
- .description('sign in with GitHub for CLI authorization')
72
+ .description('creates a sign-in session with OAuth for CLI')
75
73
  .action(async () => {
76
74
  await authService.login({});
77
75
  });
78
76
 
79
77
  program
80
78
  .command('logout')
81
- .description('remove the local sign-in session')
79
+ .description('remove the CLI OAuth sign-in session')
82
80
  .action(async () => {
83
81
  await authService.logout();
84
82
  });
85
83
 
86
84
  program
87
85
  .command('whoami')
88
- .description('print the current sign-in session')
86
+ .description('print the current CLI OAuth sign-in session (or api key account)')
89
87
  .action(async () => {
90
88
  await authService.whoami();
91
89
  });
92
90
 
91
+ program
92
+ .command('start [port]')
93
+ .option('-p, --port <port>', 'bind port (default: auto-select free port)')
94
+ .option('-w, --workspace <workspace>', 'workspace root used by REST facade initialize (default: cwd)')
95
+ .description('start an OML server')
96
+ .action(async (port: string | undefined, options: { port?: string; workspace?: string }) => {
97
+ if (process.env.OML_PLATFORM_API_KEY?.trim()) {
98
+ await serverStartAction(port, { ...options, auth: await resolveServerStartAuth() });
99
+ return;
100
+ }
101
+ await serverRunAction(port, {
102
+ ...options,
103
+ authService,
104
+ auth: await resolveServerRunAuth(authService),
105
+ });
106
+ });
107
+
108
+ program
109
+ .command('stop')
110
+ .description('stop the OML server')
111
+ .action(async () => {
112
+ await serverStopAction();
113
+ });
114
+
115
+ program
116
+ .command('status')
117
+ .description('print OML server status')
118
+ .action(async () => {
119
+ await serverStatusAction();
120
+ });
121
+
122
+ program
123
+ .command('list')
124
+ .description('show all actively running servers')
125
+ .action(async () => {
126
+ await serverListAction();
127
+ });
128
+
93
129
  program
94
130
  .command('lint')
95
131
  .description('lints OML files and prints any syntax or validation errors')
96
132
  .action(async (...args: unknown[]) => {
97
133
  const done = trackCommand('oml-lint');
98
134
  try {
99
- const authToken = await resolveServerRequestToken(authService);
135
+ const authToken = resolveServerRequestToken();
100
136
  await lintAction({
101
137
  ...(args[0] as Record<string, unknown> | undefined ?? {}),
102
138
  authToken,
@@ -117,7 +153,7 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
117
153
  .action(async (...args: unknown[]) => {
118
154
  const done = trackCommand('oml-render');
119
155
  try {
120
- const authToken = await resolveServerRequestToken(authService);
156
+ const authToken = resolveServerRequestToken();
121
157
  await renderAction({
122
158
  ...(args[0] as Record<string, unknown> | undefined ?? {}),
123
159
  authToken,
@@ -139,7 +175,7 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
139
175
  .action(async (...args: unknown[]) => {
140
176
  const done = trackCommand('oml-export');
141
177
  try {
142
- const authToken = await resolveServerRequestToken(authService);
178
+ const authToken = resolveServerRequestToken();
143
179
  await exportAction({
144
180
  ...(args[0] as Record<string, unknown> | undefined ?? {}),
145
181
  authToken,
@@ -160,7 +196,7 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
160
196
  const done = trackCommand('oml-reason');
161
197
  try {
162
198
  const { reasonAction } = await import('./commands/reason.js');
163
- const authToken = await resolveServerRequestToken(authService);
199
+ const authToken = resolveServerRequestToken();
164
200
  await reasonAction({ ...(opts as Record<string, unknown>), authToken } as Parameters<typeof reasonAction>[0]);
165
201
  done();
166
202
  } catch (err) {
@@ -176,7 +212,7 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
176
212
  .action(async (...args: unknown[]) => {
177
213
  const done = trackCommand('oml-validate');
178
214
  try {
179
- const authToken = await resolveServerRequestToken(authService);
215
+ const authToken = resolveServerRequestToken();
180
216
  await validateAction({
181
217
  ...(args[0] as Record<string, unknown> | undefined ?? {}),
182
218
  authToken,
@@ -188,67 +224,22 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
188
224
  }
189
225
  });
190
226
 
191
- const server = program
192
- .command('server')
193
- .description('manage the standalone OML language server daemon');
194
-
195
- server
196
- .command('start [port]')
197
- .option('-p, --port <port>', 'bind port (default: auto-select free port)')
198
- .option('--workspace <workspace>', 'workspace root used by REST facade initialize (default: cwd)')
199
- .description('start the OML server as a background daemon (CI/CD, requires OML_PLATFORM_API_KEY)')
200
- .action(async (port: string | undefined, options: { port?: string; workspace?: string }) => {
201
- await serverStartAction(port, { ...options, auth: await resolveServerStartAuth() });
202
- });
203
-
204
- server
205
- .command('run [port]')
206
- .option('-p, --port <port>', 'bind port (default: auto-select free port)')
207
- .option('--workspace <workspace>', 'workspace root (default: cwd)')
208
- .description('run the OML server in the foreground with interactive authentication (Ctrl-C to stop)')
209
- .action(async (port: string | undefined, options: { port?: string; workspace?: string }) => {
210
- await serverRunAction(port, { ...options, auth: await resolveServerRunAuth(authService) });
211
- });
212
-
213
- server
214
- .command('stop')
215
- .description('stop the OML language server daemon')
216
- .action(async () => {
217
- await serverStopAction();
218
- });
219
-
220
- server
221
- .command('status')
222
- .description('print server daemon status')
223
- .action(async () => {
224
- await serverStatusAction();
225
- });
226
-
227
227
  program.hook('preAction', async (_thisCommand, actionCommand) => {
228
228
  if (
229
229
  actionCommand.name() === 'login'
230
230
  || actionCommand.name() === 'logout'
231
231
  || actionCommand.name() === 'whoami'
232
232
  || actionCommand.name() === 'start'
233
- || actionCommand.name() === 'run'
234
233
  || actionCommand.name() === 'stop'
235
234
  || actionCommand.name() === 'status'
235
+ || actionCommand.name() === 'list'
236
236
  ) {
237
237
  return;
238
238
  }
239
239
  await assertServerRunning();
240
- // Require either GitHub auth or API key, then connect to platform
241
- if (!process.env.OML_PLATFORM_API_KEY) {
242
- await authService.ensureAuthenticated('OML CLI');
243
- }
244
- await initializePlatform(authService);
245
240
  });
246
241
 
247
- try {
248
- await program.parseAsync(argv);
249
- } finally {
250
- await disposePlatform();
251
- }
242
+ await program.parseAsync(argv);
252
243
  await updateCheck;
253
244
  }
254
245
 
@@ -319,53 +310,37 @@ function hasDebugFlag(argv: string[]): boolean {
319
310
  return argv.includes('--debug') || argv.includes('-d');
320
311
  }
321
312
 
322
- async function resolveServerRequestToken(authService: OmlCliAuthService): Promise<string | undefined> {
323
- const apiKey = process.env.OML_PLATFORM_API_KEY?.trim();
324
- if (apiKey && apiKey.length > 0) {
325
- const oidcToken = process.env.OML_CI_TOKEN?.trim();
326
- const apiBaseUrl = process.env.OML_PLATFORM_API_URL?.trim() ?? DEFAULT_API_BASE_URL;
327
- const exchanged = await exchangeApiToken(apiBaseUrl, apiKey, oidcToken || undefined);
328
- return exchanged.accessToken;
329
- }
330
- const snapshot = await authService.getServerAuthSnapshot();
331
- return snapshot.accessToken;
313
+ function resolveServerRequestToken(): string | undefined {
314
+ return undefined;
332
315
  }
333
316
 
334
317
  async function resolveServerStartAuth(): Promise<{ accessToken: string }> {
335
318
  const apiKey = process.env.OML_PLATFORM_API_KEY?.trim();
336
319
  if (!apiKey) {
337
320
  throw new CliExitError(
338
- 'OML_PLATFORM_API_KEY is not set. oml server start requires an API key for non-interactive use. ' +
339
- 'For interactive use, run \'oml server run\' instead.'
321
+ 'OML_PLATFORM_API_KEY is not set. For interactive use, run \'oml start\'. ' +
322
+ 'For non-interactive use, set OML_PLATFORM_API_KEY and retry.'
340
323
  );
341
324
  }
342
- const oidcToken = process.env.OML_CI_TOKEN?.trim();
343
- const apiBaseUrl = process.env.OML_PLATFORM_API_URL?.trim() ?? DEFAULT_API_BASE_URL;
344
- const result = await exchangeApiToken(apiBaseUrl, apiKey, oidcToken || undefined);
345
- return { accessToken: result.accessToken };
325
+ return { accessToken: apiKey };
346
326
  }
347
327
 
348
328
  async function resolveServerRunAuth(authService: OmlCliAuthService): Promise<{
349
329
  accessToken: string;
350
- refreshToken: string;
351
- expiresAtMs: number;
352
- onRefresh: (newAccessToken: string, newRefreshToken: string, newExpiresAtMs: number) => Promise<void>;
353
330
  }> {
354
- await authService.ensureAuthenticated('oml server run');
355
- const snapshot = await authService.getServerAuthSnapshot();
356
- if (!snapshot.refreshToken || snapshot.expiresAtMs === undefined) {
357
- throw new CliExitError('Authentication session is incomplete. Run oml login again.');
331
+ let snapshot;
332
+ try {
333
+ snapshot = await authService.getServerAuthSnapshot();
334
+ } catch {
335
+ await authService.login({});
336
+ snapshot = await authService.getServerAuthSnapshot();
358
337
  }
359
338
  return {
360
339
  accessToken: snapshot.accessToken,
361
- refreshToken: snapshot.refreshToken,
362
- expiresAtMs: snapshot.expiresAtMs,
363
- onRefresh: async (newAccessToken, newRefreshToken, newExpiresAtMs) => {
364
- await authService.storeRefreshedTokens(newAccessToken, newRefreshToken, newExpiresAtMs);
365
- },
366
340
  };
367
341
  }
368
342
 
343
+
369
344
  function formatDetailedError(error: unknown): string {
370
345
  if (!(error instanceof Error)) {
371
346
  return String(error);
@@ -9,6 +9,7 @@ import { createHash } from 'node:crypto';
9
9
  import { createRequire } from 'node:module';
10
10
  import { spawn, execFile, type ChildProcess } from 'node:child_process';
11
11
  import { promisify } from 'node:util';
12
+ import type { OmlCliAuthService } from '../../auth/auth.js';
12
13
 
13
14
  const DEFAULT_HOST = '127.0.0.1';
14
15
  const STARTUP_TIMEOUT_MS = 15_000;
@@ -34,6 +35,7 @@ type ServerLockState = {
34
35
  pid: number;
35
36
  port: number;
36
37
  owner?: 'daemon' | 'extension';
38
+ workspaceRoot?: string;
37
39
  };
38
40
 
39
41
  function workspaceHash(workspaceRoot: string): string {
@@ -56,7 +58,7 @@ async function cleanupStateFile(paths: ServerStatePaths): Promise<void> {
56
58
 
57
59
  function parseServerLock(raw: string): ServerLockState | undefined {
58
60
  try {
59
- const parsed = JSON.parse(raw) as { pid?: unknown; port?: unknown; owner?: unknown };
61
+ const parsed = JSON.parse(raw) as { pid?: unknown; port?: unknown; owner?: unknown; workspaceRoot?: unknown };
60
62
  const pid = Number(parsed.pid);
61
63
  const port = Number(parsed.port);
62
64
  if (!Number.isFinite(pid) || !Number.isFinite(port)) {
@@ -70,7 +72,10 @@ function parseServerLock(raw: string): ServerLockState | undefined {
70
72
  const owner = parsed.owner === 'daemon' || parsed.owner === 'extension'
71
73
  ? parsed.owner
72
74
  : undefined;
73
- return { pid: pidInt, port: portInt, owner };
75
+ const workspaceRoot = typeof parsed.workspaceRoot === 'string' && parsed.workspaceRoot.trim().length > 0
76
+ ? parsed.workspaceRoot.trim()
77
+ : undefined;
78
+ return { pid: pidInt, port: portInt, owner, workspaceRoot };
74
79
  } catch {
75
80
  return undefined;
76
81
  }
@@ -88,6 +93,50 @@ async function readServerLock(lockFile: string): Promise<ServerLockState | undef
88
93
  }
89
94
  }
90
95
 
96
+ type RunningServer = {
97
+ pid: number;
98
+ port: number;
99
+ owner: 'daemon' | 'extension' | 'unknown';
100
+ workspaceRoot?: string;
101
+ lockFile: string;
102
+ };
103
+
104
+ async function listRunningServers(): Promise<RunningServer[]> {
105
+ const baseDir = path.join(os.homedir(), '.oml', 'workspaces');
106
+ let entries: string[];
107
+ try {
108
+ entries = await fs.readdir(baseDir);
109
+ } catch {
110
+ return [];
111
+ }
112
+
113
+ const servers: RunningServer[] = [];
114
+ for (const entry of entries) {
115
+ const lockFile = path.join(baseDir, entry, 'server.lock');
116
+ const state = await readServerLock(lockFile);
117
+ if (!state) {
118
+ continue;
119
+ }
120
+ if (!isProcessAlive(state.pid)) {
121
+ await fs.rm(lockFile, { force: true });
122
+ continue;
123
+ }
124
+ servers.push({
125
+ pid: state.pid,
126
+ port: state.port,
127
+ owner: state.owner ?? 'unknown',
128
+ workspaceRoot: state.workspaceRoot,
129
+ lockFile,
130
+ });
131
+ }
132
+
133
+ servers.sort((left, right) =>
134
+ String(left.workspaceRoot ?? '').localeCompare(String(right.workspaceRoot ?? ''))
135
+ || left.port - right.port
136
+ );
137
+ return servers;
138
+ }
139
+
91
140
  function isProcessAlive(pid: number): boolean {
92
141
  try {
93
142
  process.kill(pid, 0);
@@ -319,7 +368,7 @@ async function serverStartDetached(
319
368
 
320
369
  const endpoint = `http://${DEFAULT_HOST}:${state.port}`;
321
370
  console.log(`OML server started on ${endpoint} (pid ${state.pid})`);
322
- console.log("Use 'oml server stop' to stop the server");
371
+ console.log("Use 'oml stop' to stop the server");
323
372
  }
324
373
 
325
374
  export async function serverStopAction(): Promise<void> {
@@ -368,14 +417,25 @@ export async function serverStatusAction(): Promise<void> {
368
417
  process.stdout.write(`${output}\n`);
369
418
  }
370
419
 
420
+ export async function serverListAction(): Promise<void> {
421
+ const servers = await listRunningServers();
422
+ if (servers.length === 0) {
423
+ process.stdout.write('No active OML servers.\n');
424
+ return;
425
+ }
426
+ process.stdout.write('Active OML servers:\n');
427
+ for (const server of servers) {
428
+ const workspace = server.workspaceRoot ?? '(unknown workspace)';
429
+ process.stdout.write(`- ${workspace} | http://${DEFAULT_HOST}:${server.port} | pid ${server.pid} | owner ${server.owner}\n`);
430
+ }
431
+ }
432
+
371
433
  export type RunServerOptions = {
372
434
  port?: number | string;
373
435
  workspace?: string;
436
+ authService: OmlCliAuthService;
374
437
  auth: {
375
438
  accessToken: string;
376
- refreshToken: string;
377
- expiresAtMs: number;
378
- onRefresh: (newAccessToken: string, newRefreshToken: string, newExpiresAtMs: number) => Promise<void>;
379
439
  };
380
440
  };
381
441
 
@@ -388,8 +448,8 @@ function sendLspNotification(childStdin: NodeJS.WritableStream, method: string,
388
448
  export async function serverRunAction(portArg: number | string | undefined, options: RunServerOptions): Promise<void> {
389
449
  if (process.env.OML_PLATFORM_API_KEY?.trim()) {
390
450
  throw new Error(
391
- 'OML_PLATFORM_API_KEY is set but oml server run requires interactive authentication. ' +
392
- 'Use \'oml server start\' for non-interactive CI/CD mode with an API key.'
451
+ 'OML_PLATFORM_API_KEY is set but interactive startup uses OAuth credentials. ' +
452
+ 'Use \'oml start\' directly to let the CLI choose the correct auth mode.'
393
453
  );
394
454
  }
395
455
 
@@ -440,38 +500,59 @@ export async function serverRunAction(portArg: number | string | undefined, opti
440
500
 
441
501
  const stdin = child.stdin;
442
502
  const REFRESH_INTERVAL_MS = 60 * 60 * 1000;
443
- let currentRefreshToken = options.auth.refreshToken;
444
- let currentExpiresAtMs = options.auth.expiresAtMs;
503
+ const REFRESH_RETRY_BASE_MS = 15_000;
504
+ const REFRESH_RETRY_MAX_MS = 5 * 60 * 1000;
505
+ let currentAccessToken = options.auth.accessToken;
506
+ let refreshFailureCount = 0;
507
+ let refreshTimer: NodeJS.Timeout | undefined;
508
+
509
+ const scheduleRefresh = (delayMs: number): void => {
510
+ if (refreshTimer) {
511
+ clearTimeout(refreshTimer);
512
+ }
513
+ refreshTimer = setTimeout(() => {
514
+ void runRefreshCycle();
515
+ }, delayMs);
516
+ };
517
+
518
+ const retryDelayMs = (failureCount: number): number => {
519
+ const step = Math.max(0, failureCount - 1);
520
+ const candidate = REFRESH_RETRY_BASE_MS * (2 ** step);
521
+ return Math.min(REFRESH_RETRY_MAX_MS, candidate);
522
+ };
445
523
 
446
- const refreshTimer = setInterval(async () => {
524
+ const runRefreshCycle = async (): Promise<void> => {
447
525
  try {
448
- const leeway = 5 * 60 * 1000;
449
- if (Date.now() + leeway < currentExpiresAtMs) {
450
- return;
526
+ const snapshot = await options.authService.getServerAuthSnapshot();
527
+ if (snapshot.accessToken !== currentAccessToken) {
528
+ currentAccessToken = snapshot.accessToken;
529
+ sendLspNotification(stdin, '$/tokenRefreshed', { accessToken: snapshot.accessToken });
451
530
  }
452
- const { refreshSupabaseAccessToken } = await import('@oml/platform');
453
- const supabaseUrl = process.env.OML_SUPABASE_URL?.trim() ?? '';
454
- const supabaseAnonKey = process.env.OML_SUPABASE_ANON_KEY?.trim() ?? '';
455
- const refreshed = await refreshSupabaseAccessToken(supabaseUrl, supabaseAnonKey, currentRefreshToken);
456
- currentRefreshToken = refreshed.refresh_token;
457
- currentExpiresAtMs = Date.now() + refreshed.expires_in * 1000;
458
- sendLspNotification(stdin, '$/tokenRefreshed', { accessToken: refreshed.access_token });
459
- await options.auth.onRefresh(refreshed.access_token, refreshed.refresh_token, currentExpiresAtMs);
531
+ refreshFailureCount = 0;
532
+ scheduleRefresh(REFRESH_INTERVAL_MS);
460
533
  } catch {
461
- // Refresh failure is non-fatal server continues with cached token.
534
+ // Keep running with the in-memory token and retry sooner while offline.
535
+ refreshFailureCount += 1;
536
+ scheduleRefresh(retryDelayMs(refreshFailureCount));
462
537
  }
463
- }, REFRESH_INTERVAL_MS);
538
+ };
539
+
540
+ scheduleRefresh(REFRESH_INTERVAL_MS);
464
541
 
465
542
  await new Promise<void>((resolve) => {
466
543
  const shutdown = (): void => {
467
- clearInterval(refreshTimer);
544
+ if (refreshTimer) {
545
+ clearTimeout(refreshTimer);
546
+ }
468
547
  child.kill('SIGTERM');
469
548
  resolve();
470
549
  };
471
550
  process.once('SIGINT', shutdown);
472
551
  process.once('SIGTERM', shutdown);
473
552
  child.once('exit', () => {
474
- clearInterval(refreshTimer);
553
+ if (refreshTimer) {
554
+ clearTimeout(refreshTimer);
555
+ }
475
556
  resolve();
476
557
  });
477
558
  });
@@ -8,7 +8,7 @@ import { createHash } from 'node:crypto';
8
8
 
9
9
  const DEFAULT_HOST = '127.0.0.1';
10
10
  const CONNECT_TIMEOUT_MS = 400;
11
- const START_SERVER_HINT = "start server first (run 'oml server start')";
11
+ const START_SERVER_HINT = "start server first (run 'oml start')";
12
12
 
13
13
  type ServerState = {
14
14
  pid: number;