@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.
- package/app/api/code/route.ts +7 -5
- package/app/api/git/route.ts +2 -2
- package/app/api/preview/route.ts +101 -87
- package/app/global-error.tsx +15 -0
- package/bin/forge-server.mjs +23 -0
- package/cli/mw.ts +13 -0
- package/components/CodeViewer.tsx +6 -6
- package/components/PreviewPanel.tsx +104 -91
- package/components/WebTerminal.tsx +12 -2
- package/dev-test.sh +1 -1
- package/install.sh +28 -0
- package/instrumentation.ts +2 -1
- package/lib/init.ts +4 -3
- package/lib/telegram-bot.ts +51 -23
- package/package.json +1 -1
- package/tsconfig.json +2 -1
package/app/api/code/route.ts
CHANGED
|
@@ -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,
|
|
180
|
-
const statusOut = execSync('git status --porcelain -u', { cwd: dir
|
|
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,
|
|
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
|
}
|
package/app/api/git/route.ts
CHANGED
|
@@ -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(/\/$/, ''),
|
package/app/api/preview/route.ts
CHANGED
|
@@ -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
|
-
|
|
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] = {
|
|
13
|
-
const state: { process: ChildProcess | null;
|
|
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():
|
|
22
|
+
function getConfig(): PreviewEntry[] {
|
|
16
23
|
try {
|
|
17
|
-
|
|
24
|
+
const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
25
|
+
return Array.isArray(data) ? data : data.port ? [data] : [];
|
|
18
26
|
} catch {
|
|
19
|
-
return
|
|
27
|
+
return [];
|
|
20
28
|
}
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
function saveConfig(
|
|
31
|
+
function saveConfig(entries: PreviewEntry[]) {
|
|
24
32
|
const dir = dirname(CONFIG_FILE);
|
|
25
33
|
mkdirSync(dir, { recursive: true });
|
|
26
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(
|
|
34
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(entries, null, 2));
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
function getCloudflaredPath(): string | null {
|
|
30
|
-
const
|
|
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 —
|
|
48
|
+
// GET — list all previews
|
|
40
49
|
export async function GET() {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
url:
|
|
44
|
-
|
|
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
|
|
57
|
+
// POST — start/stop/manage previews
|
|
49
58
|
export async function POST(req: Request) {
|
|
50
|
-
const
|
|
59
|
+
const body = await req.json();
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
let resolved = false;
|
|
117
|
+
child.stdout?.on('data', handleOutput);
|
|
118
|
+
child.stderr?.on('data', handleOutput);
|
|
88
119
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -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="
|
|
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 [
|
|
13
|
+
const [previews, setPreviews] = useState<PreviewEntry[]>([]);
|
|
7
14
|
const [inputPort, setInputPort] = useState('');
|
|
8
|
-
const [
|
|
9
|
-
const [
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
setStatus('stopped');
|
|
68
|
+
if (activePreview === port) setActivePreview(null);
|
|
69
|
+
fetchPreviews();
|
|
66
70
|
};
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
const previewSrc =
|
|
70
|
-
?
|
|
71
|
-
:
|
|
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
|
-
{/*
|
|
76
|
-
<div className="px-4 py-2 border-b border-[var(--border)]
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
134
|
+
{starting ? 'Starting...' : '+ Add'}
|
|
95
135
|
</button>
|
|
96
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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">
|
|
141
|
-
<p className="text-xs">Enter
|
|
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
|
-
|
|
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
|
|
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"
|
package/instrumentation.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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();
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
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.
|
|
133
|
+
botState.pollActive = false;
|
|
134
|
+
if (botState.polling) schedulePoll(1000);
|
|
107
135
|
}
|
|
108
136
|
|
|
109
137
|
// ─── Message Handler ─────────────────────────────────────────
|
package/package.json
CHANGED