@aion0/forge 0.3.6 → 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.
@@ -0,0 +1,100 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { existsSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { getDataDir } from '@/lib/dirs';
5
+ import { execSync } from 'node:child_process';
6
+
7
+ const LOG_FILE = join(getDataDir(), 'forge.log');
8
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB — auto-rotate above this
9
+
10
+ /** Read last N bytes from file (efficient tail) */
11
+ function tailFile(filePath: string, maxBytes: number): string {
12
+ const stat = statSync(filePath);
13
+ const size = stat.size;
14
+ const readSize = Math.min(size, maxBytes);
15
+ const buf = Buffer.alloc(readSize);
16
+ const fd = openSync(filePath, 'r');
17
+ readSync(fd, buf, 0, readSize, size - readSize);
18
+ closeSync(fd);
19
+ // Skip partial first line
20
+ const str = buf.toString('utf-8');
21
+ const firstNewline = str.indexOf('\n');
22
+ return firstNewline > 0 ? str.slice(firstNewline + 1) : str;
23
+ }
24
+
25
+ /** Rotate log if too large: forge.log → forge.log.old, start fresh */
26
+ function rotateIfNeeded() {
27
+ if (!existsSync(LOG_FILE)) return;
28
+ const stat = statSync(LOG_FILE);
29
+ if (stat.size > MAX_LOG_SIZE) {
30
+ const oldFile = LOG_FILE + '.old';
31
+ try { renameSync(LOG_FILE, oldFile); } catch {}
32
+ writeFileSync(LOG_FILE, `[forge] Log rotated at ${new Date().toISOString()} (previous: ${(stat.size / 1024 / 1024).toFixed(1)}MB)\n`, 'utf-8');
33
+ }
34
+ }
35
+
36
+ // GET /api/logs?lines=200&search=keyword
37
+ export async function GET(req: Request) {
38
+ const { searchParams } = new URL(req.url);
39
+ const lines = Math.min(parseInt(searchParams.get('lines') || '200'), 1000);
40
+ const search = searchParams.get('search') || '';
41
+
42
+ rotateIfNeeded();
43
+
44
+ if (!existsSync(LOG_FILE)) {
45
+ return NextResponse.json({ lines: [], total: 0, size: 0 });
46
+ }
47
+
48
+ try {
49
+ const stat = statSync(LOG_FILE);
50
+ // Read last 512KB max (enough for ~5000 lines)
51
+ const raw = tailFile(LOG_FILE, 512 * 1024);
52
+ let allLines = raw.split('\n').filter(Boolean);
53
+
54
+ if (search) {
55
+ allLines = allLines.filter(l => l.toLowerCase().includes(search.toLowerCase()));
56
+ }
57
+
58
+ const result = allLines.slice(-lines);
59
+
60
+ return NextResponse.json({
61
+ lines: result,
62
+ total: allLines.length,
63
+ size: stat.size,
64
+ file: LOG_FILE,
65
+ });
66
+ } catch (e: any) {
67
+ return NextResponse.json({ error: e.message }, { status: 500 });
68
+ }
69
+ }
70
+
71
+ // POST /api/logs — actions
72
+ export async function POST(req: Request) {
73
+ const body = await req.json();
74
+
75
+ if (body.action === 'clear') {
76
+ try {
77
+ writeFileSync(LOG_FILE, `[forge] Log cleared at ${new Date().toISOString()}\n`, 'utf-8');
78
+ return NextResponse.json({ ok: true });
79
+ } catch (e: any) {
80
+ return NextResponse.json({ error: e.message }, { status: 500 });
81
+ }
82
+ }
83
+
84
+ if (body.action === 'processes') {
85
+ try {
86
+ const out = execSync("ps aux | grep -E 'next-server|telegram-standalone|terminal-standalone|cloudflared' | grep -v grep", {
87
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
88
+ }).trim();
89
+ const processes = out.split('\n').filter(Boolean).map(line => {
90
+ const parts = line.trim().split(/\s+/);
91
+ return { pid: parts[1], cpu: parts[2], mem: parts[3], cmd: parts.slice(10).join(' ').slice(0, 80) };
92
+ });
93
+ return NextResponse.json({ processes });
94
+ } catch {
95
+ return NextResponse.json({ processes: [] });
96
+ }
97
+ }
98
+
99
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
100
+ }
@@ -56,6 +56,8 @@ export async function PUT(req: Request) {
56
56
  // Remove internal fields
57
57
  delete (updated as any)._secretStatus;
58
58
 
59
+ const changed = Object.keys(updated).filter(k => JSON.stringify((updated as any)[k]) !== JSON.stringify((settings as any)[k]) && !['telegramTunnelPassword', 'telegramBotToken'].includes(k));
60
+ if (changed.length > 0) console.log(`[settings] Updated: ${changed.join(', ')}`);
59
61
  saveSettings(updated);
60
62
  restartTelegramBot();
61
63
  return NextResponse.json({ ok: true });
@@ -72,6 +72,15 @@ const LOG_FILE = join(DATA_DIR, 'forge.log');
72
72
 
73
73
  process.chdir(ROOT);
74
74
 
75
+ // ── Add timestamps to all console output ──
76
+ const origLog = console.log;
77
+ const origError = console.error;
78
+ const origWarn = console.warn;
79
+ const ts = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
80
+ console.log = (...args) => origLog(`[${ts()}]`, ...args);
81
+ console.error = (...args) => origError(`[${ts()}]`, ...args);
82
+ console.warn = (...args) => origWarn(`[${ts()}]`, ...args);
83
+
75
84
  // ── Migrate old layout (~/.forge/*) to new (~/.forge/data/*) ──
76
85
  if (!getArg('--dir')) {
77
86
  const oldSettings = join(homedir(), '.forge', 'settings.yaml');
@@ -19,6 +19,7 @@ const ProjectManager = lazy(() => import('./ProjectManager'));
19
19
  const PreviewPanel = lazy(() => import('./PreviewPanel'));
20
20
  const PipelineView = lazy(() => import('./PipelineView'));
21
21
  const HelpDialog = lazy(() => import('./HelpDialog'));
22
+ const LogViewer = lazy(() => import('./LogViewer'));
22
23
  const SkillsPanel = lazy(() => import('./SkillsPanel'));
23
24
 
24
25
  interface UsageSummary {
@@ -42,7 +43,7 @@ interface ProjectInfo {
42
43
  }
43
44
 
44
45
  export default function Dashboard({ user }: { user: any }) {
45
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines' | 'skills'>('terminal');
46
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines' | 'skills' | 'logs'>('terminal');
46
47
  const [tasks, setTasks] = useState<Task[]>([]);
47
48
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
48
49
  const [showNewTask, setShowNewTask] = useState(false);
@@ -427,6 +428,12 @@ export default function Dashboard({ user }: { user: any }) {
427
428
  >
428
429
  Settings
429
430
  </button>
431
+ <button
432
+ onClick={() => { setViewMode('logs'); setShowUserMenu(false); }}
433
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
434
+ >
435
+ Logs
436
+ </button>
430
437
  <div className="border-t border-[var(--border)] my-1" />
431
438
  <button
432
439
  onClick={() => signOut({ callbackUrl: '/login' })}
@@ -573,6 +580,13 @@ export default function Dashboard({ user }: { user: any }) {
573
580
  </Suspense>
574
581
  )}
575
582
 
583
+ {/* Logs */}
584
+ {viewMode === 'logs' && (
585
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
586
+ <LogViewer />
587
+ </Suspense>
588
+ )}
589
+
576
590
  {/* Docs — always mounted to keep terminal session alive */}
577
591
  <div className={viewMode === 'docs' ? 'flex-1 min-h-0 flex' : 'hidden'}>
578
592
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -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
- <div className="px-3 py-1 text-[9px] text-[var(--yellow)] uppercase bg-[var(--bg-tertiary)] flex items-center gap-1">
238
- <span>★</span> Favorites
239
- </div>
240
- {favoriteProjects.map(p => (
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
- <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)]">
268
- {rootName}
269
- </div>
270
- {rootProjects.map(p => (
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
  }),
@@ -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
- // macOS universal binary
27
- return `${base}/cloudflared-darwin-amd64.tgz`;
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
- function followRedirects(url: string, dest: string): Promise<void> {
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
- followRedirects(res.headers.location, dest).then(resolve, reject);
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
- }).on('error', reject);
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
- mkdirSync(BIN_DIR, { recursive: true });
65
- const url = getDownloadUrl();
66
- const isTgz = url.endsWith('.tgz');
67
- const tmpPath = isTgz ? `${BIN_PATH}.tgz` : BIN_PATH;
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
- if (isTgz) {
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
- if (platform() !== 'win32') {
79
- chmodSync(BIN_PATH, 0o755);
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
- console.log(`[cloudflared] Installed to ${BIN_PATH}`);
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] cloudflared exited with code ${code}`);
268
+ pushLog(`[exit] ${reason}`);
224
269
  if (!resolved) {
225
270
  resolved = true;
226
- resolve({ error: `cloudflared exited with code ${code}` });
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');
package/lib/init.ts CHANGED
@@ -64,6 +64,9 @@ export function ensureInitialized() {
64
64
  if (gInit[initKey]) return;
65
65
  gInit[initKey] = true;
66
66
 
67
+ // Add timestamps to all console output
68
+ try { const { initLogger } = require('./logger'); initLogger(); } catch {}
69
+
67
70
  // Migrate old data layout (~/.forge/* → ~/.forge/data/*) on first run
68
71
  try { const { migrateDataDir } = require('./dirs'); migrateDataDir(); } catch {}
69
72
 
package/lib/logger.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Logger — adds timestamps + writes to forge.log file.
3
+ * Call `initLogger()` once at startup.
4
+ * Works in both dev mode (terminal + file) and production (file via redirect).
5
+ */
6
+
7
+ import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ let initialized = false;
11
+
12
+ export function initLogger() {
13
+ if (initialized) return;
14
+ initialized = true;
15
+
16
+ // Determine log file path
17
+ let logFile: string | null = null;
18
+ try {
19
+ const { getDataDir } = require('./dirs');
20
+ const dataDir = getDataDir();
21
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
22
+ logFile = join(dataDir, 'forge.log');
23
+ } catch {}
24
+
25
+ const origLog = console.log;
26
+ const origError = console.error;
27
+ const origWarn = console.warn;
28
+
29
+ const ts = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
30
+
31
+ const writeToFile = (line: string) => {
32
+ if (!logFile) return;
33
+ try { appendFileSync(logFile, line + '\n'); } catch {}
34
+ };
35
+
36
+ const SENSITIVE_PATTERNS = [
37
+ /(\d{8,})/g, // session codes (8+ digits)
38
+ /(bot\d+:[A-Za-z0-9_-]{30,})/gi, // telegram bot tokens
39
+ /(enc:[A-Za-z0-9+/=.]+)/g, // encrypted values
40
+ /(sk-ant-[A-Za-z0-9_-]+)/g, // anthropic API keys
41
+ /(sk-[A-Za-z0-9]{20,})/g, // openai API keys
42
+ ];
43
+
44
+ const sanitize = (str: string): string => {
45
+ let result = str;
46
+ for (const pattern of SENSITIVE_PATTERNS) {
47
+ result = result.replace(pattern, (match) => match.slice(0, 4) + '****');
48
+ }
49
+ return result;
50
+ };
51
+
52
+ const format = (...args: any[]): string => {
53
+ return args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
54
+ };
55
+
56
+ console.log = (...args: any[]) => {
57
+ const line = `[${ts()}] ${format(...args)}`;
58
+ origLog(line);
59
+ writeToFile(sanitize(line));
60
+ };
61
+
62
+ console.error = (...args: any[]) => {
63
+ const line = `[${ts()}] [ERROR] ${format(...args)}`;
64
+ origError(line);
65
+ writeToFile(sanitize(line));
66
+ };
67
+
68
+ console.warn = (...args: any[]) => {
69
+ const line = `[${ts()}] [WARN] ${format(...args)}`;
70
+ origWarn(line);
71
+ writeToFile(sanitize(line));
72
+ };
73
+ }
package/lib/password.ts CHANGED
@@ -59,7 +59,7 @@ export function getSessionCode(): string {
59
59
  export function rotateSessionCode(): string {
60
60
  const code = generateSessionCode();
61
61
  saveSessionCode(code);
62
- console.log(`[password] New session code: ${code}`);
62
+ console.log(`[password] New session code: ****${code.slice(-2)}`);
63
63
  return code;
64
64
  }
65
65
 
package/lib/skills.ts CHANGED
@@ -87,6 +87,7 @@ function getInstalledVersion(name: string, type: string, basePath?: string): str
87
87
  // ─── Sync from registry ──────────────────────────────────────
88
88
 
89
89
  export async function syncSkills(): Promise<{ synced: number; error?: string }> {
90
+ console.log('[skills] Syncing from registry...');
90
91
  const baseUrl = getBaseUrl();
91
92
 
92
93
  try {
@@ -198,6 +199,7 @@ export async function syncSkills(): Promise<{ synced: number; error?: string }>
198
199
  }
199
200
  }
200
201
 
202
+ console.log(`[skills] Synced ${items.length} items`);
201
203
  return { synced: items.length };
202
204
  } catch (e) {
203
205
  return { synced: 0, error: e instanceof Error ? e.message : String(e) };
@@ -284,6 +286,7 @@ async function downloadToDir(files: { path: string; download_url: string }[], de
284
286
  // ─── Install ─────────────────────────────────────────────────
285
287
 
286
288
  export async function installGlobal(name: string): Promise<void> {
289
+ console.log(`[skills] Installing "${name}" globally`);
287
290
  const row = db().prepare('SELECT type, version FROM skills WHERE name = ?').get(name) as any;
288
291
  if (!row) throw new Error(`Not found: ${name}`);
289
292
  const type: ItemType = row.type || 'skill';
@@ -318,6 +321,7 @@ export async function installGlobal(name: string): Promise<void> {
318
321
  }
319
322
 
320
323
  export async function installProject(name: string, projectPath: string): Promise<void> {
324
+ console.log(`[skills] Installing "${name}" to ${projectPath.split('/').pop()}`);
321
325
  const row = db().prepare('SELECT type, version FROM skills WHERE name = ?').get(name) as any;
322
326
  if (!row) throw new Error(`Not found: ${name}`);
323
327
  const type: ItemType = row.type || 'skill';
@@ -357,6 +361,7 @@ export async function installProject(name: string, projectPath: string): Promise
357
361
  // ─── Uninstall ───────────────────────────────────────────────
358
362
 
359
363
  export function uninstallGlobal(name: string): void {
364
+ console.log(`[skills] Uninstalling "${name}" from global`);
360
365
  // Remove from all possible locations
361
366
  try { rmSync(join(GLOBAL_SKILLS_DIR, name), { recursive: true }); } catch {}
362
367
  try { unlinkSync(join(GLOBAL_COMMANDS_DIR, `${name}.md`)); } catch {}
@@ -367,6 +372,7 @@ export function uninstallGlobal(name: string): void {
367
372
  }
368
373
 
369
374
  export function uninstallProject(name: string, projectPath: string): void {
375
+ console.log(`[skills] Uninstalling "${name}" from ${projectPath.split('/').pop()}`);
370
376
  // Remove from all possible locations
371
377
  try { rmSync(join(projectPath, '.claude', 'skills', name), { recursive: true }); } catch {}
372
378
  try { unlinkSync(join(projectPath, '.claude', 'commands', `${name}.md`)); } catch {}
@@ -405,12 +405,14 @@ function executeTask(task: Task): Promise<void> {
405
405
  WHERE id = ?
406
406
  `).run(resultText, totalCost, task.id);
407
407
  emit(task.id, 'status', 'done');
408
+ console.log(`[task] Done: ${task.id} ${task.projectName} (cost: $${totalCost?.toFixed(4) || '0'})`);
408
409
  const doneTask = getTask(task.id);
409
410
  if (doneTask) notifyTaskComplete(doneTask).catch(() => {});
410
411
  notifyTerminalSession(task, 'done', sessionId);
411
412
  resolve();
412
413
  } else {
413
414
  const errMsg = `Process exited with code ${code}`;
415
+ console.error(`[task] Failed: ${task.id} ${task.projectName} — ${errMsg}`);
414
416
  updateTaskStatus(task.id, 'failed', errMsg);
415
417
  const failedTask = getTask(task.id);
416
418
  if (failedTask) notifyTaskFailed(failedTask).catch(() => {});
package/next-env.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
3
+ import "./.next/dev/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {