jukto-cli 0.1.0
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 +109 -0
- package/dist/ai/codex.d.ts +149 -0
- package/dist/ai/codex.js +2122 -0
- package/dist/ai/index.d.ts +57 -0
- package/dist/ai/index.js +119 -0
- package/dist/ai/interface.d.ts +93 -0
- package/dist/ai/interface.js +3 -0
- package/dist/ai/opencode.d.ts +72 -0
- package/dist/ai/opencode.js +883 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3356 -0
- package/dist/transport/protocol.d.ts +77 -0
- package/dist/transport/protocol.js +79 -0
- package/dist/transport/v2.d.ts +47 -0
- package/dist/transport/v2.js +347 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import qrcode from "qrcode-terminal";
|
|
4
|
+
import shell from "shelljs";
|
|
5
|
+
import { createAiManager } from "./ai/index.js";
|
|
6
|
+
import { V2SessionTransport } from "./transport/v2.js";
|
|
7
|
+
import Ignore from "ignore";
|
|
8
|
+
const ignore = Ignore.default;
|
|
9
|
+
import * as fs from "fs/promises";
|
|
10
|
+
import * as fssync from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import * as os from "os";
|
|
13
|
+
import { randomBytes } from "crypto";
|
|
14
|
+
import { spawn, spawnSync, execSync } from "child_process";
|
|
15
|
+
import { createServer, createConnection } from "net";
|
|
16
|
+
import { createInterface } from "readline";
|
|
17
|
+
const DEFAULT_PROXY_URL = normalizeGatewayUrl(process.env.JUKTO_PROXY_URL || "https://gateway.jukto.dev");
|
|
18
|
+
const MANAGER_URL = normalizeGatewayUrl(process.env.JUKTO_MANAGER_URL || "https://manager.jukto.dev");
|
|
19
|
+
const CLI_ARGS = process.argv.slice(2);
|
|
20
|
+
function hasAnyFlag(args, ...flags) {
|
|
21
|
+
return flags.some((flag) => args.includes(flag));
|
|
22
|
+
}
|
|
23
|
+
const SHOW_HELP = hasAnyFlag(CLI_ARGS, "--help", "-h");
|
|
24
|
+
const DEBUG_MODE = hasAnyFlag(CLI_ARGS, "--debug", "-d");
|
|
25
|
+
if (DEBUG_MODE) {
|
|
26
|
+
process.env.JUKTO_DEBUG = "1";
|
|
27
|
+
process.env.JUKTO_DEBUG_AI = "1";
|
|
28
|
+
}
|
|
29
|
+
import { createRequire } from "module";
|
|
30
|
+
const __require = createRequire(import.meta.url);
|
|
31
|
+
const VERSION = __require("../package.json").version;
|
|
32
|
+
const VERBOSE_AI_LOGS = process.env.JUKTO_DEBUG_AI === "1";
|
|
33
|
+
const PTY_RELEASE_BASE_URL = "https://github.com/jukto-dev/jukto/releases/download/v0";
|
|
34
|
+
const AI_RUNTIME_INSTALL_CANDIDATES = {
|
|
35
|
+
opencode: ["opencode-ai", "@opencode-ai/cli", "opencode"],
|
|
36
|
+
codex: ["@openai/codex", "codex"],
|
|
37
|
+
};
|
|
38
|
+
const PTY_RELEASES = {
|
|
39
|
+
"linux:x64": {
|
|
40
|
+
fileName: "jukto-pty-linux-x8664-0",
|
|
41
|
+
url: `${PTY_RELEASE_BASE_URL}/jukto-pty-linux-x8664-0`,
|
|
42
|
+
},
|
|
43
|
+
"darwin:arm64": {
|
|
44
|
+
fileName: "jukto-pty-macos-arm64-0",
|
|
45
|
+
url: `${PTY_RELEASE_BASE_URL}/jukto-pty-macos-arm64-0`,
|
|
46
|
+
},
|
|
47
|
+
"win32:x64": {
|
|
48
|
+
fileName: "jukto-pty-windows-x8664-1.exe",
|
|
49
|
+
url: `${PTY_RELEASE_BASE_URL}/jukto-pty-windows-x8664-1.exe`,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
// Root directory - sandbox all file operations to this
|
|
53
|
+
const ROOT_DIR = (() => {
|
|
54
|
+
try {
|
|
55
|
+
return fssync.realpathSync(process.cwd());
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return process.cwd();
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
const CLI_CONFIG_PATH = (() => {
|
|
62
|
+
if (process.platform === "darwin") {
|
|
63
|
+
return path.join(os.homedir(), "Library", "Application Support", "jukto", "config.json");
|
|
64
|
+
}
|
|
65
|
+
if (process.platform === "win32") {
|
|
66
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
67
|
+
return path.join(appData, "jukto", "config.json");
|
|
68
|
+
}
|
|
69
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
70
|
+
return path.join(xdgConfig, "jukto", "config.json");
|
|
71
|
+
})();
|
|
72
|
+
// Terminal sessions (managed by Rust PTY binary)
|
|
73
|
+
const terminals = new Set();
|
|
74
|
+
// PTY binary process
|
|
75
|
+
let ptyProcess = null;
|
|
76
|
+
const ptyPendingSpawns = new Map();
|
|
77
|
+
function getDefaultTerminalShell() {
|
|
78
|
+
if (process.platform === "win32") {
|
|
79
|
+
return process.env.COMSPEC || "C:\\Windows\\System32\\cmd.exe";
|
|
80
|
+
}
|
|
81
|
+
return process.env.SHELL || "/bin/sh";
|
|
82
|
+
}
|
|
83
|
+
const processes = new Map();
|
|
84
|
+
const processOutputBuffers = new Map();
|
|
85
|
+
// CPU usage tracking
|
|
86
|
+
let lastCpuInfo = null;
|
|
87
|
+
// AI manager — runs OpenCode and Codex simultaneously, routes by backend
|
|
88
|
+
let aiManager = null;
|
|
89
|
+
let aiManagerInitPromise = null;
|
|
90
|
+
// Proxy tunnel management
|
|
91
|
+
let currentSessionCode = null;
|
|
92
|
+
let currentSessionPassword = null;
|
|
93
|
+
let currentPrimaryGateway = DEFAULT_PROXY_URL;
|
|
94
|
+
let activeGatewayUrl = DEFAULT_PROXY_URL;
|
|
95
|
+
let shuttingDown = false;
|
|
96
|
+
let activeV2Transport = null;
|
|
97
|
+
const trackedEditorFiles = new Map();
|
|
98
|
+
const trackedEditorDirectories = new Map();
|
|
99
|
+
const pendingTrackedFileChecks = new Set();
|
|
100
|
+
function logWithTimestamp(scope, message, fields) {
|
|
101
|
+
if (!DEBUG_MODE)
|
|
102
|
+
return;
|
|
103
|
+
const timestamp = new Date().toISOString();
|
|
104
|
+
const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
|
|
105
|
+
console.log(`[${timestamp}] [${scope}] ${message}${suffix}`);
|
|
106
|
+
}
|
|
107
|
+
function debugLog(message, ...args) {
|
|
108
|
+
if (!DEBUG_MODE)
|
|
109
|
+
return;
|
|
110
|
+
console.log(message, ...args);
|
|
111
|
+
}
|
|
112
|
+
function debugWarn(message, ...args) {
|
|
113
|
+
if (!DEBUG_MODE)
|
|
114
|
+
return;
|
|
115
|
+
console.warn(message, ...args);
|
|
116
|
+
}
|
|
117
|
+
function printHelp() {
|
|
118
|
+
console.log(`Jukto CLI v${VERSION}
|
|
119
|
+
|
|
120
|
+
Usage:
|
|
121
|
+
npx jukto-cli [options]
|
|
122
|
+
|
|
123
|
+
Options:
|
|
124
|
+
-h, --help Show help
|
|
125
|
+
-n, --new Create a new session code
|
|
126
|
+
-d, --debug Show verbose debug logs
|
|
127
|
+
--extra-ports Extra local ports to expose, comma-separated (e.g. 3000,8080)
|
|
128
|
+
`);
|
|
129
|
+
}
|
|
130
|
+
const activeTunnels = new Map();
|
|
131
|
+
const PORT_SYNC_INTERVAL_MS = 30_000;
|
|
132
|
+
const CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS = 2_500;
|
|
133
|
+
const PROXY_WS_CONNECT_TIMEOUT_MS = 12_000;
|
|
134
|
+
const TUNNEL_SETUP_BUDGET_MS = 18_000;
|
|
135
|
+
const PROXY_WS_CONNECT_RETRY_ATTEMPTS = 1;
|
|
136
|
+
const PROXY_WS_RETRY_JITTER_MIN_MS = 200;
|
|
137
|
+
const PROXY_WS_RETRY_JITTER_MAX_MS = 500;
|
|
138
|
+
const PROXY_TUNNEL_LINGER_MS = 1_200;
|
|
139
|
+
const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
|
|
140
|
+
let portSyncTimer = null;
|
|
141
|
+
let portScanInFlight = false;
|
|
142
|
+
let lastDiscoveredPorts = [];
|
|
143
|
+
function redactSensitive(input) {
|
|
144
|
+
const text = typeof input === "string" ? input : JSON.stringify(input);
|
|
145
|
+
return text
|
|
146
|
+
.replace(/([A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,})/g, "[redacted_jwt]")
|
|
147
|
+
.replace(/(password|token|authorization|resumeToken|x-manager-password)\s*[:=]\s*["']?[^"',\s}]+/gi, "$1=[redacted]")
|
|
148
|
+
.replace(/[A-Za-z0-9+/=_-]{40,}/g, "[redacted_secret]");
|
|
149
|
+
}
|
|
150
|
+
function parseProxyControlFrame(raw) {
|
|
151
|
+
const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf-8") : null;
|
|
152
|
+
if (!text)
|
|
153
|
+
return null;
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(text);
|
|
156
|
+
if (parsed?.v !== 1 || parsed?.t !== "proxy_ctrl")
|
|
157
|
+
return null;
|
|
158
|
+
if (parsed.action !== "fin" && parsed.action !== "rst")
|
|
159
|
+
return null;
|
|
160
|
+
return {
|
|
161
|
+
v: 1,
|
|
162
|
+
t: "proxy_ctrl",
|
|
163
|
+
action: parsed.action,
|
|
164
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function sendProxyControl(tunnel, action, reason) {
|
|
172
|
+
if (tunnel.proxyWs.readyState !== WebSocket.OPEN)
|
|
173
|
+
return;
|
|
174
|
+
const frame = { v: 1, t: "proxy_ctrl", action, reason };
|
|
175
|
+
tunnel.proxyWs.send(JSON.stringify(frame));
|
|
176
|
+
}
|
|
177
|
+
function maybeFinalizeTunnel(tunnelId) {
|
|
178
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
179
|
+
if (!tunnel || tunnel.closing)
|
|
180
|
+
return;
|
|
181
|
+
if (!tunnel.localEnded || !tunnel.remoteEnded)
|
|
182
|
+
return;
|
|
183
|
+
if (tunnel.finalizeTimer)
|
|
184
|
+
return;
|
|
185
|
+
tunnel.finalizeTimer = setTimeout(() => {
|
|
186
|
+
const current = activeTunnels.get(tunnelId);
|
|
187
|
+
if (!current || current.closing)
|
|
188
|
+
return;
|
|
189
|
+
current.closing = true;
|
|
190
|
+
activeTunnels.delete(tunnelId);
|
|
191
|
+
if (!current.tcpSocket.destroyed) {
|
|
192
|
+
current.tcpSocket.destroy();
|
|
193
|
+
}
|
|
194
|
+
if (current.proxyWs.readyState === WebSocket.OPEN || current.proxyWs.readyState === WebSocket.CONNECTING) {
|
|
195
|
+
current.proxyWs.close();
|
|
196
|
+
}
|
|
197
|
+
}, PROXY_TUNNEL_LINGER_MS);
|
|
198
|
+
}
|
|
199
|
+
function parseExtraPortsFromArgs(args) {
|
|
200
|
+
const values = [];
|
|
201
|
+
for (let i = 0; i < args.length; i++) {
|
|
202
|
+
const arg = args[i];
|
|
203
|
+
if (arg.startsWith("--extra-ports=")) {
|
|
204
|
+
values.push(arg.slice("--extra-ports=".length));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (arg === "--extra-ports" && i + 1 < args.length) {
|
|
208
|
+
values.push(args[i + 1]);
|
|
209
|
+
i++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const parsed = new Set();
|
|
213
|
+
for (const value of values) {
|
|
214
|
+
for (const piece of value.split(",")) {
|
|
215
|
+
const trimmed = piece.trim();
|
|
216
|
+
if (!trimmed)
|
|
217
|
+
continue;
|
|
218
|
+
const num = Number(trimmed);
|
|
219
|
+
if (!Number.isInteger(num) || num < 1 || num > 65535)
|
|
220
|
+
continue;
|
|
221
|
+
parsed.add(num);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return Array.from(parsed).sort((a, b) => a - b);
|
|
225
|
+
}
|
|
226
|
+
const EXTRA_PORTS = parseExtraPortsFromArgs(CLI_ARGS);
|
|
227
|
+
const FORCE_NEW_CODE = hasAnyFlag(CLI_ARGS, "--new", "-n");
|
|
228
|
+
const trackedProxyPorts = new Set(EXTRA_PORTS);
|
|
229
|
+
function samePortSet(a, b) {
|
|
230
|
+
if (a.length !== b.length)
|
|
231
|
+
return false;
|
|
232
|
+
for (let i = 0; i < a.length; i++) {
|
|
233
|
+
if (a[i] !== b[i])
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
// ============================================================================
|
|
239
|
+
// Path Safety
|
|
240
|
+
// ============================================================================
|
|
241
|
+
function resolveSafePath(requestedPath) {
|
|
242
|
+
// path.resolve handles ".." components, but on case-insensitive or symlinked
|
|
243
|
+
// filesystems a simple startsWith check can still be bypassed. We use
|
|
244
|
+
// realpathSync to canonicalise the path (resolves symlinks, normalises case on
|
|
245
|
+
// Windows) before comparing against ROOT_DIR, which is itself canonicalised at
|
|
246
|
+
// startup. If the path does not exist yet we fall back to the lexical resolve so
|
|
247
|
+
// that callers creating new files can still pass the check.
|
|
248
|
+
const lexical = path.resolve(ROOT_DIR, requestedPath);
|
|
249
|
+
let canonical;
|
|
250
|
+
try {
|
|
251
|
+
canonical = fssync.realpathSync(lexical);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Path doesn't exist yet — verify lexically. Still safe because path.resolve
|
|
255
|
+
// already eliminated all ".." traversals in the resolved string.
|
|
256
|
+
canonical = lexical;
|
|
257
|
+
}
|
|
258
|
+
// Ensure ROOT_DIR itself is canonical for a reliable prefix comparison.
|
|
259
|
+
const canonicalRoot = (() => {
|
|
260
|
+
try {
|
|
261
|
+
return fssync.realpathSync(ROOT_DIR);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return ROOT_DIR;
|
|
265
|
+
}
|
|
266
|
+
})();
|
|
267
|
+
if (!canonical.startsWith(canonicalRoot + path.sep) && canonical !== canonicalRoot) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
return canonical;
|
|
271
|
+
}
|
|
272
|
+
function assertSafePath(requestedPath) {
|
|
273
|
+
const safePath = resolveSafePath(requestedPath);
|
|
274
|
+
if (!safePath) {
|
|
275
|
+
const error = new Error("Access denied: path outside root directory");
|
|
276
|
+
error.code = "EACCES";
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
return safePath;
|
|
280
|
+
}
|
|
281
|
+
function generatePersistentSecret(length) {
|
|
282
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
283
|
+
const bytes = randomBytes(length);
|
|
284
|
+
let out = "";
|
|
285
|
+
for (let i = 0; i < length; i++) {
|
|
286
|
+
out += alphabet[bytes[i] % alphabet.length];
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
async function readCliConfig() {
|
|
291
|
+
try {
|
|
292
|
+
const raw = await fs.readFile(CLI_CONFIG_PATH, "utf-8");
|
|
293
|
+
const parsed = JSON.parse(raw);
|
|
294
|
+
return {
|
|
295
|
+
version: 1,
|
|
296
|
+
deviceId: typeof parsed.deviceId === "string" && parsed.deviceId ? parsed.deviceId : generatePersistentSecret(32),
|
|
297
|
+
sessions: Array.isArray(parsed.sessions)
|
|
298
|
+
? parsed.sessions.filter((entry) => (!!entry
|
|
299
|
+
&& typeof entry.rootDir === "string"
|
|
300
|
+
&& typeof entry.sessionPassword === "string"
|
|
301
|
+
&& typeof entry.savedAt === "number")).map((entry) => ({
|
|
302
|
+
rootDir: entry.rootDir,
|
|
303
|
+
sessionCode: typeof entry.sessionCode === "string" ? entry.sessionCode : null,
|
|
304
|
+
sessionPassword: entry.sessionPassword,
|
|
305
|
+
savedAt: entry.savedAt,
|
|
306
|
+
}))
|
|
307
|
+
: [],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return {
|
|
312
|
+
version: 1,
|
|
313
|
+
deviceId: generatePersistentSecret(32),
|
|
314
|
+
sessions: [],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function writeCliConfig(config) {
|
|
319
|
+
await fs.mkdir(path.dirname(CLI_CONFIG_PATH), { recursive: true });
|
|
320
|
+
await fs.writeFile(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
321
|
+
cliConfigPromise = Promise.resolve(config);
|
|
322
|
+
}
|
|
323
|
+
let cliConfigPromise = null;
|
|
324
|
+
async function getCliConfig() {
|
|
325
|
+
if (!cliConfigPromise) {
|
|
326
|
+
cliConfigPromise = readCliConfig();
|
|
327
|
+
}
|
|
328
|
+
return await cliConfigPromise;
|
|
329
|
+
}
|
|
330
|
+
function getSavedSessionForRoot(config, rootDir) {
|
|
331
|
+
const sessions = Array.isArray(config.sessions) ? config.sessions : [];
|
|
332
|
+
return sessions.find((entry) => entry.rootDir === rootDir) || null;
|
|
333
|
+
}
|
|
334
|
+
async function saveSessionForRoot(sessionCode, sessionPassword) {
|
|
335
|
+
const config = await getCliConfig();
|
|
336
|
+
const sessions = Array.isArray(config.sessions) ? [...config.sessions] : [];
|
|
337
|
+
const nextEntry = {
|
|
338
|
+
rootDir: ROOT_DIR,
|
|
339
|
+
sessionCode,
|
|
340
|
+
sessionPassword,
|
|
341
|
+
savedAt: Date.now(),
|
|
342
|
+
};
|
|
343
|
+
const deduped = sessions.filter((entry) => entry.rootDir !== ROOT_DIR);
|
|
344
|
+
deduped.unshift(nextEntry);
|
|
345
|
+
await writeCliConfig({
|
|
346
|
+
...config,
|
|
347
|
+
sessions: deduped.slice(0, 100),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async function clearSavedSessionForRoot() {
|
|
351
|
+
const config = await getCliConfig();
|
|
352
|
+
const sessions = Array.isArray(config.sessions) ? config.sessions : [];
|
|
353
|
+
await writeCliConfig({
|
|
354
|
+
...config,
|
|
355
|
+
sessions: sessions.filter((entry) => entry.rootDir !== ROOT_DIR),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
// ============================================================================
|
|
359
|
+
// File System Handlers
|
|
360
|
+
// ============================================================================
|
|
361
|
+
function isLikelyBinaryBuffer(buffer) {
|
|
362
|
+
if (buffer.length === 0)
|
|
363
|
+
return false;
|
|
364
|
+
if (buffer.includes(0x00))
|
|
365
|
+
return true;
|
|
366
|
+
let suspicious = 0;
|
|
367
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
368
|
+
const byte = buffer[i];
|
|
369
|
+
const isPrintableAscii = byte >= 0x20 && byte <= 0x7e;
|
|
370
|
+
const isCommonControl = byte === 0x09 || byte === 0x0a || byte === 0x0d;
|
|
371
|
+
if (!isPrintableAscii && !isCommonControl)
|
|
372
|
+
suspicious++;
|
|
373
|
+
}
|
|
374
|
+
return suspicious / buffer.length > 0.3;
|
|
375
|
+
}
|
|
376
|
+
async function handleFsLs(payload) {
|
|
377
|
+
const reqPath = payload.path || ".";
|
|
378
|
+
const safePath = assertSafePath(reqPath);
|
|
379
|
+
const entries = await fs.readdir(safePath, { withFileTypes: true });
|
|
380
|
+
const result = [];
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
const item = {
|
|
383
|
+
name: entry.name,
|
|
384
|
+
type: entry.isDirectory() ? "directory" : "file",
|
|
385
|
+
};
|
|
386
|
+
// Try to get size and mtime for files
|
|
387
|
+
if (entry.isFile()) {
|
|
388
|
+
try {
|
|
389
|
+
const stat = await fs.stat(path.join(safePath, entry.name));
|
|
390
|
+
item.size = stat.size;
|
|
391
|
+
item.mtime = stat.mtimeMs;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Ignore stat errors
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
result.push(item);
|
|
398
|
+
}
|
|
399
|
+
return { path: reqPath, entries: result };
|
|
400
|
+
}
|
|
401
|
+
async function handleFsSearchFiles(payload) {
|
|
402
|
+
const reqPath = payload.path || ".";
|
|
403
|
+
const query = typeof payload.query === "string" ? payload.query.trim().toLowerCase() : "";
|
|
404
|
+
const maxResults = Math.max(1, Math.min(payload.maxResults || 10, 10));
|
|
405
|
+
const safePath = assertSafePath(reqPath);
|
|
406
|
+
const rootIgnore = await loadGitignore(ROOT_DIR);
|
|
407
|
+
const matches = [];
|
|
408
|
+
async function searchDir(dirPath, relativePath, ig) {
|
|
409
|
+
if (matches.length >= maxResults)
|
|
410
|
+
return;
|
|
411
|
+
const localIgnore = ignore().add(ig);
|
|
412
|
+
try {
|
|
413
|
+
const localGitignorePath = path.join(dirPath, ".gitignore");
|
|
414
|
+
const content = await fs.readFile(localGitignorePath, "utf-8");
|
|
415
|
+
localIgnore.add(content);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// No local .gitignore
|
|
419
|
+
}
|
|
420
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
421
|
+
for (const entry of entries) {
|
|
422
|
+
if (matches.length >= maxResults)
|
|
423
|
+
break;
|
|
424
|
+
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
425
|
+
const checkPath = entry.isDirectory() ? `${relPath}/` : relPath;
|
|
426
|
+
if (localIgnore.ignores(checkPath))
|
|
427
|
+
continue;
|
|
428
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
429
|
+
if (entry.isDirectory()) {
|
|
430
|
+
await searchDir(fullPath, relPath, localIgnore);
|
|
431
|
+
}
|
|
432
|
+
else if (entry.isFile() && relPath.toLowerCase().includes(query)) {
|
|
433
|
+
matches.push({ path: relPath });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const stat = await fs.stat(safePath);
|
|
438
|
+
if (stat.isDirectory()) {
|
|
439
|
+
await searchDir(safePath, reqPath === "." ? "" : reqPath, rootIgnore);
|
|
440
|
+
}
|
|
441
|
+
else if (stat.isFile() && reqPath.toLowerCase().includes(query)) {
|
|
442
|
+
matches.push({ path: reqPath });
|
|
443
|
+
}
|
|
444
|
+
return { path: reqPath, query, maxResults, files: matches };
|
|
445
|
+
}
|
|
446
|
+
async function handleFsStat(payload) {
|
|
447
|
+
const reqPath = payload.path;
|
|
448
|
+
if (!reqPath)
|
|
449
|
+
throw Object.assign(new Error("path is required"), { code: "EINVAL" });
|
|
450
|
+
const safePath = assertSafePath(reqPath);
|
|
451
|
+
const stat = await fs.stat(safePath);
|
|
452
|
+
const result = {
|
|
453
|
+
path: reqPath,
|
|
454
|
+
type: stat.isDirectory() ? "directory" : "file",
|
|
455
|
+
size: stat.size,
|
|
456
|
+
mtime: stat.mtimeMs,
|
|
457
|
+
mode: stat.mode,
|
|
458
|
+
};
|
|
459
|
+
if (stat.isFile()) {
|
|
460
|
+
try {
|
|
461
|
+
const fd = await fs.open(safePath, "r");
|
|
462
|
+
try {
|
|
463
|
+
const sampleSize = Math.min(stat.size, 8192);
|
|
464
|
+
const sample = Buffer.alloc(sampleSize);
|
|
465
|
+
if (sampleSize > 0) {
|
|
466
|
+
await fd.read(sample, 0, sampleSize, 0);
|
|
467
|
+
}
|
|
468
|
+
result.isBinary = isLikelyBinaryBuffer(sample);
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
await fd.close();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
// Keep stat resilient even if sampling fails
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
async function handleFsRead(payload) {
|
|
481
|
+
const reqPath = payload.path;
|
|
482
|
+
if (!reqPath)
|
|
483
|
+
throw Object.assign(new Error("path is required"), { code: "EINVAL" });
|
|
484
|
+
const startedAt = Date.now();
|
|
485
|
+
logWithTimestamp("fs-read", "starting", { path: reqPath });
|
|
486
|
+
const safePath = assertSafePath(reqPath);
|
|
487
|
+
// Check if binary
|
|
488
|
+
const stat = await fs.stat(safePath);
|
|
489
|
+
const content = await fs.readFile(safePath);
|
|
490
|
+
// Detect if binary
|
|
491
|
+
const isBinary = isLikelyBinaryBuffer(content.subarray(0, 8192));
|
|
492
|
+
logWithTimestamp("fs-read", "disk read complete", {
|
|
493
|
+
path: reqPath,
|
|
494
|
+
size: stat.size,
|
|
495
|
+
bufferBytes: content.length,
|
|
496
|
+
isBinary,
|
|
497
|
+
durationMs: Date.now() - startedAt,
|
|
498
|
+
});
|
|
499
|
+
if (isBinary) {
|
|
500
|
+
return {
|
|
501
|
+
path: reqPath,
|
|
502
|
+
content: content.toString("base64"),
|
|
503
|
+
encoding: "base64",
|
|
504
|
+
size: stat.size,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
path: reqPath,
|
|
509
|
+
content: content.toString("utf-8"),
|
|
510
|
+
encoding: "utf8",
|
|
511
|
+
size: stat.size,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
async function handleFsWrite(payload) {
|
|
515
|
+
const reqPath = payload.path;
|
|
516
|
+
const content = payload.content;
|
|
517
|
+
const encoding = payload.encoding || "utf8";
|
|
518
|
+
const source = typeof payload.source === "string" ? payload.source : null;
|
|
519
|
+
if (!reqPath)
|
|
520
|
+
throw Object.assign(new Error("path is required"), { code: "EINVAL" });
|
|
521
|
+
if (typeof content !== "string")
|
|
522
|
+
throw Object.assign(new Error("content is required"), { code: "EINVAL" });
|
|
523
|
+
const safePath = assertSafePath(reqPath);
|
|
524
|
+
const parentDir = path.dirname(safePath);
|
|
525
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
526
|
+
if (encoding === "base64") {
|
|
527
|
+
await fs.writeFile(safePath, Buffer.from(content, "base64"));
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
await fs.writeFile(safePath, content, "utf-8");
|
|
531
|
+
}
|
|
532
|
+
await noteTrackedFileWrite(reqPath, source);
|
|
533
|
+
return { path: reqPath };
|
|
534
|
+
}
|
|
535
|
+
async function handleFsMkdir(payload) {
|
|
536
|
+
const reqPath = payload.path;
|
|
537
|
+
const recursive = payload.recursive !== false;
|
|
538
|
+
if (!reqPath)
|
|
539
|
+
throw Object.assign(new Error("path is required"), { code: "EINVAL" });
|
|
540
|
+
const safePath = assertSafePath(reqPath);
|
|
541
|
+
await fs.mkdir(safePath, { recursive });
|
|
542
|
+
return { path: reqPath };
|
|
543
|
+
}
|
|
544
|
+
async function handleFsRm(payload) {
|
|
545
|
+
const reqPath = payload.path;
|
|
546
|
+
const recursive = payload.recursive === true;
|
|
547
|
+
if (!reqPath)
|
|
548
|
+
throw Object.assign(new Error("path is required"), { code: "EINVAL" });
|
|
549
|
+
const safePath = assertSafePath(reqPath);
|
|
550
|
+
await fs.rm(safePath, { recursive, force: false });
|
|
551
|
+
deleteTrackedEditorFile(reqPath);
|
|
552
|
+
return { path: reqPath };
|
|
553
|
+
}
|
|
554
|
+
async function handleFsMv(payload) {
|
|
555
|
+
const from = payload.from;
|
|
556
|
+
const to = payload.to;
|
|
557
|
+
if (!from)
|
|
558
|
+
throw Object.assign(new Error("from is required"), { code: "EINVAL" });
|
|
559
|
+
if (!to)
|
|
560
|
+
throw Object.assign(new Error("to is required"), { code: "EINVAL" });
|
|
561
|
+
const safeFrom = assertSafePath(from);
|
|
562
|
+
const safeTo = assertSafePath(to);
|
|
563
|
+
await fs.rename(safeFrom, safeTo);
|
|
564
|
+
await renameTrackedEditorFile(from, to);
|
|
565
|
+
return { from, to };
|
|
566
|
+
}
|
|
567
|
+
// Load gitignore patterns
|
|
568
|
+
async function loadGitignore(dirPath) {
|
|
569
|
+
const ig = ignore();
|
|
570
|
+
ig.add(".git");
|
|
571
|
+
try {
|
|
572
|
+
const gitignorePath = path.join(dirPath, ".gitignore");
|
|
573
|
+
const content = await fs.readFile(gitignorePath, "utf-8");
|
|
574
|
+
ig.add(content);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// No .gitignore
|
|
578
|
+
}
|
|
579
|
+
return ig;
|
|
580
|
+
}
|
|
581
|
+
const KNOWN_BINARY_EXTENSIONS = new Set([
|
|
582
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".ico", ".icns", ".heic", ".heif", ".tiff", ".tif",
|
|
583
|
+
".psd", ".ai", ".eps",
|
|
584
|
+
".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac",
|
|
585
|
+
".mp4", ".mov", ".avi", ".mkv", ".webm", ".wmv", ".m4v",
|
|
586
|
+
".pdf", ".zip", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".tar",
|
|
587
|
+
".exe", ".dll", ".so", ".dylib", ".bin", ".class", ".o", ".obj", ".a", ".lib",
|
|
588
|
+
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
|
589
|
+
]);
|
|
590
|
+
function isLikelyBinaryContent(content) {
|
|
591
|
+
if (content.length === 0)
|
|
592
|
+
return false;
|
|
593
|
+
const sample = content.subarray(0, Math.min(content.length, 8192));
|
|
594
|
+
let suspicious = 0;
|
|
595
|
+
for (const byte of sample) {
|
|
596
|
+
if (byte === 0)
|
|
597
|
+
return true; // Null bytes strongly indicate binary data.
|
|
598
|
+
if (byte < 7 || (byte > 13 && byte < 32))
|
|
599
|
+
suspicious += 1;
|
|
600
|
+
}
|
|
601
|
+
return suspicious / sample.length > 0.3;
|
|
602
|
+
}
|
|
603
|
+
function shouldSkipAsBinary(filePath, content) {
|
|
604
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
605
|
+
if (KNOWN_BINARY_EXTENSIONS.has(ext))
|
|
606
|
+
return true;
|
|
607
|
+
return isLikelyBinaryContent(content);
|
|
608
|
+
}
|
|
609
|
+
async function handleFsGrep(payload) {
|
|
610
|
+
const reqPath = payload.path || ".";
|
|
611
|
+
const pattern = payload.pattern;
|
|
612
|
+
const caseSensitive = payload.caseSensitive !== false;
|
|
613
|
+
const maxResults = payload.maxResults || 100;
|
|
614
|
+
if (!pattern)
|
|
615
|
+
throw Object.assign(new Error("pattern is required"), { code: "EINVAL" });
|
|
616
|
+
const safePath = assertSafePath(reqPath);
|
|
617
|
+
const matches = [];
|
|
618
|
+
let regex;
|
|
619
|
+
try {
|
|
620
|
+
regex = new RegExp(pattern, caseSensitive ? "g" : "gi");
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
throw Object.assign(new Error("pattern must be a valid regular expression"), { code: "EINVAL" });
|
|
624
|
+
}
|
|
625
|
+
const rootIgnore = await loadGitignore(ROOT_DIR);
|
|
626
|
+
const previousSilent = shell.config.silent;
|
|
627
|
+
shell.config.silent = true;
|
|
628
|
+
try {
|
|
629
|
+
async function searchFile(filePath, relativePath) {
|
|
630
|
+
if (matches.length >= maxResults)
|
|
631
|
+
return;
|
|
632
|
+
try {
|
|
633
|
+
const rawContent = await fs.readFile(filePath);
|
|
634
|
+
if (shouldSkipAsBinary(relativePath, rawContent)) {
|
|
635
|
+
regex.lastIndex = 0;
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const content = rawContent.toString("utf-8");
|
|
639
|
+
const lines = content.split("\n");
|
|
640
|
+
for (let i = 0; i < lines.length && matches.length < maxResults; i++) {
|
|
641
|
+
if (regex.test(lines[i])) {
|
|
642
|
+
matches.push({
|
|
643
|
+
file: relativePath,
|
|
644
|
+
line: i + 1,
|
|
645
|
+
content: lines[i].substring(0, 500),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
regex.lastIndex = 0;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
regex.lastIndex = 0;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async function searchDir(dirPath, relativePath, ig) {
|
|
656
|
+
if (matches.length >= maxResults)
|
|
657
|
+
return;
|
|
658
|
+
const localIgnore = ignore().add(ig);
|
|
659
|
+
try {
|
|
660
|
+
const localGitignorePath = path.join(dirPath, ".gitignore");
|
|
661
|
+
const content = await fs.readFile(localGitignorePath, "utf-8");
|
|
662
|
+
localIgnore.add(content);
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
// No local .gitignore
|
|
666
|
+
}
|
|
667
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
668
|
+
for (const entry of entries) {
|
|
669
|
+
if (matches.length >= maxResults)
|
|
670
|
+
break;
|
|
671
|
+
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
672
|
+
const checkPath = entry.isDirectory() ? `${relPath}/` : relPath;
|
|
673
|
+
if (localIgnore.ignores(checkPath))
|
|
674
|
+
continue;
|
|
675
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
676
|
+
if (entry.isDirectory()) {
|
|
677
|
+
await searchDir(fullPath, relPath, localIgnore);
|
|
678
|
+
}
|
|
679
|
+
else if (entry.isFile()) {
|
|
680
|
+
await searchFile(fullPath, relPath);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const stat = await fs.stat(safePath);
|
|
685
|
+
if (stat.isDirectory()) {
|
|
686
|
+
await searchDir(safePath, reqPath === "." ? "" : reqPath, rootIgnore);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
await searchFile(safePath, reqPath);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
finally {
|
|
693
|
+
shell.config.silent = previousSilent;
|
|
694
|
+
}
|
|
695
|
+
return { matches };
|
|
696
|
+
}
|
|
697
|
+
async function handleFsCreate(payload) {
|
|
698
|
+
const reqPath = payload.path;
|
|
699
|
+
const type = payload.type;
|
|
700
|
+
if (!reqPath)
|
|
701
|
+
throw Object.assign(new Error("path is required"), { code: "EINVAL" });
|
|
702
|
+
if (!type || (type !== "file" && type !== "directory")) {
|
|
703
|
+
throw Object.assign(new Error("type must be 'file' or 'directory'"), { code: "EINVAL" });
|
|
704
|
+
}
|
|
705
|
+
const safePath = assertSafePath(reqPath);
|
|
706
|
+
if (type === "directory") {
|
|
707
|
+
await fs.mkdir(safePath, { recursive: true });
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
// Create parent directories if needed
|
|
711
|
+
const parentDir = path.dirname(safePath);
|
|
712
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
713
|
+
// Create empty file
|
|
714
|
+
await fs.writeFile(safePath, "");
|
|
715
|
+
}
|
|
716
|
+
return { path: reqPath };
|
|
717
|
+
}
|
|
718
|
+
// ============================================================================
|
|
719
|
+
// Git Handlers
|
|
720
|
+
// ============================================================================
|
|
721
|
+
async function runGit(args) {
|
|
722
|
+
return new Promise((resolve) => {
|
|
723
|
+
const proc = spawn("git", args, { cwd: ROOT_DIR });
|
|
724
|
+
let stdout = "";
|
|
725
|
+
let stderr = "";
|
|
726
|
+
proc.stdout.on("data", (data) => (stdout += data.toString()));
|
|
727
|
+
proc.stderr.on("data", (data) => (stderr += data.toString()));
|
|
728
|
+
proc.on("close", (code) => {
|
|
729
|
+
resolve({ stdout, stderr, code: code || 0 });
|
|
730
|
+
});
|
|
731
|
+
proc.on("error", (err) => {
|
|
732
|
+
resolve({ stdout: "", stderr: err.message, code: 1 });
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
async function handleGitStatus() {
|
|
737
|
+
// Get branch
|
|
738
|
+
const branchResult = await runGit(["branch", "--show-current"]);
|
|
739
|
+
const branch = branchResult.stdout.trim();
|
|
740
|
+
// Get status
|
|
741
|
+
const statusResult = await runGit(["status", "--porcelain", "-uall"]);
|
|
742
|
+
// Preserve leading whitespace in each porcelain line.
|
|
743
|
+
// Example: " M file" (unstaged-only) starts with a space that is semantically important.
|
|
744
|
+
const lines = statusResult.stdout.split(/\r?\n/).filter((line) => line.length > 0);
|
|
745
|
+
const staged = [];
|
|
746
|
+
const unstaged = [];
|
|
747
|
+
const untracked = [];
|
|
748
|
+
for (const line of lines) {
|
|
749
|
+
const index = line[0];
|
|
750
|
+
const worktree = line[1];
|
|
751
|
+
// Git porcelain format: XY path (where X=index status, Y=worktree status)
|
|
752
|
+
// For renamed files: XY old -> new
|
|
753
|
+
let filepath = line.substring(3).trim();
|
|
754
|
+
// Handle quoted paths (git quotes paths with special chars)
|
|
755
|
+
if (filepath.startsWith('"') && filepath.endsWith('"')) {
|
|
756
|
+
filepath = filepath.slice(1, -1);
|
|
757
|
+
}
|
|
758
|
+
// For renamed files, extract just the new name
|
|
759
|
+
if (filepath.includes(' -> ')) {
|
|
760
|
+
filepath = filepath.split(' -> ')[1];
|
|
761
|
+
}
|
|
762
|
+
if (index === "?" && worktree === "?") {
|
|
763
|
+
untracked.push(filepath);
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
if (index !== " " && index !== "?") {
|
|
767
|
+
staged.push({ path: filepath, status: index });
|
|
768
|
+
}
|
|
769
|
+
if (worktree !== " " && worktree !== "?") {
|
|
770
|
+
unstaged.push({ path: filepath, status: worktree });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
// Get ahead/behind
|
|
775
|
+
const aheadBehind = await runGit(["rev-list", "--left-right", "--count", "@{u}...HEAD"]);
|
|
776
|
+
let ahead = 0;
|
|
777
|
+
let behind = 0;
|
|
778
|
+
if (aheadBehind.code === 0) {
|
|
779
|
+
const parts = aheadBehind.stdout.trim().split(/\s+/);
|
|
780
|
+
behind = parseInt(parts[0]) || 0;
|
|
781
|
+
ahead = parseInt(parts[1]) || 0;
|
|
782
|
+
}
|
|
783
|
+
return { branch, ahead, behind, staged, unstaged, untracked };
|
|
784
|
+
}
|
|
785
|
+
async function handleGitStage(payload) {
|
|
786
|
+
const paths = payload.paths;
|
|
787
|
+
if (!paths || !paths.length)
|
|
788
|
+
throw Object.assign(new Error("paths is required"), { code: "EINVAL" });
|
|
789
|
+
const result = await runGit(["add", "--", ...paths]);
|
|
790
|
+
if (result.code !== 0) {
|
|
791
|
+
throw Object.assign(new Error(result.stderr || "git add failed"), { code: "EGIT" });
|
|
792
|
+
}
|
|
793
|
+
return {};
|
|
794
|
+
}
|
|
795
|
+
async function handleGitUnstage(payload) {
|
|
796
|
+
const paths = payload.paths;
|
|
797
|
+
if (!paths || !paths.length)
|
|
798
|
+
throw Object.assign(new Error("paths is required"), { code: "EINVAL" });
|
|
799
|
+
// Use git restore --staged (Git 2.23+) which is more reliable
|
|
800
|
+
// Falls back to git reset HEAD for older versions
|
|
801
|
+
let result = await runGit(["restore", "--staged", "--", ...paths]);
|
|
802
|
+
if (result.code !== 0) {
|
|
803
|
+
// Fallback to reset for older git versions
|
|
804
|
+
result = await runGit(["reset", "HEAD", "--", ...paths]);
|
|
805
|
+
if (result.code !== 0) {
|
|
806
|
+
throw Object.assign(new Error(result.stderr || "git unstage failed"), { code: "EGIT" });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return {};
|
|
810
|
+
}
|
|
811
|
+
async function handleGitCommit(payload) {
|
|
812
|
+
const message = payload.message;
|
|
813
|
+
if (!message)
|
|
814
|
+
throw Object.assign(new Error("message is required"), { code: "EINVAL" });
|
|
815
|
+
const result = await runGit(["commit", "-m", message]);
|
|
816
|
+
if (result.code !== 0) {
|
|
817
|
+
throw Object.assign(new Error(result.stderr || "git commit failed"), { code: "EGIT" });
|
|
818
|
+
}
|
|
819
|
+
// Get the commit hash
|
|
820
|
+
const hashResult = await runGit(["rev-parse", "HEAD"]);
|
|
821
|
+
const hash = hashResult.stdout.trim().substring(0, 7);
|
|
822
|
+
return { hash, message };
|
|
823
|
+
}
|
|
824
|
+
async function handleGitLog(payload) {
|
|
825
|
+
const limit = payload.limit || 20;
|
|
826
|
+
const result = await runGit([
|
|
827
|
+
"log",
|
|
828
|
+
`-${limit}`,
|
|
829
|
+
"--pretty=format:%H|%s|%an|%at",
|
|
830
|
+
]);
|
|
831
|
+
if (result.code !== 0) {
|
|
832
|
+
throw Object.assign(new Error(result.stderr || "git log failed"), { code: "EGIT" });
|
|
833
|
+
}
|
|
834
|
+
const commits = result.stdout
|
|
835
|
+
.trim()
|
|
836
|
+
.split("\n")
|
|
837
|
+
.filter(Boolean)
|
|
838
|
+
.map((line) => {
|
|
839
|
+
const [hash, message, author, timestamp] = line.split("|");
|
|
840
|
+
return {
|
|
841
|
+
hash: hash.substring(0, 7),
|
|
842
|
+
message,
|
|
843
|
+
author,
|
|
844
|
+
date: parseInt(timestamp) * 1000,
|
|
845
|
+
};
|
|
846
|
+
});
|
|
847
|
+
return { commits };
|
|
848
|
+
}
|
|
849
|
+
async function handleGitCommitDetails(payload) {
|
|
850
|
+
const hash = payload.hash?.trim();
|
|
851
|
+
if (!hash)
|
|
852
|
+
throw Object.assign(new Error("hash is required"), { code: "EINVAL" });
|
|
853
|
+
try {
|
|
854
|
+
const commitResult = await runGit(["show", "-s", "--format=%H%n%s%n%an%n%at", hash]);
|
|
855
|
+
if (commitResult.code !== 0 || !commitResult.stdout.trim()) {
|
|
856
|
+
throw Object.assign(new Error("Commit not found"), { code: "EGIT" });
|
|
857
|
+
}
|
|
858
|
+
const commitLines = commitResult.stdout.split(/\r?\n/);
|
|
859
|
+
const fullHash = commitLines[0]?.trim() || "";
|
|
860
|
+
const message = commitLines[1] ?? "";
|
|
861
|
+
const author = commitLines[2] ?? "";
|
|
862
|
+
const timestamp = Number.parseInt(commitLines[3] ?? "0", 10);
|
|
863
|
+
if (!fullHash) {
|
|
864
|
+
throw Object.assign(new Error("Commit not found"), { code: "EGIT" });
|
|
865
|
+
}
|
|
866
|
+
const filesResult = await runGit(["show", "--name-status", "--format=", hash]);
|
|
867
|
+
if (filesResult.code !== 0) {
|
|
868
|
+
throw Object.assign(new Error(filesResult.stderr || "git show failed"), { code: "EGIT" });
|
|
869
|
+
}
|
|
870
|
+
const filesRaw = filesResult.stdout;
|
|
871
|
+
const files = filesRaw
|
|
872
|
+
.split(/\r?\n/)
|
|
873
|
+
.map((line) => line.trim())
|
|
874
|
+
.filter(Boolean)
|
|
875
|
+
.map((line) => {
|
|
876
|
+
const parts = line.split("\t");
|
|
877
|
+
const status = parts[0] || "?";
|
|
878
|
+
// Handles regular + rename/copy name-status output.
|
|
879
|
+
const path = parts[2] || parts[1] || "";
|
|
880
|
+
return { status, path };
|
|
881
|
+
})
|
|
882
|
+
.filter((entry) => !!entry.path);
|
|
883
|
+
const diffResult = await runGit(["show", "--patch", "--format=", hash]);
|
|
884
|
+
if (diffResult.code !== 0) {
|
|
885
|
+
throw Object.assign(new Error(diffResult.stderr || "git show failed"), { code: "EGIT" });
|
|
886
|
+
}
|
|
887
|
+
const diff = diffResult.stdout;
|
|
888
|
+
const fileDiffs = {};
|
|
889
|
+
const fileChunks = diff.split(/^diff --git /m).filter(Boolean);
|
|
890
|
+
for (const chunk of fileChunks) {
|
|
891
|
+
const patch = `diff --git ${chunk}`;
|
|
892
|
+
const firstLine = chunk.split(/\r?\n/, 1)[0] || "";
|
|
893
|
+
const match = firstLine.match(/^a\/(.+?) b\/(.+)$/);
|
|
894
|
+
if (match?.[2]) {
|
|
895
|
+
fileDiffs[match[2]] = patch;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
commit: {
|
|
900
|
+
hash: fullHash.substring(0, 7),
|
|
901
|
+
fullHash,
|
|
902
|
+
message,
|
|
903
|
+
author,
|
|
904
|
+
date: timestamp * 1000,
|
|
905
|
+
},
|
|
906
|
+
files,
|
|
907
|
+
diff,
|
|
908
|
+
fileDiffs,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
const message = err instanceof Error ? err.message : "git show failed";
|
|
913
|
+
throw Object.assign(new Error(message), { code: "EGIT" });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async function handleGitDiff(payload) {
|
|
917
|
+
const filepath = payload.path;
|
|
918
|
+
const staged = payload.staged === true;
|
|
919
|
+
const args = ["diff"];
|
|
920
|
+
if (staged)
|
|
921
|
+
args.push("--staged");
|
|
922
|
+
if (filepath)
|
|
923
|
+
args.push(filepath);
|
|
924
|
+
const result = await runGit(args);
|
|
925
|
+
return { diff: result.stdout };
|
|
926
|
+
}
|
|
927
|
+
async function handleGitBranches() {
|
|
928
|
+
const result = await runGit(["branch", "-a"]);
|
|
929
|
+
if (result.code !== 0) {
|
|
930
|
+
throw Object.assign(new Error(result.stderr || "git branch failed"), { code: "EGIT" });
|
|
931
|
+
}
|
|
932
|
+
const lines = result.stdout.trim().split("\n");
|
|
933
|
+
let current = "";
|
|
934
|
+
const branches = [];
|
|
935
|
+
for (const line of lines) {
|
|
936
|
+
const trimmed = line.trim();
|
|
937
|
+
if (trimmed.startsWith("* ")) {
|
|
938
|
+
current = trimmed.substring(2);
|
|
939
|
+
branches.push(current);
|
|
940
|
+
}
|
|
941
|
+
else if (!trimmed.startsWith("remotes/")) {
|
|
942
|
+
branches.push(trimmed);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return { current, branches };
|
|
946
|
+
}
|
|
947
|
+
async function handleGitCheckout(payload) {
|
|
948
|
+
const branch = payload.branch;
|
|
949
|
+
const create = payload.create === true;
|
|
950
|
+
if (!branch)
|
|
951
|
+
throw Object.assign(new Error("branch is required"), { code: "EINVAL" });
|
|
952
|
+
const args = create ? ["checkout", "-b", branch] : ["checkout", branch];
|
|
953
|
+
const result = await runGit(args);
|
|
954
|
+
if (result.code !== 0) {
|
|
955
|
+
throw Object.assign(new Error(result.stderr || "git checkout failed"), { code: "EGIT" });
|
|
956
|
+
}
|
|
957
|
+
return { branch };
|
|
958
|
+
}
|
|
959
|
+
async function handleGitDeleteBranch(payload) {
|
|
960
|
+
const branch = payload.branch;
|
|
961
|
+
if (!branch)
|
|
962
|
+
throw Object.assign(new Error("branch is required"), { code: "EINVAL" });
|
|
963
|
+
const result = await runGit(["branch", "-d", branch]);
|
|
964
|
+
if (result.code !== 0) {
|
|
965
|
+
throw Object.assign(new Error(result.stderr || "git branch delete failed"), { code: "EGIT" });
|
|
966
|
+
}
|
|
967
|
+
return { branch };
|
|
968
|
+
}
|
|
969
|
+
async function handleGitPull() {
|
|
970
|
+
const result = await runGit(["pull"]);
|
|
971
|
+
if (result.code !== 0) {
|
|
972
|
+
throw Object.assign(new Error(result.stderr || "git pull failed"), { code: "EGIT" });
|
|
973
|
+
}
|
|
974
|
+
return { success: true, summary: result.stdout.trim() || result.stderr.trim() };
|
|
975
|
+
}
|
|
976
|
+
async function handleGitPush(payload) {
|
|
977
|
+
const setUpstream = payload.setUpstream === true;
|
|
978
|
+
const args = ["push"];
|
|
979
|
+
if (setUpstream) {
|
|
980
|
+
// Get current branch name
|
|
981
|
+
const branchResult = await runGit(["branch", "--show-current"]);
|
|
982
|
+
const branch = branchResult.stdout.trim();
|
|
983
|
+
args.push("-u", "origin", branch);
|
|
984
|
+
}
|
|
985
|
+
const result = await runGit(args);
|
|
986
|
+
if (result.code !== 0) {
|
|
987
|
+
throw Object.assign(new Error(result.stderr || "git push failed"), { code: "EGIT" });
|
|
988
|
+
}
|
|
989
|
+
return { success: true };
|
|
990
|
+
}
|
|
991
|
+
async function handleGitDiscard(payload) {
|
|
992
|
+
const paths = payload.paths;
|
|
993
|
+
const all = payload.all === true;
|
|
994
|
+
if (!paths && !all) {
|
|
995
|
+
throw Object.assign(new Error("paths or all is required"), { code: "EINVAL" });
|
|
996
|
+
}
|
|
997
|
+
if (all) {
|
|
998
|
+
// Discard all changes
|
|
999
|
+
const result = await runGit(["checkout", "--", "."]);
|
|
1000
|
+
if (result.code !== 0) {
|
|
1001
|
+
throw Object.assign(new Error(result.stderr || "git checkout failed"), { code: "EGIT" });
|
|
1002
|
+
}
|
|
1003
|
+
// Also clean untracked files
|
|
1004
|
+
await runGit(["clean", "-fd"]);
|
|
1005
|
+
}
|
|
1006
|
+
else if (paths && paths.length > 0) {
|
|
1007
|
+
for (const filePath of paths) {
|
|
1008
|
+
const tracked = await runGit(["ls-files", "--error-unmatch", "--", filePath]);
|
|
1009
|
+
if (tracked.code === 0) {
|
|
1010
|
+
const result = await runGit(["checkout", "--", filePath]);
|
|
1011
|
+
if (result.code !== 0) {
|
|
1012
|
+
throw Object.assign(new Error(result.stderr || `git checkout failed for ${filePath}`), { code: "EGIT" });
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
const cleanResult = await runGit(["clean", "-fd", "--", filePath]);
|
|
1017
|
+
if (cleanResult.code !== 0) {
|
|
1018
|
+
throw Object.assign(new Error(cleanResult.stderr || `git clean failed for ${filePath}`), { code: "EGIT" });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return {};
|
|
1024
|
+
}
|
|
1025
|
+
function emitAppEvent(msg) {
|
|
1026
|
+
if (activeV2Transport) {
|
|
1027
|
+
if (!activeV2Transport.isSecure()) {
|
|
1028
|
+
if (DEBUG_MODE) {
|
|
1029
|
+
console.error("[transport:v2] dropped event before secure session:", `${msg.ns}.${msg.action}`);
|
|
1030
|
+
}
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
void activeV2Transport.sendEvent(msg).catch((error) => {
|
|
1034
|
+
if (DEBUG_MODE)
|
|
1035
|
+
console.error("[transport:v2] failed to send event:", error instanceof Error ? error.message : String(error));
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
function emitEditorFileChanged(requestPath, mtimeMs, size) {
|
|
1040
|
+
logWithTimestamp("editor-watch", "emitting fileChanged", { path: requestPath, mtime: mtimeMs, size });
|
|
1041
|
+
emitAppEvent({
|
|
1042
|
+
v: 1,
|
|
1043
|
+
id: `editor-change-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1044
|
+
ns: "editor",
|
|
1045
|
+
action: "fileChanged",
|
|
1046
|
+
payload: { path: requestPath, mtime: mtimeMs, size },
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
function emitEditorFileDeleted(requestPath) {
|
|
1050
|
+
logWithTimestamp("editor-watch", "emitting fileDeleted", { path: requestPath });
|
|
1051
|
+
emitAppEvent({
|
|
1052
|
+
v: 1,
|
|
1053
|
+
id: `editor-delete-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1054
|
+
ns: "editor",
|
|
1055
|
+
action: "fileDeleted",
|
|
1056
|
+
payload: { path: requestPath },
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
function releaseTrackedEditorDirectory(dirPath) {
|
|
1060
|
+
const trackedDir = trackedEditorDirectories.get(dirPath);
|
|
1061
|
+
if (!trackedDir || trackedDir.filePaths.size > 0) {
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
trackedDir.watcher.close();
|
|
1065
|
+
trackedEditorDirectories.delete(dirPath);
|
|
1066
|
+
}
|
|
1067
|
+
function queueTrackedEditorFileCheck(safePath) {
|
|
1068
|
+
if (pendingTrackedFileChecks.has(safePath)) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
pendingTrackedFileChecks.add(safePath);
|
|
1072
|
+
void (async () => {
|
|
1073
|
+
try {
|
|
1074
|
+
const tracked = trackedEditorFiles.get(safePath);
|
|
1075
|
+
if (!tracked) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
logWithTimestamp("editor-watch", "checking tracked file", { path: tracked.requestPath });
|
|
1079
|
+
let stat;
|
|
1080
|
+
try {
|
|
1081
|
+
stat = await fs.stat(tracked.safePath);
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
const nodeError = error;
|
|
1085
|
+
if (nodeError?.code === "ENOENT") {
|
|
1086
|
+
trackedEditorFiles.delete(tracked.safePath);
|
|
1087
|
+
const trackedDir = trackedEditorDirectories.get(tracked.dirPath);
|
|
1088
|
+
if (trackedDir) {
|
|
1089
|
+
trackedDir.filePaths.delete(tracked.safePath);
|
|
1090
|
+
releaseTrackedEditorDirectory(tracked.dirPath);
|
|
1091
|
+
}
|
|
1092
|
+
emitEditorFileDeleted(tracked.requestPath);
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
throw error;
|
|
1096
|
+
}
|
|
1097
|
+
if (!stat.isFile()) {
|
|
1098
|
+
trackedEditorFiles.delete(tracked.safePath);
|
|
1099
|
+
const trackedDir = trackedEditorDirectories.get(tracked.dirPath);
|
|
1100
|
+
if (trackedDir) {
|
|
1101
|
+
trackedDir.filePaths.delete(tracked.safePath);
|
|
1102
|
+
releaseTrackedEditorDirectory(tracked.dirPath);
|
|
1103
|
+
}
|
|
1104
|
+
emitEditorFileDeleted(tracked.requestPath);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const changed = tracked.lastMtimeMs !== stat.mtimeMs || tracked.lastSize !== stat.size;
|
|
1108
|
+
logWithTimestamp("editor-watch", "stat compared", {
|
|
1109
|
+
path: tracked.requestPath,
|
|
1110
|
+
changed,
|
|
1111
|
+
prevMtime: tracked.lastMtimeMs,
|
|
1112
|
+
nextMtime: stat.mtimeMs,
|
|
1113
|
+
prevSize: tracked.lastSize,
|
|
1114
|
+
nextSize: stat.size,
|
|
1115
|
+
suppressWatcherUntil: tracked.suppressWatcherUntil,
|
|
1116
|
+
});
|
|
1117
|
+
tracked.lastMtimeMs = stat.mtimeMs;
|
|
1118
|
+
tracked.lastSize = stat.size;
|
|
1119
|
+
if (!changed) {
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (Date.now() <= tracked.suppressWatcherUntil) {
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
emitEditorFileChanged(tracked.requestPath, stat.mtimeMs, stat.size);
|
|
1126
|
+
}
|
|
1127
|
+
catch (error) {
|
|
1128
|
+
if (DEBUG_MODE) {
|
|
1129
|
+
console.error("[editor-watch] file check failed:", error instanceof Error ? error.message : String(error));
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
finally {
|
|
1133
|
+
pendingTrackedFileChecks.delete(safePath);
|
|
1134
|
+
}
|
|
1135
|
+
})();
|
|
1136
|
+
}
|
|
1137
|
+
function ensureTrackedEditorDirectory(dirPath) {
|
|
1138
|
+
const existing = trackedEditorDirectories.get(dirPath);
|
|
1139
|
+
if (existing) {
|
|
1140
|
+
return existing;
|
|
1141
|
+
}
|
|
1142
|
+
const watcher = fssync.watch(dirPath, (_eventType, fileName) => {
|
|
1143
|
+
const trackedDir = trackedEditorDirectories.get(dirPath);
|
|
1144
|
+
if (!trackedDir) {
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const changedName = fileName == null ? null : String(fileName);
|
|
1148
|
+
logWithTimestamp("editor-watch", "directory event", { dirPath, fileName: changedName });
|
|
1149
|
+
if (!changedName) {
|
|
1150
|
+
for (const safePath of trackedDir.filePaths) {
|
|
1151
|
+
queueTrackedEditorFileCheck(safePath);
|
|
1152
|
+
}
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
for (const safePath of trackedDir.filePaths) {
|
|
1156
|
+
const tracked = trackedEditorFiles.get(safePath);
|
|
1157
|
+
if (tracked && tracked.baseName === changedName) {
|
|
1158
|
+
queueTrackedEditorFileCheck(safePath);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
watcher.on("error", (error) => {
|
|
1163
|
+
if (DEBUG_MODE) {
|
|
1164
|
+
console.error("[editor-watch] directory watcher error:", dirPath, error instanceof Error ? error.message : String(error));
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
const trackedDir = {
|
|
1168
|
+
watcher,
|
|
1169
|
+
filePaths: new Set(),
|
|
1170
|
+
};
|
|
1171
|
+
trackedEditorDirectories.set(dirPath, trackedDir);
|
|
1172
|
+
return trackedDir;
|
|
1173
|
+
}
|
|
1174
|
+
async function trackEditorFile(requestPath) {
|
|
1175
|
+
const safePath = assertSafePath(requestPath);
|
|
1176
|
+
const stat = await fs.stat(safePath);
|
|
1177
|
+
if (!stat.isFile()) {
|
|
1178
|
+
throw Object.assign(new Error("Only files can be tracked by the editor"), { code: "EINVAL" });
|
|
1179
|
+
}
|
|
1180
|
+
const existing = trackedEditorFiles.get(safePath);
|
|
1181
|
+
if (existing) {
|
|
1182
|
+
existing.openCount += 1;
|
|
1183
|
+
existing.requestPath = requestPath;
|
|
1184
|
+
existing.lastMtimeMs = stat.mtimeMs;
|
|
1185
|
+
existing.lastSize = stat.size;
|
|
1186
|
+
logWithTimestamp("editor-watch", "increment tracked file", { path: requestPath, openCount: existing.openCount });
|
|
1187
|
+
return { path: requestPath, tracked: true };
|
|
1188
|
+
}
|
|
1189
|
+
const dirPath = path.dirname(safePath);
|
|
1190
|
+
const trackedDir = ensureTrackedEditorDirectory(dirPath);
|
|
1191
|
+
trackedDir.filePaths.add(safePath);
|
|
1192
|
+
trackedEditorFiles.set(safePath, {
|
|
1193
|
+
requestPath,
|
|
1194
|
+
safePath,
|
|
1195
|
+
dirPath,
|
|
1196
|
+
baseName: path.basename(safePath),
|
|
1197
|
+
openCount: 1,
|
|
1198
|
+
lastMtimeMs: stat.mtimeMs,
|
|
1199
|
+
lastSize: stat.size,
|
|
1200
|
+
suppressWatcherUntil: 0,
|
|
1201
|
+
});
|
|
1202
|
+
logWithTimestamp("editor-watch", "tracking file", { path: requestPath, dirPath, mtime: stat.mtimeMs, size: stat.size });
|
|
1203
|
+
return { path: requestPath, tracked: true };
|
|
1204
|
+
}
|
|
1205
|
+
function untrackEditorFile(requestPath) {
|
|
1206
|
+
const safePath = assertSafePath(requestPath);
|
|
1207
|
+
const tracked = trackedEditorFiles.get(safePath);
|
|
1208
|
+
if (!tracked) {
|
|
1209
|
+
return { path: requestPath, tracked: false };
|
|
1210
|
+
}
|
|
1211
|
+
tracked.openCount -= 1;
|
|
1212
|
+
logWithTimestamp("editor-watch", "decrement tracked file", { path: requestPath, openCount: tracked.openCount });
|
|
1213
|
+
if (tracked.openCount > 0) {
|
|
1214
|
+
return { path: requestPath, tracked: true };
|
|
1215
|
+
}
|
|
1216
|
+
trackedEditorFiles.delete(safePath);
|
|
1217
|
+
const trackedDir = trackedEditorDirectories.get(tracked.dirPath);
|
|
1218
|
+
if (trackedDir) {
|
|
1219
|
+
trackedDir.filePaths.delete(safePath);
|
|
1220
|
+
releaseTrackedEditorDirectory(tracked.dirPath);
|
|
1221
|
+
}
|
|
1222
|
+
return { path: requestPath, tracked: false };
|
|
1223
|
+
}
|
|
1224
|
+
async function renameTrackedEditorFile(fromPath, toPath) {
|
|
1225
|
+
const safeFrom = assertSafePath(fromPath);
|
|
1226
|
+
const safeTo = assertSafePath(toPath);
|
|
1227
|
+
const tracked = trackedEditorFiles.get(safeFrom);
|
|
1228
|
+
if (!tracked) {
|
|
1229
|
+
return { from: fromPath, to: toPath, tracked: false };
|
|
1230
|
+
}
|
|
1231
|
+
logWithTimestamp("editor-watch", "renaming tracked file", { from: fromPath, to: toPath });
|
|
1232
|
+
const fromDir = trackedEditorDirectories.get(tracked.dirPath);
|
|
1233
|
+
if (fromDir) {
|
|
1234
|
+
fromDir.filePaths.delete(tracked.safePath);
|
|
1235
|
+
releaseTrackedEditorDirectory(tracked.dirPath);
|
|
1236
|
+
}
|
|
1237
|
+
trackedEditorFiles.delete(tracked.safePath);
|
|
1238
|
+
const stat = await fs.stat(safeTo);
|
|
1239
|
+
const nextDirPath = path.dirname(safeTo);
|
|
1240
|
+
const nextDir = ensureTrackedEditorDirectory(nextDirPath);
|
|
1241
|
+
nextDir.filePaths.add(safeTo);
|
|
1242
|
+
tracked.requestPath = toPath;
|
|
1243
|
+
tracked.safePath = safeTo;
|
|
1244
|
+
tracked.dirPath = nextDirPath;
|
|
1245
|
+
tracked.baseName = path.basename(safeTo);
|
|
1246
|
+
tracked.lastMtimeMs = stat.mtimeMs;
|
|
1247
|
+
tracked.lastSize = stat.size;
|
|
1248
|
+
trackedEditorFiles.set(safeTo, tracked);
|
|
1249
|
+
return { from: fromPath, to: toPath, tracked: true };
|
|
1250
|
+
}
|
|
1251
|
+
function deleteTrackedEditorFile(requestPath) {
|
|
1252
|
+
const safePath = assertSafePath(requestPath);
|
|
1253
|
+
const tracked = trackedEditorFiles.get(safePath);
|
|
1254
|
+
if (!tracked) {
|
|
1255
|
+
return { path: requestPath, tracked: false };
|
|
1256
|
+
}
|
|
1257
|
+
logWithTimestamp("editor-watch", "deleting tracked file", { path: requestPath });
|
|
1258
|
+
trackedEditorFiles.delete(safePath);
|
|
1259
|
+
const trackedDir = trackedEditorDirectories.get(tracked.dirPath);
|
|
1260
|
+
if (trackedDir) {
|
|
1261
|
+
trackedDir.filePaths.delete(safePath);
|
|
1262
|
+
releaseTrackedEditorDirectory(tracked.dirPath);
|
|
1263
|
+
}
|
|
1264
|
+
return { path: requestPath, tracked: false };
|
|
1265
|
+
}
|
|
1266
|
+
async function noteTrackedFileWrite(requestPath, source) {
|
|
1267
|
+
const safePath = assertSafePath(requestPath);
|
|
1268
|
+
const tracked = trackedEditorFiles.get(safePath);
|
|
1269
|
+
if (!tracked) {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const stat = await fs.stat(safePath);
|
|
1273
|
+
tracked.lastMtimeMs = stat.mtimeMs;
|
|
1274
|
+
tracked.lastSize = stat.size;
|
|
1275
|
+
tracked.suppressWatcherUntil = Date.now() + 1500;
|
|
1276
|
+
logWithTimestamp("editor-watch", "tracked file write noted", {
|
|
1277
|
+
path: tracked.requestPath,
|
|
1278
|
+
source,
|
|
1279
|
+
mtime: stat.mtimeMs,
|
|
1280
|
+
size: stat.size,
|
|
1281
|
+
suppressWatcherUntil: tracked.suppressWatcherUntil,
|
|
1282
|
+
});
|
|
1283
|
+
if (source !== "editor") {
|
|
1284
|
+
emitEditorFileChanged(tracked.requestPath, stat.mtimeMs, stat.size);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
let ensurePtyBinaryPromise = null;
|
|
1288
|
+
function normalizeJsonWithTrailingCommas(text) {
|
|
1289
|
+
return text.replace(/,\s*([}\]])/g, "$1");
|
|
1290
|
+
}
|
|
1291
|
+
function getJuktoConfigDir() {
|
|
1292
|
+
const platform = os.platform();
|
|
1293
|
+
if (platform === "win32") {
|
|
1294
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
1295
|
+
return path.join(appData, "jukto");
|
|
1296
|
+
}
|
|
1297
|
+
if (platform === "darwin") {
|
|
1298
|
+
return path.join(os.homedir(), "Library", "Application Support", "jukto");
|
|
1299
|
+
}
|
|
1300
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
1301
|
+
return path.join(xdg, "jukto");
|
|
1302
|
+
}
|
|
1303
|
+
function getPtyReleaseTarget() {
|
|
1304
|
+
const release = PTY_RELEASES[`${os.platform()}:${os.arch()}`];
|
|
1305
|
+
if (!release)
|
|
1306
|
+
return null;
|
|
1307
|
+
return release;
|
|
1308
|
+
}
|
|
1309
|
+
function getPtyBinaryPath(fileName) {
|
|
1310
|
+
return path.join(getJuktoConfigDir(), "pty-releases", fileName);
|
|
1311
|
+
}
|
|
1312
|
+
async function downloadPtyBinary(url, destination) {
|
|
1313
|
+
const tempPath = `${destination}.download`;
|
|
1314
|
+
console.log("[pty] Downloading PTY [downloading...]");
|
|
1315
|
+
const response = await fetch(url);
|
|
1316
|
+
if (!response.ok) {
|
|
1317
|
+
throw new Error(`Failed to download PTY binary (${response.status})`);
|
|
1318
|
+
}
|
|
1319
|
+
if (!response.body) {
|
|
1320
|
+
throw new Error("PTY download response had no body");
|
|
1321
|
+
}
|
|
1322
|
+
const reader = response.body.getReader();
|
|
1323
|
+
const chunks = [];
|
|
1324
|
+
let totalBytes = 0;
|
|
1325
|
+
while (true) {
|
|
1326
|
+
const { done, value } = await reader.read();
|
|
1327
|
+
if (done)
|
|
1328
|
+
break;
|
|
1329
|
+
if (value) {
|
|
1330
|
+
chunks.push(value);
|
|
1331
|
+
totalBytes += value.byteLength;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
const binary = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
1335
|
+
await fs.writeFile(tempPath, binary);
|
|
1336
|
+
if (os.platform() !== "win32") {
|
|
1337
|
+
await fs.chmod(tempPath, 0o755);
|
|
1338
|
+
}
|
|
1339
|
+
await fs.rename(tempPath, destination);
|
|
1340
|
+
console.log(`[pty] Downloaded PTY (${Math.max(1, Math.round(totalBytes / 1024))} KB)`);
|
|
1341
|
+
}
|
|
1342
|
+
async function ensurePtyBinaryReady() {
|
|
1343
|
+
if (ensurePtyBinaryPromise)
|
|
1344
|
+
return ensurePtyBinaryPromise;
|
|
1345
|
+
ensurePtyBinaryPromise = (async () => {
|
|
1346
|
+
const release = getPtyReleaseTarget();
|
|
1347
|
+
if (!release)
|
|
1348
|
+
return null;
|
|
1349
|
+
const binPath = getPtyBinaryPath(release.fileName);
|
|
1350
|
+
await fs.mkdir(path.dirname(binPath), { recursive: true });
|
|
1351
|
+
try {
|
|
1352
|
+
await fs.access(binPath);
|
|
1353
|
+
return binPath;
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
console.log(`[pty] PTY missing. Installing ${release.fileName}...`);
|
|
1357
|
+
await downloadPtyBinary(release.url, binPath);
|
|
1358
|
+
return binPath;
|
|
1359
|
+
}
|
|
1360
|
+
})();
|
|
1361
|
+
try {
|
|
1362
|
+
return await ensurePtyBinaryPromise;
|
|
1363
|
+
}
|
|
1364
|
+
finally {
|
|
1365
|
+
ensurePtyBinaryPromise = null;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
async function ensurePtyProcess() {
|
|
1369
|
+
if (ptyProcess && ptyProcess.exitCode === null)
|
|
1370
|
+
return;
|
|
1371
|
+
const binPath = await ensurePtyBinaryReady();
|
|
1372
|
+
if (!binPath) {
|
|
1373
|
+
throw new Error(`PTY is not supported on ${os.platform()}/${os.arch()}`);
|
|
1374
|
+
}
|
|
1375
|
+
ptyProcess = spawn(binPath, [], {
|
|
1376
|
+
cwd: ROOT_DIR,
|
|
1377
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1378
|
+
});
|
|
1379
|
+
ptyProcess.stderr?.on("data", (data) => {
|
|
1380
|
+
console.error("[pty]", data.toString().trim());
|
|
1381
|
+
});
|
|
1382
|
+
ptyProcess.on("exit", (code) => {
|
|
1383
|
+
debugLog(`[pty] PTY process exited with code ${code}`);
|
|
1384
|
+
ptyProcess = null;
|
|
1385
|
+
// Reject all pending spawns
|
|
1386
|
+
for (const [id, pending] of ptyPendingSpawns) {
|
|
1387
|
+
pending.reject(new Error("PTY process exited"));
|
|
1388
|
+
}
|
|
1389
|
+
ptyPendingSpawns.clear();
|
|
1390
|
+
});
|
|
1391
|
+
// Parse stdout line by line for events from the Rust binary
|
|
1392
|
+
const rl = createInterface({ input: ptyProcess.stdout });
|
|
1393
|
+
rl.on("line", (line) => {
|
|
1394
|
+
let event;
|
|
1395
|
+
try {
|
|
1396
|
+
event = JSON.parse(line);
|
|
1397
|
+
}
|
|
1398
|
+
catch {
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
if (event.event === "spawned") {
|
|
1402
|
+
const pending = ptyPendingSpawns.get(event.id);
|
|
1403
|
+
if (pending) {
|
|
1404
|
+
pending.resolve();
|
|
1405
|
+
ptyPendingSpawns.delete(event.id);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
else if (event.event === "state") {
|
|
1409
|
+
// Forward screen state to app via data channel
|
|
1410
|
+
const msg = {
|
|
1411
|
+
v: 1,
|
|
1412
|
+
id: `evt-${Date.now()}`,
|
|
1413
|
+
ns: "terminal",
|
|
1414
|
+
action: "state",
|
|
1415
|
+
payload: {
|
|
1416
|
+
terminalId: event.id,
|
|
1417
|
+
cells: event.cells,
|
|
1418
|
+
cursorX: event.cursorX,
|
|
1419
|
+
cursorY: event.cursorY,
|
|
1420
|
+
cols: event.cols,
|
|
1421
|
+
rows: event.rows,
|
|
1422
|
+
cursorVisible: event.cursorVisible,
|
|
1423
|
+
cursorStyle: event.cursorStyle,
|
|
1424
|
+
appCursorKeys: event.appCursorKeys,
|
|
1425
|
+
bracketedPaste: event.bracketedPaste,
|
|
1426
|
+
mouseMode: event.mouseMode,
|
|
1427
|
+
mouseEncoding: event.mouseEncoding,
|
|
1428
|
+
reverseVideo: event.reverseVideo,
|
|
1429
|
+
title: event.title,
|
|
1430
|
+
scrollbackLength: event.scrollbackLength,
|
|
1431
|
+
},
|
|
1432
|
+
};
|
|
1433
|
+
emitAppEvent(msg);
|
|
1434
|
+
}
|
|
1435
|
+
else if (event.event === "exit") {
|
|
1436
|
+
terminals.delete(event.id);
|
|
1437
|
+
const msg = {
|
|
1438
|
+
v: 1,
|
|
1439
|
+
id: `evt-${Date.now()}`,
|
|
1440
|
+
ns: "terminal",
|
|
1441
|
+
action: "exit",
|
|
1442
|
+
payload: { terminalId: event.id, code: event.code },
|
|
1443
|
+
};
|
|
1444
|
+
emitAppEvent(msg);
|
|
1445
|
+
}
|
|
1446
|
+
else if (event.event === "error") {
|
|
1447
|
+
const pending = ptyPendingSpawns.get(event.id);
|
|
1448
|
+
if (pending) {
|
|
1449
|
+
pending.reject(new Error(String(event.message || "PTY error")));
|
|
1450
|
+
ptyPendingSpawns.delete(event.id);
|
|
1451
|
+
}
|
|
1452
|
+
console.error(`[pty] Error for ${event.id}: ${event.message}`);
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
function sendToPty(cmd) {
|
|
1457
|
+
if (!ptyProcess || !ptyProcess.stdin) {
|
|
1458
|
+
throw Object.assign(new Error("PTY process not running"), { code: "ENOPTY" });
|
|
1459
|
+
}
|
|
1460
|
+
ptyProcess.stdin.write(JSON.stringify(cmd) + "\n");
|
|
1461
|
+
}
|
|
1462
|
+
async function handleTerminalSpawn(payload) {
|
|
1463
|
+
await ensurePtyProcess();
|
|
1464
|
+
const shell = payload.shell || getDefaultTerminalShell();
|
|
1465
|
+
const cols = payload.cols || 80;
|
|
1466
|
+
const rows = payload.rows || 24;
|
|
1467
|
+
const terminalId = `term-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
1468
|
+
// Wait for the Rust binary to confirm spawn
|
|
1469
|
+
const spawnPromise = new Promise((resolve, reject) => {
|
|
1470
|
+
ptyPendingSpawns.set(terminalId, { resolve, reject });
|
|
1471
|
+
setTimeout(() => {
|
|
1472
|
+
if (ptyPendingSpawns.has(terminalId)) {
|
|
1473
|
+
ptyPendingSpawns.delete(terminalId);
|
|
1474
|
+
reject(new Error("Spawn timed out"));
|
|
1475
|
+
}
|
|
1476
|
+
}, 10000);
|
|
1477
|
+
});
|
|
1478
|
+
sendToPty({ cmd: "spawn", id: terminalId, shell, cols, rows });
|
|
1479
|
+
await spawnPromise;
|
|
1480
|
+
terminals.add(terminalId);
|
|
1481
|
+
return { terminalId };
|
|
1482
|
+
}
|
|
1483
|
+
function handleTerminalWrite(payload) {
|
|
1484
|
+
const terminalId = payload.terminalId;
|
|
1485
|
+
const data = payload.data;
|
|
1486
|
+
if (!terminalId)
|
|
1487
|
+
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
1488
|
+
if (typeof data !== "string")
|
|
1489
|
+
throw Object.assign(new Error("data is required"), { code: "EINVAL" });
|
|
1490
|
+
if (!terminals.has(terminalId))
|
|
1491
|
+
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
1492
|
+
sendToPty({ cmd: "write", id: terminalId, data });
|
|
1493
|
+
return {};
|
|
1494
|
+
}
|
|
1495
|
+
function handleTerminalResize(payload) {
|
|
1496
|
+
const terminalId = payload.terminalId;
|
|
1497
|
+
const cols = payload.cols;
|
|
1498
|
+
const rows = payload.rows;
|
|
1499
|
+
if (!terminalId)
|
|
1500
|
+
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
1501
|
+
if (!terminals.has(terminalId))
|
|
1502
|
+
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
1503
|
+
sendToPty({ cmd: "resize", id: terminalId, cols, rows });
|
|
1504
|
+
return {};
|
|
1505
|
+
}
|
|
1506
|
+
function handleTerminalKill(payload) {
|
|
1507
|
+
const terminalId = payload.terminalId;
|
|
1508
|
+
if (!terminalId)
|
|
1509
|
+
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
1510
|
+
if (!terminals.has(terminalId))
|
|
1511
|
+
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
1512
|
+
sendToPty({ cmd: "kill", id: terminalId });
|
|
1513
|
+
terminals.delete(terminalId);
|
|
1514
|
+
return {};
|
|
1515
|
+
}
|
|
1516
|
+
function handleTerminalScroll(payload) {
|
|
1517
|
+
const terminalId = payload.terminalId;
|
|
1518
|
+
const offset = payload.offset || 0;
|
|
1519
|
+
if (!terminalId)
|
|
1520
|
+
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
1521
|
+
if (!terminals.has(terminalId))
|
|
1522
|
+
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
1523
|
+
sendToPty({ cmd: "scroll", id: terminalId, offset });
|
|
1524
|
+
return {};
|
|
1525
|
+
}
|
|
1526
|
+
// ============================================================================
|
|
1527
|
+
// System Handlers
|
|
1528
|
+
// ============================================================================
|
|
1529
|
+
function handleSystemCapabilities() {
|
|
1530
|
+
return {
|
|
1531
|
+
version: VERSION,
|
|
1532
|
+
namespaces: ["fs", "git", "terminal", "processes", "ports", "monitor", "http", "ai", "proxy", "editor"],
|
|
1533
|
+
platform: os.platform(),
|
|
1534
|
+
rootDir: ROOT_DIR,
|
|
1535
|
+
hostname: os.hostname(),
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
function handleSystemPing() {
|
|
1539
|
+
return { pong: true, timestamp: Date.now() };
|
|
1540
|
+
}
|
|
1541
|
+
// ============================================================================
|
|
1542
|
+
// Processes Handlers
|
|
1543
|
+
// ============================================================================
|
|
1544
|
+
function handleProcessesList() {
|
|
1545
|
+
const result = [];
|
|
1546
|
+
for (const [pid, proc] of processes) {
|
|
1547
|
+
result.push({
|
|
1548
|
+
pid,
|
|
1549
|
+
command: `${proc.command} ${proc.args.join(" ")}`.trim(),
|
|
1550
|
+
startTime: proc.startTime,
|
|
1551
|
+
status: proc.proc.killed ? "stopped" : "running",
|
|
1552
|
+
channel: proc.channel,
|
|
1553
|
+
cwd: proc.cwd,
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
return { processes: result };
|
|
1557
|
+
}
|
|
1558
|
+
async function handleProcessesSpawn(payload) {
|
|
1559
|
+
const command = payload.command;
|
|
1560
|
+
const args = payload.args || [];
|
|
1561
|
+
const cwd = payload.cwd;
|
|
1562
|
+
const extraEnv = payload.env || {};
|
|
1563
|
+
if (!command)
|
|
1564
|
+
throw Object.assign(new Error("command is required"), { code: "EINVAL" });
|
|
1565
|
+
const workDir = cwd ? assertSafePath(cwd) : ROOT_DIR;
|
|
1566
|
+
const proc = spawn(command, args, {
|
|
1567
|
+
cwd: workDir,
|
|
1568
|
+
env: { ...process.env, ...extraEnv },
|
|
1569
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1570
|
+
shell: false,
|
|
1571
|
+
});
|
|
1572
|
+
const pid = await new Promise((resolve, reject) => {
|
|
1573
|
+
let settled = false;
|
|
1574
|
+
const handleSpawn = () => {
|
|
1575
|
+
if (settled)
|
|
1576
|
+
return;
|
|
1577
|
+
settled = true;
|
|
1578
|
+
proc.removeListener("error", handleError);
|
|
1579
|
+
if (!proc.pid) {
|
|
1580
|
+
reject(Object.assign(new Error(`Failed to spawn "${command}"`), { code: "ERROR" }));
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
resolve(proc.pid);
|
|
1584
|
+
};
|
|
1585
|
+
const handleError = (err) => {
|
|
1586
|
+
if (settled)
|
|
1587
|
+
return;
|
|
1588
|
+
settled = true;
|
|
1589
|
+
proc.removeListener("spawn", handleSpawn);
|
|
1590
|
+
reject(Object.assign(new Error(err.message || `Failed to spawn "${command}"`), {
|
|
1591
|
+
code: err.code || "ERROR",
|
|
1592
|
+
}));
|
|
1593
|
+
};
|
|
1594
|
+
proc.once("spawn", handleSpawn);
|
|
1595
|
+
proc.once("error", handleError);
|
|
1596
|
+
});
|
|
1597
|
+
const channel = `proc-${pid}`;
|
|
1598
|
+
const managedProc = {
|
|
1599
|
+
pid,
|
|
1600
|
+
proc,
|
|
1601
|
+
command,
|
|
1602
|
+
args,
|
|
1603
|
+
cwd: workDir,
|
|
1604
|
+
startTime: Date.now(),
|
|
1605
|
+
output: [],
|
|
1606
|
+
channel,
|
|
1607
|
+
};
|
|
1608
|
+
processes.set(pid, managedProc);
|
|
1609
|
+
processOutputBuffers.set(channel, "");
|
|
1610
|
+
// Stream output
|
|
1611
|
+
const sendOutput = (stream) => (data) => {
|
|
1612
|
+
const text = data.toString();
|
|
1613
|
+
managedProc.output.push(text);
|
|
1614
|
+
processOutputBuffers.set(channel, (processOutputBuffers.get(channel) || "") + text);
|
|
1615
|
+
const msg = {
|
|
1616
|
+
v: 1,
|
|
1617
|
+
id: `evt-${Date.now()}`,
|
|
1618
|
+
ns: "processes",
|
|
1619
|
+
action: "output",
|
|
1620
|
+
payload: { pid, channel, stream, data: text },
|
|
1621
|
+
};
|
|
1622
|
+
emitAppEvent(msg);
|
|
1623
|
+
};
|
|
1624
|
+
proc.stdout?.on("data", sendOutput("stdout"));
|
|
1625
|
+
proc.stderr?.on("data", sendOutput("stderr"));
|
|
1626
|
+
proc.on("error", (err) => {
|
|
1627
|
+
const message = err.message || `Process "${command}" failed`;
|
|
1628
|
+
processOutputBuffers.set(channel, (processOutputBuffers.get(channel) || "") + `${message}\n`);
|
|
1629
|
+
emitAppEvent({
|
|
1630
|
+
v: 1,
|
|
1631
|
+
id: `evt-${Date.now()}`,
|
|
1632
|
+
ns: "processes",
|
|
1633
|
+
action: "output",
|
|
1634
|
+
payload: { pid, channel, stream: "stderr", data: `${message}\n` },
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
proc.on("close", (code, signal) => {
|
|
1638
|
+
const msg = {
|
|
1639
|
+
v: 1,
|
|
1640
|
+
id: `evt-${Date.now()}`,
|
|
1641
|
+
ns: "processes",
|
|
1642
|
+
action: "exit",
|
|
1643
|
+
payload: { pid, channel, code, signal },
|
|
1644
|
+
};
|
|
1645
|
+
emitAppEvent(msg);
|
|
1646
|
+
});
|
|
1647
|
+
return { pid, channel };
|
|
1648
|
+
}
|
|
1649
|
+
function handleProcessesKill(payload) {
|
|
1650
|
+
const pid = payload.pid;
|
|
1651
|
+
if (!pid)
|
|
1652
|
+
throw Object.assign(new Error("pid is required"), { code: "EINVAL" });
|
|
1653
|
+
const proc = processes.get(pid);
|
|
1654
|
+
if (!proc)
|
|
1655
|
+
throw Object.assign(new Error("Process not found"), { code: "ENOPROC" });
|
|
1656
|
+
proc.proc.kill();
|
|
1657
|
+
processes.delete(pid);
|
|
1658
|
+
return {};
|
|
1659
|
+
}
|
|
1660
|
+
function handleProcessesGetOutput(payload) {
|
|
1661
|
+
const channel = payload.channel;
|
|
1662
|
+
if (!channel)
|
|
1663
|
+
throw Object.assign(new Error("channel is required"), { code: "EINVAL" });
|
|
1664
|
+
const output = processOutputBuffers.get(channel) || "";
|
|
1665
|
+
return { channel, output };
|
|
1666
|
+
}
|
|
1667
|
+
function handleProcessesClearOutput(payload) {
|
|
1668
|
+
const channel = payload.channel;
|
|
1669
|
+
if (channel) {
|
|
1670
|
+
processOutputBuffers.set(channel, "");
|
|
1671
|
+
}
|
|
1672
|
+
else {
|
|
1673
|
+
processOutputBuffers.clear();
|
|
1674
|
+
}
|
|
1675
|
+
return {};
|
|
1676
|
+
}
|
|
1677
|
+
// ============================================================================
|
|
1678
|
+
// Ports Handlers
|
|
1679
|
+
// ============================================================================
|
|
1680
|
+
function handlePortsList() {
|
|
1681
|
+
const platform = os.platform();
|
|
1682
|
+
const ports = [];
|
|
1683
|
+
try {
|
|
1684
|
+
let output;
|
|
1685
|
+
if (platform === "darwin" || platform === "linux") {
|
|
1686
|
+
// Use lsof on macOS/Linux
|
|
1687
|
+
try {
|
|
1688
|
+
output = execSync("lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null || true", {
|
|
1689
|
+
encoding: "utf-8",
|
|
1690
|
+
timeout: 5000,
|
|
1691
|
+
});
|
|
1692
|
+
const lines = output.trim().split("\n").slice(1); // Skip header
|
|
1693
|
+
for (const line of lines) {
|
|
1694
|
+
const parts = line.split(/\s+/);
|
|
1695
|
+
if (parts.length >= 9) {
|
|
1696
|
+
const processName = parts[0];
|
|
1697
|
+
const pid = parseInt(parts[1]);
|
|
1698
|
+
const nameField = parts[8];
|
|
1699
|
+
// Parse address:port format
|
|
1700
|
+
const match = nameField.match(/:(\d+)$/);
|
|
1701
|
+
if (match) {
|
|
1702
|
+
const port = parseInt(match[1]);
|
|
1703
|
+
const address = nameField.replace(`:${port}`, "") || "0.0.0.0";
|
|
1704
|
+
ports.push({ port, pid, process: processName, address });
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
catch {
|
|
1710
|
+
// lsof might fail, try netstat
|
|
1711
|
+
output = execSync("netstat -tlnp 2>/dev/null || netstat -an 2>/dev/null || true", {
|
|
1712
|
+
encoding: "utf-8",
|
|
1713
|
+
timeout: 5000,
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
else if (platform === "win32") {
|
|
1718
|
+
output = execSync("netstat -ano | findstr LISTENING", {
|
|
1719
|
+
encoding: "utf-8",
|
|
1720
|
+
timeout: 5000,
|
|
1721
|
+
});
|
|
1722
|
+
const lines = output.trim().split("\n");
|
|
1723
|
+
for (const line of lines) {
|
|
1724
|
+
const parts = line.trim().split(/\s+/);
|
|
1725
|
+
if (parts.length >= 5) {
|
|
1726
|
+
const localAddr = parts[1];
|
|
1727
|
+
const pid = parseInt(parts[4]);
|
|
1728
|
+
const match = localAddr.match(/:(\d+)$/);
|
|
1729
|
+
if (match) {
|
|
1730
|
+
const port = parseInt(match[1]);
|
|
1731
|
+
const address = localAddr.replace(`:${port}`, "");
|
|
1732
|
+
ports.push({ port, pid, process: "unknown", address });
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
// Return empty list on error
|
|
1740
|
+
}
|
|
1741
|
+
return { ports };
|
|
1742
|
+
}
|
|
1743
|
+
function handlePortsIsAvailable(payload) {
|
|
1744
|
+
const port = Math.floor(Number(payload.port));
|
|
1745
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
1746
|
+
throw Object.assign(new Error("port must be an integer between 1 and 65535"), { code: "EINVAL" });
|
|
1747
|
+
}
|
|
1748
|
+
return new Promise((resolve) => {
|
|
1749
|
+
const server = createServer();
|
|
1750
|
+
server.once("error", (err) => {
|
|
1751
|
+
if (err.code === "EADDRINUSE") {
|
|
1752
|
+
resolve({ port, available: false });
|
|
1753
|
+
}
|
|
1754
|
+
else {
|
|
1755
|
+
resolve({ port, available: false, error: err.message });
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
server.once("listening", () => {
|
|
1759
|
+
server.close(() => {
|
|
1760
|
+
resolve({ port, available: true });
|
|
1761
|
+
});
|
|
1762
|
+
});
|
|
1763
|
+
server.listen(port, "127.0.0.1");
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
function handlePortsKill(payload) {
|
|
1767
|
+
const port = payload.port;
|
|
1768
|
+
if (!port)
|
|
1769
|
+
throw Object.assign(new Error("port is required"), { code: "EINVAL" });
|
|
1770
|
+
// Strict port range validation to prevent injection via crafted numeric values
|
|
1771
|
+
const portNum = Math.floor(Number(port));
|
|
1772
|
+
if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) {
|
|
1773
|
+
throw Object.assign(new Error("port must be an integer between 1 and 65535"), { code: "EINVAL" });
|
|
1774
|
+
}
|
|
1775
|
+
const platform = os.platform();
|
|
1776
|
+
try {
|
|
1777
|
+
let pid = null;
|
|
1778
|
+
if (platform === "darwin" || platform === "linux") {
|
|
1779
|
+
// Use spawnSync with an explicit args array — never shell: true — so portNum
|
|
1780
|
+
// cannot escape into a shell command even if it were somehow non-numeric.
|
|
1781
|
+
const result = spawnSync("lsof", ["-ti", String(portNum)], { encoding: "utf-8" });
|
|
1782
|
+
const pids = (result.stdout || "").trim().split("\n").filter(Boolean);
|
|
1783
|
+
for (const pidStr of pids) {
|
|
1784
|
+
const p = parseInt(pidStr, 10);
|
|
1785
|
+
if (!Number.isFinite(p) || p <= 0)
|
|
1786
|
+
continue;
|
|
1787
|
+
if (pid === null)
|
|
1788
|
+
pid = p;
|
|
1789
|
+
// Send SIGKILL directly via process.kill — no shell involved.
|
|
1790
|
+
try {
|
|
1791
|
+
process.kill(p, "SIGKILL");
|
|
1792
|
+
}
|
|
1793
|
+
catch { /* already dead */ }
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
else if (platform === "win32") {
|
|
1797
|
+
// Use netstat via args array, parse PIDs, then taskkill via args array.
|
|
1798
|
+
const result = spawnSync("netstat", ["-ano"], { encoding: "utf-8" });
|
|
1799
|
+
const lines = (result.stdout || "").trim().split("\n");
|
|
1800
|
+
for (const line of lines) {
|
|
1801
|
+
const parts = line.trim().split(/\s+/);
|
|
1802
|
+
// netstat -ano columns: Proto Local Foreign State PID
|
|
1803
|
+
// Match only lines where the local address ends with :<portNum>
|
|
1804
|
+
if (parts.length < 5)
|
|
1805
|
+
continue;
|
|
1806
|
+
const localAddr = parts[1] ?? "";
|
|
1807
|
+
if (!localAddr.endsWith(`:${portNum}`))
|
|
1808
|
+
continue;
|
|
1809
|
+
const p = parseInt(parts[4], 10);
|
|
1810
|
+
if (!Number.isFinite(p) || p <= 0)
|
|
1811
|
+
continue;
|
|
1812
|
+
if (pid === null)
|
|
1813
|
+
pid = p;
|
|
1814
|
+
spawnSync("taskkill", ["/F", "/PID", String(p)], { encoding: "utf-8" });
|
|
1815
|
+
break;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return { port: portNum, pid };
|
|
1819
|
+
}
|
|
1820
|
+
catch (err) {
|
|
1821
|
+
throw Object.assign(new Error(`Failed to kill process on port ${portNum}`), { code: "EPERM" });
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
// ============================================================================
|
|
1825
|
+
// Monitor Handlers
|
|
1826
|
+
// ============================================================================
|
|
1827
|
+
function getCpuUsage() {
|
|
1828
|
+
const cpus = os.cpus();
|
|
1829
|
+
const coreUsages = [];
|
|
1830
|
+
let totalIdle = 0;
|
|
1831
|
+
let totalTick = 0;
|
|
1832
|
+
for (let i = 0; i < cpus.length; i++) {
|
|
1833
|
+
const cpu = cpus[i];
|
|
1834
|
+
const total = Object.values(cpu.times).reduce((a, b) => a + b, 0);
|
|
1835
|
+
const idle = cpu.times.idle;
|
|
1836
|
+
if (lastCpuInfo && lastCpuInfo[i]) {
|
|
1837
|
+
const deltaTotal = total - lastCpuInfo[i].total;
|
|
1838
|
+
const deltaIdle = idle - lastCpuInfo[i].idle;
|
|
1839
|
+
const usage = deltaTotal > 0 ? ((deltaTotal - deltaIdle) / deltaTotal) * 100 : 0;
|
|
1840
|
+
coreUsages.push(Math.round(usage * 10) / 10);
|
|
1841
|
+
}
|
|
1842
|
+
else {
|
|
1843
|
+
coreUsages.push(0);
|
|
1844
|
+
}
|
|
1845
|
+
totalIdle += idle;
|
|
1846
|
+
totalTick += total;
|
|
1847
|
+
}
|
|
1848
|
+
// Update last CPU info for next calculation
|
|
1849
|
+
lastCpuInfo = cpus.map((cpu) => ({
|
|
1850
|
+
idle: cpu.times.idle,
|
|
1851
|
+
total: Object.values(cpu.times).reduce((a, b) => a + b, 0),
|
|
1852
|
+
}));
|
|
1853
|
+
const avgUsage = coreUsages.length > 0
|
|
1854
|
+
? coreUsages.reduce((a, b) => a + b, 0) / coreUsages.length
|
|
1855
|
+
: 0;
|
|
1856
|
+
return { usage: Math.round(avgUsage * 10) / 10, cores: coreUsages };
|
|
1857
|
+
}
|
|
1858
|
+
function getMemoryInfo() {
|
|
1859
|
+
const total = os.totalmem();
|
|
1860
|
+
const free = os.freemem();
|
|
1861
|
+
const used = total - free;
|
|
1862
|
+
const usedPercent = Math.round((used / total) * 1000) / 10;
|
|
1863
|
+
return { total, used, free, usedPercent };
|
|
1864
|
+
}
|
|
1865
|
+
function getDiskInfo() {
|
|
1866
|
+
const platform = os.platform();
|
|
1867
|
+
const disks = [];
|
|
1868
|
+
try {
|
|
1869
|
+
if (platform === "darwin" || platform === "linux") {
|
|
1870
|
+
const output = execSync("df -k 2>/dev/null || true", { encoding: "utf-8" });
|
|
1871
|
+
const lines = output.trim().split("\n").slice(1);
|
|
1872
|
+
for (const line of lines) {
|
|
1873
|
+
const parts = line.split(/\s+/);
|
|
1874
|
+
if (parts.length >= 6) {
|
|
1875
|
+
const filesystem = parts[0];
|
|
1876
|
+
const size = parseInt(parts[1]) * 1024;
|
|
1877
|
+
const used = parseInt(parts[2]) * 1024;
|
|
1878
|
+
const free = parseInt(parts[3]) * 1024;
|
|
1879
|
+
const mount = parts[5];
|
|
1880
|
+
// Skip special filesystems
|
|
1881
|
+
if (mount.startsWith("/") && !filesystem.startsWith("devfs") && !filesystem.startsWith("map ")) {
|
|
1882
|
+
disks.push({
|
|
1883
|
+
mount,
|
|
1884
|
+
filesystem,
|
|
1885
|
+
size,
|
|
1886
|
+
used,
|
|
1887
|
+
free,
|
|
1888
|
+
usedPercent: size > 0 ? Math.round((used / size) * 1000) / 10 : 0,
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
else if (platform === "win32") {
|
|
1895
|
+
const output = execSync("wmic logicaldisk get size,freespace,caption", { encoding: "utf-8" });
|
|
1896
|
+
const lines = output.trim().split("\n").slice(1);
|
|
1897
|
+
for (const line of lines) {
|
|
1898
|
+
const parts = line.trim().split(/\s+/);
|
|
1899
|
+
if (parts.length >= 3) {
|
|
1900
|
+
const mount = parts[0];
|
|
1901
|
+
const free = parseInt(parts[1]) || 0;
|
|
1902
|
+
const size = parseInt(parts[2]) || 0;
|
|
1903
|
+
const used = size - free;
|
|
1904
|
+
if (size > 0) {
|
|
1905
|
+
disks.push({
|
|
1906
|
+
mount,
|
|
1907
|
+
filesystem: "NTFS",
|
|
1908
|
+
size,
|
|
1909
|
+
used,
|
|
1910
|
+
free,
|
|
1911
|
+
usedPercent: Math.round((used / size) * 1000) / 10,
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
catch {
|
|
1919
|
+
// Return empty on error
|
|
1920
|
+
}
|
|
1921
|
+
return disks;
|
|
1922
|
+
}
|
|
1923
|
+
function getBatteryInfo() {
|
|
1924
|
+
const platform = os.platform();
|
|
1925
|
+
try {
|
|
1926
|
+
if (platform === "darwin") {
|
|
1927
|
+
const output = execSync("pmset -g batt 2>/dev/null || true", { encoding: "utf-8" });
|
|
1928
|
+
const percentMatch = output.match(/(\d+)%/);
|
|
1929
|
+
const chargingMatch = output.match(/AC Power|charging|charged/i);
|
|
1930
|
+
const timeMatch = output.match(/(\d+):(\d+) remaining/);
|
|
1931
|
+
if (percentMatch) {
|
|
1932
|
+
return {
|
|
1933
|
+
hasBattery: true,
|
|
1934
|
+
percent: parseInt(percentMatch[1]),
|
|
1935
|
+
charging: !!chargingMatch,
|
|
1936
|
+
timeRemaining: timeMatch ? parseInt(timeMatch[1]) * 60 + parseInt(timeMatch[2]) : null,
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
else if (platform === "linux") {
|
|
1941
|
+
try {
|
|
1942
|
+
const capacityPath = "/sys/class/power_supply/BAT0/capacity";
|
|
1943
|
+
const statusPath = "/sys/class/power_supply/BAT0/status";
|
|
1944
|
+
const capacity = parseInt(execSync(`cat ${capacityPath} 2>/dev/null || echo 0`, { encoding: "utf-8" }).trim());
|
|
1945
|
+
const status = execSync(`cat ${statusPath} 2>/dev/null || echo Unknown`, { encoding: "utf-8" }).trim();
|
|
1946
|
+
if (capacity > 0) {
|
|
1947
|
+
return {
|
|
1948
|
+
hasBattery: true,
|
|
1949
|
+
percent: capacity,
|
|
1950
|
+
charging: status === "Charging" || status === "Full",
|
|
1951
|
+
timeRemaining: null,
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
catch {
|
|
1956
|
+
// No battery
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
else if (platform === "win32") {
|
|
1960
|
+
const output = execSync("WMIC Path Win32_Battery Get EstimatedChargeRemaining,BatteryStatus 2>nul || echo", { encoding: "utf-8" });
|
|
1961
|
+
const lines = output.trim().split("\n").slice(1);
|
|
1962
|
+
if (lines.length > 0) {
|
|
1963
|
+
const parts = lines[0].trim().split(/\s+/);
|
|
1964
|
+
if (parts.length >= 2) {
|
|
1965
|
+
const status = parseInt(parts[0]);
|
|
1966
|
+
const percent = parseInt(parts[1]);
|
|
1967
|
+
return {
|
|
1968
|
+
hasBattery: true,
|
|
1969
|
+
percent: percent || 0,
|
|
1970
|
+
charging: status === 2 || status === 6, // Charging or Charging High
|
|
1971
|
+
timeRemaining: null,
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
catch {
|
|
1978
|
+
// No battery or error
|
|
1979
|
+
}
|
|
1980
|
+
return { hasBattery: false, percent: 0, charging: false, timeRemaining: null };
|
|
1981
|
+
}
|
|
1982
|
+
function handleMonitorSystem() {
|
|
1983
|
+
return {
|
|
1984
|
+
cpu: getCpuUsage(),
|
|
1985
|
+
memory: getMemoryInfo(),
|
|
1986
|
+
disk: getDiskInfo(),
|
|
1987
|
+
battery: getBatteryInfo(),
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
function handleMonitorCpu() {
|
|
1991
|
+
const cpuInfo = getCpuUsage();
|
|
1992
|
+
const cpus = os.cpus();
|
|
1993
|
+
return {
|
|
1994
|
+
...cpuInfo,
|
|
1995
|
+
model: cpus[0]?.model || "Unknown",
|
|
1996
|
+
speed: cpus[0]?.speed || 0,
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
function handleMonitorMemory() {
|
|
2000
|
+
return getMemoryInfo();
|
|
2001
|
+
}
|
|
2002
|
+
function handleMonitorDisk() {
|
|
2003
|
+
return { disks: getDiskInfo() };
|
|
2004
|
+
}
|
|
2005
|
+
function handleMonitorBattery() {
|
|
2006
|
+
return getBatteryInfo();
|
|
2007
|
+
}
|
|
2008
|
+
// ============================================================================
|
|
2009
|
+
// HTTP Handlers
|
|
2010
|
+
// ============================================================================
|
|
2011
|
+
async function handleHttpRequest(payload) {
|
|
2012
|
+
const method = payload.method || "GET";
|
|
2013
|
+
const url = payload.url;
|
|
2014
|
+
const headers = payload.headers || {};
|
|
2015
|
+
const body = payload.body;
|
|
2016
|
+
const timeout = payload.timeout || 30000;
|
|
2017
|
+
if (!url)
|
|
2018
|
+
throw Object.assign(new Error("url is required"), { code: "EINVAL" });
|
|
2019
|
+
const startTime = Date.now();
|
|
2020
|
+
try {
|
|
2021
|
+
const controller = new AbortController();
|
|
2022
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
2023
|
+
const response = await fetch(url, {
|
|
2024
|
+
method,
|
|
2025
|
+
headers,
|
|
2026
|
+
body: body || undefined,
|
|
2027
|
+
signal: controller.signal,
|
|
2028
|
+
});
|
|
2029
|
+
clearTimeout(timeoutId);
|
|
2030
|
+
const responseHeaders = {};
|
|
2031
|
+
response.headers.forEach((value, key) => {
|
|
2032
|
+
responseHeaders[key] = value;
|
|
2033
|
+
});
|
|
2034
|
+
const responseBody = await response.text();
|
|
2035
|
+
const timing = Date.now() - startTime;
|
|
2036
|
+
return {
|
|
2037
|
+
status: response.status,
|
|
2038
|
+
statusText: response.statusText,
|
|
2039
|
+
headers: responseHeaders,
|
|
2040
|
+
body: responseBody,
|
|
2041
|
+
timing,
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
catch (err) {
|
|
2045
|
+
const error = err;
|
|
2046
|
+
if (error.name === "AbortError") {
|
|
2047
|
+
throw Object.assign(new Error("Request timed out"), { code: "ETIMEOUT" });
|
|
2048
|
+
}
|
|
2049
|
+
throw Object.assign(new Error(error.message || "Network error"), { code: "ENETWORK" });
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
// ============================================================================
|
|
2053
|
+
// AI Handlers — delegated to the active AIProvider
|
|
2054
|
+
// ============================================================================
|
|
2055
|
+
// (Implementation lives in cli/src/ai/opencode.ts or cli/src/ai/codex.ts)
|
|
2056
|
+
// Proxy Handlers
|
|
2057
|
+
// ============================================================================
|
|
2058
|
+
async function scanDevPorts() {
|
|
2059
|
+
const openPorts = [];
|
|
2060
|
+
const scanPorts = Array.from(trackedProxyPorts).sort((a, b) => a - b);
|
|
2061
|
+
if (scanPorts.length === 0) {
|
|
2062
|
+
return openPorts;
|
|
2063
|
+
}
|
|
2064
|
+
const checks = scanPorts.map((port) => {
|
|
2065
|
+
return new Promise((resolve) => {
|
|
2066
|
+
let finished = false;
|
|
2067
|
+
let pending = LOOPBACK_HOSTS.length;
|
|
2068
|
+
for (const host of LOOPBACK_HOSTS) {
|
|
2069
|
+
const socket = createConnection({ port, host });
|
|
2070
|
+
socket.setTimeout(200);
|
|
2071
|
+
socket.on("connect", () => {
|
|
2072
|
+
if (finished)
|
|
2073
|
+
return;
|
|
2074
|
+
finished = true;
|
|
2075
|
+
openPorts.push(port);
|
|
2076
|
+
socket.destroy();
|
|
2077
|
+
resolve();
|
|
2078
|
+
});
|
|
2079
|
+
const onDone = () => {
|
|
2080
|
+
if (finished)
|
|
2081
|
+
return;
|
|
2082
|
+
pending -= 1;
|
|
2083
|
+
if (pending <= 0) {
|
|
2084
|
+
finished = true;
|
|
2085
|
+
resolve();
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
socket.on("timeout", () => {
|
|
2089
|
+
socket.destroy();
|
|
2090
|
+
onDone();
|
|
2091
|
+
});
|
|
2092
|
+
socket.on("error", () => {
|
|
2093
|
+
onDone();
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
});
|
|
2097
|
+
});
|
|
2098
|
+
await Promise.all(checks);
|
|
2099
|
+
return openPorts.sort((a, b) => a - b);
|
|
2100
|
+
}
|
|
2101
|
+
function getTrackedProxyPorts() {
|
|
2102
|
+
return Array.from(trackedProxyPorts).sort((a, b) => a - b);
|
|
2103
|
+
}
|
|
2104
|
+
async function publishDiscoveredPorts(force = false) {
|
|
2105
|
+
if (portScanInFlight)
|
|
2106
|
+
return;
|
|
2107
|
+
if (!activeV2Transport)
|
|
2108
|
+
return;
|
|
2109
|
+
portScanInFlight = true;
|
|
2110
|
+
try {
|
|
2111
|
+
const openPorts = await scanDevPorts();
|
|
2112
|
+
if (!force && samePortSet(openPorts, lastDiscoveredPorts)) {
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
lastDiscoveredPorts = openPorts;
|
|
2116
|
+
emitAppEvent({
|
|
2117
|
+
v: 1,
|
|
2118
|
+
id: `evt-${Date.now()}`,
|
|
2119
|
+
ns: "proxy",
|
|
2120
|
+
action: "ports_discovered",
|
|
2121
|
+
payload: { ports: openPorts },
|
|
2122
|
+
});
|
|
2123
|
+
debugLog(`[proxy] ports updated (${openPorts.length}): ${openPorts.join(", ") || "-"}`);
|
|
2124
|
+
}
|
|
2125
|
+
catch (err) {
|
|
2126
|
+
console.error("Port scan failed:", err);
|
|
2127
|
+
}
|
|
2128
|
+
finally {
|
|
2129
|
+
portScanInFlight = false;
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
async function getProxyState() {
|
|
2133
|
+
const openPorts = await scanDevPorts();
|
|
2134
|
+
lastDiscoveredPorts = openPorts;
|
|
2135
|
+
return {
|
|
2136
|
+
trackedPorts: getTrackedProxyPorts(),
|
|
2137
|
+
openPorts,
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
async function handleTrackProxyPort(payload) {
|
|
2141
|
+
const port = Number(payload.port);
|
|
2142
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2143
|
+
throw Object.assign(new Error("port must be an integer between 1 and 65535"), { code: "EINVAL" });
|
|
2144
|
+
}
|
|
2145
|
+
trackedProxyPorts.add(port);
|
|
2146
|
+
debugLog("[proxy] tracking custom port", {
|
|
2147
|
+
port,
|
|
2148
|
+
trackedPorts: getTrackedProxyPorts(),
|
|
2149
|
+
});
|
|
2150
|
+
await publishDiscoveredPorts(true);
|
|
2151
|
+
return {
|
|
2152
|
+
trackedPorts: getTrackedProxyPorts(),
|
|
2153
|
+
openPorts: lastDiscoveredPorts,
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
async function handleUntrackProxyPort(payload) {
|
|
2157
|
+
const port = Number(payload.port);
|
|
2158
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2159
|
+
throw Object.assign(new Error("port must be an integer between 1 and 65535"), { code: "EINVAL" });
|
|
2160
|
+
}
|
|
2161
|
+
trackedProxyPorts.delete(port);
|
|
2162
|
+
debugLog("[proxy] removed custom port tracking", {
|
|
2163
|
+
port,
|
|
2164
|
+
trackedPorts: getTrackedProxyPorts(),
|
|
2165
|
+
});
|
|
2166
|
+
await publishDiscoveredPorts(true);
|
|
2167
|
+
return {
|
|
2168
|
+
trackedPorts: getTrackedProxyPorts(),
|
|
2169
|
+
openPorts: lastDiscoveredPorts,
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
function stopPortSync() {
|
|
2173
|
+
if (portSyncTimer) {
|
|
2174
|
+
clearInterval(portSyncTimer);
|
|
2175
|
+
portSyncTimer = null;
|
|
2176
|
+
}
|
|
2177
|
+
portScanInFlight = false;
|
|
2178
|
+
}
|
|
2179
|
+
function startPortSync() {
|
|
2180
|
+
stopPortSync();
|
|
2181
|
+
void publishDiscoveredPorts(true);
|
|
2182
|
+
portSyncTimer = setInterval(() => {
|
|
2183
|
+
void publishDiscoveredPorts(false);
|
|
2184
|
+
}, PORT_SYNC_INTERVAL_MS);
|
|
2185
|
+
}
|
|
2186
|
+
async function handleProxyConnect(payload) {
|
|
2187
|
+
const tunnelId = payload.tunnelId;
|
|
2188
|
+
const port = payload.port;
|
|
2189
|
+
const setupStartedAt = Date.now();
|
|
2190
|
+
const getRemainingSetupMs = () => TUNNEL_SETUP_BUDGET_MS - (Date.now() - setupStartedAt);
|
|
2191
|
+
debugLog("[proxy] handleProxyConnect received", {
|
|
2192
|
+
tunnelId,
|
|
2193
|
+
port,
|
|
2194
|
+
hasSessionCode: Boolean(currentSessionCode),
|
|
2195
|
+
hasSessionPassword: Boolean(currentSessionPassword),
|
|
2196
|
+
activeGatewayUrl,
|
|
2197
|
+
});
|
|
2198
|
+
if (!tunnelId)
|
|
2199
|
+
throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
|
|
2200
|
+
if (!port)
|
|
2201
|
+
throw Object.assign(new Error("port is required"), { code: "EINVAL" });
|
|
2202
|
+
if (!currentSessionCode && !currentSessionPassword)
|
|
2203
|
+
throw Object.assign(new Error("no active session"), { code: "ENOENT" });
|
|
2204
|
+
if (getRemainingSetupMs() <= 0) {
|
|
2205
|
+
throw Object.assign(new Error("Tunnel setup timeout before start"), { code: "ETIMEOUT" });
|
|
2206
|
+
}
|
|
2207
|
+
// 1. Open TCP connection to the local service (dual-stack localhost fallback)
|
|
2208
|
+
let tcpSocket = null;
|
|
2209
|
+
let tcpConnectError = null;
|
|
2210
|
+
for (const host of LOOPBACK_HOSTS) {
|
|
2211
|
+
const remainingMs = getRemainingSetupMs();
|
|
2212
|
+
if (remainingMs <= 0) {
|
|
2213
|
+
throw Object.assign(new Error("Tunnel setup timeout before local TCP connect"), { code: "ETIMEOUT" });
|
|
2214
|
+
}
|
|
2215
|
+
const tcpConnectTimeoutMs = Math.min(CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
|
|
2216
|
+
const candidate = createConnection({ port, host });
|
|
2217
|
+
try {
|
|
2218
|
+
await new Promise((resolve, reject) => {
|
|
2219
|
+
const timeout = setTimeout(() => {
|
|
2220
|
+
candidate.destroy();
|
|
2221
|
+
reject(Object.assign(new Error(`TCP connect timeout to ${host}:${port}`), { code: "ETIMEOUT" }));
|
|
2222
|
+
}, tcpConnectTimeoutMs);
|
|
2223
|
+
candidate.on("connect", () => {
|
|
2224
|
+
clearTimeout(timeout);
|
|
2225
|
+
resolve();
|
|
2226
|
+
});
|
|
2227
|
+
candidate.on("error", (err) => {
|
|
2228
|
+
clearTimeout(timeout);
|
|
2229
|
+
reject(Object.assign(new Error(`TCP connect failed to ${host}:${port}: ${err.message}`), { code: "ECONNREFUSED" }));
|
|
2230
|
+
});
|
|
2231
|
+
});
|
|
2232
|
+
tcpSocket = candidate;
|
|
2233
|
+
break;
|
|
2234
|
+
}
|
|
2235
|
+
catch (error) {
|
|
2236
|
+
tcpConnectError = error;
|
|
2237
|
+
try {
|
|
2238
|
+
candidate.destroy();
|
|
2239
|
+
}
|
|
2240
|
+
catch {
|
|
2241
|
+
// ignore
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
if (!tcpSocket) {
|
|
2246
|
+
debugLog("[proxy] local tcp connect failed", {
|
|
2247
|
+
tunnelId,
|
|
2248
|
+
port,
|
|
2249
|
+
error: tcpConnectError?.message ?? null,
|
|
2250
|
+
});
|
|
2251
|
+
throw tcpConnectError || Object.assign(new Error(`TCP connect failed to localhost:${port}`), { code: "ECONNREFUSED" });
|
|
2252
|
+
}
|
|
2253
|
+
debugLog("[proxy] local tcp connected", { tunnelId, port });
|
|
2254
|
+
// 2. Open proxy WebSocket to gateway
|
|
2255
|
+
const wsBase = activeGatewayUrl.replace(/^https:/, "wss:");
|
|
2256
|
+
if (!wsBase.startsWith("wss://")) {
|
|
2257
|
+
throw Object.assign(new Error("Gateway URL must use https://"), { code: "EPROTO" });
|
|
2258
|
+
}
|
|
2259
|
+
const authQuery = currentSessionPassword
|
|
2260
|
+
? `password=${encodeURIComponent(currentSessionPassword)}`
|
|
2261
|
+
: `code=${encodeURIComponent(currentSessionCode)}`;
|
|
2262
|
+
const proxyWsUrl = `${wsBase}/v1/ws/proxy?${authQuery}&tunnelId=${encodeURIComponent(tunnelId)}&role=cli`;
|
|
2263
|
+
debugLog("[proxy] connecting cli proxy websocket", {
|
|
2264
|
+
tunnelId,
|
|
2265
|
+
port,
|
|
2266
|
+
authMode: currentSessionPassword ? "password" : "code",
|
|
2267
|
+
wsBase,
|
|
2268
|
+
});
|
|
2269
|
+
let proxyWs = null;
|
|
2270
|
+
let lastProxyError = null;
|
|
2271
|
+
for (let attempt = 0; attempt <= PROXY_WS_CONNECT_RETRY_ATTEMPTS; attempt++) {
|
|
2272
|
+
const remainingMs = getRemainingSetupMs();
|
|
2273
|
+
if (remainingMs <= 0) {
|
|
2274
|
+
tcpSocket.destroy();
|
|
2275
|
+
throw Object.assign(new Error("Tunnel setup timeout while connecting proxy WS"), { code: "ETIMEOUT" });
|
|
2276
|
+
}
|
|
2277
|
+
const wsConnectTimeoutMs = Math.min(PROXY_WS_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
|
|
2278
|
+
const candidateWs = new WebSocket(proxyWsUrl);
|
|
2279
|
+
try {
|
|
2280
|
+
await new Promise((resolve, reject) => {
|
|
2281
|
+
const timeout = setTimeout(() => {
|
|
2282
|
+
candidateWs.close();
|
|
2283
|
+
reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
|
|
2284
|
+
}, wsConnectTimeoutMs);
|
|
2285
|
+
candidateWs.on("open", () => {
|
|
2286
|
+
clearTimeout(timeout);
|
|
2287
|
+
resolve();
|
|
2288
|
+
});
|
|
2289
|
+
candidateWs.on("error", (err) => {
|
|
2290
|
+
clearTimeout(timeout);
|
|
2291
|
+
reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
|
|
2292
|
+
});
|
|
2293
|
+
candidateWs.on("close", () => {
|
|
2294
|
+
clearTimeout(timeout);
|
|
2295
|
+
reject(Object.assign(new Error("Proxy WS closed during connect"), { code: "ECONNRESET" }));
|
|
2296
|
+
});
|
|
2297
|
+
});
|
|
2298
|
+
proxyWs = candidateWs;
|
|
2299
|
+
break;
|
|
2300
|
+
}
|
|
2301
|
+
catch (error) {
|
|
2302
|
+
lastProxyError = error;
|
|
2303
|
+
try {
|
|
2304
|
+
candidateWs.close();
|
|
2305
|
+
}
|
|
2306
|
+
catch {
|
|
2307
|
+
// ignore
|
|
2308
|
+
}
|
|
2309
|
+
if (attempt >= PROXY_WS_CONNECT_RETRY_ATTEMPTS) {
|
|
2310
|
+
break;
|
|
2311
|
+
}
|
|
2312
|
+
const jitterSpan = PROXY_WS_RETRY_JITTER_MAX_MS - PROXY_WS_RETRY_JITTER_MIN_MS;
|
|
2313
|
+
const jitterMs = PROXY_WS_RETRY_JITTER_MIN_MS + Math.floor(Math.random() * (jitterSpan + 1));
|
|
2314
|
+
if (getRemainingSetupMs() <= jitterMs) {
|
|
2315
|
+
break;
|
|
2316
|
+
}
|
|
2317
|
+
await new Promise((resolve) => setTimeout(resolve, jitterMs));
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
if (!proxyWs) {
|
|
2321
|
+
tcpSocket.destroy();
|
|
2322
|
+
const err = lastProxyError || Object.assign(new Error("Proxy WS connect failed"), { code: "ECONNREFUSED" });
|
|
2323
|
+
debugLog("[proxy] cli proxy websocket connect failed", {
|
|
2324
|
+
tunnelId,
|
|
2325
|
+
port,
|
|
2326
|
+
error: err.message,
|
|
2327
|
+
});
|
|
2328
|
+
throw err;
|
|
2329
|
+
}
|
|
2330
|
+
debugLog("[proxy] cli proxy websocket connected", { tunnelId, port });
|
|
2331
|
+
// 3. Store the tunnel
|
|
2332
|
+
activeTunnels.set(tunnelId, {
|
|
2333
|
+
tunnelId,
|
|
2334
|
+
port,
|
|
2335
|
+
tcpSocket,
|
|
2336
|
+
proxyWs,
|
|
2337
|
+
localEnded: false,
|
|
2338
|
+
remoteEnded: false,
|
|
2339
|
+
finSent: false,
|
|
2340
|
+
finalizeTimer: null,
|
|
2341
|
+
closing: false,
|
|
2342
|
+
});
|
|
2343
|
+
// 4. Pipe: TCP data -> proxy WS (as binary)
|
|
2344
|
+
tcpSocket.on("data", (chunk) => {
|
|
2345
|
+
if (proxyWs.readyState === WebSocket.OPEN) {
|
|
2346
|
+
proxyWs.send(chunk);
|
|
2347
|
+
}
|
|
2348
|
+
});
|
|
2349
|
+
// 5. Pipe: proxy WS -> TCP socket (as binary)
|
|
2350
|
+
proxyWs.on("message", (data) => {
|
|
2351
|
+
const control = parseProxyControlFrame(data);
|
|
2352
|
+
if (control) {
|
|
2353
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
2354
|
+
if (!tunnel || tunnel.closing)
|
|
2355
|
+
return;
|
|
2356
|
+
if (control.action === "fin") {
|
|
2357
|
+
tunnel.remoteEnded = true;
|
|
2358
|
+
if (!tcpSocket.destroyed) {
|
|
2359
|
+
tcpSocket.end();
|
|
2360
|
+
}
|
|
2361
|
+
maybeFinalizeTunnel(tunnelId);
|
|
2362
|
+
}
|
|
2363
|
+
else {
|
|
2364
|
+
tunnel.closing = true;
|
|
2365
|
+
activeTunnels.delete(tunnelId);
|
|
2366
|
+
if (!tcpSocket.destroyed) {
|
|
2367
|
+
tcpSocket.destroy();
|
|
2368
|
+
}
|
|
2369
|
+
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
2370
|
+
proxyWs.close();
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
if (!tcpSocket.destroyed) {
|
|
2376
|
+
const chunk = Buffer.isBuffer(data)
|
|
2377
|
+
? data
|
|
2378
|
+
: typeof data === "string"
|
|
2379
|
+
? Buffer.from(data)
|
|
2380
|
+
: Array.isArray(data)
|
|
2381
|
+
? Buffer.concat(data)
|
|
2382
|
+
: Buffer.from(data);
|
|
2383
|
+
tcpSocket.write(chunk);
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2386
|
+
const markLocalEnded = () => {
|
|
2387
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
2388
|
+
if (!tunnel || tunnel.closing)
|
|
2389
|
+
return;
|
|
2390
|
+
tunnel.localEnded = true;
|
|
2391
|
+
if (!tunnel.finSent) {
|
|
2392
|
+
tunnel.finSent = true;
|
|
2393
|
+
sendProxyControl(tunnel, "fin");
|
|
2394
|
+
}
|
|
2395
|
+
maybeFinalizeTunnel(tunnelId);
|
|
2396
|
+
};
|
|
2397
|
+
// 6. Half-close handling
|
|
2398
|
+
tcpSocket.on("end", () => {
|
|
2399
|
+
markLocalEnded();
|
|
2400
|
+
});
|
|
2401
|
+
tcpSocket.on("close", () => {
|
|
2402
|
+
markLocalEnded();
|
|
2403
|
+
});
|
|
2404
|
+
tcpSocket.on("error", () => {
|
|
2405
|
+
debugLog("[proxy] local tcp socket error", { tunnelId, port });
|
|
2406
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
2407
|
+
if (tunnel && !tunnel.finSent) {
|
|
2408
|
+
sendProxyControl(tunnel, "rst", "tcp_error");
|
|
2409
|
+
}
|
|
2410
|
+
if (tunnel) {
|
|
2411
|
+
tunnel.closing = true;
|
|
2412
|
+
if (tunnel.finalizeTimer) {
|
|
2413
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
activeTunnels.delete(tunnelId);
|
|
2417
|
+
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
2418
|
+
proxyWs.close();
|
|
2419
|
+
}
|
|
2420
|
+
});
|
|
2421
|
+
// 7. Close cascade: WS closes -> close TCP
|
|
2422
|
+
proxyWs.on("close", () => {
|
|
2423
|
+
debugLog("[proxy] cli proxy websocket closed", { tunnelId, port });
|
|
2424
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
2425
|
+
if (tunnel) {
|
|
2426
|
+
tunnel.closing = true;
|
|
2427
|
+
if (tunnel.finalizeTimer) {
|
|
2428
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
activeTunnels.delete(tunnelId);
|
|
2432
|
+
if (!tcpSocket.destroyed) {
|
|
2433
|
+
tcpSocket.destroy();
|
|
2434
|
+
}
|
|
2435
|
+
});
|
|
2436
|
+
proxyWs.on("error", () => {
|
|
2437
|
+
debugLog("[proxy] cli proxy websocket error", { tunnelId, port });
|
|
2438
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
2439
|
+
if (tunnel) {
|
|
2440
|
+
tunnel.closing = true;
|
|
2441
|
+
if (tunnel.finalizeTimer) {
|
|
2442
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
activeTunnels.delete(tunnelId);
|
|
2446
|
+
if (!tcpSocket.destroyed) {
|
|
2447
|
+
tcpSocket.destroy();
|
|
2448
|
+
}
|
|
2449
|
+
});
|
|
2450
|
+
return { tunnelId, port };
|
|
2451
|
+
}
|
|
2452
|
+
function cleanupAllTunnels() {
|
|
2453
|
+
for (const [, tunnel] of activeTunnels) {
|
|
2454
|
+
if (tunnel.finalizeTimer) {
|
|
2455
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
2456
|
+
}
|
|
2457
|
+
tunnel.tcpSocket.destroy();
|
|
2458
|
+
if (tunnel.proxyWs.readyState === WebSocket.OPEN) {
|
|
2459
|
+
tunnel.proxyWs.close();
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
activeTunnels.clear();
|
|
2463
|
+
}
|
|
2464
|
+
// ============================================================================
|
|
2465
|
+
// Message Router
|
|
2466
|
+
// ============================================================================
|
|
2467
|
+
async function processMessage(message) {
|
|
2468
|
+
const { v, id, ns, action, payload } = message;
|
|
2469
|
+
const startedAt = Date.now();
|
|
2470
|
+
const pathValue = typeof payload?.path === "string" ? payload.path : null;
|
|
2471
|
+
logWithTimestamp("router", "request received", { id, ns, action, path: pathValue });
|
|
2472
|
+
// Validate protocol version
|
|
2473
|
+
if (v !== 1) {
|
|
2474
|
+
return {
|
|
2475
|
+
v: 1,
|
|
2476
|
+
id,
|
|
2477
|
+
ns,
|
|
2478
|
+
action,
|
|
2479
|
+
ok: false,
|
|
2480
|
+
payload: {},
|
|
2481
|
+
error: { code: "EPROTO", message: `Unsupported protocol version: ${v}` },
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
// Validate required fields
|
|
2485
|
+
if (!ns || !action) {
|
|
2486
|
+
debugWarn("[router] Ignoring message with missing ns/action:", redactSensitive(JSON.stringify(message).substring(0, 300)));
|
|
2487
|
+
return {
|
|
2488
|
+
v: 1,
|
|
2489
|
+
id,
|
|
2490
|
+
ns,
|
|
2491
|
+
action,
|
|
2492
|
+
ok: false,
|
|
2493
|
+
payload: {},
|
|
2494
|
+
error: { code: "EINVAL", message: `Missing namespace or action` },
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
try {
|
|
2498
|
+
let result;
|
|
2499
|
+
switch (ns) {
|
|
2500
|
+
case "system":
|
|
2501
|
+
switch (action) {
|
|
2502
|
+
case "capabilities":
|
|
2503
|
+
result = handleSystemCapabilities();
|
|
2504
|
+
void publishDiscoveredPorts(true);
|
|
2505
|
+
break;
|
|
2506
|
+
case "ping":
|
|
2507
|
+
result = handleSystemPing();
|
|
2508
|
+
break;
|
|
2509
|
+
case "pairDevice": {
|
|
2510
|
+
throw Object.assign(new Error("pairDevice is no longer supported"), { code: "EINVAL" });
|
|
2511
|
+
}
|
|
2512
|
+
default:
|
|
2513
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2514
|
+
}
|
|
2515
|
+
break;
|
|
2516
|
+
case "fs":
|
|
2517
|
+
switch (action) {
|
|
2518
|
+
case "ls":
|
|
2519
|
+
result = await handleFsLs(payload);
|
|
2520
|
+
break;
|
|
2521
|
+
case "searchFiles":
|
|
2522
|
+
result = await handleFsSearchFiles(payload);
|
|
2523
|
+
break;
|
|
2524
|
+
case "stat":
|
|
2525
|
+
result = await handleFsStat(payload);
|
|
2526
|
+
break;
|
|
2527
|
+
case "read":
|
|
2528
|
+
result = await handleFsRead(payload);
|
|
2529
|
+
break;
|
|
2530
|
+
case "write":
|
|
2531
|
+
result = await handleFsWrite(payload);
|
|
2532
|
+
break;
|
|
2533
|
+
case "mkdir":
|
|
2534
|
+
result = await handleFsMkdir(payload);
|
|
2535
|
+
break;
|
|
2536
|
+
case "rm":
|
|
2537
|
+
result = await handleFsRm(payload);
|
|
2538
|
+
break;
|
|
2539
|
+
case "mv":
|
|
2540
|
+
result = await handleFsMv(payload);
|
|
2541
|
+
break;
|
|
2542
|
+
case "grep":
|
|
2543
|
+
result = await handleFsGrep(payload);
|
|
2544
|
+
break;
|
|
2545
|
+
case "create":
|
|
2546
|
+
result = await handleFsCreate(payload);
|
|
2547
|
+
break;
|
|
2548
|
+
default:
|
|
2549
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2550
|
+
}
|
|
2551
|
+
break;
|
|
2552
|
+
case "editor":
|
|
2553
|
+
switch (action) {
|
|
2554
|
+
case "open":
|
|
2555
|
+
result = await trackEditorFile(payload.path);
|
|
2556
|
+
break;
|
|
2557
|
+
case "close":
|
|
2558
|
+
result = untrackEditorFile(payload.path);
|
|
2559
|
+
break;
|
|
2560
|
+
case "rename":
|
|
2561
|
+
result = await renameTrackedEditorFile(payload.from, payload.to);
|
|
2562
|
+
break;
|
|
2563
|
+
case "delete":
|
|
2564
|
+
result = deleteTrackedEditorFile(payload.path);
|
|
2565
|
+
break;
|
|
2566
|
+
default:
|
|
2567
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2568
|
+
}
|
|
2569
|
+
break;
|
|
2570
|
+
case "git":
|
|
2571
|
+
switch (action) {
|
|
2572
|
+
case "status":
|
|
2573
|
+
result = await handleGitStatus();
|
|
2574
|
+
break;
|
|
2575
|
+
case "stage":
|
|
2576
|
+
result = await handleGitStage(payload);
|
|
2577
|
+
break;
|
|
2578
|
+
case "unstage":
|
|
2579
|
+
result = await handleGitUnstage(payload);
|
|
2580
|
+
break;
|
|
2581
|
+
case "commit":
|
|
2582
|
+
result = await handleGitCommit(payload);
|
|
2583
|
+
break;
|
|
2584
|
+
case "log":
|
|
2585
|
+
result = await handleGitLog(payload);
|
|
2586
|
+
break;
|
|
2587
|
+
case "commitDetails":
|
|
2588
|
+
result = await handleGitCommitDetails(payload);
|
|
2589
|
+
break;
|
|
2590
|
+
case "diff":
|
|
2591
|
+
result = await handleGitDiff(payload);
|
|
2592
|
+
break;
|
|
2593
|
+
case "branches":
|
|
2594
|
+
result = await handleGitBranches();
|
|
2595
|
+
break;
|
|
2596
|
+
case "checkout":
|
|
2597
|
+
result = await handleGitCheckout(payload);
|
|
2598
|
+
break;
|
|
2599
|
+
case "deleteBranch":
|
|
2600
|
+
result = await handleGitDeleteBranch(payload);
|
|
2601
|
+
break;
|
|
2602
|
+
case "pull":
|
|
2603
|
+
result = await handleGitPull();
|
|
2604
|
+
break;
|
|
2605
|
+
case "push":
|
|
2606
|
+
result = await handleGitPush(payload);
|
|
2607
|
+
break;
|
|
2608
|
+
case "discard":
|
|
2609
|
+
result = await handleGitDiscard(payload);
|
|
2610
|
+
break;
|
|
2611
|
+
default:
|
|
2612
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2613
|
+
}
|
|
2614
|
+
break;
|
|
2615
|
+
case "terminal":
|
|
2616
|
+
switch (action) {
|
|
2617
|
+
case "spawn":
|
|
2618
|
+
result = await handleTerminalSpawn(payload);
|
|
2619
|
+
break;
|
|
2620
|
+
case "write":
|
|
2621
|
+
result = handleTerminalWrite(payload);
|
|
2622
|
+
break;
|
|
2623
|
+
case "resize":
|
|
2624
|
+
result = handleTerminalResize(payload);
|
|
2625
|
+
break;
|
|
2626
|
+
case "kill":
|
|
2627
|
+
result = handleTerminalKill(payload);
|
|
2628
|
+
break;
|
|
2629
|
+
case "scroll":
|
|
2630
|
+
result = handleTerminalScroll(payload);
|
|
2631
|
+
break;
|
|
2632
|
+
default:
|
|
2633
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2634
|
+
}
|
|
2635
|
+
break;
|
|
2636
|
+
case "processes":
|
|
2637
|
+
switch (action) {
|
|
2638
|
+
case "list":
|
|
2639
|
+
result = handleProcessesList();
|
|
2640
|
+
break;
|
|
2641
|
+
case "spawn":
|
|
2642
|
+
result = await handleProcessesSpawn(payload);
|
|
2643
|
+
break;
|
|
2644
|
+
case "kill":
|
|
2645
|
+
result = handleProcessesKill(payload);
|
|
2646
|
+
break;
|
|
2647
|
+
case "getOutput":
|
|
2648
|
+
result = handleProcessesGetOutput(payload);
|
|
2649
|
+
break;
|
|
2650
|
+
case "clearOutput":
|
|
2651
|
+
result = handleProcessesClearOutput(payload);
|
|
2652
|
+
break;
|
|
2653
|
+
default:
|
|
2654
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2655
|
+
}
|
|
2656
|
+
break;
|
|
2657
|
+
case "ports":
|
|
2658
|
+
switch (action) {
|
|
2659
|
+
case "list":
|
|
2660
|
+
result = handlePortsList();
|
|
2661
|
+
break;
|
|
2662
|
+
case "isAvailable":
|
|
2663
|
+
result = await handlePortsIsAvailable(payload);
|
|
2664
|
+
break;
|
|
2665
|
+
case "kill":
|
|
2666
|
+
result = handlePortsKill(payload);
|
|
2667
|
+
break;
|
|
2668
|
+
default:
|
|
2669
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2670
|
+
}
|
|
2671
|
+
break;
|
|
2672
|
+
case "monitor":
|
|
2673
|
+
switch (action) {
|
|
2674
|
+
case "system":
|
|
2675
|
+
result = handleMonitorSystem();
|
|
2676
|
+
break;
|
|
2677
|
+
case "cpu":
|
|
2678
|
+
result = handleMonitorCpu();
|
|
2679
|
+
break;
|
|
2680
|
+
case "memory":
|
|
2681
|
+
result = handleMonitorMemory();
|
|
2682
|
+
break;
|
|
2683
|
+
case "disk":
|
|
2684
|
+
result = handleMonitorDisk();
|
|
2685
|
+
break;
|
|
2686
|
+
case "battery":
|
|
2687
|
+
result = handleMonitorBattery();
|
|
2688
|
+
break;
|
|
2689
|
+
default:
|
|
2690
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2691
|
+
}
|
|
2692
|
+
break;
|
|
2693
|
+
case "http":
|
|
2694
|
+
switch (action) {
|
|
2695
|
+
case "request":
|
|
2696
|
+
result = await handleHttpRequest(payload);
|
|
2697
|
+
break;
|
|
2698
|
+
default:
|
|
2699
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2700
|
+
}
|
|
2701
|
+
break;
|
|
2702
|
+
case "ai": {
|
|
2703
|
+
if (!aiManager)
|
|
2704
|
+
throw Object.assign(new Error("AI manager not initialized"), { code: "EUNAVAILABLE" });
|
|
2705
|
+
const backend = (payload.backend === "codex" ? "codex" : "opencode");
|
|
2706
|
+
switch (action) {
|
|
2707
|
+
case "backends":
|
|
2708
|
+
result = { backends: aiManager.availableBackends() };
|
|
2709
|
+
break;
|
|
2710
|
+
case "prompt":
|
|
2711
|
+
result = await aiManager.prompt(backend, payload.sessionId, payload.text, payload.model, payload.agent, payload.files, payload.codexOptions);
|
|
2712
|
+
break;
|
|
2713
|
+
case "createSession":
|
|
2714
|
+
result = await aiManager.createSession(backend, payload.title);
|
|
2715
|
+
break;
|
|
2716
|
+
case "listSessions":
|
|
2717
|
+
result = await aiManager.listAllSessions();
|
|
2718
|
+
break;
|
|
2719
|
+
case "getSession":
|
|
2720
|
+
result = await aiManager.getSession(backend, payload.id);
|
|
2721
|
+
break;
|
|
2722
|
+
case "deleteSession":
|
|
2723
|
+
result = await aiManager.deleteSession(backend, payload.id);
|
|
2724
|
+
break;
|
|
2725
|
+
case "renameSession":
|
|
2726
|
+
result = await aiManager.renameSession(backend, payload.id, payload.title);
|
|
2727
|
+
break;
|
|
2728
|
+
case "getMessages":
|
|
2729
|
+
result = await aiManager.getMessages(backend, payload.id);
|
|
2730
|
+
break;
|
|
2731
|
+
case "statuses":
|
|
2732
|
+
result = await aiManager.statuses(backend);
|
|
2733
|
+
break;
|
|
2734
|
+
case "abort":
|
|
2735
|
+
result = await aiManager.abort(backend, payload.sessionId);
|
|
2736
|
+
break;
|
|
2737
|
+
case "agents":
|
|
2738
|
+
result = await aiManager.agents(backend);
|
|
2739
|
+
break;
|
|
2740
|
+
case "providers":
|
|
2741
|
+
result = await aiManager.providers(backend);
|
|
2742
|
+
break;
|
|
2743
|
+
case "setAuth":
|
|
2744
|
+
result = await aiManager.setAuth(backend, payload.providerId, payload.key);
|
|
2745
|
+
break;
|
|
2746
|
+
case "command":
|
|
2747
|
+
result = await aiManager.command(backend, payload.sessionId, payload.command, payload.arguments || "");
|
|
2748
|
+
break;
|
|
2749
|
+
case "revert":
|
|
2750
|
+
result = await aiManager.revert(backend, payload.sessionId, payload.messageId);
|
|
2751
|
+
break;
|
|
2752
|
+
case "unrevert":
|
|
2753
|
+
result = await aiManager.unrevert(backend, payload.sessionId);
|
|
2754
|
+
break;
|
|
2755
|
+
case "share":
|
|
2756
|
+
result = await aiManager.share(backend, payload.sessionId);
|
|
2757
|
+
break;
|
|
2758
|
+
case "permission": {
|
|
2759
|
+
const r = payload.response;
|
|
2760
|
+
const permResp = r === "once" || r === "always" || r === "reject" ? r : (payload.approved ? "once" : "reject");
|
|
2761
|
+
result = await aiManager.permissionReply(backend, payload.sessionId, payload.permissionId, permResp);
|
|
2762
|
+
break;
|
|
2763
|
+
}
|
|
2764
|
+
case "questionReply":
|
|
2765
|
+
result = await aiManager.questionReply(backend, payload.sessionId, payload.questionId, payload.answers || []);
|
|
2766
|
+
break;
|
|
2767
|
+
case "questionReject":
|
|
2768
|
+
result = await aiManager.questionReject(backend, payload.sessionId, payload.questionId);
|
|
2769
|
+
break;
|
|
2770
|
+
default:
|
|
2771
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2772
|
+
}
|
|
2773
|
+
break;
|
|
2774
|
+
}
|
|
2775
|
+
case "proxy":
|
|
2776
|
+
switch (action) {
|
|
2777
|
+
case "connect":
|
|
2778
|
+
result = await handleProxyConnect(payload);
|
|
2779
|
+
break;
|
|
2780
|
+
case "getState":
|
|
2781
|
+
result = await getProxyState();
|
|
2782
|
+
break;
|
|
2783
|
+
case "trackPort":
|
|
2784
|
+
result = await handleTrackProxyPort(payload);
|
|
2785
|
+
break;
|
|
2786
|
+
case "untrackPort":
|
|
2787
|
+
result = await handleUntrackProxyPort(payload);
|
|
2788
|
+
break;
|
|
2789
|
+
default:
|
|
2790
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
2791
|
+
}
|
|
2792
|
+
break;
|
|
2793
|
+
default:
|
|
2794
|
+
throw Object.assign(new Error(`Unknown namespace: ${ns}`), { code: "EINVAL" });
|
|
2795
|
+
}
|
|
2796
|
+
const response = { v: 1, id, ns, action, ok: true, payload: result };
|
|
2797
|
+
logWithTimestamp("router", "request completed", {
|
|
2798
|
+
id,
|
|
2799
|
+
ns,
|
|
2800
|
+
action,
|
|
2801
|
+
path: pathValue,
|
|
2802
|
+
durationMs: Date.now() - startedAt,
|
|
2803
|
+
ok: true,
|
|
2804
|
+
});
|
|
2805
|
+
return response;
|
|
2806
|
+
}
|
|
2807
|
+
catch (error) {
|
|
2808
|
+
const err = error;
|
|
2809
|
+
if (DEBUG_MODE) {
|
|
2810
|
+
console.error(`[router] ${ns}.${action} error:`, err.code || "ERROR", err.message);
|
|
2811
|
+
}
|
|
2812
|
+
logWithTimestamp("router", "request failed", {
|
|
2813
|
+
id,
|
|
2814
|
+
ns,
|
|
2815
|
+
action,
|
|
2816
|
+
path: pathValue,
|
|
2817
|
+
durationMs: Date.now() - startedAt,
|
|
2818
|
+
code: err.code || "ERROR",
|
|
2819
|
+
message: err.message || "Unknown error",
|
|
2820
|
+
});
|
|
2821
|
+
return {
|
|
2822
|
+
v: 1,
|
|
2823
|
+
id,
|
|
2824
|
+
ns,
|
|
2825
|
+
action,
|
|
2826
|
+
ok: false,
|
|
2827
|
+
payload: {},
|
|
2828
|
+
error: {
|
|
2829
|
+
code: err.code || "ERROR",
|
|
2830
|
+
message: err.message || "Unknown error",
|
|
2831
|
+
},
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
let currentReattachGeneration = null;
|
|
2836
|
+
function normalizeGatewayUrl(input) {
|
|
2837
|
+
const raw = input.trim();
|
|
2838
|
+
if (!raw) {
|
|
2839
|
+
throw new Error("Gateway URL is required");
|
|
2840
|
+
}
|
|
2841
|
+
if (raw.toLowerCase().startsWith("http://") || raw.toLowerCase().startsWith("ws://")) {
|
|
2842
|
+
throw new Error("Insecure gateway protocol is not allowed; use https://");
|
|
2843
|
+
}
|
|
2844
|
+
const withScheme = /^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
2845
|
+
let parsed;
|
|
2846
|
+
try {
|
|
2847
|
+
parsed = new URL(withScheme);
|
|
2848
|
+
}
|
|
2849
|
+
catch {
|
|
2850
|
+
throw new Error(`Invalid gateway URL: ${input}`);
|
|
2851
|
+
}
|
|
2852
|
+
if (parsed.protocol !== "https:") {
|
|
2853
|
+
throw new Error("Gateway URL must use https://");
|
|
2854
|
+
}
|
|
2855
|
+
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
|
|
2856
|
+
return `${parsed.protocol}//${parsed.host}${path}`;
|
|
2857
|
+
}
|
|
2858
|
+
async function createQrCode() {
|
|
2859
|
+
const response = await fetch(`${MANAGER_URL}/v2/qr`);
|
|
2860
|
+
if (!response.ok) {
|
|
2861
|
+
throw new Error(`Failed to create QR code from manager: ${response.status}`);
|
|
2862
|
+
}
|
|
2863
|
+
return (await response.json());
|
|
2864
|
+
}
|
|
2865
|
+
async function assembleWithCode(code) {
|
|
2866
|
+
const wsUrl = `${MANAGER_URL.replace(/^https:/, "wss:")}/v2/assemble?code=${encodeURIComponent(code)}&role=cli`;
|
|
2867
|
+
return await new Promise((resolve, reject) => {
|
|
2868
|
+
const ws = new WebSocket(wsUrl);
|
|
2869
|
+
let settled = false;
|
|
2870
|
+
const fail = (error) => {
|
|
2871
|
+
if (settled)
|
|
2872
|
+
return;
|
|
2873
|
+
settled = true;
|
|
2874
|
+
try {
|
|
2875
|
+
ws.close();
|
|
2876
|
+
}
|
|
2877
|
+
catch {
|
|
2878
|
+
// ignore
|
|
2879
|
+
}
|
|
2880
|
+
reject(error);
|
|
2881
|
+
};
|
|
2882
|
+
ws.on("message", (data) => {
|
|
2883
|
+
try {
|
|
2884
|
+
const parsed = JSON.parse(data.toString());
|
|
2885
|
+
if (parsed.type !== "assembled" || typeof parsed.code !== "string" || typeof parsed.password !== "string") {
|
|
2886
|
+
fail(new Error("Invalid assemble payload"));
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
if (settled)
|
|
2890
|
+
return;
|
|
2891
|
+
settled = true;
|
|
2892
|
+
ws.send(JSON.stringify({ type: "ack" }));
|
|
2893
|
+
resolve({ code: parsed.code, password: parsed.password });
|
|
2894
|
+
}
|
|
2895
|
+
catch (error) {
|
|
2896
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
2897
|
+
}
|
|
2898
|
+
});
|
|
2899
|
+
ws.on("close", (codeValue, reason) => {
|
|
2900
|
+
if (settled)
|
|
2901
|
+
return;
|
|
2902
|
+
fail(new Error(`Assemble socket closed (${codeValue}: ${reason.toString()})`));
|
|
2903
|
+
});
|
|
2904
|
+
ws.on("error", (error) => {
|
|
2905
|
+
fail(new Error(`Assemble socket error: ${error.message}`));
|
|
2906
|
+
});
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
async function getAssignedProxyUrl(password) {
|
|
2910
|
+
const url = new URL("/v2/proxy", MANAGER_URL);
|
|
2911
|
+
url.searchParams.set("password", password);
|
|
2912
|
+
const response = await fetch(url);
|
|
2913
|
+
if (!response.ok) {
|
|
2914
|
+
let message = `Failed to get proxy from manager: ${response.status}`;
|
|
2915
|
+
try {
|
|
2916
|
+
const payload = await response.json();
|
|
2917
|
+
if (payload.error) {
|
|
2918
|
+
message = payload.error;
|
|
2919
|
+
}
|
|
2920
|
+
else if (payload.reason) {
|
|
2921
|
+
message = payload.reason;
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
catch {
|
|
2925
|
+
// ignore parse failures and use the fallback message
|
|
2926
|
+
}
|
|
2927
|
+
throw new Error(message);
|
|
2928
|
+
}
|
|
2929
|
+
const payload = await response.json();
|
|
2930
|
+
if (typeof payload.proxyUrl !== "string" || !payload.proxyUrl) {
|
|
2931
|
+
throw new Error("Manager returned invalid proxy assignment");
|
|
2932
|
+
}
|
|
2933
|
+
return normalizeGatewayUrl(payload.proxyUrl);
|
|
2934
|
+
}
|
|
2935
|
+
async function claimReattach(password) {
|
|
2936
|
+
const response = await fetch(new URL("/v2/reattach/claim", MANAGER_URL), {
|
|
2937
|
+
method: "POST",
|
|
2938
|
+
headers: { "Content-Type": "application/json" },
|
|
2939
|
+
body: JSON.stringify({
|
|
2940
|
+
password,
|
|
2941
|
+
role: "cli",
|
|
2942
|
+
}),
|
|
2943
|
+
});
|
|
2944
|
+
if (!response.ok) {
|
|
2945
|
+
let message = `Failed to claim reattach from manager: ${response.status}`;
|
|
2946
|
+
try {
|
|
2947
|
+
const payload = await response.json();
|
|
2948
|
+
if (payload.error) {
|
|
2949
|
+
message = payload.error;
|
|
2950
|
+
}
|
|
2951
|
+
else if (payload.reason) {
|
|
2952
|
+
message = payload.reason;
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
catch {
|
|
2956
|
+
// ignore parse failures and use the fallback message
|
|
2957
|
+
}
|
|
2958
|
+
throw new Error(message);
|
|
2959
|
+
}
|
|
2960
|
+
const payload = await response.json();
|
|
2961
|
+
if (typeof payload.proxyUrl !== "string" || !payload.proxyUrl) {
|
|
2962
|
+
throw new Error("Manager returned invalid reattach proxy assignment");
|
|
2963
|
+
}
|
|
2964
|
+
if (typeof payload.generation !== "number" || !Number.isFinite(payload.generation) || payload.generation < 1) {
|
|
2965
|
+
throw new Error("Manager returned invalid reattach generation");
|
|
2966
|
+
}
|
|
2967
|
+
if (typeof payload.expiresAt !== "number" || !Number.isFinite(payload.expiresAt)) {
|
|
2968
|
+
throw new Error("Manager returned invalid reattach expiry");
|
|
2969
|
+
}
|
|
2970
|
+
return {
|
|
2971
|
+
proxyUrl: normalizeGatewayUrl(payload.proxyUrl),
|
|
2972
|
+
generation: payload.generation,
|
|
2973
|
+
expiresAt: payload.expiresAt,
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
async function revokePassword(password, reason = "revoked by cli --new") {
|
|
2977
|
+
const response = await fetch(new URL("/v2/revoke", MANAGER_URL), {
|
|
2978
|
+
method: "POST",
|
|
2979
|
+
headers: { "Content-Type": "application/json" },
|
|
2980
|
+
body: JSON.stringify({ password, reason }),
|
|
2981
|
+
});
|
|
2982
|
+
if (response.ok || response.status === 404) {
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
let message = `Failed to revoke previous session: ${response.status}`;
|
|
2986
|
+
try {
|
|
2987
|
+
const payload = await response.json();
|
|
2988
|
+
if (payload.error) {
|
|
2989
|
+
message = payload.error;
|
|
2990
|
+
}
|
|
2991
|
+
else if (payload.reason) {
|
|
2992
|
+
message = payload.reason;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
catch {
|
|
2996
|
+
// ignore parse failures and use the fallback message
|
|
2997
|
+
}
|
|
2998
|
+
throw new Error(message);
|
|
2999
|
+
}
|
|
3000
|
+
function displayQR(code) {
|
|
3001
|
+
console.log("\n");
|
|
3002
|
+
qrcode.generate(code, { small: true }, (qr) => {
|
|
3003
|
+
console.log(qr);
|
|
3004
|
+
console.log(`\n Session code: ${code}\n`);
|
|
3005
|
+
console.log(` Root directory: ${ROOT_DIR}\n`);
|
|
3006
|
+
console.log(" Scan the QR code with the Jukto app to connect.");
|
|
3007
|
+
console.log(" Press Ctrl+C to exit.\n");
|
|
3008
|
+
});
|
|
3009
|
+
}
|
|
3010
|
+
function supportsAnsiStyles() {
|
|
3011
|
+
if (!process.stdout.isTTY)
|
|
3012
|
+
return false;
|
|
3013
|
+
if (process.env.NO_COLOR)
|
|
3014
|
+
return false;
|
|
3015
|
+
if (process.env.FORCE_COLOR)
|
|
3016
|
+
return true;
|
|
3017
|
+
if (process.platform !== "win32")
|
|
3018
|
+
return true;
|
|
3019
|
+
return Boolean(process.env.WT_SESSION ||
|
|
3020
|
+
process.env.ANSICON ||
|
|
3021
|
+
process.env.ConEmuANSI === "ON" ||
|
|
3022
|
+
process.env.TERM_PROGRAM ||
|
|
3023
|
+
process.env.TERM === "xterm-256color");
|
|
3024
|
+
}
|
|
3025
|
+
function displaySavedSessionNotice() {
|
|
3026
|
+
const useAnsiStyles = supportsAnsiStyles();
|
|
3027
|
+
const red = useAnsiStyles ? "\x1b[31m" : "";
|
|
3028
|
+
const bold = useAnsiStyles ? "\x1b[1m" : "";
|
|
3029
|
+
const reset = useAnsiStyles ? "\x1b[0m" : "";
|
|
3030
|
+
const lines = [
|
|
3031
|
+
"NOTE: You're using an existing session.",
|
|
3032
|
+
"You can open it from the app via Past Sessions and select this session.",
|
|
3033
|
+
"If you want a new QR code for pairing, run: npx jukto-cli -n",
|
|
3034
|
+
];
|
|
3035
|
+
const width = Math.max(...lines.map((line) => line.length));
|
|
3036
|
+
const border = `+${"-".repeat(width + 2)}+`;
|
|
3037
|
+
console.log("");
|
|
3038
|
+
console.log(`${red}${border}${reset}`);
|
|
3039
|
+
for (const line of lines) {
|
|
3040
|
+
console.log(`${red}|${reset} ${bold}${line.padEnd(width, " ")}${reset} ${red}|${reset}`);
|
|
3041
|
+
}
|
|
3042
|
+
console.log(`${red}${border}${reset}`);
|
|
3043
|
+
console.log("");
|
|
3044
|
+
}
|
|
3045
|
+
function isCommandAvailable(command) {
|
|
3046
|
+
const probe = spawnSync(command, ["--version"], {
|
|
3047
|
+
stdio: "ignore",
|
|
3048
|
+
shell: process.platform === "win32",
|
|
3049
|
+
});
|
|
3050
|
+
const err = probe.error;
|
|
3051
|
+
if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
|
|
3052
|
+
return false;
|
|
3053
|
+
}
|
|
3054
|
+
return !err;
|
|
3055
|
+
}
|
|
3056
|
+
function askYesNo(question, defaultValue = false) {
|
|
3057
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
3058
|
+
return Promise.resolve(false);
|
|
3059
|
+
return new Promise((resolve) => {
|
|
3060
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3061
|
+
const suffix = defaultValue ? " [Y/n] " : " [y/N] ";
|
|
3062
|
+
rl.question(`${question}${suffix}`, (answer) => {
|
|
3063
|
+
rl.close();
|
|
3064
|
+
const normalized = answer.trim().toLowerCase();
|
|
3065
|
+
if (!normalized) {
|
|
3066
|
+
resolve(defaultValue);
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
resolve(normalized === "y" || normalized === "yes");
|
|
3070
|
+
});
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
function installLatestNpmPackage(pkg) {
|
|
3074
|
+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
3075
|
+
const result = spawnSync(npmCommand, ["install", "-g", `${pkg}@latest`], {
|
|
3076
|
+
stdio: "inherit",
|
|
3077
|
+
shell: process.platform === "win32",
|
|
3078
|
+
env: process.env,
|
|
3079
|
+
});
|
|
3080
|
+
return !result.error && result.status === 0;
|
|
3081
|
+
}
|
|
3082
|
+
async function ensureAiCliRuntimes() {
|
|
3083
|
+
const missingBackends = Object.keys(AI_RUNTIME_INSTALL_CANDIDATES)
|
|
3084
|
+
.filter((backend) => !isCommandAvailable(backend));
|
|
3085
|
+
if (missingBackends.length === 0)
|
|
3086
|
+
return;
|
|
3087
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
3088
|
+
console.warn(`[ai] Missing runtimes: ${missingBackends.join(", ")}. Run in an interactive shell to install them.`);
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
const installPrompt = `Missing AI runtimes (${missingBackends.join(", ")}). Install latest versions now?`;
|
|
3092
|
+
const approved = await askYesNo(installPrompt, false);
|
|
3093
|
+
if (!approved) {
|
|
3094
|
+
console.warn("[ai] Skipping AI runtime installation.");
|
|
3095
|
+
return;
|
|
3096
|
+
}
|
|
3097
|
+
for (const backend of missingBackends) {
|
|
3098
|
+
if (isCommandAvailable(backend))
|
|
3099
|
+
continue;
|
|
3100
|
+
const candidates = AI_RUNTIME_INSTALL_CANDIDATES[backend];
|
|
3101
|
+
let installed = false;
|
|
3102
|
+
for (const pkg of candidates) {
|
|
3103
|
+
console.log(`[ai] Installing ${backend} via npm package ${pkg}@latest...`);
|
|
3104
|
+
if (!installLatestNpmPackage(pkg))
|
|
3105
|
+
continue;
|
|
3106
|
+
if (isCommandAvailable(backend)) {
|
|
3107
|
+
installed = true;
|
|
3108
|
+
console.log(`[ai] ${backend} installed successfully.`);
|
|
3109
|
+
break;
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
if (!installed) {
|
|
3113
|
+
console.warn(`[ai] Failed to install ${backend}. You can install it manually and restart the CLI.`);
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
function gracefulShutdown() {
|
|
3118
|
+
shuttingDown = true;
|
|
3119
|
+
console.log("\nShutting down...");
|
|
3120
|
+
void aiManager?.destroy();
|
|
3121
|
+
stopPortSync();
|
|
3122
|
+
for (const trackedDir of trackedEditorDirectories.values()) {
|
|
3123
|
+
trackedDir.watcher.close();
|
|
3124
|
+
}
|
|
3125
|
+
trackedEditorDirectories.clear();
|
|
3126
|
+
trackedEditorFiles.clear();
|
|
3127
|
+
pendingTrackedFileChecks.clear();
|
|
3128
|
+
activeV2Transport?.close();
|
|
3129
|
+
activeV2Transport = null;
|
|
3130
|
+
if (ptyProcess) {
|
|
3131
|
+
ptyProcess.kill();
|
|
3132
|
+
ptyProcess = null;
|
|
3133
|
+
}
|
|
3134
|
+
terminals.clear();
|
|
3135
|
+
for (const [pid, managedProc] of processes) {
|
|
3136
|
+
managedProc.proc.kill();
|
|
3137
|
+
}
|
|
3138
|
+
processes.clear();
|
|
3139
|
+
processOutputBuffers.clear();
|
|
3140
|
+
cleanupAllTunnels();
|
|
3141
|
+
process.exit(0);
|
|
3142
|
+
}
|
|
3143
|
+
function startAiManagerInBackground() {
|
|
3144
|
+
if (aiManager || aiManagerInitPromise)
|
|
3145
|
+
return;
|
|
3146
|
+
aiManagerInitPromise = (async () => {
|
|
3147
|
+
try {
|
|
3148
|
+
const manager = await createAiManager();
|
|
3149
|
+
if (shuttingDown) {
|
|
3150
|
+
await manager.destroy();
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
aiManager = manager;
|
|
3154
|
+
aiManager.subscribe((backend, event) => {
|
|
3155
|
+
emitAppEvent({
|
|
3156
|
+
v: 1,
|
|
3157
|
+
id: `evt-${Date.now()}`,
|
|
3158
|
+
ns: "ai",
|
|
3159
|
+
action: "event",
|
|
3160
|
+
payload: { ...event, backend },
|
|
3161
|
+
});
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
catch (error) {
|
|
3165
|
+
if (DEBUG_MODE) {
|
|
3166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3167
|
+
console.error(`[ai] background init failed: ${message}`);
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
finally {
|
|
3171
|
+
aiManagerInitPromise = null;
|
|
3172
|
+
}
|
|
3173
|
+
})();
|
|
3174
|
+
}
|
|
3175
|
+
async function connectWebSocketV2() {
|
|
3176
|
+
const gatewayUrl = currentPrimaryGateway;
|
|
3177
|
+
if (!currentSessionPassword) {
|
|
3178
|
+
throw new Error("missing password for websocket connect");
|
|
3179
|
+
}
|
|
3180
|
+
console.log(`Connecting to gateway ${gatewayUrl}...`);
|
|
3181
|
+
activeGatewayUrl = gatewayUrl;
|
|
3182
|
+
const transport = new V2SessionTransport({
|
|
3183
|
+
gatewayUrl,
|
|
3184
|
+
password: currentSessionPassword,
|
|
3185
|
+
sessionSecret: currentSessionPassword,
|
|
3186
|
+
generation: currentReattachGeneration,
|
|
3187
|
+
role: "cli",
|
|
3188
|
+
debugLog: DEBUG_MODE ? debugLog : undefined,
|
|
3189
|
+
handlers: {
|
|
3190
|
+
onSystemMessage: async (message) => {
|
|
3191
|
+
if (message.type === "connected")
|
|
3192
|
+
return;
|
|
3193
|
+
if (message.type === "peer_connected") {
|
|
3194
|
+
console.log("App connected!\n");
|
|
3195
|
+
void publishDiscoveredPorts(true);
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
if (message.type === "peer_disconnected") {
|
|
3199
|
+
console.log("App disconnected. Waiting for reconnect window.\n");
|
|
3200
|
+
stopPortSync();
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
if (message.type === "app_disconnected") {
|
|
3204
|
+
if (message.reconnectDeadline) {
|
|
3205
|
+
console.log(`[session] app disconnected, waiting until ${new Date(message.reconnectDeadline).toISOString()}`);
|
|
3206
|
+
}
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
if (message.type === "close_connection") {
|
|
3210
|
+
const reason = message.reason || "expired";
|
|
3211
|
+
console.log(`[session] closed by gateway: ${reason}`);
|
|
3212
|
+
if (reason === "session ended from app") {
|
|
3213
|
+
console.log("[session] Run `npx jukto-cli` again and scan the new QR code to reconnect.");
|
|
3214
|
+
}
|
|
3215
|
+
gracefulShutdown();
|
|
3216
|
+
}
|
|
3217
|
+
},
|
|
3218
|
+
onProtocolRequest: async (message) => {
|
|
3219
|
+
return await processMessage(message);
|
|
3220
|
+
},
|
|
3221
|
+
onProtocolResponse: async () => {
|
|
3222
|
+
// CLI does not currently await app responses outside request/reply routing.
|
|
3223
|
+
},
|
|
3224
|
+
onProtocolEvent: async (message) => {
|
|
3225
|
+
await processMessage(message);
|
|
3226
|
+
},
|
|
3227
|
+
onClose: (reason) => {
|
|
3228
|
+
if (shuttingDown)
|
|
3229
|
+
return;
|
|
3230
|
+
stopPortSync();
|
|
3231
|
+
cleanupAllTunnels();
|
|
3232
|
+
activeV2Transport = null;
|
|
3233
|
+
setTimeout(() => {
|
|
3234
|
+
if (shuttingDown)
|
|
3235
|
+
return;
|
|
3236
|
+
void handleConnectionDrop(reason);
|
|
3237
|
+
}, 50);
|
|
3238
|
+
},
|
|
3239
|
+
},
|
|
3240
|
+
});
|
|
3241
|
+
activeV2Transport = transport;
|
|
3242
|
+
await transport.connect();
|
|
3243
|
+
startPortSync();
|
|
3244
|
+
console.log("Connected to gateway (single secure session).\n");
|
|
3245
|
+
}
|
|
3246
|
+
async function handleConnectionDrop(reason) {
|
|
3247
|
+
if (shuttingDown)
|
|
3248
|
+
return;
|
|
3249
|
+
console.log(`\nDisconnected: ${reason}`);
|
|
3250
|
+
if (!currentSessionPassword) {
|
|
3251
|
+
console.error("No reconnect password available. Exiting.");
|
|
3252
|
+
gracefulShutdown();
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
let attempt = 0;
|
|
3256
|
+
while (!shuttingDown) {
|
|
3257
|
+
attempt += 1;
|
|
3258
|
+
const base = Math.min(250 * 2 ** (attempt - 1), 30_000);
|
|
3259
|
+
const delayMs = Math.round(base * (0.8 + Math.random() * 0.4));
|
|
3260
|
+
try {
|
|
3261
|
+
const reattach = await claimReattach(currentSessionPassword);
|
|
3262
|
+
currentPrimaryGateway = reattach.proxyUrl;
|
|
3263
|
+
currentReattachGeneration = reattach.generation;
|
|
3264
|
+
await connectWebSocketV2();
|
|
3265
|
+
debugLog(`[reconnect] connected via ${activeGatewayUrl}`);
|
|
3266
|
+
return;
|
|
3267
|
+
}
|
|
3268
|
+
catch (err) {
|
|
3269
|
+
if (DEBUG_MODE)
|
|
3270
|
+
console.error(`[reconnect] attempt ${attempt} failed: ${err.message}`);
|
|
3271
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
async function main() {
|
|
3276
|
+
if (SHOW_HELP) {
|
|
3277
|
+
printHelp();
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
console.log("Jukto CLI v" + VERSION);
|
|
3281
|
+
console.log("=".repeat(20) + "\n");
|
|
3282
|
+
if (EXTRA_PORTS.length > 0) {
|
|
3283
|
+
console.log(`Extra ports enabled: ${EXTRA_PORTS.join(", ")}`);
|
|
3284
|
+
}
|
|
3285
|
+
let usedSavedSession = false;
|
|
3286
|
+
try {
|
|
3287
|
+
const cliConfig = await getCliConfig();
|
|
3288
|
+
const savedSession = getSavedSessionForRoot(cliConfig, ROOT_DIR);
|
|
3289
|
+
debugLog("Checking PTY runtime...");
|
|
3290
|
+
const ptyBinaryPath = await ensurePtyBinaryReady();
|
|
3291
|
+
if (ptyBinaryPath) {
|
|
3292
|
+
debugLog("PTY runtime ready.\n");
|
|
3293
|
+
}
|
|
3294
|
+
else {
|
|
3295
|
+
debugLog(`PTY runtime unsupported on ${os.platform()}/${os.arch()}. Skipping prefetch.\n`);
|
|
3296
|
+
}
|
|
3297
|
+
await ensureAiCliRuntimes();
|
|
3298
|
+
// Start AI backends in the background so missing or slow AI runtimes never
|
|
3299
|
+
// block QR/session startup for the rest of the CLI.
|
|
3300
|
+
startAiManagerInBackground();
|
|
3301
|
+
let sessionCodeToUse = null;
|
|
3302
|
+
let sessionPasswordToUse;
|
|
3303
|
+
if (!FORCE_NEW_CODE && savedSession) {
|
|
3304
|
+
console.log(`Using saved session for ${ROOT_DIR}`);
|
|
3305
|
+
displaySavedSessionNotice();
|
|
3306
|
+
sessionCodeToUse = savedSession.sessionCode;
|
|
3307
|
+
sessionPasswordToUse = savedSession.sessionPassword;
|
|
3308
|
+
usedSavedSession = true;
|
|
3309
|
+
}
|
|
3310
|
+
else {
|
|
3311
|
+
if (FORCE_NEW_CODE && savedSession?.sessionPassword) {
|
|
3312
|
+
await revokePassword(savedSession.sessionPassword);
|
|
3313
|
+
await clearSavedSessionForRoot();
|
|
3314
|
+
}
|
|
3315
|
+
const qr = await createQrCode();
|
|
3316
|
+
currentSessionCode = qr.code;
|
|
3317
|
+
displayQR(qr.code);
|
|
3318
|
+
const assembled = await assembleWithCode(qr.code);
|
|
3319
|
+
sessionCodeToUse = assembled.code;
|
|
3320
|
+
sessionPasswordToUse = assembled.password;
|
|
3321
|
+
await saveSessionForRoot(sessionCodeToUse, sessionPasswordToUse);
|
|
3322
|
+
}
|
|
3323
|
+
currentSessionCode = sessionCodeToUse;
|
|
3324
|
+
currentSessionPassword = sessionPasswordToUse;
|
|
3325
|
+
if (usedSavedSession) {
|
|
3326
|
+
const reattach = await claimReattach(sessionPasswordToUse);
|
|
3327
|
+
currentPrimaryGateway = reattach.proxyUrl;
|
|
3328
|
+
currentReattachGeneration = reattach.generation;
|
|
3329
|
+
}
|
|
3330
|
+
else {
|
|
3331
|
+
currentPrimaryGateway = await getAssignedProxyUrl(sessionPasswordToUse);
|
|
3332
|
+
currentReattachGeneration = null;
|
|
3333
|
+
}
|
|
3334
|
+
activeGatewayUrl = currentPrimaryGateway;
|
|
3335
|
+
await connectWebSocketV2();
|
|
3336
|
+
}
|
|
3337
|
+
catch (error) {
|
|
3338
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3339
|
+
if (usedSavedSession &&
|
|
3340
|
+
/invalid|revoked|not found|expired|password invalid|password revoked/i.test(message)) {
|
|
3341
|
+
await clearSavedSessionForRoot().catch(() => { });
|
|
3342
|
+
}
|
|
3343
|
+
if (error instanceof Error) {
|
|
3344
|
+
console.error(`Error: ${error.message}`);
|
|
3345
|
+
if (DEBUG_MODE && error.stack)
|
|
3346
|
+
console.error(error.stack);
|
|
3347
|
+
}
|
|
3348
|
+
else {
|
|
3349
|
+
console.error("An unexpected error occurred");
|
|
3350
|
+
}
|
|
3351
|
+
process.exit(1);
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
process.on("SIGINT", gracefulShutdown);
|
|
3355
|
+
process.on("SIGTERM", gracefulShutdown);
|
|
3356
|
+
main();
|