typescript-virtual-container 1.3.1 → 1.3.3
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 +61 -1
- package/builds/self-standalone.js +482 -0
- package/builds/self-standalone.js.map +7 -0
- package/{standalone-wo-sftp.js → builds/standalone-wo-sftp.js} +144 -153
- package/{standalone-wo-sftp.js.map → builds/standalone-wo-sftp.js.map} +4 -4
- package/{standalone.js → builds/standalone.js} +61 -70
- package/{standalone.js.map → builds/standalone.js.map} +4 -4
- package/builds/web-full-api.min.js +13 -0
- package/builds/web-full-api.min.js.map +7 -0
- package/builds/web-iife.min.js +13 -0
- package/builds/web-iife.min.js.map +7 -0
- package/builds/web.min.js +13 -0
- package/builds/web.min.js.map +7 -0
- package/dist/SSHMimic/loginBanner.d.ts +7 -0
- package/dist/SSHMimic/loginBanner.d.ts.map +1 -0
- package/dist/SSHMimic/loginBanner.js +22 -0
- package/dist/VirtualShell/index.d.ts +21 -1
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +34 -2
- package/dist/VirtualShell/shell.d.ts.map +1 -1
- package/dist/VirtualShell/shell.js +2 -17
- package/dist/self-standalone.d.ts +2 -0
- package/dist/self-standalone.d.ts.map +1 -0
- package/dist/self-standalone.js +147 -0
- package/dist/web-api.d.ts +26 -0
- package/dist/web-api.d.ts.map +1 -0
- package/dist/web-api.js +46 -0
- package/dist/web-full.d.ts +4 -0
- package/dist/web-full.d.ts.map +1 -0
- package/dist/web-full.js +8 -0
- package/dist/web.d.ts +108 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +773 -0
- package/examples/README.md +81 -3
- package/examples/app-iife.js +58 -0
- package/examples/app.js +28 -0
- package/examples/index-cf.html +27 -0
- package/examples/index.html +27 -0
- package/examples/server.js +55 -0
- package/examples/web-iife.min.js +13 -0
- package/examples/web.min.js +13 -0
- package/package.json +12 -5
- package/polyfills/node_child_process/index.js +2 -0
- package/polyfills/node_crypto/index.js +7 -0
- package/polyfills/node_events/index.js +9 -0
- package/polyfills/node_fs/index.js +8 -0
- package/polyfills/node_fs/promises.js +4 -0
- package/polyfills/node_os/index.js +9 -0
- package/polyfills/node_path/index.js +14 -0
- package/polyfills/node_vm/index.js +7 -0
- package/polyfills/node_zlib/index.js +3 -0
- package/src/SSHMimic/loginBanner.ts +36 -0
- package/src/VirtualShell/index.ts +60 -2
- package/src/VirtualShell/shell.ts +3 -31
- package/src/self-standalone.ts +183 -0
- package/src/web-api.ts +62 -0
- package/src/web-full.ts +11 -0
- package/src/web.ts +930 -0
- package/tests/web.test.ts +182 -0
package/src/web.ts
ADDED
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
/** biome-ignore-all lint/style/useNamingConvention: env vars */
|
|
2
|
+
import { parseScript } from "./VirtualShell/shellParser";
|
|
3
|
+
import type { CommandResult, ShellEnv } from "./types/commands";
|
|
4
|
+
import type { PipelineCommand, Statement } from "./types/pipeline";
|
|
5
|
+
import { expandAsync, expandSync } from "./utils/expand";
|
|
6
|
+
|
|
7
|
+
type WebCommandContext = {
|
|
8
|
+
args: string[];
|
|
9
|
+
stdin?: string;
|
|
10
|
+
cwd: string;
|
|
11
|
+
env: ShellEnv;
|
|
12
|
+
rawInput: string;
|
|
13
|
+
shell: WebShell;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type WebCommandHandler = (
|
|
17
|
+
context: WebCommandContext,
|
|
18
|
+
) => CommandResult | Promise<CommandResult>;
|
|
19
|
+
|
|
20
|
+
interface WebCommand {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
params: string[];
|
|
24
|
+
run: WebCommandHandler;
|
|
25
|
+
aliases?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type WebFileNode = {
|
|
29
|
+
type: "file";
|
|
30
|
+
name: string;
|
|
31
|
+
mode: number;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
contentBase64: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type WebDirectoryNode = {
|
|
38
|
+
type: "directory";
|
|
39
|
+
name: string;
|
|
40
|
+
mode: number;
|
|
41
|
+
createdAt: string;
|
|
42
|
+
updatedAt: string;
|
|
43
|
+
children: WebNode[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type WebNode = WebFileNode | WebDirectoryNode;
|
|
47
|
+
|
|
48
|
+
interface WebVfsSnapshot {
|
|
49
|
+
root: WebDirectoryNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface WebVfsOptions {
|
|
53
|
+
databaseName?: string;
|
|
54
|
+
storeName?: string;
|
|
55
|
+
key?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface WebShellOptions {
|
|
59
|
+
cwd?: string;
|
|
60
|
+
vfs?: WebVfsOptions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const textEncoder = new TextEncoder();
|
|
64
|
+
const textDecoder = new TextDecoder();
|
|
65
|
+
|
|
66
|
+
function encodeBase64(bytes: Uint8Array): string {
|
|
67
|
+
let binary = "";
|
|
68
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
69
|
+
return btoa(binary);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function decodeBase64(base64: string): Uint8Array {
|
|
73
|
+
const binary = atob(base64);
|
|
74
|
+
const bytes = new Uint8Array(binary.length);
|
|
75
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
76
|
+
bytes[index] = binary.charCodeAt(index);
|
|
77
|
+
}
|
|
78
|
+
return bytes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizePath(inputPath: string, cwd = "/"): string {
|
|
82
|
+
const raw = inputPath.startsWith("/") ? inputPath : `${cwd}/${inputPath}`;
|
|
83
|
+
const parts = raw.split("/");
|
|
84
|
+
const stack: string[] = [];
|
|
85
|
+
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
if (!part || part === ".") continue;
|
|
88
|
+
if (part === "..") {
|
|
89
|
+
stack.pop();
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
stack.push(part);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return `/${stack.join("/")}` || "/";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function dirname(inputPath: string): string {
|
|
99
|
+
const normalized = normalizePath(inputPath);
|
|
100
|
+
if (normalized === "/") return "/";
|
|
101
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
102
|
+
parts.pop();
|
|
103
|
+
return parts.length > 0 ? `/${parts.join("/")}` : "/";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function basename(inputPath: string): string {
|
|
107
|
+
const normalized = normalizePath(inputPath);
|
|
108
|
+
if (normalized === "/") return "/";
|
|
109
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
110
|
+
return parts.at(-1) ?? "/";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cloneNode(node: WebNode): WebNode {
|
|
114
|
+
if (node.type === "file") {
|
|
115
|
+
return {
|
|
116
|
+
...node,
|
|
117
|
+
contentBase64: node.contentBase64,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
...node,
|
|
123
|
+
children: node.children.map((child) => cloneNode(child)),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function makeDirectory(name: string, mode: number): WebDirectoryNode {
|
|
128
|
+
const now = new Date().toISOString();
|
|
129
|
+
return {
|
|
130
|
+
type: "directory",
|
|
131
|
+
name,
|
|
132
|
+
mode,
|
|
133
|
+
createdAt: now,
|
|
134
|
+
updatedAt: now,
|
|
135
|
+
children: [],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function makeFile(name: string, content: Uint8Array, mode: number): WebFileNode {
|
|
140
|
+
const now = new Date().toISOString();
|
|
141
|
+
return {
|
|
142
|
+
type: "file",
|
|
143
|
+
name,
|
|
144
|
+
mode,
|
|
145
|
+
createdAt: now,
|
|
146
|
+
updatedAt: now,
|
|
147
|
+
contentBase64: encodeBase64(content),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findChild(directory: WebDirectoryNode, name: string): WebNode | undefined {
|
|
152
|
+
return directory.children.find((child) => child.name === name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setChild(directory: WebDirectoryNode, node: WebNode): void {
|
|
156
|
+
const index = directory.children.findIndex((child) => child.name === node.name);
|
|
157
|
+
if (index === -1) {
|
|
158
|
+
directory.children.push(node);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
directory.children[index] = node;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function removeChild(directory: WebDirectoryNode, name: string): void {
|
|
165
|
+
directory.children = directory.children.filter((child) => child.name !== name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function splitPath(pathValue: string): string[] {
|
|
169
|
+
return normalizePath(pathValue).split("/").filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type WebIndexedDbRequest<T> = {
|
|
173
|
+
result: T;
|
|
174
|
+
error: unknown;
|
|
175
|
+
addEventListener(type: "success" | "error", listener: () => void): void;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
type WebIndexedDbObjectStore = {
|
|
179
|
+
get(key: string): WebIndexedDbRequest<unknown>;
|
|
180
|
+
put(value: string, key: string): WebIndexedDbRequest<unknown>;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type WebIndexedDbTransaction = {
|
|
184
|
+
objectStore(name: string): WebIndexedDbObjectStore;
|
|
185
|
+
error: unknown;
|
|
186
|
+
addEventListener(type: "complete" | "error" | "abort", listener: () => void): void;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
type WebIndexedDbDatabase = {
|
|
190
|
+
objectStoreNames: {
|
|
191
|
+
contains(name: string): boolean;
|
|
192
|
+
};
|
|
193
|
+
createObjectStore(name: string): unknown;
|
|
194
|
+
transaction(name: string, mode: "readonly" | "readwrite"): WebIndexedDbTransaction;
|
|
195
|
+
close(): void;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type WebIndexedDbOpenRequest = {
|
|
199
|
+
result: WebIndexedDbDatabase;
|
|
200
|
+
error: unknown;
|
|
201
|
+
addEventListener(type: "upgradeneeded" | "success" | "error", listener: () => void): void;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
type WebIndexedDbFactory = {
|
|
205
|
+
open(name: string, version?: number): WebIndexedDbOpenRequest;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const webGlobal = globalThis as typeof globalThis & { indexedDB?: WebIndexedDbFactory };
|
|
209
|
+
|
|
210
|
+
function promisifyRequest<T>(request: WebIndexedDbRequest<T>): Promise<T> {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
request.addEventListener("success", () => resolve(request.result));
|
|
213
|
+
request.addEventListener("error", () => reject(request.error));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
class IndexedDbMirrorVfs {
|
|
218
|
+
private readonly databaseName: string;
|
|
219
|
+
private readonly storeName: string;
|
|
220
|
+
private readonly key: string;
|
|
221
|
+
private root: WebDirectoryNode;
|
|
222
|
+
|
|
223
|
+
constructor(options: WebVfsOptions = {}) {
|
|
224
|
+
this.databaseName = options.databaseName ?? "typescript-virtual-container-web";
|
|
225
|
+
this.storeName = options.storeName ?? "snapshots";
|
|
226
|
+
this.key = options.key ?? "current";
|
|
227
|
+
this.root = makeDirectory("", 0o755);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async openDatabase(): Promise<WebIndexedDbDatabase> {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
const indexedDbFactory = webGlobal.indexedDB;
|
|
233
|
+
if (!indexedDbFactory) {
|
|
234
|
+
reject(new Error("IndexedDB is not available in this environment"));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const request = indexedDbFactory.open(this.databaseName, 1);
|
|
239
|
+
request.addEventListener("upgradeneeded", () => {
|
|
240
|
+
const database = request.result;
|
|
241
|
+
if (!database.objectStoreNames.contains(this.storeName)) {
|
|
242
|
+
database.createObjectStore(this.storeName);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
request.addEventListener("success", () => resolve(request.result));
|
|
246
|
+
request.addEventListener("error", () => reject(request.error));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async readSnapshot(): Promise<WebVfsSnapshot | null> {
|
|
251
|
+
const database = await this.openDatabase();
|
|
252
|
+
try {
|
|
253
|
+
const transaction = database.transaction(this.storeName, "readonly");
|
|
254
|
+
const store = transaction.objectStore(this.storeName);
|
|
255
|
+
const request = store.get(this.key);
|
|
256
|
+
const result = (await promisifyRequest(request)) as string | undefined;
|
|
257
|
+
if (!result) return null;
|
|
258
|
+
return JSON.parse(result) as WebVfsSnapshot;
|
|
259
|
+
} finally {
|
|
260
|
+
database.close();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async writeSnapshot(snapshot: WebVfsSnapshot): Promise<void> {
|
|
265
|
+
const database = await this.openDatabase();
|
|
266
|
+
try {
|
|
267
|
+
const transaction = database.transaction(this.storeName, "readwrite");
|
|
268
|
+
const store = transaction.objectStore(this.storeName);
|
|
269
|
+
await promisifyRequest(store.put(JSON.stringify(snapshot), this.key));
|
|
270
|
+
await new Promise<void>((resolve, reject) => {
|
|
271
|
+
transaction.addEventListener("complete", () => resolve());
|
|
272
|
+
transaction.addEventListener("error", () => reject(transaction.error));
|
|
273
|
+
transaction.addEventListener("abort", () => reject(transaction.error));
|
|
274
|
+
});
|
|
275
|
+
} finally {
|
|
276
|
+
database.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private serializeNode(node: WebNode): WebNode {
|
|
281
|
+
if (node.type === "file") return { ...node };
|
|
282
|
+
return {
|
|
283
|
+
...node,
|
|
284
|
+
children: node.children.map((child) => this.serializeNode(child)),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private deserializeNode(node: WebNode): WebNode {
|
|
289
|
+
if (node.type === "file") return { ...node };
|
|
290
|
+
return {
|
|
291
|
+
...node,
|
|
292
|
+
children: node.children.map((child) => this.deserializeNode(child)),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private getNode(targetPath: string): WebNode {
|
|
297
|
+
const normalized = normalizePath(targetPath);
|
|
298
|
+
if (normalized === "/") return this.root;
|
|
299
|
+
|
|
300
|
+
const parts = splitPath(normalized);
|
|
301
|
+
let current: WebNode = this.root;
|
|
302
|
+
for (const part of parts) {
|
|
303
|
+
if (current.type !== "directory") {
|
|
304
|
+
throw new Error(`Not a directory: ${normalized}`);
|
|
305
|
+
}
|
|
306
|
+
const child = findChild(current, part);
|
|
307
|
+
if (!child) throw new Error(`No such file or directory: ${normalized}`);
|
|
308
|
+
current = child;
|
|
309
|
+
}
|
|
310
|
+
return current;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private ensureDirectory(targetPath: string, mode: number): WebDirectoryNode {
|
|
314
|
+
const normalized = normalizePath(targetPath);
|
|
315
|
+
if (normalized === "/") return this.root;
|
|
316
|
+
|
|
317
|
+
const parts = splitPath(normalized);
|
|
318
|
+
let current = this.root;
|
|
319
|
+
for (const part of parts) {
|
|
320
|
+
const existing = findChild(current, part);
|
|
321
|
+
if (!existing) {
|
|
322
|
+
const created = makeDirectory(part, mode);
|
|
323
|
+
setChild(current, created);
|
|
324
|
+
current = created;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (existing.type !== "directory") {
|
|
328
|
+
throw new Error(`Cannot create directory '${normalized}': path is a file.`);
|
|
329
|
+
}
|
|
330
|
+
current = existing;
|
|
331
|
+
}
|
|
332
|
+
return current;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private removeNode(targetPath: string, recursive: boolean): void {
|
|
336
|
+
const normalized = normalizePath(targetPath);
|
|
337
|
+
if (normalized === "/") throw new Error("Cannot remove root directory");
|
|
338
|
+
const parent = this.getNode(dirname(normalized));
|
|
339
|
+
if (parent.type !== "directory") throw new Error(`Not a directory: ${dirname(normalized)}`);
|
|
340
|
+
const name = basename(normalized);
|
|
341
|
+
const node = findChild(parent, name);
|
|
342
|
+
if (!node) throw new Error(`No such file or directory: ${normalized}`);
|
|
343
|
+
if (node.type === "directory" && node.children.length > 0 && !recursive) {
|
|
344
|
+
throw new Error(`Cannot remove '${normalized}': directory not empty.`);
|
|
345
|
+
}
|
|
346
|
+
removeChild(parent, name);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private copyNode(node: WebNode): WebNode {
|
|
350
|
+
if (node.type === "file") {
|
|
351
|
+
return {
|
|
352
|
+
...node,
|
|
353
|
+
contentBase64: node.contentBase64,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
...node,
|
|
358
|
+
children: node.children.map((child) => this.copyNode(child)),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
public async restoreMirror(): Promise<void> {
|
|
363
|
+
const snapshot = await this.readSnapshot();
|
|
364
|
+
if (!snapshot) return;
|
|
365
|
+
this.root = this.deserializeNode(snapshot.root) as WebDirectoryNode;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
public async flushMirror(): Promise<void> {
|
|
369
|
+
await this.writeSnapshot({ root: this.serializeNode(this.root) as WebDirectoryNode });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
public exists(targetPath: string): boolean {
|
|
373
|
+
try {
|
|
374
|
+
this.getNode(targetPath);
|
|
375
|
+
return true;
|
|
376
|
+
} catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
public list(targetPath: string): string[] {
|
|
382
|
+
const node = this.getNode(targetPath);
|
|
383
|
+
if (node.type !== "directory") throw new Error(`Not a directory: ${targetPath}`);
|
|
384
|
+
return node.children.map((child) => child.name).sort((a, b) => a.localeCompare(b));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
public stat(targetPath: string): {
|
|
388
|
+
type: "file" | "directory";
|
|
389
|
+
mode: number;
|
|
390
|
+
size: number;
|
|
391
|
+
name: string;
|
|
392
|
+
} {
|
|
393
|
+
const node = this.getNode(targetPath);
|
|
394
|
+
if (node.type === "file") {
|
|
395
|
+
return {
|
|
396
|
+
type: "file",
|
|
397
|
+
mode: node.mode,
|
|
398
|
+
size: decodeBase64(node.contentBase64).byteLength,
|
|
399
|
+
name: node.name,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return { type: "directory", mode: node.mode, size: 0, name: node.name };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
public readFile(targetPath: string): string {
|
|
406
|
+
const node = this.getNode(targetPath);
|
|
407
|
+
if (node.type !== "file") throw new Error(`Is a directory: ${targetPath}`);
|
|
408
|
+
return textDecoder.decode(decodeBase64(node.contentBase64));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
public writeFile(
|
|
412
|
+
targetPath: string,
|
|
413
|
+
content: string | Uint8Array,
|
|
414
|
+
mode = 0o644,
|
|
415
|
+
): void {
|
|
416
|
+
const normalized = normalizePath(targetPath);
|
|
417
|
+
const parent = this.ensureDirectory(dirname(normalized), 0o755);
|
|
418
|
+
const bytes = typeof content === "string" ? textEncoder.encode(content) : content;
|
|
419
|
+
const file = makeFile(basename(normalized), bytes, mode);
|
|
420
|
+
setChild(parent, file);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
public mkdir(targetPath: string, mode = 0o755): void {
|
|
424
|
+
this.ensureDirectory(targetPath, mode);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
public touch(targetPath: string): void {
|
|
428
|
+
if (this.exists(targetPath)) return;
|
|
429
|
+
this.writeFile(targetPath, "");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
public move(fromPath: string, toPath: string): void {
|
|
433
|
+
const source = this.getNode(fromPath);
|
|
434
|
+
const sourceParent = this.getNode(dirname(fromPath));
|
|
435
|
+
const destinationParent = this.ensureDirectory(dirname(toPath), 0o755);
|
|
436
|
+
if (sourceParent.type !== "directory") throw new Error(`Not a directory: ${dirname(fromPath)}`);
|
|
437
|
+
removeChild(sourceParent, basename(fromPath));
|
|
438
|
+
const clone = cloneNode(source);
|
|
439
|
+
clone.name = basename(toPath);
|
|
440
|
+
setChild(destinationParent, clone);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
public copy(fromPath: string, toPath: string): void {
|
|
444
|
+
const source = this.getNode(fromPath);
|
|
445
|
+
const destinationParent = this.ensureDirectory(dirname(toPath), 0o755);
|
|
446
|
+
const clone = this.copyNode(source);
|
|
447
|
+
clone.name = basename(toPath);
|
|
448
|
+
setChild(destinationParent, clone);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
public remove(targetPath: string, options: { recursive?: boolean } = {}): void {
|
|
452
|
+
this.removeNode(targetPath, options.recursive ?? false);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
public exportSnapshot(): WebVfsSnapshot {
|
|
456
|
+
return { root: this.serializeNode(this.root) as WebDirectoryNode };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
public importSnapshot(snapshot: WebVfsSnapshot): void {
|
|
460
|
+
this.root = this.deserializeNode(snapshot.root) as WebDirectoryNode;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
class WebShell {
|
|
465
|
+
readonly hostname: string;
|
|
466
|
+
readonly vfs: IndexedDbMirrorVfs;
|
|
467
|
+
readonly env: ShellEnv;
|
|
468
|
+
private cwd: string;
|
|
469
|
+
private readonly commands = new Map<string, WebCommand>();
|
|
470
|
+
private initialized = false;
|
|
471
|
+
|
|
472
|
+
constructor(hostname: string, options: WebShellOptions = {}) {
|
|
473
|
+
this.hostname = hostname;
|
|
474
|
+
this.cwd = options.cwd ?? "/home/root";
|
|
475
|
+
this.env = {
|
|
476
|
+
vars: {
|
|
477
|
+
PATH: "/usr/bin:/bin",
|
|
478
|
+
HOME: "/home/root",
|
|
479
|
+
USER: "root",
|
|
480
|
+
LOGNAME: "root",
|
|
481
|
+
SHELL: "/bin/sh",
|
|
482
|
+
HOSTNAME: hostname,
|
|
483
|
+
PWD: this.cwd,
|
|
484
|
+
},
|
|
485
|
+
lastExitCode: 0,
|
|
486
|
+
};
|
|
487
|
+
this.vfs = new IndexedDbMirrorVfs(options.vfs);
|
|
488
|
+
this.registerBuiltins();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private register(command: WebCommand): void {
|
|
492
|
+
this.commands.set(command.name.toLowerCase(), command);
|
|
493
|
+
for (const alias of command.aliases ?? []) {
|
|
494
|
+
this.commands.set(alias.toLowerCase(), command);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private registerBuiltins(): void {
|
|
499
|
+
this.register({
|
|
500
|
+
name: "help",
|
|
501
|
+
description: "List available web commands",
|
|
502
|
+
params: [],
|
|
503
|
+
run: () => ({ stdout: `${this.listCommands().join("\n")}\n`, exitCode: 0 }),
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
this.register({
|
|
507
|
+
name: "pwd",
|
|
508
|
+
description: "Print current directory",
|
|
509
|
+
params: [],
|
|
510
|
+
run: () => ({ stdout: `${this.cwd}\n`, exitCode: 0 }),
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
this.register({
|
|
514
|
+
name: "cd",
|
|
515
|
+
description: "Change current directory",
|
|
516
|
+
params: ["[dir]"],
|
|
517
|
+
run: ({ args }) => {
|
|
518
|
+
const target = args[0] ? normalizePath(args[0], this.cwd) : "/home/root";
|
|
519
|
+
if (!this.vfs.exists(target) || this.vfs.stat(target).type !== "directory") {
|
|
520
|
+
return { stderr: `cd: no such file or directory: ${target}`, exitCode: 1 };
|
|
521
|
+
}
|
|
522
|
+
this.cwd = target;
|
|
523
|
+
this.env.vars.PWD = target;
|
|
524
|
+
return { exitCode: 0, nextCwd: target };
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
this.register({
|
|
529
|
+
name: "echo",
|
|
530
|
+
description: "Display text",
|
|
531
|
+
params: ["[-n] [-e] [text...]"],
|
|
532
|
+
run: ({ args, stdin }) => {
|
|
533
|
+
const noNewline = args.includes("-n");
|
|
534
|
+
const raw = args.filter((arg) => arg !== "-n" && arg !== "-e" && arg !== "-E");
|
|
535
|
+
const text = raw.length > 0 ? raw.join(" ") : (stdin ?? "");
|
|
536
|
+
const expanded = expandSync(text, this.env.vars, this.env.lastExitCode, this.env.vars.HOME);
|
|
537
|
+
return { stdout: noNewline ? expanded : `${expanded}\n`, exitCode: 0 };
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
this.register({
|
|
542
|
+
name: "env",
|
|
543
|
+
description: "Print environment variables",
|
|
544
|
+
params: [],
|
|
545
|
+
run: () => ({ stdout: `${Object.entries(this.env.vars).map(([key, value]) => `${key}=${value}`).join("\n")}\n`, exitCode: 0 }),
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
this.register({
|
|
549
|
+
name: "export",
|
|
550
|
+
description: "Set environment variables",
|
|
551
|
+
params: ["KEY=VALUE..."],
|
|
552
|
+
run: ({ args }) => {
|
|
553
|
+
for (const arg of args) {
|
|
554
|
+
const eq = arg.indexOf("=");
|
|
555
|
+
if (eq === -1) continue;
|
|
556
|
+
const key = arg.slice(0, eq).trim();
|
|
557
|
+
const value = arg.slice(eq + 1);
|
|
558
|
+
if (key) this.env.vars[key] = value;
|
|
559
|
+
}
|
|
560
|
+
return { exitCode: 0 };
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
this.register({
|
|
565
|
+
name: "unset",
|
|
566
|
+
description: "Unset environment variables",
|
|
567
|
+
params: ["NAME..."],
|
|
568
|
+
run: ({ args }) => {
|
|
569
|
+
for (const name of args) delete this.env.vars[name];
|
|
570
|
+
return { exitCode: 0 };
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
this.register({
|
|
575
|
+
name: "mkdir",
|
|
576
|
+
description: "Create directories",
|
|
577
|
+
params: ["[-p] dir..."],
|
|
578
|
+
run: async ({ args }) => {
|
|
579
|
+
const paths = args.filter((arg) => arg !== "-p");
|
|
580
|
+
for (const target of paths) {
|
|
581
|
+
this.vfs.mkdir(normalizePath(target, this.cwd));
|
|
582
|
+
}
|
|
583
|
+
await this.vfs.flushMirror();
|
|
584
|
+
return { exitCode: 0 };
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
this.register({
|
|
589
|
+
name: "touch",
|
|
590
|
+
description: "Create files",
|
|
591
|
+
params: ["file..."],
|
|
592
|
+
run: async ({ args }) => {
|
|
593
|
+
for (const target of args) {
|
|
594
|
+
this.vfs.touch(normalizePath(target, this.cwd));
|
|
595
|
+
}
|
|
596
|
+
await this.vfs.flushMirror();
|
|
597
|
+
return { exitCode: 0 };
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
this.register({
|
|
602
|
+
name: "rm",
|
|
603
|
+
description: "Remove files or directories",
|
|
604
|
+
params: ["[-r] [-f] path..."],
|
|
605
|
+
run: async ({ args }) => {
|
|
606
|
+
const recursive = args.includes("-r");
|
|
607
|
+
const targets = args.filter((arg) => arg !== "-r" && arg !== "-f");
|
|
608
|
+
for (const target of targets) {
|
|
609
|
+
this.vfs.remove(normalizePath(target, this.cwd), { recursive });
|
|
610
|
+
}
|
|
611
|
+
await this.vfs.flushMirror();
|
|
612
|
+
return { exitCode: 0 };
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
this.register({
|
|
617
|
+
name: "cp",
|
|
618
|
+
description: "Copy files or directories",
|
|
619
|
+
params: ["[-r] source destination"],
|
|
620
|
+
run: async ({ args }) => {
|
|
621
|
+
const recursive = args.includes("-r");
|
|
622
|
+
const items = args.filter((arg) => arg !== "-r");
|
|
623
|
+
if (items.length < 2) return { stderr: "cp: missing destination file operand", exitCode: 1 };
|
|
624
|
+
const dest = normalizePath(items.at(-1)!, this.cwd);
|
|
625
|
+
for (const source of items.slice(0, -1)) {
|
|
626
|
+
const sourcePath = normalizePath(source, this.cwd);
|
|
627
|
+
if (!recursive && this.vfs.stat(sourcePath).type === "directory") {
|
|
628
|
+
return { stderr: `cp: -r not specified; omitting directory '${sourcePath}'`, exitCode: 1 };
|
|
629
|
+
}
|
|
630
|
+
this.vfs.copy(sourcePath, dest);
|
|
631
|
+
}
|
|
632
|
+
await this.vfs.flushMirror();
|
|
633
|
+
return { exitCode: 0 };
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
this.register({
|
|
638
|
+
name: "mv",
|
|
639
|
+
description: "Move or rename files",
|
|
640
|
+
params: ["source destination"],
|
|
641
|
+
run: async ({ args }) => {
|
|
642
|
+
if (args.length < 2) return { stderr: "mv: missing destination file operand", exitCode: 1 };
|
|
643
|
+
const source = normalizePath(args[0]!, this.cwd);
|
|
644
|
+
const destination = normalizePath(args[1]!, this.cwd);
|
|
645
|
+
this.vfs.move(source, destination);
|
|
646
|
+
await this.vfs.flushMirror();
|
|
647
|
+
return { exitCode: 0 };
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
this.register({
|
|
652
|
+
name: "cat",
|
|
653
|
+
description: "Concatenate files",
|
|
654
|
+
params: ["[file...]"],
|
|
655
|
+
run: ({ args, stdin }) => {
|
|
656
|
+
if (args.length === 0) return { stdout: stdin ?? "", exitCode: 0 };
|
|
657
|
+
let output = "";
|
|
658
|
+
for (const source of args) {
|
|
659
|
+
output += this.vfs.readFile(normalizePath(source, this.cwd));
|
|
660
|
+
}
|
|
661
|
+
return { stdout: output, exitCode: 0 };
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
this.register({
|
|
666
|
+
name: "ls",
|
|
667
|
+
description: "List files",
|
|
668
|
+
params: ["[path]"],
|
|
669
|
+
run: ({ args }) => {
|
|
670
|
+
const target = normalizePath(args[0] ?? ".", this.cwd);
|
|
671
|
+
const entries = this.vfs.list(target);
|
|
672
|
+
return { stdout: `${entries.join(" ")}\n`, exitCode: 0 };
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
this.register({
|
|
677
|
+
name: "tee",
|
|
678
|
+
description: "Read from stdin and write to files",
|
|
679
|
+
params: ["[-a] file..."],
|
|
680
|
+
run: async ({ args, stdin }) => {
|
|
681
|
+
const append = args.includes("-a");
|
|
682
|
+
const targets = args.filter((arg) => arg !== "-a");
|
|
683
|
+
const content = stdin ?? "";
|
|
684
|
+
for (const target of targets) {
|
|
685
|
+
const normalized = normalizePath(target, this.cwd);
|
|
686
|
+
if (append && this.vfs.exists(normalized)) {
|
|
687
|
+
const current = this.vfs.readFile(normalized);
|
|
688
|
+
this.vfs.writeFile(normalized, `${current}${content}`);
|
|
689
|
+
} else {
|
|
690
|
+
this.vfs.writeFile(normalized, content);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
await this.vfs.flushMirror();
|
|
694
|
+
return { stdout: content, exitCode: 0 };
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
this.register({
|
|
699
|
+
name: "curl",
|
|
700
|
+
description: "Fetch a URL and optionally write to a file",
|
|
701
|
+
params: ["[-o file] URL"],
|
|
702
|
+
run: async ({ args }) => {
|
|
703
|
+
const outputIndex = args.indexOf("-o");
|
|
704
|
+
const outputTarget = outputIndex !== -1 ? args[outputIndex + 1] : undefined;
|
|
705
|
+
const filtered = args.filter((arg, index) => arg !== "-o" && index !== outputIndex + 1);
|
|
706
|
+
const url = filtered.at(-1);
|
|
707
|
+
if (!url) return { stderr: "curl: missing URL", exitCode: 2 };
|
|
708
|
+
const response = await fetch(url);
|
|
709
|
+
const body = await response.text();
|
|
710
|
+
if (outputTarget) {
|
|
711
|
+
this.vfs.writeFile(normalizePath(outputTarget, this.cwd), body);
|
|
712
|
+
await this.vfs.flushMirror();
|
|
713
|
+
return { exitCode: response.ok ? 0 : 1 };
|
|
714
|
+
}
|
|
715
|
+
return { stdout: body, exitCode: response.ok ? 0 : 1 };
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
this.register({
|
|
720
|
+
name: "wget",
|
|
721
|
+
description: "Fetch a URL and optionally write to a file",
|
|
722
|
+
params: ["[-O file] URL"],
|
|
723
|
+
run: async ({ args }) => {
|
|
724
|
+
const outputIndex = args.indexOf("-O");
|
|
725
|
+
const outputTarget = outputIndex !== -1 ? args[outputIndex + 1] : undefined;
|
|
726
|
+
const filtered = args.filter((arg, index) => arg !== "-O" && index !== outputIndex + 1);
|
|
727
|
+
const url = filtered.at(-1);
|
|
728
|
+
if (!url) return { stderr: "wget: missing URL", exitCode: 2 };
|
|
729
|
+
const response = await fetch(url);
|
|
730
|
+
const body = await response.text();
|
|
731
|
+
const filename = outputTarget ?? basename(new URL(url).pathname || "index.html");
|
|
732
|
+
this.vfs.writeFile(normalizePath(filename, this.cwd), body);
|
|
733
|
+
await this.vfs.flushMirror();
|
|
734
|
+
return { exitCode: response.ok ? 0 : 1 };
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
this.register({
|
|
739
|
+
name: "true",
|
|
740
|
+
description: "Return success",
|
|
741
|
+
params: [],
|
|
742
|
+
run: () => ({ exitCode: 0 }),
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
this.register({
|
|
746
|
+
name: "false",
|
|
747
|
+
description: "Return failure",
|
|
748
|
+
params: [],
|
|
749
|
+
run: () => ({ exitCode: 1 }),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private listCommands(): string[] {
|
|
754
|
+
const unique = new Map<string, WebCommand>();
|
|
755
|
+
for (const command of this.commands.values()) unique.set(command.name, command);
|
|
756
|
+
return Array.from(unique.values())
|
|
757
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
758
|
+
.map((command) => `${command.name}${command.params.length > 0 ? ` ${command.params.join(" ")}` : ""}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private resolveCommand(name: string): WebCommand | undefined {
|
|
762
|
+
return this.commands.get(name.toLowerCase());
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
public async ensureInitialized(): Promise<void> {
|
|
766
|
+
if (this.initialized) return;
|
|
767
|
+
await this.vfs.restoreMirror();
|
|
768
|
+
if (!this.vfs.exists("/home")) this.vfs.mkdir("/home");
|
|
769
|
+
if (!this.vfs.exists("/home/root")) {
|
|
770
|
+
this.vfs.mkdir("/home/root");
|
|
771
|
+
this.vfs.writeFile("/home/root/README.txt", `Welcome to ${this.hostname}\n`);
|
|
772
|
+
}
|
|
773
|
+
if (!this.vfs.exists("/tmp")) this.vfs.mkdir("/tmp");
|
|
774
|
+
if (!this.vfs.exists("/etc")) this.vfs.mkdir("/etc");
|
|
775
|
+
if (!this.vfs.exists("/etc/hostname")) this.vfs.writeFile("/etc/hostname", `${this.hostname}\n`);
|
|
776
|
+
if (!this.vfs.exists("/etc/hosts")) {
|
|
777
|
+
this.vfs.writeFile("/etc/hosts", "127.0.0.1 localhost\n::1 localhost\n");
|
|
778
|
+
}
|
|
779
|
+
this.initialized = true;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
public getCurrentWorkingDirectory(): string {
|
|
783
|
+
return this.cwd;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
public async executeCommandLine(rawInput: string, persist = true): Promise<CommandResult> {
|
|
787
|
+
await this.ensureInitialized();
|
|
788
|
+
const trimmed = rawInput.trim();
|
|
789
|
+
if (!trimmed) return { exitCode: 0 };
|
|
790
|
+
|
|
791
|
+
const expanded = await expandAsync(
|
|
792
|
+
trimmed,
|
|
793
|
+
this.env.vars,
|
|
794
|
+
this.env.lastExitCode,
|
|
795
|
+
(subcommand) => this.executeCommandLine(subcommand, false).then((r) => r.stdout ?? ""),
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
const script = parseScript(expanded);
|
|
799
|
+
const result = await this.executeStatements(script.statements);
|
|
800
|
+
this.env.lastExitCode = result.exitCode ?? 0;
|
|
801
|
+
if (persist) await this.vfs.flushMirror();
|
|
802
|
+
return result;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private async executeStatements(statements: Statement[]): Promise<CommandResult> {
|
|
806
|
+
let last: CommandResult = { exitCode: 0 };
|
|
807
|
+
let index = 0;
|
|
808
|
+
|
|
809
|
+
while (index < statements.length) {
|
|
810
|
+
const stmt = statements[index]!;
|
|
811
|
+
last = await this.executePipeline(stmt.pipeline.commands as PipelineCommand[]);
|
|
812
|
+
this.env.lastExitCode = last.exitCode ?? 0;
|
|
813
|
+
if (last.closeSession || last.switchUser) return last;
|
|
814
|
+
|
|
815
|
+
const op = stmt.op;
|
|
816
|
+
if (!op || op === ";") {
|
|
817
|
+
// continue
|
|
818
|
+
} else if (op === "&&") {
|
|
819
|
+
if ((last.exitCode ?? 0) !== 0) {
|
|
820
|
+
while (index < statements.length && statements[index]?.op === "&&") index += 1;
|
|
821
|
+
}
|
|
822
|
+
} else if (op === "||") {
|
|
823
|
+
if ((last.exitCode ?? 0) === 0) {
|
|
824
|
+
while (index < statements.length && statements[index]?.op === "||") index += 1;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
index += 1;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return last;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private async executePipeline(commands: PipelineCommand[]): Promise<CommandResult> {
|
|
834
|
+
if (commands.length === 0) return { exitCode: 0 };
|
|
835
|
+
if (commands.length === 1) {
|
|
836
|
+
return this.executeSingleCommandWithRedirections(commands[0]!);
|
|
837
|
+
}
|
|
838
|
+
return this.executePipelineChain(commands);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private async executeSingleCommandWithRedirections(command: PipelineCommand): Promise<CommandResult> {
|
|
842
|
+
let stdin: string | undefined;
|
|
843
|
+
if (command.inputFile) {
|
|
844
|
+
const inputPath = normalizePath(command.inputFile, this.cwd);
|
|
845
|
+
try {
|
|
846
|
+
stdin = this.vfs.readFile(inputPath);
|
|
847
|
+
} catch {
|
|
848
|
+
return { stderr: `${command.inputFile}: No such file or directory`, exitCode: 1 };
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const result = await this.executeCommand(command.name, command.args, stdin);
|
|
853
|
+
|
|
854
|
+
if (command.outputFile) {
|
|
855
|
+
const outputPath = normalizePath(command.outputFile, this.cwd);
|
|
856
|
+
const output = result.stdout ?? "";
|
|
857
|
+
if (command.appendOutput && this.vfs.exists(outputPath)) {
|
|
858
|
+
const existing = this.vfs.readFile(outputPath);
|
|
859
|
+
this.vfs.writeFile(outputPath, `${existing}${output}`);
|
|
860
|
+
} else {
|
|
861
|
+
this.vfs.writeFile(outputPath, output);
|
|
862
|
+
}
|
|
863
|
+
return { ...result, stdout: "" };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return result;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private async executePipelineChain(commands: PipelineCommand[]): Promise<CommandResult> {
|
|
870
|
+
let currentOutput = "";
|
|
871
|
+
let exitCode = 0;
|
|
872
|
+
|
|
873
|
+
for (let index = 0; index < commands.length; index += 1) {
|
|
874
|
+
const command = commands[index]!;
|
|
875
|
+
if (index === 0 && command.inputFile) {
|
|
876
|
+
const inputPath = normalizePath(command.inputFile, this.cwd);
|
|
877
|
+
try {
|
|
878
|
+
currentOutput = this.vfs.readFile(inputPath);
|
|
879
|
+
} catch {
|
|
880
|
+
return { stderr: `${command.inputFile}: No such file or directory`, exitCode: 1 };
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const result = await this.executeCommand(command.name, command.args, currentOutput);
|
|
885
|
+
currentOutput = result.stdout ?? "";
|
|
886
|
+
exitCode = result.exitCode ?? 0;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return { stdout: currentOutput, exitCode };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private async executeCommand(
|
|
893
|
+
name: string,
|
|
894
|
+
args: string[],
|
|
895
|
+
stdin?: string,
|
|
896
|
+
): Promise<CommandResult> {
|
|
897
|
+
const command = this.resolveCommand(name);
|
|
898
|
+
if (!command) {
|
|
899
|
+
return { stderr: `${name}: command not found`, exitCode: 127 };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const expandedArgs = args.map((arg) => expandSync(arg, this.env.vars, this.env.lastExitCode, this.env.vars.HOME));
|
|
903
|
+
const context: WebCommandContext = {
|
|
904
|
+
args: expandedArgs,
|
|
905
|
+
stdin,
|
|
906
|
+
cwd: this.cwd,
|
|
907
|
+
env: this.env,
|
|
908
|
+
rawInput: `${name} ${args.join(" ")}`.trim(),
|
|
909
|
+
shell: this,
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
const result = await command.run(context);
|
|
914
|
+
if (result.nextCwd) {
|
|
915
|
+
this.cwd = result.nextCwd;
|
|
916
|
+
this.env.vars.PWD = result.nextCwd;
|
|
917
|
+
}
|
|
918
|
+
return result;
|
|
919
|
+
} catch (error) {
|
|
920
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
921
|
+
return { stderr: message, exitCode: 1 };
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
export { IndexedDbMirrorVfs, WebShell };
|
|
927
|
+
export function createWebShell(hostname = "typescript-vm", options: WebShellOptions = {}): WebShell {
|
|
928
|
+
return new WebShell(hostname, options);
|
|
929
|
+
}
|
|
930
|
+
export type { WebCommand, WebCommandContext, WebShellOptions, WebVfsOptions };
|