@aion0/forge 0.4.9 → 0.4.11

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 CHANGED
@@ -1,11 +1,13 @@
1
- # Forge v0.4.9
1
+ # Forge v0.4.11
2
2
 
3
- Released: 2026-03-22
3
+ Released: 2026-03-23
4
4
 
5
- ## Changes since v0.4.8
5
+ ## Changes since v0.4.10
6
6
 
7
7
  ### Other
8
- - add browser window
8
+ - mobile page
9
+ - init version for mobile page
10
+ - change sync period time
9
11
 
10
12
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.8...v0.4.9
13
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.10...v0.4.11
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getSessionFilePath, readSessionEntries } from '@/lib/claude-sessions';
3
+ import { statSync } from 'node:fs';
4
+
5
+ export async function GET(req: Request, { params }: { params: Promise<{ projectName: string }> }) {
6
+ const { projectName } = await params;
7
+ const url = new URL(req.url);
8
+ const sessionId = url.searchParams.get('sessionId');
9
+
10
+ if (!sessionId) {
11
+ return NextResponse.json({ error: 'sessionId required' }, { status: 400 });
12
+ }
13
+
14
+ const filePath = getSessionFilePath(decodeURIComponent(projectName), sessionId);
15
+ if (!filePath) {
16
+ return NextResponse.json({ entries: [], count: 0, fileSize: 0 });
17
+ }
18
+
19
+ const entries = readSessionEntries(filePath);
20
+ let fileSize = 0;
21
+ try { fileSize = statSync(filePath).size; } catch {}
22
+ return NextResponse.json({ entries, count: entries.length, fileSize });
23
+ }
@@ -1,14 +1,14 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { getConfigDir } from '@/lib/dirs';
4
+ import { getConfigDir, getDataDir } from '@/lib/dirs';
5
5
  import { loadSettings } from '@/lib/settings';
6
6
  import { execSync } from 'node:child_process';
7
7
 
8
8
  const HELP_DIR = join(getConfigDir(), 'help');
9
9
  const SOURCE_HELP_DIR = join(process.cwd(), 'lib', 'help-docs');
10
10
 
11
- /** Ensure help docs are copied to ~/.forge/help/ */
11
+ /** Ensure help docs are copied to ~/.forge/help/ and CLAUDE.md to ~/.forge/data/ */
12
12
  function ensureHelpDocs() {
13
13
  if (!existsSync(HELP_DIR)) mkdirSync(HELP_DIR, { recursive: true });
14
14
  if (existsSync(SOURCE_HELP_DIR)) {
@@ -16,10 +16,16 @@ function ensureHelpDocs() {
16
16
  if (!file.endsWith('.md')) continue;
17
17
  const src = join(SOURCE_HELP_DIR, file);
18
18
  const dest = join(HELP_DIR, file);
19
- // Always overwrite to keep docs up to date
20
19
  writeFileSync(dest, readFileSync(src));
21
20
  }
22
21
  }
22
+ // Copy CLAUDE.md to data dir so Help AI (working in ~/.forge/data/) picks it up
23
+ const dataDir = getDataDir();
24
+ const claudeMdSrc = join(HELP_DIR, 'CLAUDE.md');
25
+ const claudeMdDest = join(dataDir, 'CLAUDE.md');
26
+ if (existsSync(claudeMdSrc)) {
27
+ writeFileSync(claudeMdDest, readFileSync(claudeMdSrc));
28
+ }
23
29
  }
24
30
 
25
31
  /** Check if any agent CLI is available */
@@ -51,7 +57,7 @@ export async function GET(req: Request) {
51
57
  const docs = existsSync(HELP_DIR)
52
58
  ? readdirSync(HELP_DIR).filter(f => f.endsWith('.md')).sort()
53
59
  : [];
54
- return NextResponse.json({ agent, docsCount: docs.length, helpDir: HELP_DIR });
60
+ return NextResponse.json({ agent, docsCount: docs.length, helpDir: HELP_DIR, dataDir: getDataDir() });
55
61
  }
56
62
 
57
63
  if (action === 'docs') {
@@ -0,0 +1,87 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { spawn } from 'node:child_process';
3
+ import { loadSettings } from '@/lib/settings';
4
+
5
+ export const dynamic = 'force-dynamic';
6
+ export const runtime = 'nodejs';
7
+
8
+ // POST /api/mobile-chat — send a message to claude and stream response
9
+ export async function POST(req: Request) {
10
+ const { message, projectPath, resume } = await req.json() as {
11
+ message: string;
12
+ projectPath: string;
13
+ resume?: boolean;
14
+ };
15
+
16
+ if (!message || !projectPath) {
17
+ return NextResponse.json({ error: 'message and projectPath required' }, { status: 400 });
18
+ }
19
+
20
+ const settings = loadSettings();
21
+ const claudePath = settings.claudePath || 'claude';
22
+
23
+ const args = ['-p', '--dangerously-skip-permissions', '--output-format', 'json'];
24
+ if (resume) args.push('-c');
25
+
26
+ const child = spawn(claudePath, args, {
27
+ cwd: projectPath,
28
+ env: { ...process.env },
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ });
31
+
32
+ child.stdin.write(message);
33
+ child.stdin.end();
34
+
35
+ const encoder = new TextEncoder();
36
+ let closed = false;
37
+
38
+ const stream = new ReadableStream({
39
+ start(controller) {
40
+ child.stdout.on('data', (chunk: Buffer) => {
41
+ if (closed) return;
42
+ try {
43
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', text: chunk.toString() })}\n\n`));
44
+ } catch {}
45
+ });
46
+
47
+ child.stderr.on('data', (chunk: Buffer) => {
48
+ if (closed) return;
49
+ const text = chunk.toString();
50
+ if (text.includes('npm update') || text.includes('WARN')) return;
51
+ try {
52
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'stderr', text })}\n\n`));
53
+ } catch {}
54
+ });
55
+
56
+ child.on('exit', (code) => {
57
+ if (closed) return;
58
+ closed = true;
59
+ try {
60
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', code })}\n\n`));
61
+ controller.close();
62
+ } catch {}
63
+ });
64
+
65
+ child.on('error', (err) => {
66
+ if (closed) return;
67
+ closed = true;
68
+ try {
69
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`));
70
+ controller.close();
71
+ } catch {}
72
+ });
73
+ },
74
+ cancel() {
75
+ closed = true;
76
+ try { child.kill('SIGTERM'); } catch {}
77
+ },
78
+ });
79
+
80
+ return new Response(stream, {
81
+ headers: {
82
+ 'Content-Type': 'text/event-stream',
83
+ 'Cache-Control': 'no-cache, no-transform',
84
+ Connection: 'keep-alive',
85
+ },
86
+ });
87
+ }
@@ -62,6 +62,14 @@ export async function POST(req: Request) {
62
62
  const entry = state.entries.get(body.port);
63
63
  if (entry?.process) {
64
64
  entry.process.kill('SIGTERM');
65
+ } else {
66
+ // Process ref lost (hot-reload) — kill by port match
67
+ try {
68
+ const pids = execSync(`pgrep -f 'cloudflared tunnel.*localhost:${body.port}'`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
69
+ for (const pid of pids.split('\n').filter(Boolean)) {
70
+ try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
71
+ }
72
+ } catch {}
65
73
  }
66
74
  state.entries.delete(body.port);
67
75
  syncConfig();
@@ -0,0 +1,9 @@
1
+ import { auth } from '@/lib/auth';
2
+ import { redirect } from 'next/navigation';
3
+ import MobileView from '@/components/MobileView';
4
+
5
+ export default async function MobilePage() {
6
+ const session = await auth();
7
+ if (!session) redirect('/login');
8
+ return <MobileView />;
9
+ }
package/app/page.tsx CHANGED
@@ -1,9 +1,21 @@
1
1
  import { auth } from '@/lib/auth';
2
2
  import { redirect } from 'next/navigation';
3
+ import { headers } from 'next/headers';
3
4
  import Dashboard from '@/components/Dashboard';
4
5
 
5
- export default async function Home() {
6
+ export default async function Home({ searchParams }: { searchParams: Promise<{ force?: string }> }) {
6
7
  const session = await auth();
7
8
  if (!session) redirect('/login');
9
+
10
+ const params = await searchParams;
11
+
12
+ // Auto-detect mobile and redirect (skip if ?force=desktop)
13
+ if (params.force !== 'desktop') {
14
+ const headersList = await headers();
15
+ const ua = headersList.get('user-agent') || '';
16
+ const isMobile = /iPhone|iPad|iPod|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
17
+ if (isMobile) redirect('/mobile');
18
+ }
19
+
8
20
  return <Dashboard user={session.user} />;
9
21
  }
@@ -0,0 +1,175 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+
5
+ interface PreviewEntry {
6
+ port: number;
7
+ url: string | null;
8
+ status: string;
9
+ label?: string;
10
+ }
11
+
12
+ export default function BrowserPanel({ onClose }: { onClose?: () => void }) {
13
+ const [browserUrl, setBrowserUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('forge-browser-url') || '' : '');
14
+ const [browserKey, setBrowserKey] = useState(0);
15
+ const [previews, setPreviews] = useState<PreviewEntry[]>([]);
16
+ const [tunnelStarting, setTunnelStarting] = useState(false);
17
+ const browserUrlRef = useRef<HTMLInputElement>(null);
18
+ const isRemote = typeof window !== 'undefined' && !['localhost', '127.0.0.1'].includes(window.location.hostname);
19
+
20
+ const fetchPreviews = useCallback(() => {
21
+ fetch('/api/preview').then(r => r.json()).then(data => {
22
+ if (Array.isArray(data)) setPreviews(data.filter((p: any) => p.status === 'running'));
23
+ }).catch(() => {});
24
+ }, []);
25
+
26
+ useEffect(() => {
27
+ fetchPreviews();
28
+ const timer = setInterval(fetchPreviews, 10000);
29
+ return () => clearInterval(timer);
30
+ }, [fetchPreviews]);
31
+
32
+ const normalizeUrl = (val: string): string => {
33
+ if (/^\d+$/.test(val)) return `http://localhost:${val}`;
34
+ if (!/^https?:\/\//i.test(val)) return `http://${val}`;
35
+ return val;
36
+ };
37
+
38
+ const navigate = (url: string) => {
39
+ const normalized = normalizeUrl(url);
40
+ setBrowserUrl(normalized);
41
+ localStorage.setItem('forge-browser-url', normalized);
42
+ if (browserUrlRef.current) browserUrlRef.current.value = normalized;
43
+ setBrowserKey(k => k + 1);
44
+ };
45
+
46
+ const handleTunnel = async () => {
47
+ const input = prompt('Enter port(s) to create tunnel (e.g. 3100 or 3100,8080):');
48
+ if (!input) return;
49
+ const ports = input.split(',').map(s => parseInt(s.trim())).filter(p => p > 0 && p <= 65535);
50
+ if (ports.length === 0) { alert('Invalid port(s)'); return; }
51
+ setTunnelStarting(true);
52
+ const results: string[] = [];
53
+ for (const port of ports) {
54
+ try {
55
+ const res = await fetch('/api/preview', {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify({ action: 'start', port }),
59
+ });
60
+ const data = await res.json();
61
+ if (data.url) {
62
+ results.push(data.url);
63
+ setPreviews(prev => {
64
+ const exists = prev.find(p => p.port === port);
65
+ if (exists) return prev.map(p => p.port === port ? { ...p, url: data.url, status: 'running' } : p);
66
+ return [...prev, { port, url: data.url, status: 'running' }];
67
+ });
68
+ } else if (data.status === 'starting' || data.status === 'stopped') {
69
+ // Tunnel started but URL not ready yet or exited
70
+ results.push('');
71
+ } else {
72
+ alert(`Port ${port}: ${data.error || 'Failed'}`);
73
+ }
74
+ } catch { alert(`Port ${port}: Failed to start tunnel`); }
75
+ }
76
+ // Navigate to first successful URL
77
+ const firstUrl = results.find(u => u);
78
+ if (firstUrl) navigate(firstUrl);
79
+ // Refresh list to pick up any that were still starting
80
+ setTimeout(fetchPreviews, 3000);
81
+ setTunnelStarting(false);
82
+ };
83
+
84
+ const stopTunnel = async (port: number) => {
85
+ await fetch('/api/preview', {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({ action: 'stop', port }),
89
+ });
90
+ setPreviews(prev => prev.filter(x => x.port !== port));
91
+ };
92
+
93
+ return (
94
+ <div className="flex-1 flex flex-col min-h-0">
95
+ {/* URL bar */}
96
+ <div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--border)] bg-[var(--bg-tertiary)] shrink-0">
97
+ <input
98
+ ref={browserUrlRef}
99
+ type="text"
100
+ defaultValue={browserUrl}
101
+ placeholder="Enter URL"
102
+ onKeyDown={e => {
103
+ if (e.key === 'Enter') {
104
+ const val = (e.target as HTMLInputElement).value.trim();
105
+ if (!val) return;
106
+ navigate(val);
107
+ }
108
+ }}
109
+ className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] min-w-0"
110
+ />
111
+ <button onClick={() => setBrowserKey(k => k + 1)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Refresh">↻</button>
112
+ <button onClick={() => window.open(browserUrl, '_blank')} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Open in new tab">↗</button>
113
+ <button
114
+ disabled={tunnelStarting}
115
+ onClick={handleTunnel}
116
+ className="text-[9px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--accent)] disabled:opacity-50"
117
+ title="Create tunnel for a port (remote access)"
118
+ >{tunnelStarting ? 'Starting...' : 'Tunnel'}</button>
119
+ {onClose && (
120
+ <button onClick={onClose} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--red)] px-1" title="Close">✕</button>
121
+ )}
122
+ </div>
123
+ {/* Active tunnels bar */}
124
+ {previews.length > 0 && (
125
+ <div className="flex items-center gap-1 px-2 py-0.5 border-b border-[var(--border)]/50 bg-[var(--bg-secondary)] shrink-0 overflow-x-auto">
126
+ {previews.map(p => (
127
+ <div key={p.port} className="flex items-center gap-1 shrink-0">
128
+ <button
129
+ onClick={() => {
130
+ const url = isRemote && p.url ? p.url : `http://localhost:${p.port}`;
131
+ navigate(url);
132
+ }}
133
+ className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
134
+ >
135
+ <span className="text-green-400 mr-0.5">●</span>
136
+ :{p.port}
137
+ </button>
138
+ {p.url && (
139
+ <button
140
+ onClick={() => navigator.clipboard.writeText(p.url!).then(() => alert('Tunnel URL copied'))}
141
+ className="text-[8px] text-green-400 hover:underline truncate max-w-[120px]"
142
+ title={p.url}
143
+ >{p.url.replace('https://', '').slice(0, 20)}...</button>
144
+ )}
145
+ <button onClick={() => stopTunnel(p.port)} className="text-[8px] text-red-400 hover:text-red-300">✕</button>
146
+ </div>
147
+ ))}
148
+ </div>
149
+ )}
150
+ {/* Content */}
151
+ <div className="flex-1 relative">
152
+ {tunnelStarting && (
153
+ <div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs z-10 bg-[var(--bg-primary)]/80">
154
+ Creating tunnel... this may take up to 30 seconds
155
+ </div>
156
+ )}
157
+ {browserUrl ? (
158
+ <iframe
159
+ key={browserKey}
160
+ src={browserUrl}
161
+ className="absolute inset-0 w-full h-full border-0 bg-white"
162
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
163
+ />
164
+ ) : (
165
+ <div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
166
+ <div className="text-center space-y-1">
167
+ <p>Enter a URL or port number and press Enter</p>
168
+ <p className="text-[9px]">Click Tunnel to create a public URL for remote access</p>
169
+ </div>
170
+ </div>
171
+ )}
172
+ </div>
173
+ </div>
174
+ );
175
+ }
@@ -189,12 +189,6 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
189
189
  const [editing, setEditing] = useState(false);
190
190
  const [editContent, setEditContent] = useState('');
191
191
  const [saving, setSaving] = useState(false);
192
- const [browserOpen, setBrowserOpen] = useState(false);
193
- const [browserUrl, setBrowserUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('forge-browser-url') || '' : '');
194
- const [browserKey, setBrowserKey] = useState(0);
195
- const [browserWidth, setBrowserWidth] = useState(640);
196
- const browserDragRef = useRef<{ startX: number; startW: number } | null>(null);
197
- const [browserDragging, setBrowserDragging] = useState(false);
198
192
 
199
193
  const handleCodeOpenChange = useCallback((open: boolean) => {
200
194
  setCodeOpen(open);
@@ -427,74 +421,10 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
427
421
  )}
428
422
 
429
423
  {/* Terminal + Browser — main area */}
430
- <div className={`flex ${codeOpen ? 'shrink-0' : 'flex-1'}`} style={codeOpen ? { height: terminalHeight } : undefined}>
431
- <div className="flex-1 min-w-0">
432
- <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
433
- <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} browserOpen={browserOpen} onBrowserToggle={() => { setBrowserOpen(v => !v); if (!browserOpen) setBrowserKey(k => k + 1); }} />
434
- </Suspense>
435
- </div>
436
- {browserOpen && (
437
- <>
438
- <div
439
- onMouseDown={(e) => {
440
- e.preventDefault();
441
- browserDragRef.current = { startX: e.clientX, startW: browserWidth };
442
- setBrowserDragging(true);
443
- const onMove = (ev: MouseEvent) => {
444
- if (!browserDragRef.current) return;
445
- setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW - (ev.clientX - browserDragRef.current.startX))));
446
- };
447
- const onUp = () => {
448
- browserDragRef.current = null;
449
- setBrowserDragging(false);
450
- window.removeEventListener('mousemove', onMove);
451
- window.removeEventListener('mouseup', onUp);
452
- };
453
- window.addEventListener('mousemove', onMove);
454
- window.addEventListener('mouseup', onUp);
455
- }}
456
- className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
457
- />
458
- <div style={{ width: browserWidth }} className="shrink-0 flex flex-col">
459
- <div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--border)] bg-[var(--bg-tertiary)] shrink-0">
460
- <input
461
- type="text"
462
- defaultValue={browserUrl}
463
- placeholder="http://localhost:3000"
464
- onKeyDown={e => {
465
- if (e.key === 'Enter') {
466
- const url = (e.target as HTMLInputElement).value.trim();
467
- if (url) {
468
- setBrowserUrl(url);
469
- localStorage.setItem('forge-browser-url', url);
470
- setBrowserKey(k => k + 1);
471
- }
472
- }
473
- }}
474
- className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] min-w-0"
475
- />
476
- <button onClick={() => setBrowserKey(k => k + 1)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Refresh">↻</button>
477
- <button onClick={() => window.open(browserUrl, '_blank')} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Open in new tab">↗</button>
478
- <button onClick={() => setBrowserOpen(false)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--red)] px-1" title="Close">✕</button>
479
- </div>
480
- <div className="flex-1 relative">
481
- {browserUrl ? (
482
- <iframe
483
- key={browserKey}
484
- src={browserUrl}
485
- className="absolute inset-0 w-full h-full border-0 bg-white"
486
- sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
487
- />
488
- ) : (
489
- <div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
490
- Enter a URL and press Enter
491
- </div>
492
- )}
493
- {browserDragging && <div className="absolute inset-0 z-10" />}
494
- </div>
495
- </div>
496
- </>
497
- )}
424
+ <div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
425
+ <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
426
+ <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
427
+ </Suspense>
498
428
  </div>
499
429
 
500
430
  {/* Resize handle */}