keystone-cli 0.2.0 → 0.3.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 +24 -8
- package/package.json +14 -3
- package/src/cli.ts +108 -18
- package/src/expression/evaluator.test.ts +4 -0
- package/src/expression/evaluator.ts +9 -1
- package/src/parser/agent-parser.ts +11 -4
- package/src/parser/config-schema.ts +11 -0
- package/src/parser/schema.ts +1 -1
- package/src/parser/workflow-parser.ts +5 -4
- package/src/runner/llm-executor.test.ts +174 -81
- package/src/runner/llm-executor.ts +8 -3
- package/src/runner/mcp-client.test.ts +85 -47
- package/src/runner/mcp-client.ts +235 -42
- package/src/runner/mcp-manager.ts +42 -2
- package/src/runner/step-executor.test.ts +2 -2
- package/src/runner/step-executor.ts +21 -7
- package/src/runner/workflow-runner.ts +9 -2
- package/src/utils/auth-manager.test.ts +86 -0
- package/src/utils/auth-manager.ts +89 -0
- package/src/utils/config-loader.test.ts +30 -0
- package/src/utils/config-loader.ts +11 -1
- package/src/utils/mermaid.test.ts +27 -3
package/src/runner/mcp-client.ts
CHANGED
|
@@ -57,12 +57,16 @@ class StdConfigTransport implements MCPTransport {
|
|
|
57
57
|
const response = JSON.parse(line) as MCPResponse;
|
|
58
58
|
callback(response);
|
|
59
59
|
} catch (e) {
|
|
60
|
-
//
|
|
60
|
+
// Log non-JSON lines to stderr so they show up in the terminal
|
|
61
|
+
if (line.trim()) {
|
|
62
|
+
process.stderr.write(`[MCP Server Output] ${line}\n`);
|
|
63
|
+
}
|
|
61
64
|
}
|
|
62
65
|
});
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
close(): void {
|
|
69
|
+
this.rl.close();
|
|
66
70
|
this.process.kill();
|
|
67
71
|
}
|
|
68
72
|
}
|
|
@@ -70,49 +74,168 @@ class StdConfigTransport implements MCPTransport {
|
|
|
70
74
|
class SSETransport implements MCPTransport {
|
|
71
75
|
private url: string;
|
|
72
76
|
private headers: Record<string, string>;
|
|
73
|
-
private eventSource: EventSource | null = null;
|
|
74
77
|
private endpoint?: string;
|
|
75
78
|
private onMessageCallback?: (message: MCPResponse) => void;
|
|
79
|
+
private abortController: AbortController | null = null;
|
|
80
|
+
private sessionId?: string;
|
|
76
81
|
|
|
77
82
|
constructor(url: string, headers: Record<string, string> = {}) {
|
|
78
83
|
this.url = url;
|
|
79
84
|
this.headers = headers;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
async connect(): Promise<void> {
|
|
83
|
-
|
|
84
|
-
// @ts-ignore - Bun supports EventSource
|
|
85
|
-
this.eventSource = new EventSource(this.url, { headers: this.headers });
|
|
87
|
+
async connect(timeout = 60000): Promise<void> {
|
|
88
|
+
this.abortController = new AbortController();
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const timeoutId = setTimeout(() => {
|
|
92
|
+
this.close();
|
|
93
|
+
reject(new Error(`SSE connection timeout: ${this.url}`));
|
|
94
|
+
}, timeout);
|
|
95
|
+
|
|
96
|
+
(async () => {
|
|
97
|
+
try {
|
|
98
|
+
let response = await fetch(this.url, {
|
|
99
|
+
headers: {
|
|
100
|
+
Accept: 'application/json, text/event-stream',
|
|
101
|
+
...this.headers,
|
|
102
|
+
},
|
|
103
|
+
signal: this.abortController?.signal,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (response.status === 405) {
|
|
107
|
+
// Some MCP servers (like GitHub) require POST to start a session
|
|
108
|
+
response = await fetch(this.url, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: {
|
|
111
|
+
Accept: 'application/json, text/event-stream',
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
...this.headers,
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
jsonrpc: '2.0',
|
|
117
|
+
id: 'ping',
|
|
118
|
+
method: 'ping',
|
|
119
|
+
}),
|
|
120
|
+
signal: this.abortController?.signal,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
91
123
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
resolve();
|
|
99
|
-
});
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
clearTimeout(timeoutId);
|
|
126
|
+
reject(new Error(`SSE connection failed: ${response.status} ${response.statusText}`));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
100
129
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
130
|
+
// Check for session ID in headers
|
|
131
|
+
this.sessionId =
|
|
132
|
+
response.headers.get('mcp-session-id') ||
|
|
133
|
+
response.headers.get('Mcp-Session-Id') ||
|
|
134
|
+
undefined;
|
|
135
|
+
|
|
136
|
+
const reader = response.body?.getReader();
|
|
137
|
+
if (!reader) {
|
|
138
|
+
clearTimeout(timeoutId);
|
|
139
|
+
reject(new Error('Failed to get response body reader'));
|
|
140
|
+
return;
|
|
108
141
|
}
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
142
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
143
|
+
// Process the stream in the background
|
|
144
|
+
(async () => {
|
|
145
|
+
let buffer = '';
|
|
146
|
+
const decoder = new TextDecoder();
|
|
147
|
+
let currentEvent: { event?: string; data?: string } = {};
|
|
148
|
+
let isResolved = false;
|
|
149
|
+
|
|
150
|
+
const dispatchEvent = () => {
|
|
151
|
+
if (currentEvent.data) {
|
|
152
|
+
if (currentEvent.event === 'endpoint') {
|
|
153
|
+
this.endpoint = currentEvent.data;
|
|
154
|
+
if (this.endpoint) {
|
|
155
|
+
this.endpoint = new URL(this.endpoint, this.url).href;
|
|
156
|
+
}
|
|
157
|
+
if (!isResolved) {
|
|
158
|
+
isResolved = true;
|
|
159
|
+
clearTimeout(timeoutId);
|
|
160
|
+
resolve();
|
|
161
|
+
}
|
|
162
|
+
} else if (!currentEvent.event || currentEvent.event === 'message') {
|
|
163
|
+
// If we get a message before an endpoint, assume the URL itself is the endpoint
|
|
164
|
+
// (Common in some MCP over SSE implementations like GitHub's)
|
|
165
|
+
if (!this.endpoint) {
|
|
166
|
+
this.endpoint = this.url;
|
|
167
|
+
if (!isResolved) {
|
|
168
|
+
isResolved = true;
|
|
169
|
+
clearTimeout(timeoutId);
|
|
170
|
+
resolve();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (this.onMessageCallback && currentEvent.data) {
|
|
175
|
+
try {
|
|
176
|
+
const message = JSON.parse(currentEvent.data) as MCPResponse;
|
|
177
|
+
this.onMessageCallback(message);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Ignore parse errors
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
currentEvent = {};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
while (true) {
|
|
189
|
+
const { done, value } = await reader.read();
|
|
190
|
+
if (done) {
|
|
191
|
+
// Dispatch any remaining data
|
|
192
|
+
dispatchEvent();
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
buffer += decoder.decode(value, { stream: true });
|
|
197
|
+
const lines = buffer.split(/\r\n|\r|\n/);
|
|
198
|
+
buffer = lines.pop() || '';
|
|
199
|
+
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
if (line.trim() === '') {
|
|
202
|
+
dispatchEvent();
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (line.startsWith('event:')) {
|
|
207
|
+
currentEvent.event = line.substring(6).trim();
|
|
208
|
+
} else if (line.startsWith('data:')) {
|
|
209
|
+
const data = line.substring(5).trim();
|
|
210
|
+
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!isResolved) {
|
|
216
|
+
// If the stream ended before we resolved, but we have a session ID, we can try to resolve
|
|
217
|
+
if (this.sessionId && !this.endpoint) {
|
|
218
|
+
this.endpoint = this.url;
|
|
219
|
+
isResolved = true;
|
|
220
|
+
clearTimeout(timeoutId);
|
|
221
|
+
resolve();
|
|
222
|
+
} else {
|
|
223
|
+
clearTimeout(timeoutId);
|
|
224
|
+
reject(new Error('SSE stream ended before connection established'));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if ((err as Error).name !== 'AbortError' && !isResolved) {
|
|
229
|
+
clearTimeout(timeoutId);
|
|
230
|
+
reject(err);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
} catch (err) {
|
|
235
|
+
clearTimeout(timeoutId);
|
|
236
|
+
reject(err);
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
116
239
|
});
|
|
117
240
|
}
|
|
118
241
|
|
|
@@ -121,17 +244,87 @@ class SSETransport implements MCPTransport {
|
|
|
121
244
|
throw new Error('SSE transport not connected or endpoint not received');
|
|
122
245
|
}
|
|
123
246
|
|
|
247
|
+
const headers = {
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
...this.headers,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
if (this.sessionId) {
|
|
253
|
+
headers['mcp-session-id'] = this.sessionId;
|
|
254
|
+
}
|
|
255
|
+
|
|
124
256
|
const response = await fetch(this.endpoint, {
|
|
125
257
|
method: 'POST',
|
|
126
|
-
headers
|
|
127
|
-
'Content-Type': 'application/json',
|
|
128
|
-
...this.headers,
|
|
129
|
-
},
|
|
258
|
+
headers,
|
|
130
259
|
body: JSON.stringify(message),
|
|
131
260
|
});
|
|
132
261
|
|
|
133
262
|
if (!response.ok) {
|
|
134
|
-
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Failed to send message to MCP server: ${response.status} ${response.statusText}${
|
|
266
|
+
text ? ` - ${text}` : ''
|
|
267
|
+
}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Some MCP servers (like GitHub) send the response directly in the POST response as SSE
|
|
272
|
+
const contentType = response.headers.get('content-type');
|
|
273
|
+
if (contentType?.includes('text/event-stream')) {
|
|
274
|
+
const reader = response.body?.getReader();
|
|
275
|
+
if (reader) {
|
|
276
|
+
(async () => {
|
|
277
|
+
let buffer = '';
|
|
278
|
+
const decoder = new TextDecoder();
|
|
279
|
+
let currentEvent: { event?: string; data?: string } = {};
|
|
280
|
+
|
|
281
|
+
const dispatchEvent = () => {
|
|
282
|
+
if (
|
|
283
|
+
this.onMessageCallback &&
|
|
284
|
+
currentEvent.data &&
|
|
285
|
+
(!currentEvent.event || currentEvent.event === 'message')
|
|
286
|
+
) {
|
|
287
|
+
try {
|
|
288
|
+
const message = JSON.parse(currentEvent.data) as MCPResponse;
|
|
289
|
+
this.onMessageCallback(message);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
// Ignore parse errors
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
currentEvent = {};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
while (true) {
|
|
299
|
+
const { done, value } = await reader.read();
|
|
300
|
+
if (done) {
|
|
301
|
+
dispatchEvent();
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
buffer += decoder.decode(value, { stream: true });
|
|
306
|
+
const lines = buffer.split(/\r\n|\r|\n/);
|
|
307
|
+
buffer = lines.pop() || '';
|
|
308
|
+
|
|
309
|
+
for (const line of lines) {
|
|
310
|
+
if (line.trim() === '') {
|
|
311
|
+
dispatchEvent();
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (line.startsWith('event:')) {
|
|
316
|
+
currentEvent.event = line.substring(6).trim();
|
|
317
|
+
} else if (line.startsWith('data:')) {
|
|
318
|
+
const data = line.substring(5).trim();
|
|
319
|
+
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
// Ignore stream errors
|
|
325
|
+
}
|
|
326
|
+
})();
|
|
327
|
+
}
|
|
135
328
|
}
|
|
136
329
|
}
|
|
137
330
|
|
|
@@ -140,7 +333,7 @@ class SSETransport implements MCPTransport {
|
|
|
140
333
|
}
|
|
141
334
|
|
|
142
335
|
close(): void {
|
|
143
|
-
this.
|
|
336
|
+
this.abortController?.abort();
|
|
144
337
|
}
|
|
145
338
|
}
|
|
146
339
|
|
|
@@ -154,14 +347,14 @@ export class MCPClient {
|
|
|
154
347
|
transportOrCommand: MCPTransport | string,
|
|
155
348
|
timeoutOrArgs: number | string[] = [],
|
|
156
349
|
env: Record<string, string> = {},
|
|
157
|
-
timeout =
|
|
350
|
+
timeout = 60000
|
|
158
351
|
) {
|
|
159
352
|
if (typeof transportOrCommand === 'string') {
|
|
160
353
|
this.transport = new StdConfigTransport(transportOrCommand, timeoutOrArgs as string[], env);
|
|
161
354
|
this.timeout = timeout;
|
|
162
355
|
} else {
|
|
163
356
|
this.transport = transportOrCommand;
|
|
164
|
-
this.timeout = (timeoutOrArgs as number) ||
|
|
357
|
+
this.timeout = (timeoutOrArgs as number) || 60000;
|
|
165
358
|
}
|
|
166
359
|
|
|
167
360
|
this.transport.onMessage((response) => {
|
|
@@ -179,7 +372,7 @@ export class MCPClient {
|
|
|
179
372
|
command: string,
|
|
180
373
|
args: string[] = [],
|
|
181
374
|
env: Record<string, string> = {},
|
|
182
|
-
timeout =
|
|
375
|
+
timeout = 60000
|
|
183
376
|
): Promise<MCPClient> {
|
|
184
377
|
const transport = new StdConfigTransport(command, args, env);
|
|
185
378
|
return new MCPClient(transport, timeout);
|
|
@@ -188,10 +381,10 @@ export class MCPClient {
|
|
|
188
381
|
static async createRemote(
|
|
189
382
|
url: string,
|
|
190
383
|
headers: Record<string, string> = {},
|
|
191
|
-
timeout =
|
|
384
|
+
timeout = 60000
|
|
192
385
|
): Promise<MCPClient> {
|
|
193
386
|
const transport = new SSETransport(url, headers);
|
|
194
|
-
await transport.connect();
|
|
387
|
+
await transport.connect(timeout);
|
|
195
388
|
return new MCPClient(transport, timeout);
|
|
196
389
|
}
|
|
197
390
|
|
|
@@ -10,6 +10,9 @@ export interface MCPServerConfig {
|
|
|
10
10
|
env?: Record<string, string>;
|
|
11
11
|
url?: string;
|
|
12
12
|
headers?: Record<string, string>;
|
|
13
|
+
oauth?: {
|
|
14
|
+
scope?: string;
|
|
15
|
+
};
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export class MCPManager {
|
|
@@ -70,10 +73,47 @@ export class MCPManager {
|
|
|
70
73
|
try {
|
|
71
74
|
if (config.type === 'remote') {
|
|
72
75
|
if (!config.url) throw new Error('Remote MCP server missing URL');
|
|
73
|
-
|
|
76
|
+
|
|
77
|
+
const headers = { ...(config.headers || {}) };
|
|
78
|
+
|
|
79
|
+
if (config.oauth) {
|
|
80
|
+
const { AuthManager } = await import('../utils/auth-manager');
|
|
81
|
+
const auth = AuthManager.load();
|
|
82
|
+
const token = auth.mcp_tokens?.[config.name]?.access_token;
|
|
83
|
+
|
|
84
|
+
if (!token) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`MCP server ${config.name} requires OAuth. Please run "keystone mcp login ${config.name}" first.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
headers.Authorization = `Bearer ${token}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
client = await MCPClient.createRemote(config.url, headers);
|
|
74
94
|
} else {
|
|
75
95
|
if (!config.command) throw new Error('Local MCP server missing command');
|
|
76
|
-
|
|
96
|
+
|
|
97
|
+
const env = { ...(config.env || {}) };
|
|
98
|
+
|
|
99
|
+
if (config.oauth) {
|
|
100
|
+
const { AuthManager } = await import('../utils/auth-manager');
|
|
101
|
+
const auth = AuthManager.load();
|
|
102
|
+
const token = auth.mcp_tokens?.[config.name]?.access_token;
|
|
103
|
+
|
|
104
|
+
if (!token) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`MCP server ${config.name} requires OAuth. Please run "keystone mcp login ${config.name}" first.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Pass token to the local proxy via environment variables
|
|
111
|
+
// Most proxies expect AUTHORIZATION or MCP_TOKEN
|
|
112
|
+
env.AUTHORIZATION = `Bearer ${token}`;
|
|
113
|
+
env.MCP_TOKEN = token;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
client = await MCPClient.createLocal(config.command, config.args || [], env);
|
|
77
117
|
}
|
|
78
118
|
|
|
79
119
|
await client.initialize();
|
|
@@ -374,7 +374,7 @@ describe('step-executor', () => {
|
|
|
374
374
|
const step: WorkflowStep = {
|
|
375
375
|
id: 'w1',
|
|
376
376
|
type: 'workflow',
|
|
377
|
-
|
|
377
|
+
path: 'child.yaml',
|
|
378
378
|
};
|
|
379
379
|
// @ts-ignore
|
|
380
380
|
const executeWorkflowFn = mock(() =>
|
|
@@ -392,7 +392,7 @@ describe('step-executor', () => {
|
|
|
392
392
|
const step: WorkflowStep = {
|
|
393
393
|
id: 'w1',
|
|
394
394
|
type: 'workflow',
|
|
395
|
-
|
|
395
|
+
path: 'child.yaml',
|
|
396
396
|
};
|
|
397
397
|
const result = await executeStep(step, context);
|
|
398
398
|
expect(result.status).toBe('failed');
|
|
@@ -42,7 +42,8 @@ export async function executeStep(
|
|
|
42
42
|
context: ExpressionContext,
|
|
43
43
|
logger: Logger = console,
|
|
44
44
|
executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
|
|
45
|
-
mcpManager?: MCPManager
|
|
45
|
+
mcpManager?: MCPManager,
|
|
46
|
+
workflowDir?: string
|
|
46
47
|
): Promise<StepResult> {
|
|
47
48
|
try {
|
|
48
49
|
let result: StepResult;
|
|
@@ -66,9 +67,10 @@ export async function executeStep(
|
|
|
66
67
|
result = await executeLlmStep(
|
|
67
68
|
step,
|
|
68
69
|
context,
|
|
69
|
-
(s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager),
|
|
70
|
+
(s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager, workflowDir),
|
|
70
71
|
logger,
|
|
71
|
-
mcpManager
|
|
72
|
+
mcpManager,
|
|
73
|
+
workflowDir
|
|
72
74
|
);
|
|
73
75
|
break;
|
|
74
76
|
case 'workflow':
|
|
@@ -180,6 +182,13 @@ async function executeFileStep(
|
|
|
180
182
|
throw new Error('Content is required for write operation');
|
|
181
183
|
}
|
|
182
184
|
const content = ExpressionEvaluator.evaluateString(step.content, context);
|
|
185
|
+
|
|
186
|
+
// Ensure parent directory exists
|
|
187
|
+
const fs = await import('node:fs/promises');
|
|
188
|
+
const pathModule = await import('node:path');
|
|
189
|
+
const dir = pathModule.dirname(path);
|
|
190
|
+
await fs.mkdir(dir, { recursive: true });
|
|
191
|
+
|
|
183
192
|
const bytes = await Bun.write(path, content);
|
|
184
193
|
return {
|
|
185
194
|
output: { path, bytes },
|
|
@@ -193,8 +202,13 @@ async function executeFileStep(
|
|
|
193
202
|
}
|
|
194
203
|
const content = ExpressionEvaluator.evaluateString(step.content, context);
|
|
195
204
|
|
|
196
|
-
//
|
|
205
|
+
// Ensure parent directory exists
|
|
197
206
|
const fs = await import('node:fs/promises');
|
|
207
|
+
const pathModule = await import('node:path');
|
|
208
|
+
const dir = pathModule.dirname(path);
|
|
209
|
+
await fs.mkdir(dir, { recursive: true });
|
|
210
|
+
|
|
211
|
+
// Use Node.js fs for efficient append operation
|
|
198
212
|
await fs.appendFile(path, content, 'utf-8');
|
|
199
213
|
|
|
200
214
|
return {
|
|
@@ -310,10 +324,10 @@ async function executeHumanStep(
|
|
|
310
324
|
try {
|
|
311
325
|
if (step.inputType === 'confirm') {
|
|
312
326
|
logger.log(`\n❓ ${message}`);
|
|
313
|
-
|
|
314
|
-
|
|
327
|
+
const answer = await rl.question('Confirm? (Y/n): ');
|
|
328
|
+
const isConfirmed = answer.toLowerCase() !== 'n';
|
|
315
329
|
return {
|
|
316
|
-
output:
|
|
330
|
+
output: isConfirmed,
|
|
317
331
|
status: 'success',
|
|
318
332
|
};
|
|
319
333
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
2
3
|
import { WorkflowDb } from '../db/workflow-db.ts';
|
|
3
4
|
import type { ExpressionContext } from '../expression/evaluator.ts';
|
|
4
5
|
import { ExpressionEvaluator } from '../expression/evaluator.ts';
|
|
@@ -46,6 +47,7 @@ export interface RunOptions {
|
|
|
46
47
|
logger?: Logger;
|
|
47
48
|
mcpManager?: MCPManager;
|
|
48
49
|
preventExit?: boolean; // Defaults to false
|
|
50
|
+
workflowDir?: string;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export interface StepContext {
|
|
@@ -456,7 +458,8 @@ export class WorkflowRunner {
|
|
|
456
458
|
context,
|
|
457
459
|
this.logger,
|
|
458
460
|
this.executeSubWorkflow.bind(this),
|
|
459
|
-
this.mcpManager
|
|
461
|
+
this.mcpManager,
|
|
462
|
+
this.options.workflowDir
|
|
460
463
|
);
|
|
461
464
|
if (result.status === 'failed') {
|
|
462
465
|
throw new Error(result.error || 'Step failed');
|
|
@@ -700,6 +703,7 @@ export class WorkflowRunner {
|
|
|
700
703
|
): Promise<StepResult> {
|
|
701
704
|
const workflowPath = WorkflowRegistry.resolvePath(step.path);
|
|
702
705
|
const workflow = WorkflowParser.loadWorkflow(workflowPath);
|
|
706
|
+
const subWorkflowDir = dirname(workflowPath);
|
|
703
707
|
|
|
704
708
|
// Evaluate inputs for the sub-workflow
|
|
705
709
|
const inputs: Record<string, unknown> = {};
|
|
@@ -716,6 +720,7 @@ export class WorkflowRunner {
|
|
|
716
720
|
dbPath: this.db.dbPath,
|
|
717
721
|
logger: this.logger,
|
|
718
722
|
mcpManager: this.mcpManager,
|
|
723
|
+
workflowDir: subWorkflowDir,
|
|
719
724
|
});
|
|
720
725
|
|
|
721
726
|
try {
|
|
@@ -874,7 +879,9 @@ export class WorkflowRunner {
|
|
|
874
879
|
} finally {
|
|
875
880
|
this.removeSignalHandlers();
|
|
876
881
|
await this.runFinally();
|
|
877
|
-
|
|
882
|
+
if (!this.options.mcpManager) {
|
|
883
|
+
await this.mcpManager.stopAll();
|
|
884
|
+
}
|
|
878
885
|
this.db.close();
|
|
879
886
|
}
|
|
880
887
|
}
|
|
@@ -149,4 +149,90 @@ describe('AuthManager', () => {
|
|
|
149
149
|
consoleSpy.mockRestore();
|
|
150
150
|
});
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
describe('Device Login', () => {
|
|
154
|
+
it('initGitHubDeviceLogin should return device code data', async () => {
|
|
155
|
+
const mockFetch = mock(() =>
|
|
156
|
+
Promise.resolve(
|
|
157
|
+
new Response(
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
device_code: 'dev_code',
|
|
160
|
+
user_code: 'USER-CODE',
|
|
161
|
+
verification_uri: 'https://github.com/login/device',
|
|
162
|
+
expires_in: 900,
|
|
163
|
+
interval: 5,
|
|
164
|
+
}),
|
|
165
|
+
{ status: 200 }
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
// @ts-ignore
|
|
170
|
+
global.fetch = mockFetch;
|
|
171
|
+
|
|
172
|
+
const result = await AuthManager.initGitHubDeviceLogin();
|
|
173
|
+
expect(result.device_code).toBe('dev_code');
|
|
174
|
+
expect(result.user_code).toBe('USER-CODE');
|
|
175
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('pollGitHubDeviceLogin should return token when successful', async () => {
|
|
179
|
+
let callCount = 0;
|
|
180
|
+
const mockFetch = mock(() => {
|
|
181
|
+
callCount++;
|
|
182
|
+
if (callCount === 1) {
|
|
183
|
+
return Promise.resolve(
|
|
184
|
+
new Response(
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
error: 'authorization_pending',
|
|
187
|
+
}),
|
|
188
|
+
{ status: 200 }
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return Promise.resolve(
|
|
193
|
+
new Response(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
access_token: 'gh_access_token',
|
|
196
|
+
}),
|
|
197
|
+
{ status: 200 }
|
|
198
|
+
)
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
// @ts-ignore
|
|
202
|
+
global.fetch = mockFetch;
|
|
203
|
+
|
|
204
|
+
// Mock setTimeout to resolve immediately
|
|
205
|
+
const originalTimeout = global.setTimeout;
|
|
206
|
+
// @ts-ignore
|
|
207
|
+
global.setTimeout = (fn) => fn();
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const token = await AuthManager.pollGitHubDeviceLogin('dev_code');
|
|
211
|
+
expect(token).toBe('gh_access_token');
|
|
212
|
+
expect(callCount).toBe(2);
|
|
213
|
+
} finally {
|
|
214
|
+
global.setTimeout = originalTimeout;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('pollGitHubDeviceLogin should throw on other errors', async () => {
|
|
219
|
+
const mockFetch = mock(() =>
|
|
220
|
+
Promise.resolve(
|
|
221
|
+
new Response(
|
|
222
|
+
JSON.stringify({
|
|
223
|
+
error: 'expired_token',
|
|
224
|
+
error_description: 'The device code has expired',
|
|
225
|
+
}),
|
|
226
|
+
{ status: 200 }
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
// @ts-ignore
|
|
231
|
+
global.fetch = mockFetch;
|
|
232
|
+
|
|
233
|
+
await expect(AuthManager.pollGitHubDeviceLogin('dev_code')).rejects.toThrow(
|
|
234
|
+
'The device code has expired'
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
152
238
|
});
|