amalgm 0.1.44 → 0.1.47
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 +0 -1
- package/lib/cli.js +0 -2
- package/lib/runtime-manifest.js +1 -129
- package/lib/service.js +0 -1
- package/lib/supervisor.js +0 -3
- package/lib/tunnel-chat.js +12 -12
- package/lib/tunnel-events.js +12 -12
- package/package.json +2 -2
- package/runtime/lib/runtime-manifest.js +159 -0
- package/runtime/scripts/amalgm-mcp/agents/hooks.js +182 -0
- package/runtime/scripts/amalgm-mcp/agents/rest.js +6 -1
- package/runtime/scripts/amalgm-mcp/agents/store.js +61 -31
- package/runtime/scripts/amalgm-mcp/agents/talk.js +12 -22
- package/runtime/scripts/amalgm-mcp/agents/tools.js +8 -13
- package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +3 -2
- package/runtime/scripts/amalgm-mcp/config.js +3 -2
- package/runtime/scripts/amalgm-mcp/index.js +2 -2
- package/runtime/scripts/amalgm-mcp/lib/chat-runner.js +1 -1
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +12 -48
- package/runtime/scripts/chat-core/adapters/claude.js +3 -1
- package/runtime/scripts/chat-core/adapters/codex.js +173 -29
- package/runtime/scripts/chat-core/auth.js +1 -1
- package/runtime/scripts/chat-core/contract.js +2 -1
- package/runtime/scripts/chat-core/tooling/mcp-bundle.js +3 -3
- package/runtime/scripts/chat-core/tooling/native-config.js +133 -0
- package/runtime/scripts/chat-server/config.js +2 -1
- package/runtime/scripts/local-gateway.js +17 -17
- package/runtime/scripts/port-monitor.js +7 -8
- package/runtime/scripts/fs-watcher.js +0 -923
|
@@ -1,923 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Filesystem Watcher — computer-side WebSocket daemon for real-time FS awareness.
|
|
3
|
-
*
|
|
4
|
-
* Runs on the selected computer on port 8082. The browser can connect directly
|
|
5
|
-
* on this device, or through the preview/tunnel gateway for a remote computer.
|
|
6
|
-
*
|
|
7
|
-
* Features:
|
|
8
|
-
* - Real-time filesystem change notifications (inotify via fs.watch)
|
|
9
|
-
* - Request/response operations: list, read, stat, search
|
|
10
|
-
* - Heartbeat keep-alive (30s ping)
|
|
11
|
-
* - Multiple concurrent WebSocket clients
|
|
12
|
-
*
|
|
13
|
-
* Protocol (JSON over WebSocket):
|
|
14
|
-
*
|
|
15
|
-
* Client → Server:
|
|
16
|
-
* { type: "list", id: "req-1", path: "/workspace" }
|
|
17
|
-
* { type: "read", id: "req-2", path: "/workspace/file.txt" }
|
|
18
|
-
* { type: "stat", id: "req-3", path: "/workspace/file.txt" }
|
|
19
|
-
* { type: "search", id: "req-4", basePath: "/workspace", pattern: "src", maxDepth: 3 }
|
|
20
|
-
* { type: "pong" }
|
|
21
|
-
*
|
|
22
|
-
* Server → Client:
|
|
23
|
-
* { type: "list:result", id: "req-1", files: [...] }
|
|
24
|
-
* { type: "read:result", id: "req-2", content: "..." }
|
|
25
|
-
* { type: "stat:result", id: "req-3", stat: {...} }
|
|
26
|
-
* { type: "search:result", id: "req-4", matches: [...] }
|
|
27
|
-
* { type: "watch", event: "change|rename", filename: "file.ts", dir: "/workspace/src" }
|
|
28
|
-
* { type: "ping" }
|
|
29
|
-
* { type: "error", id: "req-1", message: "..." }
|
|
30
|
-
* { type: "connected" }
|
|
31
|
-
*
|
|
32
|
-
* Dependencies: ws (installed at /opt/ws-dep/node_modules/ws during snapshot build)
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
const fs = require('fs');
|
|
36
|
-
const path = require('path');
|
|
37
|
-
const http = require('http');
|
|
38
|
-
const { execSync } = require('child_process');
|
|
39
|
-
const {
|
|
40
|
-
applyRuntimeCors,
|
|
41
|
-
authorizeRuntimeHttp,
|
|
42
|
-
isAuthorizedRuntimeRequest,
|
|
43
|
-
runtimeAuthRequired,
|
|
44
|
-
} = require('./runtime-auth');
|
|
45
|
-
|
|
46
|
-
// Try to load ws from several possible locations
|
|
47
|
-
let WebSocketServer;
|
|
48
|
-
try {
|
|
49
|
-
({ WebSocketServer } = require('/opt/ws-dep/node_modules/ws'));
|
|
50
|
-
} catch {
|
|
51
|
-
try {
|
|
52
|
-
({ WebSocketServer } = require('ws'));
|
|
53
|
-
} catch {
|
|
54
|
-
console.error('[FsWatcher] Cannot find ws module. Install with: npm install ws --prefix /opt/ws-dep');
|
|
55
|
-
process.exit(1);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Configuration
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
const PORT = parseInt(process.env.FS_WATCHER_PORT || '8082', 10);
|
|
64
|
-
const BIND_HOST = process.env.AMALGM_BIND_HOST || '0.0.0.0';
|
|
65
|
-
const WATCH_ROOT = process.env.FS_WATCHER_ROOT || '/workspace';
|
|
66
|
-
const HEARTBEAT_INTERVAL_MS = 30000;
|
|
67
|
-
const HEARTBEAT_TIMEOUT_MS = 10000;
|
|
68
|
-
const MAX_FILE_READ_SIZE = 10 * 1024 * 1024; // 10MB max file read
|
|
69
|
-
const PORT_POLL_INTERVAL_MS = 2000;
|
|
70
|
-
const AGENT_PORT = parseInt(process.env.SANDBOX_AGENT_PORT || process.env.PORT || '8080', 10);
|
|
71
|
-
const PLATFORM_AGENT_PORT = parseInt(process.env.SANDBOX_AGENT_PLATFORM_PORT || '8085', 10);
|
|
72
|
-
const PORT_MONITOR_PORT = parseInt(process.env.PORT_MONITOR_PORT || '8081', 10);
|
|
73
|
-
const AMALGM_MCP_PORT = parseInt(process.env.AMALGM_MCP_PORT || '8083', 10);
|
|
74
|
-
const CHAT_SERVER_PORT = parseInt(process.env.CHAT_SERVER_PORT || '8084', 10);
|
|
75
|
-
const AMALGM_GATEWAY_PORT = parseInt(process.env.AMALGM_GATEWAY_PORT || '28781', 10);
|
|
76
|
-
const INTERNAL_SERVICE_PORTS = new Set([
|
|
77
|
-
AGENT_PORT,
|
|
78
|
-
PLATFORM_AGENT_PORT,
|
|
79
|
-
PORT_MONITOR_PORT,
|
|
80
|
-
PORT,
|
|
81
|
-
AMALGM_MCP_PORT,
|
|
82
|
-
CHAT_SERVER_PORT,
|
|
83
|
-
AMALGM_GATEWAY_PORT,
|
|
84
|
-
4096,
|
|
85
|
-
]);
|
|
86
|
-
const COMMON_DEV_PORTS = new Set([3000, 3001, 3002, 3003, 4000, 4200, 4321, 5000, 5001, 5173, 5174, 6006, 7000, 8000, 8001, 8888]);
|
|
87
|
-
const BLOCKED_PREVIEW_PROCESSES = new Set(['.opencode', 'controlce', 'figma_age', 'figma_agent', 'loom', 'opencode', 'rapportd']);
|
|
88
|
-
const DEV_PREVIEW_PROCESS_RE = /^(node|bun|deno|npm|pnpm|yarn|vite|next|astro|nuxt|svelte|remix|webpack|rspack|parcel|serve|http-server|tsx|python|python3|uvicorn|gunicorn|flask|django|ruby|rails|puma|php|java|go|air|cargo|trunk|dotnet|nginx)$/i;
|
|
89
|
-
const INTERNAL_COMMAND_RE = /(?:^|\s)(?:node(?:-[^\s]+)?\s+)?[^\s]*(?:\/|\\)runtime(?:\/|\\)scripts(?:\/|\\)(?:port-monitor|fs-watcher|chat-server|local-gateway|amalgm-mcp(?:\/|\\)index)\.js(?:\s|$)/i;
|
|
90
|
-
const BLOCKED_COMMAND_RE = /(?:agent-browser|chromium|chrome)(?:\s|$)/i;
|
|
91
|
-
|
|
92
|
-
function readProcessCommand(pid) {
|
|
93
|
-
if (!Number.isInteger(pid) || pid <= 0) return '';
|
|
94
|
-
try {
|
|
95
|
-
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
|
|
96
|
-
} catch {
|
|
97
|
-
return '';
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function normalizeProcessName(processName) {
|
|
102
|
-
const name = String(processName || '').trim().toLowerCase();
|
|
103
|
-
if (/^node(?:$|[-_])/.test(name)) return 'node';
|
|
104
|
-
return name;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function publicProcessName(info) {
|
|
108
|
-
if (!info || typeof info !== 'object') return info || null;
|
|
109
|
-
return info.processName || null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function isPreviewablePort(port, processInfo) {
|
|
113
|
-
if (!Number.isInteger(port) || port < 3000 || port > 65535) return false;
|
|
114
|
-
if (INTERNAL_SERVICE_PORTS.has(port)) return false;
|
|
115
|
-
if (port >= 9100 && port <= 9199) return false;
|
|
116
|
-
const command = typeof processInfo === 'object' && processInfo ? String(processInfo.command || '') : '';
|
|
117
|
-
if (INTERNAL_COMMAND_RE.test(command) || BLOCKED_COMMAND_RE.test(command)) return false;
|
|
118
|
-
const name = normalizeProcessName(typeof processInfo === 'object' && processInfo ? processInfo.processName : processInfo);
|
|
119
|
-
if (BLOCKED_PREVIEW_PROCESSES.has(name) || name.startsWith('figma')) return false;
|
|
120
|
-
if (!name) return COMMON_DEV_PORTS.has(port);
|
|
121
|
-
if (DEV_PREVIEW_PROCESS_RE.test(name)) return true;
|
|
122
|
-
return COMMON_DEV_PORTS.has(port) && name === 'unknown';
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const HOME_DIR = process.env.HOME || require('os').homedir();
|
|
126
|
-
const AMALGM_DIR = process.env.AMALGM_DIR || path.join(HOME_DIR, '.amalgm');
|
|
127
|
-
const AUTH_BACKUP_DIR = path.join(AMALGM_DIR, 'auth-configs');
|
|
128
|
-
const AUTH_BACKUP_ENABLED = process.env.AMALGM_AUTH_BACKUP_ENABLED !== 'false';
|
|
129
|
-
const CREATE_AUTH_WATCH_DIRS = process.env.AMALGM_CREATE_AUTH_WATCH_DIRS !== 'false';
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// Auth status monitoring — watch CLI credential files for real auth state
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
// Maps harnessId → array of paths that prove the user has authenticated.
|
|
136
|
-
// A harness is "authenticated" if ANY of its credential paths exist and are non-empty.
|
|
137
|
-
// Check both HOME_DIR and /root/ — container HOME varies.
|
|
138
|
-
const AUTH_HOME_DIRS = [
|
|
139
|
-
HOME_DIR,
|
|
140
|
-
...(process.platform === 'linux' ? ['/root'] : []),
|
|
141
|
-
].filter((v, i, a) => a.indexOf(v) === i);
|
|
142
|
-
|
|
143
|
-
const AUTH_CREDENTIAL_PATHS = {
|
|
144
|
-
claude_code: AUTH_HOME_DIRS.flatMap((h) => [
|
|
145
|
-
path.join(h, '.claude', '.credentials.json'),
|
|
146
|
-
path.join(h, '.claude', 'credentials.json'),
|
|
147
|
-
path.join(h, '.config', 'claude', 'credentials.json'),
|
|
148
|
-
]),
|
|
149
|
-
codex: AUTH_HOME_DIRS.flatMap((h) => [
|
|
150
|
-
path.join(h, '.codex', 'auth.json'),
|
|
151
|
-
path.join(h, '.codex', 'credentials.json'),
|
|
152
|
-
]),
|
|
153
|
-
opencode: AUTH_HOME_DIRS.flatMap((h) => [
|
|
154
|
-
path.join(h, '.local', 'share', 'opencode', 'auth.json'),
|
|
155
|
-
path.join(h, '.config', 'opencode', 'auth.json'),
|
|
156
|
-
]),
|
|
157
|
-
pi: AUTH_HOME_DIRS.flatMap((h) => [
|
|
158
|
-
path.join(h, '.pi', 'agent', 'auth.json'),
|
|
159
|
-
]),
|
|
160
|
-
amp: AUTH_HOME_DIRS.flatMap((h) => [
|
|
161
|
-
path.join(h, '.config', 'amp', 'settings.json'),
|
|
162
|
-
path.join(h, '.amp', 'config.json'),
|
|
163
|
-
path.join(h, '.amp', 'auth.json'),
|
|
164
|
-
]),
|
|
165
|
-
cursor: AUTH_HOME_DIRS.flatMap((h) => [
|
|
166
|
-
path.join(h, '.cursor', 'cli-config.json'),
|
|
167
|
-
path.join(h, '.cursor', 'credentials.json'),
|
|
168
|
-
path.join(h, '.config', 'cursor', 'credentials.json'),
|
|
169
|
-
path.join(h, '.local', 'share', 'cursor-agent', 'auth.json'),
|
|
170
|
-
]),
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
// Directories to watch for auth changes
|
|
174
|
-
const AUTH_WATCH_DIRS = AUTH_HOME_DIRS.flatMap((h) => [
|
|
175
|
-
path.join(h, '.claude'),
|
|
176
|
-
path.join(h, '.codex'),
|
|
177
|
-
path.join(h, '.config', 'claude'),
|
|
178
|
-
path.join(h, '.config', 'opencode'),
|
|
179
|
-
path.join(h, '.local', 'share', 'opencode'),
|
|
180
|
-
path.join(h, '.pi', 'agent'),
|
|
181
|
-
path.join(h, '.config', 'amp'),
|
|
182
|
-
path.join(h, '.amp'),
|
|
183
|
-
path.join(h, '.cursor'),
|
|
184
|
-
path.join(h, '.config', 'cursor'),
|
|
185
|
-
path.join(h, '.local', 'share', 'cursor-agent'),
|
|
186
|
-
]);
|
|
187
|
-
|
|
188
|
-
const AUTH_WATCH_FILES_BY_DIR = new Map();
|
|
189
|
-
for (const credentialPaths of Object.values(AUTH_CREDENTIAL_PATHS)) {
|
|
190
|
-
for (const credentialPath of credentialPaths) {
|
|
191
|
-
const dir = path.dirname(credentialPath);
|
|
192
|
-
const filename = path.basename(credentialPath);
|
|
193
|
-
const files = AUTH_WATCH_FILES_BY_DIR.get(dir) ?? new Set();
|
|
194
|
-
files.add(filename);
|
|
195
|
-
AUTH_WATCH_FILES_BY_DIR.set(dir, files);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function isAuthCredentialEvent(dir, filename) {
|
|
200
|
-
if (!filename) return true;
|
|
201
|
-
|
|
202
|
-
const files = AUTH_WATCH_FILES_BY_DIR.get(dir);
|
|
203
|
-
if (!files) return false;
|
|
204
|
-
|
|
205
|
-
return files.has(path.basename(String(filename)));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** @type {Map<string, fs.FSWatcher>} */
|
|
209
|
-
const authWatchers = new Map();
|
|
210
|
-
|
|
211
|
-
/** Last known auth status per harness (avoids duplicate broadcasts) */
|
|
212
|
-
const lastAuthStatus = new Map();
|
|
213
|
-
|
|
214
|
-
function checkCursorAuth() {
|
|
215
|
-
for (const h of AUTH_HOME_DIRS) {
|
|
216
|
-
const candidates = [
|
|
217
|
-
path.join(h, '.local', 'bin', 'cursor-agent'),
|
|
218
|
-
'/usr/local/bin/cursor-agent',
|
|
219
|
-
'cursor-agent',
|
|
220
|
-
];
|
|
221
|
-
|
|
222
|
-
for (const executable of candidates) {
|
|
223
|
-
try {
|
|
224
|
-
const raw = execSync(`${JSON.stringify(executable)} status`, {
|
|
225
|
-
encoding: 'utf8',
|
|
226
|
-
timeout: 3000,
|
|
227
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
228
|
-
});
|
|
229
|
-
if (/logged in|authenticated/i.test(raw) && !/not logged in|not authenticated/i.test(raw)) {
|
|
230
|
-
return true;
|
|
231
|
-
}
|
|
232
|
-
} catch { /* try next executable */ }
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return false;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function checkAuthForHarness(harnessId) {
|
|
239
|
-
if (harnessId === 'cursor' && checkCursorAuth()) return true;
|
|
240
|
-
|
|
241
|
-
const paths = AUTH_CREDENTIAL_PATHS[harnessId];
|
|
242
|
-
if (!paths) return false;
|
|
243
|
-
|
|
244
|
-
for (const p of paths) {
|
|
245
|
-
try {
|
|
246
|
-
const stat = fs.statSync(p);
|
|
247
|
-
if (!stat.isFile() || stat.size === 0) continue;
|
|
248
|
-
|
|
249
|
-
// Codex stores both proxy API-key auth and real user auth in auth.json.
|
|
250
|
-
// Only treat non-apikey auth as provider_auth-ready.
|
|
251
|
-
if (harnessId === 'codex' && p.endsWith('/auth.json')) {
|
|
252
|
-
try {
|
|
253
|
-
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
254
|
-
if (parsed?.auth_mode && parsed.auth_mode !== 'apikey') return true;
|
|
255
|
-
continue;
|
|
256
|
-
} catch {
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return true;
|
|
262
|
-
} catch { /* file doesn't exist */ }
|
|
263
|
-
}
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function getFullAuthStatus() {
|
|
268
|
-
const status = {};
|
|
269
|
-
for (const harnessId of Object.keys(AUTH_CREDENTIAL_PATHS)) {
|
|
270
|
-
status[harnessId] = checkAuthForHarness(harnessId);
|
|
271
|
-
}
|
|
272
|
-
return status;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function broadcastAuthStatus() {
|
|
276
|
-
const status = getFullAuthStatus();
|
|
277
|
-
|
|
278
|
-
// Only broadcast if something changed
|
|
279
|
-
const statusStr = JSON.stringify(status);
|
|
280
|
-
if (statusStr === lastAuthStatus.get('_all')) return;
|
|
281
|
-
lastAuthStatus.set('_all', statusStr);
|
|
282
|
-
|
|
283
|
-
console.log('[FsWatcher:Auth] Status changed:', status);
|
|
284
|
-
|
|
285
|
-
const msg = JSON.stringify({ type: 'auth:status', status, timestamp: Date.now() });
|
|
286
|
-
for (const ws of clients) {
|
|
287
|
-
try {
|
|
288
|
-
if (ws.readyState === 1) ws.send(msg);
|
|
289
|
-
} catch { /* ignore */ }
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Immediately back up auth configs when tokens change (don't wait for 5-min sync)
|
|
293
|
-
triggerAuthBackup();
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function copyAuthBackupFile(source) {
|
|
297
|
-
const relative = path.relative(HOME_DIR, source);
|
|
298
|
-
if (relative.startsWith('..') || path.isAbsolute(relative)) return false;
|
|
299
|
-
|
|
300
|
-
try {
|
|
301
|
-
const stat = fs.statSync(source);
|
|
302
|
-
if (!stat.isFile() || stat.size === 0) return false;
|
|
303
|
-
|
|
304
|
-
const backup = path.join(AUTH_BACKUP_DIR, relative);
|
|
305
|
-
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
|
306
|
-
fs.copyFileSync(source, backup);
|
|
307
|
-
return true;
|
|
308
|
-
} catch {
|
|
309
|
-
return false;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function triggerAuthBackup() {
|
|
314
|
-
if (!AUTH_BACKUP_ENABLED) return;
|
|
315
|
-
|
|
316
|
-
try {
|
|
317
|
-
fs.rmSync(AUTH_BACKUP_DIR, { recursive: true, force: true });
|
|
318
|
-
|
|
319
|
-
let copied = 0;
|
|
320
|
-
const seen = new Set();
|
|
321
|
-
for (const credentialPaths of Object.values(AUTH_CREDENTIAL_PATHS)) {
|
|
322
|
-
for (const source of credentialPaths) {
|
|
323
|
-
if (seen.has(source)) continue;
|
|
324
|
-
seen.add(source);
|
|
325
|
-
if (copyAuthBackupFile(source)) copied += 1;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
console.log(`[FsWatcher:Auth] Auth configs backed up to ${AUTH_BACKUP_DIR} (${copied} file${copied === 1 ? '' : 's'})`);
|
|
330
|
-
} catch (err) {
|
|
331
|
-
console.error('[FsWatcher:Auth] Backup failed:', err.message);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Debounce auth checks — credential writes can touch multiple files rapidly */
|
|
336
|
-
let authCheckTimer = null;
|
|
337
|
-
function scheduleAuthCheck() {
|
|
338
|
-
if (authCheckTimer) clearTimeout(authCheckTimer);
|
|
339
|
-
authCheckTimer = setTimeout(() => {
|
|
340
|
-
authCheckTimer = null;
|
|
341
|
-
broadcastAuthStatus();
|
|
342
|
-
}, 500);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function startAuthWatching() {
|
|
346
|
-
for (const dir of AUTH_WATCH_DIRS) {
|
|
347
|
-
if (authWatchers.has(dir)) continue;
|
|
348
|
-
|
|
349
|
-
if (CREATE_AUTH_WATCH_DIRS) {
|
|
350
|
-
// Container snapshots opt into this so auth directories can be watched
|
|
351
|
-
// before a CLI creates them. The npm runtime leaves user homes untouched.
|
|
352
|
-
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
353
|
-
} else if (!fs.existsSync(dir)) {
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
try {
|
|
358
|
-
const watcher = fs.watch(dir, { recursive: false }, (eventType, filename) => {
|
|
359
|
-
if (!isAuthCredentialEvent(dir, filename)) return;
|
|
360
|
-
console.log(`[FsWatcher:Auth] ${eventType} in ${dir}: ${filename}`);
|
|
361
|
-
scheduleAuthCheck();
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
watcher.on('error', (err) => {
|
|
365
|
-
console.error(`[FsWatcher:Auth] Watch error on ${dir}:`, err.message);
|
|
366
|
-
authWatchers.delete(dir);
|
|
367
|
-
// Retry after a delay
|
|
368
|
-
setTimeout(() => startAuthWatching(), 5000);
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
authWatchers.set(dir, watcher);
|
|
372
|
-
console.log(`[FsWatcher:Auth] Watching ${dir}`);
|
|
373
|
-
} catch (err) {
|
|
374
|
-
console.error(`[FsWatcher:Auth] Cannot watch ${dir}:`, err.message);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Initial status check
|
|
379
|
-
broadcastAuthStatus();
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Extensions that should always be read as UTF-8 text
|
|
383
|
-
const TEXT_EXTENSIONS = new Set([
|
|
384
|
-
'txt', 'md', 'json', 'js', 'ts', 'tsx', 'jsx', 'py', 'html', 'css',
|
|
385
|
-
'xml', 'yaml', 'yml', 'sh', 'bash', 'env', 'gitignore', 'dockerfile',
|
|
386
|
-
'svg', 'csv', 'sql', 'toml', 'rs', 'go', 'rb', 'java', 'kt', 'swift',
|
|
387
|
-
'c', 'cpp', 'cs', 'php', 'scss', 'log', 'ini', 'cfg', 'conf', 'tsv',
|
|
388
|
-
'makefile', 'cmake', 'gradle', 'properties', 'lock', 'editorconfig',
|
|
389
|
-
'dockerignore', 'zsh',
|
|
390
|
-
]);
|
|
391
|
-
|
|
392
|
-
// ---------------------------------------------------------------------------
|
|
393
|
-
// Active clients
|
|
394
|
-
// ---------------------------------------------------------------------------
|
|
395
|
-
|
|
396
|
-
/** @type {Set<import('ws').WebSocket>} */
|
|
397
|
-
const clients = new Set();
|
|
398
|
-
|
|
399
|
-
// ---------------------------------------------------------------------------
|
|
400
|
-
// Filesystem watcher
|
|
401
|
-
// ---------------------------------------------------------------------------
|
|
402
|
-
|
|
403
|
-
/** @type {Map<string, fs.FSWatcher>} */
|
|
404
|
-
const watchers = new Map();
|
|
405
|
-
|
|
406
|
-
function isSameOrChildPath(candidate, root) {
|
|
407
|
-
const relative = path.relative(root, candidate);
|
|
408
|
-
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function getWatchRoots() {
|
|
412
|
-
const roots = [];
|
|
413
|
-
|
|
414
|
-
for (const rawRoot of [WATCH_ROOT, AMALGM_DIR]) {
|
|
415
|
-
const root = path.resolve(rawRoot);
|
|
416
|
-
if (roots.some((existingRoot) => isSameOrChildPath(root, existingRoot))) continue;
|
|
417
|
-
roots.push(root);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
return roots;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function startWatching() {
|
|
424
|
-
try {
|
|
425
|
-
fs.mkdirSync(AMALGM_DIR, { recursive: true });
|
|
426
|
-
} catch { /* best effort */ }
|
|
427
|
-
|
|
428
|
-
for (const root of getWatchRoots()) {
|
|
429
|
-
watchDirectory(root);
|
|
430
|
-
console.log(`[FsWatcher] Watching ${root}`);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function watchDirectory(dirPath) {
|
|
435
|
-
if (watchers.has(dirPath)) return;
|
|
436
|
-
|
|
437
|
-
try {
|
|
438
|
-
// Use recursive watching if supported (Linux kernel 5.9+ with inotify)
|
|
439
|
-
const watcher = fs.watch(dirPath, { recursive: true }, (eventType, filename) => {
|
|
440
|
-
if (!filename) return;
|
|
441
|
-
|
|
442
|
-
// Detect git HEAD changes in split-gitdir layout (.gitdirs/<repo>/HEAD)
|
|
443
|
-
if (filename.match(/^\.gitdirs\/[^/]+\/HEAD$/)) {
|
|
444
|
-
const fullPath = path.join(dirPath, filename);
|
|
445
|
-
const repoName = filename.split('/')[1];
|
|
446
|
-
broadcastGitBranchChange(fullPath, `/workspace/${repoName}`);
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Skip hidden/temp files (allow .amalgm for internal platform data)
|
|
451
|
-
if (filename.startsWith('.') && filename !== '.env' && filename !== '.gitignore' && !filename.startsWith('.amalgm')) return;
|
|
452
|
-
if (filename.includes('node_modules')) return;
|
|
453
|
-
if (filename.includes('.git/')) return;
|
|
454
|
-
|
|
455
|
-
const fullPath = path.join(dirPath, filename);
|
|
456
|
-
broadcastFsEvent(eventType, filename, dirPath, fullPath);
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
watcher.on('error', (err) => {
|
|
460
|
-
console.error(`[FsWatcher] Watch error on ${dirPath}:`, err.message);
|
|
461
|
-
watchers.delete(dirPath);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
watchers.set(dirPath, watcher);
|
|
465
|
-
} catch (err) {
|
|
466
|
-
// Recursive watching not supported — fall back to non-recursive
|
|
467
|
-
try {
|
|
468
|
-
const watcher = fs.watch(dirPath, (eventType, filename) => {
|
|
469
|
-
if (!filename) return;
|
|
470
|
-
if ((filename.startsWith('.') && !filename.startsWith('.amalgm')) || filename === 'node_modules') return;
|
|
471
|
-
|
|
472
|
-
const fullPath = path.join(dirPath, filename);
|
|
473
|
-
broadcastFsEvent(eventType, filename, dirPath, fullPath);
|
|
474
|
-
|
|
475
|
-
// If a new directory was created, watch it too
|
|
476
|
-
try {
|
|
477
|
-
if (fs.statSync(fullPath).isDirectory()) {
|
|
478
|
-
watchDirectory(fullPath);
|
|
479
|
-
}
|
|
480
|
-
} catch { /* file may have been deleted */ }
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
watcher.on('error', () => {
|
|
484
|
-
watchers.delete(dirPath);
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
watchers.set(dirPath, watcher);
|
|
488
|
-
|
|
489
|
-
// Watch subdirectories
|
|
490
|
-
try {
|
|
491
|
-
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
492
|
-
for (const entry of entries) {
|
|
493
|
-
if (entry.isDirectory() && (!entry.name.startsWith('.') || entry.name.startsWith('.amalgm')) && entry.name !== 'node_modules') {
|
|
494
|
-
watchDirectory(path.join(dirPath, entry.name));
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
} catch { /* permission denied */ }
|
|
498
|
-
} catch (err2) {
|
|
499
|
-
console.error(`[FsWatcher] Cannot watch ${dirPath}:`, err2.message);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function broadcastGitBranchChange(headPath, projectPath) {
|
|
505
|
-
if (clients.size === 0) return;
|
|
506
|
-
|
|
507
|
-
try {
|
|
508
|
-
const content = fs.readFileSync(headPath, 'utf-8').trim();
|
|
509
|
-
// HEAD is either "ref: refs/heads/<branch>" or a raw commit hash
|
|
510
|
-
const refMatch = content.match(/^ref: refs\/heads\/(.+)$/);
|
|
511
|
-
const branch = refMatch ? refMatch[1] : content.slice(0, 7); // short hash if detached
|
|
512
|
-
|
|
513
|
-
const msg = JSON.stringify({
|
|
514
|
-
type: 'git:branch-change',
|
|
515
|
-
branch,
|
|
516
|
-
projectPath,
|
|
517
|
-
detached: !refMatch,
|
|
518
|
-
timestamp: Date.now(),
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
for (const ws of clients) {
|
|
522
|
-
try {
|
|
523
|
-
if (ws.readyState === 1) ws.send(msg);
|
|
524
|
-
} catch { /* ignore */ }
|
|
525
|
-
}
|
|
526
|
-
} catch { /* HEAD file may be mid-write */ }
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function broadcastFsEvent(eventType, filename, dir, fullPath) {
|
|
530
|
-
if (clients.size === 0) return;
|
|
531
|
-
|
|
532
|
-
// Determine if path still exists
|
|
533
|
-
let exists = true;
|
|
534
|
-
let isDir = false;
|
|
535
|
-
try {
|
|
536
|
-
const stat = fs.statSync(fullPath);
|
|
537
|
-
isDir = stat.isDirectory();
|
|
538
|
-
} catch {
|
|
539
|
-
exists = false;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const msg = JSON.stringify({
|
|
543
|
-
type: 'watch',
|
|
544
|
-
event: eventType, // 'change' or 'rename'
|
|
545
|
-
filename,
|
|
546
|
-
dir,
|
|
547
|
-
fullPath,
|
|
548
|
-
exists,
|
|
549
|
-
isDir,
|
|
550
|
-
timestamp: Date.now(),
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
for (const ws of clients) {
|
|
554
|
-
try {
|
|
555
|
-
if (ws.readyState === 1) { // OPEN
|
|
556
|
-
ws.send(msg);
|
|
557
|
-
}
|
|
558
|
-
} catch { /* ignore */ }
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// ---------------------------------------------------------------------------
|
|
563
|
-
// Request handlers
|
|
564
|
-
// ---------------------------------------------------------------------------
|
|
565
|
-
|
|
566
|
-
async function handleMessage(ws, raw) {
|
|
567
|
-
let msg;
|
|
568
|
-
try {
|
|
569
|
-
msg = JSON.parse(raw);
|
|
570
|
-
} catch {
|
|
571
|
-
sendError(ws, null, 'Invalid JSON');
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const { type, id } = msg;
|
|
576
|
-
|
|
577
|
-
switch (type) {
|
|
578
|
-
case 'pong':
|
|
579
|
-
ws._lastPong = Date.now();
|
|
580
|
-
return;
|
|
581
|
-
|
|
582
|
-
case 'list':
|
|
583
|
-
return handleList(ws, id, msg.path);
|
|
584
|
-
|
|
585
|
-
case 'read':
|
|
586
|
-
return handleRead(ws, id, msg.path);
|
|
587
|
-
|
|
588
|
-
case 'stat':
|
|
589
|
-
return handleStat(ws, id, msg.path);
|
|
590
|
-
|
|
591
|
-
case 'search':
|
|
592
|
-
return handleSearch(ws, id, msg.basePath, msg.pattern, msg.maxDepth);
|
|
593
|
-
|
|
594
|
-
case 'auth:check':
|
|
595
|
-
return handleAuthCheck(ws, id);
|
|
596
|
-
|
|
597
|
-
default:
|
|
598
|
-
sendError(ws, id, `Unknown message type: ${type}`);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function handleList(ws, id, dirPath) {
|
|
603
|
-
if (!dirPath) {
|
|
604
|
-
sendError(ws, id, 'path is required');
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
try {
|
|
609
|
-
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
610
|
-
const files = entries.map((entry) => {
|
|
611
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
612
|
-
let size = 0;
|
|
613
|
-
let modTime = null;
|
|
614
|
-
try {
|
|
615
|
-
const stat = fs.statSync(fullPath);
|
|
616
|
-
size = stat.size;
|
|
617
|
-
modTime = stat.mtime.toISOString();
|
|
618
|
-
} catch { /* permission denied */ }
|
|
619
|
-
|
|
620
|
-
return {
|
|
621
|
-
name: entry.name,
|
|
622
|
-
isDir: entry.isDirectory(),
|
|
623
|
-
size,
|
|
624
|
-
modTime,
|
|
625
|
-
};
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
sendJson(ws, { type: 'list:result', id, files });
|
|
629
|
-
} catch (err) {
|
|
630
|
-
sendError(ws, id, `Failed to list ${dirPath}: ${err.message}`);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
function handleRead(ws, id, filePath) {
|
|
635
|
-
if (!filePath) {
|
|
636
|
-
sendError(ws, id, 'path is required');
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
try {
|
|
641
|
-
const stat = fs.statSync(filePath);
|
|
642
|
-
if (stat.size > MAX_FILE_READ_SIZE) {
|
|
643
|
-
sendError(ws, id, `File too large (${stat.size} bytes, max ${MAX_FILE_READ_SIZE})`);
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
648
|
-
// Also treat extensionless dotfiles and known text files as text
|
|
649
|
-
const isText = TEXT_EXTENSIONS.has(ext) || ext === '' || stat.size === 0;
|
|
650
|
-
|
|
651
|
-
if (isText) {
|
|
652
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
653
|
-
sendJson(ws, { type: 'read:result', id, content, size: stat.size, encoding: 'utf-8' });
|
|
654
|
-
} else {
|
|
655
|
-
const buffer = fs.readFileSync(filePath);
|
|
656
|
-
const content = buffer.toString('base64');
|
|
657
|
-
sendJson(ws, { type: 'read:result', id, content, size: stat.size, encoding: 'base64' });
|
|
658
|
-
}
|
|
659
|
-
} catch (err) {
|
|
660
|
-
sendError(ws, id, `Failed to read ${filePath}: ${err.message}`);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function handleStat(ws, id, filePath) {
|
|
665
|
-
if (!filePath) {
|
|
666
|
-
sendError(ws, id, 'path is required');
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
try {
|
|
671
|
-
const stat = fs.statSync(filePath);
|
|
672
|
-
sendJson(ws, {
|
|
673
|
-
type: 'stat:result',
|
|
674
|
-
id,
|
|
675
|
-
stat: {
|
|
676
|
-
size: stat.size,
|
|
677
|
-
isDir: stat.isDirectory(),
|
|
678
|
-
isFile: stat.isFile(),
|
|
679
|
-
modTime: stat.mtime.toISOString(),
|
|
680
|
-
createTime: stat.birthtime.toISOString(),
|
|
681
|
-
mode: stat.mode,
|
|
682
|
-
},
|
|
683
|
-
});
|
|
684
|
-
} catch (err) {
|
|
685
|
-
sendError(ws, id, `Failed to stat ${filePath}: ${err.message}`);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function handleSearch(ws, id, basePath, pattern, maxDepth) {
|
|
690
|
-
if (!basePath || !pattern) {
|
|
691
|
-
sendError(ws, id, 'basePath and pattern are required');
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
const depth = Math.min(maxDepth || 3, 5);
|
|
696
|
-
|
|
697
|
-
try {
|
|
698
|
-
// Use find command for efficient directory search
|
|
699
|
-
const cmd = `find ${JSON.stringify(basePath)} -maxdepth ${depth} -type d -iname '*${pattern.replace(/'/g, "\\'")}*' 2>/dev/null | head -50`;
|
|
700
|
-
const output = execSync(cmd, { encoding: 'utf-8', timeout: 5000 });
|
|
701
|
-
const matches = output.trim().split('\n').filter(Boolean);
|
|
702
|
-
sendJson(ws, { type: 'search:result', id, matches });
|
|
703
|
-
} catch (err) {
|
|
704
|
-
// find may fail or timeout — return empty
|
|
705
|
-
sendJson(ws, { type: 'search:result', id, matches: [] });
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
function handleAuthCheck(ws, id) {
|
|
710
|
-
const status = getFullAuthStatus();
|
|
711
|
-
sendJson(ws, { type: 'auth:check:result', id, status, timestamp: Date.now() });
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// ---------------------------------------------------------------------------
|
|
715
|
-
// Helpers
|
|
716
|
-
// ---------------------------------------------------------------------------
|
|
717
|
-
|
|
718
|
-
function sendJson(ws, obj) {
|
|
719
|
-
try {
|
|
720
|
-
if (ws.readyState === 1) {
|
|
721
|
-
ws.send(JSON.stringify(obj));
|
|
722
|
-
}
|
|
723
|
-
} catch { /* ignore */ }
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
function sendError(ws, id, message) {
|
|
727
|
-
sendJson(ws, { type: 'error', id, message });
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// ---------------------------------------------------------------------------
|
|
731
|
-
// Port monitoring (integrated — same logic as port-monitor.js but over WS)
|
|
732
|
-
// ---------------------------------------------------------------------------
|
|
733
|
-
|
|
734
|
-
/** @type {Map<number, string|null>} port -> processName */
|
|
735
|
-
const knownPorts = new Map();
|
|
736
|
-
|
|
737
|
-
function broadcastPortEvent(event) {
|
|
738
|
-
if (clients.size === 0) return;
|
|
739
|
-
const msg = JSON.stringify(event);
|
|
740
|
-
for (const ws of clients) {
|
|
741
|
-
try {
|
|
742
|
-
if (ws.readyState === 1) ws.send(msg);
|
|
743
|
-
} catch { /* ignore */ }
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
function getListeningPorts() {
|
|
748
|
-
/** @type {Map<number, { processName: string|null, pid: number|null, command: string }>} */
|
|
749
|
-
const currentPorts = new Map();
|
|
750
|
-
|
|
751
|
-
if (process.platform === 'darwin') {
|
|
752
|
-
const output = execSync('lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null', { encoding: 'utf-8' });
|
|
753
|
-
const lines = output.split('\n').slice(1);
|
|
754
|
-
|
|
755
|
-
for (const line of lines) {
|
|
756
|
-
const trimmed = line.trim();
|
|
757
|
-
if (!trimmed) continue;
|
|
758
|
-
const match = trimmed.match(/^(\S+)\s+(\d+)\s+.+\sTCP\s+.+:(\d+)\s+\(LISTEN\)$/);
|
|
759
|
-
if (!match) continue;
|
|
760
|
-
currentPorts.set(parseInt(match[3], 10), {
|
|
761
|
-
processName: match[1] || null,
|
|
762
|
-
pid: parseInt(match[2], 10),
|
|
763
|
-
command: '',
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
return currentPorts;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
const output = execSync('ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null', { encoding: 'utf-8' });
|
|
771
|
-
const lines = output.split('\n');
|
|
772
|
-
|
|
773
|
-
for (const line of lines) {
|
|
774
|
-
const portMatch = line.match(/:(\d+)\s+/);
|
|
775
|
-
if (!portMatch) continue;
|
|
776
|
-
|
|
777
|
-
let processName = null;
|
|
778
|
-
const procMatch = line.match(/users:\(\("([^"]+)"/) || line.match(/\/([^\s/]+)\s*$/);
|
|
779
|
-
if (procMatch) processName = procMatch[1];
|
|
780
|
-
const pidMatch = line.match(/pid=(\d+)/);
|
|
781
|
-
const pid = pidMatch ? parseInt(pidMatch[1], 10) : null;
|
|
782
|
-
|
|
783
|
-
currentPorts.set(parseInt(portMatch[1], 10), {
|
|
784
|
-
processName,
|
|
785
|
-
pid,
|
|
786
|
-
command: pid ? readProcessCommand(pid) : '',
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
return currentPorts;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
function checkPorts() {
|
|
794
|
-
try {
|
|
795
|
-
const currentPorts = getListeningPorts();
|
|
796
|
-
|
|
797
|
-
for (const [port, processInfo] of currentPorts) {
|
|
798
|
-
if (!isPreviewablePort(port, processInfo)) {
|
|
799
|
-
currentPorts.delete(port);
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// Detect newly opened ports
|
|
804
|
-
for (const [port, processInfo] of currentPorts) {
|
|
805
|
-
if (!knownPorts.has(port)) {
|
|
806
|
-
knownPorts.set(port, processInfo);
|
|
807
|
-
const processName = publicProcessName(processInfo);
|
|
808
|
-
console.log(`[FsWatcher:Ports] Port opened: ${port} (${processName || 'unknown'})`);
|
|
809
|
-
broadcastPortEvent({ type: 'port', event: 'opened', port, processName, timestamp: Date.now() });
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Detect closed ports
|
|
814
|
-
for (const [port] of knownPorts) {
|
|
815
|
-
if (!currentPorts.has(port)) {
|
|
816
|
-
knownPorts.delete(port);
|
|
817
|
-
console.log(`[FsWatcher:Ports] Port closed: ${port}`);
|
|
818
|
-
broadcastPortEvent({ type: 'port', event: 'closed', port, timestamp: Date.now() });
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
} catch { /* ss may not be available */ }
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// Start port polling
|
|
825
|
-
setInterval(checkPorts, PORT_POLL_INTERVAL_MS);
|
|
826
|
-
setTimeout(checkPorts, 1000); // Initial check after 1s
|
|
827
|
-
|
|
828
|
-
// ---------------------------------------------------------------------------
|
|
829
|
-
// HTTP server + WebSocket
|
|
830
|
-
// ---------------------------------------------------------------------------
|
|
831
|
-
|
|
832
|
-
const httpServer = http.createServer((req, res) => {
|
|
833
|
-
applyRuntimeCors(req, res, { methods: 'GET, OPTIONS' });
|
|
834
|
-
|
|
835
|
-
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
836
|
-
|
|
837
|
-
if (url.pathname === '/healthz' || url.pathname === '/') {
|
|
838
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
839
|
-
res.end(JSON.stringify({
|
|
840
|
-
status: 'ok',
|
|
841
|
-
clients: clients.size,
|
|
842
|
-
watchRoot: WATCH_ROOT,
|
|
843
|
-
watchers: watchers.size,
|
|
844
|
-
openPorts: [...knownPorts.entries()].map(([port, processName]) => ({ port, processName })),
|
|
845
|
-
}));
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// HTTP endpoint for auth status — faster than waiting for WS connect
|
|
850
|
-
if (url.pathname === '/auth/status') {
|
|
851
|
-
if (!authorizeRuntimeHttp(req, res, { methods: 'GET, OPTIONS' })) return;
|
|
852
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
853
|
-
res.end(JSON.stringify({ status: getFullAuthStatus(), timestamp: Date.now() }));
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
res.writeHead(404);
|
|
858
|
-
res.end('Not found');
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
const wss = new WebSocketServer({
|
|
862
|
-
server: httpServer,
|
|
863
|
-
verifyClient(info, done) {
|
|
864
|
-
if (!runtimeAuthRequired() || isAuthorizedRuntimeRequest(info.req)) {
|
|
865
|
-
done(true);
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
done(false, 401, 'Unauthorized');
|
|
869
|
-
},
|
|
870
|
-
});
|
|
871
|
-
|
|
872
|
-
wss.on('connection', (ws, req) => {
|
|
873
|
-
console.log(`[FsWatcher] Client connected (total: ${clients.size + 1})`);
|
|
874
|
-
clients.add(ws);
|
|
875
|
-
ws._lastPong = Date.now();
|
|
876
|
-
|
|
877
|
-
// Send connected confirmation + current open ports + auth status
|
|
878
|
-
sendJson(ws, { type: 'connected', watchRoot: WATCH_ROOT });
|
|
879
|
-
if (knownPorts.size > 0) {
|
|
880
|
-
sendJson(ws, {
|
|
881
|
-
type: 'port_init',
|
|
882
|
-
ports: [...knownPorts.entries()].map(([port, processName]) => ({ port, processName })),
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
// Send current auth status immediately on connect
|
|
886
|
-
sendJson(ws, { type: 'auth:status', status: getFullAuthStatus(), timestamp: Date.now() });
|
|
887
|
-
|
|
888
|
-
// Heartbeat
|
|
889
|
-
const heartbeat = setInterval(() => {
|
|
890
|
-
if (Date.now() - ws._lastPong > HEARTBEAT_INTERVAL_MS + HEARTBEAT_TIMEOUT_MS) {
|
|
891
|
-
console.log('[FsWatcher] Client heartbeat timeout, disconnecting');
|
|
892
|
-
ws.terminate();
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
sendJson(ws, { type: 'ping' });
|
|
896
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
897
|
-
|
|
898
|
-
ws.on('message', (raw) => {
|
|
899
|
-
handleMessage(ws, raw.toString());
|
|
900
|
-
});
|
|
901
|
-
|
|
902
|
-
ws.on('close', () => {
|
|
903
|
-
console.log(`[FsWatcher] Client disconnected (remaining: ${clients.size - 1})`);
|
|
904
|
-
clients.delete(ws);
|
|
905
|
-
clearInterval(heartbeat);
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
ws.on('error', (err) => {
|
|
909
|
-
console.error('[FsWatcher] Client error:', err.message);
|
|
910
|
-
clients.delete(ws);
|
|
911
|
-
clearInterval(heartbeat);
|
|
912
|
-
});
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
// ---------------------------------------------------------------------------
|
|
916
|
-
// Start
|
|
917
|
-
// ---------------------------------------------------------------------------
|
|
918
|
-
|
|
919
|
-
httpServer.listen(PORT, BIND_HOST, () => {
|
|
920
|
-
console.log(`[FsWatcher] Listening on ${BIND_HOST}:${PORT} (ws + http)`);
|
|
921
|
-
startWatching();
|
|
922
|
-
startAuthWatching();
|
|
923
|
-
});
|