@aion0/forge 0.3.5 → 0.3.7
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 +15 -0
- package/app/api/help/route.ts +78 -0
- package/app/api/logs/route.ts +100 -0
- package/app/api/settings/route.ts +2 -0
- package/bin/forge-server.mjs +9 -0
- package/components/Dashboard.tsx +31 -1
- package/components/HelpDialog.tsx +169 -0
- package/components/HelpTerminal.tsx +130 -0
- package/components/LogViewer.tsx +194 -0
- package/components/ProjectManager.tsx +26 -8
- package/lib/auth.ts +2 -0
- package/lib/cloudflared.ts +71 -25
- package/lib/help-docs/00-overview.md +34 -0
- package/lib/help-docs/01-settings.md +37 -0
- package/lib/help-docs/02-telegram.md +41 -0
- package/lib/help-docs/03-tunnel.md +31 -0
- package/lib/help-docs/04-tasks.md +52 -0
- package/lib/help-docs/05-pipelines.md +73 -0
- package/lib/help-docs/06-skills.md +43 -0
- package/lib/help-docs/07-projects.md +39 -0
- package/lib/help-docs/08-rules.md +53 -0
- package/lib/help-docs/09-issue-autofix.md +51 -0
- package/lib/help-docs/10-troubleshooting.md +82 -0
- package/lib/init.ts +3 -0
- package/lib/logger.ts +73 -0
- package/lib/password.ts +1 -1
- package/lib/skills.ts +6 -0
- package/lib/task-manager.ts +2 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
export default function LogViewer() {
|
|
6
|
+
const [lines, setLines] = useState<string[]>([]);
|
|
7
|
+
const [total, setTotal] = useState(0);
|
|
8
|
+
const [fileSize, setFileSize] = useState(0);
|
|
9
|
+
const [filePath, setFilePath] = useState('');
|
|
10
|
+
const [search, setSearch] = useState('');
|
|
11
|
+
const [maxLines, setMaxLines] = useState(200);
|
|
12
|
+
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
13
|
+
const [processes, setProcesses] = useState<{ pid: string; cpu: string; mem: string; cmd: string }[]>([]);
|
|
14
|
+
const [showProcesses, setShowProcesses] = useState(false);
|
|
15
|
+
const bottomRef = useRef<HTMLDivElement>(null);
|
|
16
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
18
|
+
|
|
19
|
+
const fetchLogs = useCallback(async () => {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(`/api/logs?lines=${maxLines}${search ? `&search=${encodeURIComponent(search)}` : ''}`);
|
|
22
|
+
const data = await res.json();
|
|
23
|
+
setLines(data.lines || []);
|
|
24
|
+
setTotal(data.total || 0);
|
|
25
|
+
setFileSize(data.size || 0);
|
|
26
|
+
if (data.file) setFilePath(data.file);
|
|
27
|
+
} catch {}
|
|
28
|
+
}, [maxLines, search]);
|
|
29
|
+
|
|
30
|
+
const fetchProcesses = async () => {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch('/api/logs', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({ action: 'processes' }),
|
|
36
|
+
});
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
setProcesses(data.processes || []);
|
|
39
|
+
} catch {}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const clearLogs = async () => {
|
|
43
|
+
if (!confirm('Clear all logs?')) return;
|
|
44
|
+
await fetch('/api/logs', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ action: 'clear' }),
|
|
48
|
+
});
|
|
49
|
+
fetchLogs();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Initial + auto refresh
|
|
53
|
+
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!autoRefresh) return;
|
|
56
|
+
const id = setInterval(fetchLogs, 3000);
|
|
57
|
+
return () => clearInterval(id);
|
|
58
|
+
}, [autoRefresh, fetchLogs]);
|
|
59
|
+
|
|
60
|
+
// Auto scroll
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (autoScroll) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
63
|
+
}, [lines, autoScroll]);
|
|
64
|
+
|
|
65
|
+
// Detect manual scroll
|
|
66
|
+
const onScroll = () => {
|
|
67
|
+
const el = containerRef.current;
|
|
68
|
+
if (!el) return;
|
|
69
|
+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
|
70
|
+
setAutoScroll(atBottom);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const formatSize = (bytes: number) => {
|
|
74
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
75
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
76
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const getLineColor = (line: string) => {
|
|
80
|
+
if (line.includes('[error]') || line.includes('Error') || line.includes('FATAL')) return 'text-red-400';
|
|
81
|
+
if (line.includes('[warn]') || line.includes('Warning') || line.includes('WARN')) return 'text-yellow-400';
|
|
82
|
+
if (line.includes('[forge]') || line.includes('[init]')) return 'text-cyan-400';
|
|
83
|
+
if (line.includes('[task]') || line.includes('[pipeline]')) return 'text-green-400';
|
|
84
|
+
if (line.includes('[telegram]') || line.includes('[terminal]')) return 'text-purple-400';
|
|
85
|
+
if (line.includes('[issue-scanner]') || line.includes('[watcher]')) return 'text-orange-300';
|
|
86
|
+
return 'text-[var(--text-primary)]';
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
91
|
+
{/* Toolbar */}
|
|
92
|
+
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border)] shrink-0 flex-wrap">
|
|
93
|
+
<span className="text-xs font-semibold text-[var(--text-primary)]">Logs</span>
|
|
94
|
+
<span className="text-[8px] text-[var(--text-secondary)]">{total} lines · {formatSize(fileSize)}</span>
|
|
95
|
+
|
|
96
|
+
{/* Search */}
|
|
97
|
+
<input
|
|
98
|
+
type="text"
|
|
99
|
+
value={search}
|
|
100
|
+
onChange={e => setSearch(e.target.value)}
|
|
101
|
+
placeholder="Filter..."
|
|
102
|
+
className="px-2 py-0.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)] w-32 focus:outline-none focus:border-[var(--accent)]"
|
|
103
|
+
/>
|
|
104
|
+
|
|
105
|
+
{/* Max lines */}
|
|
106
|
+
<select
|
|
107
|
+
value={maxLines}
|
|
108
|
+
onChange={e => setMaxLines(Number(e.target.value))}
|
|
109
|
+
className="px-1 py-0.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
110
|
+
>
|
|
111
|
+
<option value={100}>100 lines</option>
|
|
112
|
+
<option value={200}>200 lines</option>
|
|
113
|
+
<option value={500}>500 lines</option>
|
|
114
|
+
<option value={1000}>1000 lines</option>
|
|
115
|
+
</select>
|
|
116
|
+
|
|
117
|
+
<div className="ml-auto flex items-center gap-2">
|
|
118
|
+
{/* Auto refresh toggle */}
|
|
119
|
+
<label className="flex items-center gap-1 text-[9px] text-[var(--text-secondary)] cursor-pointer">
|
|
120
|
+
<input type="checkbox" checked={autoRefresh} onChange={e => setAutoRefresh(e.target.checked)} className="accent-[var(--accent)]" />
|
|
121
|
+
Auto (3s)
|
|
122
|
+
</label>
|
|
123
|
+
|
|
124
|
+
{/* Processes */}
|
|
125
|
+
<button
|
|
126
|
+
onClick={() => { setShowProcesses(v => !v); fetchProcesses(); }}
|
|
127
|
+
className={`text-[9px] px-2 py-0.5 rounded ${showProcesses ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
128
|
+
>Processes</button>
|
|
129
|
+
|
|
130
|
+
{/* Refresh */}
|
|
131
|
+
<button onClick={fetchLogs} className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">↻</button>
|
|
132
|
+
|
|
133
|
+
{/* Clear */}
|
|
134
|
+
<button onClick={clearLogs} className="text-[9px] text-[var(--red)] hover:underline">Clear</button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{/* Processes panel */}
|
|
139
|
+
{showProcesses && processes.length > 0 && (
|
|
140
|
+
<div className="border-b border-[var(--border)] bg-[var(--bg-tertiary)] max-h-32 overflow-y-auto shrink-0">
|
|
141
|
+
<div className="px-4 py-1 text-[8px] text-[var(--text-secondary)] uppercase">Running Processes</div>
|
|
142
|
+
{processes.map(p => (
|
|
143
|
+
<div key={p.pid} className="px-4 py-0.5 text-[10px] font-mono flex gap-3">
|
|
144
|
+
<span className="text-[var(--accent)] w-12 shrink-0">{p.pid}</span>
|
|
145
|
+
<span className="text-green-400 w-10 shrink-0">{p.cpu}%</span>
|
|
146
|
+
<span className="text-yellow-400 w-10 shrink-0">{p.mem}%</span>
|
|
147
|
+
<span className="text-[var(--text-secondary)] truncate">{p.cmd}</span>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Log content */}
|
|
154
|
+
<div
|
|
155
|
+
ref={containerRef}
|
|
156
|
+
onScroll={onScroll}
|
|
157
|
+
className="flex-1 overflow-auto bg-[var(--bg-primary)] font-mono text-[11px] leading-[1.6]"
|
|
158
|
+
>
|
|
159
|
+
{lines.length === 0 ? (
|
|
160
|
+
<div className="flex items-center justify-center h-full text-[var(--text-secondary)] text-xs">
|
|
161
|
+
{filePath ? 'No log entries' : 'Log file not found — server running in foreground?'}
|
|
162
|
+
</div>
|
|
163
|
+
) : (
|
|
164
|
+
<div className="p-3">
|
|
165
|
+
{lines.map((line, i) => (
|
|
166
|
+
<div key={i} className={`${getLineColor(line)} hover:bg-[var(--bg-tertiary)] px-1`}>
|
|
167
|
+
{search ? (
|
|
168
|
+
// Highlight search matches
|
|
169
|
+
line.split(new RegExp(`(${search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')).map((part, j) =>
|
|
170
|
+
part.toLowerCase() === search.toLowerCase()
|
|
171
|
+
? <span key={j} className="bg-[var(--yellow)]/30 text-[var(--yellow)]">{part}</span>
|
|
172
|
+
: part
|
|
173
|
+
)
|
|
174
|
+
) : line}
|
|
175
|
+
</div>
|
|
176
|
+
))}
|
|
177
|
+
<div ref={bottomRef} />
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{/* Footer */}
|
|
183
|
+
<div className="px-4 py-1 border-t border-[var(--border)] shrink-0 flex items-center gap-2 text-[8px] text-[var(--text-secondary)]">
|
|
184
|
+
<span>{filePath}</span>
|
|
185
|
+
{!autoScroll && (
|
|
186
|
+
<button
|
|
187
|
+
onClick={() => { setAutoScroll(true); bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }}
|
|
188
|
+
className="ml-auto text-[var(--accent)] hover:underline"
|
|
189
|
+
>↓ Scroll to bottom</button>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -192,9 +192,18 @@ export default function ProjectManager() {
|
|
|
192
192
|
};
|
|
193
193
|
|
|
194
194
|
// Group projects by root
|
|
195
|
+
const [collapsedRoots, setCollapsedRoots] = useState<Set<string>>(new Set());
|
|
195
196
|
const roots = [...new Set(projects.map(p => p.root))];
|
|
196
197
|
const favoriteProjects = projects.filter(p => favorites.includes(p.path));
|
|
197
198
|
|
|
199
|
+
const toggleRoot = (root: string) => {
|
|
200
|
+
setCollapsedRoots(prev => {
|
|
201
|
+
const next = new Set(prev);
|
|
202
|
+
if (next.has(root)) next.delete(root); else next.add(root);
|
|
203
|
+
return next;
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
198
207
|
return (
|
|
199
208
|
<div className="flex-1 flex min-h-0">
|
|
200
209
|
{/* Left sidebar — project list */}
|
|
@@ -234,10 +243,14 @@ export default function ProjectManager() {
|
|
|
234
243
|
{/* Favorites section */}
|
|
235
244
|
{favoriteProjects.length > 0 && (
|
|
236
245
|
<div>
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
246
|
+
<button
|
|
247
|
+
onClick={() => toggleRoot('__favorites__')}
|
|
248
|
+
className="w-full px-3 py-1 text-[9px] text-[var(--yellow)] uppercase bg-[var(--bg-tertiary)] flex items-center gap-1 hover:bg-[var(--border)]/30"
|
|
249
|
+
>
|
|
250
|
+
<span className="text-[8px]">{collapsedRoots.has('__favorites__') ? '▸' : '▾'}</span>
|
|
251
|
+
<span>★</span> Favorites ({favoriteProjects.length})
|
|
252
|
+
</button>
|
|
253
|
+
{!collapsedRoots.has('__favorites__') && favoriteProjects.map(p => (
|
|
241
254
|
<button
|
|
242
255
|
key={`fav-${p.path}`}
|
|
243
256
|
onClick={() => openProjectTab(p)}
|
|
@@ -262,12 +275,17 @@ export default function ProjectManager() {
|
|
|
262
275
|
{roots.map(root => {
|
|
263
276
|
const rootName = root.split('/').pop() || root;
|
|
264
277
|
const rootProjects = projects.filter(p => p.root === root).sort((a, b) => a.name.localeCompare(b.name));
|
|
278
|
+
const isCollapsed = collapsedRoots.has(root);
|
|
265
279
|
return (
|
|
266
280
|
<div key={root}>
|
|
267
|
-
<
|
|
268
|
-
{
|
|
269
|
-
|
|
270
|
-
|
|
281
|
+
<button
|
|
282
|
+
onClick={() => toggleRoot(root)}
|
|
283
|
+
className="w-full px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)] flex items-center gap-1 hover:bg-[var(--border)]/30"
|
|
284
|
+
>
|
|
285
|
+
<span className="text-[8px]">{isCollapsed ? '▸' : '▾'}</span>
|
|
286
|
+
{rootName} ({rootProjects.length})
|
|
287
|
+
</button>
|
|
288
|
+
{!isCollapsed && rootProjects.map(p => (
|
|
271
289
|
<button
|
|
272
290
|
key={p.path}
|
|
273
291
|
onClick={() => openProjectTab(p)}
|
package/lib/auth.ts
CHANGED
|
@@ -37,8 +37,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
|
37
37
|
if (verifyLogin(password, sessionCode, isRemote)) {
|
|
38
38
|
const { loadSettings } = await import('./settings');
|
|
39
39
|
const settings = loadSettings();
|
|
40
|
+
console.log(`[auth] Login success (${isRemote ? 'remote' : 'local'})`);
|
|
40
41
|
return { id: 'local', name: settings.displayName || 'Forge', email: settings.displayEmail || 'local@forge' };
|
|
41
42
|
}
|
|
43
|
+
console.warn(`[auth] Login failed (${isRemote ? 'remote' : 'local'})`);
|
|
42
44
|
return null;
|
|
43
45
|
},
|
|
44
46
|
}),
|
package/lib/cloudflared.ts
CHANGED
|
@@ -23,11 +23,13 @@ function getDownloadUrl(): string {
|
|
|
23
23
|
const base = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
|
|
24
24
|
|
|
25
25
|
if (os === 'darwin') {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
return cpu === 'arm64'
|
|
27
|
+
? `${base}/cloudflared-darwin-arm64.tgz`
|
|
28
|
+
: `${base}/cloudflared-darwin-amd64.tgz`;
|
|
28
29
|
}
|
|
29
30
|
if (os === 'linux') {
|
|
30
31
|
if (cpu === 'arm64') return `${base}/cloudflared-linux-arm64`;
|
|
32
|
+
if (cpu === 'arm') return `${base}/cloudflared-linux-arm`;
|
|
31
33
|
return `${base}/cloudflared-linux-amd64`;
|
|
32
34
|
}
|
|
33
35
|
if (os === 'win32') {
|
|
@@ -38,49 +40,87 @@ function getDownloadUrl(): string {
|
|
|
38
40
|
|
|
39
41
|
// ─── Download helper ────────────────────────────────────────────
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000; // 2 minutes total per redirect hop
|
|
44
|
+
|
|
45
|
+
function followRedirects(url: string, dest: string, redirectsLeft = 10): Promise<void> {
|
|
42
46
|
return new Promise((resolve, reject) => {
|
|
43
47
|
const client = url.startsWith('https') ? https : http;
|
|
44
|
-
client.get(url, { headers: { 'User-Agent': 'forge' } }, (res) => {
|
|
48
|
+
const req = client.get(url, { headers: { 'User-Agent': 'forge/1.0' } }, (res) => {
|
|
45
49
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
46
|
-
|
|
50
|
+
res.resume(); // drain redirect response
|
|
51
|
+
if (redirectsLeft <= 0) {
|
|
52
|
+
reject(new Error('Too many redirects'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
followRedirects(res.headers.location, dest, redirectsLeft - 1).then(resolve, reject);
|
|
47
56
|
return;
|
|
48
57
|
}
|
|
49
58
|
if (res.statusCode !== 200) {
|
|
59
|
+
res.resume();
|
|
50
60
|
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
51
61
|
return;
|
|
52
62
|
}
|
|
53
63
|
const file = createWriteStream(dest);
|
|
54
64
|
res.pipe(file);
|
|
55
65
|
file.on('finish', () => file.close(() => resolve()));
|
|
56
|
-
file.on('error', reject);
|
|
57
|
-
|
|
66
|
+
file.on('error', (err) => { try { unlinkSync(dest); } catch {} reject(err); });
|
|
67
|
+
res.on('error', (err) => { try { unlinkSync(dest); } catch {} reject(err); });
|
|
68
|
+
});
|
|
69
|
+
req.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
|
|
70
|
+
req.destroy(new Error(`Download timed out after ${DOWNLOAD_TIMEOUT_MS / 1000}s`));
|
|
71
|
+
});
|
|
72
|
+
req.on('error', reject);
|
|
58
73
|
});
|
|
59
74
|
}
|
|
60
75
|
|
|
76
|
+
// Guard against concurrent downloads
|
|
77
|
+
let downloadPromise: Promise<string> | null = null;
|
|
78
|
+
|
|
61
79
|
export async function downloadCloudflared(): Promise<string> {
|
|
62
80
|
if (existsSync(BIN_PATH)) return BIN_PATH;
|
|
81
|
+
if (downloadPromise) return downloadPromise;
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
console.log(`[cloudflared] Downloading from ${url}...`);
|
|
70
|
-
await followRedirects(url, tmpPath);
|
|
83
|
+
downloadPromise = (async () => {
|
|
84
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
85
|
+
const url = getDownloadUrl();
|
|
86
|
+
const isTgz = url.endsWith('.tgz');
|
|
87
|
+
const tmpPath = isTgz ? `${BIN_PATH}.tgz` : `${BIN_PATH}.tmp`;
|
|
71
88
|
|
|
72
|
-
|
|
73
|
-
// Extract tgz (macOS)
|
|
74
|
-
execSync(`tar -xzf "${tmpPath}" -C "${BIN_DIR}"`, { encoding: 'utf-8' });
|
|
89
|
+
// Clean up any leftover partial files from a previous failed attempt
|
|
75
90
|
try { unlinkSync(tmpPath); } catch {}
|
|
76
|
-
}
|
|
77
91
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
console.log(`[cloudflared] Downloading from ${url}...`);
|
|
93
|
+
try {
|
|
94
|
+
await followRedirects(url, tmpPath);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
try { unlinkSync(tmpPath); } catch {}
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isTgz) {
|
|
101
|
+
// Extract tgz (macOS)
|
|
102
|
+
try {
|
|
103
|
+
execSync(`tar -xzf "${tmpPath}" -C "${BIN_DIR}"`, { encoding: 'utf-8' });
|
|
104
|
+
} finally {
|
|
105
|
+
try { unlinkSync(tmpPath); } catch {}
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
// Rename .tmp to final name atomically
|
|
109
|
+
const { renameSync } = require('node:fs');
|
|
110
|
+
renameSync(tmpPath, BIN_PATH);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (platform() !== 'win32') {
|
|
114
|
+
chmodSync(BIN_PATH, 0o755);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log(`[cloudflared] Installed to ${BIN_PATH}`);
|
|
118
|
+
return BIN_PATH;
|
|
119
|
+
})().finally(() => {
|
|
120
|
+
downloadPromise = null;
|
|
121
|
+
});
|
|
81
122
|
|
|
82
|
-
|
|
83
|
-
return BIN_PATH;
|
|
123
|
+
return downloadPromise;
|
|
84
124
|
}
|
|
85
125
|
|
|
86
126
|
export function isInstalled(): boolean {
|
|
@@ -130,6 +170,7 @@ function pushLog(line: string) {
|
|
|
130
170
|
}
|
|
131
171
|
|
|
132
172
|
export async function startTunnel(localPort: number = parseInt(process.env.PORT || '3000')): Promise<{ url?: string; error?: string }> {
|
|
173
|
+
console.log(`[tunnel] Starting tunnel on port ${localPort}...`);
|
|
133
174
|
// Check if this worker already has a process
|
|
134
175
|
if (state.process) {
|
|
135
176
|
return state.url ? { url: state.url } : { error: 'Tunnel is starting...' };
|
|
@@ -207,6 +248,7 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
|
|
|
207
248
|
state.status = 'error';
|
|
208
249
|
state.error = err.message;
|
|
209
250
|
pushLog(`[error] ${err.message}`);
|
|
251
|
+
console.error(`[tunnel] Error: ${err.message}`);
|
|
210
252
|
if (!resolved) {
|
|
211
253
|
resolved = true;
|
|
212
254
|
resolve({ error: err.message });
|
|
@@ -214,16 +256,19 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
|
|
|
214
256
|
});
|
|
215
257
|
|
|
216
258
|
state.process.on('exit', (code) => {
|
|
259
|
+
const recentLog = state.log.slice(-5).join(' ').slice(0, 200);
|
|
260
|
+
const reason = code !== 0 ? `cloudflared failed (exit ${code}): ${recentLog || 'no output'}` : 'cloudflared stopped';
|
|
261
|
+
console.log(`[tunnel] ${reason}`);
|
|
217
262
|
state.process = null;
|
|
218
263
|
if (state.status !== 'error') {
|
|
219
264
|
state.status = 'stopped';
|
|
220
265
|
}
|
|
221
266
|
state.url = null;
|
|
222
267
|
saveTunnelState();
|
|
223
|
-
pushLog(`[exit]
|
|
268
|
+
pushLog(`[exit] ${reason}`);
|
|
224
269
|
if (!resolved) {
|
|
225
270
|
resolved = true;
|
|
226
|
-
resolve({ error:
|
|
271
|
+
resolve({ error: reason });
|
|
227
272
|
}
|
|
228
273
|
});
|
|
229
274
|
|
|
@@ -241,6 +286,7 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
|
|
|
241
286
|
}
|
|
242
287
|
|
|
243
288
|
export function stopTunnel() {
|
|
289
|
+
console.log('[tunnel] Stopping tunnel');
|
|
244
290
|
stopHealthCheck();
|
|
245
291
|
if (state.process) {
|
|
246
292
|
state.process.kill('SIGTERM');
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Forge Overview
|
|
2
|
+
|
|
3
|
+
Forge is a self-hosted Vibe Coding platform for Claude Code. It provides a browser-based terminal, AI task orchestration, remote access, and mobile control via Telegram.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @aion0/forge
|
|
9
|
+
forge server start
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Open `http://localhost:3000`. First launch prompts you to set an admin password.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
- Node.js >= 20
|
|
16
|
+
- tmux (`brew install tmux` on macOS)
|
|
17
|
+
- Claude Code CLI (`npm install -g @anthropic-ai/claude-code`)
|
|
18
|
+
|
|
19
|
+
## Data Location
|
|
20
|
+
- Config: `~/.forge/` (binaries)
|
|
21
|
+
- Data: `~/.forge/data/` (settings, database, state)
|
|
22
|
+
- Claude: `~/.claude/` (skills, commands, sessions)
|
|
23
|
+
|
|
24
|
+
## Server Commands
|
|
25
|
+
```bash
|
|
26
|
+
forge server start # background (default)
|
|
27
|
+
forge server start --foreground # foreground
|
|
28
|
+
forge server start --dev # dev mode with hot-reload
|
|
29
|
+
forge server stop # stop
|
|
30
|
+
forge server restart # restart
|
|
31
|
+
forge server start --port 4000 # custom port
|
|
32
|
+
forge server start --dir ~/.forge-test # custom data dir
|
|
33
|
+
forge --reset-password # reset admin password
|
|
34
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Settings Configuration
|
|
2
|
+
|
|
3
|
+
Settings are stored in `~/.forge/data/settings.yaml`. Configure via the web UI (Settings button in top-right menu) or edit YAML directly.
|
|
4
|
+
|
|
5
|
+
## All Settings Fields
|
|
6
|
+
|
|
7
|
+
| Field | Type | Default | Description |
|
|
8
|
+
|-------|------|---------|-------------|
|
|
9
|
+
| `projectRoots` | string[] | `[]` | Directories containing your projects (e.g. `~/Projects`) |
|
|
10
|
+
| `docRoots` | string[] | `[]` | Markdown/Obsidian vault directories |
|
|
11
|
+
| `claudePath` | string | `""` | Path to claude binary (auto-detected if empty) |
|
|
12
|
+
| `claudeHome` | string | `""` | Claude Code home directory (default: `~/.claude`) |
|
|
13
|
+
| `telegramBotToken` | string | `""` | Telegram Bot API token (encrypted) |
|
|
14
|
+
| `telegramChatId` | string | `""` | Telegram chat ID (comma-separated for multiple users) |
|
|
15
|
+
| `notifyOnComplete` | boolean | `true` | Telegram notification on task completion |
|
|
16
|
+
| `notifyOnFailure` | boolean | `true` | Telegram notification on task failure |
|
|
17
|
+
| `tunnelAutoStart` | boolean | `false` | Auto-start Cloudflare Tunnel on server startup |
|
|
18
|
+
| `telegramTunnelPassword` | string | `""` | Admin password for login + tunnel + secrets (encrypted) |
|
|
19
|
+
| `taskModel` | string | `"default"` | Model for background tasks |
|
|
20
|
+
| `pipelineModel` | string | `"default"` | Model for pipeline workflows |
|
|
21
|
+
| `telegramModel` | string | `"sonnet"` | Model for Telegram AI features |
|
|
22
|
+
| `skipPermissions` | boolean | `false` | Add `--dangerously-skip-permissions` to claude invocations |
|
|
23
|
+
| `notificationRetentionDays` | number | `30` | Auto-cleanup notifications older than N days |
|
|
24
|
+
| `skillsRepoUrl` | string | forge-skills URL | GitHub raw URL for skills registry |
|
|
25
|
+
| `displayName` | string | `"Forge"` | Display name shown in header |
|
|
26
|
+
| `displayEmail` | string | `""` | User email |
|
|
27
|
+
|
|
28
|
+
## Admin Password
|
|
29
|
+
|
|
30
|
+
- Set on first launch (CLI prompt)
|
|
31
|
+
- Required for: login, tunnel start, secret changes, Telegram commands
|
|
32
|
+
- Reset: `forge --reset-password`
|
|
33
|
+
- Forgot? Run `forge --reset-password` in terminal
|
|
34
|
+
|
|
35
|
+
## Encrypted Fields
|
|
36
|
+
|
|
37
|
+
`telegramBotToken` and `telegramTunnelPassword` are encrypted with AES-256-GCM. The encryption key is stored at `~/.forge/data/.encrypt-key`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Telegram Bot Setup
|
|
2
|
+
|
|
3
|
+
## Setup Steps
|
|
4
|
+
|
|
5
|
+
1. Open Telegram, search for [@BotFather](https://t.me/botfather)
|
|
6
|
+
2. Send `/newbot`, follow prompts to create a bot
|
|
7
|
+
3. Copy the bot token (looks like `6234567890:ABCDefGHIJKLMNOPQRSTUVWXYZ`)
|
|
8
|
+
4. In Forge Settings, paste the token into **Telegram Bot Token**
|
|
9
|
+
5. To get your Chat ID: send any message to your bot, then visit `https://api.telegram.org/bot<TOKEN>/getUpdates` — find `chat.id` in the response
|
|
10
|
+
6. Paste the Chat ID into **Telegram Chat ID** in Settings
|
|
11
|
+
7. The bot starts automatically after saving
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
| Command | Description |
|
|
16
|
+
|---------|-------------|
|
|
17
|
+
| `/task <project> <prompt>` | Create a background task |
|
|
18
|
+
| `/tasks [status]` | List tasks (running/queued/done/failed) |
|
|
19
|
+
| `/sessions [project]` | AI summary of Claude Code sessions |
|
|
20
|
+
| `/watch <id>` | Live stream task output |
|
|
21
|
+
| `/unwatch <id>` | Stop streaming |
|
|
22
|
+
| `/docs <query>` | Search Obsidian vault |
|
|
23
|
+
| `/note <text>` | Quick note to vault |
|
|
24
|
+
| `/peek <project>` | Preview running session |
|
|
25
|
+
| `/cancel <id>` | Cancel a task |
|
|
26
|
+
| `/retry <id>` | Retry a failed task |
|
|
27
|
+
| `/tunnel_start <password>` | Start Cloudflare Tunnel (returns URL + code) |
|
|
28
|
+
| `/tunnel_stop` | Stop tunnel |
|
|
29
|
+
| `/tunnel_code <password>` | Get session code for remote login |
|
|
30
|
+
| `/projects` | List configured projects |
|
|
31
|
+
|
|
32
|
+
## Shortcuts
|
|
33
|
+
- Reply to a task message to interact with it
|
|
34
|
+
- Send `"project: instructions"` to quick-create a task
|
|
35
|
+
- Numbered lists — reply with a number to select
|
|
36
|
+
|
|
37
|
+
## Troubleshooting
|
|
38
|
+
|
|
39
|
+
- **Bot not responding**: Check token is correct, restart server
|
|
40
|
+
- **"Unauthorized"**: Chat ID doesn't match configured value
|
|
41
|
+
- **Multiple users**: Set comma-separated Chat IDs (e.g. `123456,789012`)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Remote Access (Cloudflare Tunnel)
|
|
2
|
+
|
|
3
|
+
## How It Works
|
|
4
|
+
|
|
5
|
+
Forge creates a temporary Cloudflare Tunnel — a secure public URL that routes to your local Forge server. No Cloudflare account needed.
|
|
6
|
+
|
|
7
|
+
## Start Tunnel
|
|
8
|
+
|
|
9
|
+
**From UI**: Click the "Tunnel" button in the top-right header.
|
|
10
|
+
|
|
11
|
+
**From Telegram**: `/tunnel_start <admin_password>`
|
|
12
|
+
|
|
13
|
+
**Auto-start**: Set `tunnelAutoStart: true` in Settings.
|
|
14
|
+
|
|
15
|
+
## Login Flow
|
|
16
|
+
|
|
17
|
+
- **Local access** (localhost, LAN): Admin password only
|
|
18
|
+
- **Remote access** (via tunnel, `.trycloudflare.com`): Admin password + Session Code (2FA)
|
|
19
|
+
|
|
20
|
+
Session code is generated when tunnel starts. Get it via:
|
|
21
|
+
- Telegram: `/tunnel_code <password>`
|
|
22
|
+
- CLI: `forge tcode`
|
|
23
|
+
|
|
24
|
+
## Troubleshooting
|
|
25
|
+
|
|
26
|
+
- **Tunnel stuck at "starting"**: Kill old cloudflared processes: `pkill -f cloudflared`
|
|
27
|
+
- **URL not reachable**: Tunnel may have timed out, restart it
|
|
28
|
+
- **Session cookie invalid after restart**: Set `AUTH_SECRET` in `~/.forge/data/.env.local`:
|
|
29
|
+
```bash
|
|
30
|
+
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> ~/.forge/data/.env.local
|
|
31
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Background Tasks
|
|
2
|
+
|
|
3
|
+
## What Are Tasks?
|
|
4
|
+
|
|
5
|
+
Tasks run Claude Code prompts in the background. They use `claude -p` (print mode) — execute and exit, no persistent session. Your code runs on your machine using your Claude subscription.
|
|
6
|
+
|
|
7
|
+
## Create a Task
|
|
8
|
+
|
|
9
|
+
**From UI**: Click "+ New Task" in the Tasks tab.
|
|
10
|
+
|
|
11
|
+
**From CLI**:
|
|
12
|
+
```bash
|
|
13
|
+
forge task my-project "fix the login bug"
|
|
14
|
+
forge task my-project "add unit tests for utils.ts" --new # fresh session
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**From Telegram**: `/task my-project fix the login bug`
|
|
18
|
+
|
|
19
|
+
## Task Modes
|
|
20
|
+
|
|
21
|
+
| Mode | Description |
|
|
22
|
+
|------|-------------|
|
|
23
|
+
| `prompt` | Run Claude Code with a prompt (default) |
|
|
24
|
+
| `shell` | Execute raw shell command |
|
|
25
|
+
| `monitor` | Watch a session and trigger actions |
|
|
26
|
+
|
|
27
|
+
## Watch Task Output
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
forge watch <task-id> # live stream in terminal
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or from Telegram: `/watch <task-id>`
|
|
34
|
+
|
|
35
|
+
## CLI Commands
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
forge tasks # list all tasks
|
|
39
|
+
forge tasks running # filter by status
|
|
40
|
+
forge status <id> # task details
|
|
41
|
+
forge cancel <id> # cancel
|
|
42
|
+
forge retry <id> # retry failed task
|
|
43
|
+
forge log <id> # execution log
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- **Per-project concurrency**: One prompt task per project at a time, others queue
|
|
49
|
+
- **Session continuity**: All tasks in the same project share one Claude conversation
|
|
50
|
+
- **Cost tracking**: Token usage and USD cost per task
|
|
51
|
+
- **Git tracking**: Captures branch name and git diff after execution
|
|
52
|
+
- **Scheduled execution**: Set `scheduledAt` for deferred tasks
|