typescript-virtual-container 1.0.8 → 1.1.1-b
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/.vscode/settings.json +18 -0
- package/README.md +183 -92
- package/modules/shellInteractive.ts +45 -0
- package/modules/shellRuntime.ts +76 -0
- package/package.json +1 -1
- package/src/{SSHMimic/client.ts → SSHClient/index.ts} +17 -20
- package/src/SSHMimic/exec.ts +6 -17
- package/src/SSHMimic/executor.ts +20 -31
- package/src/SSHMimic/index.ts +23 -85
- package/src/VirtualFileSystem/index.ts +26 -1
- package/src/VirtualShell/index.ts +131 -26
- package/src/VirtualShell/shell.ts +43 -141
- package/src/VirtualShell/shellParser.ts +32 -7
- package/src/{SSHMimic/users.ts → VirtualUserManager/index.ts} +155 -3
- package/src/{VirtualShell/commands → commands}/adduser.ts +3 -3
- package/src/{VirtualShell/commands → commands}/cat.ts +4 -4
- package/src/{VirtualShell/commands → commands}/cd.ts +3 -3
- package/src/{VirtualShell/commands → commands}/clear.ts +1 -1
- package/src/{VirtualShell/commands → commands}/curl.ts +3 -3
- package/src/{VirtualShell/commands → commands}/deluser.ts +3 -3
- package/src/{VirtualShell/commands → commands}/echo.ts +1 -1
- package/src/{VirtualShell/commands → commands}/env.ts +1 -1
- package/src/{VirtualShell/commands → commands}/exit.ts +1 -1
- package/src/{VirtualShell/commands → commands}/export.ts +1 -1
- package/src/{VirtualShell/commands → commands}/grep.ts +3 -3
- package/src/{VirtualShell/commands → commands}/help.ts +1 -1
- package/src/{VirtualShell/commands → commands}/helpers.ts +1 -1
- package/src/{VirtualShell/commands → commands}/hostname.ts +1 -1
- package/src/{VirtualShell/commands → commands}/htop.ts +1 -1
- package/src/{VirtualShell/commands → commands}/index.ts +19 -110
- package/src/{VirtualShell/commands → commands}/ls.ts +7 -5
- package/src/{VirtualShell/commands → commands}/mkdir.ts +3 -3
- package/src/{VirtualShell/commands → commands}/nano.ts +4 -4
- package/src/{VirtualShell/commands → commands}/neofetch.ts +4 -4
- package/src/{VirtualShell/commands → commands}/pwd.ts +1 -1
- package/src/{VirtualShell/commands → commands}/rm.ts +3 -3
- package/src/{VirtualShell/commands → commands}/set.ts +1 -1
- package/src/{VirtualShell/commands → commands}/sh.ts +3 -14
- package/src/{VirtualShell/commands → commands}/su.ts +3 -2
- package/src/{VirtualShell/commands → commands}/sudo.ts +4 -7
- package/src/{VirtualShell/commands → commands}/touch.ts +4 -4
- package/src/{VirtualShell/commands → commands}/tree.ts +3 -3
- package/src/{VirtualShell/commands → commands}/unset.ts +1 -1
- package/src/{VirtualShell/commands → commands}/wget.ts +3 -3
- package/src/{VirtualShell/commands → commands}/who.ts +4 -4
- package/src/{VirtualShell/commands → commands}/whoami.ts +1 -1
- package/src/index.ts +6 -6
- package/src/standalone.ts +19 -14
- package/src/types/commands.ts +3 -11
- package/tests/command-helpers.test.ts +1 -1
- package/tests/helpers.test.ts +1 -1
- package/tests/parser-executor.test.ts +3 -6
- package/tests/users.test.ts +61 -1
- /package/src/{VirtualShell/commands → commands}/command-helpers.ts +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes, randomUUID, scryptSync } from "node:crypto";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
3
4
|
|
|
4
5
|
/** Persisted virtual user credential record. */
|
|
@@ -26,22 +27,27 @@ export interface VirtualActiveSession {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
+
* Persistent user, sudoers, and active-session manager for the shell runtime.
|
|
31
|
+
*
|
|
32
|
+
* Passwords are hashed with scrypt and stored in the backing virtual filesystem.
|
|
30
33
|
*/
|
|
31
34
|
export class VirtualUserManager {
|
|
32
35
|
private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
|
|
33
36
|
private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
|
|
37
|
+
private readonly quotasPath = "/virtual-env-js/.auth/quotas";
|
|
34
38
|
private readonly authDirPath = "/virtual-env-js/.auth";
|
|
35
39
|
private readonly users = new Map<string, VirtualUserRecord>();
|
|
36
40
|
private readonly sudoers = new Set<string>();
|
|
41
|
+
private readonly quotas = new Map<string, number>();
|
|
37
42
|
private readonly activeSessions = new Map<string, VirtualActiveSession>();
|
|
38
43
|
private nextTty = 0;
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
|
-
* Creates user manager instance.
|
|
46
|
+
* Creates a user manager instance backed by a virtual filesystem.
|
|
42
47
|
*
|
|
43
48
|
* @param vfs Backing virtual filesystem used for persistence.
|
|
44
|
-
* @param defaultRootPassword Initial root password used when root
|
|
49
|
+
* @param defaultRootPassword Initial root password used when root is created.
|
|
50
|
+
* @param autoSudoForNewUsers Whether newly created users are added to sudoers.
|
|
45
51
|
*/
|
|
46
52
|
constructor(
|
|
47
53
|
private readonly vfs: VirtualFileSystem,
|
|
@@ -55,6 +61,7 @@ export class VirtualUserManager {
|
|
|
55
61
|
public async initialize(): Promise<void> {
|
|
56
62
|
this.loadFromVfs();
|
|
57
63
|
this.loadSudoersFromVfs();
|
|
64
|
+
this.loadQuotasFromVfs();
|
|
58
65
|
|
|
59
66
|
this.users.set("root", this.createRecord("root", this.defaultRootPassword));
|
|
60
67
|
|
|
@@ -63,6 +70,113 @@ export class VirtualUserManager {
|
|
|
63
70
|
await this.persist();
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Sets max allowed bytes under /home/<username>.
|
|
75
|
+
*
|
|
76
|
+
* @param username Target username.
|
|
77
|
+
* @param maxBytes Quota ceiling in bytes.
|
|
78
|
+
*/
|
|
79
|
+
public async setQuotaBytes(
|
|
80
|
+
username: string,
|
|
81
|
+
maxBytes: number,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
this.validateUsername(username);
|
|
84
|
+
if (!this.users.has(username)) {
|
|
85
|
+
throw new Error(`quota: user '${username}' does not exist`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!Number.isFinite(maxBytes) || maxBytes < 0) {
|
|
89
|
+
throw new Error("quota: maxBytes must be a non-negative number");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.quotas.set(username, Math.floor(maxBytes));
|
|
93
|
+
await this.persist();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Removes quota for a user.
|
|
98
|
+
*
|
|
99
|
+
* @param username Target username.
|
|
100
|
+
*/
|
|
101
|
+
public async clearQuota(username: string): Promise<void> {
|
|
102
|
+
this.validateUsername(username);
|
|
103
|
+
this.quotas.delete(username);
|
|
104
|
+
await this.persist();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Gets configured quota in bytes for a user.
|
|
109
|
+
*
|
|
110
|
+
* @param username Target username.
|
|
111
|
+
* @returns Quota in bytes, or null when unlimited.
|
|
112
|
+
*/
|
|
113
|
+
public getQuotaBytes(username: string): number | null {
|
|
114
|
+
return this.quotas.get(username) ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Computes current usage under /home/<username>.
|
|
119
|
+
*
|
|
120
|
+
* @param username Target username.
|
|
121
|
+
* @returns Current usage in bytes.
|
|
122
|
+
*/
|
|
123
|
+
public getUsageBytes(username: string): number {
|
|
124
|
+
const homePath = `/home/${username}`;
|
|
125
|
+
if (!this.vfs.exists(homePath)) {
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return this.vfs.getUsageBytes(homePath);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validates that writing file content would not exceed user quota.
|
|
134
|
+
*
|
|
135
|
+
* Quotas are enforced only for writes inside /home/<username>.
|
|
136
|
+
*
|
|
137
|
+
* @param username Authenticated user.
|
|
138
|
+
* @param targetPath Target file path.
|
|
139
|
+
* @param nextContent New file content.
|
|
140
|
+
*/
|
|
141
|
+
public assertWriteWithinQuota(
|
|
142
|
+
username: string,
|
|
143
|
+
targetPath: string,
|
|
144
|
+
nextContent: string | Buffer,
|
|
145
|
+
): void {
|
|
146
|
+
const quota = this.quotas.get(username);
|
|
147
|
+
if (quota === undefined) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const normalizedPath = normalizeVfsPath(targetPath);
|
|
152
|
+
const homePath = normalizeVfsPath(`/home/${username}`);
|
|
153
|
+
const inUserHome =
|
|
154
|
+
normalizedPath === homePath || normalizedPath.startsWith(`${homePath}/`);
|
|
155
|
+
if (!inUserHome) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const currentUsage = this.getUsageBytes(username);
|
|
160
|
+
let existingSize = 0;
|
|
161
|
+
if (this.vfs.exists(normalizedPath)) {
|
|
162
|
+
const existing = this.vfs.stat(normalizedPath);
|
|
163
|
+
if (existing.type === "file") {
|
|
164
|
+
existingSize = existing.size;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const incomingSize = Buffer.isBuffer(nextContent)
|
|
169
|
+
? nextContent.length
|
|
170
|
+
: Buffer.byteLength(nextContent, "utf8");
|
|
171
|
+
const projectedUsage = currentUsage - existingSize + incomingSize;
|
|
172
|
+
|
|
173
|
+
if (projectedUsage > quota) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`quota exceeded for '${username}': ${projectedUsage}/${quota} bytes`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
66
180
|
/**
|
|
67
181
|
* Verifies plaintext password against stored record.
|
|
68
182
|
*
|
|
@@ -288,6 +402,30 @@ export class VirtualUserManager {
|
|
|
288
402
|
}
|
|
289
403
|
}
|
|
290
404
|
|
|
405
|
+
private loadQuotasFromVfs(): void {
|
|
406
|
+
this.quotas.clear();
|
|
407
|
+
|
|
408
|
+
if (!this.vfs.exists(this.quotasPath)) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const raw = this.vfs.readFile(this.quotasPath);
|
|
413
|
+
for (const line of raw.split("\n")) {
|
|
414
|
+
const trimmed = line.trim();
|
|
415
|
+
if (trimmed.length === 0) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const [username, value] = trimmed.split(":");
|
|
420
|
+
const bytes = Number.parseInt(value ?? "", 10);
|
|
421
|
+
if (!username || !Number.isFinite(bytes) || bytes < 0) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
this.quotas.set(username, bytes);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
291
429
|
private async persist(): Promise<void> {
|
|
292
430
|
if (!this.vfs.exists(this.authDirPath)) {
|
|
293
431
|
this.vfs.mkdir(this.authDirPath, 0o700);
|
|
@@ -311,6 +449,15 @@ export class VirtualUserManager {
|
|
|
311
449
|
sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
|
|
312
450
|
{ mode: 0o600 },
|
|
313
451
|
);
|
|
452
|
+
const quotasContent = Array.from(this.quotas.entries())
|
|
453
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
454
|
+
.map(([username, maxBytes]) => `${username}:${maxBytes}`)
|
|
455
|
+
.join("\n");
|
|
456
|
+
this.vfs.writeFile(
|
|
457
|
+
this.quotasPath,
|
|
458
|
+
quotasContent.length > 0 ? `${quotasContent}\n` : "",
|
|
459
|
+
{ mode: 0o600 },
|
|
460
|
+
);
|
|
314
461
|
await this.vfs.flushMirror();
|
|
315
462
|
}
|
|
316
463
|
|
|
@@ -343,3 +490,8 @@ export class VirtualUserManager {
|
|
|
343
490
|
}
|
|
344
491
|
}
|
|
345
492
|
}
|
|
493
|
+
|
|
494
|
+
function normalizeVfsPath(targetPath: string): string {
|
|
495
|
+
const normalized = path.posix.normalize(targetPath);
|
|
496
|
+
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
497
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { ShellModule } from "
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
2
|
|
|
3
3
|
export const adduserCommand: ShellModule = {
|
|
4
4
|
name: "adduser",
|
|
5
5
|
params: ["<username> <password>"],
|
|
6
|
-
run: async ({ authUser,
|
|
6
|
+
run: async ({ authUser, shell, args }) => {
|
|
7
7
|
if (authUser !== "root") {
|
|
8
8
|
return { stderr: "adduser: permission denied", exitCode: 1 };
|
|
9
9
|
}
|
|
@@ -16,7 +16,7 @@ export const adduserCommand: ShellModule = {
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
await users.addUser(username, password);
|
|
19
|
+
await shell.users.addUser(username, password);
|
|
20
20
|
return { stdout: `adduser: user '${username}' created`, exitCode: 0 };
|
|
21
21
|
},
|
|
22
22
|
};
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import type { ShellModule } from "
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
2
|
import { getArg } from "./command-helpers";
|
|
3
3
|
import { assertPathAccess, resolveReadablePath } from "./helpers";
|
|
4
4
|
|
|
5
5
|
export const catCommand: ShellModule = {
|
|
6
6
|
name: "cat",
|
|
7
7
|
params: ["<file>"],
|
|
8
|
-
run: ({ authUser,
|
|
8
|
+
run: ({ authUser, shell, cwd, args }) => {
|
|
9
9
|
const fileArg = getArg(args, 0);
|
|
10
10
|
if (!fileArg) {
|
|
11
11
|
return { stderr: "cat: missing file operand", exitCode: 1 };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const target = resolveReadablePath(vfs, cwd, fileArg);
|
|
14
|
+
const target = resolveReadablePath(shell.vfs, cwd, fileArg);
|
|
15
15
|
assertPathAccess(authUser, target, "cat");
|
|
16
|
-
return { stdout: vfs.readFile(target), exitCode: 0 };
|
|
16
|
+
return { stdout: shell.vfs.readFile(target), exitCode: 0 };
|
|
17
17
|
},
|
|
18
18
|
};
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { ShellModule } from "
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
2
|
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
3
|
|
|
4
4
|
export const cdCommand: ShellModule = {
|
|
5
5
|
name: "cd",
|
|
6
6
|
params: ["[path]"],
|
|
7
|
-
run: ({ authUser,
|
|
7
|
+
run: ({ authUser, shell, cwd, args, mode }) => {
|
|
8
8
|
const target = resolvePath(cwd, args[0] ?? "/virtual-env-js");
|
|
9
9
|
assertPathAccess(authUser, target, "cd");
|
|
10
|
-
const stats = vfs.stat(target);
|
|
10
|
+
const stats = shell.vfs.stat(target);
|
|
11
11
|
if (stats.type !== "directory") {
|
|
12
12
|
return { stderr: `cd: not a directory: ${target}`, exitCode: 1 };
|
|
13
13
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ShellModule } from "
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
2
|
import { parseArgs } from "./command-helpers";
|
|
3
3
|
import {
|
|
4
4
|
assertPathAccess,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
export const curlCommand: ShellModule = {
|
|
11
11
|
name: "curl",
|
|
12
12
|
params: ["[-o file] <url>"],
|
|
13
|
-
run: async ({ authUser,
|
|
13
|
+
run: async ({ authUser, cwd, args, shell }) => {
|
|
14
14
|
const { flagsWithValues, positionals } = parseArgs(args, {
|
|
15
15
|
flagsWithValue: ["-o", "--output"],
|
|
16
16
|
});
|
|
@@ -39,7 +39,7 @@ export const curlCommand: ShellModule = {
|
|
|
39
39
|
if (outputPath) {
|
|
40
40
|
const target = resolvePath(cwd, outputPath);
|
|
41
41
|
assertPathAccess(authUser, target, "curl");
|
|
42
|
-
|
|
42
|
+
shell.writeFileAsUser(authUser, target, result.stdout);
|
|
43
43
|
return {
|
|
44
44
|
stderr: result.stderr
|
|
45
45
|
? normalizeTerminalOutput(result.stderr)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { ShellModule } from "
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
2
|
|
|
3
3
|
export const deluserCommand: ShellModule = {
|
|
4
4
|
name: "deluser",
|
|
5
5
|
params: ["<username>"],
|
|
6
|
-
run: async ({ authUser,
|
|
6
|
+
run: async ({ authUser, args, shell }) => {
|
|
7
7
|
if (authUser !== "root") {
|
|
8
8
|
return { stderr: "deluser: permission denied", exitCode: 1 };
|
|
9
9
|
}
|
|
@@ -13,7 +13,7 @@ export const deluserCommand: ShellModule = {
|
|
|
13
13
|
return { stderr: "deluser: usage: deluser <username>", exitCode: 1 };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
await users.deleteUser(username);
|
|
16
|
+
await shell.users.deleteUser(username);
|
|
17
17
|
return { stdout: `deluser: user '${username}' deleted`, exitCode: 0 };
|
|
18
18
|
},
|
|
19
19
|
};
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { ShellModule } from "
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
2
|
import { parseArgs } from "./command-helpers";
|
|
3
3
|
import { assertPathAccess, resolvePath } from "./helpers";
|
|
4
4
|
|
|
5
5
|
export const grepCommand: ShellModule = {
|
|
6
6
|
name: "grep",
|
|
7
7
|
params: ["[-i] [-v] <pattern> [file...]"],
|
|
8
|
-
run: ({ authUser,
|
|
8
|
+
run: ({ authUser, shell, cwd, args, stdin }) => {
|
|
9
9
|
const { flags, positionals } = parseArgs(args, { flags: ["-i", "-v"] });
|
|
10
10
|
const caseInsensitive = flags.has("-i");
|
|
11
11
|
const invertMatch = flags.has("-v");
|
|
@@ -52,7 +52,7 @@ export const grepCommand: ShellModule = {
|
|
|
52
52
|
const target = resolvePath(cwd, file);
|
|
53
53
|
try {
|
|
54
54
|
assertPathAccess(authUser, target, "grep");
|
|
55
|
-
const content = vfs.readFile(target);
|
|
55
|
+
const content = shell.vfs.readFile(target);
|
|
56
56
|
const lines = content.split("\n");
|
|
57
57
|
|
|
58
58
|
for (const line of lines) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type VirtualFileSystem from "
|
|
3
|
+
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
4
4
|
|
|
5
5
|
const PROTECTED_PREFIXES = ["/virtual-env-js/.auth"] as const;
|
|
6
6
|
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { VirtualUserManager } from "../../SSHMimic/users";
|
|
1
|
+
import type { VirtualShell } from "../VirtualShell";
|
|
3
2
|
import type {
|
|
4
3
|
CommandContext,
|
|
5
4
|
CommandMode,
|
|
6
5
|
CommandResult,
|
|
7
6
|
ShellModule,
|
|
8
|
-
} from "
|
|
9
|
-
import type VirtualFileSystem from "../../VirtualFileSystem";
|
|
7
|
+
} from "../types/commands";
|
|
10
8
|
import { adduserCommand } from "./adduser";
|
|
11
9
|
import { catCommand } from "./cat";
|
|
12
10
|
import { cdCommand } from "./cd";
|
|
@@ -79,12 +77,7 @@ const helpCommand = createHelpCommand(() =>
|
|
|
79
77
|
const commandRegistry = new Map<string, ShellModule>();
|
|
80
78
|
let cachedCommandNames: string[] | null = null;
|
|
81
79
|
|
|
82
|
-
function invalidateCache(): void {
|
|
83
|
-
cachedCommandNames = null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
80
|
function buildCache(): void {
|
|
87
|
-
commandRegistry.clear();
|
|
88
81
|
for (const mod of getCommandModules()) {
|
|
89
82
|
commandRegistry.set(mod.name, mod);
|
|
90
83
|
for (const alias of mod.aliases ?? []) {
|
|
@@ -95,20 +88,16 @@ function buildCache(): void {
|
|
|
95
88
|
}
|
|
96
89
|
|
|
97
90
|
function getCommandModules(): ShellModule[] {
|
|
91
|
+
// console.log("Loading command modules...");
|
|
92
|
+
// console.log(
|
|
93
|
+
// `Base commands: ${BASE_COMMANDS.map((cmd) => cmd.name).join(", ")}`,
|
|
94
|
+
// );
|
|
95
|
+
// console.log(
|
|
96
|
+
// `Custom commands: ${customCommands.map((cmd) => cmd.name).join(", ")}`,
|
|
97
|
+
// );
|
|
98
98
|
return [...BASE_COMMANDS, ...customCommands, helpCommand];
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function _getTakenCommandNames(modules: ShellModule[]): Set<string> {
|
|
102
|
-
const taken = new Set<string>();
|
|
103
|
-
for (const mod of modules) {
|
|
104
|
-
taken.add(mod.name);
|
|
105
|
-
for (const alias of mod.aliases ?? []) {
|
|
106
|
-
taken.add(alias);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return taken;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
101
|
export function registerCommand(module: ShellModule): void {
|
|
113
102
|
const normalized: ShellModule = {
|
|
114
103
|
...module,
|
|
@@ -130,7 +119,7 @@ export function registerCommand(module: ShellModule): void {
|
|
|
130
119
|
commandRegistry.set(name, normalized);
|
|
131
120
|
}
|
|
132
121
|
|
|
133
|
-
|
|
122
|
+
buildCache();
|
|
134
123
|
}
|
|
135
124
|
|
|
136
125
|
export function createCustomCommand(
|
|
@@ -153,6 +142,9 @@ export function getCommandNames(): string[] {
|
|
|
153
142
|
}
|
|
154
143
|
|
|
155
144
|
export function resolveModule(name: string): ShellModule | undefined {
|
|
145
|
+
if (!cachedCommandNames) {
|
|
146
|
+
buildCache();
|
|
147
|
+
}
|
|
156
148
|
return commandRegistry.get(name.toLowerCase());
|
|
157
149
|
}
|
|
158
150
|
|
|
@@ -207,94 +199,14 @@ function parseInput(rawInput: string): { commandName: string; args: string[] } {
|
|
|
207
199
|
}
|
|
208
200
|
|
|
209
201
|
// Internal async function for pipeline execution
|
|
210
|
-
async function _runCommandInternal(
|
|
211
|
-
rawInput: string,
|
|
212
|
-
authUser: string,
|
|
213
|
-
hostname: string,
|
|
214
|
-
users: VirtualUserManager,
|
|
215
|
-
mode: CommandMode,
|
|
216
|
-
cwd: string,
|
|
217
|
-
shellProps: ShellProperties,
|
|
218
|
-
vfs: VirtualFileSystem,
|
|
219
|
-
stdin?: string,
|
|
220
|
-
): Promise<CommandResult> {
|
|
221
|
-
// Check if input contains pipes or redirections
|
|
222
|
-
if (
|
|
223
|
-
rawInput.includes("|") ||
|
|
224
|
-
rawInput.includes(">") ||
|
|
225
|
-
rawInput.includes("<")
|
|
226
|
-
) {
|
|
227
|
-
// Use pipeline executor
|
|
228
|
-
const { parseShellPipeline } = await import("../shellParser");
|
|
229
|
-
const { executePipeline } = await import("../../SSHMimic/executor");
|
|
230
|
-
|
|
231
|
-
const pipeline = parseShellPipeline(rawInput);
|
|
232
|
-
if (!pipeline.isValid) {
|
|
233
|
-
return {
|
|
234
|
-
stderr: pipeline.error || "Syntax error",
|
|
235
|
-
exitCode: 1,
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
try {
|
|
240
|
-
return await executePipeline(
|
|
241
|
-
pipeline,
|
|
242
|
-
authUser,
|
|
243
|
-
hostname,
|
|
244
|
-
users,
|
|
245
|
-
mode,
|
|
246
|
-
cwd,
|
|
247
|
-
vfs,
|
|
248
|
-
);
|
|
249
|
-
} catch (error: unknown) {
|
|
250
|
-
const message =
|
|
251
|
-
error instanceof Error ? error.message : "Pipeline execution failed";
|
|
252
|
-
return { stderr: message, exitCode: 1 };
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Regular command execution
|
|
257
|
-
const { commandName, args } = parseInput(rawInput);
|
|
258
|
-
const mod = resolveModule(commandName);
|
|
259
|
-
|
|
260
|
-
if (!mod) {
|
|
261
|
-
return {
|
|
262
|
-
stderr: `Command '${rawInput}' not found`,
|
|
263
|
-
exitCode: 127,
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
try {
|
|
268
|
-
const result = mod.run({
|
|
269
|
-
authUser,
|
|
270
|
-
hostname,
|
|
271
|
-
users,
|
|
272
|
-
activeSessions: users.listActiveSessions(),
|
|
273
|
-
rawInput,
|
|
274
|
-
mode,
|
|
275
|
-
args,
|
|
276
|
-
shellProps,
|
|
277
|
-
stdin,
|
|
278
|
-
cwd,
|
|
279
|
-
vfs,
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
return await Promise.resolve(result);
|
|
283
|
-
} catch (error: unknown) {
|
|
284
|
-
const message = error instanceof Error ? error.message : "Command failed";
|
|
285
|
-
return { stderr: message, exitCode: 1 };
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
202
|
|
|
289
203
|
export async function runCommand(
|
|
290
204
|
rawInput: string,
|
|
291
205
|
authUser: string,
|
|
292
206
|
hostname: string,
|
|
293
|
-
users: VirtualUserManager,
|
|
294
207
|
mode: CommandMode,
|
|
295
208
|
cwd: string,
|
|
296
|
-
|
|
297
|
-
vfs: VirtualFileSystem,
|
|
209
|
+
shell: VirtualShell,
|
|
298
210
|
stdin?: string,
|
|
299
211
|
): Promise<CommandResult> {
|
|
300
212
|
const trimmed = rawInput.trim();
|
|
@@ -304,8 +216,8 @@ export async function runCommand(
|
|
|
304
216
|
}
|
|
305
217
|
|
|
306
218
|
if (trimmed.includes("|") || trimmed.includes(">") || trimmed.includes("<")) {
|
|
307
|
-
const { parseShellPipeline } = await import("../shellParser");
|
|
308
|
-
const { executePipeline } = await import("
|
|
219
|
+
const { parseShellPipeline } = await import("../VirtualShell/shellParser");
|
|
220
|
+
const { executePipeline } = await import("../SSHMimic/executor");
|
|
309
221
|
|
|
310
222
|
const pipeline = parseShellPipeline(trimmed);
|
|
311
223
|
if (!pipeline.isValid) {
|
|
@@ -320,10 +232,9 @@ export async function runCommand(
|
|
|
320
232
|
pipeline,
|
|
321
233
|
authUser,
|
|
322
234
|
hostname,
|
|
323
|
-
users,
|
|
324
235
|
mode,
|
|
325
236
|
cwd,
|
|
326
|
-
|
|
237
|
+
shell,
|
|
327
238
|
);
|
|
328
239
|
} catch (error: unknown) {
|
|
329
240
|
const message =
|
|
@@ -346,15 +257,13 @@ export async function runCommand(
|
|
|
346
257
|
return await mod.run({
|
|
347
258
|
authUser,
|
|
348
259
|
hostname,
|
|
349
|
-
users,
|
|
350
|
-
activeSessions: users.listActiveSessions(),
|
|
260
|
+
activeSessions: shell.users.listActiveSessions(),
|
|
351
261
|
rawInput: trimmed,
|
|
352
262
|
mode,
|
|
353
263
|
args,
|
|
354
264
|
stdin,
|
|
355
265
|
cwd,
|
|
356
|
-
|
|
357
|
-
shellProps,
|
|
266
|
+
shell,
|
|
358
267
|
});
|
|
359
268
|
} catch (error: unknown) {
|
|
360
269
|
const message = error instanceof Error ? error.message : "Command failed";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ShellModule } from "
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
2
|
import { getArg, ifFlag } from "./command-helpers";
|
|
3
3
|
import { assertPathAccess, joinListWithType, resolvePath } from "./helpers";
|
|
4
4
|
|
|
@@ -29,22 +29,24 @@ function formatDate(date: Date): string {
|
|
|
29
29
|
export const lsCommand: ShellModule = {
|
|
30
30
|
name: "ls",
|
|
31
31
|
params: ["[path]"],
|
|
32
|
-
run: ({ authUser,
|
|
32
|
+
run: ({ authUser, shell, cwd, args }) => {
|
|
33
33
|
const longFormat = ifFlag(args, ["-l", "--long"]);
|
|
34
34
|
const targetArg = getArg(args, 0, { flags: ["-l", "--long"] });
|
|
35
35
|
const target = resolvePath(cwd, targetArg ?? cwd);
|
|
36
36
|
assertPathAccess(authUser, target, "ls");
|
|
37
|
-
const items = vfs
|
|
37
|
+
const items = shell.vfs
|
|
38
|
+
.list(target)
|
|
39
|
+
.filter((name) => !name.startsWith("."));
|
|
38
40
|
const rendered = longFormat
|
|
39
41
|
? items
|
|
40
42
|
.map((name) => {
|
|
41
43
|
const childPath = resolvePath(target, name);
|
|
42
|
-
const stat = vfs.stat(childPath);
|
|
44
|
+
const stat = shell.vfs.stat(childPath);
|
|
43
45
|
const size = stat.type === "file" ? stat.size : stat.childrenCount;
|
|
44
46
|
return `${formatPermissions(stat.mode, stat.type === "directory")} 1 ${size} ${formatDate(stat.updatedAt)} ${name}${stat.type === "directory" ? "/" : ""}`;
|
|
45
47
|
})
|
|
46
48
|
.join("\n")
|
|
47
|
-
: joinListWithType(target, items, (p) => vfs.stat(p));
|
|
49
|
+
: joinListWithType(target, items, (p) => shell.vfs.stat(p));
|
|
48
50
|
return { stdout: rendered, exitCode: 0 };
|
|
49
51
|
},
|
|
50
52
|
};
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { ShellModule } from "
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
2
|
import { getArg } from "./command-helpers";
|
|
3
3
|
import { assertPathAccess, resolvePath } from "./helpers";
|
|
4
4
|
|
|
5
5
|
export const mkdirCommand: ShellModule = {
|
|
6
6
|
name: "mkdir",
|
|
7
7
|
params: ["<dir>"],
|
|
8
|
-
run: ({ authUser,
|
|
8
|
+
run: ({ authUser, shell, cwd, args }) => {
|
|
9
9
|
if (args.length === 0) {
|
|
10
10
|
return { stderr: "mkdir: missing operand", exitCode: 1 };
|
|
11
11
|
}
|
|
@@ -17,7 +17,7 @@ export const mkdirCommand: ShellModule = {
|
|
|
17
17
|
}
|
|
18
18
|
const target = resolvePath(cwd, dir);
|
|
19
19
|
assertPathAccess(authUser, target, "mkdir");
|
|
20
|
-
vfs.mkdir(target);
|
|
20
|
+
shell.vfs.mkdir(target);
|
|
21
21
|
}
|
|
22
22
|
return { exitCode: 0 };
|
|
23
23
|
},
|