@aion0/forge 0.5.20 → 0.5.22

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 (40) hide show
  1. package/.forge/agent-context.json +1 -1
  2. package/RELEASE_NOTES.md +32 -6
  3. package/app/api/code/route.ts +10 -4
  4. package/app/api/plugins/route.ts +75 -0
  5. package/components/Dashboard.tsx +1 -0
  6. package/components/PipelineEditor.tsx +135 -9
  7. package/components/PluginsPanel.tsx +472 -0
  8. package/components/ProjectDetail.tsx +36 -98
  9. package/components/SessionView.tsx +4 -4
  10. package/components/SettingsModal.tsx +160 -66
  11. package/components/SkillsPanel.tsx +14 -5
  12. package/components/TerminalLauncher.tsx +398 -0
  13. package/components/WebTerminal.tsx +84 -84
  14. package/components/WorkspaceView.tsx +371 -87
  15. package/lib/agents/index.ts +7 -4
  16. package/lib/builtin-plugins/docker.yaml +70 -0
  17. package/lib/builtin-plugins/http.yaml +66 -0
  18. package/lib/builtin-plugins/jenkins.yaml +92 -0
  19. package/lib/builtin-plugins/llm-vision.yaml +85 -0
  20. package/lib/builtin-plugins/playwright.yaml +111 -0
  21. package/lib/builtin-plugins/shell-command.yaml +60 -0
  22. package/lib/builtin-plugins/slack.yaml +48 -0
  23. package/lib/builtin-plugins/webhook.yaml +56 -0
  24. package/lib/forge-mcp-server.ts +116 -2
  25. package/lib/pipeline.ts +62 -5
  26. package/lib/plugins/executor.ts +347 -0
  27. package/lib/plugins/registry.ts +228 -0
  28. package/lib/plugins/types.ts +103 -0
  29. package/lib/project-sessions.ts +7 -2
  30. package/lib/session-utils.ts +7 -3
  31. package/lib/terminal-standalone.ts +6 -34
  32. package/lib/workspace/agent-worker.ts +1 -1
  33. package/lib/workspace/orchestrator.ts +414 -136
  34. package/lib/workspace/presets.ts +5 -3
  35. package/lib/workspace/session-monitor.ts +14 -10
  36. package/lib/workspace/types.ts +3 -1
  37. package/lib/workspace-standalone.ts +38 -21
  38. package/next-env.d.ts +1 -1
  39. package/package.json +1 -1
  40. package/qa/.forge/agent-context.json +1 -1
@@ -288,6 +288,119 @@ function createForgeMcpServer(sessionId: string): McpServer {
288
288
  }
289
289
  );
290
290
 
291
+ // ── trigger_pipeline ──────────────────────────
292
+ server.tool(
293
+ 'trigger_pipeline',
294
+ 'Trigger a pipeline workflow. Lists available workflows if called without arguments.',
295
+ {
296
+ workflow: z.string().optional().describe('Workflow name to trigger. Omit to list available workflows.'),
297
+ input: z.record(z.string(), z.string()).optional().describe('Input variables for the pipeline (e.g., { project: "my-app" })'),
298
+ },
299
+ async (params) => {
300
+ try {
301
+ if (!params.workflow) {
302
+ // List available workflows
303
+ const { listWorkflows } = await import('./pipeline');
304
+ const workflows = listWorkflows();
305
+ if (workflows.length === 0) {
306
+ return { content: [{ type: 'text', text: 'No workflows found. Create .yaml files in ~/.forge/flows/' }] };
307
+ }
308
+ const list = workflows.map((w: any) => `• ${w.name}${w.description ? ' — ' + w.description : ''} (${Object.keys(w.nodes || {}).length} nodes)`).join('\n');
309
+ return { content: [{ type: 'text', text: `Available workflows:\n${list}` }] };
310
+ }
311
+
312
+ const { startPipeline } = await import('./pipeline');
313
+ const pipeline = startPipeline(params.workflow, (params.input || {}) as Record<string, string>);
314
+ return { content: [{ type: 'text', text: `Pipeline started: ${pipeline.id} (workflow: ${params.workflow}, status: ${pipeline.status})` }] };
315
+ } catch (err: any) {
316
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
317
+ }
318
+ }
319
+ );
320
+
321
+ // ── run_plugin ──────────────────────────────────
322
+ server.tool(
323
+ 'run_plugin',
324
+ 'Run an installed plugin action directly. Lists installed plugins if called without arguments.',
325
+ {
326
+ plugin: z.string().optional().describe('Plugin ID (e.g., "jenkins", "shell-command", "docker"). Omit to list installed plugins.'),
327
+ action: z.string().optional().describe('Action name (e.g., "trigger", "run", "build"). Uses default action if omitted.'),
328
+ params: z.record(z.string(), z.string()).optional().describe('Parameters for the action. Keys matching plugin config fields will override config values.'),
329
+ wait: z.boolean().optional().describe('Auto-run "wait" action after main action (for async operations like Jenkins builds)'),
330
+ },
331
+ async (params) => {
332
+ try {
333
+ const { listInstalledPlugins, getInstalledPlugin } = await import('./plugins/registry');
334
+
335
+ if (!params.plugin) {
336
+ const installed = listInstalledPlugins();
337
+ if (installed.length === 0) {
338
+ return { content: [{ type: 'text', text: 'No plugins installed. Install from the Plugins page.' }] };
339
+ }
340
+ const list = installed.map((p: any) => {
341
+ const actions = Object.keys(p.definition.actions).join(', ');
342
+ return `• ${p.definition.icon} ${p.id} — ${p.definition.description || p.definition.name}\n actions: ${actions}`;
343
+ }).join('\n');
344
+ return { content: [{ type: 'text', text: `Installed plugins:\n${list}` }] };
345
+ }
346
+
347
+ const inst = getInstalledPlugin(params.plugin);
348
+ if (!inst) return { content: [{ type: 'text', text: `Plugin "${params.plugin}" not installed.` }] };
349
+ if (!inst.enabled) return { content: [{ type: 'text', text: `Plugin "${params.plugin}" is disabled.` }] };
350
+
351
+ const { executePluginWithWait } = await import('./plugins/executor');
352
+ const actionName = params.action || inst.definition.defaultAction || Object.keys(inst.definition.actions)[0];
353
+
354
+ if (!inst.definition.actions[actionName]) {
355
+ const available = Object.keys(inst.definition.actions).join(', ');
356
+ return { content: [{ type: 'text', text: `Action "${actionName}" not found. Available: ${available}` }] };
357
+ }
358
+
359
+ const result = await executePluginWithWait(inst, actionName, params.params || {}, params.wait || false);
360
+
361
+ const output = JSON.stringify(result.output, null, 2);
362
+ const status = result.ok ? 'OK' : 'FAILED';
363
+ const duration = result.duration ? ` (${result.duration}ms)` : '';
364
+ const error = result.error ? `\nError: ${result.error}` : '';
365
+
366
+ return { content: [{ type: 'text', text: `${status}${duration}${error}\n${output}` }] };
367
+ } catch (err: any) {
368
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
369
+ }
370
+ }
371
+ );
372
+
373
+ // ── get_pipeline_status ────────────────────────
374
+ server.tool(
375
+ 'get_pipeline_status',
376
+ 'Check the status and results of a running or completed pipeline.',
377
+ {
378
+ pipeline_id: z.string().describe('Pipeline ID to check'),
379
+ },
380
+ async (params) => {
381
+ try {
382
+ const { getPipeline } = await import('./pipeline');
383
+ const pipeline = getPipeline(params.pipeline_id);
384
+ if (!pipeline) return { content: [{ type: 'text', text: `Pipeline "${params.pipeline_id}" not found.` }] };
385
+
386
+ const nodes = Object.entries(pipeline.nodes).map(([id, n]: [string, any]) => {
387
+ let line = ` ${id}: ${n.status}`;
388
+ if (n.error) line += ` — ${n.error}`;
389
+ if (n.outputs && Object.keys(n.outputs).length > 0) {
390
+ for (const [k, v] of Object.entries(n.outputs)) {
391
+ line += `\n ${k}: ${String(v).slice(0, 200)}`;
392
+ }
393
+ }
394
+ return line;
395
+ }).join('\n');
396
+
397
+ return { content: [{ type: 'text', text: `Pipeline ${pipeline.id} [${pipeline.status}] (${pipeline.workflowName})\n${nodes}` }] };
398
+ } catch (err: any) {
399
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
400
+ }
401
+ }
402
+ );
403
+
291
404
  return server;
292
405
  }
293
406
 
@@ -325,8 +438,9 @@ export async function startMcpServer(port: number): Promise<void> {
325
438
  // Each session gets its own MCP server with context
326
439
  const server = createForgeMcpServer(sessionId);
327
440
  await server.connect(transport);
328
- const agentLabel = workspaceId ? (getOrch(workspaceId)?.getSnapshot()?.agents?.find((a: any) => a.id === agentId)?.label || agentId) : 'unknown';
329
- console.log(`[forge-mcp] Client connected: ${agentLabel} (ws=${workspaceId.slice(0, 8)}, session=${sessionId})`);
441
+ let agentLabel = 'unknown';
442
+ try { agentLabel = workspaceId ? (getOrch(workspaceId)?.getSnapshot()?.agents?.find((a: any) => a.id === agentId)?.label || agentId) : 'unknown'; } catch {}
443
+ console.log(`[forge-mcp] Client connected: ${agentLabel} (ws=${workspaceId?.slice(0, 8) || '?'}, session=${sessionId})`);
330
444
  return;
331
445
  }
332
446
 
package/lib/pipeline.ts CHANGED
@@ -31,11 +31,16 @@ export interface WorkflowNode {
31
31
  id: string;
32
32
  project: string;
33
33
  prompt: string;
34
- mode?: 'claude' | 'shell'; // default: 'claude' (agent -p), 'shell' runs raw shell command
34
+ mode?: 'claude' | 'shell' | 'plugin'; // default: 'claude', 'shell' runs command, 'plugin' runs plugin action
35
35
  agent?: string; // agent ID (default: from settings)
36
36
  branch?: string; // auto checkout this branch before running (supports templates)
37
+ // Plugin mode fields
38
+ plugin?: string; // plugin ID (e.g., 'jenkins', 'docker')
39
+ pluginAction?: string; // action name (e.g., 'trigger', 'build'), defaults to plugin's defaultAction
40
+ pluginParams?: Record<string, any>; // per-use parameters
41
+ pluginWait?: boolean; // auto-run 'wait' action after main action
37
42
  dependsOn: string[];
38
- outputs: { name: string; extract: 'result' | 'git_diff' | 'stdout' }[];
43
+ outputs: { name: string; extract: 'result' | 'git_diff' | 'stdout' | 'plugin' }[];
39
44
  routes: { condition: string; next: string }[];
40
45
  maxIterations: number;
41
46
  }
@@ -302,9 +307,13 @@ function parseWorkflow(raw: string): Workflow {
302
307
  id,
303
308
  project: n.project || '',
304
309
  prompt: n.prompt || '',
305
- mode: n.mode || 'claude',
310
+ mode: n.mode || (n.plugin ? 'plugin' : 'claude'),
306
311
  agent: n.agent || undefined,
307
312
  branch: n.branch || undefined,
313
+ plugin: n.plugin || undefined,
314
+ pluginAction: n.plugin_action || n.pluginAction || undefined,
315
+ pluginParams: n.plugin_params || n.pluginParams || n.params || undefined,
316
+ pluginWait: n.plugin_wait || n.pluginWait || n.wait || false,
308
317
  dependsOn: n.depends_on || n.dependsOn || [],
309
318
  outputs: (n.outputs || []).map((o: any) => ({
310
319
  name: o.name,
@@ -1024,7 +1033,7 @@ export function cancelPipeline(id: string): boolean {
1024
1033
 
1025
1034
  // ─── Node Scheduling ──────────────────────────────────────
1026
1035
 
1027
- function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
1036
+ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
1028
1037
  const ctx = { input: pipeline.input, vars: pipeline.vars, nodes: pipeline.nodes };
1029
1038
 
1030
1039
  for (const nodeId of pipeline.nodeOrder) {
@@ -1089,7 +1098,55 @@ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
1089
1098
  }
1090
1099
  }
1091
1100
 
1092
- // Create task mode: 'shell' runs raw command, 'claude' runs claude -p
1101
+ // ── Plugin mode: execute plugin action directly ──
1102
+ if (nodeDef.mode === 'plugin' && nodeDef.plugin) {
1103
+ nodeState.status = 'running';
1104
+ nodeState.startedAt = new Date().toISOString();
1105
+ savePipeline(pipeline);
1106
+ notifyStep(pipeline, nodeId, 'running');
1107
+
1108
+ try {
1109
+ const { getInstalledPlugin } = await import('./plugins/registry');
1110
+ const { executePluginWithWait } = await import('./plugins/executor');
1111
+
1112
+ const inst = getInstalledPlugin(nodeDef.plugin);
1113
+ if (!inst) throw new Error(`Plugin "${nodeDef.plugin}" not installed`);
1114
+ if (!inst.enabled) throw new Error(`Plugin "${nodeDef.plugin}" is disabled`);
1115
+
1116
+ // Resolve template params
1117
+ const resolvedParams: Record<string, any> = {};
1118
+ for (const [k, v] of Object.entries(nodeDef.pluginParams || {})) {
1119
+ resolvedParams[k] = typeof v === 'string' ? resolveTemplate(v, ctx) : v;
1120
+ }
1121
+
1122
+ const actionName = nodeDef.pluginAction || inst.definition.defaultAction || Object.keys(inst.definition.actions)[0];
1123
+ const result = await executePluginWithWait(inst, actionName, resolvedParams, nodeDef.pluginWait);
1124
+
1125
+ if (result.ok) {
1126
+ nodeState.status = 'done';
1127
+ nodeState.completedAt = new Date().toISOString();
1128
+ // Store plugin outputs
1129
+ for (const [name, value] of Object.entries(result.output)) {
1130
+ nodeState.outputs[name] = typeof value === 'string' ? value : JSON.stringify(value);
1131
+ }
1132
+ savePipeline(pipeline);
1133
+ notifyStep(pipeline, nodeId, 'done');
1134
+ console.log(`[pipeline] Plugin ${nodeDef.plugin}.${actionName}: done (${result.duration}ms)`);
1135
+ } else {
1136
+ throw new Error(result.error || 'Plugin action failed');
1137
+ }
1138
+ } catch (err: any) {
1139
+ nodeState.status = 'failed';
1140
+ nodeState.error = err.message;
1141
+ nodeState.completedAt = new Date().toISOString();
1142
+ savePipeline(pipeline);
1143
+ notifyStep(pipeline, nodeId, 'failed', err.message);
1144
+ console.error(`[pipeline] Plugin ${nodeDef.plugin}: failed — ${err.message}`);
1145
+ }
1146
+ continue;
1147
+ }
1148
+
1149
+ // ── Shell/Agent mode: create task ──
1093
1150
  const taskMode = nodeDef.mode === 'shell' ? 'shell' : 'prompt';
1094
1151
  const task = createTask({
1095
1152
  projectName: projectInfo.name,
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Plugin Executor — runs plugin actions (http, poll, shell, script).
3
+ *
4
+ * Resolves templates ({{config.x}}, {{params.x}}) and executes the action.
5
+ */
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import type { PluginAction, PluginActionResult, InstalledPlugin } from './types';
9
+
10
+ // ─── Template Resolution ─────────────────────────────────
11
+
12
+ function resolveTemplate(template: string, ctx: Record<string, any>): string {
13
+ return template.replace(/\{\{(\w+)\.(\w+)\}\}/g, (_, scope, key) => {
14
+ return ctx[scope]?.[key] ?? '';
15
+ }).replace(/\{\{(\w+)\s*\|\s*json\}\}/g, (_, scope) => {
16
+ return JSON.stringify(ctx[scope] || {});
17
+ });
18
+ }
19
+
20
+ function resolveObject(obj: Record<string, string> | undefined, ctx: Record<string, any>): Record<string, string> {
21
+ if (!obj) return {};
22
+ const result: Record<string, string> = {};
23
+ for (const [k, v] of Object.entries(obj)) {
24
+ result[k] = resolveTemplate(v, ctx);
25
+ }
26
+ return result;
27
+ }
28
+
29
+ // ─── JSONPath-like extraction ────────────────────────────
30
+
31
+ function extractValue(data: any, path: string): any {
32
+ if (path === '$body' || path === '$stdout') return typeof data === 'string' ? data : JSON.stringify(data);
33
+ if (path.startsWith('$.')) {
34
+ const keys = path.slice(2).split('.');
35
+ let current = data;
36
+ for (const key of keys) {
37
+ if (current == null) return undefined;
38
+ current = current[key];
39
+ }
40
+ return current;
41
+ }
42
+ return data;
43
+ }
44
+
45
+ function extractOutputs(data: any, outputSpec: Record<string, string> | undefined): Record<string, any> {
46
+ if (!outputSpec) return { result: data };
47
+ const result: Record<string, any> = {};
48
+ for (const [name, path] of Object.entries(outputSpec)) {
49
+ result[name] = extractValue(data, path);
50
+ }
51
+ return result;
52
+ }
53
+
54
+ // ─── Executors ───────────────────────────────────────────
55
+
56
+ async function executeHttp(action: PluginAction, ctx: Record<string, any>): Promise<PluginActionResult> {
57
+ const url = resolveTemplate(action.url || '', ctx);
58
+ const method = (action.method || 'GET').toUpperCase();
59
+ const headers = resolveObject(action.headers, ctx);
60
+ const body = action.body ? resolveTemplate(action.body, ctx) : undefined;
61
+
62
+ const startTime = Date.now();
63
+ try {
64
+ const res = await fetch(url, {
65
+ method,
66
+ headers: { 'Content-Type': 'application/json', ...headers },
67
+ body: method !== 'GET' ? body : undefined,
68
+ });
69
+
70
+ let data: any;
71
+ const contentType = res.headers.get('content-type') || '';
72
+ if (contentType.includes('json')) {
73
+ data = await res.json();
74
+ } else {
75
+ data = await res.text();
76
+ }
77
+
78
+ return {
79
+ ok: res.ok,
80
+ output: extractOutputs(data, action.output),
81
+ rawResponse: typeof data === 'string' ? data : JSON.stringify(data),
82
+ duration: Date.now() - startTime,
83
+ };
84
+ } catch (err: any) {
85
+ return { ok: false, output: {}, error: err.message, duration: Date.now() - startTime };
86
+ }
87
+ }
88
+
89
+ async function executePoll(action: PluginAction, ctx: Record<string, any>): Promise<PluginActionResult> {
90
+ const url = resolveTemplate(action.url || '', ctx);
91
+ const headers = resolveObject(action.headers, ctx);
92
+ const interval = (action.interval || 30) * 1000;
93
+ const timeout = (action.timeout || 1800) * 1000;
94
+ const untilExpr = action.until || '$.result != null';
95
+
96
+ const startTime = Date.now();
97
+
98
+ while (Date.now() - startTime < timeout) {
99
+ try {
100
+ const res = await fetch(url, { headers });
101
+ const data = await res.json();
102
+
103
+ // Evaluate condition
104
+ const conditionMet = evaluateCondition(data, untilExpr);
105
+ if (conditionMet) {
106
+ return {
107
+ ok: true,
108
+ output: extractOutputs(data, action.output),
109
+ rawResponse: JSON.stringify(data),
110
+ duration: Date.now() - startTime,
111
+ };
112
+ }
113
+ } catch {}
114
+
115
+ // Wait before next poll
116
+ await new Promise(r => setTimeout(r, interval));
117
+ }
118
+
119
+ return { ok: false, output: {}, error: 'Poll timeout', duration: Date.now() - startTime };
120
+ }
121
+
122
+ function evaluateCondition(data: any, expr: string): boolean {
123
+ // Simple condition parser: "$.field != null", "$.field == value"
124
+ const match = expr.match(/^(\$\.[.\w]+)\s*(==|!=|>|<)\s*(.+)$/);
125
+ if (!match) return false;
126
+ const [, path, op, expected] = match;
127
+ const actual = extractValue(data, path);
128
+ const exp = expected === 'null' ? null : expected === 'true' ? true : expected === 'false' ? false : expected;
129
+
130
+ switch (op) {
131
+ case '==': return actual == exp;
132
+ case '!=': return actual != exp;
133
+ case '>': return Number(actual) > Number(exp);
134
+ case '<': return Number(actual) < Number(exp);
135
+ default: return false;
136
+ }
137
+ }
138
+
139
+ async function executeShell(action: PluginAction, ctx: Record<string, any>): Promise<PluginActionResult> {
140
+ const command = resolveTemplate(action.command || '', ctx);
141
+ const rawCwd = action.cwd ? resolveTemplate(action.cwd, ctx) : '';
142
+ const cwd = rawCwd || undefined;
143
+
144
+ const startTime = Date.now();
145
+ const timeout = (action.timeout || 300) * 1000;
146
+
147
+ return new Promise((resolve) => {
148
+ let resolved = false;
149
+ const done = (result: PluginActionResult) => {
150
+ if (resolved) return;
151
+ resolved = true;
152
+ resolve(result);
153
+ };
154
+
155
+ try {
156
+ // Use spawn with detached: true so the child gets its own process group.
157
+ // This prevents crashes/signals in the child (e.g., Playwright browser crash)
158
+ // from propagating to Forge's process group and killing sibling services.
159
+ const child = spawn('/bin/sh', ['-c', command], {
160
+ cwd,
161
+ stdio: ['ignore', 'pipe', 'pipe'],
162
+ env: { ...process.env, FORCE_COLOR: '0' },
163
+ detached: true,
164
+ });
165
+
166
+ let stdout = '';
167
+ let stderr = '';
168
+
169
+ child.stdout?.on('data', (chunk: Buffer) => {
170
+ stdout += chunk.toString('utf-8');
171
+ // Cap buffer to prevent memory issues
172
+ if (stdout.length > 10 * 1024 * 1024) {
173
+ stdout = stdout.slice(-5 * 1024 * 1024);
174
+ }
175
+ });
176
+ child.stderr?.on('data', (chunk: Buffer) => {
177
+ stderr += chunk.toString('utf-8');
178
+ if (stderr.length > 10 * 1024 * 1024) {
179
+ stderr = stderr.slice(-5 * 1024 * 1024);
180
+ }
181
+ });
182
+
183
+ console.log(`[plugin-shell] pid=${child.pid} pgid=new command=${command.slice(0, 80)}`);
184
+
185
+ child.on('error', (e) => {
186
+ console.log(`[plugin-shell] pid=${child.pid} error: ${e.message}`);
187
+ done({
188
+ ok: false,
189
+ output: {},
190
+ error: `Process error: ${e.message}`,
191
+ duration: Date.now() - startTime,
192
+ });
193
+ });
194
+
195
+ // Use 'close' instead of 'exit' to ensure all stdout/stderr data is collected
196
+ child.on('close', (code, signal) => {
197
+ child.unref();
198
+ console.log(`[plugin-shell] pid=${child.pid} closed code=${code} signal=${signal} stdout=${stdout.length}b stderr=${stderr.length}b`);
199
+ const combined = (stdout || stderr || '').trim();
200
+ // Treat SIGTERM (143) with output as a normal completion — many test runners
201
+ // and complex commands send SIGTERM to their process group during cleanup,
202
+ // which kills the shell wrapper but doesn't indicate a real failure.
203
+ const killedBySignal = code === null || code === 143 || code === 130;
204
+ const hasOutput = combined.length > 0;
205
+ const effectiveOk = code === 0 || (killedBySignal && hasOutput);
206
+
207
+ done({
208
+ ok: effectiveOk,
209
+ output: extractOutputs(combined, action.output),
210
+ error: effectiveOk ? undefined : (signal ? `Killed by ${signal}` : `Exit code ${code}`),
211
+ rawResponse: combined.slice(0, 5000),
212
+ duration: Date.now() - startTime,
213
+ });
214
+ });
215
+
216
+ // Timeout: kill the child's process group
217
+ const timer = setTimeout(() => {
218
+ try { process.kill(-child.pid!, 'SIGTERM'); } catch {}
219
+ setTimeout(() => {
220
+ try { process.kill(-child.pid!, 'SIGKILL'); } catch {}
221
+ }, 3000);
222
+ done({
223
+ ok: false,
224
+ output: {},
225
+ error: `Command timed out after ${timeout / 1000}s`,
226
+ rawResponse: (stderr || stdout || '').slice(0, 5000),
227
+ duration: Date.now() - startTime,
228
+ });
229
+ }, timeout);
230
+ // Don't let the timer keep the process alive
231
+ timer.unref();
232
+
233
+ child.on('exit', () => clearTimeout(timer));
234
+ } catch (e: any) {
235
+ done({
236
+ ok: false,
237
+ output: {},
238
+ error: `Failed to spawn: ${e.message}`,
239
+ duration: Date.now() - startTime,
240
+ });
241
+ }
242
+ });
243
+ }
244
+
245
+ // ─── Public API ──────────────────────────────────────────
246
+
247
+ /**
248
+ * Execute a plugin action.
249
+ * @param plugin - Installed plugin instance
250
+ * @param actionName - Action to execute (e.g., 'trigger', 'wait')
251
+ * @param params - Per-use parameters
252
+ */
253
+ export async function executePluginAction(
254
+ plugin: InstalledPlugin,
255
+ actionName: string,
256
+ params: Record<string, any> = {},
257
+ ): Promise<PluginActionResult> {
258
+ // Auto-resolve action by config.mode prefix: "test" → "docker_test" if mode=docker
259
+ let action = plugin.definition.actions[actionName];
260
+ if (!action && plugin.config.mode) {
261
+ const modeAction = `${plugin.config.mode}_${actionName}`;
262
+ action = plugin.definition.actions[modeAction];
263
+ }
264
+ if (!action) {
265
+ return { ok: false, output: {}, error: `Action "${actionName}" not found in plugin "${plugin.id}"` };
266
+ }
267
+
268
+ // Fill missing config values with definition defaults (handles plugins installed before defaults were added)
269
+ const configWithDefaults = { ...plugin.config };
270
+ if (plugin.definition.config) {
271
+ for (const [k, fieldDef] of Object.entries(plugin.definition.config)) {
272
+ if (configWithDefaults[k] == null && (fieldDef as any).default != null) {
273
+ configWithDefaults[k] = (fieldDef as any).default;
274
+ }
275
+ }
276
+ }
277
+
278
+ // params can override config values — supports multi-instance scenarios
279
+ // e.g., different Jenkins URLs per pipeline node via params.jenkins_url
280
+ const mergedConfig = { ...configWithDefaults };
281
+ const remainingParams = { ...params };
282
+ for (const k of Object.keys(params)) {
283
+ if (k in plugin.definition.config && params[k] != null) {
284
+ mergedConfig[k] = params[k];
285
+ delete remainingParams[k];
286
+ }
287
+ }
288
+
289
+ // Config fields named "default_xxx" provide fallback for params.xxx
290
+ for (const [k, v] of Object.entries(mergedConfig)) {
291
+ if (k.startsWith('default_')) {
292
+ const paramKey = k.slice(8); // "default_job" → "job"
293
+ if (remainingParams[paramKey] == null && v != null) {
294
+ remainingParams[paramKey] = v;
295
+ }
296
+ }
297
+ }
298
+
299
+ const ctx = {
300
+ config: mergedConfig,
301
+ params: remainingParams,
302
+ };
303
+
304
+ console.log(`[plugin] ${plugin.id}.${actionName}: executing (${action.run})`);
305
+
306
+ switch (action.run) {
307
+ case 'http':
308
+ return executeHttp(action, ctx);
309
+ case 'poll':
310
+ return executePoll(action, ctx);
311
+ case 'shell':
312
+ return executeShell(action, ctx);
313
+ case 'script':
314
+ // TODO: implement script execution
315
+ return { ok: false, output: {}, error: 'Script execution not yet implemented' };
316
+ default:
317
+ return { ok: false, output: {}, error: `Unknown action type: ${action.run}` };
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Execute a plugin with auto-wait.
323
+ * Runs the specified action, then if wait=true, runs the 'wait' action.
324
+ */
325
+ export async function executePluginWithWait(
326
+ plugin: InstalledPlugin,
327
+ actionName: string,
328
+ params: Record<string, any> = {},
329
+ wait: boolean = false,
330
+ ): Promise<PluginActionResult> {
331
+ const result = await executePluginAction(plugin, actionName, params);
332
+ if (!result.ok || !wait) return result;
333
+
334
+ // Auto-wait: if plugin has a 'wait' action, run it
335
+ if (plugin.definition.actions['wait']) {
336
+ const waitResult = await executePluginAction(plugin, 'wait', params);
337
+ return {
338
+ ok: waitResult.ok,
339
+ output: { ...result.output, ...waitResult.output },
340
+ rawResponse: waitResult.rawResponse,
341
+ duration: (result.duration || 0) + (waitResult.duration || 0),
342
+ error: waitResult.error,
343
+ };
344
+ }
345
+
346
+ return result;
347
+ }