@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 +36 -36
- package/out/auth/auth.d.ts +10 -0
- package/out/auth/auth.js +250 -137
- package/out/auth/auth.js.map +1 -1
- package/out/cli.js +48 -26
- package/out/cli.js.map +1 -1
- package/out/commands/export.d.ts +0 -1
- package/out/commands/export.js +0 -1
- package/out/commands/export.js.map +1 -1
- package/out/commands/lint.js +2 -1
- package/out/commands/lint.js.map +1 -1
- package/out/commands/reason.d.ts +0 -1
- package/out/commands/reason.js +20 -7
- package/out/commands/reason.js.map +1 -1
- package/out/commands/render.d.ts +0 -1
- package/out/commands/render.js.map +1 -1
- package/out/commands/server/actions.d.ts +8 -0
- package/out/commands/server/actions.js +38 -10
- package/out/commands/server/actions.js.map +1 -1
- package/out/commands/server/rest.js +15 -8
- package/out/commands/server/rest.js.map +1 -1
- package/out/commands/validate.js +9 -6
- package/out/commands/validate.js.map +1 -1
- package/package.json +6 -4
- package/src/auth/auth.ts +265 -153
- package/src/cli.ts +55 -35
- package/src/commands/export.ts +0 -2
- package/src/commands/lint.ts +2 -1
- package/src/commands/reason.ts +21 -10
- package/src/commands/render.ts +0 -1
- package/src/commands/server/actions.ts +49 -10
- package/src/commands/server/rest.ts +17 -9
- package/src/commands/validate.ts +8 -6
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
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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,33 +83,14 @@ export class OmlCliAuthService {
|
|
|
90
83
|
refreshToken: session.refreshToken,
|
|
91
84
|
expiresAtMs: session.expiresAtMs,
|
|
92
85
|
});
|
|
93
|
-
|
|
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
|
|
88
|
+
console.error(chalk.green(`Signed in as ${summary} via GitHub.`));
|
|
103
89
|
}
|
|
104
90
|
|
|
105
91
|
async logout(): Promise<void> {
|
|
106
|
-
const activeServers = await listActiveServers();
|
|
107
92
|
await deleteCredential();
|
|
108
|
-
|
|
109
|
-
if (activeServers.length > 0) {
|
|
110
|
-
console.error(chalk.yellow('Stored OAuth credentials were cleared. Running servers were not stopped:'));
|
|
111
|
-
for (const server of activeServers) {
|
|
112
|
-
console.error(`- ${server.workspaceRoot ?? '(unknown workspace)'} on port ${server.port} (pid ${server.pid})`);
|
|
113
|
-
}
|
|
114
|
-
if (process.env[API_KEY_ENV]?.trim()) {
|
|
115
|
-
console.error(chalk.yellow('OML_PLATFORM_API_KEY is still set, so future starts may use API-key auth.'));
|
|
116
|
-
}
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
console.error(chalk.green('Stored OAuth credentials were cleared.'));
|
|
93
|
+
console.error(chalk.green('Signed out.'));
|
|
120
94
|
if (process.env[API_KEY_ENV]?.trim()) {
|
|
121
95
|
console.error(chalk.yellow('OML_PLATFORM_API_KEY is still set, so future starts may use API-key auth.'));
|
|
122
96
|
}
|
|
@@ -127,27 +101,85 @@ export class OmlCliAuthService {
|
|
|
127
101
|
if (apiKey) {
|
|
128
102
|
console.error('Auth mode: api_key');
|
|
129
103
|
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
104
|
return;
|
|
135
105
|
}
|
|
136
106
|
|
|
137
107
|
const credential = await readCredential();
|
|
138
|
-
|
|
139
|
-
if (!credential || !profile) {
|
|
108
|
+
if (!credential) {
|
|
140
109
|
console.error(chalk.yellow('Not signed in.'));
|
|
141
110
|
return;
|
|
142
111
|
}
|
|
112
|
+
const claims = decodeJwtClaims(credential.accessToken);
|
|
143
113
|
console.error('Auth mode: oauth');
|
|
144
|
-
console.error(`
|
|
145
|
-
console.error(`User
|
|
146
|
-
console.error(`
|
|
147
|
-
console.error(`
|
|
148
|
-
console.error(`
|
|
149
|
-
|
|
150
|
-
|
|
114
|
+
console.error(`User ID: ${claims.userId ?? '(unknown)'}`);
|
|
115
|
+
console.error(`User label: ${claims.userLabel ?? '(not set)'}`);
|
|
116
|
+
console.error(`Email: ${claims.email ?? '(not set)'}`);
|
|
117
|
+
console.error(`Tier: ${claims.tier ?? '(not set)'}`);
|
|
118
|
+
console.error(`Token expires at: ${new Date(credential.expiresAtMs).toISOString()}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async getDeviceId(): Promise<string> {
|
|
122
|
+
return getOrCreateDeviceId();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getEntitlementCache(): Promise<{ expiry: number; featureIds: string[]; token?: string } | null> {
|
|
126
|
+
try {
|
|
127
|
+
const keytar = await getKeytarModule();
|
|
128
|
+
const [expiryRaw, featuresRaw, tokenRaw, pubkeyRaw] = await Promise.all([
|
|
129
|
+
keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY),
|
|
130
|
+
keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY),
|
|
131
|
+
keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY),
|
|
132
|
+
keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY),
|
|
133
|
+
]);
|
|
134
|
+
const expiry = Number(expiryRaw ?? NaN);
|
|
135
|
+
if (!Number.isFinite(expiry) || expiry <= Date.now() || !featuresRaw) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const featureIds = JSON.parse(featuresRaw) as unknown;
|
|
139
|
+
if (!Array.isArray(featureIds) || !featureIds.every((x) => typeof x === 'string')) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
let token: string | undefined;
|
|
143
|
+
if (tokenRaw && pubkeyRaw) {
|
|
144
|
+
try {
|
|
145
|
+
const deviceId = await getOrCreateDeviceId();
|
|
146
|
+
const valid = await verifyEntitlementsToken(tokenRaw, deviceId, JSON.parse(pubkeyRaw) as JsonWebKey);
|
|
147
|
+
if (valid) {
|
|
148
|
+
token = tokenRaw;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Verification failed — proceed without token; gate will re-fetch.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { expiry, featureIds: featureIds as string[], token };
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async saveEntitlementCache(expiry: number, featureIds: string[], token?: string): Promise<void> {
|
|
161
|
+
try {
|
|
162
|
+
const keytar = await getKeytarModule();
|
|
163
|
+
const writes: Promise<void>[] = [
|
|
164
|
+
keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY, String(expiry)),
|
|
165
|
+
keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY, JSON.stringify(featureIds)),
|
|
166
|
+
];
|
|
167
|
+
if (token) {
|
|
168
|
+
writes.push(keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY, token));
|
|
169
|
+
}
|
|
170
|
+
await Promise.all(writes);
|
|
171
|
+
} catch {
|
|
172
|
+
// Credential store unavailable — skip silently.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async saveEntitlementsPubkey(pubkeyJwk: JsonWebKey): Promise<void> {
|
|
177
|
+
try {
|
|
178
|
+
const keytar = await getKeytarModule();
|
|
179
|
+
await keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY, JSON.stringify(pubkeyJwk));
|
|
180
|
+
} catch {
|
|
181
|
+
// Credential store unavailable — skip silently.
|
|
182
|
+
}
|
|
151
183
|
}
|
|
152
184
|
|
|
153
185
|
async ensureAuthenticated(operationName: string): Promise<void> {
|
|
@@ -198,6 +230,10 @@ export class OmlCliAuthService {
|
|
|
198
230
|
}
|
|
199
231
|
}
|
|
200
232
|
|
|
233
|
+
async hasStoredCredential(): Promise<boolean> {
|
|
234
|
+
return (await readCredential()) !== undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
201
237
|
private async refreshCredential(): Promise<StoredCredential> {
|
|
202
238
|
const session = await readCredential();
|
|
203
239
|
if (!session?.refreshToken) {
|
|
@@ -222,18 +258,10 @@ export class OmlCliAuthService {
|
|
|
222
258
|
expiresAtMs: Date.now() + (refreshed.expires_in * 1000),
|
|
223
259
|
};
|
|
224
260
|
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
261
|
return updatedSession;
|
|
233
262
|
} catch (error) {
|
|
234
263
|
if (isUnauthorizedError(error)) {
|
|
235
264
|
await deleteCredential();
|
|
236
|
-
await deleteProfile();
|
|
237
265
|
throw new Error('Authentication refresh failed because the stored credential was revoked. Run \'oml login\' again.');
|
|
238
266
|
}
|
|
239
267
|
throw new Error('Authentication refresh failed. Check your network connection or sign in again with \'oml login\'.');
|
|
@@ -272,77 +300,46 @@ async function deleteCredential(): Promise<void> {
|
|
|
272
300
|
keytar.deletePassword(KEYCHAIN_SERVICE, ACCESS_TOKEN_KEY),
|
|
273
301
|
keytar.deletePassword(KEYCHAIN_SERVICE, REFRESH_TOKEN_KEY),
|
|
274
302
|
keytar.deletePassword(KEYCHAIN_SERVICE, EXPIRES_AT_KEY),
|
|
303
|
+
keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY),
|
|
304
|
+
keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY),
|
|
305
|
+
keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY),
|
|
306
|
+
keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY),
|
|
275
307
|
]);
|
|
276
308
|
}
|
|
277
309
|
|
|
278
|
-
async function
|
|
310
|
+
async function getOrCreateDeviceId(): Promise<string> {
|
|
279
311
|
try {
|
|
280
|
-
|
|
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
|
-
};
|
|
312
|
+
return (await fs.readFile('/etc/machine-id', 'utf-8')).trim();
|
|
293
313
|
} catch {
|
|
294
|
-
|
|
314
|
+
try {
|
|
315
|
+
return (await fs.readFile(LOCAL_MACHINE_ID_PATH, 'utf-8')).trim();
|
|
316
|
+
} catch {
|
|
317
|
+
const id = crypto.randomUUID();
|
|
318
|
+
await fs.mkdir(path.dirname(LOCAL_MACHINE_ID_PATH), { recursive: true });
|
|
319
|
+
await fs.writeFile(LOCAL_MACHINE_ID_PATH, id, { encoding: 'utf-8', mode: 0o600 });
|
|
320
|
+
return id;
|
|
321
|
+
}
|
|
295
322
|
}
|
|
296
323
|
}
|
|
297
324
|
|
|
298
|
-
async function
|
|
299
|
-
await
|
|
300
|
-
|
|
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
|
-
}
|
|
325
|
+
async function fetchAndSaveEntitlementsPubkey(apiBaseUrl: string, authService: OmlCliAuthService): Promise<void> {
|
|
326
|
+
const response = await fetch(`${apiBaseUrl}/entitlements/pubkey`);
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
return;
|
|
310
329
|
}
|
|
330
|
+
const jwk = await response.json() as JsonWebKey;
|
|
331
|
+
await authService.saveEntitlementsPubkey(jwk);
|
|
311
332
|
}
|
|
312
333
|
|
|
313
334
|
async function authenticateWithGitHub(): Promise<{
|
|
314
|
-
provider: Provider;
|
|
315
|
-
userId: string;
|
|
316
335
|
userLabel?: string;
|
|
317
336
|
email: string | null;
|
|
318
|
-
tier?: string;
|
|
319
337
|
accessToken: string;
|
|
320
338
|
refreshToken: string;
|
|
321
339
|
expiresAtMs: number;
|
|
322
340
|
}> {
|
|
323
341
|
const clientId = resolveClientId();
|
|
324
|
-
const
|
|
325
|
-
client_id: clientId,
|
|
326
|
-
scope: 'read:user user:email'
|
|
327
|
-
});
|
|
328
|
-
const response = await fetch(GITHUB_DEVICE_CODE_URL, {
|
|
329
|
-
method: 'POST',
|
|
330
|
-
headers: {
|
|
331
|
-
accept: 'application/json',
|
|
332
|
-
'content-type': 'application/x-www-form-urlencoded'
|
|
333
|
-
},
|
|
334
|
-
body: params
|
|
335
|
-
});
|
|
336
|
-
if (!response.ok) {
|
|
337
|
-
throw new Error(`GitHub device authorization failed: HTTP ${response.status} ${response.statusText}`);
|
|
338
|
-
}
|
|
339
|
-
const device = await response.json() as GitHubDeviceCodeResponse;
|
|
340
|
-
if (!device.device_code || !device.user_code || !device.verification_uri) {
|
|
341
|
-
throw new Error('GitHub device authorization response was incomplete.');
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
printDeviceFlowInstructions('GitHub', device.verification_uri, device.user_code);
|
|
345
|
-
const token = await pollForGitHubAccessToken(clientId, device);
|
|
342
|
+
const token = await authenticateWithGitHubDevice(clientId);
|
|
346
343
|
const userResponse = await fetch(GITHUB_USER_URL, {
|
|
347
344
|
headers: {
|
|
348
345
|
accept: 'application/vnd.github+json',
|
|
@@ -360,17 +357,39 @@ async function authenticateWithGitHub(): Promise<{
|
|
|
360
357
|
const platformSession = await exchangeGitHubToken(resolveApiBaseUrl(), token);
|
|
361
358
|
|
|
362
359
|
return {
|
|
363
|
-
provider: 'github',
|
|
364
|
-
userId: platformSession.user_id,
|
|
365
360
|
userLabel: user.login,
|
|
366
361
|
email: platformSession.email,
|
|
367
|
-
tier: platformSession.tier,
|
|
368
362
|
accessToken: platformSession.access_token,
|
|
369
363
|
refreshToken: platformSession.refresh_token,
|
|
370
364
|
expiresAtMs: Date.now() + platformSession.expires_in * 1000,
|
|
371
365
|
};
|
|
372
366
|
}
|
|
373
367
|
|
|
368
|
+
async function authenticateWithGitHubDevice(clientId: string): Promise<string> {
|
|
369
|
+
const params = new URLSearchParams({
|
|
370
|
+
client_id: clientId,
|
|
371
|
+
scope: 'read:user user:email'
|
|
372
|
+
});
|
|
373
|
+
const response = await fetch(GITHUB_DEVICE_CODE_URL, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
headers: {
|
|
376
|
+
accept: 'application/json',
|
|
377
|
+
'content-type': 'application/x-www-form-urlencoded'
|
|
378
|
+
},
|
|
379
|
+
body: params
|
|
380
|
+
});
|
|
381
|
+
if (!response.ok) {
|
|
382
|
+
throw new Error(`GitHub device authorization failed: HTTP ${response.status} ${response.statusText}`);
|
|
383
|
+
}
|
|
384
|
+
const device = await response.json() as GitHubDeviceCodeResponse;
|
|
385
|
+
if (!device.device_code || !device.user_code || !device.verification_uri) {
|
|
386
|
+
throw new Error('GitHub device authorization response was incomplete.');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
printDeviceFlowInstructions('GitHub', device.verification_uri, device.user_code);
|
|
390
|
+
return await pollForGitHubAccessToken(clientId, device);
|
|
391
|
+
}
|
|
392
|
+
|
|
374
393
|
function printDeviceFlowInstructions(providerName: string, verificationUri: string, userCode: string): void {
|
|
375
394
|
console.error(chalk.cyan(`${providerName} sign-in required.`));
|
|
376
395
|
console.error(`Open: ${verificationUri}`);
|
|
@@ -444,37 +463,6 @@ async function acquireCredentialLock(): Promise<{ release: () => Promise<void> }
|
|
|
444
463
|
throw new Error('Timed out waiting for the CLI credential lock.');
|
|
445
464
|
}
|
|
446
465
|
|
|
447
|
-
async function listActiveServers(): Promise<Array<{ pid: number; port: number; workspaceRoot?: string }>> {
|
|
448
|
-
const baseDir = path.join(os.homedir(), '.oml', 'workspaces');
|
|
449
|
-
try {
|
|
450
|
-
const workspaceDirs = await fs.readdir(baseDir);
|
|
451
|
-
const active: Array<{ pid: number; port: number; workspaceRoot?: string }> = [];
|
|
452
|
-
for (const workspaceDir of workspaceDirs) {
|
|
453
|
-
const lockFile = path.join(baseDir, workspaceDir, 'server.lock');
|
|
454
|
-
try {
|
|
455
|
-
const raw = await fs.readFile(lockFile, 'utf-8');
|
|
456
|
-
const parsed = JSON.parse(raw) as { pid?: unknown; port?: unknown; workspaceRoot?: unknown };
|
|
457
|
-
const pid = Number(parsed.pid);
|
|
458
|
-
const port = Number(parsed.port);
|
|
459
|
-
if (!Number.isFinite(pid) || !Number.isFinite(port)) {
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
process.kill(Math.trunc(pid), 0);
|
|
463
|
-
active.push({
|
|
464
|
-
pid: Math.trunc(pid),
|
|
465
|
-
port: Math.trunc(port),
|
|
466
|
-
workspaceRoot: typeof parsed.workspaceRoot === 'string' ? parsed.workspaceRoot : undefined,
|
|
467
|
-
});
|
|
468
|
-
} catch {
|
|
469
|
-
// ignore malformed or stale lock entries
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
return active;
|
|
473
|
-
} catch {
|
|
474
|
-
return [];
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
466
|
function resolveClientId(): string {
|
|
479
467
|
const configured = process.env.OML_AUTH_GITHUB_CLIENT_ID?.trim() || DEFAULT_GITHUB_CLIENT_ID;
|
|
480
468
|
if (configured) {
|
|
@@ -506,25 +494,149 @@ function isUnauthorizedError(error: unknown): boolean {
|
|
|
506
494
|
return /\b401\b/.test(message);
|
|
507
495
|
}
|
|
508
496
|
|
|
497
|
+
const CREDENTIALS_FILE_PATH = path.join(os.homedir(), '.oml', 'credentials.json');
|
|
498
|
+
const LOCAL_MACHINE_ID_PATH = path.join(os.homedir(), '.oml', 'machine-id');
|
|
499
|
+
|
|
500
|
+
// Derive a 256-bit encryption key bound to this machine and user.
|
|
501
|
+
// Uses /etc/machine-id (Linux) or a locally generated UUID as the key material,
|
|
502
|
+
// combined with the user's home directory so the key is user-specific too.
|
|
503
|
+
async function deriveMachineKey(): Promise<Buffer> {
|
|
504
|
+
const machineId = await getOrCreateDeviceId();
|
|
505
|
+
const ikm = Buffer.from(`${machineId}:${os.homedir()}`, 'utf-8');
|
|
506
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
507
|
+
crypto.hkdf('sha256', ikm, Buffer.alloc(0), 'oml-cli-credentials-v1', 32, (err, key) => {
|
|
508
|
+
if (err) { reject(err); } else { resolve(Buffer.from(key)); }
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function encryptValue(value: string, key: Buffer): string {
|
|
514
|
+
const iv = crypto.randomBytes(12);
|
|
515
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
516
|
+
const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]);
|
|
517
|
+
const tag = cipher.getAuthTag();
|
|
518
|
+
return JSON.stringify({
|
|
519
|
+
v: 1,
|
|
520
|
+
iv: iv.toString('base64'),
|
|
521
|
+
tag: tag.toString('base64'),
|
|
522
|
+
data: encrypted.toString('base64'),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function decryptValue(stored: string, key: Buffer): string {
|
|
527
|
+
const parsed = JSON.parse(stored) as { v?: number; iv: string; tag: string; data: string };
|
|
528
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(parsed.iv, 'base64'));
|
|
529
|
+
decipher.setAuthTag(Buffer.from(parsed.tag, 'base64'));
|
|
530
|
+
return decipher.update(Buffer.from(parsed.data, 'base64'), undefined, 'utf-8') + decipher.final('utf-8');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
class FileKeytar implements KeytarModule {
|
|
534
|
+
private keyPromise: Promise<Buffer> | undefined;
|
|
535
|
+
|
|
536
|
+
private getKey(): Promise<Buffer> {
|
|
537
|
+
if (!this.keyPromise) {
|
|
538
|
+
this.keyPromise = deriveMachineKey();
|
|
539
|
+
}
|
|
540
|
+
return this.keyPromise;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private async read(): Promise<Record<string, string>> {
|
|
544
|
+
try {
|
|
545
|
+
const content = await fs.readFile(CREDENTIALS_FILE_PATH, 'utf-8');
|
|
546
|
+
return JSON.parse(content) as Record<string, string>;
|
|
547
|
+
} catch {
|
|
548
|
+
return {};
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private async write(store: Record<string, string>): Promise<void> {
|
|
553
|
+
await fs.mkdir(path.dirname(CREDENTIALS_FILE_PATH), { recursive: true });
|
|
554
|
+
await fs.writeFile(CREDENTIALS_FILE_PATH, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private storeKey(service: string, account: string): string {
|
|
558
|
+
return `${service}:${account}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async getPassword(service: string, account: string): Promise<string | null> {
|
|
562
|
+
const store = await this.read();
|
|
563
|
+
const raw = store[this.storeKey(service, account)];
|
|
564
|
+
if (raw === undefined || raw === null) {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const key = await this.getKey();
|
|
569
|
+
return decryptValue(raw, key);
|
|
570
|
+
} catch {
|
|
571
|
+
// Unreadable entry (wrong machine, corrupt, or legacy plaintext) — treat as missing.
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async setPassword(service: string, account: string, password: string): Promise<void> {
|
|
577
|
+
const [store, key] = await Promise.all([this.read(), this.getKey()]);
|
|
578
|
+
store[this.storeKey(service, account)] = encryptValue(password, key);
|
|
579
|
+
await this.write(store);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async deletePassword(service: string, account: string): Promise<boolean> {
|
|
583
|
+
const store = await this.read();
|
|
584
|
+
const k = this.storeKey(service, account);
|
|
585
|
+
if (!(k in store)) {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
delete store[k];
|
|
589
|
+
await this.write(store);
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
509
594
|
async function getKeytarModule(): Promise<KeytarModule> {
|
|
510
595
|
if (!keytarModulePromise) {
|
|
511
596
|
keytarModulePromise = import('keytar')
|
|
512
597
|
.then((loaded) => loaded.default as KeytarModule)
|
|
513
598
|
.catch((error: unknown) => {
|
|
599
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
600
|
+
if (/libsecret|dlopen|ERR_DLOPEN_FAILED/i.test(message)) {
|
|
601
|
+
console.error(chalk.yellow(
|
|
602
|
+
'System keychain unavailable; credentials will be stored encrypted at ' +
|
|
603
|
+
CREDENTIALS_FILE_PATH + ' using a machine-bound key. ' +
|
|
604
|
+
'Set OML_PLATFORM_API_KEY for non-interactive environments.'
|
|
605
|
+
));
|
|
606
|
+
return new FileKeytar();
|
|
607
|
+
}
|
|
514
608
|
keytarModulePromise = undefined;
|
|
515
|
-
throw new Error(
|
|
609
|
+
throw new Error(`Secure credential storage is unavailable: ${message}`);
|
|
516
610
|
});
|
|
517
611
|
}
|
|
518
612
|
return keytarModulePromise;
|
|
519
613
|
}
|
|
520
614
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
615
|
+
type JwtClaims = {
|
|
616
|
+
userId?: string;
|
|
617
|
+
userLabel?: string;
|
|
618
|
+
email?: string;
|
|
619
|
+
tier?: string;
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
function decodeJwtClaims(token: string): JwtClaims {
|
|
623
|
+
try {
|
|
624
|
+
const payload = token.split('.')[1];
|
|
625
|
+
if (!payload) { return {}; }
|
|
626
|
+
const json = JSON.parse(Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8')) as Record<string, unknown>;
|
|
627
|
+
const userMeta = json['user_metadata'];
|
|
628
|
+
const appMeta = json['app_metadata'];
|
|
629
|
+
return {
|
|
630
|
+
userId: typeof json['sub'] === 'string' ? json['sub'] : undefined,
|
|
631
|
+
email: typeof json['email'] === 'string' ? json['email'] : undefined,
|
|
632
|
+
userLabel: userMeta && typeof (userMeta as Record<string, unknown>)['user_name'] === 'string'
|
|
633
|
+
? (userMeta as Record<string, unknown>)['user_name'] as string : undefined,
|
|
634
|
+
tier: appMeta && typeof (appMeta as Record<string, unknown>)['tier'] === 'string'
|
|
635
|
+
? (appMeta as Record<string, unknown>)['tier'] as string : undefined,
|
|
636
|
+
};
|
|
637
|
+
} catch {
|
|
638
|
+
return {};
|
|
526
639
|
}
|
|
527
|
-
return `Secure credential storage is unavailable: ${message}`;
|
|
528
640
|
}
|
|
529
641
|
|
|
530
642
|
type GitHubDeviceCodeResponse = {
|