@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/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
|
|
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,26 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { getDb } from '@/src/core/db/database';
|
|
3
|
+
import { getDbPath } from '@/src/config';
|
|
4
|
+
|
|
5
|
+
function db() { return getDb(getDbPath()); }
|
|
6
|
+
|
|
7
|
+
// GET /api/favorites — list all favorites
|
|
8
|
+
export async function GET() {
|
|
9
|
+
const rows = db().prepare('SELECT project_path FROM project_favorites ORDER BY created_at ASC').all() as any[];
|
|
10
|
+
return NextResponse.json(rows.map(r => r.project_path));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// POST /api/favorites — add or remove
|
|
14
|
+
export async function POST(req: Request) {
|
|
15
|
+
const { action, projectPath } = await req.json();
|
|
16
|
+
if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
|
|
17
|
+
|
|
18
|
+
if (action === 'add') {
|
|
19
|
+
db().prepare('INSERT OR IGNORE INTO project_favorites (project_path) VALUES (?)').run(projectPath);
|
|
20
|
+
} else if (action === 'remove') {
|
|
21
|
+
db().prepare('DELETE FROM project_favorites WHERE project_path = ?').run(projectPath);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rows = db().prepare('SELECT project_path FROM project_favorites ORDER BY created_at ASC').all() as any[];
|
|
25
|
+
return NextResponse.json(rows.map(r => r.project_path));
|
|
26
|
+
}
|
package/app/api/git/route.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
-
import { execSync } from 'node:child_process';
|
|
2
|
+
import { execSync, exec } from 'node:child_process';
|
|
3
3
|
import { existsSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
6
7
|
import { loadSettings } from '@/lib/settings';
|
|
7
8
|
|
|
8
9
|
function isUnderProjectRoot(dir: string): boolean {
|
|
@@ -11,8 +12,17 @@ function isUnderProjectRoot(dir: string): boolean {
|
|
|
11
12
|
return roots.some(root => dir.startsWith(root) || dir === root);
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const execAsync = promisify(exec);
|
|
16
|
+
|
|
17
|
+
function gitSync(cmd: string, cwd: string): string {
|
|
18
|
+
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function gitAsync(cmd: string, cwd: string): Promise<string> {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await execAsync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 });
|
|
24
|
+
return stdout.trim();
|
|
25
|
+
} catch { return ''; }
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
// GET /api/git?dir=<path> — git status for a project
|
|
@@ -23,38 +33,33 @@ export async function GET(req: NextRequest) {
|
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
try {
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
36
|
+
// Run all git commands in parallel
|
|
37
|
+
const [branchOut, statusOut, remoteOut, lastCommitOut, logOut] = await Promise.all([
|
|
38
|
+
gitAsync('rev-parse --abbrev-ref HEAD', dir),
|
|
39
|
+
gitAsync('status --porcelain -u', dir),
|
|
40
|
+
gitAsync('remote get-url origin', dir),
|
|
41
|
+
gitAsync('log -1 --format="%h %s"', dir),
|
|
42
|
+
gitAsync('log --format="%h||%s||%an||%ar" -10', dir),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const branch = branchOut;
|
|
46
|
+
const changes = statusOut ? statusOut.split('\n').filter(Boolean).map(line => ({
|
|
29
47
|
status: line.substring(0, 2).trim() || 'M',
|
|
30
48
|
path: line.substring(3).replace(/\/$/, ''),
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
|
-
let remote = '';
|
|
34
|
-
try { remote = git('remote get-url origin', dir); } catch {}
|
|
49
|
+
})) : [];
|
|
35
50
|
|
|
36
|
-
let ahead = 0;
|
|
37
|
-
let behind = 0;
|
|
51
|
+
let ahead = 0, behind = 0;
|
|
38
52
|
try {
|
|
39
|
-
const counts =
|
|
40
|
-
const [a, b] = counts.split('\t');
|
|
41
|
-
ahead = parseInt(a) || 0;
|
|
42
|
-
behind = parseInt(b) || 0;
|
|
53
|
+
const counts = await gitAsync(`rev-list --left-right --count HEAD...origin/${branch}`, dir);
|
|
54
|
+
if (counts) { const [a, b] = counts.split('\t'); ahead = parseInt(a) || 0; behind = parseInt(b) || 0; }
|
|
43
55
|
} catch {}
|
|
44
56
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
const logOut = git('log --format="%h||%s||%an||%ar" -20', dir);
|
|
51
|
-
log = logOut.split('\n').filter(Boolean).map(line => {
|
|
52
|
-
const [hash, message, author, date] = line.split('||');
|
|
53
|
-
return { hash, message, author, date };
|
|
54
|
-
});
|
|
55
|
-
} catch {}
|
|
57
|
+
const log = logOut ? logOut.split('\n').filter(Boolean).map(line => {
|
|
58
|
+
const [hash, message, author, date] = line.split('||');
|
|
59
|
+
return { hash, message, author, date };
|
|
60
|
+
}) : [];
|
|
56
61
|
|
|
57
|
-
return NextResponse.json({ branch, changes, remote, ahead, behind, lastCommit, log });
|
|
62
|
+
return NextResponse.json({ branch, changes, remote: remoteOut, ahead, behind, lastCommit: lastCommitOut, log });
|
|
58
63
|
} catch (e: any) {
|
|
59
64
|
return NextResponse.json({ error: e.message }, { status: 500 });
|
|
60
65
|
}
|
|
@@ -96,30 +101,30 @@ export async function POST(req: NextRequest) {
|
|
|
96
101
|
if (!message) return NextResponse.json({ error: 'message required' }, { status: 400 });
|
|
97
102
|
if (files && files.length > 0) {
|
|
98
103
|
for (const f of files) {
|
|
99
|
-
|
|
104
|
+
gitSync(`add "${f}"`, dir);
|
|
100
105
|
}
|
|
101
106
|
} else {
|
|
102
|
-
|
|
107
|
+
gitSync('add -A', dir);
|
|
103
108
|
}
|
|
104
|
-
|
|
109
|
+
gitSync(`commit -m "${message.replace(/"/g, '\\"')}"`, dir);
|
|
105
110
|
return NextResponse.json({ ok: true });
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
if (action === 'push') {
|
|
109
|
-
const output =
|
|
114
|
+
const output = gitSync('push', dir);
|
|
110
115
|
return NextResponse.json({ ok: true, output });
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
if (action === 'pull') {
|
|
114
|
-
const output =
|
|
119
|
+
const output = gitSync('pull', dir);
|
|
115
120
|
return NextResponse.json({ ok: true, output });
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
if (action === 'stage') {
|
|
119
124
|
if (files && files.length > 0) {
|
|
120
|
-
for (const f of files)
|
|
125
|
+
for (const f of files) gitSync(`add "${f}"`, dir);
|
|
121
126
|
} else {
|
|
122
|
-
|
|
127
|
+
gitSync('add -A', dir);
|
|
123
128
|
}
|
|
124
129
|
return NextResponse.json({ ok: true });
|
|
125
130
|
}
|
|
@@ -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 (
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|
package/app/api/skills/route.ts
CHANGED
|
@@ -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';
|
|
@@ -98,8 +99,6 @@ export async function GET(req: Request) {
|
|
|
98
99
|
return NextResponse.json({ content: '(Failed to load)' });
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
|
-
// Refresh install state from filesystem
|
|
102
|
-
refreshInstallState(getProjectPaths());
|
|
103
102
|
const skills = listSkills();
|
|
104
103
|
const projects = getProjectPaths().map(p => ({ path: p, name: p.split('/').pop() || p }));
|
|
105
104
|
return NextResponse.json({ skills, projects });
|
|
@@ -156,5 +155,16 @@ export async function POST(req: Request) {
|
|
|
156
155
|
}
|
|
157
156
|
}
|
|
158
157
|
|
|
158
|
+
if (body.action === 'purge-deleted') {
|
|
159
|
+
const { name } = body;
|
|
160
|
+
if (!name) return NextResponse.json({ ok: false, error: 'name required' }, { status: 400 });
|
|
161
|
+
try {
|
|
162
|
+
purgeDeletedSkill(name);
|
|
163
|
+
return NextResponse.json({ ok: true });
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
159
169
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
160
170
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getDb } from '@/src/core/db/database';
|
|
3
|
+
import { getDbPath } from '@/src/config';
|
|
4
|
+
|
|
5
|
+
function db() { return getDb(getDbPath()); }
|
|
6
|
+
|
|
7
|
+
export async function GET(req: NextRequest) {
|
|
8
|
+
const type = req.nextUrl.searchParams.get('type') || 'projects';
|
|
9
|
+
try {
|
|
10
|
+
const row = db().prepare('SELECT data FROM tab_state WHERE type = ?').get(type) as any;
|
|
11
|
+
if (row?.data) return NextResponse.json(JSON.parse(row.data));
|
|
12
|
+
} catch {}
|
|
13
|
+
return NextResponse.json({ tabs: [], activeTabId: 0 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function POST(req: NextRequest) {
|
|
17
|
+
const type = req.nextUrl.searchParams.get('type') || 'projects';
|
|
18
|
+
try {
|
|
19
|
+
const body = await req.json();
|
|
20
|
+
db().prepare('INSERT OR REPLACE INTO tab_state (type, data) VALUES (?, ?)').run(type, JSON.stringify(body));
|
|
21
|
+
return NextResponse.json({ ok: true });
|
|
22
|
+
} catch (e: any) {
|
|
23
|
+
return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
|
|
24
|
+
}
|
|
25
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -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);
|
|
@@ -188,7 +189,7 @@ if (!isStop) {
|
|
|
188
189
|
writeFileSync(settingsFile, YAML.stringify(settings), 'utf-8');
|
|
189
190
|
console.log('[forge] Admin password saved');
|
|
190
191
|
|
|
191
|
-
if (resetPassword
|
|
192
|
+
if (resetPassword) {
|
|
192
193
|
process.exit(0);
|
|
193
194
|
}
|
|
194
195
|
}
|
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 }) {
|