@aion0/forge 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -174,10 +174,12 @@ export async function GET(req: Request) {
174
174
  }
175
175
  const gitRepos: GitRepo[] = [];
176
176
 
177
+ const gitOpts = { encoding: 'utf-8' as const, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] as any };
178
+
177
179
  function scanGitStatus(dir: string, repoName: string, pathPrefix: string) {
178
180
  try {
179
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, encoding: 'utf-8', timeout: 3000 }).trim();
180
- const statusOut = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 5000 });
181
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { ...gitOpts, cwd: dir, timeout: 3000 }).toString().trim();
182
+ const statusOut = execSync('git status --porcelain -u', { ...gitOpts, cwd: dir }).toString();
181
183
  const changes = statusOut.replace(/\n$/, '').split('\n').filter(Boolean)
182
184
  .map(line => {
183
185
  if (line.length < 4) return null;
@@ -188,7 +190,7 @@ export async function GET(req: Request) {
188
190
  })
189
191
  .filter((g): g is { status: string; path: string } => g !== null && !!g.path && !g.path.includes(' -> '));
190
192
  let remote = '';
191
- try { remote = execSync('git remote get-url origin', { cwd: dir, encoding: 'utf-8', timeout: 2000 }).trim(); } catch {}
193
+ try { remote = execSync('git remote get-url origin', { ...gitOpts, cwd: dir, timeout: 2000 }).toString().trim(); } catch {}
192
194
  if (branch || changes.length > 0) {
193
195
  gitRepos.push({ name: repoName, branch, remote, changes });
194
196
  }
@@ -197,7 +199,7 @@ export async function GET(req: Request) {
197
199
 
198
200
  // Check if root is a git repo
199
201
  try {
200
- execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000 });
202
+ execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
201
203
  scanGitStatus(resolvedDir, '.', '');
202
204
  } catch {
203
205
  // Root is not a git repo — scan subdirectories
@@ -206,7 +208,7 @@ export async function GET(req: Request) {
206
208
  if (!entry.isDirectory() || entry.name.startsWith('.') || IGNORE.has(entry.name)) continue;
207
209
  const subDir = join(resolvedDir, entry.name);
208
210
  try {
209
- execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000 });
211
+ execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
210
212
  scanGitStatus(subDir, entry.name, entry.name);
211
213
  } catch {}
212
214
  }
@@ -12,7 +12,7 @@ function isUnderProjectRoot(dir: string): boolean {
12
12
  }
13
13
 
14
14
  function git(cmd: string, cwd: string): string {
15
- return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 15000 }).trim();
15
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
16
16
  }
17
17
 
18
18
  // GET /api/git?dir=<path> — git status for a project
@@ -24,7 +24,7 @@ export async function GET(req: NextRequest) {
24
24
 
25
25
  try {
26
26
  const branch = git('rev-parse --abbrev-ref HEAD', dir);
27
- const statusRaw = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 15000 });
27
+ const statusRaw = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
28
28
  const changes = statusRaw.replace(/\n$/, '').split('\n').filter(Boolean).map(line => ({
29
29
  status: line.substring(0, 2).trim() || 'M',
30
30
  path: line.substring(3).replace(/\/$/, ''),
@@ -4,30 +4,39 @@ import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { spawn, execSync, type ChildProcess } from 'node:child_process';
6
6
 
7
- const CONFIG_FILE = join(homedir(), '.forge', 'preview.json');
7
+ const CONFIG_FILE = join(process.env.FORGE_DATA_DIR || join(homedir(), '.forge'), 'preview.json');
8
8
 
9
- // Persist tunnel state across hot-reloads
9
+ interface PreviewEntry {
10
+ port: number;
11
+ url: string | null;
12
+ status: string;
13
+ label?: string;
14
+ }
15
+
16
+ // Persist state across hot-reloads
10
17
  const stateKey = Symbol.for('mw-preview-state');
11
18
  const g = globalThis as any;
12
- if (!g[stateKey]) g[stateKey] = { process: null, port: 0, url: null, status: 'stopped' };
13
- const state: { process: ChildProcess | null; port: number; url: string | null; status: string } = g[stateKey];
19
+ if (!g[stateKey]) g[stateKey] = { entries: new Map<number, { process: ChildProcess | null; url: string | null; status: string; label: string }>() };
20
+ const state: { entries: Map<number, { process: ChildProcess | null; url: string | null; status: string; label: string }> } = g[stateKey];
14
21
 
15
- function getConfig(): { port: number } {
22
+ function getConfig(): PreviewEntry[] {
16
23
  try {
17
- return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
24
+ const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
25
+ return Array.isArray(data) ? data : data.port ? [data] : [];
18
26
  } catch {
19
- return { port: 0 };
27
+ return [];
20
28
  }
21
29
  }
22
30
 
23
- function saveConfig(config: { port: number }) {
31
+ function saveConfig(entries: PreviewEntry[]) {
24
32
  const dir = dirname(CONFIG_FILE);
25
33
  mkdirSync(dir, { recursive: true });
26
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
34
+ writeFileSync(CONFIG_FILE, JSON.stringify(entries, null, 2));
27
35
  }
28
36
 
29
37
  function getCloudflaredPath(): string | null {
30
- const binPath = join(homedir(), '.forge', 'bin', 'cloudflared');
38
+ const dataDir = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
39
+ const binPath = join(dataDir, 'bin', 'cloudflared');
31
40
  if (existsSync(binPath)) return binPath;
32
41
  try {
33
42
  return execSync('which cloudflared', { encoding: 'utf-8' }).trim();
@@ -36,100 +45,105 @@ function getCloudflaredPath(): string | null {
36
45
  }
37
46
  }
38
47
 
39
- // GET — get current preview status
48
+ // GET — list all previews
40
49
  export async function GET() {
41
- return NextResponse.json({
42
- port: state.port,
43
- url: state.url,
44
- status: state.status,
45
- });
50
+ const entries: PreviewEntry[] = [];
51
+ for (const [port, s] of state.entries) {
52
+ entries.push({ port, url: s.url, status: s.status, label: s.label });
53
+ }
54
+ return NextResponse.json(entries);
46
55
  }
47
56
 
48
- // POST — start/stop preview tunnel
57
+ // POST — start/stop/manage previews
49
58
  export async function POST(req: Request) {
50
- const { port, action } = await req.json();
59
+ const body = await req.json();
51
60
 
52
- if (action === 'stop' || port === 0) {
53
- if (state.process) {
54
- state.process.kill('SIGTERM');
55
- state.process = null;
61
+ // Stop a preview
62
+ if (body.action === 'stop' && body.port) {
63
+ const entry = state.entries.get(body.port);
64
+ if (entry?.process) {
65
+ entry.process.kill('SIGTERM');
56
66
  }
57
- state.port = 0;
58
- state.url = null;
59
- state.status = 'stopped';
60
- saveConfig({ port: 0 });
61
- return NextResponse.json({ port: 0, url: null, status: 'stopped' });
67
+ state.entries.delete(body.port);
68
+ syncConfig();
69
+ return NextResponse.json({ ok: true });
62
70
  }
63
71
 
64
- const p = parseInt(port) || 0;
65
- if (!p || p < 1 || p > 65535) {
66
- return NextResponse.json({ error: 'Invalid port' }, { status: 400 });
67
- }
72
+ // Start a new preview
73
+ if (body.action === 'start' && body.port) {
74
+ const port = parseInt(body.port);
75
+ if (!port || port < 1 || port > 65535) {
76
+ return NextResponse.json({ error: 'Invalid port' }, { status: 400 });
77
+ }
68
78
 
69
- // Kill existing tunnel if any
70
- if (state.process) {
71
- state.process.kill('SIGTERM');
72
- state.process = null;
73
- }
79
+ // Already running?
80
+ const existing = state.entries.get(port);
81
+ if (existing && existing.status === 'running') {
82
+ return NextResponse.json({ port, url: existing.url, status: 'running', label: existing.label });
83
+ }
74
84
 
75
- const binPath = getCloudflaredPath();
76
- if (!binPath) {
77
- return NextResponse.json({ error: 'cloudflared not installed. Start the main tunnel first to auto-download it.' }, { status: 500 });
78
- }
85
+ const binPath = getCloudflaredPath();
86
+ if (!binPath) {
87
+ return NextResponse.json({ error: 'cloudflared not installed. Start the main tunnel first.' }, { status: 500 });
88
+ }
79
89
 
80
- state.port = p;
81
- state.status = 'starting';
82
- state.url = null;
83
- saveConfig({ port: p });
90
+ const label = body.label || `localhost:${port}`;
91
+ state.entries.set(port, { process: null, url: null, status: 'starting', label });
92
+ syncConfig();
93
+
94
+ // Start tunnel
95
+ return new Promise<NextResponse>((resolve) => {
96
+ let resolved = false;
97
+ const child = spawn(binPath, ['tunnel', '--url', `http://localhost:${port}`], {
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+
101
+ const entry = state.entries.get(port)!;
102
+ entry.process = child;
103
+
104
+ const handleOutput = (data: Buffer) => {
105
+ const urlMatch = data.toString().match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
106
+ if (urlMatch && !entry.url) {
107
+ entry.url = urlMatch[1];
108
+ entry.status = 'running';
109
+ syncConfig();
110
+ if (!resolved) {
111
+ resolved = true;
112
+ resolve(NextResponse.json({ port, url: entry.url, status: 'running', label }));
113
+ }
114
+ }
115
+ };
84
116
 
85
- // Start tunnel
86
- return new Promise<NextResponse>((resolve) => {
87
- let resolved = false;
117
+ child.stdout?.on('data', handleOutput);
118
+ child.stderr?.on('data', handleOutput);
88
119
 
89
- const child = spawn(binPath, ['tunnel', '--url', `http://localhost:${p}`], {
90
- stdio: ['ignore', 'pipe', 'pipe'],
91
- });
92
- state.process = child;
93
-
94
- const handleOutput = (data: Buffer) => {
95
- const text = data.toString();
96
- const urlMatch = text.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
97
- if (urlMatch && !state.url) {
98
- state.url = urlMatch[1];
99
- state.status = 'running';
120
+ child.on('exit', () => {
121
+ entry.process = null;
122
+ entry.status = 'stopped';
123
+ entry.url = null;
124
+ syncConfig();
100
125
  if (!resolved) {
101
126
  resolved = true;
102
- resolve(NextResponse.json({ port: p, url: state.url, status: 'running' }));
127
+ resolve(NextResponse.json({ port, url: null, status: 'stopped', error: 'Tunnel exited' }));
103
128
  }
104
- }
105
- };
106
-
107
- child.stdout?.on('data', handleOutput);
108
- child.stderr?.on('data', handleOutput);
109
-
110
- child.on('exit', () => {
111
- state.process = null;
112
- state.status = 'stopped';
113
- state.url = null;
114
- if (!resolved) {
115
- resolved = true;
116
- resolve(NextResponse.json({ port: p, url: null, status: 'stopped', error: 'Tunnel exited' }));
117
- }
118
- });
129
+ });
119
130
 
120
- child.on('error', (err) => {
121
- state.status = 'error';
122
- if (!resolved) {
123
- resolved = true;
124
- resolve(NextResponse.json({ error: err.message }, { status: 500 }));
125
- }
131
+ setTimeout(() => {
132
+ if (!resolved) {
133
+ resolved = true;
134
+ resolve(NextResponse.json({ port, url: null, status: entry.status, error: 'Timeout' }));
135
+ }
136
+ }, 30000);
126
137
  });
138
+ }
139
+
140
+ return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
141
+ }
127
142
 
128
- setTimeout(() => {
129
- if (!resolved) {
130
- resolved = true;
131
- resolve(NextResponse.json({ port: p, url: null, status: state.status, error: 'Timeout waiting for tunnel URL' }));
132
- }
133
- }, 30000);
134
- });
143
+ function syncConfig() {
144
+ const entries: PreviewEntry[] = [];
145
+ for (const [port, s] of state.entries) {
146
+ entries.push({ port, url: s.url, status: s.status, label: s.label });
147
+ }
148
+ saveConfig(entries);
135
149
  }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
4
+ return (
5
+ <html>
6
+ <body style={{ background: '#0a0a0a', color: '#e5e5e5', fontFamily: 'monospace', padding: '2rem' }}>
7
+ <h2>Something went wrong</h2>
8
+ <p style={{ color: '#999' }}>{error.message}</p>
9
+ <button onClick={reset} style={{ marginTop: '1rem', padding: '0.5rem 1rem', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
10
+ Try again
11
+ </button>
12
+ </body>
13
+ </html>
14
+ );
15
+ }
@@ -12,6 +12,7 @@
12
12
  * forge-server --port 4000 Custom web port (default: 3000)
13
13
  * forge-server --terminal-port 4001 Custom terminal port (default: 3001)
14
14
  * forge-server --dir ~/.forge-test Custom data directory (default: ~/.forge)
15
+ * forge-server --reset-terminal Kill terminal server before start (loses tmux sessions)
15
16
  *
16
17
  * Examples:
17
18
  * forge-server --background --port 4000 --terminal-port 4001 --dir ~/.forge-staging
@@ -35,11 +36,19 @@ function getArg(name) {
35
36
  return process.argv[idx + 1];
36
37
  }
37
38
 
39
+ // ── Version ──
40
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
41
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
42
+ console.log(`@aion0/forge v${pkg.version}`);
43
+ process.exit(0);
44
+ }
45
+
38
46
  const isDev = process.argv.includes('--dev');
39
47
  const isBackground = process.argv.includes('--background');
40
48
  const isStop = process.argv.includes('--stop');
41
49
  const isRestart = process.argv.includes('--restart');
42
50
  const isRebuild = process.argv.includes('--rebuild');
51
+ const resetTerminal = process.argv.includes('--reset-terminal');
43
52
 
44
53
  const webPort = parseInt(getArg('--port')) || 3000;
45
54
  const terminalPort = parseInt(getArg('--terminal-port')) || 3001;
@@ -70,6 +79,20 @@ process.env.PORT = String(webPort);
70
79
  process.env.TERMINAL_PORT = String(terminalPort);
71
80
  process.env.FORGE_DATA_DIR = DATA_DIR;
72
81
 
82
+ // ── Reset terminal server (kill port + tmux sessions) ──
83
+ if (resetTerminal) {
84
+ console.log(`[forge] Resetting terminal server (port ${terminalPort})...`);
85
+ try {
86
+ const pids = execSync(`lsof -ti:${terminalPort}`, { encoding: 'utf-8' }).trim();
87
+ for (const pid of pids.split('\n').filter(Boolean)) {
88
+ try { execSync(`kill ${pid.trim()}`); } catch {}
89
+ }
90
+ console.log(`[forge] Killed terminal server on port ${terminalPort}`);
91
+ } catch {
92
+ console.log(`[forge] No process on port ${terminalPort}`);
93
+ }
94
+ }
95
+
73
96
  // ── Helper: stop running instance ──
74
97
  function stopServer() {
75
98
  try {
package/cli/mw.ts CHANGED
@@ -31,6 +31,19 @@ async function api(path: string, opts?: RequestInit) {
31
31
  }
32
32
 
33
33
  async function main() {
34
+ if (cmd === '--version' || cmd === '-v') {
35
+ const { readFileSync } = await import('node:fs');
36
+ const { join, dirname } = await import('node:path');
37
+ const { fileURLToPath } = await import('node:url');
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
40
+ console.log(`@aion0/forge v${pkg.version}`);
41
+ } catch {
42
+ console.log('forge (version unknown)');
43
+ }
44
+ process.exit(0);
45
+ }
46
+
34
47
  switch (cmd) {
35
48
  case 'task':
36
49
  case 't': {
@@ -394,7 +394,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
394
394
  }, []);
395
395
 
396
396
  return (
397
- <div className="flex-1 flex flex-col min-h-0">
397
+ <div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
398
398
  {/* Task completion notification */}
399
399
  {taskNotification && (
400
400
  <div className="shrink-0 px-3 py-1.5 bg-green-900/30 border-b border-green-800/50 flex items-center gap-2 text-xs">
@@ -431,7 +431,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
431
431
  )}
432
432
 
433
433
  {/* File browser + code viewer — bottom */}
434
- {codeOpen && <div className="flex-1 flex min-h-0">
434
+ {codeOpen && <div className="flex-1 flex min-h-0 min-w-0 overflow-hidden">
435
435
  {/* Sidebar */}
436
436
  {sidebarOpen && (
437
437
  <aside className="w-56 border-r border-[var(--border)] flex flex-col shrink-0">
@@ -609,7 +609,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
609
609
  )}
610
610
 
611
611
  {/* Code viewer */}
612
- <main className="flex-1 flex flex-col min-w-0">
612
+ <main className="flex-1 flex flex-col min-w-0 overflow-hidden" style={{ width: 0 }}>
613
613
  <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0 flex items-center gap-2">
614
614
  <button
615
615
  onClick={() => setSidebarOpen(v => !v)}
@@ -672,7 +672,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
672
672
  </div>
673
673
  ) : viewMode === 'diff' && diffContent ? (
674
674
  <div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
675
- <pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
675
+ <pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2, overflow: 'auto', maxWidth: 0, minWidth: '100%' }}>
676
676
  {diffContent.split('\n').map((line, i) => {
677
677
  const color = line.startsWith('+') ? 'text-green-400 bg-green-900/20'
678
678
  : line.startsWith('-') ? 'text-red-400 bg-red-900/20'
@@ -689,11 +689,11 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
689
689
  </div>
690
690
  ) : selectedFile && content !== null ? (
691
691
  <div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
692
- <pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
692
+ <pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2, overflow: 'auto', maxWidth: 0, minWidth: '100%' }}>
693
693
  {content.split('\n').map((line, i) => (
694
694
  <div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
695
695
  <span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
696
- <span className="flex-1">{highlightLine(line, language)}</span>
696
+ <span className="whitespace-pre">{highlightLine(line, language)}</span>
697
697
  </div>
698
698
  ))}
699
699
  </pre>
@@ -1,134 +1,154 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
+
5
+ interface PreviewEntry {
6
+ port: number;
7
+ url: string | null;
8
+ status: string;
9
+ label?: string;
10
+ }
4
11
 
5
12
  export default function PreviewPanel() {
6
- const [port, setPort] = useState(0);
13
+ const [previews, setPreviews] = useState<PreviewEntry[]>([]);
7
14
  const [inputPort, setInputPort] = useState('');
8
- const [tunnelUrl, setTunnelUrl] = useState<string | null>(null);
9
- const [status, setStatus] = useState<string>('stopped');
15
+ const [inputLabel, setInputLabel] = useState('');
16
+ const [starting, setStarting] = useState(false);
10
17
  const [error, setError] = useState('');
18
+ const [activePreview, setActivePreview] = useState<number | null>(null);
11
19
  const [isRemote, setIsRemote] = useState(false);
12
20
 
13
21
  useEffect(() => {
14
22
  setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
15
- fetch('/api/preview')
16
- .then(r => r.json())
17
- .then(d => {
18
- if (d.port) {
19
- setPort(d.port);
20
- setInputPort(String(d.port));
21
- setTunnelUrl(d.url || null);
22
- setStatus(d.status || 'stopped');
23
- }
24
- })
25
- .catch(() => {});
26
23
  }, []);
27
24
 
25
+ const fetchPreviews = useCallback(async () => {
26
+ try {
27
+ const res = await fetch('/api/preview');
28
+ const data = await res.json();
29
+ if (Array.isArray(data)) setPreviews(data);
30
+ } catch {}
31
+ }, []);
32
+
33
+ useEffect(() => {
34
+ fetchPreviews();
35
+ const timer = setInterval(fetchPreviews, 5000);
36
+ return () => clearInterval(timer);
37
+ }, [fetchPreviews]);
38
+
28
39
  const handleStart = async () => {
29
40
  const p = parseInt(inputPort);
30
- if (!p || p < 1 || p > 65535) {
31
- setError('Invalid port');
32
- return;
33
- }
41
+ if (!p || p < 1 || p > 65535) { setError('Invalid port'); return; }
34
42
  setError('');
35
- setStatus('starting');
43
+ setStarting(true);
36
44
  try {
37
45
  const res = await fetch('/api/preview', {
38
46
  method: 'POST',
39
47
  headers: { 'Content-Type': 'application/json' },
40
- body: JSON.stringify({ port: p }),
48
+ body: JSON.stringify({ action: 'start', port: p, label: inputLabel || undefined }),
41
49
  });
42
50
  const data = await res.json();
43
- if (data.error) {
44
- setError(data.error);
45
- setStatus('error');
46
- } else {
47
- setPort(data.port);
48
- setTunnelUrl(data.url || null);
49
- setStatus(data.status || 'running');
51
+ if (data.error) setError(data.error);
52
+ else {
53
+ setInputPort('');
54
+ setInputLabel('');
55
+ setActivePreview(p);
50
56
  }
51
- } catch {
52
- setError('Failed to start tunnel');
53
- setStatus('error');
54
- }
57
+ fetchPreviews();
58
+ } catch { setError('Failed'); }
59
+ setStarting(false);
55
60
  };
56
61
 
57
- const handleStop = async () => {
62
+ const handleStop = async (port: number) => {
58
63
  await fetch('/api/preview', {
59
64
  method: 'POST',
60
65
  headers: { 'Content-Type': 'application/json' },
61
- body: JSON.stringify({ action: 'stop' }),
66
+ body: JSON.stringify({ action: 'stop', port }),
62
67
  });
63
- setPort(0);
64
- setTunnelUrl(null);
65
- setStatus('stopped');
68
+ if (activePreview === port) setActivePreview(null);
69
+ fetchPreviews();
66
70
  };
67
71
 
68
- // What to show in iframe: tunnel URL for remote, localhost for local
69
- const previewSrc = isRemote
70
- ? tunnelUrl
71
- : port ? `http://localhost:${port}` : null;
72
+ const active = previews.find(p => p.port === activePreview);
73
+ const previewSrc = active
74
+ ? (isRemote ? active.url : `http://localhost:${active.port}`)
75
+ : null;
72
76
 
73
77
  return (
74
78
  <div className="flex-1 flex flex-col min-h-0">
75
- {/* Control bar */}
76
- <div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-3 shrink-0 flex-wrap">
77
- <span className="text-[11px] font-semibold text-[var(--text-primary)]">Preview</span>
78
-
79
- <input
80
- type="number"
81
- value={inputPort}
82
- onChange={e => setInputPort(e.target.value)}
83
- onKeyDown={e => e.key === 'Enter' && handleStart()}
84
- placeholder="Port"
85
- className="w-24 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
86
- />
79
+ {/* Top bar */}
80
+ <div className="px-4 py-2 border-b border-[var(--border)] shrink-0 space-y-2">
81
+ {/* Preview list */}
82
+ <div className="flex items-center gap-2 flex-wrap">
83
+ <span className="text-[11px] font-semibold text-[var(--text-primary)]">Demo Preview</span>
84
+ {previews.map(p => (
85
+ <div key={p.port} className="flex items-center gap-1">
86
+ <button
87
+ onClick={() => setActivePreview(p.port)}
88
+ className={`text-[10px] px-2 py-0.5 rounded ${activePreview === p.port ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
89
+ >
90
+ <span className={`mr-1 ${p.status === 'running' ? 'text-green-400' : 'text-gray-500'}`}>●</span>
91
+ {p.label || `:${p.port}`}
92
+ </button>
93
+ {p.url && (
94
+ <button
95
+ onClick={() => navigator.clipboard.writeText(p.url!)}
96
+ className="text-[8px] text-green-400 hover:text-green-300 truncate max-w-[150px]"
97
+ title={`Copy: ${p.url}`}
98
+ >
99
+ {p.url.replace('https://', '').slice(0, 20)}...
100
+ </button>
101
+ )}
102
+ <button
103
+ onClick={() => handleStop(p.port)}
104
+ className="text-[9px] text-red-400 hover:text-red-300"
105
+ >
106
+ x
107
+ </button>
108
+ </div>
109
+ ))}
110
+ </div>
87
111
 
88
- {status === 'stopped' || status === 'error' ? (
112
+ {/* Add new */}
113
+ <div className="flex items-center gap-2">
114
+ <input
115
+ type="number"
116
+ value={inputPort}
117
+ onChange={e => setInputPort(e.target.value)}
118
+ onKeyDown={e => e.key === 'Enter' && handleStart()}
119
+ placeholder="Port"
120
+ className="w-20 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
121
+ />
122
+ <input
123
+ value={inputLabel}
124
+ onChange={e => setInputLabel(e.target.value)}
125
+ onKeyDown={e => e.key === 'Enter' && handleStart()}
126
+ placeholder="Label (optional)"
127
+ className="w-32 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
128
+ />
89
129
  <button
90
130
  onClick={handleStart}
91
- disabled={!inputPort}
131
+ disabled={!inputPort || starting}
92
132
  className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
93
133
  >
94
- Start Tunnel
134
+ {starting ? 'Starting...' : '+ Add'}
95
135
  </button>
96
- ) : status === 'starting' ? (
97
- <span className="text-[10px] text-yellow-400">Starting tunnel...</span>
98
- ) : (
99
- <>
100
- <span className="text-[10px] text-green-400">● localhost:{port}</span>
101
- {tunnelUrl && (
102
- <button
103
- onClick={() => { navigator.clipboard.writeText(tunnelUrl); }}
104
- className="text-[10px] text-green-400 hover:text-green-300 truncate max-w-[250px]"
105
- title={`Click to copy: ${tunnelUrl}`}
106
- >
107
- {tunnelUrl.replace('https://', '')}
108
- </button>
109
- )}
136
+ {active && (
110
137
  <a
111
138
  href={previewSrc || '#'}
112
139
  target="_blank"
113
140
  rel="noopener"
114
- className="text-[10px] text-[var(--accent)] hover:underline"
141
+ className="text-[10px] text-[var(--accent)] hover:underline ml-auto"
115
142
  >
116
143
  Open ↗
117
144
  </a>
118
- <button
119
- onClick={handleStop}
120
- className="text-[10px] text-red-400 hover:text-red-300"
121
- >
122
- Stop
123
- </button>
124
- </>
125
- )}
126
-
127
- {error && <span className="text-[10px] text-red-400">{error}</span>}
145
+ )}
146
+ {error && <span className="text-[10px] text-red-400">{error}</span>}
147
+ </div>
128
148
  </div>
129
149
 
130
150
  {/* Preview iframe */}
131
- {previewSrc && status === 'running' ? (
151
+ {previewSrc && active?.status === 'running' ? (
132
152
  <iframe
133
153
  src={previewSrc}
134
154
  className="flex-1 w-full border-0 bg-white"
@@ -137,15 +157,8 @@ export default function PreviewPanel() {
137
157
  ) : (
138
158
  <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
139
159
  <div className="text-center space-y-3 max-w-md">
140
- <p className="text-sm">Preview a local dev server</p>
141
- <p className="text-xs">Enter the port of your running dev server and click Start Tunnel.</p>
142
- <p className="text-xs">A dedicated Cloudflare Tunnel will be created for that port, giving it its own public URL — no path prefix issues.</p>
143
- <div className="text-[10px] text-left bg-[var(--bg-tertiary)] rounded p-3 space-y-1">
144
- <p>1. Start your dev server: <code className="text-[var(--accent)]">npm run dev</code></p>
145
- <p>2. Enter its port (e.g. <code className="text-[var(--accent)]">4321</code>)</p>
146
- <p>3. Click <strong>Start Tunnel</strong></p>
147
- <p>4. Share the generated URL — it maps directly to your dev server</p>
148
- </div>
160
+ <p className="text-sm">{previews.length > 0 ? 'Select a preview to display' : 'Preview local dev servers'}</p>
161
+ <p className="text-xs">Enter a port, add a label, and click Add. Each preview gets its own Cloudflare Tunnel URL.</p>
149
162
  </div>
150
163
  </div>
151
164
  )}
@@ -429,7 +429,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
429
429
  const detachedCount = tmuxSessions.filter(s => !usedSessions.includes(s.name)).length;
430
430
 
431
431
  return (
432
- <div className="h-full w-full flex-1 flex flex-col bg-[#1a1a2e]">
432
+ <div className="h-full w-full flex-1 flex flex-col bg-[#1a1a2e] overflow-hidden">
433
433
  {/* Tab bar + toolbar */}
434
434
  <div className="flex items-center bg-[#12122a] border-b border-[#2a2a4a] shrink-0">
435
435
  {/* Tabs */}
@@ -1080,7 +1080,17 @@ const MemoTerminalPane = memo(function TerminalPane({
1080
1080
  }, 500);
1081
1081
  }
1082
1082
  } else if (msg.type === 'error') {
1083
- term.write(`\r\n\x1b[93m[${msg.message || 'error'}]\x1b[0m\r\n`);
1083
+ // Session no longer exists — auto-create a new one
1084
+ if (!connectedSession && msg.message?.includes('no longer exists') && createRetries < MAX_CREATE_RETRIES) {
1085
+ createRetries++;
1086
+ isNewlyCreated = true;
1087
+ term.write(`\r\n\x1b[93m[${msg.message} — creating new session...]\x1b[0m\r\n`);
1088
+ if (socket.readyState === WebSocket.OPEN) {
1089
+ socket.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows }));
1090
+ }
1091
+ } else {
1092
+ term.write(`\r\n\x1b[93m[${msg.message || 'error'}]\x1b[0m\r\n`);
1093
+ }
1084
1094
  } else if (msg.type === 'exit') {
1085
1095
  term.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n');
1086
1096
  }
package/dev-test.sh CHANGED
@@ -2,4 +2,4 @@
2
2
  # dev-test.sh — Start Forge test instance (port 4000, data ~/.forge-test)
3
3
 
4
4
  mkdir -p ~/.forge-test
5
- PORT=4000 TERMINAL_PORT=4001 FORGE_DATA_DIR=~/.forge-test pnpm dev -- -p 4000
5
+ PORT=4000 TERMINAL_PORT=4001 FORGE_DATA_DIR=~/.forge-test npx next dev --turbopack -p 4000
package/install.sh ADDED
@@ -0,0 +1,28 @@
1
+ #!/bin/bash
2
+ # install.sh — Install Forge globally, ready to run
3
+ #
4
+ # Usage:
5
+ # ./install.sh # from npm
6
+ # ./install.sh local # from local source
7
+
8
+ set -e
9
+
10
+ if [ "$1" = "local" ] || [ "$1" = "--local" ]; then
11
+ echo "[forge] Installing from local source..."
12
+ npm uninstall -g @aion0/forge 2>/dev/null || true
13
+ npm link
14
+ echo "[forge] Building..."
15
+ pnpm build
16
+ else
17
+ echo "[forge] Installing from npm..."
18
+ rm -rf "$(npm root -g)/@aion0/forge" 2>/dev/null || true
19
+ npm cache clean --force 2>/dev/null || true
20
+ npm install -g @aion0/forge
21
+ echo "[forge] Building..."
22
+ cd "$(npm root -g)/@aion0/forge" && npx next build && cd -
23
+ fi
24
+
25
+ echo ""
26
+ echo "[forge] Done."
27
+ forge-server --version
28
+ echo "Run: forge-server"
@@ -9,7 +9,8 @@ export async function register() {
9
9
  const { existsSync, readFileSync } = await import('node:fs');
10
10
  const { join } = await import('node:path');
11
11
  const { homedir } = await import('node:os');
12
- const envFile = join(homedir(), '.forge', '.env.local');
12
+ const dataDir = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
13
+ const envFile = join(dataDir, '.env.local');
13
14
  if (existsSync(envFile)) {
14
15
  for (const line of readFileSync(envFile, 'utf-8').split('\n')) {
15
16
  const trimmed = line.trim();
package/lib/init.ts CHANGED
@@ -22,7 +22,7 @@ export function ensureInitialized() {
22
22
  // Display login password (auto-generated, rotates daily)
23
23
  const password = getPassword();
24
24
  console.log(`[init] Login password: ${password} (valid today)`);
25
- console.log('[init] Forgot? Run: mw password');
25
+ console.log('[init] Forgot? Run: forge password');
26
26
 
27
27
  // Start background task runner
28
28
  ensureRunnerStarted();
@@ -61,11 +61,12 @@ function startTerminalProcess() {
61
61
 
62
62
  const termPort = Number(process.env.TERMINAL_PORT) || 3001;
63
63
 
64
- // Check if port is already in use
64
+ // Check if port is already in use — kill stale process if needed
65
65
  const net = require('node:net');
66
66
  const tester = net.createServer();
67
67
  tester.once('error', () => {
68
- console.log(`[terminal] Port ${termPort} already in use, skipping`);
68
+ // Port in use — terminal server already running, reuse it
69
+ console.log(`[terminal] Port ${termPort} already in use, reusing existing`);
69
70
  });
70
71
  tester.once('listening', () => {
71
72
  tester.close();
@@ -19,8 +19,8 @@ import type { Task, TaskLogEntry } from '@/src/types';
19
19
  // Prevent duplicate polling and state loss across hot-reloads
20
20
  const globalKey = Symbol.for('mw-telegram-state');
21
21
  const g = globalThis as any;
22
- if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0 };
23
- const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number } = g[globalKey];
22
+ if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0, taskListenerAttached: false, pollActive: false };
23
+ const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number; taskListenerAttached: boolean; pollActive: boolean } = g[globalKey];
24
24
 
25
25
  // Track which Telegram message maps to which task (for reply-based interaction)
26
26
  const taskMessageMap = new Map<number, string>(); // messageId → taskId
@@ -56,54 +56,82 @@ export function startTelegramBot() {
56
56
  // Set bot command menu
57
57
  setBotCommands(settings.telegramBotToken);
58
58
 
59
- // Listen for task events → stream to Telegram
60
- onTaskEvent((taskId, event, data) => {
61
- const settings = loadSettings();
62
- if (!settings.telegramBotToken || !settings.telegramChatId) return;
63
- const chatId = Number(settings.telegramChatId);
64
-
65
- if (event === 'log') {
66
- bufferLogEntry(taskId, chatId, data as TaskLogEntry);
67
- } else if (event === 'status') {
68
- handleStatusChange(taskId, chatId, data as string);
69
- }
70
- });
59
+ // Listen for task events → stream to Telegram (only once)
60
+ if (!botState.taskListenerAttached) {
61
+ botState.taskListenerAttached = true;
62
+ onTaskEvent((taskId, event, data) => {
63
+ const settings = loadSettings();
64
+ if (!settings.telegramBotToken || !settings.telegramChatId) return;
65
+ const chatId = Number(settings.telegramChatId.split(',')[0].trim());
66
+
67
+ if (event === 'log') {
68
+ bufferLogEntry(taskId, chatId, data as TaskLogEntry);
69
+ } else if (event === 'status') {
70
+ handleStatusChange(taskId, chatId, data as string);
71
+ }
72
+ });
73
+ }
71
74
 
72
- poll();
75
+ // Skip stale updates on startup — set offset to -1 to get only new messages
76
+ if (botState.lastUpdateId === 0) {
77
+ fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=-1`)
78
+ .then(r => r.json())
79
+ .then(data => {
80
+ if (data.ok && data.result?.length > 0) {
81
+ botState.lastUpdateId = data.result[data.result.length - 1].update_id;
82
+ }
83
+ poll();
84
+ })
85
+ .catch(() => poll());
86
+ } else {
87
+ poll();
88
+ }
73
89
  }
74
90
 
75
91
  export function stopTelegramBot() {
76
92
  botState.polling = false;
93
+ botState.pollActive = false;
77
94
  if (botState.pollTimer) { clearTimeout(botState.pollTimer); botState.pollTimer = null; }
78
95
  }
79
96
 
80
97
  // ─── Polling ─────────────────────────────────────────────────
81
98
 
99
+ function schedulePoll(delay: number = 1000) {
100
+ if (botState.pollTimer) clearTimeout(botState.pollTimer);
101
+ botState.pollTimer = setTimeout(poll, delay);
102
+ }
103
+
82
104
  async function poll() {
83
- if (!botState.polling) return;
105
+ // Prevent concurrent polls — main cause of duplicate messages after sleep/wake
106
+ if (!botState.polling || botState.pollActive) return;
107
+ botState.pollActive = true;
84
108
 
85
109
  try {
86
110
  const settings = loadSettings();
111
+ const controller = new AbortController();
112
+ const timeout = setTimeout(() => controller.abort(), 35000);
113
+
87
114
  const url = `https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=${botState.lastUpdateId + 1}&timeout=30`;
88
- const res = await fetch(url);
115
+ const res = await fetch(url, { signal: controller.signal });
116
+ clearTimeout(timeout);
117
+
89
118
  const data = await res.json();
90
119
 
91
120
  if (data.ok && data.result) {
92
121
  for (const update of data.result) {
122
+ if (update.update_id <= botState.lastUpdateId) continue;
93
123
  botState.lastUpdateId = update.update_id;
94
124
  if (update.message?.text) {
95
125
  await handleMessage(update.message);
96
126
  }
97
127
  }
98
128
  }
99
- } catch (err: any) {
100
- const isNetworkError = err?.cause?.code === 'ECONNRESET' || err?.message?.includes('fetch failed');
101
- if (!isNetworkError) {
102
- console.error('[telegram] Poll error:', err);
103
- }
129
+ } catch {
130
+ // Network errors during sleep/wake silent
104
131
  }
105
132
 
106
- botState.pollTimer = setTimeout(poll, 1000);
133
+ botState.pollActive = false;
134
+ if (botState.polling) schedulePoll(1000);
107
135
  }
108
136
 
109
137
  // ─── Message Handler ─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
package/tsconfig.json CHANGED
@@ -33,7 +33,8 @@
33
33
  "**/*.ts",
34
34
  "**/*.tsx",
35
35
  ".next/types/**/*.ts",
36
- ".next/dev/types/**/*.ts"
36
+ ".next/dev/types/**/*.ts",
37
+ ".next/dev/dev/types/**/*.ts"
37
38
  ],
38
39
  "exclude": [
39
40
  "node_modules"