@oml/cli 0.14.2 → 0.14.4
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/out/auth/auth.d.ts +3 -3
- package/out/auth/auth.js +254 -97
- package/out/auth/auth.js.map +1 -1
- package/out/auth/platform.d.ts +1 -1
- package/out/auth/platform.js +4 -26
- package/out/auth/platform.js.map +1 -1
- package/out/cli.js +59 -81
- package/out/cli.js.map +1 -1
- package/out/commands/server/actions.d.ts +3 -3
- package/out/commands/server/actions.js +88 -25
- package/out/commands/server/actions.js.map +1 -1
- package/out/commands/server/require.js +1 -1
- package/out/commands/server/require.js.map +1 -1
- package/out/commands/server/rest.js +103 -28
- package/out/commands/server/rest.js.map +1 -1
- package/package.json +4 -3
- package/src/auth/auth.ts +292 -115
- package/src/auth/platform.ts +4 -30
- package/src/cli.ts +62 -87
- package/src/commands/server/actions.ts +108 -29
- package/src/commands/server/require.ts +1 -1
- package/src/commands/server/rest.ts +119 -29
package/src/auth/auth.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
DEFAULT_SUPABASE_ANON_KEY,
|
|
11
11
|
DEFAULT_SUPABASE_URL
|
|
12
12
|
} from './constants.js';
|
|
13
|
+
|
|
13
14
|
const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
|
14
15
|
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
|
15
16
|
const GITHUB_USER_URL = 'https://api.github.com/user';
|
|
@@ -17,20 +18,40 @@ const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liQkHYczdOAvHp5P';
|
|
|
17
18
|
const API_BASE_URL_ENV = 'OML_PLATFORM_API_URL';
|
|
18
19
|
const SUPABASE_URL_ENV = 'OML_SUPABASE_URL';
|
|
19
20
|
const SUPABASE_ANON_KEY_ENV = 'OML_SUPABASE_ANON_KEY';
|
|
21
|
+
const API_KEY_ENV = 'OML_PLATFORM_API_KEY';
|
|
22
|
+
const KEYCHAIN_SERVICE = 'oml-code';
|
|
23
|
+
const ACCESS_TOKEN_KEY = 'oml.cli.access_token';
|
|
24
|
+
const REFRESH_TOKEN_KEY = 'oml.cli.refresh_token';
|
|
25
|
+
const EXPIRES_AT_KEY = 'oml.cli.expires_at';
|
|
26
|
+
const PROFILE_PATH = path.join(os.homedir(), '.oml', 'cli-profile.json');
|
|
27
|
+
const LOCK_PATH = path.join(os.homedir(), '.oml', 'credentials.lock');
|
|
28
|
+
const SESSION_EXPIRATION_LEEWAY_MS = 20_000;
|
|
29
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
30
|
+
const LOCK_POLL_INTERVAL_MS = 200;
|
|
31
|
+
|
|
32
|
+
type KeytarModule = {
|
|
33
|
+
getPassword(service: string, account: string): Promise<string | null>;
|
|
34
|
+
setPassword(service: string, account: string, password: string): Promise<void>;
|
|
35
|
+
deletePassword(service: string, account: string): Promise<boolean>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let keytarModulePromise: Promise<KeytarModule> | undefined;
|
|
20
39
|
|
|
21
40
|
type Provider = 'github';
|
|
22
41
|
|
|
23
|
-
type
|
|
42
|
+
type StoredProfile = {
|
|
24
43
|
provider: Provider;
|
|
25
44
|
userId: string;
|
|
26
45
|
userLabel?: string;
|
|
27
46
|
email: string | null;
|
|
28
47
|
tier?: string;
|
|
48
|
+
signedInAt: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type StoredCredential = {
|
|
29
52
|
accessToken: string;
|
|
30
53
|
refreshToken: string;
|
|
31
|
-
|
|
32
|
-
expiresIn: number;
|
|
33
|
-
signedInAt: string;
|
|
54
|
+
expiresAtMs: number;
|
|
34
55
|
};
|
|
35
56
|
|
|
36
57
|
type LoginOptions = {
|
|
@@ -43,134 +64,222 @@ export type OmlCliServerAuthSnapshot = {
|
|
|
43
64
|
};
|
|
44
65
|
|
|
45
66
|
export class OmlCliAuthService {
|
|
67
|
+
async login(_options: LoginOptions): Promise<void> {
|
|
68
|
+
const apiKey = process.env[API_KEY_ENV]?.trim();
|
|
69
|
+
if (apiKey) {
|
|
70
|
+
console.error(chalk.yellow(
|
|
71
|
+
'OML_PLATFORM_API_KEY is set and will take precedence over stored OAuth credentials. ' +
|
|
72
|
+
'Unset it to use interactive CLI login.'
|
|
73
|
+
));
|
|
74
|
+
console.error('Run `oml whoami` to inspect the currently effective authentication mode.');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
46
77
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
const existing = await this.tryGetValidSnapshot();
|
|
79
|
+
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
|
+
));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const session = await authenticateWithGitHub();
|
|
88
|
+
await writeCredential({
|
|
89
|
+
accessToken: session.accessToken,
|
|
90
|
+
refreshToken: session.refreshToken,
|
|
91
|
+
expiresAtMs: session.expiresAtMs,
|
|
92
|
+
});
|
|
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
|
+
});
|
|
50
101
|
const summary = session.userLabel ?? session.email ?? 'signed-in user';
|
|
51
102
|
console.error(chalk.green(`Signed in as ${summary} via ${session.provider}.`));
|
|
52
103
|
}
|
|
53
104
|
|
|
54
105
|
async logout(): Promise<void> {
|
|
55
|
-
await
|
|
56
|
-
|
|
106
|
+
const activeServers = await listActiveServers();
|
|
107
|
+
await deleteCredential();
|
|
108
|
+
await deleteProfile();
|
|
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.'));
|
|
120
|
+
if (process.env[API_KEY_ENV]?.trim()) {
|
|
121
|
+
console.error(chalk.yellow('OML_PLATFORM_API_KEY is still set, so future starts may use API-key auth.'));
|
|
122
|
+
}
|
|
57
123
|
}
|
|
58
124
|
|
|
59
125
|
async whoami(): Promise<void> {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
126
|
+
const apiKey = process.env[API_KEY_ENV]?.trim();
|
|
127
|
+
if (apiKey) {
|
|
128
|
+
console.error('Auth mode: api_key');
|
|
129
|
+
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
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const credential = await readCredential();
|
|
138
|
+
const profile = await readProfile();
|
|
139
|
+
if (!credential || !profile) {
|
|
62
140
|
console.error(chalk.yellow('Not signed in.'));
|
|
63
141
|
return;
|
|
64
142
|
}
|
|
65
|
-
console.error(
|
|
66
|
-
console.error(`
|
|
67
|
-
console.error(`User
|
|
68
|
-
console.error(`
|
|
69
|
-
console.error(`
|
|
70
|
-
console.error(`
|
|
143
|
+
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()}`);
|
|
71
151
|
}
|
|
72
152
|
|
|
73
153
|
async ensureAuthenticated(operationName: string): Promise<void> {
|
|
74
|
-
|
|
154
|
+
if (process.env[API_KEY_ENV]?.trim()) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const session = await readCredential();
|
|
75
158
|
if (!session) {
|
|
76
159
|
throw new Error(`${operationName} requires authentication. Run 'oml login' first.`);
|
|
77
160
|
}
|
|
78
161
|
}
|
|
79
162
|
|
|
80
163
|
async getAccessToken(): Promise<string> {
|
|
81
|
-
const session = await
|
|
82
|
-
if (!session?.accessToken) {
|
|
83
|
-
throw new Error('OML CLI authentication is required. Run \'oml login\' first.');
|
|
84
|
-
}
|
|
164
|
+
const session = await this.getServerAuthSnapshot();
|
|
85
165
|
return session.accessToken;
|
|
86
166
|
}
|
|
87
167
|
|
|
88
168
|
async refreshAccessToken(): Promise<string> {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
throw new Error('OML CLI authentication is required. Run \'oml login\' first.');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
let refreshed;
|
|
95
|
-
try {
|
|
96
|
-
refreshed = await refreshSupabaseAccessToken(
|
|
97
|
-
resolveSupabaseUrl(),
|
|
98
|
-
resolveSupabaseAnonKey(),
|
|
99
|
-
session.refreshToken
|
|
100
|
-
);
|
|
101
|
-
} catch {
|
|
102
|
-
throw new Error('Authentication refresh failed. Check your network connection or sign in again with \'oml login\'.');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const updatedSession: StoredSession = {
|
|
106
|
-
...session,
|
|
107
|
-
accessToken: refreshed.access_token,
|
|
108
|
-
refreshToken: refreshed.refresh_token,
|
|
109
|
-
tokenType: refreshed.token_type,
|
|
110
|
-
expiresIn: refreshed.expires_in,
|
|
111
|
-
email: refreshed.email ?? session.email,
|
|
112
|
-
signedInAt: new Date().toISOString(),
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
await writeSession(updatedSession);
|
|
116
|
-
return updatedSession.accessToken;
|
|
169
|
+
const refreshed = await this.refreshCredential();
|
|
170
|
+
return refreshed.accessToken;
|
|
117
171
|
}
|
|
118
172
|
|
|
119
173
|
async getServerAuthSnapshot(): Promise<OmlCliServerAuthSnapshot> {
|
|
120
|
-
const session = await
|
|
174
|
+
const session = await readCredential();
|
|
121
175
|
if (!session?.accessToken) {
|
|
122
176
|
throw new Error('OML CLI authentication is required. Run \'oml login\' first.');
|
|
123
177
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
178
|
+
if (Date.now() + SESSION_EXPIRATION_LEEWAY_MS >= session.expiresAtMs) {
|
|
179
|
+
const refreshed = await this.refreshCredential();
|
|
180
|
+
return {
|
|
181
|
+
accessToken: refreshed.accessToken,
|
|
182
|
+
refreshToken: refreshed.refreshToken,
|
|
183
|
+
expiresAtMs: refreshed.expiresAtMs,
|
|
184
|
+
};
|
|
131
185
|
}
|
|
132
186
|
return {
|
|
133
187
|
accessToken: session.accessToken,
|
|
134
188
|
refreshToken: session.refreshToken,
|
|
135
|
-
expiresAtMs,
|
|
189
|
+
expiresAtMs: session.expiresAtMs,
|
|
136
190
|
};
|
|
137
191
|
}
|
|
138
192
|
|
|
139
|
-
async
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
193
|
+
private async tryGetValidSnapshot(): Promise<OmlCliServerAuthSnapshot | null> {
|
|
194
|
+
try {
|
|
195
|
+
return await this.getServerAuthSnapshot();
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
143
198
|
}
|
|
144
|
-
const expiresIn = Math.max(0, Math.round((expiresAtMs - Date.now()) / 1000));
|
|
145
|
-
const updatedSession: StoredSession = {
|
|
146
|
-
...session,
|
|
147
|
-
accessToken,
|
|
148
|
-
refreshToken,
|
|
149
|
-
expiresIn,
|
|
150
|
-
signedInAt: new Date().toISOString(),
|
|
151
|
-
};
|
|
152
|
-
await writeSession(updatedSession);
|
|
153
199
|
}
|
|
154
200
|
|
|
155
|
-
private async
|
|
156
|
-
|
|
201
|
+
private async refreshCredential(): Promise<StoredCredential> {
|
|
202
|
+
const session = await readCredential();
|
|
203
|
+
if (!session?.refreshToken) {
|
|
204
|
+
throw new Error('OML CLI authentication is required. Run \'oml login\' first.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const lock = await acquireCredentialLock();
|
|
208
|
+
try {
|
|
209
|
+
const latest = await readCredential();
|
|
210
|
+
if (latest && Date.now() + SESSION_EXPIRATION_LEEWAY_MS < latest.expiresAtMs) {
|
|
211
|
+
return latest;
|
|
212
|
+
}
|
|
213
|
+
const source = latest ?? session;
|
|
214
|
+
const refreshed = await refreshSupabaseAccessToken(
|
|
215
|
+
resolveSupabaseUrl(),
|
|
216
|
+
resolveSupabaseAnonKey(),
|
|
217
|
+
source.refreshToken
|
|
218
|
+
);
|
|
219
|
+
const updatedSession: StoredCredential = {
|
|
220
|
+
accessToken: refreshed.access_token,
|
|
221
|
+
refreshToken: refreshed.refresh_token,
|
|
222
|
+
expiresAtMs: Date.now() + (refreshed.expires_in * 1000),
|
|
223
|
+
};
|
|
224
|
+
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
|
+
return updatedSession;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (isUnauthorizedError(error)) {
|
|
235
|
+
await deleteCredential();
|
|
236
|
+
await deleteProfile();
|
|
237
|
+
throw new Error('Authentication refresh failed because the stored credential was revoked. Run \'oml login\' again.');
|
|
238
|
+
}
|
|
239
|
+
throw new Error('Authentication refresh failed. Check your network connection or sign in again with \'oml login\'.');
|
|
240
|
+
} finally {
|
|
241
|
+
await lock.release();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function readCredential(): Promise<StoredCredential | undefined> {
|
|
247
|
+
const keytar = await getKeytarModule();
|
|
248
|
+
const [accessToken, refreshToken, expiresAtRaw] = await Promise.all([
|
|
249
|
+
keytar.getPassword(KEYCHAIN_SERVICE, ACCESS_TOKEN_KEY),
|
|
250
|
+
keytar.getPassword(KEYCHAIN_SERVICE, REFRESH_TOKEN_KEY),
|
|
251
|
+
keytar.getPassword(KEYCHAIN_SERVICE, EXPIRES_AT_KEY),
|
|
252
|
+
]);
|
|
253
|
+
const expiresAtMs = Number(expiresAtRaw ?? NaN);
|
|
254
|
+
if (!accessToken || !refreshToken || !Number.isFinite(expiresAtMs)) {
|
|
255
|
+
return undefined;
|
|
157
256
|
}
|
|
257
|
+
return { accessToken, refreshToken, expiresAtMs };
|
|
158
258
|
}
|
|
159
259
|
|
|
160
|
-
async function
|
|
260
|
+
async function writeCredential(credential: StoredCredential): Promise<void> {
|
|
261
|
+
const keytar = await getKeytarModule();
|
|
262
|
+
await Promise.all([
|
|
263
|
+
keytar.setPassword(KEYCHAIN_SERVICE, ACCESS_TOKEN_KEY, credential.accessToken),
|
|
264
|
+
keytar.setPassword(KEYCHAIN_SERVICE, REFRESH_TOKEN_KEY, credential.refreshToken),
|
|
265
|
+
keytar.setPassword(KEYCHAIN_SERVICE, EXPIRES_AT_KEY, String(credential.expiresAtMs)),
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function deleteCredential(): Promise<void> {
|
|
270
|
+
const keytar = await getKeytarModule();
|
|
271
|
+
await Promise.all([
|
|
272
|
+
keytar.deletePassword(KEYCHAIN_SERVICE, ACCESS_TOKEN_KEY),
|
|
273
|
+
keytar.deletePassword(KEYCHAIN_SERVICE, REFRESH_TOKEN_KEY),
|
|
274
|
+
keytar.deletePassword(KEYCHAIN_SERVICE, EXPIRES_AT_KEY),
|
|
275
|
+
]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function readProfile(): Promise<StoredProfile | undefined> {
|
|
161
279
|
try {
|
|
162
|
-
const content = await fs.readFile(
|
|
163
|
-
const data = JSON.parse(content) as Partial<
|
|
164
|
-
if (!data.
|
|
165
|
-
return undefined;
|
|
166
|
-
}
|
|
167
|
-
if (!data.accessToken) {
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
if (!data.refreshToken || !data.tokenType || typeof data.expiresIn !== 'number') {
|
|
171
|
-
return undefined;
|
|
172
|
-
}
|
|
173
|
-
if (data.provider !== 'github') {
|
|
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) {
|
|
174
283
|
return undefined;
|
|
175
284
|
}
|
|
176
285
|
return {
|
|
@@ -179,34 +288,21 @@ async function readSession(): Promise<StoredSession | undefined> {
|
|
|
179
288
|
userLabel: data.userLabel,
|
|
180
289
|
email: data.email ?? null,
|
|
181
290
|
tier: data.tier,
|
|
182
|
-
|
|
183
|
-
refreshToken: data.refreshToken,
|
|
184
|
-
tokenType: data.tokenType,
|
|
185
|
-
expiresIn: data.expiresIn,
|
|
186
|
-
signedInAt: data.signedInAt
|
|
291
|
+
signedInAt: data.signedInAt,
|
|
187
292
|
};
|
|
188
293
|
} catch {
|
|
189
294
|
return undefined;
|
|
190
295
|
}
|
|
191
296
|
}
|
|
192
297
|
|
|
193
|
-
async function
|
|
194
|
-
|
|
195
|
-
await fs.
|
|
196
|
-
await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`, 'utf-8');
|
|
197
|
-
if (warnAboutPlaintext) {
|
|
198
|
-
process.stderr.write(
|
|
199
|
-
chalk.yellow(
|
|
200
|
-
`[oml] Warning: credentials stored in plaintext at ${sessionPath}. ` +
|
|
201
|
-
`A system keychain is not available in this environment.\n`
|
|
202
|
-
)
|
|
203
|
-
);
|
|
204
|
-
}
|
|
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');
|
|
205
301
|
}
|
|
206
302
|
|
|
207
|
-
async function
|
|
303
|
+
async function deleteProfile(): Promise<void> {
|
|
208
304
|
try {
|
|
209
|
-
await fs.unlink(
|
|
305
|
+
await fs.unlink(PROFILE_PATH);
|
|
210
306
|
} catch (error) {
|
|
211
307
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
212
308
|
throw error;
|
|
@@ -214,11 +310,16 @@ async function deleteSession(): Promise<void> {
|
|
|
214
310
|
}
|
|
215
311
|
}
|
|
216
312
|
|
|
217
|
-
function
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
313
|
+
async function authenticateWithGitHub(): Promise<{
|
|
314
|
+
provider: Provider;
|
|
315
|
+
userId: string;
|
|
316
|
+
userLabel?: string;
|
|
317
|
+
email: string | null;
|
|
318
|
+
tier?: string;
|
|
319
|
+
accessToken: string;
|
|
320
|
+
refreshToken: string;
|
|
321
|
+
expiresAtMs: number;
|
|
322
|
+
}> {
|
|
222
323
|
const clientId = resolveClientId();
|
|
223
324
|
const params = new URLSearchParams({
|
|
224
325
|
client_id: clientId,
|
|
@@ -266,9 +367,7 @@ async function authenticateWithGitHub(): Promise<StoredSession> {
|
|
|
266
367
|
tier: platformSession.tier,
|
|
267
368
|
accessToken: platformSession.access_token,
|
|
268
369
|
refreshToken: platformSession.refresh_token,
|
|
269
|
-
|
|
270
|
-
expiresIn: platformSession.expires_in,
|
|
271
|
-
signedInAt: new Date().toISOString()
|
|
370
|
+
expiresAtMs: Date.now() + platformSession.expires_in * 1000,
|
|
272
371
|
};
|
|
273
372
|
}
|
|
274
373
|
|
|
@@ -323,6 +422,59 @@ async function pollForGitHubAccessToken(clientId: string, device: GitHubDeviceCo
|
|
|
323
422
|
}
|
|
324
423
|
}
|
|
325
424
|
|
|
425
|
+
async function acquireCredentialLock(): Promise<{ release: () => Promise<void> }> {
|
|
426
|
+
await fs.mkdir(path.dirname(LOCK_PATH), { recursive: true });
|
|
427
|
+
const startedAt = Date.now();
|
|
428
|
+
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
429
|
+
try {
|
|
430
|
+
const handle = await fs.open(LOCK_PATH, 'wx');
|
|
431
|
+
return {
|
|
432
|
+
release: async () => {
|
|
433
|
+
await handle.close();
|
|
434
|
+
await fs.rm(LOCK_PATH, { force: true });
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
await delay(LOCK_POLL_INTERVAL_MS);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
throw new Error('Timed out waiting for the CLI credential lock.');
|
|
445
|
+
}
|
|
446
|
+
|
|
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
|
+
|
|
326
478
|
function resolveClientId(): string {
|
|
327
479
|
const configured = process.env.OML_AUTH_GITHUB_CLIENT_ID?.trim() || DEFAULT_GITHUB_CLIENT_ID;
|
|
328
480
|
if (configured) {
|
|
@@ -349,6 +501,32 @@ function delay(ms: number): Promise<void> {
|
|
|
349
501
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
350
502
|
}
|
|
351
503
|
|
|
504
|
+
function isUnauthorizedError(error: unknown): boolean {
|
|
505
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
506
|
+
return /\b401\b/.test(message);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function getKeytarModule(): Promise<KeytarModule> {
|
|
510
|
+
if (!keytarModulePromise) {
|
|
511
|
+
keytarModulePromise = import('keytar')
|
|
512
|
+
.then((loaded) => loaded.default as KeytarModule)
|
|
513
|
+
.catch((error: unknown) => {
|
|
514
|
+
keytarModulePromise = undefined;
|
|
515
|
+
throw new Error(formatKeytarLoadError(error));
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return keytarModulePromise;
|
|
519
|
+
}
|
|
520
|
+
|
|
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.';
|
|
526
|
+
}
|
|
527
|
+
return `Secure credential storage is unavailable: ${message}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
352
530
|
type GitHubDeviceCodeResponse = {
|
|
353
531
|
device_code?: string;
|
|
354
532
|
user_code?: string;
|
|
@@ -363,6 +541,5 @@ type GitHubTokenResponse = {
|
|
|
363
541
|
};
|
|
364
542
|
|
|
365
543
|
type GitHubUserResponse = {
|
|
366
|
-
id?: number;
|
|
367
544
|
login?: string;
|
|
368
545
|
};
|
package/src/auth/platform.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* commands to execute.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { OmlClient, FileStorageAdapter, installNodeShutdownHandlers
|
|
11
|
+
import { OmlClient, FileStorageAdapter, installNodeShutdownHandlers } from '@oml/platform';
|
|
12
12
|
import type { OmlClientConfig } from '@oml/platform';
|
|
13
13
|
import type { NodeShutdownHandle } from '@oml/platform';
|
|
14
14
|
import chalk from 'chalk';
|
|
@@ -16,7 +16,6 @@ import { DEFAULT_API_BASE_URL } from './constants.js';
|
|
|
16
16
|
import { OmlCliAuthService } from './auth.js';
|
|
17
17
|
const API_BASE_URL_ENV = 'OML_PLATFORM_API_URL';
|
|
18
18
|
const API_KEY_ENV = 'OML_PLATFORM_API_KEY';
|
|
19
|
-
const OIDC_TOKEN_ENV = 'OML_CI_TOKEN';
|
|
20
19
|
|
|
21
20
|
let client: OmlClient | null = null;
|
|
22
21
|
let shutdownHandle: NodeShutdownHandle | null = null;
|
|
@@ -37,24 +36,6 @@ export async function initializePlatform(
|
|
|
37
36
|
const resolvedApiBaseUrl = process.env[API_BASE_URL_ENV]?.trim() || apiBaseUrl;
|
|
38
37
|
|
|
39
38
|
const apiKey = process.env[API_KEY_ENV]?.trim();
|
|
40
|
-
let apiKeyAccessToken: string | undefined;
|
|
41
|
-
let apiKeyAccessTokenExpiresAtMs = 0;
|
|
42
|
-
|
|
43
|
-
const getApiKeyAccessToken = async (): Promise<string> => {
|
|
44
|
-
const refreshLeewayMs = 20_000;
|
|
45
|
-
if (apiKeyAccessToken && Date.now() + refreshLeewayMs < apiKeyAccessTokenExpiresAtMs) {
|
|
46
|
-
return apiKeyAccessToken;
|
|
47
|
-
}
|
|
48
|
-
if (!apiKey) {
|
|
49
|
-
throw new Error('OML API key is missing.');
|
|
50
|
-
}
|
|
51
|
-
const oidcToken = process.env[OIDC_TOKEN_ENV]?.trim() || undefined;
|
|
52
|
-
const exchanged = await exchangeApiToken(resolvedApiBaseUrl, apiKey, oidcToken);
|
|
53
|
-
apiKeyAccessToken = exchanged.accessToken;
|
|
54
|
-
apiKeyAccessTokenExpiresAtMs = Date.now() + (Math.max(1, exchanged.expiresIn) * 1000);
|
|
55
|
-
return apiKeyAccessToken;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
39
|
const config: OmlClientConfig = {
|
|
59
40
|
apiBaseUrl: resolvedApiBaseUrl,
|
|
60
41
|
tool: 'oml-cli',
|
|
@@ -62,21 +43,14 @@ export async function initializePlatform(
|
|
|
62
43
|
auth: apiKey
|
|
63
44
|
? {
|
|
64
45
|
method: 'oauth',
|
|
65
|
-
getToken: () =>
|
|
66
|
-
refreshToken: () =>
|
|
46
|
+
getToken: async () => apiKey,
|
|
47
|
+
refreshToken: async () => apiKey,
|
|
67
48
|
}
|
|
68
49
|
: {
|
|
69
50
|
method: 'oauth',
|
|
70
51
|
getToken: () => authService.getAccessToken(),
|
|
71
52
|
refreshToken: () => authService.refreshAccessToken(),
|
|
72
53
|
},
|
|
73
|
-
onConcurrencyLimit: (info) => {
|
|
74
|
-
console.error(chalk.yellow(
|
|
75
|
-
`OML Platform: concurrent session limit reached `
|
|
76
|
-
+ `(${info.active_sessions}/${info.max_sessions}). `
|
|
77
|
-
+ `Close another instance or upgrade your plan.`
|
|
78
|
-
));
|
|
79
|
-
},
|
|
80
54
|
onAuthError: (error) => {
|
|
81
55
|
console.error(chalk.red(
|
|
82
56
|
`OML Platform: authentication error — ${toGenericPlatformErrorMessage(error)}`
|
|
@@ -97,7 +71,7 @@ export async function initializePlatform(
|
|
|
97
71
|
|
|
98
72
|
/**
|
|
99
73
|
* Dispose the platform client. Flushes any buffered telemetry
|
|
100
|
-
* and
|
|
74
|
+
* and flushes buffered telemetry. Call at the end of CLI execution.
|
|
101
75
|
*/
|
|
102
76
|
export async function disposePlatform(): Promise<void> {
|
|
103
77
|
if (shutdownHandle) {
|