@aion0/forge 0.3.3 → 0.3.5
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/CLAUDE.md +2 -1
- package/app/api/favorites/route.ts +26 -0
- package/app/api/git/route.ts +40 -35
- package/app/api/issue-scanner/route.ts +116 -0
- package/app/api/pipelines/route.ts +11 -3
- package/app/api/skills/route.ts +12 -2
- package/app/api/tabs/route.ts +25 -0
- package/bin/forge-server.mjs +81 -22
- package/cli/mw.ts +2 -1
- package/components/DocTerminal.tsx +3 -2
- package/components/DocsViewer.tsx +160 -3
- package/components/PipelineView.tsx +3 -2
- package/components/ProjectDetail.tsx +1115 -0
- package/components/ProjectManager.tsx +191 -836
- package/components/SkillsPanel.tsx +28 -6
- package/components/TabBar.tsx +46 -0
- package/components/WebTerminal.tsx +4 -3
- package/lib/cloudflared.ts +1 -1
- package/lib/init.ts +6 -0
- package/lib/issue-scanner.ts +298 -0
- package/lib/pipeline.ts +296 -28
- package/lib/settings.ts +2 -0
- package/lib/skills.ts +30 -2
- package/lib/task-manager.ts +39 -0
- package/lib/terminal-standalone.ts +5 -1
- package/next.config.ts +3 -1
- package/package.json +1 -1
- package/src/core/db/database.ts +13 -0
- package/src/types/index.ts +1 -1
package/lib/pipeline.ts
CHANGED
|
@@ -30,8 +30,10 @@ export interface WorkflowNode {
|
|
|
30
30
|
id: string;
|
|
31
31
|
project: string;
|
|
32
32
|
prompt: string;
|
|
33
|
+
mode?: 'claude' | 'shell'; // default: 'claude' (claude -p), 'shell' runs raw shell command
|
|
34
|
+
branch?: string; // auto checkout this branch before running (supports templates)
|
|
33
35
|
dependsOn: string[];
|
|
34
|
-
outputs: { name: string; extract: 'result' | 'git_diff' }[];
|
|
36
|
+
outputs: { name: string; extract: 'result' | 'git_diff' | 'stdout' }[];
|
|
35
37
|
routes: { condition: string; next: string }[];
|
|
36
38
|
maxIterations: number;
|
|
37
39
|
}
|
|
@@ -70,21 +72,183 @@ export interface Pipeline {
|
|
|
70
72
|
|
|
71
73
|
// ─── Workflow Loading ─────────────────────────────────────
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// ─── Built-in workflows ──────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export const BUILTIN_WORKFLOWS: Record<string, string> = {
|
|
78
|
+
'issue-auto-fix': `
|
|
79
|
+
name: issue-auto-fix
|
|
80
|
+
description: "Fetch a GitHub issue → fix code on a new branch → create PR"
|
|
81
|
+
input:
|
|
82
|
+
issue_id: "GitHub issue number"
|
|
83
|
+
project: "Project name"
|
|
84
|
+
base_branch: "Base branch (default: auto-detect)"
|
|
85
|
+
extra_context: "Additional instructions for the fix (optional)"
|
|
86
|
+
nodes:
|
|
87
|
+
setup:
|
|
88
|
+
mode: shell
|
|
89
|
+
project: "{{input.project}}"
|
|
90
|
+
prompt: |
|
|
91
|
+
cd "$(git rev-parse --show-toplevel)" && \
|
|
92
|
+
if [ -n "$(git status --porcelain)" ]; then echo "ERROR: Working directory has uncommitted changes. Please commit or stash first." && exit 1; fi && \
|
|
93
|
+
ORIG_BRANCH=$(git branch --show-current || git rev-parse --short HEAD) && \
|
|
94
|
+
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git remote get-url origin | sed 's/.*github.com[:/]//;s/.git$//') && \
|
|
95
|
+
BASE={{input.base_branch}} && \
|
|
96
|
+
if [ -z "$BASE" ] || [ "$BASE" = "auto-detect" ]; then BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo main); fi && \
|
|
97
|
+
git checkout "$BASE" 2>/dev/null || true && \
|
|
98
|
+
git pull origin "$BASE" 2>/dev/null || true && \
|
|
99
|
+
OLD_BRANCH=$(git branch --list "fix/{{input.issue_id}}-*" | head -1 | tr -d ' *') && \
|
|
100
|
+
if [ -n "$OLD_BRANCH" ]; then git branch -D "$OLD_BRANCH" 2>/dev/null || true; fi && \
|
|
101
|
+
echo "REPO=$REPO" && echo "BASE=$BASE" && echo "ORIG_BRANCH=$ORIG_BRANCH"
|
|
102
|
+
outputs:
|
|
103
|
+
- name: info
|
|
104
|
+
extract: stdout
|
|
105
|
+
fetch-issue:
|
|
106
|
+
mode: shell
|
|
107
|
+
project: "{{input.project}}"
|
|
108
|
+
depends_on: [setup]
|
|
109
|
+
prompt: |
|
|
110
|
+
REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
|
|
111
|
+
gh issue view {{input.issue_id}} --json title,body,labels,number -R "$REPO"
|
|
112
|
+
outputs:
|
|
113
|
+
- name: issue_json
|
|
114
|
+
extract: stdout
|
|
115
|
+
fix-code:
|
|
116
|
+
project: "{{input.project}}"
|
|
117
|
+
depends_on: [fetch-issue]
|
|
118
|
+
prompt: |
|
|
119
|
+
A GitHub issue needs to be fixed. Here is the issue data:
|
|
120
|
+
|
|
121
|
+
{{nodes.fetch-issue.outputs.issue_json}}
|
|
122
|
+
|
|
123
|
+
Steps:
|
|
124
|
+
1. Create a new branch from the current branch (which is already on the base). Name format: fix/{{input.issue_id}}-<short-description> (e.g. fix/3-add-validation, fix/15-null-pointer). Any old branch for this issue has been cleaned up.
|
|
125
|
+
2. Analyze the issue and fix the code.
|
|
126
|
+
3. Stage and commit with a message referencing #{{input.issue_id}}.
|
|
127
|
+
|
|
128
|
+
Base branch info: {{nodes.setup.outputs.info}}
|
|
129
|
+
|
|
130
|
+
Additional context from user: {{input.extra_context}}
|
|
131
|
+
outputs:
|
|
132
|
+
- name: summary
|
|
133
|
+
extract: result
|
|
134
|
+
- name: diff
|
|
135
|
+
extract: git_diff
|
|
136
|
+
push-and-pr:
|
|
137
|
+
mode: shell
|
|
138
|
+
project: "{{input.project}}"
|
|
139
|
+
depends_on: [fix-code]
|
|
140
|
+
prompt: |
|
|
141
|
+
REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
|
|
142
|
+
BRANCH=$(git branch --show-current) && \
|
|
143
|
+
git push -u origin "$BRANCH" --force-with-lease 2>&1 && \
|
|
144
|
+
PR_URL=$(gh pr create --title 'Fix #{{input.issue_id}}' \
|
|
145
|
+
--body 'Auto-fix by Forge Pipeline for issue #{{input.issue_id}}.' -R "$REPO" 2>/dev/null || \
|
|
146
|
+
gh pr view "$BRANCH" --json url -q .url -R "$REPO" 2>/dev/null) && \
|
|
147
|
+
echo "$PR_URL"
|
|
148
|
+
outputs:
|
|
149
|
+
- name: pr_url
|
|
150
|
+
extract: stdout
|
|
151
|
+
notify:
|
|
152
|
+
mode: shell
|
|
153
|
+
project: "{{input.project}}"
|
|
154
|
+
depends_on: [push-and-pr]
|
|
155
|
+
prompt: |
|
|
156
|
+
ORIG=$(echo '{{nodes.setup.outputs.info}}' | grep ORIG_BRANCH= | cut -d= -f2) && \
|
|
157
|
+
if [ -n "$(git status --porcelain)" ]; then
|
|
158
|
+
echo "PR created for issue #{{input.issue_id}}: {{nodes.push-and-pr.outputs.pr_url}} (staying on $(git branch --show-current) - uncommitted changes)"
|
|
159
|
+
else
|
|
160
|
+
git checkout "$ORIG" 2>/dev/null || true
|
|
161
|
+
echo "PR created for issue #{{input.issue_id}}: {{nodes.push-and-pr.outputs.pr_url}} (switched back to $ORIG)"
|
|
162
|
+
fi
|
|
163
|
+
`,
|
|
164
|
+
'pr-review': `
|
|
165
|
+
name: pr-review
|
|
166
|
+
description: "Review a PR → approve or request changes → notify"
|
|
167
|
+
input:
|
|
168
|
+
pr_number: "Pull request number"
|
|
169
|
+
project: "Project name"
|
|
170
|
+
nodes:
|
|
171
|
+
setup:
|
|
172
|
+
mode: shell
|
|
173
|
+
project: "{{input.project}}"
|
|
174
|
+
prompt: |
|
|
175
|
+
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git remote get-url origin | sed 's/.*github.com[:/]//;s/.git$//') && \
|
|
176
|
+
echo "REPO=$REPO"
|
|
177
|
+
outputs:
|
|
178
|
+
- name: info
|
|
179
|
+
extract: stdout
|
|
180
|
+
fetch-pr:
|
|
181
|
+
mode: shell
|
|
182
|
+
project: "{{input.project}}"
|
|
183
|
+
depends_on: [setup]
|
|
184
|
+
prompt: |
|
|
185
|
+
REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
|
|
186
|
+
gh pr diff {{input.pr_number}} -R "$REPO"
|
|
187
|
+
outputs:
|
|
188
|
+
- name: diff
|
|
189
|
+
extract: stdout
|
|
190
|
+
review:
|
|
191
|
+
project: "{{input.project}}"
|
|
192
|
+
depends_on: [fetch-pr]
|
|
193
|
+
prompt: |
|
|
194
|
+
Review the following pull request diff carefully. Check for:
|
|
195
|
+
- Bugs and logic errors
|
|
196
|
+
- Security vulnerabilities
|
|
197
|
+
- Performance issues
|
|
198
|
+
- Code style and best practices
|
|
199
|
+
|
|
200
|
+
PR #{{input.pr_number}} diff:
|
|
201
|
+
{{nodes.fetch-pr.outputs.diff}}
|
|
202
|
+
|
|
203
|
+
Respond with:
|
|
204
|
+
1. APPROVED or CHANGES_REQUESTED
|
|
205
|
+
2. Detailed list of specific issues found with file paths and line numbers
|
|
206
|
+
3. Suggestions for improvement
|
|
207
|
+
outputs:
|
|
208
|
+
- name: review_result
|
|
209
|
+
extract: result
|
|
210
|
+
post-review:
|
|
211
|
+
mode: shell
|
|
212
|
+
project: "{{input.project}}"
|
|
213
|
+
depends_on: [review]
|
|
214
|
+
prompt: "echo 'Review complete for PR #{{input.pr_number}}'"
|
|
215
|
+
outputs:
|
|
216
|
+
- name: status
|
|
217
|
+
extract: stdout
|
|
218
|
+
`,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export interface WorkflowWithMeta extends Workflow {
|
|
222
|
+
builtin?: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function listWorkflows(): WorkflowWithMeta[] {
|
|
226
|
+
// User workflows
|
|
227
|
+
const userWorkflows: WorkflowWithMeta[] = [];
|
|
228
|
+
if (existsSync(WORKFLOWS_DIR)) {
|
|
229
|
+
for (const f of readdirSync(WORKFLOWS_DIR).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
78
230
|
try {
|
|
79
|
-
|
|
80
|
-
} catch {
|
|
81
|
-
|
|
231
|
+
userWorkflows.push({ ...parseWorkflow(readFileSync(join(WORKFLOWS_DIR, f), 'utf-8')), builtin: false });
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Built-in workflows (don't override user ones with same name)
|
|
237
|
+
const userNames = new Set(userWorkflows.map(w => w.name));
|
|
238
|
+
const builtins: WorkflowWithMeta[] = [];
|
|
239
|
+
for (const [, yaml] of Object.entries(BUILTIN_WORKFLOWS)) {
|
|
240
|
+
try {
|
|
241
|
+
const w = parseWorkflow(yaml);
|
|
242
|
+
if (!userNames.has(w.name)) {
|
|
243
|
+
builtins.push({ ...w, builtin: true });
|
|
82
244
|
}
|
|
83
|
-
}
|
|
84
|
-
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return [...builtins, ...userWorkflows];
|
|
85
249
|
}
|
|
86
250
|
|
|
87
|
-
export function getWorkflow(name: string):
|
|
251
|
+
export function getWorkflow(name: string): WorkflowWithMeta | null {
|
|
88
252
|
return listWorkflows().find(w => w.name === name) || null;
|
|
89
253
|
}
|
|
90
254
|
|
|
@@ -98,6 +262,8 @@ function parseWorkflow(raw: string): Workflow {
|
|
|
98
262
|
id,
|
|
99
263
|
project: n.project || '',
|
|
100
264
|
prompt: n.prompt || '',
|
|
265
|
+
mode: n.mode || 'claude',
|
|
266
|
+
branch: n.branch || undefined,
|
|
101
267
|
dependsOn: n.depends_on || n.dependsOn || [],
|
|
102
268
|
outputs: (n.outputs || []).map((o: any) => ({
|
|
103
269
|
name: o.name,
|
|
@@ -167,31 +333,61 @@ export function listPipelines(): Pipeline[] {
|
|
|
167
333
|
|
|
168
334
|
// ─── Template Resolution ──────────────────────────────────
|
|
169
335
|
|
|
336
|
+
/** Escape a string for safe embedding in shell commands (single-quote wrapping) */
|
|
337
|
+
function shellEscape(s: string): string {
|
|
338
|
+
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
|
|
339
|
+
return s.replace(/'/g, "'\\''");
|
|
340
|
+
}
|
|
341
|
+
|
|
170
342
|
function resolveTemplate(template: string, ctx: {
|
|
171
343
|
input: Record<string, string>;
|
|
172
344
|
vars: Record<string, string>;
|
|
173
345
|
nodes: Record<string, PipelineNodeState>;
|
|
174
|
-
}): string {
|
|
346
|
+
}, shellMode?: boolean): string {
|
|
175
347
|
return template.replace(/\{\{(.*?)\}\}/g, (_, expr) => {
|
|
176
348
|
const path = expr.trim();
|
|
349
|
+
let value = '';
|
|
177
350
|
|
|
178
351
|
// {{input.xxx}}
|
|
179
|
-
if (path.startsWith('input.'))
|
|
180
|
-
|
|
352
|
+
if (path.startsWith('input.')) value = ctx.input[path.slice(6)] || '';
|
|
181
353
|
// {{vars.xxx}}
|
|
182
|
-
if (path.startsWith('vars.'))
|
|
183
|
-
|
|
354
|
+
else if (path.startsWith('vars.')) value = ctx.vars[path.slice(5)] || '';
|
|
184
355
|
// {{nodes.xxx.outputs.yyy}}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
356
|
+
else {
|
|
357
|
+
const nodeMatch = path.match(/^nodes\.([\w-]+)\.outputs\.([\w-]+)$/);
|
|
358
|
+
if (nodeMatch) {
|
|
359
|
+
const [, nodeId, outputName] = nodeMatch;
|
|
360
|
+
value = ctx.nodes[nodeId]?.outputs[outputName] || '';
|
|
361
|
+
} else {
|
|
362
|
+
return `{{${path}}}`;
|
|
363
|
+
}
|
|
189
364
|
}
|
|
190
365
|
|
|
191
|
-
return
|
|
366
|
+
return shellMode ? shellEscape(value) : value;
|
|
192
367
|
});
|
|
193
368
|
}
|
|
194
369
|
|
|
370
|
+
// ─── Project-level pipeline lock ─────────────────────────
|
|
371
|
+
const projectPipelineLocks = new Map<string, string>(); // projectPath → pipelineId
|
|
372
|
+
|
|
373
|
+
function acquireProjectLock(projectPath: string, pipelineId: string): boolean {
|
|
374
|
+
const existing = projectPipelineLocks.get(projectPath);
|
|
375
|
+
if (existing && existing !== pipelineId) {
|
|
376
|
+
// Check if the existing pipeline is still running
|
|
377
|
+
const p = getPipeline(existing);
|
|
378
|
+
if (p && p.status === 'running') return false;
|
|
379
|
+
// Stale lock, clear it
|
|
380
|
+
}
|
|
381
|
+
projectPipelineLocks.set(projectPath, pipelineId);
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function releaseProjectLock(projectPath: string, pipelineId: string) {
|
|
386
|
+
if (projectPipelineLocks.get(projectPath) === pipelineId) {
|
|
387
|
+
projectPipelineLocks.delete(projectPath);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
195
391
|
// ─── Pipeline Execution ───────────────────────────────────
|
|
196
392
|
|
|
197
393
|
export function startPipeline(workflowName: string, input: Record<string, string>): Pipeline {
|
|
@@ -254,7 +450,7 @@ function recoverStuckPipelines() {
|
|
|
254
450
|
const nodeDef = workflow.nodes[nodeId];
|
|
255
451
|
if (nodeDef) {
|
|
256
452
|
for (const outputDef of nodeDef.outputs) {
|
|
257
|
-
if (outputDef.extract === 'result') node.outputs[outputDef.name] = task.resultSummary || '';
|
|
453
|
+
if (outputDef.extract === 'result' || outputDef.extract === 'stdout') node.outputs[outputDef.name] = task.resultSummary || '';
|
|
258
454
|
else if (outputDef.extract === 'git_diff') node.outputs[outputDef.name] = task.gitDiff || '';
|
|
259
455
|
}
|
|
260
456
|
}
|
|
@@ -337,8 +533,9 @@ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
337
533
|
if (!depsReady) continue;
|
|
338
534
|
|
|
339
535
|
// Resolve templates
|
|
536
|
+
const isShell = nodeDef.mode === 'shell';
|
|
340
537
|
const project = resolveTemplate(nodeDef.project, ctx);
|
|
341
|
-
const prompt = resolveTemplate(nodeDef.prompt, ctx);
|
|
538
|
+
const prompt = resolveTemplate(nodeDef.prompt, ctx, isShell);
|
|
342
539
|
|
|
343
540
|
const projectInfo = getProjectInfo(project);
|
|
344
541
|
if (!projectInfo) {
|
|
@@ -349,16 +546,41 @@ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
349
546
|
continue;
|
|
350
547
|
}
|
|
351
548
|
|
|
352
|
-
//
|
|
549
|
+
// Auto checkout branch if specified
|
|
550
|
+
if (nodeDef.branch) {
|
|
551
|
+
const branchName = resolveTemplate(nodeDef.branch, ctx);
|
|
552
|
+
try {
|
|
553
|
+
const { execSync } = require('node:child_process');
|
|
554
|
+
// Create branch if not exists, or switch to it
|
|
555
|
+
try {
|
|
556
|
+
execSync(`git checkout -b ${branchName}`, { cwd: projectInfo.path, stdio: 'pipe' });
|
|
557
|
+
} catch {
|
|
558
|
+
execSync(`git checkout ${branchName}`, { cwd: projectInfo.path, stdio: 'pipe' });
|
|
559
|
+
}
|
|
560
|
+
console.log(`[pipeline] Checked out branch: ${branchName}`);
|
|
561
|
+
} catch (e: any) {
|
|
562
|
+
nodeState.status = 'failed';
|
|
563
|
+
nodeState.error = `Branch checkout failed: ${e.message}`;
|
|
564
|
+
savePipeline(pipeline);
|
|
565
|
+
notifyStep(pipeline, nodeId, 'failed', nodeState.error);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Create task — mode: 'shell' runs raw command, 'claude' runs claude -p
|
|
571
|
+
const taskMode = nodeDef.mode === 'shell' ? 'shell' : 'prompt';
|
|
353
572
|
const task = createTask({
|
|
354
573
|
projectName: projectInfo.name,
|
|
355
574
|
projectPath: projectInfo.path,
|
|
356
575
|
prompt,
|
|
576
|
+
mode: taskMode as any,
|
|
357
577
|
});
|
|
358
578
|
pipelineTaskIds.add(task.id);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
579
|
+
if (taskMode !== 'shell') {
|
|
580
|
+
const pipelineModel = loadSettings().pipelineModel;
|
|
581
|
+
if (pipelineModel && pipelineModel !== 'default') {
|
|
582
|
+
taskModelOverrides.set(task.id, pipelineModel);
|
|
583
|
+
}
|
|
362
584
|
}
|
|
363
585
|
|
|
364
586
|
nodeState.status = 'running';
|
|
@@ -384,6 +606,50 @@ function checkPipelineCompletion(pipeline: Pipeline) {
|
|
|
384
606
|
pipeline.completedAt = new Date().toISOString();
|
|
385
607
|
savePipeline(pipeline);
|
|
386
608
|
notifyPipelineComplete(pipeline);
|
|
609
|
+
|
|
610
|
+
// Update issue_autofix_processed status
|
|
611
|
+
if (pipeline.workflowName === 'issue-auto-fix') {
|
|
612
|
+
try {
|
|
613
|
+
const { updateProcessedStatus } = require('./issue-scanner');
|
|
614
|
+
const issueId = parseInt(pipeline.input.issue_id);
|
|
615
|
+
const projectInfo = getProjectInfo(pipeline.input.project);
|
|
616
|
+
if (projectInfo && issueId) {
|
|
617
|
+
const prOutput = pipeline.nodes['push-and-pr']?.outputs?.pr_url || '';
|
|
618
|
+
const prMatch = prOutput.match(/\/pull\/(\d+)/);
|
|
619
|
+
const prNumber = prMatch ? parseInt(prMatch[1]) : undefined;
|
|
620
|
+
updateProcessedStatus(projectInfo.path, issueId, pipeline.status, prNumber);
|
|
621
|
+
}
|
|
622
|
+
} catch {}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Auto-chain: issue-auto-fix → pr-review
|
|
626
|
+
if (pipeline.workflowName === 'issue-auto-fix' && pipeline.status === 'done') {
|
|
627
|
+
try {
|
|
628
|
+
// Extract PR number from push-and-pr output
|
|
629
|
+
const prOutput = pipeline.nodes['push-and-pr']?.outputs?.pr_url || '';
|
|
630
|
+
const prMatch = prOutput.match(/\/pull\/(\d+)/);
|
|
631
|
+
if (prMatch) {
|
|
632
|
+
const prNumber = prMatch[1];
|
|
633
|
+
console.log(`[pipeline] Auto-triggering pr-review for PR #${prNumber}`);
|
|
634
|
+
startPipeline('pr-review', {
|
|
635
|
+
pr_number: prNumber,
|
|
636
|
+
project: pipeline.input.project || '',
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
} catch (e) {
|
|
640
|
+
console.error('[pipeline] Failed to auto-trigger pr-review:', e);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Release project lock
|
|
645
|
+
const workflow = getWorkflow(pipeline.workflowName);
|
|
646
|
+
if (workflow) {
|
|
647
|
+
const projectNames = new Set(Object.values(workflow.nodes).map(n => n.project));
|
|
648
|
+
for (const pName of projectNames) {
|
|
649
|
+
const pInfo = getProjectInfo(resolveTemplate(pName, { input: pipeline.input, vars: pipeline.vars, nodes: pipeline.nodes }));
|
|
650
|
+
if (pInfo) releaseProjectLock(pInfo.path, pipeline.id);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
387
653
|
}
|
|
388
654
|
}
|
|
389
655
|
|
|
@@ -422,6 +688,8 @@ function setupTaskListener(pipelineId: string) {
|
|
|
422
688
|
for (const outputDef of nodeDef.outputs) {
|
|
423
689
|
if (outputDef.extract === 'result') {
|
|
424
690
|
nodeState.outputs[outputDef.name] = task.resultSummary || '';
|
|
691
|
+
} else if (outputDef.extract === 'stdout') {
|
|
692
|
+
nodeState.outputs[outputDef.name] = task.resultSummary || '';
|
|
425
693
|
} else if (outputDef.extract === 'git_diff') {
|
|
426
694
|
nodeState.outputs[outputDef.name] = task.gitDiff || '';
|
|
427
695
|
}
|
package/lib/settings.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface Settings {
|
|
|
26
26
|
skillsRepoUrl: string; // GitHub raw URL for skills registry
|
|
27
27
|
displayName: string; // User display name (shown in header)
|
|
28
28
|
displayEmail: string; // User email (for session/future integrations)
|
|
29
|
+
favoriteProjects: string[]; // Favorite project paths (shown at top of sidebar)
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
const defaults: Settings = {
|
|
@@ -47,6 +48,7 @@ const defaults: Settings = {
|
|
|
47
48
|
skillsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-skills/main',
|
|
48
49
|
displayName: 'Forge',
|
|
49
50
|
displayEmail: '',
|
|
51
|
+
favoriteProjects: [],
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
/** Load settings with secrets decrypted (for internal use) */
|
package/lib/skills.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface SkillItem {
|
|
|
34
34
|
installedProjects: string[];
|
|
35
35
|
installedVersion: string; // version currently installed (empty if not installed)
|
|
36
36
|
hasUpdate: boolean; // true if registry version > installed version
|
|
37
|
+
deletedRemotely: boolean; // true if removed from remote registry but still installed locally
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
function db() {
|
|
@@ -182,13 +183,16 @@ export async function syncSkills(): Promise<{ synced: number; error?: string }>
|
|
|
182
183
|
});
|
|
183
184
|
tx();
|
|
184
185
|
|
|
185
|
-
//
|
|
186
|
+
// Handle items no longer in registry
|
|
186
187
|
const registryNames = new Set(items.map(s => s.name));
|
|
187
188
|
const dbItems = db().prepare('SELECT name, installed_global, installed_projects FROM skills').all() as any[];
|
|
188
189
|
for (const row of dbItems) {
|
|
189
190
|
if (!registryNames.has(row.name)) {
|
|
190
191
|
const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
|
|
191
|
-
if (
|
|
192
|
+
if (hasLocal) {
|
|
193
|
+
// Still installed locally — mark as deleted remotely so the user can decide
|
|
194
|
+
db().prepare('UPDATE skills SET deleted_remotely = 1 WHERE name = ?').run(row.name);
|
|
195
|
+
} else {
|
|
192
196
|
db().prepare('DELETE FROM skills WHERE name = ?').run(row.name);
|
|
193
197
|
}
|
|
194
198
|
}
|
|
@@ -223,6 +227,7 @@ export function listSkills(): SkillItem[] {
|
|
|
223
227
|
installedProjects: JSON.parse(r.installed_projects || '[]'),
|
|
224
228
|
installedVersion,
|
|
225
229
|
hasUpdate: isInstalled && !!registryVersion && !!installedVersion && compareVersions(registryVersion, installedVersion) > 0,
|
|
230
|
+
deletedRemotely: !!r.deleted_remotely,
|
|
226
231
|
};
|
|
227
232
|
});
|
|
228
233
|
}
|
|
@@ -529,6 +534,29 @@ export function installLocal(name: string, type: string, sourceProject: string |
|
|
|
529
534
|
return { ok: true };
|
|
530
535
|
}
|
|
531
536
|
|
|
537
|
+
/** Remove all local files for a skill deleted from the remote registry, then drop its DB record. */
|
|
538
|
+
export function purgeDeletedSkill(name: string): void {
|
|
539
|
+
const row = db().prepare('SELECT type, installed_projects FROM skills WHERE name = ?').get(name) as any;
|
|
540
|
+
if (!row) return;
|
|
541
|
+
const type: string = row.type || 'skill';
|
|
542
|
+
const projects: string[] = JSON.parse(row.installed_projects || '[]');
|
|
543
|
+
|
|
544
|
+
// Remove global files
|
|
545
|
+
try { rmSync(join(GLOBAL_SKILLS_DIR, name), { recursive: true }); } catch {}
|
|
546
|
+
try { unlinkSync(join(GLOBAL_COMMANDS_DIR, `${name}.md`)); } catch {}
|
|
547
|
+
try { rmSync(join(GLOBAL_COMMANDS_DIR, name), { recursive: true }); } catch {}
|
|
548
|
+
|
|
549
|
+
// Remove project-local files
|
|
550
|
+
for (const pp of projects) {
|
|
551
|
+
try { rmSync(join(pp, '.claude', 'skills', name), { recursive: true }); } catch {}
|
|
552
|
+
try { unlinkSync(join(pp, '.claude', 'commands', `${name}.md`)); } catch {}
|
|
553
|
+
try { rmSync(join(pp, '.claude', 'commands', name), { recursive: true }); } catch {}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Remove DB record entirely
|
|
557
|
+
db().prepare('DELETE FROM skills WHERE name = ?').run(name);
|
|
558
|
+
}
|
|
559
|
+
|
|
532
560
|
/** Delete a local skill/command from a specific project or global */
|
|
533
561
|
export function deleteLocal(name: string, type: string, projectPath?: string): boolean {
|
|
534
562
|
const base = projectPath ? join(projectPath, '.claude') : getClaudeDir();
|
package/lib/task-manager.ts
CHANGED
|
@@ -235,7 +235,46 @@ async function processNextTask() {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
function executeShellTask(task: Task): Promise<void> {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
updateTaskStatus(task.id, 'running');
|
|
241
|
+
db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
|
|
242
|
+
console.log(`[task:shell] ${task.projectName}: "${task.prompt.slice(0, 80)}"`);
|
|
243
|
+
|
|
244
|
+
const child = spawn('bash', ['-c', task.prompt], {
|
|
245
|
+
cwd: task.projectPath,
|
|
246
|
+
env: { ...process.env },
|
|
247
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
let stdout = '';
|
|
251
|
+
let stderr = '';
|
|
252
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
253
|
+
const text = chunk.toString();
|
|
254
|
+
stdout += text;
|
|
255
|
+
appendLog(task.id, { type: 'system', subtype: 'text', content: text, timestamp: new Date().toISOString() });
|
|
256
|
+
});
|
|
257
|
+
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
258
|
+
|
|
259
|
+
child.on('exit', (code) => {
|
|
260
|
+
if (code === 0) {
|
|
261
|
+
db().prepare('UPDATE tasks SET status = ?, result_summary = ?, completed_at = datetime(\'now\') WHERE id = ?')
|
|
262
|
+
.run('done', stdout.trim(), task.id);
|
|
263
|
+
emit(task.id, 'status', 'done');
|
|
264
|
+
} else {
|
|
265
|
+
const errMsg = stderr.trim() || `Exit code ${code}`;
|
|
266
|
+
db().prepare('UPDATE tasks SET status = ?, error = ?, completed_at = datetime(\'now\') WHERE id = ?')
|
|
267
|
+
.run('failed', errMsg, task.id);
|
|
268
|
+
emit(task.id, 'status', 'failed');
|
|
269
|
+
}
|
|
270
|
+
resolve();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
238
275
|
function executeTask(task: Task): Promise<void> {
|
|
276
|
+
if (task.mode === 'shell') return executeShellTask(task);
|
|
277
|
+
|
|
239
278
|
return new Promise((resolve, reject) => {
|
|
240
279
|
const settings = loadSettings();
|
|
241
280
|
const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
|
|
@@ -28,12 +28,16 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
28
28
|
import * as pty from 'node-pty';
|
|
29
29
|
import { execSync } from 'node:child_process';
|
|
30
30
|
import { homedir } from 'node:os';
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
31
32
|
import { getDataDir } from './dirs';
|
|
32
33
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
|
33
34
|
import { join } from 'node:path';
|
|
34
35
|
|
|
35
36
|
const PORT = Number(process.env.TERMINAL_PORT) || 3001;
|
|
36
|
-
|
|
37
|
+
// Session prefix based on DATA_DIR hash — default instance keeps 'mw-' for backward compat
|
|
38
|
+
const _dataDir = process.env.FORGE_DATA_DIR || '';
|
|
39
|
+
const _isDefault = !_dataDir || _dataDir.endsWith('/data') || _dataDir.endsWith('/.forge');
|
|
40
|
+
const SESSION_PREFIX = _isDefault ? 'mw-' : `mw${createHash('md5').update(_dataDir).digest('hex').slice(0, 6)}-`;
|
|
37
41
|
|
|
38
42
|
// Remove CLAUDECODE env so Claude Code can run inside terminal sessions
|
|
39
43
|
delete process.env.CLAUDECODE;
|
package/next.config.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { NextConfig } from 'next';
|
|
2
2
|
|
|
3
|
+
const terminalPort = parseInt(process.env.TERMINAL_PORT || '') || 3001;
|
|
4
|
+
|
|
3
5
|
const nextConfig: NextConfig = {
|
|
4
6
|
serverExternalPackages: ['better-sqlite3'],
|
|
5
7
|
async rewrites() {
|
|
@@ -7,7 +9,7 @@ const nextConfig: NextConfig = {
|
|
|
7
9
|
{
|
|
8
10
|
// Proxy terminal WebSocket through Next.js so it works via Cloudflare Tunnel
|
|
9
11
|
source: '/terminal-ws',
|
|
10
|
-
destination:
|
|
12
|
+
destination: `http://localhost:${terminalPort}`,
|
|
11
13
|
},
|
|
12
14
|
];
|
|
13
15
|
},
|
package/package.json
CHANGED
package/src/core/db/database.ts
CHANGED
|
@@ -33,6 +33,7 @@ function initSchema(db: Database.Database) {
|
|
|
33
33
|
migrate('ALTER TABLE skills ADD COLUMN archive TEXT');
|
|
34
34
|
migrate("ALTER TABLE skills ADD COLUMN installed_version TEXT NOT NULL DEFAULT ''");
|
|
35
35
|
migrate('ALTER TABLE skills ADD COLUMN rating REAL DEFAULT 0');
|
|
36
|
+
migrate('ALTER TABLE skills ADD COLUMN deleted_remotely INTEGER NOT NULL DEFAULT 0');
|
|
36
37
|
|
|
37
38
|
db.exec(`
|
|
38
39
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
@@ -148,6 +149,18 @@ function initSchema(db: Database.Database) {
|
|
|
148
149
|
synced_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
149
150
|
);
|
|
150
151
|
|
|
152
|
+
-- Tab state (projects, docs, etc)
|
|
153
|
+
CREATE TABLE IF NOT EXISTS tab_state (
|
|
154
|
+
type TEXT PRIMARY KEY,
|
|
155
|
+
data TEXT NOT NULL DEFAULT '{}'
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
-- Project favorites
|
|
159
|
+
CREATE TABLE IF NOT EXISTS project_favorites (
|
|
160
|
+
project_path TEXT PRIMARY KEY,
|
|
161
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
162
|
+
);
|
|
163
|
+
|
|
151
164
|
-- Session watchers — monitor sessions and notify via Telegram
|
|
152
165
|
CREATE TABLE IF NOT EXISTS session_watchers (
|
|
153
166
|
id TEXT PRIMARY KEY,
|
package/src/types/index.ts
CHANGED
|
@@ -76,7 +76,7 @@ export interface UsageRecord {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export type TaskStatus = 'queued' | 'running' | 'done' | 'failed' | 'cancelled';
|
|
79
|
-
export type TaskMode = 'prompt' | 'monitor';
|
|
79
|
+
export type TaskMode = 'prompt' | 'monitor' | 'shell';
|
|
80
80
|
|
|
81
81
|
export interface WatchConfig {
|
|
82
82
|
condition: 'change' | 'idle' | 'complete' | 'error' | 'keyword';
|