keystone-cli 1.3.0 → 2.0.1

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 (42) hide show
  1. package/README.md +127 -140
  2. package/package.json +6 -3
  3. package/src/cli.ts +54 -369
  4. package/src/commands/init.ts +15 -29
  5. package/src/db/memory-db.test.ts +45 -0
  6. package/src/db/memory-db.ts +47 -21
  7. package/src/db/sqlite-setup.ts +26 -3
  8. package/src/db/workflow-db.ts +12 -5
  9. package/src/parser/config-schema.ts +17 -13
  10. package/src/parser/schema.ts +4 -2
  11. package/src/runner/__test__/llm-mock-setup.ts +173 -0
  12. package/src/runner/__test__/llm-test-setup.ts +271 -0
  13. package/src/runner/engine-executor.test.ts +25 -18
  14. package/src/runner/executors/blueprint-executor.ts +0 -1
  15. package/src/runner/executors/dynamic-executor.ts +11 -6
  16. package/src/runner/executors/engine-executor.ts +5 -1
  17. package/src/runner/executors/llm-executor.ts +502 -1033
  18. package/src/runner/executors/memory-executor.ts +35 -19
  19. package/src/runner/executors/plan-executor.ts +0 -1
  20. package/src/runner/executors/types.ts +4 -4
  21. package/src/runner/llm-adapter.integration.test.ts +151 -0
  22. package/src/runner/llm-adapter.ts +270 -1398
  23. package/src/runner/llm-clarification.test.ts +91 -106
  24. package/src/runner/llm-executor.test.ts +217 -1181
  25. package/src/runner/memoization.test.ts +0 -1
  26. package/src/runner/recovery-security.test.ts +51 -20
  27. package/src/runner/reflexion.test.ts +55 -18
  28. package/src/runner/standard-tools-integration.test.ts +137 -87
  29. package/src/runner/step-executor.test.ts +36 -80
  30. package/src/runner/step-executor.ts +0 -2
  31. package/src/runner/test-harness.ts +3 -29
  32. package/src/runner/tool-integration.test.ts +122 -73
  33. package/src/runner/workflow-runner.ts +110 -49
  34. package/src/runner/workflow-scheduler.ts +11 -1
  35. package/src/runner/workflow-summary.ts +144 -0
  36. package/src/utils/auth-manager.test.ts +10 -520
  37. package/src/utils/auth-manager.ts +3 -756
  38. package/src/utils/config-loader.ts +12 -0
  39. package/src/utils/constants.ts +0 -17
  40. package/src/utils/process-sandbox.ts +15 -3
  41. package/src/runner/llm-adapter-runtime.test.ts +0 -209
  42. package/src/runner/llm-adapter.test.ts +0 -1012
@@ -45,10 +45,11 @@ describe('AuthManager', () => {
45
45
  });
46
46
 
47
47
  it('should load and parse auth data if file exists', () => {
48
- fs.writeFileSync(TEMP_AUTH_FILE, JSON.stringify({ github_token: 'test-token' }));
48
+ const authData = { mcp_tokens: { test: { access_token: 'test-token' } } };
49
+ fs.writeFileSync(TEMP_AUTH_FILE, JSON.stringify(authData));
49
50
 
50
51
  const data = AuthManager.load();
51
- expect(data).toEqual({ github_token: 'test-token' });
52
+ expect(data).toEqual(authData);
52
53
  });
53
54
 
54
55
  it('should return empty object if JSON parsing fails', () => {
@@ -61,14 +62,16 @@ describe('AuthManager', () => {
61
62
 
62
63
  describe('save()', () => {
63
64
  it('should save data merged with current data', () => {
64
- fs.writeFileSync(TEMP_AUTH_FILE, JSON.stringify({ github_token: 'old-token' }));
65
+ fs.writeFileSync(
66
+ TEMP_AUTH_FILE,
67
+ JSON.stringify({ mcp_tokens: { s1: { access_token: 't1' } } })
68
+ );
65
69
 
66
- AuthManager.save({ copilot_token: 'new-copilot-token' });
70
+ AuthManager.save({ mcp_tokens: { s2: { access_token: 't2' } } });
67
71
 
68
72
  const content = fs.readFileSync(TEMP_AUTH_FILE, 'utf8');
69
73
  expect(JSON.parse(content)).toEqual({
70
- github_token: 'old-token',
71
- copilot_token: 'new-copilot-token',
74
+ mcp_tokens: { s2: { access_token: 't2' } },
72
75
  });
73
76
  });
74
77
  });
@@ -85,524 +88,11 @@ describe('AuthManager', () => {
85
88
  AuthManager.setLogger(mockLogger);
86
89
  // Trigger a log through save failure to verify
87
90
  process.env.KEYSTONE_AUTH_PATH = '/non/existent/path/auth.json';
88
- AuthManager.save({ github_token: 'test' });
91
+ AuthManager.save({ mcp_tokens: { test: { access_token: 'test' } } });
89
92
  expect(mockLogger.error).toHaveBeenCalled();
90
93
  process.env.KEYSTONE_AUTH_PATH = TEMP_AUTH_FILE;
91
94
  // Reset logger
92
95
  AuthManager.setLogger(new ConsoleLogger());
93
96
  });
94
97
  });
95
-
96
- describe('getCopilotToken()', () => {
97
- it('should return undefined if no github_token', async () => {
98
- fs.writeFileSync(TEMP_AUTH_FILE, JSON.stringify({}));
99
- const token = await AuthManager.getCopilotToken();
100
- expect(token).toBeUndefined();
101
- });
102
-
103
- it('should return cached token if valid', async () => {
104
- const expires = Math.floor(Date.now() / 1000) + 1000;
105
- fs.writeFileSync(
106
- TEMP_AUTH_FILE,
107
- JSON.stringify({
108
- github_token: 'gh-token',
109
- copilot_token: 'cached-token',
110
- copilot_expires_at: expires,
111
- })
112
- );
113
-
114
- const token = await AuthManager.getCopilotToken();
115
- expect(token).toBe('cached-token');
116
- });
117
-
118
- it('should refresh token if expired', async () => {
119
- fs.writeFileSync(
120
- TEMP_AUTH_FILE,
121
- JSON.stringify({
122
- github_token: 'gh-token',
123
- copilot_token: 'expired-token',
124
- copilot_expires_at: Math.floor(Date.now() / 1000) - 1000,
125
- })
126
- );
127
-
128
- // Mock fetch
129
- const mockFetch = mock(() =>
130
- Promise.resolve(
131
- new Response(
132
- JSON.stringify({
133
- token: 'new-token',
134
- expires_at: Math.floor(Date.now() / 1000) + 3600,
135
- }),
136
- { status: 200 }
137
- )
138
- )
139
- );
140
- // @ts-ignore
141
- global.fetch = mockFetch;
142
-
143
- const token = await AuthManager.getCopilotToken();
144
- expect(token).toBe('new-token');
145
- expect(mockFetch).toHaveBeenCalled();
146
- });
147
-
148
- it('should return undefined and log error if refresh fails', async () => {
149
- fs.writeFileSync(
150
- TEMP_AUTH_FILE,
151
- JSON.stringify({
152
- github_token: 'gh-token',
153
- })
154
- );
155
-
156
- // Mock fetch failure
157
- // @ts-ignore
158
- global.fetch = mock(() =>
159
- Promise.resolve(
160
- new Response('Unauthorized', {
161
- status: 401,
162
- statusText: 'Unauthorized',
163
- })
164
- )
165
- );
166
-
167
- const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
168
- const token = await AuthManager.getCopilotToken();
169
-
170
- expect(token).toBeUndefined();
171
- expect(consoleSpy).toHaveBeenCalled();
172
- consoleSpy.mockRestore();
173
- });
174
- });
175
-
176
- describe('Device Login', () => {
177
- it('initGitHubDeviceLogin should return device code data', async () => {
178
- const mockFetch = mock(() =>
179
- Promise.resolve(
180
- new Response(
181
- JSON.stringify({
182
- device_code: 'dev_code',
183
- user_code: 'USER-CODE',
184
- verification_uri: 'https://github.com/login/device',
185
- expires_in: 900,
186
- interval: 5,
187
- }),
188
- { status: 200 }
189
- )
190
- )
191
- );
192
- // @ts-ignore
193
- global.fetch = mockFetch;
194
-
195
- const result = await AuthManager.initGitHubDeviceLogin();
196
- expect(result.device_code).toBe('dev_code');
197
- expect(result.user_code).toBe('USER-CODE');
198
- expect(mockFetch).toHaveBeenCalled();
199
- });
200
-
201
- it('pollGitHubDeviceLogin should return token when successful', async () => {
202
- let callCount = 0;
203
- const mockFetch = mock(() => {
204
- callCount++;
205
- if (callCount === 1) {
206
- return Promise.resolve(
207
- new Response(
208
- JSON.stringify({
209
- error: 'authorization_pending',
210
- }),
211
- { status: 200 }
212
- )
213
- );
214
- }
215
- return Promise.resolve(
216
- new Response(
217
- JSON.stringify({
218
- access_token: 'gh_access_token',
219
- }),
220
- { status: 200 }
221
- )
222
- );
223
- });
224
- // @ts-ignore
225
- global.fetch = mockFetch;
226
-
227
- // Mock setTimeout to resolve immediately
228
- const originalTimeout = global.setTimeout;
229
- // @ts-ignore
230
- global.setTimeout = (fn) => fn();
231
-
232
- try {
233
- const token = await AuthManager.pollGitHubDeviceLogin('dev_code');
234
- expect(token).toBe('gh_access_token');
235
- expect(callCount).toBe(2);
236
- } finally {
237
- global.setTimeout = originalTimeout;
238
- }
239
- });
240
-
241
- it('pollGitHubDeviceLogin should throw on other errors', async () => {
242
- const mockFetch = mock(() =>
243
- Promise.resolve(
244
- new Response(
245
- JSON.stringify({
246
- error: 'expired_token',
247
- error_description: 'The device code has expired',
248
- }),
249
- { status: 200 }
250
- )
251
- )
252
- );
253
- // @ts-ignore
254
- global.fetch = mockFetch;
255
-
256
- await expect(AuthManager.pollGitHubDeviceLogin('dev_code')).rejects.toThrow(
257
- 'The device code has expired'
258
- );
259
- });
260
-
261
- it('pollGitHubDeviceLogin should timeout after 15 minutes', async () => {
262
- // Mock fetch to always return authorization_pending
263
- // @ts-ignore
264
- global.fetch = mock(() =>
265
- Promise.resolve(
266
- new Response(
267
- JSON.stringify({
268
- error: 'authorization_pending',
269
- }),
270
- { status: 200 }
271
- )
272
- )
273
- );
274
-
275
- // Mock Date.now to simulate time passing
276
- let now = Date.now();
277
- const dateSpy = spyOn(Date, 'now').mockImplementation(() => {
278
- const current = now;
279
- now += 1000 * 60 * 16; // Advance 16 minutes on each call to trigger timeout immediately
280
- return current;
281
- });
282
-
283
- try {
284
- await expect(AuthManager.pollGitHubDeviceLogin('dev_code')).rejects.toThrow(
285
- 'Device login timed out'
286
- );
287
- } finally {
288
- dateSpy.mockRestore();
289
- }
290
- });
291
- });
292
-
293
- describe('OAuth Helpers', () => {
294
- it('generateCodeVerifier should return hex string', () => {
295
- // @ts-ignore - access private
296
- const verifier = AuthManager.generateCodeVerifier();
297
- expect(verifier).toMatch(/^[0-9a-f]+$/);
298
- expect(verifier.length).toBe(64);
299
- });
300
-
301
- it('createCodeChallenge should return base64url string', () => {
302
- const verifier = 'test-verifier';
303
- // @ts-ignore - access private
304
- const challenge = AuthManager.createCodeChallenge(verifier);
305
- expect(challenge).toBeDefined();
306
- expect(challenge).not.toContain('+');
307
- expect(challenge).not.toContain('/');
308
- });
309
- });
310
-
311
- describe('Anthropic Claude', () => {
312
- it('createAnthropicClaudeAuth should return url and verifier', () => {
313
- const { url, verifier } = AuthManager.createAnthropicClaudeAuth();
314
- expect(url).toContain('https://claude.ai/oauth/authorize');
315
- expect(url).toContain('client_id=');
316
- expect(verifier).toBeDefined();
317
- });
318
-
319
- it('exchangeAnthropicClaudeCode should return tokens', async () => {
320
- const mockFetch = mock(() =>
321
- Promise.resolve(
322
- new Response(
323
- JSON.stringify({
324
- access_token: 'claude-access',
325
- refresh_token: 'claude-refresh',
326
- expires_in: 3600,
327
- }),
328
- { status: 200 }
329
- )
330
- )
331
- );
332
- // @ts-ignore
333
- global.fetch = mockFetch;
334
-
335
- const result = await AuthManager.exchangeAnthropicClaudeCode('code#verifier', 'verifier');
336
- expect(result.access_token).toBe('claude-access');
337
- });
338
-
339
- it('getAnthropicClaudeToken should return cached token if valid', async () => {
340
- const expires = Math.floor(Date.now() / 1000) + 1000;
341
- fs.writeFileSync(
342
- TEMP_AUTH_FILE,
343
- JSON.stringify({
344
- anthropic_claude: {
345
- access_token: 'claude-cached',
346
- refresh_token: 'refresh',
347
- expires_at: expires,
348
- },
349
- })
350
- );
351
-
352
- const token = await AuthManager.getAnthropicClaudeToken();
353
- expect(token).toBe('claude-cached');
354
- });
355
-
356
- it('getAnthropicClaudeToken should refresh if expired', async () => {
357
- fs.writeFileSync(
358
- TEMP_AUTH_FILE,
359
- JSON.stringify({
360
- anthropic_claude: {
361
- access_token: 'expired',
362
- refresh_token: 'refresh',
363
- expires_at: Math.floor(Date.now() / 1000) - 1000,
364
- },
365
- })
366
- );
367
-
368
- // @ts-ignore
369
- global.fetch = mock(() =>
370
- Promise.resolve(
371
- new Response(
372
- JSON.stringify({
373
- access_token: 'new-claude-token',
374
- refresh_token: 'new-refresh',
375
- expires_in: 3600,
376
- }),
377
- { status: 200 }
378
- )
379
- )
380
- );
381
-
382
- const token = await AuthManager.getAnthropicClaudeToken();
383
- expect(token).toBe('new-claude-token');
384
- });
385
- });
386
-
387
- describe('Google Gemini', () => {
388
- it('getGoogleGeminiToken should return undefined if not logged in', async () => {
389
- const token = await AuthManager.getGoogleGeminiToken();
390
- expect(token).toBeUndefined();
391
- });
392
-
393
- it('getGoogleGeminiToken should refresh if expired', async () => {
394
- fs.writeFileSync(
395
- TEMP_AUTH_FILE,
396
- JSON.stringify({
397
- google_gemini: {
398
- access_token: 'expired',
399
- refresh_token: 'refresh',
400
- expires_at: Math.floor(Date.now() / 1000) - 1000,
401
- },
402
- })
403
- );
404
-
405
- process.env.GOOGLE_GEMINI_OAUTH_CLIENT_SECRET = 'secret';
406
-
407
- // @ts-ignore
408
- global.fetch = mock(() =>
409
- Promise.resolve(
410
- new Response(
411
- JSON.stringify({
412
- access_token: 'new-gemini-token',
413
- expires_in: 3600,
414
- }),
415
- { status: 200 }
416
- )
417
- )
418
- );
419
-
420
- const token = await AuthManager.getGoogleGeminiToken();
421
- expect(token).toBe('new-gemini-token');
422
- });
423
-
424
- it('fetchGoogleGeminiProjectId should return project ID from loadCodeAssist', async () => {
425
- // @ts-ignore - access private
426
- const spyFetch = mock(() =>
427
- Promise.resolve(
428
- new Response(
429
- JSON.stringify({
430
- cloudaicompanionProject: 'test-project-id',
431
- }),
432
- { status: 200 }
433
- )
434
- )
435
- );
436
- // @ts-ignore
437
- global.fetch = spyFetch;
438
-
439
- // @ts-ignore - access private
440
- const projectId = await AuthManager.fetchGoogleGeminiProjectId('access-token');
441
- expect(projectId).toBe('test-project-id');
442
- });
443
-
444
- it('fetchGoogleGeminiProjectId should return project ID from nested object', async () => {
445
- // @ts-ignore - access private
446
- const spyFetch = mock(() =>
447
- Promise.resolve(
448
- new Response(
449
- JSON.stringify({
450
- cloudaicompanionProject: { id: 'nested-id' },
451
- }),
452
- { status: 200 }
453
- )
454
- )
455
- );
456
- // @ts-ignore
457
- global.fetch = spyFetch;
458
-
459
- // @ts-ignore - access private
460
- const projectId = await AuthManager.fetchGoogleGeminiProjectId('access-token');
461
- expect(projectId).toBe('nested-id');
462
- });
463
-
464
- it('loginGoogleGemini should handle OAuth callback', async () => {
465
- process.env.GOOGLE_GEMINI_OAUTH_CLIENT_SECRET = 'secret';
466
-
467
- let fetchHandler: any;
468
- const mockServer = {
469
- port: 51121,
470
- stop: mock(() => {}),
471
- };
472
-
473
- // @ts-ignore - mock Bun.serve
474
- const serveSpy = spyOn(Bun, 'serve').mockImplementation((options: any) => {
475
- fetchHandler = options.fetch;
476
- return mockServer;
477
- });
478
-
479
- // Mock openBrowser to prevent browser opening
480
- const openBrowserSpy = spyOn(AuthManager, 'openBrowser').mockImplementation(() => {});
481
-
482
- try {
483
- const loginPromise = AuthManager.loginGoogleGemini('test-project');
484
-
485
- // Verify server was started
486
- expect(serveSpy).toHaveBeenCalled();
487
- expect(fetchHandler).toBeDefined();
488
-
489
- // Simulating the fetch handler call with the mock server
490
- const req = new Request('http://localhost:51121/oauth-callback?code=test-code');
491
- // @ts-ignore
492
- global.fetch = mock(() =>
493
- Promise.resolve(
494
- new Response(
495
- JSON.stringify({
496
- access_token: 'access',
497
- refresh_token: 'refresh',
498
- expires_in: 3600,
499
- }),
500
- { status: 200 }
501
- )
502
- )
503
- );
504
-
505
- const response = await fetchHandler(req, mockServer);
506
- expect(response.status).toBe(200);
507
-
508
- await loginPromise;
509
- const auth = AuthManager.load();
510
- expect(auth.google_gemini?.access_token).toBe('access');
511
- } finally {
512
- serveSpy.mockRestore();
513
- openBrowserSpy.mockRestore();
514
- }
515
- });
516
- });
517
-
518
- describe('OpenAI ChatGPT Login', () => {
519
- it('loginOpenAIChatGPT should handle OAuth callback', async () => {
520
- let fetchHandler: any;
521
- const mockServer = {
522
- stop: mock(() => {}),
523
- };
524
-
525
- // @ts-ignore - mock Bun.serve
526
- const serveSpy = spyOn(Bun, 'serve').mockImplementation((options: any) => {
527
- fetchHandler = options.fetch;
528
- return mockServer;
529
- });
530
-
531
- // Mock openBrowser to prevent browser opening
532
- const openBrowserSpy = spyOn(AuthManager, 'openBrowser').mockImplementation(() => {});
533
-
534
- try {
535
- const loginPromise = AuthManager.loginOpenAIChatGPT();
536
-
537
- expect(serveSpy).toHaveBeenCalled();
538
- expect(fetchHandler).toBeDefined();
539
-
540
- // Simulate callback
541
- const req = new Request('http://localhost:1455/auth/callback?code=test-code&state=xyz');
542
- // The code expects the state to match. We can't easily get the random state,
543
- // but since we are mocking, we can just ensure the branch is covered.
544
- // Actually, let's just test that the handler exists and can be called.
545
-
546
- // @ts-ignore
547
- global.fetch = mock(() =>
548
- Promise.resolve(
549
- new Response(
550
- JSON.stringify({
551
- access_token: 'access',
552
- refresh_token: 'refresh',
553
- expires_in: 3600,
554
- }),
555
- { status: 200 }
556
- )
557
- )
558
- );
559
-
560
- // We skip the state check by not providing it in the URL if we want to test failure,
561
- // or we can try to find where it's stored.
562
- // But for now, let's just trigger it.
563
-
564
- const response = await fetchHandler(req);
565
- // It should return 400 because of state mismatch in real code,
566
- // unless we mock the state generation.
567
- expect(response.status).toBe(400);
568
-
569
- await expect(loginPromise).rejects.toThrow('Invalid OAuth state');
570
- } finally {
571
- serveSpy.mockRestore();
572
- openBrowserSpy.mockRestore();
573
- }
574
- });
575
- });
576
-
577
- describe('OpenAI ChatGPT', () => {
578
- it('getOpenAIChatGPTToken should refresh if expired', async () => {
579
- fs.writeFileSync(
580
- TEMP_AUTH_FILE,
581
- JSON.stringify({
582
- openai_chatgpt: {
583
- access_token: 'expired',
584
- refresh_token: 'refresh',
585
- expires_at: Math.floor(Date.now() / 1000) - 1000,
586
- },
587
- })
588
- );
589
-
590
- // @ts-ignore
591
- global.fetch = mock(() =>
592
- Promise.resolve(
593
- new Response(
594
- JSON.stringify({
595
- access_token: 'new-chatgpt-token',
596
- refresh_token: 'new-refresh',
597
- expires_in: 3600,
598
- }),
599
- { status: 200 }
600
- )
601
- )
602
- );
603
-
604
- const token = await AuthManager.getOpenAIChatGPTToken();
605
- expect(token).toBe('new-chatgpt-token');
606
- });
607
- });
608
98
  });