terminalos 0.4.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 +324 -0
- package/bin/cli.js +28 -0
- package/build/assets/html2pdf-S6WA7sS3.js +240 -0
- package/build/assets/index-CfXPiaFw.css +32 -0
- package/build/assets/index-DfoqUTmD.js +141 -0
- package/build/index.html +17 -0
- package/electron/fs-watcher.js +181 -0
- package/electron/fs-watcher.ts +183 -0
- package/electron/main.js +245 -0
- package/electron/main.ts +241 -0
- package/electron/preload.js +77 -0
- package/electron/preload.ts +105 -0
- package/electron/process-detector.js +63 -0
- package/electron/process-detector.ts +70 -0
- package/electron/pty-manager.js +188 -0
- package/electron/pty-manager.ts +181 -0
- package/electron/tsconfig.json +14 -0
- package/electron/versions-manager.js +74 -0
- package/electron/versions-manager.ts +98 -0
- package/electron/window-state.js +49 -0
- package/electron/window-state.ts +52 -0
- package/package.json +104 -0
- package/runtime-dist/server.js +626 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* terminalOS web runtime server.
|
|
4
|
+
* Started by `npx terminalOS --run`. Serves the pre-built React frontend and
|
|
5
|
+
* handles all terminal/FS operations over WebSocket so the user's local shell
|
|
6
|
+
* and files are used — nothing runs on a remote server.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
42
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
43
|
+
};
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.startServer = startServer;
|
|
46
|
+
const express_1 = __importDefault(require("express"));
|
|
47
|
+
const http_1 = require("http");
|
|
48
|
+
const ws_1 = require("ws");
|
|
49
|
+
const path_1 = __importDefault(require("path"));
|
|
50
|
+
const os_1 = __importDefault(require("os"));
|
|
51
|
+
const fs_1 = __importDefault(require("fs"));
|
|
52
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
53
|
+
const child_process_1 = require("child_process");
|
|
54
|
+
const util_1 = require("util");
|
|
55
|
+
const pty = __importStar(require("node-pty"));
|
|
56
|
+
const uuid_1 = require("uuid");
|
|
57
|
+
const chokidar_1 = __importDefault(require("chokidar"));
|
|
58
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
59
|
+
// Polyfill DOMMatrix for pdf-parse in Node.js
|
|
60
|
+
if (typeof globalThis.DOMMatrix === 'undefined') {
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
;
|
|
63
|
+
globalThis.DOMMatrix = class DOMMatrix {
|
|
64
|
+
constructor(_init) {
|
|
65
|
+
this.a = 1;
|
|
66
|
+
this.b = 0;
|
|
67
|
+
this.c = 0;
|
|
68
|
+
this.d = 1;
|
|
69
|
+
this.e = 0;
|
|
70
|
+
this.f = 0;
|
|
71
|
+
this.m11 = 1;
|
|
72
|
+
this.m12 = 0;
|
|
73
|
+
this.m13 = 0;
|
|
74
|
+
this.m14 = 0;
|
|
75
|
+
this.m21 = 0;
|
|
76
|
+
this.m22 = 1;
|
|
77
|
+
this.m23 = 0;
|
|
78
|
+
this.m24 = 0;
|
|
79
|
+
this.m31 = 0;
|
|
80
|
+
this.m32 = 0;
|
|
81
|
+
this.m33 = 1;
|
|
82
|
+
this.m34 = 0;
|
|
83
|
+
this.m41 = 0;
|
|
84
|
+
this.m42 = 0;
|
|
85
|
+
this.m43 = 0;
|
|
86
|
+
this.m44 = 1;
|
|
87
|
+
this.is2D = true;
|
|
88
|
+
this.isIdentity = true;
|
|
89
|
+
}
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
static fromFloat32Array() { return new globalThis.DOMMatrix(); }
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
93
|
+
static fromFloat64Array() { return new globalThis.DOMMatrix(); }
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
static fromMatrix() { return new globalThis.DOMMatrix(); }
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
multiply() { return new globalThis.DOMMatrix(); }
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
translate() { return new globalThis.DOMMatrix(); }
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
+
scale() { return new globalThis.DOMMatrix(); }
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
103
|
+
rotate() { return new globalThis.DOMMatrix(); }
|
|
104
|
+
toFloat32Array() { return new Float32Array(16); }
|
|
105
|
+
toFloat64Array() { return new Float64Array(16); }
|
|
106
|
+
toString() { return 'matrix(1, 0, 0, 1, 0, 0)'; }
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
110
|
+
const pdfParse = require('pdf-parse');
|
|
111
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
112
|
+
const mammoth = require('mammoth');
|
|
113
|
+
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
114
|
+
const AI_SIGNATURES = [
|
|
115
|
+
{ pattern: /claude\s+code/i, name: 'claude code', color: '#D4A27F' },
|
|
116
|
+
{ pattern: /opencode/i, name: 'opencode', color: '#7FB5D4' },
|
|
117
|
+
{ pattern: /aider/i, name: 'aider', color: '#A27FD4' },
|
|
118
|
+
{ pattern: /continue/i, name: 'continue', color: '#7FD4A2' },
|
|
119
|
+
{ pattern: /\$\s*claude\b/, name: 'claude code', color: '#D4A27F' },
|
|
120
|
+
];
|
|
121
|
+
const ANSI_RE = /\x1b\[[0-9;]*[mGKHFABCDJsu]|\x1b\][^\x07]*\x07|\x1b[()][AB012]/g;
|
|
122
|
+
const SHELL_PROMPT_PATTERN = /(?:^|\n)\s{0,6}[$%❯>]\s{0,2}$/;
|
|
123
|
+
class ProcessDetector {
|
|
124
|
+
constructor() {
|
|
125
|
+
this.slidingWindow = '';
|
|
126
|
+
this.windowSize = 2048;
|
|
127
|
+
this.currentAI = null;
|
|
128
|
+
this.hasAI = false;
|
|
129
|
+
this.detectedAt = 0;
|
|
130
|
+
this.gracePeriodMs = 3000;
|
|
131
|
+
}
|
|
132
|
+
detect(data) {
|
|
133
|
+
this.slidingWindow = (this.slidingWindow + data).slice(-this.windowSize);
|
|
134
|
+
if (!this.hasAI) {
|
|
135
|
+
for (const sig of AI_SIGNATURES) {
|
|
136
|
+
if (sig.pattern.test(this.slidingWindow)) {
|
|
137
|
+
this.currentAI = { name: sig.name, color: sig.color };
|
|
138
|
+
this.hasAI = true;
|
|
139
|
+
this.detectedAt = Date.now();
|
|
140
|
+
return 'detected';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
if (Date.now() - this.detectedAt < this.gracePeriodMs)
|
|
146
|
+
return null;
|
|
147
|
+
const plain = this.slidingWindow.replace(ANSI_RE, '');
|
|
148
|
+
const lastLines = plain.split('\n').slice(-3).join('\n');
|
|
149
|
+
if (SHELL_PROMPT_PATTERN.test(lastLines)) {
|
|
150
|
+
this.currentAI = null;
|
|
151
|
+
this.hasAI = false;
|
|
152
|
+
return 'exited';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
getCurrentAI() { return this.currentAI; }
|
|
158
|
+
}
|
|
159
|
+
// ─── PTY Manager (callback-based, no Electron) ───────────────────────────────
|
|
160
|
+
function createZdotdir() {
|
|
161
|
+
const zdotdir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'aiterm-'));
|
|
162
|
+
const zprofile = [
|
|
163
|
+
'# aiTerm: source real .zprofile (restores full PATH)',
|
|
164
|
+
'[ -f "$HOME/.zprofile" ] && source "$HOME/.zprofile"',
|
|
165
|
+
].join('\n');
|
|
166
|
+
fs_1.default.writeFileSync(path_1.default.join(zdotdir, '.zprofile'), zprofile);
|
|
167
|
+
const zshrc = [
|
|
168
|
+
'# aiTerm: source real .zshrc first',
|
|
169
|
+
'unset ZDOTDIR',
|
|
170
|
+
'[ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc"',
|
|
171
|
+
'',
|
|
172
|
+
'_aiterm_precmd() {',
|
|
173
|
+
' printf "\\n"',
|
|
174
|
+
' PROMPT=" "',
|
|
175
|
+
' RPROMPT=""',
|
|
176
|
+
' printf "\\033]9001;%s\\007" "${CONDA_DEFAULT_ENV:-}"',
|
|
177
|
+
'}',
|
|
178
|
+
'precmd_functions+=(_aiterm_precmd)',
|
|
179
|
+
'',
|
|
180
|
+
'_aiterm_preexec() {',
|
|
181
|
+
' printf "\\x1b[1A\\x1b[2K \\x1b[1m%s\\x1b[22m\\r\\n" "$1"',
|
|
182
|
+
'}',
|
|
183
|
+
'preexec_functions+=(_aiterm_preexec)',
|
|
184
|
+
'PROMPT=" "',
|
|
185
|
+
'RPROMPT=""',
|
|
186
|
+
].join('\n');
|
|
187
|
+
fs_1.default.writeFileSync(path_1.default.join(zdotdir, '.zshrc'), zshrc);
|
|
188
|
+
return zdotdir;
|
|
189
|
+
}
|
|
190
|
+
class WebPtyManager {
|
|
191
|
+
constructor(send) {
|
|
192
|
+
this.send = send;
|
|
193
|
+
this.sessions = new Map();
|
|
194
|
+
this.resizeTimers = new Map();
|
|
195
|
+
}
|
|
196
|
+
create(opts) {
|
|
197
|
+
const sessionId = (0, uuid_1.v4)();
|
|
198
|
+
const shell = process.platform === 'win32' ? 'cmd.exe' : (process.env.SHELL ?? '/bin/bash');
|
|
199
|
+
const cwd = opts.cwd ?? process.env.HOME ?? '/';
|
|
200
|
+
const isZsh = shell.endsWith('zsh');
|
|
201
|
+
const promptEnv = isZsh ? { ZDOTDIR: createZdotdir() } : { PS1: ' ', PROMPT: ' ' };
|
|
202
|
+
const ptyProcess = pty.spawn(shell, ['-l'], {
|
|
203
|
+
name: 'xterm-256color',
|
|
204
|
+
cols: 80,
|
|
205
|
+
rows: 24,
|
|
206
|
+
cwd,
|
|
207
|
+
env: {
|
|
208
|
+
...process.env,
|
|
209
|
+
TERM: 'xterm-256color',
|
|
210
|
+
COLORTERM: 'truecolor',
|
|
211
|
+
...promptEnv,
|
|
212
|
+
...opts.env,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
const detector = new ProcessDetector();
|
|
216
|
+
const session = { id: sessionId, pty: ptyProcess, buffer: [], flushTimer: null, detector };
|
|
217
|
+
ptyProcess.onData((data) => {
|
|
218
|
+
session.buffer.push(data);
|
|
219
|
+
if (!session.flushTimer) {
|
|
220
|
+
session.flushTimer = setTimeout(() => {
|
|
221
|
+
if (session.buffer.length > 0) {
|
|
222
|
+
const chunk = session.buffer.join('');
|
|
223
|
+
session.buffer = [];
|
|
224
|
+
session.flushTimer = null;
|
|
225
|
+
this.send('pty:data', [sessionId, chunk]);
|
|
226
|
+
const result = detector.detect(chunk);
|
|
227
|
+
if (result === 'detected') {
|
|
228
|
+
const ai = detector.getCurrentAI();
|
|
229
|
+
if (ai)
|
|
230
|
+
this.send('pty:ai-detected', [sessionId, ai]);
|
|
231
|
+
}
|
|
232
|
+
else if (result === 'exited') {
|
|
233
|
+
this.send('pty:ai-exited', [sessionId]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}, 1);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
240
|
+
if (session.flushTimer)
|
|
241
|
+
clearTimeout(session.flushTimer);
|
|
242
|
+
this.send('pty:exit', [sessionId, exitCode ?? 0]);
|
|
243
|
+
this.sessions.delete(sessionId);
|
|
244
|
+
});
|
|
245
|
+
this.sessions.set(sessionId, session);
|
|
246
|
+
return sessionId;
|
|
247
|
+
}
|
|
248
|
+
write(sessionId, data) {
|
|
249
|
+
this.sessions.get(sessionId)?.pty.write(data);
|
|
250
|
+
}
|
|
251
|
+
resize(sessionId, cols, rows) {
|
|
252
|
+
const existing = this.resizeTimers.get(sessionId);
|
|
253
|
+
if (existing)
|
|
254
|
+
clearTimeout(existing);
|
|
255
|
+
const timer = setTimeout(() => {
|
|
256
|
+
this.sessions.get(sessionId)?.pty.resize(cols, rows);
|
|
257
|
+
this.resizeTimers.delete(sessionId);
|
|
258
|
+
}, 50);
|
|
259
|
+
this.resizeTimers.set(sessionId, timer);
|
|
260
|
+
}
|
|
261
|
+
kill(sessionId) {
|
|
262
|
+
const session = this.sessions.get(sessionId);
|
|
263
|
+
if (session) {
|
|
264
|
+
if (session.flushTimer)
|
|
265
|
+
clearTimeout(session.flushTimer);
|
|
266
|
+
session.pty.kill();
|
|
267
|
+
this.sessions.delete(sessionId);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
killAll() {
|
|
271
|
+
for (const [id] of this.sessions)
|
|
272
|
+
this.kill(id);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function getContentSize(entryPath, ext) {
|
|
276
|
+
try {
|
|
277
|
+
if (ext === 'pdf') {
|
|
278
|
+
const buffer = await promises_1.default.readFile(entryPath);
|
|
279
|
+
const result = await pdfParse(buffer);
|
|
280
|
+
return result.text.length;
|
|
281
|
+
}
|
|
282
|
+
if (ext === 'docx') {
|
|
283
|
+
const buffer = await promises_1.default.readFile(entryPath);
|
|
284
|
+
const result = await mammoth.extractRawText({ buffer });
|
|
285
|
+
return result.value.length;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch { /* fallback */ }
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
class WebFsWatcher {
|
|
292
|
+
constructor(send) {
|
|
293
|
+
this.send = send;
|
|
294
|
+
this.watcher = null;
|
|
295
|
+
this.watchRoot = null;
|
|
296
|
+
}
|
|
297
|
+
async readDir(dirPath) {
|
|
298
|
+
const resolved = path_1.default.resolve(dirPath);
|
|
299
|
+
const entries = await promises_1.default.readdir(resolved, { withFileTypes: true });
|
|
300
|
+
const result = [];
|
|
301
|
+
await Promise.all(entries.map(async (entry) => {
|
|
302
|
+
const entryPath = path_1.default.join(resolved, entry.name);
|
|
303
|
+
const isDirectory = entry.isDirectory();
|
|
304
|
+
const ext = isDirectory ? '' : path_1.default.extname(entry.name).slice(1).toLowerCase();
|
|
305
|
+
const stat = isDirectory ? null : await promises_1.default.stat(entryPath).catch(() => null);
|
|
306
|
+
const contentSize = isDirectory ? undefined : await getContentSize(entryPath, ext);
|
|
307
|
+
result.push({ name: entry.name, path: entryPath, isDirectory, ext, size: stat?.size, contentSize });
|
|
308
|
+
}));
|
|
309
|
+
result.sort((a, b) => {
|
|
310
|
+
if (a.isDirectory && !b.isDirectory)
|
|
311
|
+
return -1;
|
|
312
|
+
if (!a.isDirectory && b.isDirectory)
|
|
313
|
+
return 1;
|
|
314
|
+
return a.name.localeCompare(b.name);
|
|
315
|
+
});
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
async readFile(filePath) {
|
|
319
|
+
return promises_1.default.readFile(path_1.default.resolve(filePath), 'utf8');
|
|
320
|
+
}
|
|
321
|
+
async writeFile(filePath, content) {
|
|
322
|
+
await promises_1.default.writeFile(path_1.default.resolve(filePath), content, 'utf8');
|
|
323
|
+
}
|
|
324
|
+
async mkdir(dirPath) {
|
|
325
|
+
await promises_1.default.mkdir(path_1.default.resolve(dirPath), { recursive: true });
|
|
326
|
+
}
|
|
327
|
+
async rename(srcPath, destPath) {
|
|
328
|
+
await promises_1.default.rename(path_1.default.resolve(srcPath), path_1.default.resolve(destPath));
|
|
329
|
+
}
|
|
330
|
+
async copyExternal(srcPath, destDir) {
|
|
331
|
+
const src = path_1.default.resolve(srcPath);
|
|
332
|
+
const dest = path_1.default.join(path_1.default.resolve(destDir), path_1.default.basename(src));
|
|
333
|
+
await promises_1.default.cp(src, dest, { recursive: true });
|
|
334
|
+
}
|
|
335
|
+
async delete(targetPath) {
|
|
336
|
+
await promises_1.default.rm(path_1.default.resolve(targetPath), { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
async writeBinaryFile(filePath, base64Data) {
|
|
339
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
340
|
+
await promises_1.default.writeFile(path_1.default.resolve(filePath), buffer);
|
|
341
|
+
}
|
|
342
|
+
setWatchRoot(rootPath) {
|
|
343
|
+
const resolved = path_1.default.resolve(rootPath);
|
|
344
|
+
if (this.watchRoot === resolved)
|
|
345
|
+
return;
|
|
346
|
+
this.watcher?.close();
|
|
347
|
+
this.watchRoot = resolved;
|
|
348
|
+
this.watcher = chokidar_1.default.watch(resolved, {
|
|
349
|
+
ignoreInitial: true,
|
|
350
|
+
ignored: [/(^|[/\\])\../, /node_modules/, /\.git/, /dist/, /build/],
|
|
351
|
+
depth: 5,
|
|
352
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 100 },
|
|
353
|
+
});
|
|
354
|
+
const emit = (type, filePath) => this.send('fs:watch', [{ type, path: filePath }]);
|
|
355
|
+
this.watcher
|
|
356
|
+
.on('add', (p) => emit('add', p))
|
|
357
|
+
.on('addDir', (p) => emit('addDir', p))
|
|
358
|
+
.on('change', (p) => emit('change', p))
|
|
359
|
+
.on('unlink', (p) => emit('unlink', p))
|
|
360
|
+
.on('unlinkDir', (p) => emit('unlinkDir', p));
|
|
361
|
+
}
|
|
362
|
+
close() {
|
|
363
|
+
this.watcher?.close();
|
|
364
|
+
this.watcher = null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// ─── Versions Manager (no Electron, stores in ~/.terminalos/) ────────────────
|
|
368
|
+
const MAX_VERSIONS = 50;
|
|
369
|
+
class WebVersionsManager {
|
|
370
|
+
getVersionsDir() {
|
|
371
|
+
return path_1.default.join(os_1.default.homedir(), '.terminalos', 'md-versions');
|
|
372
|
+
}
|
|
373
|
+
getKey(filePath) {
|
|
374
|
+
return crypto_1.default.createHash('sha256').update(filePath).digest('hex');
|
|
375
|
+
}
|
|
376
|
+
async load(filePath) {
|
|
377
|
+
const vFile = path_1.default.join(this.getVersionsDir(), `${this.getKey(filePath)}.json`);
|
|
378
|
+
try {
|
|
379
|
+
const data = await promises_1.default.readFile(vFile, 'utf8');
|
|
380
|
+
return JSON.parse(data).versions ?? [];
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async persist(filePath, versions) {
|
|
387
|
+
const dir = this.getVersionsDir();
|
|
388
|
+
await promises_1.default.mkdir(dir, { recursive: true });
|
|
389
|
+
const vFile = path_1.default.join(dir, `${this.getKey(filePath)}.json`);
|
|
390
|
+
await promises_1.default.writeFile(vFile, JSON.stringify({ filePath, versions }), 'utf8');
|
|
391
|
+
}
|
|
392
|
+
async saveVersion(filePath, content) {
|
|
393
|
+
const versions = await this.load(filePath);
|
|
394
|
+
if (versions.length > 0 && versions[versions.length - 1].content === content)
|
|
395
|
+
return null;
|
|
396
|
+
const nextVersion = versions.length > 0 ? versions[versions.length - 1].version + 1 : 1;
|
|
397
|
+
const now = Date.now();
|
|
398
|
+
const newVersion = { id: new Date(now).toISOString(), version: nextVersion, timestamp: now, content };
|
|
399
|
+
versions.push(newVersion);
|
|
400
|
+
const pruned = versions.length > MAX_VERSIONS ? versions.slice(versions.length - MAX_VERSIONS) : versions;
|
|
401
|
+
await this.persist(filePath, pruned);
|
|
402
|
+
return { id: newVersion.id, version: newVersion.version, timestamp: newVersion.timestamp };
|
|
403
|
+
}
|
|
404
|
+
async listVersions(filePath) {
|
|
405
|
+
const versions = await this.load(filePath);
|
|
406
|
+
return versions.map(({ id, version, timestamp }) => ({ id, version, timestamp })).reverse();
|
|
407
|
+
}
|
|
408
|
+
async getVersion(filePath, versionId) {
|
|
409
|
+
const versions = await this.load(filePath);
|
|
410
|
+
return versions.find(v => v.id === versionId)?.content ?? null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// ─── Git helper ──────────────────────────────────────────────────────────────
|
|
414
|
+
async function getGitBranch(cwd) {
|
|
415
|
+
try {
|
|
416
|
+
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd });
|
|
417
|
+
return stdout.trim() || null;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// ─── Open helper (cross-platform) ────────────────────────────────────────────
|
|
424
|
+
async function openUrl(target) {
|
|
425
|
+
const cmd = process.platform === 'darwin' ? `open "${target}"` :
|
|
426
|
+
process.platform === 'win32' ? `start "" "${target}"` :
|
|
427
|
+
`xdg-open "${target}"`;
|
|
428
|
+
await execAsync(cmd).catch(() => { });
|
|
429
|
+
}
|
|
430
|
+
function handleConnection(ws, versionsManager, pkgVersion) {
|
|
431
|
+
const send = (event, args) => {
|
|
432
|
+
if (ws.readyState === ws_1.WebSocket.OPEN) {
|
|
433
|
+
ws.send(JSON.stringify({ event, args }));
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
const ptyManager = new WebPtyManager(send);
|
|
437
|
+
const fsWatcher = new WebFsWatcher(send);
|
|
438
|
+
const respond = (id, result) => {
|
|
439
|
+
if (ws.readyState === ws_1.WebSocket.OPEN)
|
|
440
|
+
ws.send(JSON.stringify({ id, result }));
|
|
441
|
+
};
|
|
442
|
+
const respondError = (id, error) => {
|
|
443
|
+
if (ws.readyState === ws_1.WebSocket.OPEN)
|
|
444
|
+
ws.send(JSON.stringify({ id, error }));
|
|
445
|
+
};
|
|
446
|
+
ws.on('message', async (raw) => {
|
|
447
|
+
let msg;
|
|
448
|
+
try {
|
|
449
|
+
msg = JSON.parse(raw.toString());
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const { id, method, params = {} } = msg;
|
|
455
|
+
if (!method)
|
|
456
|
+
return;
|
|
457
|
+
try {
|
|
458
|
+
switch (method) {
|
|
459
|
+
// PTY
|
|
460
|
+
case 'pty:create': {
|
|
461
|
+
const sessionId = ptyManager.create(params);
|
|
462
|
+
if (id)
|
|
463
|
+
respond(id, sessionId);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case 'pty:write':
|
|
467
|
+
ptyManager.write(params.sessionId, params.data);
|
|
468
|
+
break;
|
|
469
|
+
case 'pty:resize':
|
|
470
|
+
ptyManager.resize(params.sessionId, params.cols, params.rows);
|
|
471
|
+
break;
|
|
472
|
+
case 'pty:kill':
|
|
473
|
+
ptyManager.kill(params.sessionId);
|
|
474
|
+
if (id)
|
|
475
|
+
respond(id, null);
|
|
476
|
+
break;
|
|
477
|
+
// FS
|
|
478
|
+
case 'fs:openFolder':
|
|
479
|
+
// Cannot open native dialog from browser — return null
|
|
480
|
+
if (id)
|
|
481
|
+
respond(id, null);
|
|
482
|
+
break;
|
|
483
|
+
case 'fs:readDir': {
|
|
484
|
+
const entries = await fsWatcher.readDir(params.path);
|
|
485
|
+
if (id)
|
|
486
|
+
respond(id, entries);
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
case 'fs:readFile': {
|
|
490
|
+
const content = await fsWatcher.readFile(params.path);
|
|
491
|
+
if (id)
|
|
492
|
+
respond(id, content);
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case 'fs:writeFile':
|
|
496
|
+
await fsWatcher.writeFile(params.path, params.content);
|
|
497
|
+
if (id)
|
|
498
|
+
respond(id, null);
|
|
499
|
+
break;
|
|
500
|
+
case 'fs:writeBinaryFile':
|
|
501
|
+
await fsWatcher.writeBinaryFile(params.filePath, params.data);
|
|
502
|
+
if (id)
|
|
503
|
+
respond(id, null);
|
|
504
|
+
break;
|
|
505
|
+
case 'fs:mkdir':
|
|
506
|
+
await fsWatcher.mkdir(params.path);
|
|
507
|
+
if (id)
|
|
508
|
+
respond(id, null);
|
|
509
|
+
break;
|
|
510
|
+
case 'fs:delete':
|
|
511
|
+
await fsWatcher.delete(params.path);
|
|
512
|
+
if (id)
|
|
513
|
+
respond(id, null);
|
|
514
|
+
break;
|
|
515
|
+
case 'fs:setWatchRoot':
|
|
516
|
+
fsWatcher.setWatchRoot(params.path);
|
|
517
|
+
break;
|
|
518
|
+
// Versions
|
|
519
|
+
case 'fs:versions:save': {
|
|
520
|
+
const meta = await versionsManager.saveVersion(params.filePath, params.content);
|
|
521
|
+
if (id)
|
|
522
|
+
respond(id, meta);
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case 'fs:versions:list': {
|
|
526
|
+
const list = await versionsManager.listVersions(params.filePath);
|
|
527
|
+
if (id)
|
|
528
|
+
respond(id, list);
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
case 'fs:versions:get': {
|
|
532
|
+
const ver = await versionsManager.getVersion(params.filePath, params.versionId);
|
|
533
|
+
if (id)
|
|
534
|
+
respond(id, ver);
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
// App
|
|
538
|
+
case 'app:getVersion':
|
|
539
|
+
if (id)
|
|
540
|
+
respond(id, pkgVersion);
|
|
541
|
+
break;
|
|
542
|
+
case 'app:getGitBranch': {
|
|
543
|
+
const branch = await getGitBranch(params.cwd);
|
|
544
|
+
if (id)
|
|
545
|
+
respond(id, branch);
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case 'app:checkForUpdates':
|
|
549
|
+
if (id)
|
|
550
|
+
respond(id, null);
|
|
551
|
+
break;
|
|
552
|
+
// Shell
|
|
553
|
+
case 'shell:openExternal':
|
|
554
|
+
await openUrl(params.url);
|
|
555
|
+
break;
|
|
556
|
+
case 'shell:openPath':
|
|
557
|
+
await openUrl(params.path);
|
|
558
|
+
break;
|
|
559
|
+
case 'shell:openInFinder':
|
|
560
|
+
await openUrl(params.path);
|
|
561
|
+
break;
|
|
562
|
+
// Window (no-ops in web mode)
|
|
563
|
+
case 'window:minimize':
|
|
564
|
+
case 'window:maximize':
|
|
565
|
+
case 'window:close':
|
|
566
|
+
break;
|
|
567
|
+
default:
|
|
568
|
+
if (id)
|
|
569
|
+
respondError(id, `Unknown method: ${method}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
if (id)
|
|
574
|
+
respondError(id, err.message ?? 'Internal error');
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
ws.on('close', () => {
|
|
578
|
+
ptyManager.killAll();
|
|
579
|
+
fsWatcher.close();
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
// ─── Start server ─────────────────────────────────────────────────────────────
|
|
583
|
+
function startServer(port) {
|
|
584
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
585
|
+
const pkgVersion = require('../package.json').version;
|
|
586
|
+
const versionsManager = new WebVersionsManager();
|
|
587
|
+
const app = (0, express_1.default)();
|
|
588
|
+
const httpServer = (0, http_1.createServer)(app);
|
|
589
|
+
const wss = new ws_1.WebSocketServer({ server: httpServer, path: '/ws' });
|
|
590
|
+
// Serve the pre-built React frontend
|
|
591
|
+
const buildDir = path_1.default.resolve(__dirname, '../build');
|
|
592
|
+
app.use(express_1.default.static(buildDir));
|
|
593
|
+
// ---- FS: pick-folder (native OS dialog) ----
|
|
594
|
+
app.get('/api/fs/pick-folder', async (_req, res) => {
|
|
595
|
+
try {
|
|
596
|
+
let cmd;
|
|
597
|
+
if (process.platform === 'darwin') {
|
|
598
|
+
cmd = `osascript -e 'POSIX path of (choose folder with prompt "Select a folder:")'`;
|
|
599
|
+
}
|
|
600
|
+
else if (process.platform === 'linux') {
|
|
601
|
+
cmd = `zenity --file-selection --directory --title="Select a folder"`;
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
return res.json({ path: null });
|
|
605
|
+
}
|
|
606
|
+
const { stdout } = await execAsync(cmd);
|
|
607
|
+
res.json({ path: stdout.trim().replace(/\/$/, '') });
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
res.json({ path: null });
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
// SPA fallback
|
|
614
|
+
app.get('*', (_req, res) => {
|
|
615
|
+
res.sendFile(path_1.default.join(buildDir, 'index.html'));
|
|
616
|
+
});
|
|
617
|
+
wss.on('connection', (ws) => {
|
|
618
|
+
handleConnection(ws, versionsManager, pkgVersion);
|
|
619
|
+
});
|
|
620
|
+
httpServer.listen(port, '127.0.0.1', () => {
|
|
621
|
+
const url = `http://localhost:${port}`;
|
|
622
|
+
console.log(`\n terminalOS is running at ${url}\n`);
|
|
623
|
+
// Open browser automatically
|
|
624
|
+
openUrl(url).catch(() => { });
|
|
625
|
+
});
|
|
626
|
+
}
|