@oml/cli 0.14.17 → 0.16.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/README.md CHANGED
@@ -32,26 +32,36 @@ The CLI uses the built-in production OML Platform endpoint by default. Set `OML_
32
32
  ### Quick Start
33
33
 
34
34
  ```bash
35
+ # Sign in
36
+ oml login
37
+
35
38
  # Lint the current workspace
36
- node ./packages/cli/bin/cli.js lint
39
+ oml lint
37
40
 
38
- # Start the standalone OML server on the default port (8080)
39
- node ./packages/cli/bin/cli.js server start
41
+ # Start the OML server
42
+ oml start
40
43
 
41
- # Lint against a local platform dev server
42
- OML_PLATFORM_API_URL=http://127.0.0.1:8787 \
43
- node ./packages/cli/bin/cli.js lint
44
+ # Stop the OML server
45
+ oml stop
44
46
 
45
- # Export RDF output with entailments
46
- node ./packages/cli/bin/cli.js export -o build/owl
47
+ # Export asserted OWL files
48
+ oml export -o build/owl
47
49
 
48
50
  # Run consistency reasoning (check-only)
49
- node ./packages/cli/bin/cli.js reason
51
+ oml reason
52
+
53
+ # Run consistency reasoning and persist entailments
54
+ oml reason -o build/owl
50
55
 
51
56
  # Render markdown to static HTML
52
- node ./packages/cli/bin/cli.js render -m src/md -b build/web
57
+ oml render -m src/md -b build/web
53
58
  ```
54
59
 
60
+ ### Global Options
61
+
62
+ - `-v, --version` — print the version number
63
+ - `-d, --debug` — print detailed error diagnostics (stack traces and nested causes)
64
+
55
65
  ### Commands
56
66
 
57
67
  - `login`
@@ -62,29 +72,19 @@ node ./packages/cli/bin/cli.js render -m src/md -b build/web
62
72
  Prints the current sign-in session.
63
73
  - `lint`
64
74
  Validates one file, or the current workspace when no file is given.
65
- - `export [-o|--owl <dir>] [-f <ttl|trig|nt|nq|n3>] [--clean] [--pretty] [--only]`
66
- Exports OML to RDF and materializes entailments via `oml/reason`.
67
- - `reason [-e|--explanation <true|false>] [--only]`
68
- Calls `/v0/reason` to run workspace consistency checks in check-only mode (no entailment materialization, no file persistence).
69
- - `render -m|--md <dir> -b|--web <dir> [-c|--context <ontology-iri>] [--only]`
70
- Runs `lint`, then renders markdown files to static HTML.
71
- - `validate [--only]`
72
- Validates table-editor SHACL blocks in workspace markdown files. Template markdown files (frontmatter `template`) are skipped as validation targets, while compose templates are still expanded for other markdown files. Runs `lint` first unless `--only` is provided.
73
- - `server start [port] [--port <port>] [--workspace <workspace>]`
74
- Starts the standalone REST server daemon. When no port is provided, it uses `8080`.
75
- - `server stop`
76
- Stops the standalone server daemon.
77
- - `server status`
78
- Prints the standalone server daemon status.
79
-
80
- ### Notes
81
-
82
- - Pass `--debug` with any command (for example `oml login --debug`) to print stack traces and nested error causes.
83
- - The CLI uses `OML_PLATFORM_API_KEY` when it is set. Otherwise, operational commands use the token from `oml login` for platform authorization.
84
- - OAuth login refresh uses built-in Supabase defaults. Set `OML_SUPABASE_URL` or `OML_SUPABASE_ANON_KEY` to override them.
85
- - GitHub device-flow login requires `OML_AUTH_GITHUB_CLIENT_ID`, unless you embed `DEFAULT_GITHUB_CLIENT_ID` in [`src/auth.ts`](./src/auth.ts).
86
- - `oml login` exchanges the GitHub token with the platform at `OML_PLATFORM_API_URL` or the built-in default endpoint, then stores the platform session locally.
87
- - `reason` runs `oml/reason` consistency checks per workspace model in check-only mode (no entailment files written).
88
- - `render` runs `lint` unless `--only` is provided, then renders markdown files to static HTML.
89
- - `server start` fails clearly when the requested host and port are already occupied.
90
- - When installed from npm, the CLI checks the npm registry for newer `@oml/cli` releases and prints `npm install -g @oml/cli@latest` when an update is available. Set `OML_NO_UPDATE_NOTIFIER=1` to disable the check.
75
+ - `export [-o|--owl <dir>] [-f|--format <ttl|trig|nt|nq|n3>] [--pretty]`
76
+ Exports asserted OWL files (no reasoning or entailment materialization).
77
+ - `reason [-o|--owl <dir>] [-f|--format <ttl|trig|nt|nq|n3>] [--pretty] [-u|--unique-names-assumption <true|false>] [-e|--explanation <true|false>]`
78
+ Runs workspace consistency checks. Without `--owl`, runs in check-only mode with no file output. With `--owl`, persists assertions and entailments to the given folder.
79
+ - `render -m|--md <input-folder> -b|--web <output-folder> [-c|--context <model-path>]`
80
+ Runs `lint`, then renders markdown files to static HTML. The optional `--context` sets the workspace-relative `.oml` model path used as the default navigation context for wikilinks.
81
+ - `validate`
82
+ Validates table-editor SHACL blocks in workspace markdown files. Runs `lint` first.
83
+ - `start [port] [-p|--port <port>] [-w|--workspace <workspace>]`
84
+ Starts the OML server. When no port is provided, an available port is selected automatically. Use `OML_PLATFORM_API_KEY` for non-interactive (CI) start; otherwise an interactive OAuth login is triggered if no session is stored.
85
+ - `stop`
86
+ Stops the running OML server.
87
+ - `status`
88
+ Prints the OML server status.
89
+ - `list`
90
+ Shows all actively running OML servers.
@@ -1,3 +1,4 @@
1
+ import type { JsonWebKey } from 'node:crypto';
1
2
  type LoginOptions = {};
2
3
  export type OmlCliServerAuthSnapshot = {
3
4
  accessToken: string;
@@ -8,11 +9,20 @@ export declare class OmlCliAuthService {
8
9
  login(_options: LoginOptions): Promise<void>;
9
10
  logout(): Promise<void>;
10
11
  whoami(): Promise<void>;
12
+ getDeviceId(): Promise<string>;
13
+ getEntitlementCache(): Promise<{
14
+ expiry: number;
15
+ featureIds: string[];
16
+ token?: string;
17
+ } | null>;
18
+ saveEntitlementCache(expiry: number, featureIds: string[], token?: string): Promise<void>;
19
+ saveEntitlementsPubkey(pubkeyJwk: JsonWebKey): Promise<void>;
11
20
  ensureAuthenticated(operationName: string): Promise<void>;
12
21
  getAccessToken(): Promise<string>;
13
22
  refreshAccessToken(): Promise<string>;
14
23
  getServerAuthSnapshot(): Promise<OmlCliServerAuthSnapshot>;
15
24
  private tryGetValidSnapshot;
25
+ hasStoredCredential(): Promise<boolean>;
16
26
  private refreshCredential;
17
27
  }
18
28
  export {};
package/out/auth/auth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Copyright (c) 2026 Modelware. All rights reserved.
2
- import { exchangeGitHubToken, refreshSupabaseAccessToken } from '@oml/platform';
2
+ import { exchangeGitHubToken, refreshSupabaseAccessToken, verifyEntitlementsToken } from '@oml/platform';
3
3
  import chalk from 'chalk';
4
+ import * as crypto from 'node:crypto';
4
5
  import * as fs from 'node:fs/promises';
5
6
  import * as os from 'node:os';
6
7
  import * as path from 'node:path';
@@ -17,7 +18,10 @@ const KEYCHAIN_SERVICE = 'oml-code';
17
18
  const ACCESS_TOKEN_KEY = 'oml.cli.access_token';
18
19
  const REFRESH_TOKEN_KEY = 'oml.cli.refresh_token';
19
20
  const EXPIRES_AT_KEY = 'oml.cli.expires_at';
20
- const PROFILE_PATH = path.join(os.homedir(), '.oml', 'cli-profile.json');
21
+ const ENTITLEMENT_EXPIRY_KEY = 'oml.cli.entitlement_expiry';
22
+ const ENTITLEMENT_FEATURES_KEY = 'oml.cli.entitlement_features';
23
+ const ENTITLEMENT_TOKEN_KEY = 'oml.cli.entitlement_token';
24
+ const ENTITLEMENT_PUBKEY_KEY = 'oml.cli.entitlement_pubkey';
21
25
  const LOCK_PATH = path.join(os.homedir(), '.oml', 'credentials.lock');
22
26
  const SESSION_EXPIRATION_LEEWAY_MS = 20000;
23
27
  const LOCK_TIMEOUT_MS = 5000;
@@ -34,8 +38,9 @@ export class OmlCliAuthService {
34
38
  }
35
39
  const existing = await this.tryGetValidSnapshot();
36
40
  if (existing) {
37
- const profile = await readProfile();
38
- console.error(chalk.green(`Already signed in as ${profile?.userLabel ?? profile?.email ?? profile?.userId ?? 'current user'}.`));
41
+ const claims = decodeJwtClaims(existing.accessToken);
42
+ const label = claims.userLabel ?? claims.email ?? 'current user';
43
+ console.error(chalk.green(`Already signed in as ${label}.`));
39
44
  return;
40
45
  }
41
46
  const session = await authenticateWithGitHub();
@@ -44,32 +49,13 @@ export class OmlCliAuthService {
44
49
  refreshToken: session.refreshToken,
45
50
  expiresAtMs: session.expiresAtMs,
46
51
  });
47
- await writeProfile({
48
- provider: session.provider,
49
- userId: session.userId,
50
- userLabel: session.userLabel,
51
- email: session.email,
52
- tier: session.tier,
53
- signedInAt: new Date().toISOString(),
54
- });
52
+ void fetchAndSaveEntitlementsPubkey(resolveApiBaseUrl(), this).catch(() => undefined);
55
53
  const summary = session.userLabel ?? session.email ?? 'signed-in user';
56
- console.error(chalk.green(`Signed in as ${summary} via ${session.provider}.`));
54
+ console.error(chalk.green(`Signed in as ${summary} via GitHub.`));
57
55
  }
58
56
  async logout() {
59
- const activeServers = await listActiveServers();
60
57
  await deleteCredential();
61
- await deleteProfile();
62
- if (activeServers.length > 0) {
63
- console.error(chalk.yellow('Stored OAuth credentials were cleared. Running servers were not stopped:'));
64
- for (const server of activeServers) {
65
- console.error(`- ${server.workspaceRoot ?? '(unknown workspace)'} on port ${server.port} (pid ${server.pid})`);
66
- }
67
- if (process.env[API_KEY_ENV]?.trim()) {
68
- console.error(chalk.yellow('OML_PLATFORM_API_KEY is still set, so future starts may use API-key auth.'));
69
- }
70
- return;
71
- }
72
- console.error(chalk.green('Stored OAuth credentials were cleared.'));
58
+ console.error(chalk.green('Signed out.'));
73
59
  if (process.env[API_KEY_ENV]?.trim()) {
74
60
  console.error(chalk.yellow('OML_PLATFORM_API_KEY is still set, so future starts may use API-key auth.'));
75
61
  }
@@ -79,26 +65,84 @@ export class OmlCliAuthService {
79
65
  if (apiKey) {
80
66
  console.error('Auth mode: api_key');
81
67
  console.error('Account: resolved by OML Platform from OML_PLATFORM_API_KEY');
82
- const profile = await readProfile();
83
- if (profile) {
84
- console.error(`Stored OAuth user: ${profile.userLabel ?? profile.email ?? profile.userId}`);
85
- }
86
68
  return;
87
69
  }
88
70
  const credential = await readCredential();
89
- const profile = await readProfile();
90
- if (!credential || !profile) {
71
+ if (!credential) {
91
72
  console.error(chalk.yellow('Not signed in.'));
92
73
  return;
93
74
  }
75
+ const claims = decodeJwtClaims(credential.accessToken);
94
76
  console.error('Auth mode: oauth');
95
- console.error(`Provider: ${profile.provider}`);
96
- console.error(`User ID: ${profile.userId}`);
97
- console.error(`User label: ${profile.userLabel ?? '(not set)'}`);
98
- console.error(`Email: ${profile.email ?? '(not set)'}`);
99
- console.error(`Tier: ${profile.tier ?? '(not set)'}`);
100
- console.error(`Signed in at: ${profile.signedInAt}`);
101
- console.error(`Access token expires at: ${new Date(credential.expiresAtMs).toISOString()}`);
77
+ console.error(`User ID: ${claims.userId ?? '(unknown)'}`);
78
+ console.error(`User label: ${claims.userLabel ?? '(not set)'}`);
79
+ console.error(`Email: ${claims.email ?? '(not set)'}`);
80
+ console.error(`Tier: ${claims.tier ?? '(not set)'}`);
81
+ console.error(`Token expires at: ${new Date(credential.expiresAtMs).toISOString()}`);
82
+ }
83
+ async getDeviceId() {
84
+ return getOrCreateDeviceId();
85
+ }
86
+ async getEntitlementCache() {
87
+ try {
88
+ const keytar = await getKeytarModule();
89
+ const [expiryRaw, featuresRaw, tokenRaw, pubkeyRaw] = await Promise.all([
90
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY),
91
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY),
92
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY),
93
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY),
94
+ ]);
95
+ const expiry = Number(expiryRaw ?? NaN);
96
+ if (!Number.isFinite(expiry) || expiry <= Date.now() || !featuresRaw) {
97
+ return null;
98
+ }
99
+ const featureIds = JSON.parse(featuresRaw);
100
+ if (!Array.isArray(featureIds) || !featureIds.every((x) => typeof x === 'string')) {
101
+ return null;
102
+ }
103
+ let token;
104
+ if (tokenRaw && pubkeyRaw) {
105
+ try {
106
+ const deviceId = await getOrCreateDeviceId();
107
+ const valid = await verifyEntitlementsToken(tokenRaw, deviceId, JSON.parse(pubkeyRaw));
108
+ if (valid) {
109
+ token = tokenRaw;
110
+ }
111
+ }
112
+ catch {
113
+ // Verification failed — proceed without token; gate will re-fetch.
114
+ }
115
+ }
116
+ return { expiry, featureIds: featureIds, token };
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ async saveEntitlementCache(expiry, featureIds, token) {
123
+ try {
124
+ const keytar = await getKeytarModule();
125
+ const writes = [
126
+ keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY, String(expiry)),
127
+ keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY, JSON.stringify(featureIds)),
128
+ ];
129
+ if (token) {
130
+ writes.push(keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY, token));
131
+ }
132
+ await Promise.all(writes);
133
+ }
134
+ catch {
135
+ // Credential store unavailable — skip silently.
136
+ }
137
+ }
138
+ async saveEntitlementsPubkey(pubkeyJwk) {
139
+ try {
140
+ const keytar = await getKeytarModule();
141
+ await keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY, JSON.stringify(pubkeyJwk));
142
+ }
143
+ catch {
144
+ // Credential store unavailable — skip silently.
145
+ }
102
146
  }
103
147
  async ensureAuthenticated(operationName) {
104
148
  if (process.env[API_KEY_ENV]?.trim()) {
@@ -144,6 +188,9 @@ export class OmlCliAuthService {
144
188
  return null;
145
189
  }
146
190
  }
191
+ async hasStoredCredential() {
192
+ return (await readCredential()) !== undefined;
193
+ }
147
194
  async refreshCredential() {
148
195
  const session = await readCredential();
149
196
  if (!session?.refreshToken) {
@@ -163,19 +210,11 @@ export class OmlCliAuthService {
163
210
  expiresAtMs: Date.now() + (refreshed.expires_in * 1000),
164
211
  };
165
212
  await writeCredential(updatedSession);
166
- const profile = await readProfile();
167
- if (profile) {
168
- await writeProfile({
169
- ...profile,
170
- email: refreshed.email ?? profile.email,
171
- });
172
- }
173
213
  return updatedSession;
174
214
  }
175
215
  catch (error) {
176
216
  if (isUnauthorizedError(error)) {
177
217
  await deleteCredential();
178
- await deleteProfile();
179
218
  throw new Error('Authentication refresh failed because the stored credential was revoked. Run \'oml login\' again.');
180
219
  }
181
220
  throw new Error('Authentication refresh failed. Check your network connection or sign in again with \'oml login\'.');
@@ -212,65 +251,39 @@ async function deleteCredential() {
212
251
  keytar.deletePassword(KEYCHAIN_SERVICE, ACCESS_TOKEN_KEY),
213
252
  keytar.deletePassword(KEYCHAIN_SERVICE, REFRESH_TOKEN_KEY),
214
253
  keytar.deletePassword(KEYCHAIN_SERVICE, EXPIRES_AT_KEY),
254
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY),
255
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY),
256
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY),
257
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY),
215
258
  ]);
216
259
  }
217
- async function readProfile() {
260
+ async function getOrCreateDeviceId() {
218
261
  try {
219
- const content = await fs.readFile(PROFILE_PATH, 'utf-8');
220
- const data = JSON.parse(content);
221
- if (!data.provider || !data.userId || !data.signedInAt) {
222
- return undefined;
223
- }
224
- return {
225
- provider: data.provider,
226
- userId: data.userId,
227
- userLabel: data.userLabel,
228
- email: data.email ?? null,
229
- tier: data.tier,
230
- signedInAt: data.signedInAt,
231
- };
262
+ return (await fs.readFile('/etc/machine-id', 'utf-8')).trim();
232
263
  }
233
264
  catch {
234
- return undefined;
265
+ try {
266
+ return (await fs.readFile(LOCAL_MACHINE_ID_PATH, 'utf-8')).trim();
267
+ }
268
+ catch {
269
+ const id = crypto.randomUUID();
270
+ await fs.mkdir(path.dirname(LOCAL_MACHINE_ID_PATH), { recursive: true });
271
+ await fs.writeFile(LOCAL_MACHINE_ID_PATH, id, { encoding: 'utf-8', mode: 0o600 });
272
+ return id;
273
+ }
235
274
  }
236
275
  }
237
- async function writeProfile(profile) {
238
- await fs.mkdir(path.dirname(PROFILE_PATH), { recursive: true });
239
- await fs.writeFile(PROFILE_PATH, `${JSON.stringify(profile, null, 2)}\n`, 'utf-8');
240
- }
241
- async function deleteProfile() {
242
- try {
243
- await fs.unlink(PROFILE_PATH);
244
- }
245
- catch (error) {
246
- if (error.code !== 'ENOENT') {
247
- throw error;
248
- }
276
+ async function fetchAndSaveEntitlementsPubkey(apiBaseUrl, authService) {
277
+ const response = await fetch(`${apiBaseUrl}/entitlements/pubkey`);
278
+ if (!response.ok) {
279
+ return;
249
280
  }
281
+ const jwk = await response.json();
282
+ await authService.saveEntitlementsPubkey(jwk);
250
283
  }
251
284
  async function authenticateWithGitHub() {
252
285
  const clientId = resolveClientId();
253
- const params = new URLSearchParams({
254
- client_id: clientId,
255
- scope: 'read:user user:email'
256
- });
257
- const response = await fetch(GITHUB_DEVICE_CODE_URL, {
258
- method: 'POST',
259
- headers: {
260
- accept: 'application/json',
261
- 'content-type': 'application/x-www-form-urlencoded'
262
- },
263
- body: params
264
- });
265
- if (!response.ok) {
266
- throw new Error(`GitHub device authorization failed: HTTP ${response.status} ${response.statusText}`);
267
- }
268
- const device = await response.json();
269
- if (!device.device_code || !device.user_code || !device.verification_uri) {
270
- throw new Error('GitHub device authorization response was incomplete.');
271
- }
272
- printDeviceFlowInstructions('GitHub', device.verification_uri, device.user_code);
273
- const token = await pollForGitHubAccessToken(clientId, device);
286
+ const token = await authenticateWithGitHubDevice(clientId);
274
287
  const userResponse = await fetch(GITHUB_USER_URL, {
275
288
  headers: {
276
289
  accept: 'application/vnd.github+json',
@@ -286,16 +299,36 @@ async function authenticateWithGitHub() {
286
299
  }
287
300
  const platformSession = await exchangeGitHubToken(resolveApiBaseUrl(), token);
288
301
  return {
289
- provider: 'github',
290
- userId: platformSession.user_id,
291
302
  userLabel: user.login,
292
303
  email: platformSession.email,
293
- tier: platformSession.tier,
294
304
  accessToken: platformSession.access_token,
295
305
  refreshToken: platformSession.refresh_token,
296
306
  expiresAtMs: Date.now() + platformSession.expires_in * 1000,
297
307
  };
298
308
  }
309
+ async function authenticateWithGitHubDevice(clientId) {
310
+ const params = new URLSearchParams({
311
+ client_id: clientId,
312
+ scope: 'read:user user:email'
313
+ });
314
+ const response = await fetch(GITHUB_DEVICE_CODE_URL, {
315
+ method: 'POST',
316
+ headers: {
317
+ accept: 'application/json',
318
+ 'content-type': 'application/x-www-form-urlencoded'
319
+ },
320
+ body: params
321
+ });
322
+ if (!response.ok) {
323
+ throw new Error(`GitHub device authorization failed: HTTP ${response.status} ${response.statusText}`);
324
+ }
325
+ const device = await response.json();
326
+ if (!device.device_code || !device.user_code || !device.verification_uri) {
327
+ throw new Error('GitHub device authorization response was incomplete.');
328
+ }
329
+ printDeviceFlowInstructions('GitHub', device.verification_uri, device.user_code);
330
+ return await pollForGitHubAccessToken(clientId, device);
331
+ }
299
332
  function printDeviceFlowInstructions(providerName, verificationUri, userCode) {
300
333
  console.error(chalk.cyan(`${providerName} sign-in required.`));
301
334
  console.error(`Open: ${verificationUri}`);
@@ -367,38 +400,6 @@ async function acquireCredentialLock() {
367
400
  }
368
401
  throw new Error('Timed out waiting for the CLI credential lock.');
369
402
  }
370
- async function listActiveServers() {
371
- const baseDir = path.join(os.homedir(), '.oml', 'workspaces');
372
- try {
373
- const workspaceDirs = await fs.readdir(baseDir);
374
- const active = [];
375
- for (const workspaceDir of workspaceDirs) {
376
- const lockFile = path.join(baseDir, workspaceDir, 'server.lock');
377
- try {
378
- const raw = await fs.readFile(lockFile, 'utf-8');
379
- const parsed = JSON.parse(raw);
380
- const pid = Number(parsed.pid);
381
- const port = Number(parsed.port);
382
- if (!Number.isFinite(pid) || !Number.isFinite(port)) {
383
- continue;
384
- }
385
- process.kill(Math.trunc(pid), 0);
386
- active.push({
387
- pid: Math.trunc(pid),
388
- port: Math.trunc(port),
389
- workspaceRoot: typeof parsed.workspaceRoot === 'string' ? parsed.workspaceRoot : undefined,
390
- });
391
- }
392
- catch {
393
- // ignore malformed or stale lock entries
394
- }
395
- }
396
- return active;
397
- }
398
- catch {
399
- return [];
400
- }
401
- }
402
403
  function resolveClientId() {
403
404
  const configured = process.env.OML_AUTH_GITHUB_CLIENT_ID?.trim() || DEFAULT_GITHUB_CLIENT_ID;
404
405
  if (configured) {
@@ -422,23 +423,135 @@ function isUnauthorizedError(error) {
422
423
  const message = error instanceof Error ? error.message : String(error);
423
424
  return /\b401\b/.test(message);
424
425
  }
426
+ const CREDENTIALS_FILE_PATH = path.join(os.homedir(), '.oml', 'credentials.json');
427
+ const LOCAL_MACHINE_ID_PATH = path.join(os.homedir(), '.oml', 'machine-id');
428
+ // Derive a 256-bit encryption key bound to this machine and user.
429
+ // Uses /etc/machine-id (Linux) or a locally generated UUID as the key material,
430
+ // combined with the user's home directory so the key is user-specific too.
431
+ async function deriveMachineKey() {
432
+ const machineId = await getOrCreateDeviceId();
433
+ const ikm = Buffer.from(`${machineId}:${os.homedir()}`, 'utf-8');
434
+ return new Promise((resolve, reject) => {
435
+ crypto.hkdf('sha256', ikm, Buffer.alloc(0), 'oml-cli-credentials-v1', 32, (err, key) => {
436
+ if (err) {
437
+ reject(err);
438
+ }
439
+ else {
440
+ resolve(Buffer.from(key));
441
+ }
442
+ });
443
+ });
444
+ }
445
+ function encryptValue(value, key) {
446
+ const iv = crypto.randomBytes(12);
447
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
448
+ const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]);
449
+ const tag = cipher.getAuthTag();
450
+ return JSON.stringify({
451
+ v: 1,
452
+ iv: iv.toString('base64'),
453
+ tag: tag.toString('base64'),
454
+ data: encrypted.toString('base64'),
455
+ });
456
+ }
457
+ function decryptValue(stored, key) {
458
+ const parsed = JSON.parse(stored);
459
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(parsed.iv, 'base64'));
460
+ decipher.setAuthTag(Buffer.from(parsed.tag, 'base64'));
461
+ return decipher.update(Buffer.from(parsed.data, 'base64'), undefined, 'utf-8') + decipher.final('utf-8');
462
+ }
463
+ class FileKeytar {
464
+ getKey() {
465
+ if (!this.keyPromise) {
466
+ this.keyPromise = deriveMachineKey();
467
+ }
468
+ return this.keyPromise;
469
+ }
470
+ async read() {
471
+ try {
472
+ const content = await fs.readFile(CREDENTIALS_FILE_PATH, 'utf-8');
473
+ return JSON.parse(content);
474
+ }
475
+ catch {
476
+ return {};
477
+ }
478
+ }
479
+ async write(store) {
480
+ await fs.mkdir(path.dirname(CREDENTIALS_FILE_PATH), { recursive: true });
481
+ await fs.writeFile(CREDENTIALS_FILE_PATH, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
482
+ }
483
+ storeKey(service, account) {
484
+ return `${service}:${account}`;
485
+ }
486
+ async getPassword(service, account) {
487
+ const store = await this.read();
488
+ const raw = store[this.storeKey(service, account)];
489
+ if (raw === undefined || raw === null) {
490
+ return null;
491
+ }
492
+ try {
493
+ const key = await this.getKey();
494
+ return decryptValue(raw, key);
495
+ }
496
+ catch {
497
+ // Unreadable entry (wrong machine, corrupt, or legacy plaintext) — treat as missing.
498
+ return null;
499
+ }
500
+ }
501
+ async setPassword(service, account, password) {
502
+ const [store, key] = await Promise.all([this.read(), this.getKey()]);
503
+ store[this.storeKey(service, account)] = encryptValue(password, key);
504
+ await this.write(store);
505
+ }
506
+ async deletePassword(service, account) {
507
+ const store = await this.read();
508
+ const k = this.storeKey(service, account);
509
+ if (!(k in store)) {
510
+ return false;
511
+ }
512
+ delete store[k];
513
+ await this.write(store);
514
+ return true;
515
+ }
516
+ }
425
517
  async function getKeytarModule() {
426
518
  if (!keytarModulePromise) {
427
519
  keytarModulePromise = import('keytar')
428
520
  .then((loaded) => loaded.default)
429
521
  .catch((error) => {
522
+ const message = error instanceof Error ? error.message : String(error);
523
+ if (/libsecret|dlopen|ERR_DLOPEN_FAILED/i.test(message)) {
524
+ console.error(chalk.yellow('System keychain unavailable; credentials will be stored encrypted at ' +
525
+ CREDENTIALS_FILE_PATH + ' using a machine-bound key. ' +
526
+ 'Set OML_PLATFORM_API_KEY for non-interactive environments.'));
527
+ return new FileKeytar();
528
+ }
430
529
  keytarModulePromise = undefined;
431
- throw new Error(formatKeytarLoadError(error));
530
+ throw new Error(`Secure credential storage is unavailable: ${message}`);
432
531
  });
433
532
  }
434
533
  return keytarModulePromise;
435
534
  }
436
- function formatKeytarLoadError(error) {
437
- const message = error instanceof Error ? error.message : String(error);
438
- if (/libsecret|dlopen|ERR_DLOPEN_FAILED/i.test(message)) {
439
- return 'Secure credential storage is unavailable (missing system keychain runtime, e.g. libsecret on Linux). ' +
440
- 'Install the OS keychain runtime or set OML_PLATFORM_API_KEY for non-interactive mode.';
535
+ function decodeJwtClaims(token) {
536
+ try {
537
+ const payload = token.split('.')[1];
538
+ if (!payload) {
539
+ return {};
540
+ }
541
+ const json = JSON.parse(Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8'));
542
+ const userMeta = json['user_metadata'];
543
+ const appMeta = json['app_metadata'];
544
+ return {
545
+ userId: typeof json['sub'] === 'string' ? json['sub'] : undefined,
546
+ email: typeof json['email'] === 'string' ? json['email'] : undefined,
547
+ userLabel: userMeta && typeof userMeta['user_name'] === 'string'
548
+ ? userMeta['user_name'] : undefined,
549
+ tier: appMeta && typeof appMeta['tier'] === 'string'
550
+ ? appMeta['tier'] : undefined,
551
+ };
552
+ }
553
+ catch {
554
+ return {};
441
555
  }
442
- return `Secure credential storage is unavailable: ${message}`;
443
556
  }
444
557
  //# sourceMappingURL=auth.js.map