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.
Files changed (103) hide show
  1. package/README.md +486 -54
  2. package/package.json +8 -2
  3. package/src/__fixtures__/index.ts +100 -0
  4. package/src/cli.ts +809 -90
  5. package/src/db/memory-db.ts +35 -1
  6. package/src/db/workflow-db.test.ts +24 -0
  7. package/src/db/workflow-db.ts +469 -14
  8. package/src/expression/evaluator.ts +68 -4
  9. package/src/parser/agent-parser.ts +6 -3
  10. package/src/parser/config-schema.ts +38 -2
  11. package/src/parser/schema.ts +192 -7
  12. package/src/parser/test-schema.ts +29 -0
  13. package/src/parser/workflow-parser.test.ts +54 -0
  14. package/src/parser/workflow-parser.ts +153 -7
  15. package/src/runner/aggregate-error.test.ts +57 -0
  16. package/src/runner/aggregate-error.ts +46 -0
  17. package/src/runner/audit-verification.test.ts +2 -2
  18. package/src/runner/auto-heal.test.ts +1 -1
  19. package/src/runner/blueprint-executor.test.ts +63 -0
  20. package/src/runner/blueprint-executor.ts +157 -0
  21. package/src/runner/concurrency-limit.test.ts +82 -0
  22. package/src/runner/debug-repl.ts +18 -3
  23. package/src/runner/durable-timers.test.ts +200 -0
  24. package/src/runner/engine-executor.test.ts +464 -0
  25. package/src/runner/engine-executor.ts +491 -0
  26. package/src/runner/foreach-executor.ts +30 -12
  27. package/src/runner/llm-adapter.test.ts +282 -5
  28. package/src/runner/llm-adapter.ts +581 -8
  29. package/src/runner/llm-clarification.test.ts +79 -21
  30. package/src/runner/llm-errors.ts +83 -0
  31. package/src/runner/llm-executor.test.ts +258 -219
  32. package/src/runner/llm-executor.ts +226 -29
  33. package/src/runner/mcp-client.ts +70 -3
  34. package/src/runner/mcp-manager.test.ts +52 -52
  35. package/src/runner/mcp-manager.ts +12 -5
  36. package/src/runner/mcp-server.test.ts +117 -78
  37. package/src/runner/mcp-server.ts +13 -4
  38. package/src/runner/optimization-runner.ts +48 -31
  39. package/src/runner/reflexion.test.ts +1 -1
  40. package/src/runner/resource-pool.test.ts +113 -0
  41. package/src/runner/resource-pool.ts +164 -0
  42. package/src/runner/shell-executor.ts +130 -32
  43. package/src/runner/standard-tools-integration.test.ts +36 -36
  44. package/src/runner/standard-tools.test.ts +18 -0
  45. package/src/runner/standard-tools.ts +110 -37
  46. package/src/runner/step-executor.test.ts +176 -16
  47. package/src/runner/step-executor.ts +530 -86
  48. package/src/runner/stream-utils.test.ts +14 -0
  49. package/src/runner/subflow-outputs.test.ts +103 -0
  50. package/src/runner/test-harness.ts +161 -0
  51. package/src/runner/tool-integration.test.ts +73 -79
  52. package/src/runner/workflow-runner.test.ts +492 -15
  53. package/src/runner/workflow-runner.ts +1438 -79
  54. package/src/runner/workflow-subflows.test.ts +255 -0
  55. package/src/templates/agents/keystone-architect.md +17 -12
  56. package/src/templates/agents/tester.md +21 -0
  57. package/src/templates/child-rollback.yaml +11 -0
  58. package/src/templates/decompose-implement.yaml +53 -0
  59. package/src/templates/decompose-problem.yaml +159 -0
  60. package/src/templates/decompose-research.yaml +52 -0
  61. package/src/templates/decompose-review.yaml +51 -0
  62. package/src/templates/dev.yaml +134 -0
  63. package/src/templates/engine-example.yaml +33 -0
  64. package/src/templates/fan-out-fan-in.yaml +61 -0
  65. package/src/templates/memory-service.yaml +1 -1
  66. package/src/templates/parent-rollback.yaml +16 -0
  67. package/src/templates/robust-automation.yaml +1 -1
  68. package/src/templates/scaffold-feature.yaml +29 -27
  69. package/src/templates/scaffold-generate.yaml +41 -0
  70. package/src/templates/scaffold-plan.yaml +53 -0
  71. package/src/types/status.ts +3 -0
  72. package/src/ui/dashboard.tsx +4 -3
  73. package/src/utils/assets.macro.ts +36 -0
  74. package/src/utils/auth-manager.ts +585 -8
  75. package/src/utils/blueprint-utils.test.ts +49 -0
  76. package/src/utils/blueprint-utils.ts +80 -0
  77. package/src/utils/circuit-breaker.test.ts +177 -0
  78. package/src/utils/circuit-breaker.ts +160 -0
  79. package/src/utils/config-loader.test.ts +100 -13
  80. package/src/utils/config-loader.ts +44 -17
  81. package/src/utils/constants.ts +62 -0
  82. package/src/utils/error-renderer.test.ts +267 -0
  83. package/src/utils/error-renderer.ts +320 -0
  84. package/src/utils/json-parser.test.ts +4 -0
  85. package/src/utils/json-parser.ts +18 -1
  86. package/src/utils/mermaid.ts +4 -0
  87. package/src/utils/paths.test.ts +46 -0
  88. package/src/utils/paths.ts +70 -0
  89. package/src/utils/process-sandbox.test.ts +128 -0
  90. package/src/utils/process-sandbox.ts +293 -0
  91. package/src/utils/rate-limiter.test.ts +143 -0
  92. package/src/utils/rate-limiter.ts +221 -0
  93. package/src/utils/redactor.test.ts +23 -15
  94. package/src/utils/redactor.ts +65 -25
  95. package/src/utils/resource-loader.test.ts +54 -0
  96. package/src/utils/resource-loader.ts +158 -0
  97. package/src/utils/sandbox.test.ts +69 -4
  98. package/src/utils/sandbox.ts +69 -6
  99. package/src/utils/schema-validator.ts +65 -0
  100. package/src/utils/workflow-registry.test.ts +57 -0
  101. package/src/utils/workflow-registry.ts +45 -25
  102. /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
  103. /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
- // Use ConsoleLogger as a safe fallback for top-level utility
63
- console.error(
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
- // Use ConsoleLogger as a safe fallback for top-level utility
201
- console.error('Error refreshing Copilot token:', error);
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
+ });