@oml/cli 0.14.17 → 0.15.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/src/auth/auth.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  // Copyright (c) 2026 Modelware. All rights reserved.
2
2
 
3
- import { exchangeGitHubToken, refreshSupabaseAccessToken } from '@oml/platform';
3
+ import { exchangeGitHubToken, refreshSupabaseAccessToken, verifyEntitlementsToken } from '@oml/platform';
4
4
  import chalk from 'chalk';
5
+ import * as crypto from 'node:crypto';
6
+ import type { JsonWebKey } from 'node:crypto';
5
7
  import * as fs from 'node:fs/promises';
6
8
  import * as os from 'node:os';
7
9
  import * as path from 'node:path';
@@ -23,7 +25,10 @@ const KEYCHAIN_SERVICE = 'oml-code';
23
25
  const ACCESS_TOKEN_KEY = 'oml.cli.access_token';
24
26
  const REFRESH_TOKEN_KEY = 'oml.cli.refresh_token';
25
27
  const EXPIRES_AT_KEY = 'oml.cli.expires_at';
26
- const PROFILE_PATH = path.join(os.homedir(), '.oml', 'cli-profile.json');
28
+ const ENTITLEMENT_EXPIRY_KEY = 'oml.cli.entitlement_expiry';
29
+ const ENTITLEMENT_FEATURES_KEY = 'oml.cli.entitlement_features';
30
+ const ENTITLEMENT_TOKEN_KEY = 'oml.cli.entitlement_token';
31
+ const ENTITLEMENT_PUBKEY_KEY = 'oml.cli.entitlement_pubkey';
27
32
  const LOCK_PATH = path.join(os.homedir(), '.oml', 'credentials.lock');
28
33
  const SESSION_EXPIRATION_LEEWAY_MS = 20_000;
29
34
  const LOCK_TIMEOUT_MS = 5_000;
@@ -37,17 +42,6 @@ type KeytarModule = {
37
42
 
38
43
  let keytarModulePromise: Promise<KeytarModule> | undefined;
39
44
 
40
- type Provider = 'github';
41
-
42
- type StoredProfile = {
43
- provider: Provider;
44
- userId: string;
45
- userLabel?: string;
46
- email: string | null;
47
- tier?: string;
48
- signedInAt: string;
49
- };
50
-
51
45
  type StoredCredential = {
52
46
  accessToken: string;
53
47
  refreshToken: string;
@@ -77,10 +71,9 @@ export class OmlCliAuthService {
77
71
 
78
72
  const existing = await this.tryGetValidSnapshot();
79
73
  if (existing) {
80
- const profile = await readProfile();
81
- console.error(chalk.green(
82
- `Already signed in as ${profile?.userLabel ?? profile?.email ?? profile?.userId ?? 'current user'}.`
83
- ));
74
+ const claims = decodeJwtClaims(existing.accessToken);
75
+ const label = claims.userLabel ?? claims.email ?? 'current user';
76
+ console.error(chalk.green(`Already signed in as ${label}.`));
84
77
  return;
85
78
  }
86
79
 
@@ -90,22 +83,14 @@ export class OmlCliAuthService {
90
83
  refreshToken: session.refreshToken,
91
84
  expiresAtMs: session.expiresAtMs,
92
85
  });
93
- await writeProfile({
94
- provider: session.provider,
95
- userId: session.userId,
96
- userLabel: session.userLabel,
97
- email: session.email,
98
- tier: session.tier,
99
- signedInAt: new Date().toISOString(),
100
- });
86
+ void fetchAndSaveEntitlementsPubkey(resolveApiBaseUrl(), this).catch(() => undefined);
101
87
  const summary = session.userLabel ?? session.email ?? 'signed-in user';
102
- console.error(chalk.green(`Signed in as ${summary} via ${session.provider}.`));
88
+ console.error(chalk.green(`Signed in as ${summary} via GitHub.`));
103
89
  }
104
90
 
105
91
  async logout(): Promise<void> {
106
92
  const activeServers = await listActiveServers();
107
93
  await deleteCredential();
108
- await deleteProfile();
109
94
  if (activeServers.length > 0) {
110
95
  console.error(chalk.yellow('Stored OAuth credentials were cleared. Running servers were not stopped:'));
111
96
  for (const server of activeServers) {
@@ -127,27 +112,85 @@ export class OmlCliAuthService {
127
112
  if (apiKey) {
128
113
  console.error('Auth mode: api_key');
129
114
  console.error('Account: resolved by OML Platform from OML_PLATFORM_API_KEY');
130
- const profile = await readProfile();
131
- if (profile) {
132
- console.error(`Stored OAuth user: ${profile.userLabel ?? profile.email ?? profile.userId}`);
133
- }
134
115
  return;
135
116
  }
136
117
 
137
118
  const credential = await readCredential();
138
- const profile = await readProfile();
139
- if (!credential || !profile) {
119
+ if (!credential) {
140
120
  console.error(chalk.yellow('Not signed in.'));
141
121
  return;
142
122
  }
123
+ const claims = decodeJwtClaims(credential.accessToken);
143
124
  console.error('Auth mode: oauth');
144
- console.error(`Provider: ${profile.provider}`);
145
- console.error(`User ID: ${profile.userId}`);
146
- console.error(`User label: ${profile.userLabel ?? '(not set)'}`);
147
- console.error(`Email: ${profile.email ?? '(not set)'}`);
148
- console.error(`Tier: ${profile.tier ?? '(not set)'}`);
149
- console.error(`Signed in at: ${profile.signedInAt}`);
150
- console.error(`Access token expires at: ${new Date(credential.expiresAtMs).toISOString()}`);
125
+ console.error(`User ID: ${claims.userId ?? '(unknown)'}`);
126
+ console.error(`User label: ${claims.userLabel ?? '(not set)'}`);
127
+ console.error(`Email: ${claims.email ?? '(not set)'}`);
128
+ console.error(`Tier: ${claims.tier ?? '(not set)'}`);
129
+ console.error(`Token expires at: ${new Date(credential.expiresAtMs).toISOString()}`);
130
+ }
131
+
132
+ async getDeviceId(): Promise<string> {
133
+ return getOrCreateDeviceId();
134
+ }
135
+
136
+ async getEntitlementCache(): Promise<{ expiry: number; featureIds: string[]; token?: string } | null> {
137
+ try {
138
+ const keytar = await getKeytarModule();
139
+ const [expiryRaw, featuresRaw, tokenRaw, pubkeyRaw] = await Promise.all([
140
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY),
141
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY),
142
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY),
143
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY),
144
+ ]);
145
+ const expiry = Number(expiryRaw ?? NaN);
146
+ if (!Number.isFinite(expiry) || expiry <= Date.now() || !featuresRaw) {
147
+ return null;
148
+ }
149
+ const featureIds = JSON.parse(featuresRaw) as unknown;
150
+ if (!Array.isArray(featureIds) || !featureIds.every((x) => typeof x === 'string')) {
151
+ return null;
152
+ }
153
+ let token: string | undefined;
154
+ if (tokenRaw && pubkeyRaw) {
155
+ try {
156
+ const deviceId = await getOrCreateDeviceId();
157
+ const valid = await verifyEntitlementsToken(tokenRaw, deviceId, JSON.parse(pubkeyRaw) as JsonWebKey);
158
+ if (valid) {
159
+ token = tokenRaw;
160
+ }
161
+ } catch {
162
+ // Verification failed — proceed without token; gate will re-fetch.
163
+ }
164
+ }
165
+ return { expiry, featureIds: featureIds as string[], token };
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ async saveEntitlementCache(expiry: number, featureIds: string[], token?: string): Promise<void> {
172
+ try {
173
+ const keytar = await getKeytarModule();
174
+ const writes: Promise<void>[] = [
175
+ keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY, String(expiry)),
176
+ keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY, JSON.stringify(featureIds)),
177
+ ];
178
+ if (token) {
179
+ writes.push(keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY, token));
180
+ }
181
+ await Promise.all(writes);
182
+ } catch {
183
+ // Credential store unavailable — skip silently.
184
+ }
185
+ }
186
+
187
+ async saveEntitlementsPubkey(pubkeyJwk: JsonWebKey): Promise<void> {
188
+ try {
189
+ const keytar = await getKeytarModule();
190
+ await keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY, JSON.stringify(pubkeyJwk));
191
+ } catch {
192
+ // Credential store unavailable — skip silently.
193
+ }
151
194
  }
152
195
 
153
196
  async ensureAuthenticated(operationName: string): Promise<void> {
@@ -198,6 +241,10 @@ export class OmlCliAuthService {
198
241
  }
199
242
  }
200
243
 
244
+ async hasStoredCredential(): Promise<boolean> {
245
+ return (await readCredential()) !== undefined;
246
+ }
247
+
201
248
  private async refreshCredential(): Promise<StoredCredential> {
202
249
  const session = await readCredential();
203
250
  if (!session?.refreshToken) {
@@ -222,18 +269,10 @@ export class OmlCliAuthService {
222
269
  expiresAtMs: Date.now() + (refreshed.expires_in * 1000),
223
270
  };
224
271
  await writeCredential(updatedSession);
225
- const profile = await readProfile();
226
- if (profile) {
227
- await writeProfile({
228
- ...profile,
229
- email: refreshed.email ?? profile.email,
230
- });
231
- }
232
272
  return updatedSession;
233
273
  } catch (error) {
234
274
  if (isUnauthorizedError(error)) {
235
275
  await deleteCredential();
236
- await deleteProfile();
237
276
  throw new Error('Authentication refresh failed because the stored credential was revoked. Run \'oml login\' again.');
238
277
  }
239
278
  throw new Error('Authentication refresh failed. Check your network connection or sign in again with \'oml login\'.');
@@ -272,50 +311,40 @@ async function deleteCredential(): Promise<void> {
272
311
  keytar.deletePassword(KEYCHAIN_SERVICE, ACCESS_TOKEN_KEY),
273
312
  keytar.deletePassword(KEYCHAIN_SERVICE, REFRESH_TOKEN_KEY),
274
313
  keytar.deletePassword(KEYCHAIN_SERVICE, EXPIRES_AT_KEY),
314
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY),
315
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY),
316
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY),
317
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY),
275
318
  ]);
276
319
  }
277
320
 
278
- async function readProfile(): Promise<StoredProfile | undefined> {
321
+ async function getOrCreateDeviceId(): Promise<string> {
279
322
  try {
280
- const content = await fs.readFile(PROFILE_PATH, 'utf-8');
281
- const data = JSON.parse(content) as Partial<StoredProfile>;
282
- if (!data.provider || !data.userId || !data.signedInAt) {
283
- return undefined;
284
- }
285
- return {
286
- provider: data.provider,
287
- userId: data.userId,
288
- userLabel: data.userLabel,
289
- email: data.email ?? null,
290
- tier: data.tier,
291
- signedInAt: data.signedInAt,
292
- };
323
+ return (await fs.readFile('/etc/machine-id', 'utf-8')).trim();
293
324
  } catch {
294
- return undefined;
325
+ try {
326
+ return (await fs.readFile(LOCAL_MACHINE_ID_PATH, 'utf-8')).trim();
327
+ } catch {
328
+ const id = crypto.randomUUID();
329
+ await fs.mkdir(path.dirname(LOCAL_MACHINE_ID_PATH), { recursive: true });
330
+ await fs.writeFile(LOCAL_MACHINE_ID_PATH, id, { encoding: 'utf-8', mode: 0o600 });
331
+ return id;
332
+ }
295
333
  }
296
334
  }
297
335
 
298
- async function writeProfile(profile: StoredProfile): Promise<void> {
299
- await fs.mkdir(path.dirname(PROFILE_PATH), { recursive: true });
300
- await fs.writeFile(PROFILE_PATH, `${JSON.stringify(profile, null, 2)}\n`, 'utf-8');
301
- }
302
-
303
- async function deleteProfile(): Promise<void> {
304
- try {
305
- await fs.unlink(PROFILE_PATH);
306
- } catch (error) {
307
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
308
- throw error;
309
- }
336
+ async function fetchAndSaveEntitlementsPubkey(apiBaseUrl: string, authService: OmlCliAuthService): Promise<void> {
337
+ const response = await fetch(`${apiBaseUrl}/entitlements/pubkey`);
338
+ if (!response.ok) {
339
+ return;
310
340
  }
341
+ const jwk = await response.json() as JsonWebKey;
342
+ await authService.saveEntitlementsPubkey(jwk);
311
343
  }
312
344
 
313
345
  async function authenticateWithGitHub(): Promise<{
314
- provider: Provider;
315
- userId: string;
316
346
  userLabel?: string;
317
347
  email: string | null;
318
- tier?: string;
319
348
  accessToken: string;
320
349
  refreshToken: string;
321
350
  expiresAtMs: number;
@@ -360,11 +389,8 @@ async function authenticateWithGitHub(): Promise<{
360
389
  const platformSession = await exchangeGitHubToken(resolveApiBaseUrl(), token);
361
390
 
362
391
  return {
363
- provider: 'github',
364
- userId: platformSession.user_id,
365
392
  userLabel: user.login,
366
393
  email: platformSession.email,
367
- tier: platformSession.tier,
368
394
  accessToken: platformSession.access_token,
369
395
  refreshToken: platformSession.refresh_token,
370
396
  expiresAtMs: Date.now() + platformSession.expires_in * 1000,
@@ -506,25 +532,149 @@ function isUnauthorizedError(error: unknown): boolean {
506
532
  return /\b401\b/.test(message);
507
533
  }
508
534
 
535
+ const CREDENTIALS_FILE_PATH = path.join(os.homedir(), '.oml', 'credentials.json');
536
+ const LOCAL_MACHINE_ID_PATH = path.join(os.homedir(), '.oml', 'machine-id');
537
+
538
+ // Derive a 256-bit encryption key bound to this machine and user.
539
+ // Uses /etc/machine-id (Linux) or a locally generated UUID as the key material,
540
+ // combined with the user's home directory so the key is user-specific too.
541
+ async function deriveMachineKey(): Promise<Buffer> {
542
+ const machineId = await getOrCreateDeviceId();
543
+ const ikm = Buffer.from(`${machineId}:${os.homedir()}`, 'utf-8');
544
+ return new Promise<Buffer>((resolve, reject) => {
545
+ crypto.hkdf('sha256', ikm, Buffer.alloc(0), 'oml-cli-credentials-v1', 32, (err, key) => {
546
+ if (err) { reject(err); } else { resolve(Buffer.from(key)); }
547
+ });
548
+ });
549
+ }
550
+
551
+ function encryptValue(value: string, key: Buffer): string {
552
+ const iv = crypto.randomBytes(12);
553
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
554
+ const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]);
555
+ const tag = cipher.getAuthTag();
556
+ return JSON.stringify({
557
+ v: 1,
558
+ iv: iv.toString('base64'),
559
+ tag: tag.toString('base64'),
560
+ data: encrypted.toString('base64'),
561
+ });
562
+ }
563
+
564
+ function decryptValue(stored: string, key: Buffer): string {
565
+ const parsed = JSON.parse(stored) as { v?: number; iv: string; tag: string; data: string };
566
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(parsed.iv, 'base64'));
567
+ decipher.setAuthTag(Buffer.from(parsed.tag, 'base64'));
568
+ return decipher.update(Buffer.from(parsed.data, 'base64'), undefined, 'utf-8') + decipher.final('utf-8');
569
+ }
570
+
571
+ class FileKeytar implements KeytarModule {
572
+ private keyPromise: Promise<Buffer> | undefined;
573
+
574
+ private getKey(): Promise<Buffer> {
575
+ if (!this.keyPromise) {
576
+ this.keyPromise = deriveMachineKey();
577
+ }
578
+ return this.keyPromise;
579
+ }
580
+
581
+ private async read(): Promise<Record<string, string>> {
582
+ try {
583
+ const content = await fs.readFile(CREDENTIALS_FILE_PATH, 'utf-8');
584
+ return JSON.parse(content) as Record<string, string>;
585
+ } catch {
586
+ return {};
587
+ }
588
+ }
589
+
590
+ private async write(store: Record<string, string>): Promise<void> {
591
+ await fs.mkdir(path.dirname(CREDENTIALS_FILE_PATH), { recursive: true });
592
+ await fs.writeFile(CREDENTIALS_FILE_PATH, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
593
+ }
594
+
595
+ private storeKey(service: string, account: string): string {
596
+ return `${service}:${account}`;
597
+ }
598
+
599
+ async getPassword(service: string, account: string): Promise<string | null> {
600
+ const store = await this.read();
601
+ const raw = store[this.storeKey(service, account)];
602
+ if (raw === undefined || raw === null) {
603
+ return null;
604
+ }
605
+ try {
606
+ const key = await this.getKey();
607
+ return decryptValue(raw, key);
608
+ } catch {
609
+ // Unreadable entry (wrong machine, corrupt, or legacy plaintext) — treat as missing.
610
+ return null;
611
+ }
612
+ }
613
+
614
+ async setPassword(service: string, account: string, password: string): Promise<void> {
615
+ const [store, key] = await Promise.all([this.read(), this.getKey()]);
616
+ store[this.storeKey(service, account)] = encryptValue(password, key);
617
+ await this.write(store);
618
+ }
619
+
620
+ async deletePassword(service: string, account: string): Promise<boolean> {
621
+ const store = await this.read();
622
+ const k = this.storeKey(service, account);
623
+ if (!(k in store)) {
624
+ return false;
625
+ }
626
+ delete store[k];
627
+ await this.write(store);
628
+ return true;
629
+ }
630
+ }
631
+
509
632
  async function getKeytarModule(): Promise<KeytarModule> {
510
633
  if (!keytarModulePromise) {
511
634
  keytarModulePromise = import('keytar')
512
635
  .then((loaded) => loaded.default as KeytarModule)
513
636
  .catch((error: unknown) => {
637
+ const message = error instanceof Error ? error.message : String(error);
638
+ if (/libsecret|dlopen|ERR_DLOPEN_FAILED/i.test(message)) {
639
+ console.error(chalk.yellow(
640
+ 'System keychain unavailable; credentials will be stored encrypted at ' +
641
+ CREDENTIALS_FILE_PATH + ' using a machine-bound key. ' +
642
+ 'Set OML_PLATFORM_API_KEY for non-interactive environments.'
643
+ ));
644
+ return new FileKeytar();
645
+ }
514
646
  keytarModulePromise = undefined;
515
- throw new Error(formatKeytarLoadError(error));
647
+ throw new Error(`Secure credential storage is unavailable: ${message}`);
516
648
  });
517
649
  }
518
650
  return keytarModulePromise;
519
651
  }
520
652
 
521
- function formatKeytarLoadError(error: unknown): string {
522
- const message = error instanceof Error ? error.message : String(error);
523
- if (/libsecret|dlopen|ERR_DLOPEN_FAILED/i.test(message)) {
524
- return 'Secure credential storage is unavailable (missing system keychain runtime, e.g. libsecret on Linux). ' +
525
- 'Install the OS keychain runtime or set OML_PLATFORM_API_KEY for non-interactive mode.';
653
+ type JwtClaims = {
654
+ userId?: string;
655
+ userLabel?: string;
656
+ email?: string;
657
+ tier?: string;
658
+ };
659
+
660
+ function decodeJwtClaims(token: string): JwtClaims {
661
+ try {
662
+ const payload = token.split('.')[1];
663
+ if (!payload) { return {}; }
664
+ const json = JSON.parse(Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8')) as Record<string, unknown>;
665
+ const userMeta = json['user_metadata'];
666
+ const appMeta = json['app_metadata'];
667
+ return {
668
+ userId: typeof json['sub'] === 'string' ? json['sub'] : undefined,
669
+ email: typeof json['email'] === 'string' ? json['email'] : undefined,
670
+ userLabel: userMeta && typeof (userMeta as Record<string, unknown>)['user_name'] === 'string'
671
+ ? (userMeta as Record<string, unknown>)['user_name'] as string : undefined,
672
+ tier: appMeta && typeof (appMeta as Record<string, unknown>)['tier'] === 'string'
673
+ ? (appMeta as Record<string, unknown>)['tier'] as string : undefined,
674
+ };
675
+ } catch {
676
+ return {};
526
677
  }
527
- return `Secure credential storage is unavailable: ${message}`;
528
678
  }
529
679
 
530
680
  type GitHubDeviceCodeResponse = {
package/src/cli.ts CHANGED
@@ -28,20 +28,20 @@ export interface CliCommandInfo {
28
28
  export function getWorkspaceCommands(): CliCommandInfo[] {
29
29
  return [
30
30
  { name: 'lint', description: 'lints OML files and prints any syntax or validation errors' },
31
- {
32
- name: 'render [options]',
31
+ {
32
+ name: 'render [options]',
33
33
  description: 'lint the workspace, then render markdown files to static html',
34
- usage: 'render -m <input-folder> -b <output-folder> [--clean] [-c <ontology-iri>]'
34
+ usage: 'render -m <input-folder> -b <output-folder> [-c <ontology-iri>]'
35
35
  },
36
- {
37
- name: 'export [options]',
36
+ {
37
+ name: 'export [options]',
38
38
  description: 'export asserted OWL files (no reasoning)',
39
- usage: 'export [-o <dir>] [-f <ext>] [--clean] [--pretty]'
39
+ usage: 'export [-o <dir>] [-f <ext>] [--pretty]'
40
40
  },
41
- {
42
- name: 'reason [options]',
41
+ {
42
+ name: 'reason [options]',
43
43
  description: 'run workspace consistency checks (or persist assertions/entailments with --owl)',
44
- usage: 'reason [-o <dir>] [-f <ext>] [--clean] [--pretty] [-e <true|false>]'
44
+ usage: 'reason [-o <dir>] [-f <ext>] [--pretty] [-e <true|false>]'
45
45
  },
46
46
  {
47
47
  name: 'validate [options]',
@@ -94,14 +94,20 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
94
94
  .option('-w, --workspace <workspace>', 'workspace root used by REST facade initialize (default: cwd)')
95
95
  .description('start an OML server')
96
96
  .action(async (port: string | undefined, options: { port?: string; workspace?: string }) => {
97
+ const [entitlementCache, deviceId] = await Promise.all([
98
+ authService.getEntitlementCache().then((c) => c ?? undefined),
99
+ authService.getDeviceId().catch(() => undefined),
100
+ ]);
97
101
  if (process.env.OML_PLATFORM_API_KEY?.trim()) {
98
- await serverStartAction(port, { ...options, auth: await resolveServerStartAuth() });
102
+ await serverStartAction(port, { ...options, auth: await resolveServerStartAuth(), entitlementCache });
99
103
  return;
100
104
  }
101
105
  await serverRunAction(port, {
102
106
  ...options,
103
107
  authService,
104
108
  auth: await resolveServerRunAuth(authService),
109
+ entitlementCache,
110
+ deviceId,
105
111
  });
106
112
  });
107
113
 
@@ -148,7 +154,6 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
148
154
  .command('render')
149
155
  .requiredOption('-m, --md <input-folder>', 'folder containing markdown files to render')
150
156
  .requiredOption('-b, --web <output-folder>', 'folder where rendered static site files are written')
151
- .option('--clean', 'remove output folder before render')
152
157
  .option('-c, --context <model-path>', 'workspace-relative .oml model path used as default navigation context for wikilinks')
153
158
  .description('lint the workspace, then render markdown files to static html')
154
159
  .action(async (...args: unknown[]) => {
@@ -170,7 +175,6 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
170
175
  .command('export')
171
176
  .option('-o, --owl <dir>', 'folder where RDF output files are written')
172
177
  .option('-f, --format <ext>', 'RDF format extension: ttl, trig, nt, nq, or n3', 'ttl')
173
- .option('--clean', 'remove output folder before export')
174
178
  .option('--pretty', 'pretty-print Turtle/TriG output with blank lines between top-level blocks')
175
179
  .description('export asserted OWL files (no reasoning)')
176
180
  .action(async (...args: unknown[]) => {
@@ -192,7 +196,6 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
192
196
  .command('reason')
193
197
  .option('-o, --owl <dir>', 'persist assertions/entailments to folder (default: check-only with no persistence)')
194
198
  .option('-f, --format <ext>', 'RDF format extension: ttl, trig, nt, nq, or n3', 'ttl')
195
- .option('--clean', 'remove output folder before reason (only when --owl is provided)')
196
199
  .option('--pretty', 'pretty-print Turtle/TriG output with blank lines between top-level blocks (only when --owl is provided)')
197
200
  .option('-u, --unique-names-assumption [value]', 'enable or disable the unique names assumption', parseBooleanOption, true)
198
201
  .option('-e, --explanation [value]', 'enable or disable inconsistency explanations', parseBooleanOption, false)
@@ -322,29 +325,21 @@ async function resolveServerStartAuth(): Promise<{ accessToken: string }> {
322
325
  const apiKey = process.env.OML_PLATFORM_API_KEY?.trim();
323
326
  if (!apiKey) {
324
327
  throw new CliExitError(
325
- 'OML_PLATFORM_API_KEY is not set. For interactive use, run \'oml start\'. ' +
326
- 'For non-interactive use, set OML_PLATFORM_API_KEY and retry.'
328
+ 'OML_PLATFORM_API_KEY is required for non-interactive server start. ' +
329
+ 'Unset it and run \'oml start\' again to use interactive OAuth login instead.'
327
330
  );
328
331
  }
329
332
  return { accessToken: apiKey };
330
333
  }
331
334
 
332
- async function resolveServerRunAuth(authService: OmlCliAuthService): Promise<{
333
- accessToken: string;
334
- }> {
335
- let snapshot;
336
- try {
337
- snapshot = await authService.getServerAuthSnapshot();
338
- } catch {
335
+ async function resolveServerRunAuth(authService: OmlCliAuthService): Promise<{ accessToken: string }> {
336
+ if (!(await authService.hasStoredCredential())) {
337
+ console.error(chalk.cyan('Authentication required to start the server. Signing in...'));
339
338
  await authService.login({});
340
- snapshot = await authService.getServerAuthSnapshot();
341
339
  }
342
- return {
343
- accessToken: snapshot.accessToken,
344
- };
340
+ return { accessToken: (await authService.getServerAuthSnapshot()).accessToken };
345
341
  }
346
342
 
347
-
348
343
  function formatDetailedError(error: unknown): string {
349
344
  if (!(error instanceof Error)) {
350
345
  return String(error);
@@ -10,7 +10,6 @@ import { restPost } from './server/rest.js';
10
10
  export type ExportOptions = {
11
11
  owl?: string;
12
12
  format?: 'ttl' | 'trig' | 'nt' | 'nq' | 'n3' | string;
13
- clean?: boolean;
14
13
  pretty?: boolean;
15
14
  authToken?: string;
16
15
  };
@@ -44,7 +43,6 @@ export const exportAction = async (opts: ExportOptions): Promise<void> => {
44
43
  {
45
44
  owl: opts.owl,
46
45
  format,
47
- clean: opts.clean,
48
46
  pretty: opts.pretty === true,
49
47
  } as Record<string, unknown>,
50
48
  opts.authToken,
@@ -71,7 +71,8 @@ export const lintAction = async (opts: LintOptions): Promise<void> => {
71
71
  failCli(chalk.red(formatLintSummary(result, elapsedMs)));
72
72
  }
73
73
  if (result.warnings > 0) {
74
- failCli(chalk.yellow(formatLintSummary(result, elapsedMs)));
74
+ console.warn(chalk.yellow(formatLintSummary(result, elapsedMs)));
75
+ return;
75
76
  }
76
77
  console.log(chalk.green(formatLintSummary(result, elapsedMs)));
77
78
  };
@@ -16,7 +16,6 @@ type RdfFormat = 'ttl' | 'trig' | 'nt' | 'nq' | 'n3';
16
16
  export type ReasonOptions = {
17
17
  owl?: string;
18
18
  format?: RdfFormat | string;
19
- clean?: boolean;
20
19
  pretty?: boolean;
21
20
  explanation?: boolean;
22
21
  uniqueNamesAssumption?: boolean;
@@ -36,7 +35,6 @@ type AssertionsPayload = {
36
35
  files: Array<{
37
36
  modelUri: string;
38
37
  ontologyIri: string;
39
- path: string;
40
38
  content: string;
41
39
  }>;
42
40
  };
@@ -150,8 +148,21 @@ function resolveEntailmentsPath(uri: string): string {
150
148
  return trimmed;
151
149
  }
152
150
 
153
- function modelUriToRelativePath(modelUri: string): string {
154
- const trimmed = modelUri.trim();
151
+ function ontologyIriToTempPath(ontologyIri: string, format: RdfFormat): string {
152
+ try {
153
+ const iri = new URL(ontologyIri);
154
+ const rawPath = iri.pathname.replace(/\/+$/, '');
155
+ const segments = rawPath.split('/').filter(Boolean);
156
+ const stem = segments.at(-1) || 'index';
157
+ const dirSegs = iri.host ? [iri.host, ...segments.slice(0, -1)] : segments.slice(0, -1);
158
+ return dirSegs.length > 0 ? path.join(...dirSegs, `${stem}.${format}`) : `${stem}.${format}`;
159
+ } catch {
160
+ return ontologyIri.replace(/[^a-zA-Z0-9_/-]/g, '_') + '.' + format;
161
+ }
162
+ }
163
+
164
+ function modelUriToRelativePath(modelUri: string | undefined): string {
165
+ const trimmed = (modelUri ?? '').trim();
155
166
  if (trimmed.startsWith('file://')) {
156
167
  const absolute = fileURLToPath(trimmed);
157
168
  const relative = path.relative(process.cwd(), absolute);
@@ -190,9 +201,7 @@ export const reasonAction = async (opts: ReasonOptions): Promise<void> => {
190
201
  : undefined;
191
202
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'oml-reason-cli-'));
192
203
  if (outputDir) {
193
- if (opts.clean === true) {
194
- await fs.rm(outputDir, { recursive: true, force: true });
195
- }
204
+ await fs.rm(outputDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
196
205
  }
197
206
  const orderedFiles = sortFilesLeafToRoot(assertions.files);
198
207
  let firstInconsistent: { file: AssertionsPayload['files'][number]; result: Record<string, unknown> } | undefined;
@@ -200,12 +209,12 @@ export const reasonAction = async (opts: ReasonOptions): Promise<void> => {
200
209
  let entailmentsProduced = 0;
201
210
  try {
202
211
  for (const file of orderedFiles) {
203
- const target = path.join(tempRoot, file.path);
212
+ const target = path.join(tempRoot, ontologyIriToTempPath(file.ontologyIri, outputFormat));
204
213
  await fs.mkdir(path.dirname(target), { recursive: true });
205
214
  await fs.writeFile(target, prettyPrintRdf(file.content, outputFormat), 'utf-8');
206
215
  }
207
216
  for (const file of orderedFiles) {
208
- const target = path.join(tempRoot, file.path);
217
+ const target = path.join(tempRoot, ontologyIriToTempPath(file.ontologyIri, outputFormat));
209
218
  try {
210
219
  const result = await checkConsistency({
211
220
  input: target,
@@ -257,7 +266,9 @@ export const reasonAction = async (opts: ReasonOptions): Promise<void> => {
257
266
  failCli(chalk.red(`reason failed: ${modelUri}: ${firstFailure.error}`));
258
267
  }
259
268
  if (firstInconsistent) {
260
- const where = modelUriToRelativePath(firstInconsistent.file.modelUri);
269
+ const where = modelUriToRelativePath(firstInconsistent.file.modelUri)
270
+ || firstInconsistent.file.ontologyIri.trim()
271
+ || '<unknown>';
261
272
  if (opts.explanation === true) {
262
273
  failCli(chalk.red(`Inconsistency found in: ${where}\n${JSON.stringify(firstInconsistent.result, null, 2)}`));
263
274
  }
@@ -10,7 +10,6 @@ import { restPost } from './server/rest.js';
10
10
  export type RenderOptions = {
11
11
  md: string;
12
12
  web: string;
13
- clean?: boolean;
14
13
  context?: string;
15
14
  authToken?: string;
16
15
  };