keystone-cli 0.8.0 → 1.0.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 +486 -54
- package/package.json +8 -2
- package/src/__fixtures__/index.ts +100 -0
- package/src/cli.ts +809 -90
- package/src/db/memory-db.ts +35 -1
- package/src/db/workflow-db.test.ts +24 -0
- package/src/db/workflow-db.ts +469 -14
- package/src/expression/evaluator.ts +68 -4
- package/src/parser/agent-parser.ts +6 -3
- package/src/parser/config-schema.ts +38 -2
- package/src/parser/schema.ts +192 -7
- package/src/parser/test-schema.ts +29 -0
- package/src/parser/workflow-parser.test.ts +54 -0
- package/src/parser/workflow-parser.ts +153 -7
- package/src/runner/aggregate-error.test.ts +57 -0
- package/src/runner/aggregate-error.ts +46 -0
- package/src/runner/audit-verification.test.ts +2 -2
- package/src/runner/auto-heal.test.ts +1 -1
- package/src/runner/blueprint-executor.test.ts +63 -0
- package/src/runner/blueprint-executor.ts +157 -0
- package/src/runner/concurrency-limit.test.ts +82 -0
- package/src/runner/debug-repl.ts +18 -3
- package/src/runner/durable-timers.test.ts +200 -0
- package/src/runner/engine-executor.test.ts +464 -0
- package/src/runner/engine-executor.ts +491 -0
- package/src/runner/foreach-executor.ts +30 -12
- package/src/runner/llm-adapter.test.ts +282 -5
- package/src/runner/llm-adapter.ts +581 -8
- package/src/runner/llm-clarification.test.ts +79 -21
- package/src/runner/llm-errors.ts +83 -0
- package/src/runner/llm-executor.test.ts +258 -219
- package/src/runner/llm-executor.ts +226 -29
- package/src/runner/mcp-client.ts +70 -3
- package/src/runner/mcp-manager.test.ts +52 -52
- package/src/runner/mcp-manager.ts +12 -5
- package/src/runner/mcp-server.test.ts +117 -78
- package/src/runner/mcp-server.ts +13 -4
- package/src/runner/optimization-runner.ts +48 -31
- package/src/runner/reflexion.test.ts +1 -1
- package/src/runner/resource-pool.test.ts +113 -0
- package/src/runner/resource-pool.ts +164 -0
- package/src/runner/shell-executor.ts +130 -32
- package/src/runner/standard-tools-integration.test.ts +36 -36
- package/src/runner/standard-tools.test.ts +18 -0
- package/src/runner/standard-tools.ts +110 -37
- package/src/runner/step-executor.test.ts +176 -16
- package/src/runner/step-executor.ts +530 -86
- package/src/runner/stream-utils.test.ts +14 -0
- package/src/runner/subflow-outputs.test.ts +103 -0
- package/src/runner/test-harness.ts +161 -0
- package/src/runner/tool-integration.test.ts +73 -79
- package/src/runner/workflow-runner.test.ts +492 -15
- package/src/runner/workflow-runner.ts +1438 -79
- package/src/runner/workflow-subflows.test.ts +255 -0
- package/src/templates/agents/keystone-architect.md +17 -12
- package/src/templates/agents/tester.md +21 -0
- package/src/templates/child-rollback.yaml +11 -0
- package/src/templates/decompose-implement.yaml +53 -0
- package/src/templates/decompose-problem.yaml +159 -0
- package/src/templates/decompose-research.yaml +52 -0
- package/src/templates/decompose-review.yaml +51 -0
- package/src/templates/dev.yaml +134 -0
- package/src/templates/engine-example.yaml +33 -0
- package/src/templates/fan-out-fan-in.yaml +61 -0
- package/src/templates/memory-service.yaml +1 -1
- package/src/templates/parent-rollback.yaml +16 -0
- package/src/templates/robust-automation.yaml +1 -1
- package/src/templates/scaffold-feature.yaml +29 -27
- package/src/templates/scaffold-generate.yaml +41 -0
- package/src/templates/scaffold-plan.yaml +53 -0
- package/src/types/status.ts +3 -0
- package/src/ui/dashboard.tsx +4 -3
- package/src/utils/assets.macro.ts +36 -0
- package/src/utils/auth-manager.ts +585 -8
- package/src/utils/blueprint-utils.test.ts +49 -0
- package/src/utils/blueprint-utils.ts +80 -0
- package/src/utils/circuit-breaker.test.ts +177 -0
- package/src/utils/circuit-breaker.ts +160 -0
- package/src/utils/config-loader.test.ts +100 -13
- package/src/utils/config-loader.ts +44 -17
- package/src/utils/constants.ts +62 -0
- package/src/utils/error-renderer.test.ts +267 -0
- package/src/utils/error-renderer.ts +320 -0
- package/src/utils/json-parser.test.ts +4 -0
- package/src/utils/json-parser.ts +18 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.test.ts +46 -0
- package/src/utils/paths.ts +70 -0
- package/src/utils/process-sandbox.test.ts +128 -0
- package/src/utils/process-sandbox.ts +293 -0
- package/src/utils/rate-limiter.test.ts +143 -0
- package/src/utils/rate-limiter.ts +221 -0
- package/src/utils/redactor.test.ts +23 -15
- package/src/utils/redactor.ts +65 -25
- package/src/utils/resource-loader.test.ts +54 -0
- package/src/utils/resource-loader.ts +158 -0
- package/src/utils/sandbox.test.ts +69 -4
- package/src/utils/sandbox.ts +69 -6
- package/src/utils/schema-validator.ts +65 -0
- package/src/utils/workflow-registry.test.ts +57 -0
- package/src/utils/workflow-registry.ts +45 -25
- /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
- /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
5
|
+
import { ConsoleLogger, type Logger } from './logger';
|
|
4
6
|
|
|
5
7
|
export interface AuthData {
|
|
6
8
|
github_token?: string;
|
|
@@ -8,6 +10,18 @@ export interface AuthData {
|
|
|
8
10
|
copilot_expires_at?: number;
|
|
9
11
|
openai_api_key?: string;
|
|
10
12
|
anthropic_api_key?: string;
|
|
13
|
+
google_gemini?: {
|
|
14
|
+
access_token: string;
|
|
15
|
+
refresh_token: string;
|
|
16
|
+
expires_at: number;
|
|
17
|
+
email?: string;
|
|
18
|
+
project_id?: string;
|
|
19
|
+
};
|
|
20
|
+
anthropic_claude?: {
|
|
21
|
+
access_token: string;
|
|
22
|
+
refresh_token: string;
|
|
23
|
+
expires_at: number;
|
|
24
|
+
};
|
|
11
25
|
mcp_tokens?: Record<
|
|
12
26
|
string,
|
|
13
27
|
{
|
|
@@ -16,6 +30,12 @@ export interface AuthData {
|
|
|
16
30
|
refresh_token?: string;
|
|
17
31
|
}
|
|
18
32
|
>;
|
|
33
|
+
openai_chatgpt?: {
|
|
34
|
+
access_token: string;
|
|
35
|
+
refresh_token: string;
|
|
36
|
+
expires_at: number;
|
|
37
|
+
account_id?: string;
|
|
38
|
+
};
|
|
19
39
|
}
|
|
20
40
|
|
|
21
41
|
export const COPILOT_HEADERS = {
|
|
@@ -25,11 +45,33 @@ export const COPILOT_HEADERS = {
|
|
|
25
45
|
};
|
|
26
46
|
|
|
27
47
|
const GITHUB_CLIENT_ID = '013444988716b5155f4c'; // GitHub CLI Client ID
|
|
28
|
-
|
|
29
|
-
/** Buffer time in seconds before token expiry to trigger refresh (5 minutes) */
|
|
30
48
|
const TOKEN_REFRESH_BUFFER_SECONDS = 300;
|
|
49
|
+
const OPENAI_CHATGPT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
50
|
+
const OPENAI_CHATGPT_REDIRECT_URI = 'http://localhost:1455/callback';
|
|
51
|
+
const ANTHROPIC_OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
52
|
+
const ANTHROPIC_OAUTH_REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback';
|
|
53
|
+
const ANTHROPIC_OAUTH_SCOPE = 'org:create_api_key user:profile user:inference';
|
|
54
|
+
const GOOGLE_GEMINI_OAUTH_CLIENT_ID =
|
|
55
|
+
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
|
56
|
+
const GOOGLE_GEMINI_OAUTH_REDIRECT_URI = 'http://localhost:51121/oauth-callback';
|
|
57
|
+
const GOOGLE_GEMINI_OAUTH_SCOPES = [
|
|
58
|
+
'https://www.googleapis.com/auth/cloud-platform',
|
|
59
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
60
|
+
'https://www.googleapis.com/auth/userinfo.profile',
|
|
61
|
+
'https://www.googleapis.com/auth/cclog',
|
|
62
|
+
'https://www.googleapis.com/auth/experimentsandconfigs',
|
|
63
|
+
];
|
|
64
|
+
const GOOGLE_GEMINI_LOAD_ENDPOINTS = [
|
|
65
|
+
'https://cloudcode-pa.googleapis.com',
|
|
66
|
+
'https://daily-cloudcode-pa.sandbox.googleapis.com',
|
|
67
|
+
'https://autopush-cloudcode-pa.sandbox.googleapis.com',
|
|
68
|
+
];
|
|
69
|
+
const GOOGLE_GEMINI_METADATA_HEADER =
|
|
70
|
+
'{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
|
|
31
71
|
|
|
32
72
|
export class AuthManager {
|
|
73
|
+
private static logger: Logger = new ConsoleLogger();
|
|
74
|
+
|
|
33
75
|
private static getAuthPath(): string {
|
|
34
76
|
if (process.env.KEYSTONE_AUTH_PATH) {
|
|
35
77
|
return process.env.KEYSTONE_AUTH_PATH;
|
|
@@ -59,14 +101,16 @@ export class AuthManager {
|
|
|
59
101
|
try {
|
|
60
102
|
writeFileSync(path, JSON.stringify({ ...current, ...data }, null, 2), { mode: 0o600 });
|
|
61
103
|
} catch (error) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
'Failed to save auth data:',
|
|
65
|
-
error instanceof Error ? error.message : String(error)
|
|
104
|
+
AuthManager.logger.error(
|
|
105
|
+
`Failed to save auth data: ${error instanceof Error ? error.message : String(error)}`
|
|
66
106
|
);
|
|
67
107
|
}
|
|
68
108
|
}
|
|
69
109
|
|
|
110
|
+
static setLogger(logger: Logger): void {
|
|
111
|
+
AuthManager.logger = logger;
|
|
112
|
+
}
|
|
113
|
+
|
|
70
114
|
static async initGitHubDeviceLogin(): Promise<{
|
|
71
115
|
device_code: string;
|
|
72
116
|
user_code: string;
|
|
@@ -197,8 +241,541 @@ export class AuthManager {
|
|
|
197
241
|
|
|
198
242
|
return data.token;
|
|
199
243
|
} catch (error) {
|
|
200
|
-
|
|
201
|
-
|
|
244
|
+
AuthManager.logger.error(`Error refreshing Copilot token: ${String(error)}`);
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private static generateCodeVerifier(): string {
|
|
250
|
+
return randomBytes(32).toString('hex');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private static createCodeChallenge(verifier: string): string {
|
|
254
|
+
const hash = createHash('sha256').update(verifier).digest();
|
|
255
|
+
return hash.toString('base64url');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private static getGoogleGeminiClientSecret(): string {
|
|
259
|
+
const secret =
|
|
260
|
+
process.env.GOOGLE_GEMINI_OAUTH_CLIENT_SECRET || process.env.KEYSTONE_GEMINI_CLIENT_SECRET;
|
|
261
|
+
if (!secret) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
'Missing Google Gemini OAuth client secret. Set GOOGLE_GEMINI_OAUTH_CLIENT_SECRET or KEYSTONE_GEMINI_CLIENT_SECRET.'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return secret;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
static createAnthropicClaudeAuth(): { url: string; verifier: string } {
|
|
270
|
+
const verifier = AuthManager.generateCodeVerifier();
|
|
271
|
+
const challenge = AuthManager.createCodeChallenge(verifier);
|
|
272
|
+
|
|
273
|
+
const authUrl = `https://claude.ai/oauth/authorize?${new URLSearchParams({
|
|
274
|
+
code: 'true',
|
|
275
|
+
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
|
276
|
+
response_type: 'code',
|
|
277
|
+
redirect_uri: ANTHROPIC_OAUTH_REDIRECT_URI,
|
|
278
|
+
scope: ANTHROPIC_OAUTH_SCOPE,
|
|
279
|
+
code_challenge: challenge,
|
|
280
|
+
code_challenge_method: 'S256',
|
|
281
|
+
state: verifier,
|
|
282
|
+
}).toString()}`;
|
|
283
|
+
|
|
284
|
+
return { url: authUrl, verifier };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
static async exchangeAnthropicClaudeCode(
|
|
288
|
+
code: string,
|
|
289
|
+
verifier: string
|
|
290
|
+
): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
|
|
291
|
+
const [authCode, stateFromCode] = code.split('#');
|
|
292
|
+
if (stateFromCode && stateFromCode !== verifier) {
|
|
293
|
+
throw new Error('Invalid OAuth state');
|
|
294
|
+
}
|
|
295
|
+
const response = await fetch('https://console.anthropic.com/v1/oauth/token', {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: { 'Content-Type': 'application/json' },
|
|
298
|
+
body: JSON.stringify({
|
|
299
|
+
code: authCode,
|
|
300
|
+
state: stateFromCode || verifier,
|
|
301
|
+
grant_type: 'authorization_code',
|
|
302
|
+
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
|
303
|
+
redirect_uri: ANTHROPIC_OAUTH_REDIRECT_URI,
|
|
304
|
+
code_verifier: verifier,
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (!response.ok) {
|
|
309
|
+
const error = await response.text();
|
|
310
|
+
throw new Error(`Failed to exchange Claude auth code: ${response.status} - ${error}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return (await response.json()) as {
|
|
314
|
+
access_token: string;
|
|
315
|
+
refresh_token: string;
|
|
316
|
+
expires_in: number;
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private static async fetchGoogleGeminiProjectId(
|
|
321
|
+
accessToken: string
|
|
322
|
+
): Promise<string | undefined> {
|
|
323
|
+
const loadHeaders: Record<string, string> = {
|
|
324
|
+
Authorization: `Bearer ${accessToken}`,
|
|
325
|
+
'Content-Type': 'application/json',
|
|
326
|
+
'User-Agent': 'google-api-nodejs-client/9.15.1',
|
|
327
|
+
'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
|
|
328
|
+
'Client-Metadata': GOOGLE_GEMINI_METADATA_HEADER,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
for (const baseEndpoint of GOOGLE_GEMINI_LOAD_ENDPOINTS) {
|
|
332
|
+
try {
|
|
333
|
+
const response = await fetch(`${baseEndpoint}/v1internal:loadCodeAssist`, {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: loadHeaders,
|
|
336
|
+
body: JSON.stringify({
|
|
337
|
+
metadata: {
|
|
338
|
+
ideType: 'IDE_UNSPECIFIED',
|
|
339
|
+
platform: 'PLATFORM_UNSPECIFIED',
|
|
340
|
+
pluginType: 'GEMINI',
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (!response.ok) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const data = (await response.json()) as {
|
|
350
|
+
cloudaicompanionProject?: string | { id?: string };
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
if (typeof data.cloudaicompanionProject === 'string' && data.cloudaicompanionProject) {
|
|
354
|
+
return data.cloudaicompanionProject;
|
|
355
|
+
}
|
|
356
|
+
if (
|
|
357
|
+
data.cloudaicompanionProject &&
|
|
358
|
+
typeof data.cloudaicompanionProject.id === 'string' &&
|
|
359
|
+
data.cloudaicompanionProject.id
|
|
360
|
+
) {
|
|
361
|
+
return data.cloudaicompanionProject.id;
|
|
362
|
+
}
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
static async loginGoogleGemini(projectId?: string): Promise<void> {
|
|
370
|
+
const verifier = AuthManager.generateCodeVerifier();
|
|
371
|
+
const challenge = AuthManager.createCodeChallenge(verifier);
|
|
372
|
+
const state = randomBytes(16).toString('hex');
|
|
373
|
+
|
|
374
|
+
return new Promise((resolve, reject) => {
|
|
375
|
+
const serverRef: { current?: ReturnType<typeof Bun.serve> } = {};
|
|
376
|
+
const stopServer = () => {
|
|
377
|
+
serverRef.current?.stop();
|
|
378
|
+
};
|
|
379
|
+
const timeout = setTimeout(
|
|
380
|
+
() => {
|
|
381
|
+
stopServer();
|
|
382
|
+
reject(new Error('Login timed out after 5 minutes'));
|
|
383
|
+
},
|
|
384
|
+
5 * 60 * 1000
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
serverRef.current = Bun.serve({
|
|
388
|
+
port: 51121,
|
|
389
|
+
async fetch(req) {
|
|
390
|
+
const url = new URL(req.url);
|
|
391
|
+
if (url.pathname === '/oauth-callback') {
|
|
392
|
+
const error = url.searchParams.get('error');
|
|
393
|
+
if (error) {
|
|
394
|
+
clearTimeout(timeout);
|
|
395
|
+
setTimeout(stopServer, 100);
|
|
396
|
+
reject(new Error(`Authorization error: ${error}`));
|
|
397
|
+
return new Response(`Error: ${error}`, { status: 400 });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const code = url.searchParams.get('code');
|
|
401
|
+
const returnedState = url.searchParams.get('state');
|
|
402
|
+
if (!code) {
|
|
403
|
+
return new Response('Missing code parameter', { status: 400 });
|
|
404
|
+
}
|
|
405
|
+
if (returnedState && returnedState !== state) {
|
|
406
|
+
clearTimeout(timeout);
|
|
407
|
+
setTimeout(stopServer, 100);
|
|
408
|
+
reject(new Error('Invalid OAuth state'));
|
|
409
|
+
return new Response('Invalid state parameter', { status: 400 });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
414
|
+
method: 'POST',
|
|
415
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
416
|
+
body: new URLSearchParams({
|
|
417
|
+
client_id: GOOGLE_GEMINI_OAUTH_CLIENT_ID,
|
|
418
|
+
client_secret: AuthManager.getGoogleGeminiClientSecret(),
|
|
419
|
+
code,
|
|
420
|
+
grant_type: 'authorization_code',
|
|
421
|
+
redirect_uri: GOOGLE_GEMINI_OAUTH_REDIRECT_URI,
|
|
422
|
+
code_verifier: verifier,
|
|
423
|
+
}),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
if (!response.ok) {
|
|
427
|
+
const errorText = await response.text();
|
|
428
|
+
throw new Error(`Failed to exchange code: ${response.status} - ${errorText}`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const data = (await response.json()) as {
|
|
432
|
+
access_token: string;
|
|
433
|
+
refresh_token?: string;
|
|
434
|
+
expires_in: number;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
if (!data.refresh_token) {
|
|
438
|
+
throw new Error('Missing refresh token in response. Try re-authenticating.');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let email: string | undefined;
|
|
442
|
+
try {
|
|
443
|
+
const userInfoResponse = await fetch(
|
|
444
|
+
'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
|
|
445
|
+
{ headers: { Authorization: `Bearer ${data.access_token}` } }
|
|
446
|
+
);
|
|
447
|
+
if (userInfoResponse.ok) {
|
|
448
|
+
const userInfo = (await userInfoResponse.json()) as { email?: string };
|
|
449
|
+
email = userInfo.email;
|
|
450
|
+
}
|
|
451
|
+
} catch {
|
|
452
|
+
// Ignore user info lookup failures
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let resolvedProjectId =
|
|
456
|
+
projectId ||
|
|
457
|
+
process.env.GOOGLE_GEMINI_PROJECT_ID ||
|
|
458
|
+
process.env.KEYSTONE_GEMINI_PROJECT_ID;
|
|
459
|
+
if (!resolvedProjectId) {
|
|
460
|
+
resolvedProjectId = await AuthManager.fetchGoogleGeminiProjectId(data.access_token);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
AuthManager.save({
|
|
464
|
+
google_gemini: {
|
|
465
|
+
access_token: data.access_token,
|
|
466
|
+
refresh_token: data.refresh_token,
|
|
467
|
+
expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
|
|
468
|
+
email,
|
|
469
|
+
project_id: resolvedProjectId,
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
clearTimeout(timeout);
|
|
474
|
+
setTimeout(stopServer, 100);
|
|
475
|
+
resolve();
|
|
476
|
+
return new Response(
|
|
477
|
+
'<h1>Authentication Successful!</h1><p>You can close this window and return to the terminal.</p>',
|
|
478
|
+
{ headers: { 'Content-Type': 'text/html' } }
|
|
479
|
+
);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
clearTimeout(timeout);
|
|
482
|
+
setTimeout(stopServer, 100);
|
|
483
|
+
reject(err);
|
|
484
|
+
return new Response(`Error: ${err instanceof Error ? err.message : String(err)}`, {
|
|
485
|
+
status: 500,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return new Response('Not Found', { status: 404 });
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({
|
|
494
|
+
client_id: GOOGLE_GEMINI_OAUTH_CLIENT_ID,
|
|
495
|
+
response_type: 'code',
|
|
496
|
+
redirect_uri: GOOGLE_GEMINI_OAUTH_REDIRECT_URI,
|
|
497
|
+
scope: GOOGLE_GEMINI_OAUTH_SCOPES.join(' '),
|
|
498
|
+
code_challenge: challenge,
|
|
499
|
+
code_challenge_method: 'S256',
|
|
500
|
+
access_type: 'offline',
|
|
501
|
+
prompt: 'consent',
|
|
502
|
+
state,
|
|
503
|
+
}).toString()}`;
|
|
504
|
+
|
|
505
|
+
AuthManager.logger.log('\nTo login with Google Gemini (OAuth):');
|
|
506
|
+
AuthManager.logger.log('1. Visit the following URL in your browser:');
|
|
507
|
+
AuthManager.logger.log(` ${authUrl}\n`);
|
|
508
|
+
AuthManager.logger.log('Waiting for authorization...');
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
const { platform } = process;
|
|
512
|
+
const command =
|
|
513
|
+
platform === 'win32' ? 'start' : platform === 'darwin' ? 'open' : 'xdg-open';
|
|
514
|
+
const { spawn } = require('node:child_process');
|
|
515
|
+
spawn(command, [authUrl]);
|
|
516
|
+
} catch {
|
|
517
|
+
// Ignore if we can't open the browser automatically
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
static async loginOpenAIChatGPT(): Promise<void> {
|
|
523
|
+
const verifier = AuthManager.generateCodeVerifier();
|
|
524
|
+
const challenge = AuthManager.createCodeChallenge(verifier);
|
|
525
|
+
const state = randomBytes(16).toString('hex');
|
|
526
|
+
|
|
527
|
+
return new Promise((resolve, reject) => {
|
|
528
|
+
const serverRef: { current?: ReturnType<typeof Bun.serve> } = {};
|
|
529
|
+
const stopServer = () => {
|
|
530
|
+
serverRef.current?.stop();
|
|
531
|
+
};
|
|
532
|
+
const timeout = setTimeout(
|
|
533
|
+
() => {
|
|
534
|
+
stopServer();
|
|
535
|
+
reject(new Error('Login timed out after 5 minutes'));
|
|
536
|
+
},
|
|
537
|
+
5 * 60 * 1000
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
serverRef.current = Bun.serve({
|
|
541
|
+
port: 1455,
|
|
542
|
+
async fetch(req) {
|
|
543
|
+
const url = new URL(req.url);
|
|
544
|
+
if (url.pathname === '/callback') {
|
|
545
|
+
const code = url.searchParams.get('code');
|
|
546
|
+
const returnedState = url.searchParams.get('state');
|
|
547
|
+
if (!returnedState || returnedState !== state) {
|
|
548
|
+
clearTimeout(timeout);
|
|
549
|
+
setTimeout(stopServer, 100);
|
|
550
|
+
reject(new Error('Invalid OAuth state'));
|
|
551
|
+
return new Response('Invalid state parameter', { status: 400 });
|
|
552
|
+
}
|
|
553
|
+
if (code) {
|
|
554
|
+
try {
|
|
555
|
+
const response = await fetch('https://chatgpt.com/oauth/token', {
|
|
556
|
+
method: 'POST',
|
|
557
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
558
|
+
body: new URLSearchParams({
|
|
559
|
+
client_id: OPENAI_CHATGPT_CLIENT_ID,
|
|
560
|
+
grant_type: 'authorization_code',
|
|
561
|
+
code,
|
|
562
|
+
redirect_uri: OPENAI_CHATGPT_REDIRECT_URI,
|
|
563
|
+
code_verifier: verifier,
|
|
564
|
+
}),
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
if (!response.ok) {
|
|
568
|
+
const error = await response.text();
|
|
569
|
+
throw new Error(`Failed to exchange code: ${response.status} - ${error}`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const data = (await response.json()) as {
|
|
573
|
+
access_token: string;
|
|
574
|
+
refresh_token: string;
|
|
575
|
+
expires_in: number;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
AuthManager.save({
|
|
579
|
+
openai_chatgpt: {
|
|
580
|
+
access_token: data.access_token,
|
|
581
|
+
refresh_token: data.refresh_token,
|
|
582
|
+
expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
clearTimeout(timeout);
|
|
587
|
+
setTimeout(stopServer, 100);
|
|
588
|
+
resolve();
|
|
589
|
+
return new Response(
|
|
590
|
+
'<h1>Authentication Successful!</h1><p>You can close this window and return to the terminal.</p>',
|
|
591
|
+
{ headers: { 'Content-Type': 'text/html' } }
|
|
592
|
+
);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
clearTimeout(timeout);
|
|
595
|
+
setTimeout(stopServer, 100);
|
|
596
|
+
reject(err);
|
|
597
|
+
return new Response(`Error: ${err instanceof Error ? err.message : String(err)}`, {
|
|
598
|
+
status: 500,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
return new Response('Missing code parameter', { status: 400 });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return new Response('Not Found', { status: 404 });
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const authUrl = `https://chatgpt.com/oauth/authorize?${new URLSearchParams({
|
|
610
|
+
client_id: OPENAI_CHATGPT_CLIENT_ID,
|
|
611
|
+
code_challenge: challenge,
|
|
612
|
+
code_challenge_method: 'S256',
|
|
613
|
+
redirect_uri: OPENAI_CHATGPT_REDIRECT_URI,
|
|
614
|
+
response_type: 'code',
|
|
615
|
+
scope: 'openid profile email offline_access',
|
|
616
|
+
state,
|
|
617
|
+
}).toString()}`;
|
|
618
|
+
|
|
619
|
+
AuthManager.logger.log('\nTo login with OpenAI ChatGPT:');
|
|
620
|
+
AuthManager.logger.log('1. Visit the following URL in your browser:');
|
|
621
|
+
AuthManager.logger.log(` ${authUrl}\n`);
|
|
622
|
+
AuthManager.logger.log('Waiting for authorization...');
|
|
623
|
+
|
|
624
|
+
// Attempt to open the browser
|
|
625
|
+
try {
|
|
626
|
+
const { platform } = process;
|
|
627
|
+
const command =
|
|
628
|
+
platform === 'win32' ? 'start' : platform === 'darwin' ? 'open' : 'xdg-open';
|
|
629
|
+
const { spawn } = require('node:child_process');
|
|
630
|
+
spawn(command, [authUrl]);
|
|
631
|
+
} catch (e) {
|
|
632
|
+
// Ignore if we can't open the browser automatically
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
static async getOpenAIChatGPTToken(): Promise<string | undefined> {
|
|
638
|
+
const auth = AuthManager.load();
|
|
639
|
+
if (!auth.openai_chatgpt) return undefined;
|
|
640
|
+
|
|
641
|
+
const { access_token, refresh_token, expires_at } = auth.openai_chatgpt;
|
|
642
|
+
|
|
643
|
+
// Check if valid
|
|
644
|
+
if (expires_at > Date.now() / 1000 + TOKEN_REFRESH_BUFFER_SECONDS) {
|
|
645
|
+
return access_token;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Refresh
|
|
649
|
+
try {
|
|
650
|
+
const response = await fetch('https://chatgpt.com/oauth/token', {
|
|
651
|
+
method: 'POST',
|
|
652
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
653
|
+
body: new URLSearchParams({
|
|
654
|
+
client_id: OPENAI_CHATGPT_CLIENT_ID,
|
|
655
|
+
grant_type: 'refresh_token',
|
|
656
|
+
refresh_token,
|
|
657
|
+
}),
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
if (!response.ok) {
|
|
661
|
+
throw new Error(`Failed to refresh token: ${response.statusText}`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const data = (await response.json()) as {
|
|
665
|
+
access_token: string;
|
|
666
|
+
refresh_token: string;
|
|
667
|
+
expires_in: number;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
AuthManager.save({
|
|
671
|
+
openai_chatgpt: {
|
|
672
|
+
access_token: data.access_token,
|
|
673
|
+
refresh_token: data.refresh_token,
|
|
674
|
+
expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
return data.access_token;
|
|
679
|
+
} catch (error) {
|
|
680
|
+
AuthManager.logger.error(`Error refreshing OpenAI ChatGPT token: ${String(error)}`);
|
|
681
|
+
return undefined;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
static async getGoogleGeminiToken(): Promise<string | undefined> {
|
|
686
|
+
const auth = AuthManager.load();
|
|
687
|
+
if (!auth.google_gemini) return undefined;
|
|
688
|
+
|
|
689
|
+
const { access_token, refresh_token, expires_at } = auth.google_gemini;
|
|
690
|
+
|
|
691
|
+
if (expires_at > Date.now() / 1000 + TOKEN_REFRESH_BUFFER_SECONDS) {
|
|
692
|
+
return access_token;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (!refresh_token) {
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
701
|
+
method: 'POST',
|
|
702
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
703
|
+
body: new URLSearchParams({
|
|
704
|
+
client_id: GOOGLE_GEMINI_OAUTH_CLIENT_ID,
|
|
705
|
+
client_secret: AuthManager.getGoogleGeminiClientSecret(),
|
|
706
|
+
grant_type: 'refresh_token',
|
|
707
|
+
refresh_token,
|
|
708
|
+
}),
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
if (!response.ok) {
|
|
712
|
+
throw new Error(`Failed to refresh token: ${response.statusText}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const data = (await response.json()) as {
|
|
716
|
+
access_token: string;
|
|
717
|
+
refresh_token?: string;
|
|
718
|
+
expires_in: number;
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
AuthManager.save({
|
|
722
|
+
google_gemini: {
|
|
723
|
+
...auth.google_gemini,
|
|
724
|
+
access_token: data.access_token,
|
|
725
|
+
refresh_token: data.refresh_token || refresh_token,
|
|
726
|
+
expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
return data.access_token;
|
|
731
|
+
} catch (error) {
|
|
732
|
+
AuthManager.logger.error(`Error refreshing Google Gemini token: ${String(error)}`);
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
static async getAnthropicClaudeToken(): Promise<string | undefined> {
|
|
738
|
+
const auth = AuthManager.load();
|
|
739
|
+
if (!auth.anthropic_claude) return undefined;
|
|
740
|
+
|
|
741
|
+
const { access_token, refresh_token, expires_at } = auth.anthropic_claude;
|
|
742
|
+
|
|
743
|
+
if (expires_at > Date.now() / 1000 + TOKEN_REFRESH_BUFFER_SECONDS) {
|
|
744
|
+
return access_token;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
const response = await fetch('https://console.anthropic.com/v1/oauth/token', {
|
|
749
|
+
method: 'POST',
|
|
750
|
+
headers: { 'Content-Type': 'application/json' },
|
|
751
|
+
body: JSON.stringify({
|
|
752
|
+
grant_type: 'refresh_token',
|
|
753
|
+
refresh_token,
|
|
754
|
+
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
|
755
|
+
}),
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
if (!response.ok) {
|
|
759
|
+
throw new Error(`Failed to refresh token: ${response.statusText}`);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const data = (await response.json()) as {
|
|
763
|
+
access_token: string;
|
|
764
|
+
refresh_token: string;
|
|
765
|
+
expires_in: number;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
AuthManager.save({
|
|
769
|
+
anthropic_claude: {
|
|
770
|
+
access_token: data.access_token,
|
|
771
|
+
refresh_token: data.refresh_token,
|
|
772
|
+
expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
return data.access_token;
|
|
777
|
+
} catch (error) {
|
|
778
|
+
AuthManager.logger.error(`Error refreshing Anthropic Claude token: ${String(error)}`);
|
|
202
779
|
return undefined;
|
|
203
780
|
}
|
|
204
781
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { Blueprint } from '../parser/schema';
|
|
3
|
+
import { BlueprintUtils } from './blueprint-utils';
|
|
4
|
+
|
|
5
|
+
describe('BlueprintUtils', () => {
|
|
6
|
+
const mockBlueprint: Blueprint = {
|
|
7
|
+
architecture: {
|
|
8
|
+
description: 'Test Architecture',
|
|
9
|
+
patterns: ['MVC'],
|
|
10
|
+
},
|
|
11
|
+
files: [
|
|
12
|
+
{ path: 'src/index.ts', purpose: 'Main entry point' },
|
|
13
|
+
{ path: 'src/app.ts', purpose: 'App logic' },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
it('should calculate a stable hash', () => {
|
|
18
|
+
const hash1 = BlueprintUtils.calculateHash(mockBlueprint);
|
|
19
|
+
const hash2 = BlueprintUtils.calculateHash({
|
|
20
|
+
...mockBlueprint,
|
|
21
|
+
files: [...mockBlueprint.files].reverse(), // Different order
|
|
22
|
+
});
|
|
23
|
+
expect(hash1).toBe(hash2);
|
|
24
|
+
expect(hash1).toHaveLength(64);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should detect missing files', () => {
|
|
28
|
+
const generated = [{ path: 'src/index.ts' }];
|
|
29
|
+
const diffs = BlueprintUtils.detectDrift(mockBlueprint, generated);
|
|
30
|
+
expect(diffs).toContain('Missing file: src/app.ts');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should detect extra files', () => {
|
|
34
|
+
const generated = [{ path: 'src/index.ts' }, { path: 'src/app.ts' }, { path: 'src/extra.ts' }];
|
|
35
|
+
const diffs = BlueprintUtils.detectDrift(mockBlueprint, generated);
|
|
36
|
+
expect(diffs).toContain('Extra file not in blueprint: src/extra.ts');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should detect purpose drift', () => {
|
|
40
|
+
const generated = [
|
|
41
|
+
{ path: 'src/index.ts', purpose: 'Different purpose' },
|
|
42
|
+
{ path: 'src/app.ts', purpose: 'App logic' },
|
|
43
|
+
];
|
|
44
|
+
const diffs = BlueprintUtils.detectDrift(mockBlueprint, generated);
|
|
45
|
+
expect(diffs).toContain(
|
|
46
|
+
'Purpose drift in src/index.ts: expected "Main entry point", got "Different purpose"'
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|