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.
- package/README.md +288 -24
- package/package.json +8 -4
- package/src/cli.ts +538 -419
- package/src/commands/doc.ts +31 -0
- package/src/commands/event.ts +29 -0
- package/src/commands/graph.ts +37 -0
- package/src/commands/index.ts +14 -0
- package/src/commands/init.ts +185 -0
- package/src/commands/run.ts +124 -0
- package/src/commands/schema.ts +40 -0
- package/src/commands/utils.ts +78 -0
- package/src/commands/validate.ts +111 -0
- package/src/db/memory-db.ts +50 -2
- package/src/db/workflow-db.test.ts +314 -0
- package/src/db/workflow-db.ts +810 -210
- package/src/expression/evaluator-audit.test.ts +4 -2
- package/src/expression/evaluator.test.ts +14 -1
- package/src/expression/evaluator.ts +166 -19
- package/src/parser/config-schema.ts +18 -0
- package/src/parser/schema.ts +153 -22
- package/src/parser/test-schema.ts +6 -6
- package/src/parser/workflow-parser.test.ts +24 -0
- package/src/parser/workflow-parser.ts +65 -3
- package/src/runner/auto-heal.test.ts +5 -6
- package/src/runner/blueprint-executor.test.ts +2 -2
- package/src/runner/debug-repl.test.ts +5 -8
- package/src/runner/debug-repl.ts +59 -16
- package/src/runner/durable-timers.test.ts +11 -2
- package/src/runner/engine-executor.test.ts +1 -1
- package/src/runner/events.ts +57 -0
- package/src/runner/executors/artifact-executor.ts +166 -0
- package/src/runner/{blueprint-executor.ts → executors/blueprint-executor.ts} +15 -7
- package/src/runner/{engine-executor.ts → executors/engine-executor.ts} +55 -7
- package/src/runner/executors/file-executor.test.ts +48 -0
- package/src/runner/executors/file-executor.ts +324 -0
- package/src/runner/{foreach-executor.ts → executors/foreach-executor.ts} +168 -80
- package/src/runner/executors/human-executor.ts +144 -0
- package/src/runner/executors/join-executor.ts +75 -0
- package/src/runner/executors/llm-executor.ts +1266 -0
- package/src/runner/executors/memory-executor.ts +71 -0
- package/src/runner/executors/plan-executor.ts +104 -0
- package/src/runner/executors/request-executor.ts +265 -0
- package/src/runner/executors/script-executor.ts +43 -0
- package/src/runner/executors/shell-executor.ts +403 -0
- package/src/runner/executors/subworkflow-executor.ts +114 -0
- package/src/runner/executors/types.ts +69 -0
- package/src/runner/executors/wait-executor.ts +59 -0
- package/src/runner/join-scheduling.test.ts +197 -0
- package/src/runner/llm-adapter-runtime.test.ts +209 -0
- package/src/runner/llm-adapter.test.ts +419 -24
- package/src/runner/llm-adapter.ts +414 -17
- package/src/runner/llm-clarification.test.ts +2 -1
- package/src/runner/llm-executor.test.ts +532 -17
- package/src/runner/mcp-client-audit.test.ts +1 -2
- package/src/runner/mcp-client.ts +136 -46
- package/src/runner/mcp-manager.test.ts +4 -0
- package/src/runner/mcp-server.test.ts +58 -0
- package/src/runner/mcp-server.ts +26 -0
- package/src/runner/memoization.test.ts +190 -0
- package/src/runner/optimization-runner.ts +4 -9
- package/src/runner/quality-gate.test.ts +69 -0
- package/src/runner/reflexion.test.ts +6 -17
- package/src/runner/resource-pool.ts +102 -14
- package/src/runner/services/context-builder.ts +144 -0
- package/src/runner/services/secret-manager.ts +105 -0
- package/src/runner/services/workflow-validator.ts +131 -0
- package/src/runner/shell-executor.test.ts +28 -4
- package/src/runner/standard-tools-ast.test.ts +196 -0
- package/src/runner/standard-tools-execution.test.ts +27 -0
- package/src/runner/standard-tools-integration.test.ts +6 -10
- package/src/runner/standard-tools.ts +339 -102
- package/src/runner/step-executor.test.ts +216 -4
- package/src/runner/step-executor.ts +69 -941
- package/src/runner/stream-utils.ts +7 -3
- package/src/runner/test-harness.ts +20 -1
- package/src/runner/timeout.test.ts +10 -0
- package/src/runner/timeout.ts +11 -2
- package/src/runner/tool-integration.test.ts +1 -1
- package/src/runner/wait-step.test.ts +102 -0
- package/src/runner/workflow-runner.test.ts +208 -15
- package/src/runner/workflow-runner.ts +890 -818
- package/src/runner/workflow-scheduler.ts +75 -0
- package/src/runner/workflow-state.ts +269 -0
- package/src/runner/workflow-subflows.test.ts +13 -12
- package/src/scripts/generate-schemas.ts +16 -0
- package/src/templates/agents/explore.md +1 -0
- package/src/templates/agents/general.md +1 -0
- package/src/templates/agents/handoff-router.md +14 -0
- package/src/templates/agents/handoff-specialist.md +15 -0
- package/src/templates/agents/keystone-architect.md +13 -44
- package/src/templates/agents/my-agent.md +1 -0
- package/src/templates/agents/software-engineer.md +1 -0
- package/src/templates/agents/summarizer.md +1 -0
- package/src/templates/agents/test-agent.md +1 -0
- package/src/templates/agents/tester.md +1 -0
- package/src/templates/{basic-inputs.yaml → basics/basic-inputs.yaml} +2 -0
- package/src/templates/{basic-shell.yaml → basics/basic-shell.yaml} +2 -1
- package/src/templates/{full-feature-demo.yaml → basics/full-feature-demo.yaml} +2 -0
- package/src/templates/{stop-watch.yaml → basics/stop-watch.yaml} +1 -0
- package/src/templates/{child-rollback.yaml → control-flow/child-rollback.yaml} +1 -0
- package/src/templates/{cleanup-finally.yaml → control-flow/cleanup-finally.yaml} +1 -0
- package/src/templates/{fan-out-fan-in.yaml → control-flow/fan-out-fan-in.yaml} +3 -0
- package/src/templates/control-flow/idempotency-example.yaml +30 -0
- package/src/templates/{loop-parallel.yaml → control-flow/loop-parallel.yaml} +3 -0
- package/src/templates/{parent-rollback.yaml → control-flow/parent-rollback.yaml} +1 -0
- package/src/templates/{retry-policy.yaml → control-flow/retry-policy.yaml} +3 -0
- package/src/templates/features/artifact-example.yaml +39 -0
- package/src/templates/{engine-example.yaml → features/engine-example.yaml} +1 -0
- package/src/templates/{human-interaction.yaml → features/human-interaction.yaml} +1 -0
- package/src/templates/{llm-agent.yaml → features/llm-agent.yaml} +1 -0
- package/src/templates/{memory-service.yaml → features/memory-service.yaml} +2 -0
- package/src/templates/{robust-automation.yaml → features/robust-automation.yaml} +3 -0
- package/src/templates/features/script-example.yaml +27 -0
- package/src/templates/patterns/agent-handoff.yaml +53 -0
- package/src/templates/{approval-process.yaml → patterns/approval-process.yaml} +1 -0
- package/src/templates/{batch-processor.yaml → patterns/batch-processor.yaml} +2 -0
- package/src/templates/{composition-child.yaml → patterns/composition-child.yaml} +1 -0
- package/src/templates/{composition-parent.yaml → patterns/composition-parent.yaml} +1 -0
- package/src/templates/{data-pipeline.yaml → patterns/data-pipeline.yaml} +2 -0
- package/src/templates/{decompose-implement.yaml → scaffolding/decompose-implement.yaml} +1 -0
- package/src/templates/{decompose-problem.yaml → scaffolding/decompose-problem.yaml} +1 -0
- package/src/templates/{decompose-research.yaml → scaffolding/decompose-research.yaml} +1 -0
- package/src/templates/{decompose-review.yaml → scaffolding/decompose-review.yaml} +1 -0
- package/src/templates/{dev.yaml → scaffolding/dev.yaml} +1 -0
- package/src/templates/scaffolding/review-loop.yaml +97 -0
- package/src/templates/{scaffold-feature.yaml → scaffolding/scaffold-feature.yaml} +2 -0
- package/src/templates/{scaffold-generate.yaml → scaffolding/scaffold-generate.yaml} +1 -0
- package/src/templates/{scaffold-plan.yaml → scaffolding/scaffold-plan.yaml} +1 -0
- package/src/templates/testing/invalid.yaml +6 -0
- package/src/ui/dashboard.tsx +191 -33
- package/src/utils/auth-manager.test.ts +337 -0
- package/src/utils/auth-manager.ts +157 -61
- package/src/utils/blueprint-utils.ts +4 -6
- package/src/utils/config-loader.test.ts +2 -0
- package/src/utils/config-loader.ts +12 -3
- package/src/utils/constants.ts +76 -0
- package/src/utils/container.ts +63 -0
- package/src/utils/context-injector.test.ts +200 -0
- package/src/utils/context-injector.ts +244 -0
- package/src/utils/doc-generator.ts +85 -0
- package/src/utils/env-filter.ts +45 -0
- package/src/utils/json-parser.test.ts +12 -0
- package/src/utils/json-parser.ts +30 -5
- package/src/utils/logger.ts +12 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.ts +52 -1
- package/src/utils/process-sandbox-worker.test.ts +46 -0
- package/src/utils/process-sandbox.ts +227 -14
- package/src/utils/redactor.test.ts +11 -6
- package/src/utils/redactor.ts +25 -9
- package/src/utils/sandbox.ts +3 -0
- package/src/utils/workflow-registry.test.ts +2 -2
- package/src/runner/llm-executor.ts +0 -638
- package/src/runner/shell-executor.ts +0 -366
- package/src/templates/invalid.yaml +0 -5
package/src/ui/dashboard.tsx
CHANGED
|
@@ -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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
}
|
|
48
|
-
} catch (e) {
|
|
49
|
-
// Ignore read error
|
|
74
|
+
}
|
|
50
75
|
}
|
|
51
|
-
return
|
|
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={
|
|
144
|
+
<Box width={24}>
|
|
98
145
|
<Text bold color="cyan">
|
|
99
146
|
WORKFLOW
|
|
100
147
|
</Text>
|
|
101
148
|
</Box>
|
|
102
|
-
<Box width={
|
|
149
|
+
<Box width={12}>
|
|
103
150
|
<Text bold color="cyan">
|
|
104
151
|
STATUS
|
|
105
152
|
</Text>
|
|
106
153
|
</Box>
|
|
107
|
-
<Box width={
|
|
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
|
-
|
|
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(
|
|
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={
|
|
200
|
+
<Box width={24}>
|
|
134
201
|
<Text>{run.workflow_name}</Text>
|
|
135
202
|
</Box>
|
|
136
|
-
<Box width={
|
|
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={
|
|
142
|
-
<Text color="gray">{
|
|
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
|
});
|