@aion0/forge 0.3.2 → 0.3.4

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 CHANGED
@@ -9,7 +9,8 @@ forge server start # production via npm link/install
9
9
  forge server start --dev # dev mode
10
10
  forge server start # background by default, logs to ~/.forge/forge.log
11
11
  forge server start --foreground # foreground mode
12
- forge server stop # stop background server
12
+ forge server stop # stop default instance (port 3000)
13
+ forge server stop --port 4000 --dir ~/.forge-staging # stop specific instance
13
14
  forge server restart # stop + start (safe for remote)
14
15
  forge server rebuild # force rebuild
15
16
  forge server start --port 4000 --terminal-port 4001 --dir ~/.forge-staging
@@ -0,0 +1,116 @@
1
+ import { NextResponse } from 'next/server';
2
+ import {
3
+ getConfig,
4
+ saveConfig,
5
+ listConfigs,
6
+ scanAndTrigger,
7
+ restartScanner,
8
+ getProcessedIssues,
9
+ resetProcessedIssue,
10
+ getNextScanTime,
11
+ type IssueAutofixConfig,
12
+ } from '@/lib/issue-scanner';
13
+
14
+ // GET /api/issue-scanner?project=PATH — get config + processed issues
15
+ export async function GET(req: Request) {
16
+ const { searchParams } = new URL(req.url);
17
+ const projectPath = searchParams.get('project');
18
+
19
+ if (projectPath) {
20
+ const config = getConfig(projectPath);
21
+ const processed = getProcessedIssues(projectPath);
22
+ const scanTime = getNextScanTime(projectPath);
23
+ return NextResponse.json({ config, processed, ...scanTime });
24
+ }
25
+
26
+ // List all enabled configs
27
+ const configs = listConfigs();
28
+ return NextResponse.json({ configs });
29
+ }
30
+
31
+ // POST /api/issue-scanner
32
+ export async function POST(req: Request) {
33
+ const body = await req.json();
34
+
35
+ // Save config
36
+ if (body.action === 'save-config') {
37
+ const config: IssueAutofixConfig = {
38
+ projectPath: body.projectPath,
39
+ projectName: body.projectName,
40
+ enabled: !!body.enabled,
41
+ interval: body.interval || 30,
42
+ labels: body.labels || [],
43
+ baseBranch: body.baseBranch || '',
44
+ };
45
+ saveConfig(config);
46
+ restartScanner();
47
+ return NextResponse.json({ ok: true });
48
+ }
49
+
50
+ // Manual scan & trigger
51
+ if (body.action === 'scan') {
52
+ const config = getConfig(body.projectPath);
53
+ if (!config) return NextResponse.json({ error: 'Not configured' }, { status: 400 });
54
+ const result = scanAndTrigger(config);
55
+ return NextResponse.json(result);
56
+ }
57
+
58
+ // Manual trigger for a specific issue
59
+ if (body.action === 'trigger') {
60
+ const { startPipeline } = require('@/lib/pipeline');
61
+ const config = getConfig(body.projectPath);
62
+ const projectName = config?.projectName || body.projectName;
63
+ try {
64
+ const pipeline = startPipeline('issue-auto-fix', {
65
+ issue_id: String(body.issueId),
66
+ project: projectName,
67
+ base_branch: config?.baseBranch || body.baseBranch || 'auto-detect',
68
+ });
69
+ // Track in processed issues
70
+ const { getDb } = require('@/src/core/db/database');
71
+ const { getDbPath } = require('@/src/config');
72
+ getDb(getDbPath()).prepare(`
73
+ INSERT OR REPLACE INTO issue_autofix_processed (project_path, issue_number, pipeline_id, status)
74
+ VALUES (?, ?, ?, 'processing')
75
+ `).run(body.projectPath, body.issueId, pipeline.id);
76
+ return NextResponse.json({ ok: true, pipelineId: pipeline.id });
77
+ } catch (e) {
78
+ return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
79
+ }
80
+ }
81
+
82
+ // Reset a processed issue (allow re-scan)
83
+ if (body.action === 'reset') {
84
+ resetProcessedIssue(body.projectPath, body.issueId);
85
+ return NextResponse.json({ ok: true });
86
+ }
87
+
88
+ // Retry with additional context/instructions
89
+ if (body.action === 'retry') {
90
+ const { startPipeline } = require('@/lib/pipeline');
91
+ const config = getConfig(body.projectPath);
92
+ const projectName = config?.projectName || body.projectName;
93
+ // Reset the processed record first, then re-create with new pipeline
94
+ resetProcessedIssue(body.projectPath, body.issueId);
95
+ try {
96
+ const pipeline = startPipeline('issue-auto-fix', {
97
+ issue_id: String(body.issueId),
98
+ project: projectName,
99
+ base_branch: config?.baseBranch || 'auto-detect',
100
+ extra_context: body.context || '',
101
+ });
102
+ // Re-mark as processed with new pipeline ID
103
+ const { getDb } = require('@/src/core/db/database');
104
+ const { getDbPath } = require('@/src/config');
105
+ getDb(getDbPath()).prepare(`
106
+ INSERT OR REPLACE INTO issue_autofix_processed (project_path, issue_number, pipeline_id, status)
107
+ VALUES (?, ?, ?, 'processing')
108
+ `).run(body.projectPath, body.issueId, pipeline.id);
109
+ return NextResponse.json({ ok: true, pipelineId: pipeline.id });
110
+ } catch (e) {
111
+ return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
112
+ }
113
+ }
114
+
115
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
116
+ }
@@ -24,9 +24,17 @@ export async function GET(req: Request) {
24
24
  const filePath = join(FLOWS_DIR, `${name}.yaml`);
25
25
  const altPath = join(FLOWS_DIR, `${name}.yml`);
26
26
  const path = existsSync(filePath) ? filePath : existsSync(altPath) ? altPath : null;
27
- if (!path) return NextResponse.json({ error: 'Not found' }, { status: 404 });
28
- const yaml = readFileSync(path, 'utf-8');
29
- return NextResponse.json({ yaml });
27
+ if (path) {
28
+ return NextResponse.json({ yaml: readFileSync(path, 'utf-8') });
29
+ }
30
+ // Check built-in workflows
31
+ const workflow = listWorkflows().find(w => w.name === name);
32
+ if (workflow?.builtin) {
33
+ const { BUILTIN_WORKFLOWS } = await import('@/lib/pipeline');
34
+ const yaml = BUILTIN_WORKFLOWS[name];
35
+ if (yaml) return NextResponse.json({ yaml: yaml.trim(), builtin: true });
36
+ }
37
+ return NextResponse.json({ error: 'Not found' }, { status: 404 });
30
38
  } catch {
31
39
  return NextResponse.json({ error: 'Failed to read' }, { status: 500 });
32
40
  }
@@ -8,6 +8,7 @@ import {
8
8
  uninstallProject,
9
9
  refreshInstallState,
10
10
  checkLocalModified,
11
+ purgeDeletedSkill,
11
12
  } from '@/lib/skills';
12
13
  import { loadSettings } from '@/lib/settings';
13
14
  import { homedir } from 'node:os';
@@ -156,5 +157,16 @@ export async function POST(req: Request) {
156
157
  }
157
158
  }
158
159
 
160
+ if (body.action === 'purge-deleted') {
161
+ const { name } = body;
162
+ if (!name) return NextResponse.json({ ok: false, error: 'name required' }, { status: 400 });
163
+ try {
164
+ purgeDeletedSkill(name);
165
+ return NextResponse.json({ ok: true });
166
+ } catch (e) {
167
+ return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
168
+ }
169
+ }
170
+
159
171
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
160
172
  }
@@ -12,7 +12,7 @@ export default function LoginPage() {
12
12
 
13
13
  useEffect(() => {
14
14
  const host = window.location.hostname;
15
- setIsRemote(!['localhost', '127.0.0.1'].includes(host));
15
+ setIsRemote(host.endsWith('.trycloudflare.com'));
16
16
  // Restore theme
17
17
  const saved = localStorage.getItem('forge-theme');
18
18
  if (saved === 'light') document.documentElement.setAttribute('data-theme', 'light');
@@ -67,6 +67,7 @@ const terminalPort = parseInt(getArg('--terminal-port')) || (webPort + 1);
67
67
  const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge', 'data');
68
68
 
69
69
  const PID_FILE = join(DATA_DIR, 'forge.pid');
70
+ const PIDS_FILE = join(DATA_DIR, 'forge.pids'); // all child process PIDs
70
71
  const LOG_FILE = join(DATA_DIR, 'forge.log');
71
72
 
72
73
  process.chdir(ROOT);
@@ -208,16 +209,43 @@ if (resetTerminal) {
208
209
  }
209
210
  }
210
211
 
212
+ // ── PID tracking for clean shutdown ──
213
+
214
+ function savePids(pids) {
215
+ writeFileSync(PIDS_FILE, JSON.stringify(pids), 'utf-8');
216
+ }
217
+
218
+ function loadPids() {
219
+ try { return JSON.parse(readFileSync(PIDS_FILE, 'utf-8')); } catch { return []; }
220
+ }
221
+
222
+ function killTrackedPids() {
223
+ const pids = loadPids();
224
+ for (const pid of pids) {
225
+ try { process.kill(pid, 'SIGTERM'); } catch {}
226
+ }
227
+ // Give them a moment, then force kill
228
+ if (pids.length > 0) {
229
+ setTimeout(() => {
230
+ for (const pid of pids) {
231
+ try { process.kill(pid, 'SIGKILL'); } catch {}
232
+ }
233
+ }, 2000);
234
+ }
235
+ try { unlinkSync(PIDS_FILE); } catch {}
236
+ }
237
+
211
238
  // ── Kill orphan standalone processes ──
212
239
  const protectedPids = new Set();
213
240
 
214
241
  function cleanupOrphans() {
242
+ const myPid = String(process.pid);
243
+ const instanceTag = `--forge-port=${webPort}`;
215
244
  try {
216
- // Only kill processes on OUR ports, not other instances
245
+ // Kill processes on our ports
217
246
  for (const port of [webPort, terminalPort]) {
218
247
  try {
219
248
  const pids = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
220
- const myPid = String(process.pid);
221
249
  for (const pid of pids.split('\n').filter(Boolean)) {
222
250
  const p = pid.trim();
223
251
  if (p === myPid || protectedPids.has(p)) continue;
@@ -225,20 +253,18 @@ function cleanupOrphans() {
225
253
  }
226
254
  } catch {}
227
255
  }
228
- // Kill standalone processes that belong to this instance (match by FORGE_DATA_DIR)
256
+ // Kill standalone processes: our instance's + orphans without any tag
229
257
  try {
230
258
  const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone' | grep -v grep`, {
231
259
  encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
232
260
  }).trim();
233
- if (out) {
234
- const myPid = String(process.pid);
235
- for (const line of out.split('\n').filter(Boolean)) {
236
- // Only kill if it matches our DATA_DIR or port
237
- if (!line.includes(DATA_DIR) && !line.includes(`PORT=${webPort}`) && !line.includes(`TERMINAL_PORT=${terminalPort}`)) continue;
238
- const pid = line.trim().split(/\s+/)[1];
239
- if (pid === myPid || protectedPids.has(pid)) continue;
240
- try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
241
- }
261
+ for (const line of out.split('\n').filter(Boolean)) {
262
+ const isOurs = line.includes(instanceTag);
263
+ const isOrphan = !line.includes('--forge-port='); // no tag = legacy orphan
264
+ if (!isOurs && !isOrphan) continue; // belongs to another instance, skip
265
+ const pid = line.trim().split(/\s+/)[1];
266
+ if (pid === myPid || protectedPids.has(pid)) continue;
267
+ try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
242
268
  }
243
269
  } catch {}
244
270
  } catch {}
@@ -250,9 +276,11 @@ const services = [];
250
276
  function startServices() {
251
277
  cleanupOrphans();
252
278
 
279
+ const instanceTag = `--forge-port=${webPort}`;
280
+
253
281
  // Terminal server
254
282
  const termScript = join(ROOT, 'lib', 'terminal-standalone.ts');
255
- const termChild = spawn('npx', ['tsx', termScript], {
283
+ const termChild = spawn('npx', ['tsx', termScript, instanceTag], {
256
284
  cwd: ROOT,
257
285
  stdio: ['ignore', 'inherit', 'inherit'],
258
286
  env: { ...process.env },
@@ -262,13 +290,17 @@ function startServices() {
262
290
 
263
291
  // Telegram bot
264
292
  const telegramScript = join(ROOT, 'lib', 'telegram-standalone.ts');
265
- const telegramChild = spawn('npx', ['tsx', telegramScript], {
293
+ const telegramChild = spawn('npx', ['tsx', telegramScript, instanceTag], {
266
294
  cwd: ROOT,
267
295
  stdio: ['ignore', 'inherit', 'inherit'],
268
296
  env: { ...process.env },
269
297
  });
270
298
  services.push(telegramChild);
271
299
  console.log(`[forge] Telegram bot started (pid: ${telegramChild.pid})`);
300
+
301
+ // Track all child PIDs for clean shutdown
302
+ const childPids = services.map(c => c.pid).filter(Boolean);
303
+ savePids(childPids);
272
304
  }
273
305
 
274
306
  function stopServices() {
@@ -280,20 +312,47 @@ function stopServices() {
280
312
  }
281
313
 
282
314
  // ── Helper: stop running instance ──
283
- function stopServer() {
315
+ async function stopServer() {
284
316
  stopServices();
285
317
  try { unlinkSync(join(DATA_DIR, 'tunnel-state.json')); } catch {}
286
318
 
319
+ // Kill all tracked child PIDs first
320
+ killTrackedPids();
321
+
322
+ let stopped = false;
323
+
324
+ // Try PID file first
287
325
  try {
288
326
  const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim());
289
327
  process.kill(pid, 'SIGTERM');
290
- unlinkSync(PID_FILE);
291
328
  console.log(`[forge] Stopped (pid ${pid})`);
292
- return true;
293
- } catch {
329
+ stopped = true;
330
+ } catch {}
331
+ try { unlinkSync(PID_FILE); } catch {}
332
+
333
+ // Also kill by port (in case PID file is stale)
334
+ const portPids = [];
335
+ try {
336
+ const pids = execSync(`lsof -ti:${webPort}`, { encoding: 'utf-8', timeout: 3000 }).trim();
337
+ for (const p of pids.split('\n').filter(Boolean)) {
338
+ const pid = parseInt(p.trim());
339
+ try { process.kill(pid, 'SIGTERM'); stopped = true; portPids.push(pid); } catch {}
340
+ }
341
+ if (pids) console.log(`[forge] Killed processes on port ${webPort}`);
342
+ } catch {}
343
+
344
+ // Force kill after 2 seconds if SIGTERM didn't work
345
+ if (portPids.length > 0) {
346
+ await new Promise(r => setTimeout(r, 2000));
347
+ for (const pid of portPids) {
348
+ try { process.kill(pid, 'SIGKILL'); } catch {}
349
+ }
350
+ }
351
+
352
+ if (!stopped) {
294
353
  console.log('[forge] No running server found');
295
- return false;
296
354
  }
355
+ return stopped;
297
356
  }
298
357
 
299
358
  // ── Helper: start background server ──
@@ -329,13 +388,13 @@ function startBackground() {
329
388
 
330
389
  // ── Stop ──
331
390
  if (isStop) {
332
- stopServer();
391
+ await stopServer();
333
392
  process.exit(0);
334
393
  }
335
394
 
336
395
  // ── Restart ──
337
396
  if (isRestart) {
338
- stopServer();
397
+ await stopServer();
339
398
  // Wait for port to fully release
340
399
  const net = await import('node:net');
341
400
  for (let i = 0; i < 20; i++) {
package/cli/mw.ts CHANGED
@@ -16,7 +16,8 @@
16
16
  * mw watch <id> — live stream task output
17
17
  */
18
18
 
19
- const BASE = process.env.MW_URL || 'http://localhost:3000';
19
+ const _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === '--port');
20
+ const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '3000'}`;
20
21
 
21
22
  const [, , cmd, ...args] = process.argv;
22
23
 
@@ -8,13 +8,14 @@ import '@xterm/xterm/css/xterm.css';
8
8
  const SESSION_NAME = 'mw-docs-claude';
9
9
 
10
10
  function getWsUrl() {
11
- if (typeof window === 'undefined') return 'ws://localhost:3001';
11
+ if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
12
12
  const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
13
13
  const wsHost = window.location.hostname;
14
14
  if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
15
15
  return `${wsProtocol}//${window.location.host}/terminal-ws`;
16
16
  }
17
- return `${wsProtocol}//${wsHost}:3001`;
17
+ const webPort = parseInt(window.location.port) || 3000;
18
+ return `${wsProtocol}//${wsHost}:${webPort + 1}`;
18
19
  }
19
20
 
20
21
  export default function DocTerminal({ docRoot }: { docRoot: string }) {
@@ -17,6 +17,7 @@ interface WorkflowNode {
17
17
  interface Workflow {
18
18
  name: string;
19
19
  description?: string;
20
+ builtin?: boolean;
20
21
  vars: Record<string, string>;
21
22
  input: Record<string, string>;
22
23
  nodes: Record<string, WorkflowNode>;
@@ -168,7 +169,7 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
168
169
  >
169
170
  <option value="">Editor ▾</option>
170
171
  <option value="">+ New workflow</option>
171
- {workflows.map(w => <option key={w.name} value={w.name}>{w.name}</option>)}
172
+ {workflows.map(w => <option key={w.name} value={w.name}>{w.builtin ? '⚙ ' : ''}{w.name}</option>)}
172
173
  </select>
173
174
  <button
174
175
  onClick={() => setShowCreate(v => !v)}
@@ -188,7 +189,7 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
188
189
  >
189
190
  <option value="">Select workflow...</option>
190
191
  {workflows.map(w => (
191
- <option key={w.name} value={w.name}>{w.name}{w.description ? ` — ${w.description}` : ''}</option>
192
+ <option key={w.name} value={w.name}>{w.builtin ? '[Built-in] ' : ''}{w.name}{w.description ? ` — ${w.description}` : ''}</option>
192
193
  ))}
193
194
  </select>
194
195