@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/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
|
-
|
|
39
|
+
oml lint
|
|
37
40
|
|
|
38
|
-
# Start the
|
|
39
|
-
|
|
41
|
+
# Start the OML server
|
|
42
|
+
oml start
|
|
40
43
|
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
node ./packages/cli/bin/cli.js lint
|
|
44
|
+
# Stop the OML server
|
|
45
|
+
oml stop
|
|
44
46
|
|
|
45
|
-
# Export
|
|
46
|
-
|
|
47
|
+
# Export asserted OWL files
|
|
48
|
+
oml export -o build/owl
|
|
47
49
|
|
|
48
50
|
# Run consistency reasoning (check-only)
|
|
49
|
-
|
|
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
|
-
|
|
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>] [--
|
|
66
|
-
Exports
|
|
67
|
-
- `reason [-
|
|
68
|
-
|
|
69
|
-
- `render -m|--md <
|
|
70
|
-
Runs `lint`, then renders markdown files to static HTML.
|
|
71
|
-
- `validate
|
|
72
|
-
Validates table-editor SHACL blocks in workspace markdown files.
|
|
73
|
-
- `
|
|
74
|
-
Starts the
|
|
75
|
-
- `
|
|
76
|
-
Stops the
|
|
77
|
-
- `
|
|
78
|
-
Prints the
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
package/out/auth/auth.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(`
|
|
96
|
-
console.error(`User
|
|
97
|
-
console.error(`
|
|
98
|
-
console.error(`
|
|
99
|
-
console.error(`
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
260
|
+
async function getOrCreateDeviceId() {
|
|
218
261
|
try {
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
238
|
-
await
|
|
239
|
-
|
|
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
|
|
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(
|
|
530
|
+
throw new Error(`Secure credential storage is unavailable: ${message}`);
|
|
432
531
|
});
|
|
433
532
|
}
|
|
434
533
|
return keytarModulePromise;
|
|
435
534
|
}
|
|
436
|
-
function
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|