@aion0/forge 0.1.4 → 0.1.7
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/README.md +14 -1
- package/app/api/terminal-state/route.ts +15 -0
- package/bin/forge-server.mjs +14 -0
- package/components/WebTerminal.tsx +15 -28
- package/instrumentation.ts +18 -1
- package/lib/terminal-standalone.ts +36 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -166,7 +166,8 @@ All config lives in `~/.forge/`:
|
|
|
166
166
|
|
|
167
167
|
```
|
|
168
168
|
~/.forge/
|
|
169
|
-
|
|
169
|
+
.env.local # Environment variables (AUTH_SECRET, API keys, etc.)
|
|
170
|
+
settings.yaml # Main configuration
|
|
170
171
|
password.json # Daily auto-generated login password
|
|
171
172
|
data.db # SQLite database (tasks, sessions)
|
|
172
173
|
terminal-state.json # Terminal tab layout
|
|
@@ -174,6 +175,18 @@ All config lives in `~/.forge/`:
|
|
|
174
175
|
bin/ # Auto-downloaded binaries (cloudflared)
|
|
175
176
|
```
|
|
176
177
|
|
|
178
|
+
### .env.local (optional)
|
|
179
|
+
|
|
180
|
+
```env
|
|
181
|
+
# Fixed auth secret (optional — auto-generated if not set)
|
|
182
|
+
AUTH_SECRET=<random-string>
|
|
183
|
+
|
|
184
|
+
# Optional: AI provider API keys for multi-model chat
|
|
185
|
+
# ANTHROPIC_API_KEY=sk-ant-...
|
|
186
|
+
# OPENAI_API_KEY=sk-...
|
|
187
|
+
# GOOGLE_GENERATIVE_AI_API_KEY=AI...
|
|
188
|
+
```
|
|
189
|
+
|
|
177
190
|
### settings.yaml
|
|
178
191
|
|
|
179
192
|
```yaml
|
|
@@ -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
|
@@ -28,6 +28,20 @@ const isStop = process.argv.includes('--stop');
|
|
|
28
28
|
process.chdir(ROOT);
|
|
29
29
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
30
30
|
|
|
31
|
+
// ── Load ~/.forge/.env.local ──
|
|
32
|
+
const envFile = join(DATA_DIR, '.env.local');
|
|
33
|
+
if (existsSync(envFile)) {
|
|
34
|
+
for (const line of readFileSync(envFile, 'utf-8').split('\n')) {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
37
|
+
const eq = trimmed.indexOf('=');
|
|
38
|
+
if (eq === -1) continue;
|
|
39
|
+
const key = trimmed.slice(0, eq).trim();
|
|
40
|
+
const val = trimmed.slice(eq + 1).trim();
|
|
41
|
+
if (!process.env[key]) process.env[key] = val;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
// ── Stop ──
|
|
32
46
|
if (isStop) {
|
|
33
47
|
try {
|
|
@@ -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),
|
package/instrumentation.ts
CHANGED
|
@@ -5,10 +5,27 @@
|
|
|
5
5
|
export async function register() {
|
|
6
6
|
// Only run on server, not Edge
|
|
7
7
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
8
|
+
// Load ~/.forge/.env.local if it exists (works for both pnpm dev and forge-server)
|
|
9
|
+
const { existsSync, readFileSync } = await import('node:fs');
|
|
10
|
+
const { join } = await import('node:path');
|
|
11
|
+
const { homedir } = await import('node:os');
|
|
12
|
+
const envFile = join(homedir(), '.forge', '.env.local');
|
|
13
|
+
if (existsSync(envFile)) {
|
|
14
|
+
for (const line of readFileSync(envFile, 'utf-8').split('\n')) {
|
|
15
|
+
const trimmed = line.trim();
|
|
16
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
17
|
+
const eq = trimmed.indexOf('=');
|
|
18
|
+
if (eq === -1) continue;
|
|
19
|
+
const key = trimmed.slice(0, eq).trim();
|
|
20
|
+
const val = trimmed.slice(eq + 1).trim();
|
|
21
|
+
if (!process.env[key]) process.env[key] = val;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
8
25
|
const { getPassword } = await import('./lib/password');
|
|
9
26
|
const password = getPassword();
|
|
10
27
|
process.env.MW_PASSWORD = password;
|
|
11
28
|
console.log(`[init] Login password: ${password}`);
|
|
12
|
-
console.log('[init] Forgot password? Run:
|
|
29
|
+
console.log('[init] Forgot password? Run: forge password');
|
|
13
30
|
}
|
|
14
31
|
}
|
|
@@ -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
|
|
|
@@ -352,18 +380,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
352
380
|
if (sessionName) trackDetach(ws, sessionName);
|
|
353
381
|
createdAt.delete(ws);
|
|
354
382
|
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
setTimeout(() => {
|
|
358
|
-
const clients = sessionClients.get(disconnectedSession)?.size ?? 0;
|
|
359
|
-
if (clients === 0 && tmuxSessionExists(disconnectedSession)) {
|
|
360
|
-
const renamed = getRenamedSessions();
|
|
361
|
-
if (!renamed.has(disconnectedSession)) {
|
|
362
|
-
console.log(`[terminal] Auto-killing orphaned session "${disconnectedSession}" (no clients after 10s, not renamed)`);
|
|
363
|
-
killTmuxSession(disconnectedSession);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}, 10_000);
|
|
367
|
-
}
|
|
383
|
+
// Orphan cleanup is handled by the periodic cleanupOrphanedSessions() (every 30s)
|
|
384
|
+
// which checks sessionClients and getRenamedSessions() from terminal-state.json
|
|
368
385
|
});
|
|
369
386
|
});
|