keystone-cli 1.0.2 → 1.1.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 (155) hide show
  1. package/README.md +288 -24
  2. package/package.json +8 -4
  3. package/src/cli.ts +538 -419
  4. package/src/commands/doc.ts +31 -0
  5. package/src/commands/event.ts +29 -0
  6. package/src/commands/graph.ts +37 -0
  7. package/src/commands/index.ts +14 -0
  8. package/src/commands/init.ts +185 -0
  9. package/src/commands/run.ts +124 -0
  10. package/src/commands/schema.ts +40 -0
  11. package/src/commands/utils.ts +78 -0
  12. package/src/commands/validate.ts +111 -0
  13. package/src/db/memory-db.ts +50 -2
  14. package/src/db/workflow-db.test.ts +314 -0
  15. package/src/db/workflow-db.ts +810 -210
  16. package/src/expression/evaluator-audit.test.ts +4 -2
  17. package/src/expression/evaluator.test.ts +14 -1
  18. package/src/expression/evaluator.ts +166 -19
  19. package/src/parser/config-schema.ts +18 -0
  20. package/src/parser/schema.ts +153 -22
  21. package/src/parser/test-schema.ts +6 -6
  22. package/src/parser/workflow-parser.test.ts +24 -0
  23. package/src/parser/workflow-parser.ts +65 -3
  24. package/src/runner/auto-heal.test.ts +5 -6
  25. package/src/runner/blueprint-executor.test.ts +2 -2
  26. package/src/runner/debug-repl.test.ts +5 -8
  27. package/src/runner/debug-repl.ts +59 -16
  28. package/src/runner/durable-timers.test.ts +11 -2
  29. package/src/runner/engine-executor.test.ts +1 -1
  30. package/src/runner/events.ts +57 -0
  31. package/src/runner/executors/artifact-executor.ts +166 -0
  32. package/src/runner/{blueprint-executor.ts → executors/blueprint-executor.ts} +15 -7
  33. package/src/runner/{engine-executor.ts → executors/engine-executor.ts} +55 -7
  34. package/src/runner/executors/file-executor.test.ts +48 -0
  35. package/src/runner/executors/file-executor.ts +324 -0
  36. package/src/runner/{foreach-executor.ts → executors/foreach-executor.ts} +168 -80
  37. package/src/runner/executors/human-executor.ts +144 -0
  38. package/src/runner/executors/join-executor.ts +75 -0
  39. package/src/runner/executors/llm-executor.ts +1266 -0
  40. package/src/runner/executors/memory-executor.ts +71 -0
  41. package/src/runner/executors/plan-executor.ts +104 -0
  42. package/src/runner/executors/request-executor.ts +265 -0
  43. package/src/runner/executors/script-executor.ts +43 -0
  44. package/src/runner/executors/shell-executor.ts +403 -0
  45. package/src/runner/executors/subworkflow-executor.ts +114 -0
  46. package/src/runner/executors/types.ts +69 -0
  47. package/src/runner/executors/wait-executor.ts +59 -0
  48. package/src/runner/join-scheduling.test.ts +197 -0
  49. package/src/runner/llm-adapter-runtime.test.ts +209 -0
  50. package/src/runner/llm-adapter.test.ts +419 -24
  51. package/src/runner/llm-adapter.ts +414 -17
  52. package/src/runner/llm-clarification.test.ts +2 -1
  53. package/src/runner/llm-executor.test.ts +532 -17
  54. package/src/runner/mcp-client-audit.test.ts +1 -2
  55. package/src/runner/mcp-client.ts +136 -46
  56. package/src/runner/mcp-manager.test.ts +4 -0
  57. package/src/runner/mcp-server.test.ts +58 -0
  58. package/src/runner/mcp-server.ts +26 -0
  59. package/src/runner/memoization.test.ts +190 -0
  60. package/src/runner/optimization-runner.ts +4 -9
  61. package/src/runner/quality-gate.test.ts +69 -0
  62. package/src/runner/reflexion.test.ts +6 -17
  63. package/src/runner/resource-pool.ts +102 -14
  64. package/src/runner/services/context-builder.ts +144 -0
  65. package/src/runner/services/secret-manager.ts +105 -0
  66. package/src/runner/services/workflow-validator.ts +131 -0
  67. package/src/runner/shell-executor.test.ts +28 -4
  68. package/src/runner/standard-tools-ast.test.ts +196 -0
  69. package/src/runner/standard-tools-execution.test.ts +27 -0
  70. package/src/runner/standard-tools-integration.test.ts +6 -10
  71. package/src/runner/standard-tools.ts +339 -102
  72. package/src/runner/step-executor.test.ts +216 -4
  73. package/src/runner/step-executor.ts +69 -941
  74. package/src/runner/stream-utils.ts +7 -3
  75. package/src/runner/test-harness.ts +20 -1
  76. package/src/runner/timeout.test.ts +10 -0
  77. package/src/runner/timeout.ts +11 -2
  78. package/src/runner/tool-integration.test.ts +1 -1
  79. package/src/runner/wait-step.test.ts +102 -0
  80. package/src/runner/workflow-runner.test.ts +208 -15
  81. package/src/runner/workflow-runner.ts +890 -818
  82. package/src/runner/workflow-scheduler.ts +75 -0
  83. package/src/runner/workflow-state.ts +269 -0
  84. package/src/runner/workflow-subflows.test.ts +13 -12
  85. package/src/scripts/generate-schemas.ts +16 -0
  86. package/src/templates/agents/explore.md +1 -0
  87. package/src/templates/agents/general.md +1 -0
  88. package/src/templates/agents/handoff-router.md +14 -0
  89. package/src/templates/agents/handoff-specialist.md +15 -0
  90. package/src/templates/agents/keystone-architect.md +13 -44
  91. package/src/templates/agents/my-agent.md +1 -0
  92. package/src/templates/agents/software-engineer.md +1 -0
  93. package/src/templates/agents/summarizer.md +1 -0
  94. package/src/templates/agents/test-agent.md +1 -0
  95. package/src/templates/agents/tester.md +1 -0
  96. package/src/templates/{basic-inputs.yaml → basics/basic-inputs.yaml} +2 -0
  97. package/src/templates/{basic-shell.yaml → basics/basic-shell.yaml} +2 -1
  98. package/src/templates/{full-feature-demo.yaml → basics/full-feature-demo.yaml} +2 -0
  99. package/src/templates/{stop-watch.yaml → basics/stop-watch.yaml} +1 -0
  100. package/src/templates/{child-rollback.yaml → control-flow/child-rollback.yaml} +1 -0
  101. package/src/templates/{cleanup-finally.yaml → control-flow/cleanup-finally.yaml} +1 -0
  102. package/src/templates/{fan-out-fan-in.yaml → control-flow/fan-out-fan-in.yaml} +3 -0
  103. package/src/templates/control-flow/idempotency-example.yaml +30 -0
  104. package/src/templates/{loop-parallel.yaml → control-flow/loop-parallel.yaml} +3 -0
  105. package/src/templates/{parent-rollback.yaml → control-flow/parent-rollback.yaml} +1 -0
  106. package/src/templates/{retry-policy.yaml → control-flow/retry-policy.yaml} +3 -0
  107. package/src/templates/features/artifact-example.yaml +39 -0
  108. package/src/templates/{engine-example.yaml → features/engine-example.yaml} +1 -0
  109. package/src/templates/{human-interaction.yaml → features/human-interaction.yaml} +1 -0
  110. package/src/templates/{llm-agent.yaml → features/llm-agent.yaml} +1 -0
  111. package/src/templates/{memory-service.yaml → features/memory-service.yaml} +2 -0
  112. package/src/templates/{robust-automation.yaml → features/robust-automation.yaml} +3 -0
  113. package/src/templates/features/script-example.yaml +27 -0
  114. package/src/templates/patterns/agent-handoff.yaml +53 -0
  115. package/src/templates/{approval-process.yaml → patterns/approval-process.yaml} +1 -0
  116. package/src/templates/{batch-processor.yaml → patterns/batch-processor.yaml} +2 -0
  117. package/src/templates/{composition-child.yaml → patterns/composition-child.yaml} +1 -0
  118. package/src/templates/{composition-parent.yaml → patterns/composition-parent.yaml} +1 -0
  119. package/src/templates/{data-pipeline.yaml → patterns/data-pipeline.yaml} +2 -0
  120. package/src/templates/{decompose-implement.yaml → scaffolding/decompose-implement.yaml} +1 -0
  121. package/src/templates/{decompose-problem.yaml → scaffolding/decompose-problem.yaml} +1 -0
  122. package/src/templates/{decompose-research.yaml → scaffolding/decompose-research.yaml} +1 -0
  123. package/src/templates/{decompose-review.yaml → scaffolding/decompose-review.yaml} +1 -0
  124. package/src/templates/{dev.yaml → scaffolding/dev.yaml} +1 -0
  125. package/src/templates/scaffolding/review-loop.yaml +97 -0
  126. package/src/templates/{scaffold-feature.yaml → scaffolding/scaffold-feature.yaml} +2 -0
  127. package/src/templates/{scaffold-generate.yaml → scaffolding/scaffold-generate.yaml} +1 -0
  128. package/src/templates/{scaffold-plan.yaml → scaffolding/scaffold-plan.yaml} +1 -0
  129. package/src/templates/testing/invalid.yaml +6 -0
  130. package/src/ui/dashboard.tsx +191 -33
  131. package/src/utils/auth-manager.test.ts +337 -0
  132. package/src/utils/auth-manager.ts +157 -61
  133. package/src/utils/blueprint-utils.ts +4 -6
  134. package/src/utils/config-loader.test.ts +2 -0
  135. package/src/utils/config-loader.ts +12 -3
  136. package/src/utils/constants.ts +76 -0
  137. package/src/utils/container.ts +63 -0
  138. package/src/utils/context-injector.test.ts +200 -0
  139. package/src/utils/context-injector.ts +244 -0
  140. package/src/utils/doc-generator.ts +85 -0
  141. package/src/utils/env-filter.ts +45 -0
  142. package/src/utils/json-parser.test.ts +12 -0
  143. package/src/utils/json-parser.ts +30 -5
  144. package/src/utils/logger.ts +12 -1
  145. package/src/utils/mermaid.ts +4 -0
  146. package/src/utils/paths.ts +52 -1
  147. package/src/utils/process-sandbox-worker.test.ts +46 -0
  148. package/src/utils/process-sandbox.ts +227 -14
  149. package/src/utils/redactor.test.ts +11 -6
  150. package/src/utils/redactor.ts +25 -9
  151. package/src/utils/sandbox.ts +3 -0
  152. package/src/utils/workflow-registry.test.ts +2 -2
  153. package/src/runner/llm-executor.ts +0 -638
  154. package/src/runner/shell-executor.ts +0 -366
  155. package/src/templates/invalid.yaml +0 -5
@@ -1,6 +1,6 @@
1
1
  import { Box, Newline, Text, render, useInput } from 'ink';
2
2
  import React, { useState, useEffect, useCallback, useMemo } from 'react';
3
- import { WorkflowDb } from '../db/workflow-db.ts';
3
+ import { type StepExecution, WorkflowDb } from '../db/workflow-db.ts';
4
4
  import { ConsoleLogger } from '../utils/logger.ts';
5
5
 
6
6
  interface Run {
@@ -8,13 +8,30 @@ interface Run {
8
8
  workflow_name: string;
9
9
  status: string;
10
10
  started_at: string;
11
+ completed_at?: string | null;
11
12
  total_tokens?: number;
13
+ duration_ms?: number;
14
+ exec_total?: number;
15
+ exec_failed?: number;
16
+ exec_retries?: number;
17
+ exec_soft_failures?: number;
18
+ }
19
+
20
+ interface Thought {
21
+ id: string;
22
+ run_id: string;
23
+ workflow_name: string;
24
+ step_id: string;
25
+ content: string;
26
+ source: 'thinking' | 'reasoning';
27
+ created_at: string;
12
28
  }
13
29
 
14
30
  const logger = new ConsoleLogger();
15
31
 
16
32
  const Dashboard = () => {
17
33
  const [runs, setRuns] = useState<Run[]>([]);
34
+ const [thoughts, setThoughts] = useState<Thought[]>([]);
18
35
  const [loading, setLoading] = useState(true);
19
36
 
20
37
  // Reuse database connection instead of creating new one every 2 seconds
@@ -28,30 +45,60 @@ const Dashboard = () => {
28
45
  const fetchData = useCallback(async () => {
29
46
  try {
30
47
  const recentRuns = (await db.listRuns(10)) as (Run & { outputs: string | null })[];
31
- const runsWithUsage = await Promise.all(
32
- recentRuns.map(async (run) => {
33
- let total_tokens = 0;
34
- try {
35
- // Get steps to aggregate tokens if not in outputs (future-proofing)
36
- const steps = await db.getStepsByRun(run.id);
37
- total_tokens = steps.reduce((sum, s) => {
38
- if (s.usage) {
39
- try {
40
- const u = JSON.parse(s.usage);
41
- return sum + (u.total_tokens || 0);
42
- } catch (e) {
43
- return sum;
44
- }
45
- }
48
+ const runIds = recentRuns.map((run) => run.id);
49
+ const steps = await db.getStepsByRuns(runIds);
50
+ const stepsByRun = new Map<string, StepExecution[]>();
51
+ for (const step of steps) {
52
+ const list = stepsByRun.get(step.run_id);
53
+ if (list) {
54
+ list.push(step);
55
+ } else {
56
+ stepsByRun.set(step.run_id, [step]);
57
+ }
58
+ }
59
+
60
+ const runsWithUsage = recentRuns.map((run) => {
61
+ let total_tokens = 0;
62
+ let exec_total = 0;
63
+ let exec_failed = 0;
64
+ let exec_retries = 0;
65
+ let exec_soft_failures = 0;
66
+ const runSteps = stepsByRun.get(run.id) || [];
67
+ total_tokens = runSteps.reduce((sum, s) => {
68
+ if (s.usage) {
69
+ try {
70
+ const u = JSON.parse(s.usage);
71
+ return sum + (u.total_tokens || 0);
72
+ } catch (e) {
46
73
  return sum;
47
- }, 0);
48
- } catch (e) {
49
- // Ignore read error
74
+ }
50
75
  }
51
- return { ...run, total_tokens };
52
- })
53
- );
76
+ return sum;
77
+ }, 0);
78
+ exec_total = runSteps.length;
79
+ exec_failed = runSteps.filter((step) => step.status === 'failed').length;
80
+ exec_retries = runSteps.reduce((sum, step) => sum + (step.retry_count || 0), 0);
81
+ exec_soft_failures = runSteps.filter(
82
+ (step) => step.status === 'success' && step.error
83
+ ).length;
84
+ const startedMs = new Date(run.started_at).getTime();
85
+ const completedMs = run.completed_at ? new Date(run.completed_at).getTime() : Date.now();
86
+ const duration_ms = Number.isFinite(startedMs)
87
+ ? Math.max(0, completedMs - startedMs)
88
+ : undefined;
89
+ return {
90
+ ...run,
91
+ total_tokens,
92
+ duration_ms,
93
+ exec_total,
94
+ exec_failed,
95
+ exec_retries,
96
+ exec_soft_failures,
97
+ };
98
+ });
54
99
  setRuns(runsWithUsage);
100
+ const recentThoughts = (await db.listThoughtEvents(12)) as Thought[];
101
+ setThoughts(recentThoughts);
55
102
  } catch (error) {
56
103
  logger.error(`Failed to fetch runs: ${String(error)}`);
57
104
  } finally {
@@ -94,22 +141,42 @@ const Dashboard = () => {
94
141
  ID
95
142
  </Text>
96
143
  </Box>
97
- <Box width={30}>
144
+ <Box width={24}>
98
145
  <Text bold color="cyan">
99
146
  WORKFLOW
100
147
  </Text>
101
148
  </Box>
102
- <Box width={15}>
149
+ <Box width={12}>
103
150
  <Text bold color="cyan">
104
151
  STATUS
105
152
  </Text>
106
153
  </Box>
107
- <Box width={15}>
154
+ <Box width={10}>
155
+ <Text bold color="cyan">
156
+ START
157
+ </Text>
158
+ </Box>
159
+ <Box width={8}>
108
160
  <Text bold color="cyan">
109
- STARTED
161
+ DUR
110
162
  </Text>
111
163
  </Box>
112
- <Box>
164
+ <Box width={7}>
165
+ <Text bold color="cyan">
166
+ EXECS
167
+ </Text>
168
+ </Box>
169
+ <Box width={6}>
170
+ <Text bold color="cyan">
171
+ FAIL
172
+ </Text>
173
+ </Box>
174
+ <Box width={7}>
175
+ <Text bold color="cyan">
176
+ RETRY
177
+ </Text>
178
+ </Box>
179
+ <Box width={8}>
113
180
  <Text bold color="cyan">
114
181
  TOKENS
115
182
  </Text>
@@ -117,7 +184,7 @@ const Dashboard = () => {
117
184
  </Box>
118
185
 
119
186
  <Box marginBottom={1}>
120
- <Text color="gray">{'─'.repeat(80)}</Text>
187
+ <Text color="gray">{'─'.repeat(92)}</Text>
121
188
  </Box>
122
189
 
123
190
  {runs.length === 0 ? (
@@ -130,18 +197,34 @@ const Dashboard = () => {
130
197
  <Box width={12}>
131
198
  <Text color="gray">{run.id.substring(0, 8)}</Text>
132
199
  </Box>
133
- <Box width={30}>
200
+ <Box width={24}>
134
201
  <Text>{run.workflow_name}</Text>
135
202
  </Box>
136
- <Box width={15}>
203
+ <Box width={12}>
137
204
  <Text color={getStatusColor(run.status)}>
138
205
  {getStatusIcon(run.status)} {run.status.toUpperCase()}
139
206
  </Text>
140
207
  </Box>
141
- <Box width={15}>
142
- <Text color="gray">{new Date(run.started_at).toLocaleTimeString()}</Text>
208
+ <Box width={10}>
209
+ <Text color="gray">{formatClock(run.started_at)}</Text>
210
+ </Box>
211
+ <Box width={8}>
212
+ <Text color="gray">
213
+ {run.duration_ms !== undefined ? formatDuration(run.duration_ms) : '--:--'}
214
+ </Text>
143
215
  </Box>
144
- <Box>
216
+ <Box width={7}>
217
+ <Text color="gray">{run.exec_total ?? 0}</Text>
218
+ </Box>
219
+ <Box width={6}>
220
+ <Text color={getFailColor(run.exec_failed, run.exec_soft_failures)}>
221
+ {formatFailCount(run.exec_failed, run.exec_soft_failures)}
222
+ </Text>
223
+ </Box>
224
+ <Box width={7}>
225
+ <Text color={run.exec_retries ? 'yellow' : 'gray'}>{run.exec_retries ?? 0}</Text>
226
+ </Box>
227
+ <Box width={8}>
145
228
  <Text color="yellow">{run.total_tokens || 0}</Text>
146
229
  </Box>
147
230
  </Box>
@@ -149,6 +232,36 @@ const Dashboard = () => {
149
232
  )}
150
233
  </Box>
151
234
 
235
+ <Box marginTop={1} borderStyle="round" borderColor="gray" flexDirection="column" paddingX={1}>
236
+ <Box marginBottom={1}>
237
+ <Text bold color="cyan">
238
+ THOUGHTS
239
+ </Text>
240
+ </Box>
241
+ {thoughts.length === 0 ? (
242
+ <Text italic color="gray">
243
+ No thought events yet.
244
+ </Text>
245
+ ) : (
246
+ thoughts.map((thought) => (
247
+ <Box key={thought.id} marginBottom={0}>
248
+ <Box width={10}>
249
+ <Text color="gray">{formatClock(thought.created_at)}</Text>
250
+ </Box>
251
+ <Box width={10}>
252
+ <Text color="gray">{thought.run_id.substring(0, 8)}</Text>
253
+ </Box>
254
+ <Box width={16}>
255
+ <Text color="gray">{thought.step_id}</Text>
256
+ </Box>
257
+ <Box flexGrow={1}>
258
+ <Text>{truncateText(thought.content, 120)}</Text>
259
+ </Box>
260
+ </Box>
261
+ ))
262
+ )}
263
+ </Box>
264
+
152
265
  <Box marginTop={1} paddingX={1}>
153
266
  <Text color="gray">
154
267
  <Text bold color="white">
@@ -203,6 +316,51 @@ const getStatusIcon = (status: string) => {
203
316
  }
204
317
  };
205
318
 
319
+ const formatDuration = (durationMs: number): string => {
320
+ if (!Number.isFinite(durationMs) || durationMs < 0) return '--:--';
321
+ const totalSeconds = Math.floor(durationMs / 1000);
322
+ const hours = Math.floor(totalSeconds / 3600);
323
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
324
+ const seconds = totalSeconds % 60;
325
+ if (hours > 0) {
326
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(
327
+ seconds
328
+ ).padStart(2, '0')}`;
329
+ }
330
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
331
+ };
332
+
333
+ const formatClock = (iso: string): string => {
334
+ try {
335
+ return new Date(iso).toLocaleTimeString([], {
336
+ hour: '2-digit',
337
+ minute: '2-digit',
338
+ second: '2-digit',
339
+ hour12: false,
340
+ });
341
+ } catch {
342
+ return '--:--:--';
343
+ }
344
+ };
345
+
346
+ const truncateText = (value: string, maxLength: number): string => {
347
+ if (value.length <= maxLength) return value;
348
+ return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
349
+ };
350
+
351
+ const formatFailCount = (failed = 0, softFailed = 0): string => {
352
+ if (softFailed > 0) {
353
+ return `${failed}+${softFailed}`;
354
+ }
355
+ return String(failed);
356
+ };
357
+
358
+ const getFailColor = (failed = 0, softFailed = 0): string => {
359
+ if (failed > 0) return 'red';
360
+ if (softFailed > 0) return 'yellow';
361
+ return 'gray';
362
+ };
363
+
206
364
  export const startDashboard = () => {
207
365
  render(<Dashboard />);
208
366
  };
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, mock, spyOn } fr
2
2
  import * as fs from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { AuthManager } from './auth-manager.ts';
5
+ import { ConsoleLogger } from './logger';
5
6
 
6
7
  describe('AuthManager', () => {
7
8
  const originalFetch = global.fetch;
@@ -72,6 +73,26 @@ describe('AuthManager', () => {
72
73
  });
73
74
  });
74
75
 
76
+ describe('setLogger()', () => {
77
+ it('should set the static logger', () => {
78
+ const mockLogger = {
79
+ log: mock(() => {}),
80
+ warn: mock(() => {}),
81
+ error: mock(() => {}),
82
+ info: mock(() => {}),
83
+ debug: mock(() => {}),
84
+ };
85
+ AuthManager.setLogger(mockLogger);
86
+ // Trigger a log through save failure to verify
87
+ process.env.KEYSTONE_AUTH_PATH = '/non/existent/path/auth.json';
88
+ AuthManager.save({ github_token: 'test' });
89
+ expect(mockLogger.error).toHaveBeenCalled();
90
+ process.env.KEYSTONE_AUTH_PATH = TEMP_AUTH_FILE;
91
+ // Reset logger
92
+ AuthManager.setLogger(new ConsoleLogger());
93
+ });
94
+ });
95
+
75
96
  describe('getCopilotToken()', () => {
76
97
  it('should return undefined if no github_token', async () => {
77
98
  fs.writeFileSync(TEMP_AUTH_FILE, JSON.stringify({}));
@@ -268,4 +289,320 @@ describe('AuthManager', () => {
268
289
  }
269
290
  });
270
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
+ });
271
608
  });