typescript-virtual-container 1.2.4 → 1.2.5
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 +868 -1245
- package/benchmark-results.txt +21 -21
- package/dist/SSHMimic/index.d.ts +19 -2
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +116 -20
- package/dist/VirtualFileSystem/index.d.ts +115 -88
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +406 -258
- package/dist/VirtualShell/index.d.ts +3 -4
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +4 -6
- package/dist/VirtualUserManager/index.d.ts +25 -0
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +33 -0
- package/dist/commands/chmod.d.ts +3 -0
- package/dist/commands/chmod.d.ts.map +1 -0
- package/dist/commands/chmod.js +31 -0
- package/dist/commands/cp.d.ts +3 -0
- package/dist/commands/cp.d.ts.map +1 -0
- package/dist/commands/cp.js +68 -0
- package/dist/commands/find.d.ts +3 -0
- package/dist/commands/find.d.ts.map +1 -0
- package/dist/commands/find.js +48 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +61 -35
- package/dist/commands/head.d.ts +3 -0
- package/dist/commands/head.d.ts.map +1 -0
- package/dist/commands/head.js +30 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +25 -35
- package/dist/commands/ln.d.ts +3 -0
- package/dist/commands/ln.d.ts.map +1 -0
- package/dist/commands/ln.js +42 -0
- package/dist/commands/mv.d.ts +3 -0
- package/dist/commands/mv.d.ts.map +1 -0
- package/dist/commands/mv.js +35 -0
- package/dist/commands/tail.d.ts +3 -0
- package/dist/commands/tail.d.ts.map +1 -0
- package/dist/commands/tail.js +33 -0
- package/dist/commands/wc.d.ts +3 -0
- package/dist/commands/wc.d.ts.map +1 -0
- package/dist/commands/wc.js +48 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/package.json +5 -2
- package/scripts/publish-package.sh +70 -0
- package/src/SSHMimic/index.ts +143 -28
- package/src/VirtualFileSystem/index.ts +500 -280
- package/src/VirtualShell/index.ts +4 -6
- package/src/VirtualUserManager/index.ts +41 -0
- package/src/commands/chmod.ts +33 -0
- package/src/commands/cp.ts +76 -0
- package/src/commands/find.ts +61 -0
- package/src/commands/grep.ts +54 -38
- package/src/commands/head.ts +35 -0
- package/src/commands/index.ts +25 -43
- package/src/commands/ln.ts +47 -0
- package/src/commands/mv.ts +43 -0
- package/src/commands/tail.ts +37 -0
- package/src/commands/wc.ts +48 -0
- package/src/index.ts +1 -0
- package/standalone.js +62 -52
- package/standalone.js.map +4 -4
- package/tests/bun-test-shim.ts +1 -0
- package/tests/sftp.test.ts +115 -191
- package/tests/users.test.ts +66 -83
|
@@ -4,7 +4,7 @@ import type { CommandContext, CommandResult } from "../types/commands";
|
|
|
4
4
|
import type { ShellStream } from "../types/streams";
|
|
5
5
|
import type { PerfLogger } from "../utils/perfLogger";
|
|
6
6
|
import { createPerfLogger } from "../utils/perfLogger";
|
|
7
|
-
import VirtualFileSystem from "../VirtualFileSystem";
|
|
7
|
+
import VirtualFileSystem, { type VfsOptions } from "../VirtualFileSystem";
|
|
8
8
|
import { VirtualUserManager } from "../VirtualUserManager";
|
|
9
9
|
import { startShell } from "./shell";
|
|
10
10
|
|
|
@@ -38,7 +38,6 @@ function resolveAutoSudoForNewUsers(): boolean {
|
|
|
38
38
|
* client API.
|
|
39
39
|
*/
|
|
40
40
|
class VirtualShell extends EventEmitter {
|
|
41
|
-
basePath: string = ".";
|
|
42
41
|
vfs: VirtualFileSystem;
|
|
43
42
|
users: VirtualUserManager;
|
|
44
43
|
hostname: string;
|
|
@@ -50,19 +49,18 @@ class VirtualShell extends EventEmitter {
|
|
|
50
49
|
*
|
|
51
50
|
* @param hostname Virtual hostname used for prompts and idents.
|
|
52
51
|
* @param properties Customizable properties shown in `uname -a` and similar commands.
|
|
53
|
-
* @param
|
|
52
|
+
* @param vfsOptions Optional VFS persistence options (mode, snapshotPath).
|
|
54
53
|
*/
|
|
55
54
|
constructor(
|
|
56
55
|
hostname: string,
|
|
57
56
|
properties?: ShellProperties,
|
|
58
|
-
|
|
57
|
+
vfsOptions?: VfsOptions,
|
|
59
58
|
) {
|
|
60
59
|
super();
|
|
61
60
|
perf.mark("constructor");
|
|
62
61
|
this.hostname = hostname;
|
|
63
62
|
this.properties = properties || defaultShellProperties;
|
|
64
|
-
this.
|
|
65
|
-
this.vfs = new VirtualFileSystem(this.basePath);
|
|
63
|
+
this.vfs = new VirtualFileSystem(vfsOptions ?? {});
|
|
66
64
|
this.users = new VirtualUserManager(this.vfs, resolveAutoSudoForNewUsers());
|
|
67
65
|
|
|
68
66
|
// Store references to avoid TypeScript "used before assigned" errors
|
|
@@ -648,6 +648,47 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
648
648
|
throw new Error("invalid password");
|
|
649
649
|
}
|
|
650
650
|
}
|
|
651
|
+
private readonly authorizedKeys = new Map<
|
|
652
|
+
string,
|
|
653
|
+
Array<{ algo: string; data: Buffer }>
|
|
654
|
+
>();
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Adds an SSH public key for a user, enabling public-key authentication.
|
|
658
|
+
*
|
|
659
|
+
* @param username Target user.
|
|
660
|
+
* @param algo Key algorithm (e.g. "ssh-rsa", "ssh-ed25519").
|
|
661
|
+
* @param data Raw key data as a Buffer (the base64-decoded key bytes).
|
|
662
|
+
*/
|
|
663
|
+
public addAuthorizedKey(username: string, algo: string, data: Buffer): void {
|
|
664
|
+
perf.mark("addAuthorizedKey");
|
|
665
|
+
const keys = this.authorizedKeys.get(username) ?? [];
|
|
666
|
+
keys.push({ algo, data });
|
|
667
|
+
this.authorizedKeys.set(username, keys);
|
|
668
|
+
this.emit("key:add", { username, algo });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Removes all authorized keys for a user.
|
|
673
|
+
*
|
|
674
|
+
* @param username Target user.
|
|
675
|
+
*/
|
|
676
|
+
public removeAuthorizedKeys(username: string): void {
|
|
677
|
+
this.authorizedKeys.delete(username);
|
|
678
|
+
this.emit("key:remove", { username });
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Returns the list of authorized keys for a user.
|
|
683
|
+
* Returns an empty array when no keys are registered.
|
|
684
|
+
*
|
|
685
|
+
* @param username Target user.
|
|
686
|
+
*/
|
|
687
|
+
public getAuthorizedKeys(
|
|
688
|
+
username: string,
|
|
689
|
+
): Array<{ algo: string; data: Buffer }> {
|
|
690
|
+
return this.authorizedKeys.get(username) ?? [];
|
|
691
|
+
}
|
|
651
692
|
}
|
|
652
693
|
|
|
653
694
|
function normalizeVfsPath(targetPath: string): string {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
|
+
|
|
4
|
+
export const chmodCommand: ShellModule = {
|
|
5
|
+
name: "chmod",
|
|
6
|
+
params: ["<mode> <file>"],
|
|
7
|
+
run: ({ authUser, shell, cwd, args }) => {
|
|
8
|
+
const [modeArg, fileArg] = args;
|
|
9
|
+
if (!modeArg || !fileArg) {
|
|
10
|
+
return { stderr: "chmod: missing operand", exitCode: 1 };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const filePath = resolvePath(cwd, fileArg);
|
|
14
|
+
try {
|
|
15
|
+
assertPathAccess(authUser, filePath, "chmod");
|
|
16
|
+
if (!shell.vfs.exists(filePath)) {
|
|
17
|
+
return {
|
|
18
|
+
stderr: `chmod: ${fileArg}: No such file or directory`,
|
|
19
|
+
exitCode: 1,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const mode = parseInt(modeArg, 8);
|
|
23
|
+
if (Number.isNaN(mode)) {
|
|
24
|
+
return { stderr: `chmod: invalid mode: ${modeArg}`, exitCode: 1 };
|
|
25
|
+
}
|
|
26
|
+
shell.vfs.chmod(filePath, mode);
|
|
27
|
+
return { exitCode: 0 };
|
|
28
|
+
} catch (err) {
|
|
29
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
30
|
+
return { stderr: `chmod: ${msg}`, exitCode: 1 };
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
|
+
import { ifFlag } from "./command-helpers";
|
|
3
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
4
|
+
|
|
5
|
+
export const cpCommand: ShellModule = {
|
|
6
|
+
name: "cp",
|
|
7
|
+
params: ["[-r] <source> <dest>"],
|
|
8
|
+
run: ({ authUser, shell, cwd, args }) => {
|
|
9
|
+
const recursive = ifFlag(args, ["-r", "-R", "--recursive"]);
|
|
10
|
+
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
11
|
+
const [srcArg, destArg] = positionals;
|
|
12
|
+
|
|
13
|
+
if (!srcArg || !destArg) {
|
|
14
|
+
return { stderr: "cp: missing operand", exitCode: 1 };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const srcPath = resolvePath(cwd, srcArg);
|
|
18
|
+
const destPath = resolvePath(cwd, destArg);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
assertPathAccess(authUser, srcPath, "cp");
|
|
22
|
+
assertPathAccess(authUser, destPath, "cp");
|
|
23
|
+
|
|
24
|
+
if (!shell.vfs.exists(srcPath)) {
|
|
25
|
+
return {
|
|
26
|
+
stderr: `cp: ${srcArg}: No such file or directory`,
|
|
27
|
+
exitCode: 1,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const srcStat = shell.vfs.stat(srcPath);
|
|
32
|
+
|
|
33
|
+
if (srcStat.type === "directory") {
|
|
34
|
+
if (!recursive) {
|
|
35
|
+
return {
|
|
36
|
+
stderr: `cp: ${srcArg}: is a directory (use -r)`,
|
|
37
|
+
exitCode: 1,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const copyDir = (from: string, to: string) => {
|
|
41
|
+
shell.vfs.mkdir(to, 0o755);
|
|
42
|
+
for (const entry of shell.vfs.list(from)) {
|
|
43
|
+
const fromEntry = `${from}/${entry}`;
|
|
44
|
+
const toEntry = `${to}/${entry}`;
|
|
45
|
+
const stat = shell.vfs.stat(fromEntry);
|
|
46
|
+
if (stat.type === "directory") {
|
|
47
|
+
copyDir(fromEntry, toEntry);
|
|
48
|
+
} else {
|
|
49
|
+
const content = shell.vfs.readFileRaw(fromEntry);
|
|
50
|
+
shell.writeFileAsUser(authUser, toEntry, content);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const finalDest =
|
|
55
|
+
shell.vfs.exists(destPath) &&
|
|
56
|
+
shell.vfs.stat(destPath).type === "directory"
|
|
57
|
+
? `${destPath}/${srcArg.split("/").pop()}`
|
|
58
|
+
: destPath;
|
|
59
|
+
copyDir(srcPath, finalDest);
|
|
60
|
+
} else {
|
|
61
|
+
const finalDest =
|
|
62
|
+
shell.vfs.exists(destPath) &&
|
|
63
|
+
shell.vfs.stat(destPath).type === "directory"
|
|
64
|
+
? `${destPath}/${srcArg.split("/").pop()}`
|
|
65
|
+
: destPath;
|
|
66
|
+
const content = shell.vfs.readFileRaw(srcPath);
|
|
67
|
+
shell.writeFileAsUser(authUser, finalDest, content);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { exitCode: 0 };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
73
|
+
return { stderr: `cp: ${msg}`, exitCode: 1 };
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
|
+
import { getFlag } from "./command-helpers";
|
|
3
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
4
|
+
|
|
5
|
+
export const findCommand: ShellModule = {
|
|
6
|
+
name: "find",
|
|
7
|
+
params: ["[path] [-name <pattern>] [-type f|d]"],
|
|
8
|
+
run: ({ authUser, shell, cwd, args }) => {
|
|
9
|
+
const namePattern = getFlag(args, ["-name"]);
|
|
10
|
+
const typeFilter = getFlag(args, ["-type"]);
|
|
11
|
+
const positionals = args.filter(
|
|
12
|
+
(a) => !a.startsWith("-") && a !== namePattern && a !== typeFilter,
|
|
13
|
+
);
|
|
14
|
+
const rootArg = positionals[0] ?? ".";
|
|
15
|
+
const rootPath = resolvePath(cwd, rootArg);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
assertPathAccess(authUser, rootPath, "find");
|
|
19
|
+
if (!shell.vfs.exists(rootPath)) {
|
|
20
|
+
return {
|
|
21
|
+
stderr: `find: ${rootArg}: No such file or directory`,
|
|
22
|
+
exitCode: 1,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
27
|
+
return { stderr: `find: ${msg}`, exitCode: 1 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const nameRegex = namePattern
|
|
31
|
+
? new RegExp(
|
|
32
|
+
`^${(namePattern as string).replace(/\./g, "\\.").replace(/\*/g, ".*").replace(/\?/g, ".")}$`,
|
|
33
|
+
)
|
|
34
|
+
: null;
|
|
35
|
+
|
|
36
|
+
const results: string[] = [];
|
|
37
|
+
const walk = (currentPath: string, display: string) => {
|
|
38
|
+
const stat = shell.vfs.stat(currentPath);
|
|
39
|
+
|
|
40
|
+
const matchesType =
|
|
41
|
+
!typeFilter ||
|
|
42
|
+
(typeFilter === "f" && stat.type === "file") ||
|
|
43
|
+
(typeFilter === "d" && stat.type === "directory");
|
|
44
|
+
const matchesName =
|
|
45
|
+
!nameRegex || nameRegex.test(currentPath.split("/").pop() ?? "");
|
|
46
|
+
|
|
47
|
+
if (matchesType && matchesName) results.push(display);
|
|
48
|
+
|
|
49
|
+
if (stat.type === "directory") {
|
|
50
|
+
for (const entry of shell.vfs.list(currentPath)) {
|
|
51
|
+
const full = `${currentPath}/${entry}`;
|
|
52
|
+
const disp = `${display}/${entry}`;
|
|
53
|
+
walk(full, disp);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
walk(rootPath, rootArg);
|
|
59
|
+
return { stdout: results.join("\n"), exitCode: 0 };
|
|
60
|
+
},
|
|
61
|
+
};
|
package/src/commands/grep.ts
CHANGED
|
@@ -4,11 +4,15 @@ import { assertPathAccess, resolvePath } from "./helpers";
|
|
|
4
4
|
|
|
5
5
|
export const grepCommand: ShellModule = {
|
|
6
6
|
name: "grep",
|
|
7
|
-
params: ["[-i] [-v] <pattern> [file...]"],
|
|
7
|
+
params: ["[-i] [-v] [-n] [-r] <pattern> [file...]"],
|
|
8
8
|
run: ({ authUser, shell, cwd, args, stdin }) => {
|
|
9
|
-
const { flags, positionals } = parseArgs(args, {
|
|
9
|
+
const { flags, positionals } = parseArgs(args, {
|
|
10
|
+
flags: ["-i", "-v", "-n", "-r"],
|
|
11
|
+
});
|
|
10
12
|
const caseInsensitive = flags.has("-i");
|
|
11
13
|
const invertMatch = flags.has("-v");
|
|
14
|
+
const showLineNumbers = flags.has("-n");
|
|
15
|
+
const recursive = flags.has("-r");
|
|
12
16
|
const pattern = positionals[0];
|
|
13
17
|
const files = positionals.slice(1);
|
|
14
18
|
|
|
@@ -18,57 +22,69 @@ export const grepCommand: ShellModule = {
|
|
|
18
22
|
|
|
19
23
|
let regex: RegExp;
|
|
20
24
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
// No "g" flag — avoids the stateful lastIndex problem with regex.test()
|
|
26
|
+
const regexFlags = caseInsensitive ? "mi" : "m";
|
|
27
|
+
regex = new RegExp(pattern, regexFlags);
|
|
23
28
|
} catch {
|
|
24
29
|
return { stderr: `grep: invalid regex: ${pattern}`, exitCode: 1 };
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const lines = stdin.split("\n");
|
|
35
|
-
for (const line of lines) {
|
|
36
|
-
regex.lastIndex = 0;
|
|
32
|
+
const matchLines = (content: string, prefix = ""): string[] => {
|
|
33
|
+
const lines = content.split("\n");
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
const line = lines[i] ?? "";
|
|
37
37
|
const matches = regex.test(line);
|
|
38
38
|
const shouldInclude = invertMatch ? !matches : matches;
|
|
39
|
-
|
|
40
39
|
if (shouldInclude) {
|
|
41
|
-
|
|
40
|
+
const lineLabel = showLineNumbers ? `${i + 1}:` : "";
|
|
41
|
+
out.push(`${prefix}${lineLabel}${line}`);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
return out;
|
|
45
|
+
};
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
const readPaths = (base: string): string[] => {
|
|
48
|
+
if (!shell.vfs.exists(base)) return [];
|
|
49
|
+
const stat = shell.vfs.stat(base);
|
|
50
|
+
if (stat.type === "file") return [base];
|
|
51
|
+
if (!recursive) return [];
|
|
52
|
+
const paths: string[] = [];
|
|
53
|
+
const walk = (dir: string) => {
|
|
54
|
+
for (const entry of shell.vfs.list(dir)) {
|
|
55
|
+
const full = `${dir}/${entry}`;
|
|
56
|
+
const s = shell.vfs.stat(full);
|
|
57
|
+
if (s.type === "file") paths.push(full);
|
|
58
|
+
else walk(full);
|
|
59
|
+
}
|
|
48
60
|
};
|
|
49
|
-
|
|
61
|
+
walk(base);
|
|
62
|
+
return paths;
|
|
63
|
+
};
|
|
50
64
|
|
|
51
|
-
|
|
52
|
-
const target = resolvePath(cwd, file);
|
|
53
|
-
try {
|
|
54
|
-
assertPathAccess(authUser, target, "grep");
|
|
55
|
-
const content = shell.vfs.readFile(target);
|
|
56
|
-
const lines = content.split("\n");
|
|
65
|
+
const results: string[] = [];
|
|
57
66
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
if (files.length === 0) {
|
|
68
|
+
if (!stdin) return { stdout: "", exitCode: 1 };
|
|
69
|
+
results.push(...matchLines(stdin));
|
|
70
|
+
} else {
|
|
71
|
+
const resolvedPaths = files.flatMap((f) => {
|
|
72
|
+
const target = resolvePath(cwd, f);
|
|
73
|
+
return readPaths(target).map((p) => ({ file: f, path: p }));
|
|
74
|
+
});
|
|
62
75
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
for (const { file, path: filePath } of resolvedPaths) {
|
|
77
|
+
try {
|
|
78
|
+
assertPathAccess(authUser, filePath, "grep");
|
|
79
|
+
const content = shell.vfs.readFile(filePath);
|
|
80
|
+
const prefix = resolvedPaths.length > 1 ? `${file}:` : "";
|
|
81
|
+
results.push(...matchLines(content, prefix));
|
|
82
|
+
} catch {
|
|
83
|
+
return {
|
|
84
|
+
stderr: `grep: ${file}: No such file or directory`,
|
|
85
|
+
exitCode: 1,
|
|
86
|
+
};
|
|
66
87
|
}
|
|
67
|
-
} catch {
|
|
68
|
-
return {
|
|
69
|
-
stderr: `grep: ${file}: No such file or directory`,
|
|
70
|
-
exitCode: 1,
|
|
71
|
-
};
|
|
72
88
|
}
|
|
73
89
|
}
|
|
74
90
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
|
+
import { getFlag } from "./command-helpers";
|
|
3
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
4
|
+
|
|
5
|
+
export const headCommand: ShellModule = {
|
|
6
|
+
name: "head",
|
|
7
|
+
params: ["[-n <lines>] [file...]"],
|
|
8
|
+
run: ({ authUser, shell, cwd, args, stdin }) => {
|
|
9
|
+
const nArg = getFlag(args, ["-n"]);
|
|
10
|
+
const n = typeof nArg === "string" ? parseInt(nArg, 10) : 10;
|
|
11
|
+
const positionals = args.filter((a) => !a.startsWith("-") && a !== nArg);
|
|
12
|
+
|
|
13
|
+
const take = (content: string) =>
|
|
14
|
+
content.split("\n").slice(0, n).join("\n");
|
|
15
|
+
|
|
16
|
+
if (positionals.length === 0) {
|
|
17
|
+
return { stdout: take(stdin ?? ""), exitCode: 0 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const results: string[] = [];
|
|
21
|
+
for (const file of positionals) {
|
|
22
|
+
const filePath = resolvePath(cwd, file);
|
|
23
|
+
try {
|
|
24
|
+
assertPathAccess(authUser, filePath, "head");
|
|
25
|
+
results.push(take(shell.vfs.readFile(filePath)));
|
|
26
|
+
} catch {
|
|
27
|
+
return {
|
|
28
|
+
stderr: `head: ${file}: No such file or directory`,
|
|
29
|
+
exitCode: 1,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { stdout: results.join("\n"), exitCode: 0 };
|
|
34
|
+
},
|
|
35
|
+
};
|
package/src/commands/index.ts
CHANGED
|
@@ -8,19 +8,25 @@ import type {
|
|
|
8
8
|
import { adduserCommand } from "./adduser";
|
|
9
9
|
import { catCommand } from "./cat";
|
|
10
10
|
import { cdCommand } from "./cd";
|
|
11
|
+
import { chmodCommand } from "./chmod";
|
|
11
12
|
import { clearCommand } from "./clear";
|
|
13
|
+
import { cpCommand } from "./cp";
|
|
12
14
|
import { curlCommand } from "./curl";
|
|
13
15
|
import { deluserCommand } from "./deluser";
|
|
14
16
|
import { echoCommand } from "./echo";
|
|
15
17
|
import { envCommand } from "./env";
|
|
16
18
|
import { exitCommand } from "./exit";
|
|
17
19
|
import { exportCommand } from "./export";
|
|
20
|
+
import { findCommand } from "./find";
|
|
18
21
|
import { grepCommand } from "./grep";
|
|
22
|
+
import { headCommand } from "./head";
|
|
19
23
|
import { createHelpCommand } from "./help";
|
|
20
24
|
import { hostnameCommand } from "./hostname";
|
|
21
25
|
import { htopCommand } from "./htop";
|
|
26
|
+
import { lnCommand } from "./ln";
|
|
22
27
|
import { lsCommand } from "./ls";
|
|
23
28
|
import { mkdirCommand } from "./mkdir";
|
|
29
|
+
import { mvCommand } from "./mv";
|
|
24
30
|
import { nanoCommand } from "./nano";
|
|
25
31
|
import { neofetchCommand } from "./neofetch";
|
|
26
32
|
import { passwdCommand } from "./passwd";
|
|
@@ -30,9 +36,11 @@ import { setCommand } from "./set";
|
|
|
30
36
|
import { shCommand } from "./sh";
|
|
31
37
|
import { suCommand } from "./su";
|
|
32
38
|
import { sudoCommand } from "./sudo";
|
|
39
|
+
import { tailCommand } from "./tail";
|
|
33
40
|
import { touchCommand } from "./touch";
|
|
34
41
|
import { treeCommand } from "./tree";
|
|
35
42
|
import { unsetCommand } from "./unset";
|
|
43
|
+
import { wcCommand } from "./wc";
|
|
36
44
|
import { wgetCommand } from "./wget";
|
|
37
45
|
import { whoCommand } from "./who";
|
|
38
46
|
import { whoamiCommand } from "./whoami";
|
|
@@ -68,6 +76,14 @@ const BASE_COMMANDS: ShellModule[] = [
|
|
|
68
76
|
shCommand,
|
|
69
77
|
clearCommand,
|
|
70
78
|
exitCommand,
|
|
79
|
+
cpCommand,
|
|
80
|
+
mvCommand,
|
|
81
|
+
lnCommand,
|
|
82
|
+
findCommand,
|
|
83
|
+
wcCommand,
|
|
84
|
+
headCommand,
|
|
85
|
+
tailCommand,
|
|
86
|
+
chmodCommand,
|
|
71
87
|
];
|
|
72
88
|
|
|
73
89
|
const customCommands: ShellModule[] = [];
|
|
@@ -80,6 +96,7 @@ const commandRegistry = new Map<string, ShellModule>();
|
|
|
80
96
|
let cachedCommandNames: string[] | null = null;
|
|
81
97
|
|
|
82
98
|
function buildCache(): void {
|
|
99
|
+
commandRegistry.clear();
|
|
83
100
|
for (const mod of getCommandModules()) {
|
|
84
101
|
commandRegistry.set(mod.name, mod);
|
|
85
102
|
for (const alias of mod.aliases ?? []) {
|
|
@@ -90,13 +107,6 @@ function buildCache(): void {
|
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
function getCommandModules(): ShellModule[] {
|
|
93
|
-
// console.log("Loading command modules...");
|
|
94
|
-
// console.log(
|
|
95
|
-
// `Base commands: ${BASE_COMMANDS.map((cmd) => cmd.name).join(", ")}`,
|
|
96
|
-
// );
|
|
97
|
-
// console.log(
|
|
98
|
-
// `Custom commands: ${customCommands.map((cmd) => cmd.name).join(", ")}`,
|
|
99
|
-
// );
|
|
100
110
|
return [...BASE_COMMANDS, ...customCommands, helpCommand];
|
|
101
111
|
}
|
|
102
112
|
|
|
@@ -114,13 +124,7 @@ export function registerCommand(module: ShellModule): void {
|
|
|
114
124
|
);
|
|
115
125
|
}
|
|
116
126
|
|
|
117
|
-
|
|
118
|
-
if (commandRegistry.has(name)) {
|
|
119
|
-
throw new Error(`Command '${name}' already exists`);
|
|
120
|
-
}
|
|
121
|
-
commandRegistry.set(name, normalized);
|
|
122
|
-
}
|
|
123
|
-
|
|
127
|
+
customCommands.push(normalized);
|
|
124
128
|
buildCache();
|
|
125
129
|
}
|
|
126
130
|
|
|
@@ -129,24 +133,16 @@ export function createCustomCommand(
|
|
|
129
133
|
params: string[],
|
|
130
134
|
run: (ctx: CommandContext) => CommandResult | Promise<CommandResult>,
|
|
131
135
|
): ShellModule {
|
|
132
|
-
return {
|
|
133
|
-
name,
|
|
134
|
-
params,
|
|
135
|
-
run,
|
|
136
|
-
};
|
|
136
|
+
return { name, params, run };
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
export function getCommandNames(): string[] {
|
|
140
|
-
if (!cachedCommandNames)
|
|
141
|
-
buildCache();
|
|
142
|
-
}
|
|
140
|
+
if (!cachedCommandNames) buildCache();
|
|
143
141
|
return cachedCommandNames!;
|
|
144
142
|
}
|
|
145
143
|
|
|
146
144
|
export function resolveModule(name: string): ShellModule | undefined {
|
|
147
|
-
if (!cachedCommandNames)
|
|
148
|
-
buildCache();
|
|
149
|
-
}
|
|
145
|
+
if (!cachedCommandNames) buildCache();
|
|
150
146
|
return commandRegistry.get(name.toLowerCase());
|
|
151
147
|
}
|
|
152
148
|
|
|
@@ -166,7 +162,6 @@ function splitArgsRespectingQuotes(input: string): string[] {
|
|
|
166
162
|
quoteChar = ch;
|
|
167
163
|
continue;
|
|
168
164
|
}
|
|
169
|
-
|
|
170
165
|
if (ch === quoteChar) {
|
|
171
166
|
inQuotes = false;
|
|
172
167
|
quoteChar = "";
|
|
@@ -185,10 +180,7 @@ function splitArgsRespectingQuotes(input: string): string[] {
|
|
|
185
180
|
current += ch;
|
|
186
181
|
}
|
|
187
182
|
|
|
188
|
-
if (current.length > 0)
|
|
189
|
-
tokens.push(current);
|
|
190
|
-
}
|
|
191
|
-
|
|
183
|
+
if (current.length > 0) tokens.push(current);
|
|
192
184
|
return tokens;
|
|
193
185
|
}
|
|
194
186
|
|
|
@@ -200,8 +192,6 @@ function parseInput(rawInput: string): { commandName: string; args: string[] } {
|
|
|
200
192
|
};
|
|
201
193
|
}
|
|
202
194
|
|
|
203
|
-
// Internal async function for pipeline execution
|
|
204
|
-
|
|
205
195
|
export async function runCommand(
|
|
206
196
|
rawInput: string,
|
|
207
197
|
authUser: string,
|
|
@@ -213,9 +203,7 @@ export async function runCommand(
|
|
|
213
203
|
): Promise<CommandResult> {
|
|
214
204
|
const trimmed = rawInput.trim();
|
|
215
205
|
|
|
216
|
-
if (trimmed.length === 0) {
|
|
217
|
-
return { exitCode: 0 };
|
|
218
|
-
}
|
|
206
|
+
if (trimmed.length === 0) return { exitCode: 0 };
|
|
219
207
|
|
|
220
208
|
if (trimmed.includes("|") || trimmed.includes(">") || trimmed.includes("<")) {
|
|
221
209
|
const { parseShellPipeline } = await import("../VirtualShell/shellParser");
|
|
@@ -223,10 +211,7 @@ export async function runCommand(
|
|
|
223
211
|
|
|
224
212
|
const pipeline = parseShellPipeline(trimmed);
|
|
225
213
|
if (!pipeline.isValid) {
|
|
226
|
-
return {
|
|
227
|
-
stderr: pipeline.error || "Syntax error",
|
|
228
|
-
exitCode: 1,
|
|
229
|
-
};
|
|
214
|
+
return { stderr: pipeline.error || "Syntax error", exitCode: 1 };
|
|
230
215
|
}
|
|
231
216
|
|
|
232
217
|
try {
|
|
@@ -249,10 +234,7 @@ export async function runCommand(
|
|
|
249
234
|
const mod = resolveModule(commandName);
|
|
250
235
|
|
|
251
236
|
if (!mod) {
|
|
252
|
-
return {
|
|
253
|
-
stderr: `Command '${trimmed}' not found`,
|
|
254
|
-
exitCode: 127,
|
|
255
|
-
};
|
|
237
|
+
return { stderr: `Command '${trimmed}' not found`, exitCode: 127 };
|
|
256
238
|
}
|
|
257
239
|
|
|
258
240
|
try {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
|
+
import { ifFlag } from "./command-helpers";
|
|
3
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
4
|
+
|
|
5
|
+
export const lnCommand: ShellModule = {
|
|
6
|
+
name: "ln",
|
|
7
|
+
params: ["[-s] <target> <link_name>"],
|
|
8
|
+
run: ({ authUser, shell, cwd, args }) => {
|
|
9
|
+
const symbolic = ifFlag(args, ["-s", "--symbolic"]);
|
|
10
|
+
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
11
|
+
const [targetArg, linkArg] = positionals;
|
|
12
|
+
|
|
13
|
+
if (!targetArg || !linkArg) {
|
|
14
|
+
return { stderr: "ln: missing operand", exitCode: 1 };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const linkPath = resolvePath(cwd, linkArg);
|
|
18
|
+
const targetPath = symbolic
|
|
19
|
+
? targetArg // keep relative for symlinks
|
|
20
|
+
: resolvePath(cwd, targetArg);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
assertPathAccess(authUser, linkPath, "ln");
|
|
24
|
+
|
|
25
|
+
if (!symbolic) {
|
|
26
|
+
// Hard link — copy file contents
|
|
27
|
+
const srcPath = resolvePath(cwd, targetArg);
|
|
28
|
+
assertPathAccess(authUser, srcPath, "ln");
|
|
29
|
+
if (!shell.vfs.exists(srcPath)) {
|
|
30
|
+
return {
|
|
31
|
+
stderr: `ln: ${targetArg}: No such file or directory`,
|
|
32
|
+
exitCode: 1,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const content = shell.vfs.readFile(srcPath);
|
|
36
|
+
shell.writeFileAsUser(authUser, linkPath, content);
|
|
37
|
+
} else {
|
|
38
|
+
shell.vfs.symlink(targetPath, linkPath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { exitCode: 0 };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
return { stderr: `ln: ${msg}`, exitCode: 1 };
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ShellModule } from "../types/commands";
|
|
2
|
+
import { assertPathAccess, resolvePath } from "./helpers";
|
|
3
|
+
|
|
4
|
+
export const mvCommand: ShellModule = {
|
|
5
|
+
name: "mv",
|
|
6
|
+
params: ["<source> <dest>"],
|
|
7
|
+
run: ({ authUser, shell, cwd, args }) => {
|
|
8
|
+
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
9
|
+
const [srcArg, destArg] = positionals;
|
|
10
|
+
|
|
11
|
+
if (!srcArg || !destArg) {
|
|
12
|
+
return { stderr: "mv: missing operand", exitCode: 1 };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const srcPath = resolvePath(cwd, srcArg);
|
|
16
|
+
const destPath = resolvePath(cwd, destArg);
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
assertPathAccess(authUser, srcPath, "mv");
|
|
20
|
+
assertPathAccess(authUser, destPath, "mv");
|
|
21
|
+
|
|
22
|
+
if (!shell.vfs.exists(srcPath)) {
|
|
23
|
+
return {
|
|
24
|
+
stderr: `mv: ${srcArg}: No such file or directory`,
|
|
25
|
+
exitCode: 1,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// If dest is a directory, move into it
|
|
30
|
+
const finalDest =
|
|
31
|
+
shell.vfs.exists(destPath) &&
|
|
32
|
+
shell.vfs.stat(destPath).type === "directory"
|
|
33
|
+
? `${destPath}/${srcArg.split("/").pop()}`
|
|
34
|
+
: destPath;
|
|
35
|
+
|
|
36
|
+
shell.vfs.move(srcPath, finalDest);
|
|
37
|
+
return { exitCode: 0 };
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
40
|
+
return { stderr: `mv: ${msg}`, exitCode: 1 };
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|