@aion0/forge 0.1.6 → 0.1.8
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/terminal-state/route.ts +15 -0
- package/bin/forge-server.mjs +20 -0
- package/components/WebTerminal.tsx +15 -28
- package/lib/terminal-standalone.ts +34 -6
- package/package.json +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { NextResponse } from 'next/server';
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = join(homedir(), '.forge', 'terminal-state.json');
|
|
7
|
+
|
|
8
|
+
export async function GET() {
|
|
9
|
+
try {
|
|
10
|
+
const data = JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
|
|
11
|
+
return NextResponse.json(data);
|
|
12
|
+
} catch {
|
|
13
|
+
return NextResponse.json(null);
|
|
14
|
+
}
|
|
15
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -24,6 +24,7 @@ const LOG_FILE = join(DATA_DIR, 'forge.log');
|
|
|
24
24
|
const isDev = process.argv.includes('--dev');
|
|
25
25
|
const isBackground = process.argv.includes('--background');
|
|
26
26
|
const isStop = process.argv.includes('--stop');
|
|
27
|
+
const isRebuild = process.argv.includes('--rebuild');
|
|
27
28
|
|
|
28
29
|
process.chdir(ROOT);
|
|
29
30
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
@@ -55,6 +56,25 @@ if (isStop) {
|
|
|
55
56
|
process.exit(0);
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// ── Rebuild ──
|
|
60
|
+
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
|
+
const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
|
|
64
|
+
const versionFile = join(ROOT, '.next', '.forge-version');
|
|
65
|
+
const lastBuiltVersion = existsSync(versionFile) ? readFileSync(versionFile, 'utf-8').trim() : '';
|
|
66
|
+
if (isRebuild || lastBuiltVersion !== pkgVersion) {
|
|
67
|
+
console.log(`[forge] Rebuilding (v${pkgVersion})...`);
|
|
68
|
+
execSync('rm -rf .next', { cwd: ROOT });
|
|
69
|
+
execSync('npx next build', { cwd: ROOT, stdio: 'inherit' });
|
|
70
|
+
writeFileSync(versionFile, pkgVersion);
|
|
71
|
+
if (isRebuild) {
|
|
72
|
+
console.log('[forge] Rebuild complete');
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
// ── Background ──
|
|
59
79
|
if (isBackground) {
|
|
60
80
|
// Build if needed
|
|
@@ -46,35 +46,19 @@ function getWsUrl() {
|
|
|
46
46
|
return `${wsProtocol}//${wsHost}:3001`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** Load shared terminal state
|
|
50
|
-
function loadSharedState(): Promise<{ tabs: TabState[]; activeTabId: number; sessionLabels: Record<string, string> } | null> {
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
clearTimeout(timeout);
|
|
58
|
-
try {
|
|
59
|
-
const msg = JSON.parse(e.data);
|
|
60
|
-
if (msg.type === 'terminal-state' && msg.data) {
|
|
61
|
-
const d = msg.data;
|
|
62
|
-
if (Array.isArray(d.tabs) && d.tabs.length > 0 && typeof d.activeTabId === 'number') {
|
|
63
|
-
resolve({ tabs: d.tabs, activeTabId: d.activeTabId, sessionLabels: d.sessionLabels || {} });
|
|
64
|
-
ws.close();
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
} catch {}
|
|
69
|
-
resolve(null);
|
|
70
|
-
ws.close();
|
|
71
|
-
};
|
|
72
|
-
ws.onerror = () => { clearTimeout(timeout); resolve(null); };
|
|
73
|
-
} catch {
|
|
74
|
-
clearTimeout(timeout);
|
|
75
|
-
resolve(null);
|
|
49
|
+
/** Load shared terminal state via API (always available, doesn't depend on terminal WebSocket server) */
|
|
50
|
+
async function loadSharedState(): Promise<{ tabs: TabState[]; activeTabId: number; sessionLabels: Record<string, string> } | null> {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch('/api/terminal-state');
|
|
53
|
+
if (!res.ok) return null;
|
|
54
|
+
const d = await res.json();
|
|
55
|
+
if (d && Array.isArray(d.tabs) && d.tabs.length > 0 && typeof d.activeTabId === 'number') {
|
|
56
|
+
return { tabs: d.tabs, activeTabId: d.activeTabId, sessionLabels: d.sessionLabels || {} };
|
|
76
57
|
}
|
|
77
|
-
|
|
58
|
+
return null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
78
62
|
}
|
|
79
63
|
|
|
80
64
|
/** Save shared terminal state to server (fire-and-forget) */
|
|
@@ -179,6 +163,7 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
|
|
|
179
163
|
});
|
|
180
164
|
const [activeTabId, setActiveTabId] = useState(() => tabs[0]?.id || 1);
|
|
181
165
|
const [hydrated, setHydrated] = useState(false);
|
|
166
|
+
const stateLoadedRef = useRef(false);
|
|
182
167
|
const [tmuxSessions, setTmuxSessions] = useState<TmuxSession[]>([]);
|
|
183
168
|
const [showSessionPicker, setShowSessionPicker] = useState(false);
|
|
184
169
|
const [editingTabId, setEditingTabId] = useState<number | null>(null);
|
|
@@ -196,6 +181,7 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
|
|
|
196
181
|
setTabs(saved.tabs);
|
|
197
182
|
setActiveTabId(saved.activeTabId);
|
|
198
183
|
sessionLabelsRef.current = saved.sessionLabels || {};
|
|
184
|
+
stateLoadedRef.current = true;
|
|
199
185
|
}
|
|
200
186
|
setHydrated(true);
|
|
201
187
|
});
|
|
@@ -345,6 +331,7 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
|
|
|
345
331
|
}, [activeTabId]);
|
|
346
332
|
|
|
347
333
|
const onSessionConnected = useCallback((paneId: number, sessionName: string) => {
|
|
334
|
+
stateLoadedRef.current = true; // Allow saving once a session is connected
|
|
348
335
|
setTabs(prev => prev.map(t => ({
|
|
349
336
|
...t,
|
|
350
337
|
tree: updateSessionName(t.tree, paneId, sessionName),
|
|
@@ -182,23 +182,51 @@ function trackDetach(ws: WebSocket, sessionName: string) {
|
|
|
182
182
|
|
|
183
183
|
// ─── Periodic orphan cleanup ─────────────────────────────────
|
|
184
184
|
|
|
185
|
-
/** Clean up detached tmux sessions that
|
|
185
|
+
/** Clean up detached tmux sessions that are not tracked in terminal-state.json */
|
|
186
186
|
function cleanupOrphanedSessions() {
|
|
187
|
-
const
|
|
187
|
+
const knownSessions = getKnownSessions();
|
|
188
188
|
const sessions = listTmuxSessions();
|
|
189
189
|
for (const s of sessions) {
|
|
190
190
|
if (s.attached) continue;
|
|
191
|
-
if (
|
|
191
|
+
if (knownSessions.has(s.name)) continue; // saved in terminal state — preserve
|
|
192
192
|
const clients = sessionClients.get(s.name)?.size ?? 0;
|
|
193
193
|
if (clients === 0) {
|
|
194
|
-
console.log(`[terminal] Cleanup: killing orphaned session "${s.name}" (no clients, not renamed)`);
|
|
195
194
|
killTmuxSession(s.name);
|
|
196
195
|
}
|
|
197
196
|
}
|
|
198
197
|
}
|
|
199
198
|
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
/** Get all session names referenced in terminal-state.json (tabs + labels) */
|
|
200
|
+
function getKnownSessions(): Set<string> {
|
|
201
|
+
try {
|
|
202
|
+
const state = loadTerminalState() as any;
|
|
203
|
+
if (!state) return new Set();
|
|
204
|
+
const known = new Set<string>();
|
|
205
|
+
// From sessionLabels
|
|
206
|
+
if (state.sessionLabels) {
|
|
207
|
+
for (const name of Object.keys(state.sessionLabels)) known.add(name);
|
|
208
|
+
}
|
|
209
|
+
// From tab trees
|
|
210
|
+
if (state.tabs) {
|
|
211
|
+
for (const tab of state.tabs) {
|
|
212
|
+
collectTreeSessions(tab.tree, known);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return known;
|
|
216
|
+
} catch {
|
|
217
|
+
return new Set();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function collectTreeSessions(node: any, set: Set<string>) {
|
|
222
|
+
if (!node) return;
|
|
223
|
+
if (node.type === 'terminal' && node.sessionName) set.add(node.sessionName);
|
|
224
|
+
if (node.first) collectTreeSessions(node.first, set);
|
|
225
|
+
if (node.second) collectTreeSessions(node.second, set);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Run cleanup every 60 seconds, with a 60s initial delay to let clients reconnect after restart
|
|
229
|
+
setTimeout(() => setInterval(cleanupOrphanedSessions, 60_000), 60_000);
|
|
202
230
|
|
|
203
231
|
// ─── WebSocket server ──────────────────────────────────────────
|
|
204
232
|
|