@aion0/forge 0.2.3 → 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.
package/CLAUDE.md CHANGED
@@ -19,11 +19,15 @@ npm install -g /Users/zliu/IdeaProjects/my-workflow
19
19
  npm install -g @aion0/forge
20
20
 
21
21
  # Run via npm global install
22
- forge-server # foreground (auto-builds if needed)
23
- forge-server --dev # dev mode
24
- forge-server --background # background, logs to ~/.forge/forge.log
25
- forge-server --stop # stop background server
26
- forge-server --rebuild # force rebuild
22
+ forge-server # foreground (default port 3000)
23
+ forge-server --dev # dev mode
24
+ forge-server --background # background, logs to ~/.forge/forge.log
25
+ forge-server --stop # stop background server
26
+ forge-server --restart # stop + start (safe for remote)
27
+ forge-server --rebuild # force rebuild
28
+ forge-server --port 4000 # custom web port
29
+ forge-server --terminal-port 4001 # custom terminal port
30
+ forge-server --dir ~/.forge-staging # custom data directory
27
31
 
28
32
  # CLI
29
33
  forge # help
@@ -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
+ }
@@ -3,10 +3,20 @@
3
3
  * forge-server — Start the Forge web platform.
4
4
  *
5
5
  * Usage:
6
- * forge-server Start in foreground (production mode)
7
- * forge-server --dev Start in foreground (development mode)
8
- * forge-server --background Start in background (production mode), logs to ~/.forge/forge.log
9
- * forge-server --stop Stop background server
6
+ * forge-server Start in foreground (production)
7
+ * forge-server --dev Start in foreground (development)
8
+ * forge-server --background Start in background
9
+ * forge-server --stop Stop background server
10
+ * forge-server --restart Stop + start (safe for remote)
11
+ * forge-server --rebuild Force rebuild
12
+ * forge-server --port 4000 Custom web port (default: 3000)
13
+ * forge-server --terminal-port 4001 Custom terminal port (default: 3001)
14
+ * forge-server --dir ~/.forge-test Custom data directory (default: ~/.forge)
15
+ * forge-server --reset-terminal Kill terminal server before start (loses tmux sessions)
16
+ *
17
+ * Examples:
18
+ * forge-server --background --port 4000 --terminal-port 4001 --dir ~/.forge-staging
19
+ * forge-server --restart
10
20
  */
11
21
 
12
22
  import { execSync, spawn } from 'node:child_process';
@@ -17,19 +27,40 @@ import { homedir } from 'node:os';
17
27
 
18
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
29
  const ROOT = join(__dirname, '..');
20
- const DATA_DIR = join(homedir(), '.forge');
21
- const PID_FILE = join(DATA_DIR, 'forge.pid');
22
- const LOG_FILE = join(DATA_DIR, 'forge.log');
30
+
31
+ // ── Parse arguments ──
32
+
33
+ function getArg(name) {
34
+ const idx = process.argv.indexOf(name);
35
+ if (idx === -1 || idx + 1 >= process.argv.length) return null;
36
+ return process.argv[idx + 1];
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
+ }
23
45
 
24
46
  const isDev = process.argv.includes('--dev');
25
47
  const isBackground = process.argv.includes('--background');
26
48
  const isStop = process.argv.includes('--stop');
49
+ const isRestart = process.argv.includes('--restart');
27
50
  const isRebuild = process.argv.includes('--rebuild');
51
+ const resetTerminal = process.argv.includes('--reset-terminal');
52
+
53
+ const webPort = parseInt(getArg('--port')) || 3000;
54
+ const terminalPort = parseInt(getArg('--terminal-port')) || 3001;
55
+ const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge');
56
+
57
+ const PID_FILE = join(DATA_DIR, 'forge.pid');
58
+ const LOG_FILE = join(DATA_DIR, 'forge.log');
28
59
 
29
60
  process.chdir(ROOT);
30
61
  mkdirSync(DATA_DIR, { recursive: true });
31
62
 
32
- // ── Load ~/.forge/.env.local ──
63
+ // ── Load <data-dir>/.env.local ──
33
64
  const envFile = join(DATA_DIR, '.env.local');
34
65
  if (existsSync(envFile)) {
35
66
  for (const line of readFileSync(envFile, 'utf-8').split('\n')) {
@@ -43,30 +74,88 @@ if (existsSync(envFile)) {
43
74
  }
44
75
  }
45
76
 
46
- // ── Stop ──
47
- if (isStop) {
77
+ // Set env vars for Next.js and terminal server
78
+ process.env.PORT = String(webPort);
79
+ process.env.TERMINAL_PORT = String(terminalPort);
80
+ process.env.FORGE_DATA_DIR = DATA_DIR;
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
+
96
+ // ── Helper: stop running instance ──
97
+ function stopServer() {
48
98
  try {
49
99
  const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim());
50
100
  process.kill(pid, 'SIGTERM');
51
101
  unlinkSync(PID_FILE);
52
102
  console.log(`[forge] Stopped (pid ${pid})`);
103
+ return true;
53
104
  } catch {
54
105
  console.log('[forge] No running server found');
106
+ return false;
55
107
  }
108
+ }
109
+
110
+ // ── Helper: start background server ──
111
+ function startBackground() {
112
+ if (!existsSync(join(ROOT, '.next'))) {
113
+ console.log('[forge] Building...');
114
+ execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
115
+ }
116
+
117
+ const logFd = openSync(LOG_FILE, 'a');
118
+ const child = spawn('npx', ['next', 'start', '-p', String(webPort)], {
119
+ cwd: ROOT,
120
+ stdio: ['ignore', logFd, logFd],
121
+ env: { ...process.env },
122
+ detached: true,
123
+ });
124
+
125
+ writeFileSync(PID_FILE, String(child.pid));
126
+ child.unref();
127
+ console.log(`[forge] Started in background (pid ${child.pid})`);
128
+ console.log(`[forge] Web: http://localhost:${webPort}`);
129
+ console.log(`[forge] Terminal: ws://localhost:${terminalPort}`);
130
+ console.log(`[forge] Data: ${DATA_DIR}`);
131
+ console.log(`[forge] Log: ${LOG_FILE}`);
132
+ console.log(`[forge] Stop: forge-server --stop${DATA_DIR !== join(homedir(), '.forge') ? ` --dir ${DATA_DIR}` : ''}`);
133
+ }
134
+
135
+ // ── Stop ──
136
+ if (isStop) {
137
+ stopServer();
138
+ process.exit(0);
139
+ }
140
+
141
+ // ── Restart ──
142
+ if (isRestart) {
143
+ stopServer();
144
+ // Brief delay to let port release
145
+ await new Promise(r => setTimeout(r, 1500));
146
+ startBackground();
56
147
  process.exit(0);
57
148
  }
58
149
 
59
150
  // ── Rebuild ──
60
151
  if (isRebuild || existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
61
- // Always rebuild after npm install (new version)
62
- const buildIdFile = join(ROOT, '.next', 'BUILD_ID');
63
152
  const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
64
153
  const versionFile = join(ROOT, '.next', '.forge-version');
65
154
  const lastBuiltVersion = existsSync(versionFile) ? readFileSync(versionFile, 'utf-8').trim() : '';
66
155
  if (isRebuild || lastBuiltVersion !== pkgVersion) {
67
156
  console.log(`[forge] Rebuilding (v${pkgVersion})...`);
68
157
  execSync('rm -rf .next', { cwd: ROOT });
69
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit' });
158
+ execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
70
159
  writeFileSync(versionFile, pkgVersion);
71
160
  if (isRebuild) {
72
161
  console.log('[forge] Rebuild complete');
@@ -77,32 +166,14 @@ if (isRebuild || existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
77
166
 
78
167
  // ── Background ──
79
168
  if (isBackground) {
80
- // Build if needed
81
- if (!existsSync(join(ROOT, '.next'))) {
82
- console.log('[forge] Building...');
83
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit' });
84
- }
85
-
86
- const logFd = openSync(LOG_FILE, 'a');
87
- const child = spawn('npx', ['next', 'start'], {
88
- cwd: ROOT,
89
- stdio: ['ignore', logFd, logFd],
90
- env: { ...process.env },
91
- detached: true,
92
- });
93
-
94
- writeFileSync(PID_FILE, String(child.pid));
95
- child.unref();
96
- console.log(`[forge] Started in background (pid ${child.pid})`);
97
- console.log(`[forge] Log: ${LOG_FILE}`);
98
- console.log(`[forge] Stop: forge-server --stop`);
169
+ startBackground();
99
170
  process.exit(0);
100
171
  }
101
172
 
102
173
  // ── Foreground ──
103
174
  if (isDev) {
104
- console.log('[forge] Starting in development mode...');
105
- const child = spawn('npx', ['next', 'dev', '--turbopack'], {
175
+ console.log(`[forge] Starting dev mode (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
176
+ const child = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(webPort)], {
106
177
  cwd: ROOT,
107
178
  stdio: 'inherit',
108
179
  env: { ...process.env },
@@ -111,10 +182,10 @@ if (isDev) {
111
182
  } else {
112
183
  if (!existsSync(join(ROOT, '.next'))) {
113
184
  console.log('[forge] Building...');
114
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit' });
185
+ execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
115
186
  }
116
- console.log('[forge] Starting server...');
117
- const child = spawn('npx', ['next', 'start'], {
187
+ console.log(`[forge] Starting server (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
188
+ const child = spawn('npx', ['next', 'start', '-p', String(webPort)], {
118
189
  cwd: ROOT,
119
190
  stdio: 'inherit',
120
191
  env: { ...process.env },
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>
@@ -91,7 +91,7 @@ export default function Dashboard({ user }: { user: any }) {
91
91
  return (
92
92
  <div className="h-screen flex flex-col">
93
93
  {/* Top bar */}
94
- <header className="h-10 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0">
94
+ <header className="h-12 border-b-2 border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-secondary)]">
95
95
  <div className="flex items-center gap-4">
96
96
  <span className="text-sm font-bold text-[var(--accent)]">Forge</span>
97
97
 
@@ -155,7 +155,7 @@ export default function NewTaskModal({
155
155
  className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
156
156
  >
157
157
  {projects.map(p => (
158
- <option key={p.name} value={p.name}>
158
+ <option key={`${p.name}-${p.path}`} value={p.name}>
159
159
  {p.name} {p.language ? `(${p.language})` : ''}
160
160
  </option>
161
161
  ))}
@@ -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 ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/bash
2
+ # dev-test.sh — Start Forge test instance (port 4000, data ~/.forge-test)
3
+
4
+ mkdir -p ~/.forge-test
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();
@@ -59,16 +59,17 @@ let terminalChild: ReturnType<typeof spawn> | null = null;
59
59
  function startTerminalProcess() {
60
60
  if (terminalChild) return;
61
61
 
62
- // Check if port 3001 is already in use
62
+ const termPort = Number(process.env.TERMINAL_PORT) || 3001;
63
+
64
+ // Check if port is already in use — kill stale process if needed
63
65
  const net = require('node:net');
64
66
  const tester = net.createServer();
65
67
  tester.once('error', () => {
66
- // Port in use — terminal server already running
67
- console.log('[terminal] Port 3001 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`);
68
70
  });
69
71
  tester.once('listening', () => {
70
72
  tester.close();
71
- // Port free — start terminal server
72
73
  const script = join(process.cwd(), 'lib', 'terminal-standalone.ts');
73
74
  terminalChild = spawn('npx', ['tsx', script], {
74
75
  stdio: ['ignore', 'inherit', 'inherit'],
@@ -78,5 +79,5 @@ function startTerminalProcess() {
78
79
  terminalChild.on('exit', () => { terminalChild = null; });
79
80
  console.log('[terminal] Started standalone server (pid:', terminalChild.pid, ')');
80
81
  });
81
- tester.listen(3001);
82
+ tester.listen(termPort);
82
83
  }
package/lib/settings.ts CHANGED
@@ -3,7 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { join, dirname } from 'node:path';
4
4
  import YAML from 'yaml';
5
5
 
6
- const SETTINGS_FILE = join(homedir(), '.forge', 'settings.yaml');
6
+ const DATA_DIR = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
7
+ const SETTINGS_FILE = join(DATA_DIR, 'settings.yaml');
7
8
 
8
9
  export interface Settings {
9
10
  projectRoots: string[]; // Multiple project directories
@@ -16,13 +16,11 @@ import { startTunnel, stopTunnel, getTunnelStatus } from './cloudflared';
16
16
  import { getPassword } from './password';
17
17
  import type { Task, TaskLogEntry } from '@/src/types';
18
18
 
19
- let polling = false;
20
- let pollTimer: ReturnType<typeof setTimeout> | null = null;
21
- let lastUpdateId = 0;
22
-
23
- // Prevent duplicate polling across hot-reloads
24
- const globalKey = Symbol.for('mw-telegram-polling');
19
+ // Prevent duplicate polling and state loss across hot-reloads
20
+ const globalKey = Symbol.for('mw-telegram-state');
25
21
  const g = globalThis as any;
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];
26
24
 
27
25
  // Track which Telegram message maps to which task (for reply-based interaction)
28
26
  const taskMessageMap = new Map<number, string>(); // messageId → taskId
@@ -48,67 +46,92 @@ const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof
48
46
  // ─── Start/Stop ──────────────────────────────────────────────
49
47
 
50
48
  export function startTelegramBot() {
51
- if (polling || g[globalKey]) return;
49
+ if (botState.polling) return;
52
50
  const settings = loadSettings();
53
51
  if (!settings.telegramBotToken || !settings.telegramChatId) return;
54
52
 
55
- polling = true;
56
- g[globalKey] = true;
53
+ botState.polling = true;
57
54
  console.log('[telegram] Bot started');
58
55
 
59
56
  // Set bot command menu
60
57
  setBotCommands(settings.telegramBotToken);
61
58
 
62
- // Listen for task events → stream to Telegram
63
- onTaskEvent((taskId, event, data) => {
64
- const settings = loadSettings();
65
- if (!settings.telegramBotToken || !settings.telegramChatId) return;
66
- const chatId = Number(settings.telegramChatId);
67
-
68
- if (event === 'log') {
69
- bufferLogEntry(taskId, chatId, data as TaskLogEntry);
70
- } else if (event === 'status') {
71
- handleStatusChange(taskId, chatId, data as string);
72
- }
73
- });
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
+ }
74
74
 
75
- 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
+ }
76
89
  }
77
90
 
78
91
  export function stopTelegramBot() {
79
- polling = false;
80
- g[globalKey] = false;
81
- if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
92
+ botState.polling = false;
93
+ botState.pollActive = false;
94
+ if (botState.pollTimer) { clearTimeout(botState.pollTimer); botState.pollTimer = null; }
82
95
  }
83
96
 
84
97
  // ─── Polling ─────────────────────────────────────────────────
85
98
 
99
+ function schedulePoll(delay: number = 1000) {
100
+ if (botState.pollTimer) clearTimeout(botState.pollTimer);
101
+ botState.pollTimer = setTimeout(poll, delay);
102
+ }
103
+
86
104
  async function poll() {
87
- if (!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;
88
108
 
89
109
  try {
90
110
  const settings = loadSettings();
91
- const url = `https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=${lastUpdateId + 1}&timeout=30`;
92
- const res = await fetch(url);
111
+ const controller = new AbortController();
112
+ const timeout = setTimeout(() => controller.abort(), 35000);
113
+
114
+ const url = `https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=${botState.lastUpdateId + 1}&timeout=30`;
115
+ const res = await fetch(url, { signal: controller.signal });
116
+ clearTimeout(timeout);
117
+
93
118
  const data = await res.json();
94
119
 
95
120
  if (data.ok && data.result) {
96
121
  for (const update of data.result) {
97
- lastUpdateId = update.update_id;
122
+ if (update.update_id <= botState.lastUpdateId) continue;
123
+ botState.lastUpdateId = update.update_id;
98
124
  if (update.message?.text) {
99
125
  await handleMessage(update.message);
100
126
  }
101
127
  }
102
128
  }
103
- } catch (err: any) {
104
- // Network errors (ECONNRESET, fetch failed) are normal during sleep/wake — silent retry
105
- const isNetworkError = err?.cause?.code === 'ECONNRESET' || err?.message?.includes('fetch failed');
106
- if (!isNetworkError) {
107
- console.error('[telegram] Poll error:', err);
108
- }
129
+ } catch {
130
+ // Network errors during sleep/wake — silent
109
131
  }
110
132
 
111
- pollTimer = setTimeout(poll, 1000);
133
+ botState.pollActive = false;
134
+ if (botState.polling) schedulePoll(1000);
112
135
  }
113
136
 
114
137
  // ─── Message Handler ─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.3",
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/publish.sh ADDED
@@ -0,0 +1,45 @@
1
+ #!/bin/bash
2
+ # publish.sh — Bump version, commit, and publish to npm
3
+ #
4
+ # Usage:
5
+ # ./publish.sh # patch bump (0.2.3 → 0.2.4)
6
+ # ./publish.sh minor # minor bump (0.2.3 → 0.3.0)
7
+ # ./publish.sh major # major bump (0.2.3 → 1.0.0)
8
+ # ./publish.sh 0.5.0 # explicit version
9
+
10
+ set -e
11
+
12
+ VERSION_ARG=${1:-patch}
13
+ CURRENT=$(node -p "require('./package.json').version")
14
+
15
+ # Calculate new version
16
+ if [[ "$VERSION_ARG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
17
+ NEW_VERSION=$VERSION_ARG
18
+ elif [ "$VERSION_ARG" = "patch" ]; then
19
+ IFS='.' read -r major minor patch <<< "$CURRENT"
20
+ NEW_VERSION="$major.$minor.$((patch + 1))"
21
+ elif [ "$VERSION_ARG" = "minor" ]; then
22
+ IFS='.' read -r major minor patch <<< "$CURRENT"
23
+ NEW_VERSION="$major.$((minor + 1)).0"
24
+ elif [ "$VERSION_ARG" = "major" ]; then
25
+ IFS='.' read -r major minor patch <<< "$CURRENT"
26
+ NEW_VERSION="$((major + 1)).0.0"
27
+ else
28
+ echo "Usage: ./publish.sh [patch|minor|major|x.y.z]"
29
+ exit 1
30
+ fi
31
+
32
+ echo "Version: $CURRENT → $NEW_VERSION"
33
+ echo ""
34
+
35
+ # Update package.json
36
+ sed -i '' "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW_VERSION\"/" package.json
37
+
38
+ # Commit
39
+ git add -A
40
+ git commit -m "v$NEW_VERSION"
41
+ git tag "v$NEW_VERSION"
42
+
43
+ echo ""
44
+ echo "Ready to publish @aion0/forge@$NEW_VERSION"
45
+ echo "Run: npm login && npm publish --access public --otp=<code>"
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"