typescript-virtual-container 1.2.3 → 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 +871 -1231
- package/benchmark-results.txt +21 -21
- package/biome.json +9 -0
- package/dist/SSHMimic/index.d.ts +19 -2
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +127 -15
- 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 +5 -23
- package/dist/VirtualUserManager/index.d.ts +41 -3
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +83 -21
- 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/dist/standalone.js +7 -9
- package/package.json +7 -3
- package/scripts/publish-package.sh +70 -0
- package/src/SSHMimic/index.ts +159 -17
- package/src/VirtualFileSystem/index.ts +500 -280
- package/src/VirtualShell/index.ts +5 -33
- package/src/VirtualUserManager/index.ts +92 -26
- 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/src/standalone.ts +12 -9
- package/standalone.js +102 -0
- package/standalone.js.map +7 -0
- package/tests/bun-test-shim.ts +1 -0
- package/tests/sftp.test.ts +115 -191
- package/tests/users.test.ts +66 -83
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import { EventEmitter } from "node:events";
|
|
3
2
|
import { createCustomCommand, registerCommand, runCommand } from "../commands";
|
|
4
3
|
import type { CommandContext, CommandResult } from "../types/commands";
|
|
5
4
|
import type { ShellStream } from "../types/streams";
|
|
6
5
|
import type { PerfLogger } from "../utils/perfLogger";
|
|
7
6
|
import { createPerfLogger } from "../utils/perfLogger";
|
|
8
|
-
import VirtualFileSystem from "../VirtualFileSystem";
|
|
7
|
+
import VirtualFileSystem, { type VfsOptions } from "../VirtualFileSystem";
|
|
9
8
|
import { VirtualUserManager } from "../VirtualUserManager";
|
|
10
9
|
import { startShell } from "./shell";
|
|
11
10
|
|
|
@@ -23,27 +22,6 @@ const defaultShellProperties: ShellProperties = {
|
|
|
23
22
|
|
|
24
23
|
const perf: PerfLogger = createPerfLogger("VirtualShell");
|
|
25
24
|
|
|
26
|
-
let cachedRootPassword: string | null = null;
|
|
27
|
-
|
|
28
|
-
function resolveRootPassword(): string {
|
|
29
|
-
if (cachedRootPassword) {
|
|
30
|
-
return cachedRootPassword;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
|
|
34
|
-
if (configured && configured.trim().length > 0) {
|
|
35
|
-
cachedRootPassword = configured.trim();
|
|
36
|
-
return cachedRootPassword;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const generated = randomBytes(18).toString("base64url");
|
|
40
|
-
cachedRootPassword = generated;
|
|
41
|
-
console.warn(
|
|
42
|
-
`[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
|
|
43
|
-
);
|
|
44
|
-
return generated;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
25
|
function resolveAutoSudoForNewUsers(): boolean {
|
|
48
26
|
const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
|
|
49
27
|
if (!configured) {
|
|
@@ -60,7 +38,6 @@ function resolveAutoSudoForNewUsers(): boolean {
|
|
|
60
38
|
* client API.
|
|
61
39
|
*/
|
|
62
40
|
class VirtualShell extends EventEmitter {
|
|
63
|
-
basePath: string = ".";
|
|
64
41
|
vfs: VirtualFileSystem;
|
|
65
42
|
users: VirtualUserManager;
|
|
66
43
|
hostname: string;
|
|
@@ -72,24 +49,19 @@ class VirtualShell extends EventEmitter {
|
|
|
72
49
|
*
|
|
73
50
|
* @param hostname Virtual hostname used for prompts and idents.
|
|
74
51
|
* @param properties Customizable properties shown in `uname -a` and similar commands.
|
|
75
|
-
* @param
|
|
52
|
+
* @param vfsOptions Optional VFS persistence options (mode, snapshotPath).
|
|
76
53
|
*/
|
|
77
54
|
constructor(
|
|
78
55
|
hostname: string,
|
|
79
56
|
properties?: ShellProperties,
|
|
80
|
-
|
|
57
|
+
vfsOptions?: VfsOptions,
|
|
81
58
|
) {
|
|
82
59
|
super();
|
|
83
60
|
perf.mark("constructor");
|
|
84
61
|
this.hostname = hostname;
|
|
85
62
|
this.properties = properties || defaultShellProperties;
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
88
|
-
this.users = new VirtualUserManager(
|
|
89
|
-
this.vfs,
|
|
90
|
-
resolveRootPassword(),
|
|
91
|
-
resolveAutoSudoForNewUsers(),
|
|
92
|
-
);
|
|
63
|
+
this.vfs = new VirtualFileSystem(vfsOptions ?? {});
|
|
64
|
+
this.users = new VirtualUserManager(this.vfs, resolveAutoSudoForNewUsers());
|
|
93
65
|
|
|
94
66
|
// Store references to avoid TypeScript "used before assigned" errors
|
|
95
67
|
const vfs = this.vfs;
|
|
@@ -66,7 +66,8 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
66
66
|
*/
|
|
67
67
|
constructor(
|
|
68
68
|
private readonly vfs: VirtualFileSystem,
|
|
69
|
-
private readonly defaultRootPassword: string =
|
|
69
|
+
// private readonly defaultRootPassword: string = process.env
|
|
70
|
+
// .SSH_MIMIC_ROOT_PASSWORD || "root",
|
|
70
71
|
private readonly autoSudoForNewUsers: boolean = true,
|
|
71
72
|
) {
|
|
72
73
|
super();
|
|
@@ -85,32 +86,29 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
85
86
|
|
|
86
87
|
let changed = false;
|
|
87
88
|
if (!this.users.has("root")) {
|
|
88
|
-
this.users.set(
|
|
89
|
-
"root",
|
|
90
|
-
this.createRecord("root", this.defaultRootPassword),
|
|
91
|
-
);
|
|
89
|
+
this.users.set("root", this.createRecord("root", ""));
|
|
92
90
|
changed = true;
|
|
93
91
|
}
|
|
94
92
|
|
|
95
93
|
this.sudoers.add("root");
|
|
96
94
|
|
|
97
95
|
// Auto-create current system user for easier authentication
|
|
98
|
-
const currentUser = process.env.USER || process.env.USERNAME;
|
|
99
|
-
if (currentUser && currentUser !== "root" && !this.users.has(currentUser)) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
96
|
+
// const currentUser = process.env.USER || process.env.USERNAME;
|
|
97
|
+
// if (currentUser && currentUser !== "root" && !this.users.has(currentUser)) {
|
|
98
|
+
// const userPassword = this.defaultRootPassword;
|
|
99
|
+
// this.users.set(currentUser, this.createRecord(currentUser, userPassword));
|
|
100
|
+
// this.sudoers.add(currentUser);
|
|
101
|
+
// changed = true;
|
|
102
|
+
|
|
103
|
+
// const homePath = `/home/${currentUser}`;
|
|
104
|
+
// if (!this.vfs.exists(homePath)) {
|
|
105
|
+
// this.vfs.mkdir(homePath, 0o755);
|
|
106
|
+
// this.vfs.writeFile(
|
|
107
|
+
// `${homePath}/README.txt`,
|
|
108
|
+
// `Welcome to the virtual environment, ${currentUser}`,
|
|
109
|
+
// );
|
|
110
|
+
// }
|
|
111
|
+
// }
|
|
114
112
|
|
|
115
113
|
if (changed) {
|
|
116
114
|
await this.persist();
|
|
@@ -244,7 +242,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
244
242
|
return false;
|
|
245
243
|
}
|
|
246
244
|
|
|
247
|
-
return this.hashPassword(password
|
|
245
|
+
return this.hashPassword(password) === record.passwordHash;
|
|
248
246
|
}
|
|
249
247
|
|
|
250
248
|
/**
|
|
@@ -279,6 +277,18 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
279
277
|
this.emit("user:add", { username });
|
|
280
278
|
}
|
|
281
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Retrieves stored password hash for a user, or null if user does not exist.
|
|
282
|
+
*
|
|
283
|
+
* @param username Target username.
|
|
284
|
+
* @returns Password hash in hex encoding, or null when user is not found.
|
|
285
|
+
*/
|
|
286
|
+
public getPasswordHash(username: string): string | null {
|
|
287
|
+
perf.mark("getPasswordHash");
|
|
288
|
+
const record = this.users.get(username);
|
|
289
|
+
return record ? record.passwordHash : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
282
292
|
/**
|
|
283
293
|
* Updates password for an existing user account.
|
|
284
294
|
*
|
|
@@ -593,19 +603,34 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
593
603
|
const record = {
|
|
594
604
|
username,
|
|
595
605
|
salt,
|
|
596
|
-
passwordHash: this.hashPassword(password
|
|
606
|
+
passwordHash: this.hashPassword(password),
|
|
597
607
|
};
|
|
598
608
|
|
|
599
609
|
VirtualUserManager.recordCache.set(cacheKey, record);
|
|
600
610
|
return record;
|
|
601
611
|
}
|
|
602
612
|
|
|
603
|
-
|
|
613
|
+
public hasPassword(username: string): boolean {
|
|
614
|
+
perf.mark("hasPassword");
|
|
615
|
+
if (this.getPasswordHash(username) === this.hashPassword("")) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
const record = this.users.get(username);
|
|
619
|
+
return !!record && !!record.passwordHash;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Hashes plaintext password with per-user salt using scrypt.
|
|
624
|
+
*
|
|
625
|
+
* @param password Plaintext password.
|
|
626
|
+
* @returns Hex-encoded password hash.
|
|
627
|
+
*/
|
|
628
|
+
public hashPassword(password: string): string {
|
|
604
629
|
if (VirtualUserManager.fastPasswordHash) {
|
|
605
|
-
return createHash("sha256").update(`${
|
|
630
|
+
return createHash("sha256").update(`${password}`).digest("hex");
|
|
606
631
|
}
|
|
607
632
|
|
|
608
|
-
return scryptSync(password,
|
|
633
|
+
return scryptSync(password, "", 32).toString("hex");
|
|
609
634
|
}
|
|
610
635
|
|
|
611
636
|
private validateUsername(username: string): void {
|
|
@@ -623,6 +648,47 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
623
648
|
throw new Error("invalid password");
|
|
624
649
|
}
|
|
625
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
|
+
}
|
|
626
692
|
}
|
|
627
693
|
|
|
628
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
|
+
};
|