@aion0/forge 0.3.6 → 0.4.0
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/RELEASE_NOTES.md +8 -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 +15 -1
- 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/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
- package/publish.sh +90 -2
package/RELEASE_NOTES.md
ADDED
|
@@ -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 });
|
package/bin/forge-server.mjs
CHANGED
|
@@ -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');
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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');
|
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:
|
|
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 {}
|
package/lib/task-manager.ts
CHANGED
|
@@ -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
package/publish.sh
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# publish.sh — Bump version, commit,
|
|
2
|
+
# publish.sh — Bump version, generate release notes, commit, tag, push, create GitHub release
|
|
3
3
|
#
|
|
4
4
|
# Usage:
|
|
5
5
|
# ./publish.sh # patch bump (0.2.3 → 0.2.4)
|
|
@@ -35,11 +35,99 @@ echo ""
|
|
|
35
35
|
# Update package.json
|
|
36
36
|
sed -i '' "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW_VERSION\"/" package.json
|
|
37
37
|
|
|
38
|
-
#
|
|
38
|
+
# Generate release notes from git log since last tag
|
|
39
|
+
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
|
40
|
+
RELEASE_NOTES_FILE="RELEASE_NOTES.md"
|
|
41
|
+
|
|
42
|
+
echo "# Forge v$NEW_VERSION" > "$RELEASE_NOTES_FILE"
|
|
43
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
44
|
+
echo "Released: $(date +%Y-%m-%d)" >> "$RELEASE_NOTES_FILE"
|
|
45
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
46
|
+
|
|
47
|
+
if [ -n "$LAST_TAG" ]; then
|
|
48
|
+
echo "## Changes since $LAST_TAG" >> "$RELEASE_NOTES_FILE"
|
|
49
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
50
|
+
|
|
51
|
+
# Features
|
|
52
|
+
FEATURES=$(git log --oneline "$LAST_TAG"..HEAD --no-merges --grep="feat:" --format="- %s" 2>/dev/null)
|
|
53
|
+
if [ -n "$FEATURES" ]; then
|
|
54
|
+
echo "### Features" >> "$RELEASE_NOTES_FILE"
|
|
55
|
+
echo "$FEATURES" >> "$RELEASE_NOTES_FILE"
|
|
56
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Fixes
|
|
60
|
+
FIXES=$(git log --oneline "$LAST_TAG"..HEAD --no-merges --grep="fix:" --format="- %s" 2>/dev/null)
|
|
61
|
+
if [ -n "$FIXES" ]; then
|
|
62
|
+
echo "### Bug Fixes" >> "$RELEASE_NOTES_FILE"
|
|
63
|
+
echo "$FIXES" >> "$RELEASE_NOTES_FILE"
|
|
64
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Performance
|
|
68
|
+
PERF=$(git log --oneline "$LAST_TAG"..HEAD --no-merges --grep="perf:" --format="- %s" 2>/dev/null)
|
|
69
|
+
if [ -n "$PERF" ]; then
|
|
70
|
+
echo "### Performance" >> "$RELEASE_NOTES_FILE"
|
|
71
|
+
echo "$PERF" >> "$RELEASE_NOTES_FILE"
|
|
72
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# Refactors
|
|
76
|
+
REFACTOR=$(git log --oneline "$LAST_TAG"..HEAD --no-merges --grep="refactor:" --format="- %s" 2>/dev/null)
|
|
77
|
+
if [ -n "$REFACTOR" ]; then
|
|
78
|
+
echo "### Refactoring" >> "$RELEASE_NOTES_FILE"
|
|
79
|
+
echo "$REFACTOR" >> "$RELEASE_NOTES_FILE"
|
|
80
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
# Docs
|
|
84
|
+
DOCS=$(git log --oneline "$LAST_TAG"..HEAD --no-merges --grep="docs:" --format="- %s" 2>/dev/null)
|
|
85
|
+
if [ -n "$DOCS" ]; then
|
|
86
|
+
echo "### Documentation" >> "$RELEASE_NOTES_FILE"
|
|
87
|
+
echo "$DOCS" >> "$RELEASE_NOTES_FILE"
|
|
88
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# Other (commits without conventional prefix)
|
|
92
|
+
OTHER=$(git log --oneline "$LAST_TAG"..HEAD --no-merges --format="%s" 2>/dev/null | grep -v -E "^(feat|fix|perf|refactor|docs|chore|test|ci):" | sed 's/^/- /')
|
|
93
|
+
if [ -n "$OTHER" ]; then
|
|
94
|
+
echo "### Other" >> "$RELEASE_NOTES_FILE"
|
|
95
|
+
echo "$OTHER" >> "$RELEASE_NOTES_FILE"
|
|
96
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
97
|
+
fi
|
|
98
|
+
else
|
|
99
|
+
echo "Initial release" >> "$RELEASE_NOTES_FILE"
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
echo "" >> "$RELEASE_NOTES_FILE"
|
|
103
|
+
echo "**Full Changelog**: https://github.com/aiwatching/forge/compare/${LAST_TAG}...v${NEW_VERSION}" >> "$RELEASE_NOTES_FILE"
|
|
104
|
+
|
|
105
|
+
echo "Release notes written to $RELEASE_NOTES_FILE"
|
|
106
|
+
cat "$RELEASE_NOTES_FILE"
|
|
107
|
+
echo ""
|
|
108
|
+
|
|
109
|
+
# Commit + tag
|
|
39
110
|
git add -A
|
|
40
111
|
git commit -m "v$NEW_VERSION"
|
|
41
112
|
git tag "v$NEW_VERSION"
|
|
42
113
|
|
|
114
|
+
# Push
|
|
115
|
+
echo "Pushing to origin..."
|
|
116
|
+
git push origin main
|
|
117
|
+
git push origin "v$NEW_VERSION"
|
|
118
|
+
|
|
119
|
+
# Create GitHub Release (if gh CLI available)
|
|
120
|
+
if command -v gh &> /dev/null; then
|
|
121
|
+
echo ""
|
|
122
|
+
echo "Creating GitHub Release..."
|
|
123
|
+
gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"
|
|
124
|
+
echo "✓ GitHub Release created: https://github.com/aiwatching/forge/releases/tag/v$NEW_VERSION"
|
|
125
|
+
else
|
|
126
|
+
echo ""
|
|
127
|
+
echo "gh CLI not found. Create release manually:"
|
|
128
|
+
echo " https://github.com/aiwatching/forge/releases/new?tag=v$NEW_VERSION"
|
|
129
|
+
fi
|
|
130
|
+
|
|
43
131
|
echo ""
|
|
44
132
|
echo "Ready to publish @aion0/forge@$NEW_VERSION"
|
|
45
133
|
echo "Run: npm login && npm publish --access public --otp=<code>"
|