typescript-virtual-container 1.1.2 → 1.1.4
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/dist/SSHMimic/exec.d.ts.map +1 -1
- package/dist/SSHMimic/exec.js +8 -2
- package/dist/SSHMimic/index.d.ts +1 -0
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +9 -3
- package/dist/SSHMimic/sftp.d.ts +46 -0
- package/dist/SSHMimic/sftp.d.ts.map +1 -0
- package/dist/SSHMimic/sftp.js +576 -0
- package/dist/VirtualFileSystem/index.d.ts +6 -4
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +144 -153
- package/dist/VirtualShell/index.d.ts +6 -0
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +16 -4
- package/dist/VirtualShell/shell.d.ts.map +1 -1
- package/dist/VirtualShell/shell.js +7 -0
- package/dist/VirtualUserManager/index.d.ts +8 -0
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +30 -0
- package/dist/commands/exit.d.ts.map +1 -1
- package/dist/commands/exit.js +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +2 -0
- package/dist/commands/passwd.d.ts +3 -0
- package/dist/commands/passwd.d.ts.map +1 -0
- package/dist/commands/passwd.js +21 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/modules/neofetch.d.ts.map +1 -1
- package/dist/modules/neofetch.js +0 -1
- package/dist/standalone.js +10 -1
- package/package.json +1 -1
- package/src/SSHMimic/exec.ts +18 -12
- package/src/SSHMimic/index.ts +16 -7
- package/src/SSHMimic/sftp.ts +833 -0
- package/src/VirtualFileSystem/index.ts +158 -188
- package/src/VirtualShell/index.ts +19 -8
- package/src/VirtualShell/shell.ts +7 -0
- package/src/VirtualUserManager/index.ts +38 -0
- package/src/commands/exit.ts +1 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/passwd.ts +25 -0
- package/src/index.ts +2 -1
- package/src/modules/neofetch.ts +0 -2
- package/src/standalone.ts +11 -1
- package/tests/sftp.test.ts +319 -0
- package/tests/ssh-exec.test.ts +45 -0
- package/tests/users.test.ts +13 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
4
4
|
import type {
|
|
@@ -6,11 +6,7 @@ import type {
|
|
|
6
6
|
VfsNodeStats,
|
|
7
7
|
WriteFileOptions,
|
|
8
8
|
} from "../types/vfs";
|
|
9
|
-
import {
|
|
10
|
-
import type { InternalDirectoryNode, InternalNode } from "./internalTypes";
|
|
11
|
-
import { getNode, getParentDirectory, normalizePath, splitPath } from "./path";
|
|
12
|
-
import { applySnapshot, createSnapshot } from "./snapshot";
|
|
13
|
-
import { renderTree } from "./tree";
|
|
9
|
+
import { normalizePath } from "./path";
|
|
14
10
|
|
|
15
11
|
/**
|
|
16
12
|
* In-memory virtual filesystem with tar.gz mirror persistence.
|
|
@@ -20,38 +16,84 @@ import { renderTree } from "./tree";
|
|
|
20
16
|
* {@link VirtualFileSystem.flushMirror} to persist pending changes.
|
|
21
17
|
*/
|
|
22
18
|
class VirtualFileSystem {
|
|
23
|
-
private readonly
|
|
24
|
-
private readonly archivePath: string;
|
|
25
|
-
private dirty = false;
|
|
19
|
+
private readonly mirrorRoot: string;
|
|
26
20
|
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
private ensureMirrorRoot(): void {
|
|
22
|
+
fs.mkdirSync(this.mirrorRoot, { recursive: true, mode: 0o755 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private resolveFsPath(targetPath: string): string {
|
|
26
|
+
const normalized = normalizePath(targetPath);
|
|
27
|
+
const relativePath = normalized.slice(1);
|
|
28
|
+
const resolved = path.resolve(this.mirrorRoot, relativePath || ".");
|
|
29
|
+
const relative = path.relative(this.mirrorRoot, resolved);
|
|
30
|
+
|
|
31
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
32
|
+
throw new Error(`Invalid path '${targetPath}'.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return resolved;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private detectGzipFile(targetPath: string): boolean {
|
|
39
|
+
const fd = fs.openSync(targetPath, "r");
|
|
40
|
+
try {
|
|
41
|
+
const header = Buffer.alloc(2);
|
|
42
|
+
const bytesRead = fs.readSync(fd, header, 0, 2, 0);
|
|
43
|
+
return bytesRead === 2 && header[0] === 0x1f && header[1] === 0x8b;
|
|
44
|
+
} finally {
|
|
45
|
+
fs.closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private computeDiskUsageBytes(targetPath: string): number {
|
|
50
|
+
const stats = fs.statSync(targetPath);
|
|
51
|
+
if (stats.isFile()) {
|
|
52
|
+
return stats.size;
|
|
30
53
|
}
|
|
31
54
|
|
|
32
55
|
let total = 0;
|
|
33
|
-
for (const
|
|
34
|
-
total += this.
|
|
56
|
+
for (const entry of fs.readdirSync(targetPath)) {
|
|
57
|
+
total += this.computeDiskUsageBytes(path.join(targetPath, entry));
|
|
35
58
|
}
|
|
36
59
|
return total;
|
|
37
60
|
}
|
|
38
61
|
|
|
62
|
+
private renderTreeLines(targetPath: string, label: string): string {
|
|
63
|
+
const lines = [label];
|
|
64
|
+
|
|
65
|
+
const walk = (currentPath: string, prefix: string): void => {
|
|
66
|
+
const entries = fs
|
|
67
|
+
.readdirSync(currentPath, { withFileTypes: true })
|
|
68
|
+
.map((entry) => entry.name)
|
|
69
|
+
.sort((left, right) => left.localeCompare(right));
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
72
|
+
const name = entries[i]!;
|
|
73
|
+
const isLast = i === entries.length - 1;
|
|
74
|
+
const connector = isLast ? "└── " : "├── ";
|
|
75
|
+
const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
|
|
76
|
+
const entryPath = path.join(currentPath, name);
|
|
77
|
+
const isDirectory = fs.statSync(entryPath).isDirectory();
|
|
78
|
+
|
|
79
|
+
lines.push(`${prefix}${connector}${name}`);
|
|
80
|
+
if (isDirectory) {
|
|
81
|
+
walk(entryPath, nextPrefix);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
walk(targetPath, "");
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
39
90
|
/**
|
|
40
91
|
* Creates a virtual filesystem instance.
|
|
41
92
|
*
|
|
42
93
|
* @param baseDir Base directory used to resolve mirror archive location.
|
|
43
94
|
*/
|
|
44
95
|
constructor(baseDir: string = process.cwd()) {
|
|
45
|
-
|
|
46
|
-
this.archivePath = path.resolve(baseDir, ".vfs", "mirror.tar.gz");
|
|
47
|
-
this.root = {
|
|
48
|
-
type: "directory",
|
|
49
|
-
name: "",
|
|
50
|
-
mode: 0o755,
|
|
51
|
-
createdAt: now,
|
|
52
|
-
updatedAt: now,
|
|
53
|
-
children: new Map<string, InternalNode>(),
|
|
54
|
-
};
|
|
96
|
+
this.mirrorRoot = path.resolve(baseDir, ".vfs", "mirror");
|
|
55
97
|
}
|
|
56
98
|
|
|
57
99
|
/**
|
|
@@ -60,20 +102,7 @@ class VirtualFileSystem {
|
|
|
60
102
|
* If archive does not exist or cannot be read, creates fresh mirror file.
|
|
61
103
|
*/
|
|
62
104
|
public async restoreMirror(): Promise<void> {
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const compressed = await fs.readFile(this.archivePath);
|
|
66
|
-
const tarBuffer = gunzipSync(compressed);
|
|
67
|
-
const snapshot = await readSnapshotFromTar(tarBuffer);
|
|
68
|
-
applySnapshot(this.root, snapshot);
|
|
69
|
-
this.dirty = false;
|
|
70
|
-
return;
|
|
71
|
-
} catch {
|
|
72
|
-
console.warn(
|
|
73
|
-
`No valid mirror archive found at '${this.archivePath}'. Starting with empty filesystem.`,
|
|
74
|
-
);
|
|
75
|
-
await this.flushMirror();
|
|
76
|
-
}
|
|
105
|
+
this.ensureMirrorRoot();
|
|
77
106
|
}
|
|
78
107
|
|
|
79
108
|
/**
|
|
@@ -82,16 +111,7 @@ class VirtualFileSystem {
|
|
|
82
111
|
* No-op when nothing changed and archive already exists.
|
|
83
112
|
*/
|
|
84
113
|
public async flushMirror(): Promise<void> {
|
|
85
|
-
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
await fs.mkdir(path.dirname(this.archivePath), { recursive: true });
|
|
90
|
-
const snapshotJson = JSON.stringify(createSnapshot(this.root), null, 2);
|
|
91
|
-
const tarBuffer = await createTarBuffer(snapshotJson);
|
|
92
|
-
const compressed = gzipSync(tarBuffer);
|
|
93
|
-
await fs.writeFile(this.archivePath, compressed);
|
|
94
|
-
this.dirty = false;
|
|
114
|
+
this.ensureMirrorRoot();
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
/**
|
|
@@ -101,36 +121,14 @@ class VirtualFileSystem {
|
|
|
101
121
|
* @param mode POSIX-like mode bits for new directories.
|
|
102
122
|
*/
|
|
103
123
|
public mkdir(targetPath: string, mode: number = 0o755): void {
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!existing) {
|
|
111
|
-
const now = new Date();
|
|
112
|
-
const nextDir: InternalDirectoryNode = {
|
|
113
|
-
type: "directory",
|
|
114
|
-
name: part,
|
|
115
|
-
mode,
|
|
116
|
-
createdAt: now,
|
|
117
|
-
updatedAt: now,
|
|
118
|
-
children: new Map<string, InternalNode>(),
|
|
119
|
-
};
|
|
120
|
-
current.children.set(part, nextDir);
|
|
121
|
-
current.updatedAt = now;
|
|
122
|
-
this.dirty = true;
|
|
123
|
-
current = nextDir;
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (existing.type !== "directory") {
|
|
128
|
-
throw new Error(
|
|
129
|
-
`Cannot create directory '${normalized}': '${part}' is a file.`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
current = existing;
|
|
124
|
+
this.ensureMirrorRoot();
|
|
125
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
126
|
+
if (fs.existsSync(fsPath) && !fs.statSync(fsPath).isDirectory()) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Cannot create directory '${normalizePath(targetPath)}': path is a file.`,
|
|
129
|
+
);
|
|
133
130
|
}
|
|
131
|
+
fs.mkdirSync(fsPath, { recursive: true, mode });
|
|
134
132
|
}
|
|
135
133
|
|
|
136
134
|
/**
|
|
@@ -147,44 +145,26 @@ class VirtualFileSystem {
|
|
|
147
145
|
content: string | Buffer,
|
|
148
146
|
options: WriteFileOptions = {},
|
|
149
147
|
): void {
|
|
148
|
+
this.ensureMirrorRoot();
|
|
150
149
|
const normalized = normalizePath(targetPath);
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
true,
|
|
155
|
-
(pathToCreate) => this.mkdir(pathToCreate),
|
|
156
|
-
);
|
|
157
|
-
const now = new Date();
|
|
150
|
+
const fsPath = this.resolveFsPath(normalized);
|
|
151
|
+
const parentPath = path.dirname(fsPath);
|
|
152
|
+
fs.mkdirSync(parentPath, { recursive: true, mode: 0o755 });
|
|
158
153
|
|
|
159
154
|
const rawContent = Buffer.isBuffer(content)
|
|
160
155
|
? content
|
|
161
156
|
: Buffer.from(content, "utf8");
|
|
162
157
|
const shouldCompress = options.compress ?? false;
|
|
163
158
|
const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
|
|
164
|
-
const existing = parent.children.get(name);
|
|
165
159
|
|
|
166
|
-
if (
|
|
160
|
+
if (fs.existsSync(fsPath) && fs.statSync(fsPath).isDirectory()) {
|
|
167
161
|
throw new Error(
|
|
168
162
|
`Cannot write file '${normalized}': path is a directory.`,
|
|
169
163
|
);
|
|
170
164
|
}
|
|
171
165
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
options.mode ?? (existing?.type === "file" ? existing.mode : 0o644);
|
|
175
|
-
|
|
176
|
-
parent.children.set(name, {
|
|
177
|
-
type: "file",
|
|
178
|
-
name,
|
|
179
|
-
mode,
|
|
180
|
-
createdAt,
|
|
181
|
-
updatedAt: now,
|
|
182
|
-
content: storedContent,
|
|
183
|
-
compressed: shouldCompress,
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
parent.updatedAt = now;
|
|
187
|
-
this.dirty = true;
|
|
166
|
+
fs.writeFileSync(fsPath, storedContent);
|
|
167
|
+
fs.chmodSync(fsPath, options.mode ?? 0o644);
|
|
188
168
|
}
|
|
189
169
|
|
|
190
170
|
/**
|
|
@@ -196,12 +176,14 @@ class VirtualFileSystem {
|
|
|
196
176
|
* @returns UTF-8 string content.
|
|
197
177
|
*/
|
|
198
178
|
public readFile(targetPath: string): string {
|
|
199
|
-
|
|
200
|
-
|
|
179
|
+
this.ensureMirrorRoot();
|
|
180
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
181
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
201
182
|
throw new Error(`Cannot read '${targetPath}': not a file.`);
|
|
202
183
|
}
|
|
203
184
|
|
|
204
|
-
const
|
|
185
|
+
const stored = fs.readFileSync(fsPath);
|
|
186
|
+
const raw = this.detectGzipFile(fsPath) ? gunzipSync(stored) : stored;
|
|
205
187
|
return raw.toString("utf8");
|
|
206
188
|
}
|
|
207
189
|
|
|
@@ -213,8 +195,8 @@ class VirtualFileSystem {
|
|
|
213
195
|
*/
|
|
214
196
|
public exists(targetPath: string): boolean {
|
|
215
197
|
try {
|
|
216
|
-
|
|
217
|
-
return
|
|
198
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
199
|
+
return fs.existsSync(fsPath);
|
|
218
200
|
} catch {
|
|
219
201
|
return false;
|
|
220
202
|
}
|
|
@@ -227,10 +209,11 @@ class VirtualFileSystem {
|
|
|
227
209
|
* @param mode New POSIX-like mode.
|
|
228
210
|
*/
|
|
229
211
|
public chmod(targetPath: string, mode: number): void {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
212
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
213
|
+
if (!fs.existsSync(fsPath)) {
|
|
214
|
+
throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
|
|
215
|
+
}
|
|
216
|
+
fs.chmodSync(fsPath, mode);
|
|
234
217
|
}
|
|
235
218
|
|
|
236
219
|
/**
|
|
@@ -240,30 +223,39 @@ class VirtualFileSystem {
|
|
|
240
223
|
* @returns Typed stat object based on node type.
|
|
241
224
|
*/
|
|
242
225
|
public stat(targetPath: string): VfsNodeStats {
|
|
226
|
+
this.ensureMirrorRoot();
|
|
243
227
|
const normalized = normalizePath(targetPath);
|
|
244
|
-
const
|
|
228
|
+
const fsPath = this.resolveFsPath(normalized);
|
|
245
229
|
|
|
246
|
-
if (
|
|
230
|
+
if (!fs.existsSync(fsPath)) {
|
|
231
|
+
throw new Error(`Path '${normalized}' does not exist.`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const stats = fs.statSync(fsPath);
|
|
235
|
+
const mode = stats.mode & 0o777;
|
|
236
|
+
const name = normalized === "/" ? "" : path.posix.basename(normalized);
|
|
237
|
+
|
|
238
|
+
if (stats.isFile()) {
|
|
247
239
|
return {
|
|
248
240
|
type: "file",
|
|
249
|
-
name
|
|
241
|
+
name,
|
|
250
242
|
path: normalized,
|
|
251
|
-
mode
|
|
252
|
-
createdAt:
|
|
253
|
-
updatedAt:
|
|
254
|
-
compressed:
|
|
255
|
-
size:
|
|
243
|
+
mode,
|
|
244
|
+
createdAt: stats.birthtime,
|
|
245
|
+
updatedAt: stats.mtime,
|
|
246
|
+
compressed: this.detectGzipFile(fsPath),
|
|
247
|
+
size: stats.size,
|
|
256
248
|
};
|
|
257
249
|
}
|
|
258
250
|
|
|
259
251
|
return {
|
|
260
252
|
type: "directory",
|
|
261
|
-
name
|
|
253
|
+
name,
|
|
262
254
|
path: normalized,
|
|
263
|
-
mode
|
|
264
|
-
createdAt:
|
|
265
|
-
updatedAt:
|
|
266
|
-
childrenCount:
|
|
255
|
+
mode,
|
|
256
|
+
createdAt: stats.birthtime,
|
|
257
|
+
updatedAt: stats.mtime,
|
|
258
|
+
childrenCount: fs.readdirSync(fsPath).length,
|
|
267
259
|
};
|
|
268
260
|
}
|
|
269
261
|
|
|
@@ -274,12 +266,12 @@ class VirtualFileSystem {
|
|
|
274
266
|
* @returns Sorted child names.
|
|
275
267
|
*/
|
|
276
268
|
public list(dirPath: string = "/"): string[] {
|
|
277
|
-
const
|
|
278
|
-
if (
|
|
269
|
+
const fsPath = this.resolveFsPath(dirPath);
|
|
270
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
|
|
279
271
|
throw new Error(`Cannot list '${dirPath}': not a directory.`);
|
|
280
272
|
}
|
|
281
273
|
|
|
282
|
-
return
|
|
274
|
+
return fs.readdirSync(fsPath).sort();
|
|
283
275
|
}
|
|
284
276
|
|
|
285
277
|
/**
|
|
@@ -289,14 +281,14 @@ class VirtualFileSystem {
|
|
|
289
281
|
* @returns Multi-line tree string.
|
|
290
282
|
*/
|
|
291
283
|
public tree(dirPath: string = "/"): string {
|
|
292
|
-
const
|
|
293
|
-
if (
|
|
284
|
+
const fsPath = this.resolveFsPath(dirPath);
|
|
285
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
|
|
294
286
|
throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
|
|
295
287
|
}
|
|
296
288
|
|
|
297
289
|
const rootLabel =
|
|
298
290
|
dirPath === "/" ? "/" : path.posix.basename(normalizePath(dirPath));
|
|
299
|
-
return
|
|
291
|
+
return this.renderTreeLines(fsPath, rootLabel);
|
|
300
292
|
}
|
|
301
293
|
|
|
302
294
|
/**
|
|
@@ -309,8 +301,11 @@ class VirtualFileSystem {
|
|
|
309
301
|
* @returns Total byte usage for file content under target path.
|
|
310
302
|
*/
|
|
311
303
|
public getUsageBytes(targetPath: string = "/"): number {
|
|
312
|
-
const
|
|
313
|
-
|
|
304
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
305
|
+
if (!fs.existsSync(fsPath)) {
|
|
306
|
+
throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
|
|
307
|
+
}
|
|
308
|
+
return this.computeDiskUsageBytes(fsPath);
|
|
314
309
|
}
|
|
315
310
|
|
|
316
311
|
/**
|
|
@@ -319,16 +314,14 @@ class VirtualFileSystem {
|
|
|
319
314
|
* @param targetPath Path to file.
|
|
320
315
|
*/
|
|
321
316
|
public compressFile(targetPath: string): void {
|
|
322
|
-
const
|
|
323
|
-
if (
|
|
317
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
318
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
324
319
|
throw new Error(`Cannot compress '${targetPath}': not a file.`);
|
|
325
320
|
}
|
|
326
321
|
|
|
327
|
-
if (!
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
node.updatedAt = new Date();
|
|
331
|
-
this.dirty = true;
|
|
322
|
+
if (!this.detectGzipFile(fsPath)) {
|
|
323
|
+
const content = fs.readFileSync(fsPath);
|
|
324
|
+
fs.writeFileSync(fsPath, gzipSync(content));
|
|
332
325
|
}
|
|
333
326
|
}
|
|
334
327
|
|
|
@@ -338,16 +331,14 @@ class VirtualFileSystem {
|
|
|
338
331
|
* @param targetPath Path to file.
|
|
339
332
|
*/
|
|
340
333
|
public decompressFile(targetPath: string): void {
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
334
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
335
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
343
336
|
throw new Error(`Cannot decompress '${targetPath}': not a file.`);
|
|
344
337
|
}
|
|
345
338
|
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
node.updatedAt = new Date();
|
|
350
|
-
this.dirty = true;
|
|
339
|
+
if (this.detectGzipFile(fsPath)) {
|
|
340
|
+
const content = fs.readFileSync(fsPath);
|
|
341
|
+
fs.writeFileSync(fsPath, gunzipSync(content));
|
|
351
342
|
}
|
|
352
343
|
}
|
|
353
344
|
|
|
@@ -362,32 +353,27 @@ class VirtualFileSystem {
|
|
|
362
353
|
if (normalized === "/") {
|
|
363
354
|
throw new Error("Cannot remove root directory.");
|
|
364
355
|
}
|
|
356
|
+
const fsPath = this.resolveFsPath(normalized);
|
|
365
357
|
|
|
366
|
-
|
|
367
|
-
this.root,
|
|
368
|
-
normalized,
|
|
369
|
-
false,
|
|
370
|
-
() => undefined,
|
|
371
|
-
);
|
|
372
|
-
const node = parent.children.get(name);
|
|
373
|
-
|
|
374
|
-
if (!node) {
|
|
358
|
+
if (!fs.existsSync(fsPath)) {
|
|
375
359
|
throw new Error(`Path '${normalized}' does not exist.`);
|
|
376
360
|
}
|
|
377
361
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
362
|
+
const stats = fs.statSync(fsPath);
|
|
363
|
+
if (stats.isDirectory() && !options.recursive) {
|
|
364
|
+
const entries = fs.readdirSync(fsPath);
|
|
365
|
+
if (entries.length > 0) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`Directory '${normalized}' is not empty. Use recursive option.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
386
370
|
}
|
|
387
371
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
372
|
+
if (stats.isDirectory()) {
|
|
373
|
+
fs.rmSync(fsPath, { recursive: options.recursive ?? false });
|
|
374
|
+
} else {
|
|
375
|
+
fs.rmSync(fsPath);
|
|
376
|
+
}
|
|
391
377
|
}
|
|
392
378
|
|
|
393
379
|
/**
|
|
@@ -404,35 +390,19 @@ class VirtualFileSystem {
|
|
|
404
390
|
throw new Error("Cannot move root directory.");
|
|
405
391
|
}
|
|
406
392
|
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
fromNormalized,
|
|
410
|
-
false,
|
|
411
|
-
() => undefined,
|
|
412
|
-
);
|
|
413
|
-
const node = fromParent.children.get(fromName);
|
|
393
|
+
const fromFsPath = this.resolveFsPath(fromNormalized);
|
|
394
|
+
const toFsPath = this.resolveFsPath(toNormalized);
|
|
414
395
|
|
|
415
|
-
if (!
|
|
396
|
+
if (!fs.existsSync(fromFsPath)) {
|
|
416
397
|
throw new Error(`Path '${fromNormalized}' does not exist.`);
|
|
417
398
|
}
|
|
418
399
|
|
|
419
|
-
|
|
420
|
-
this.root,
|
|
421
|
-
toNormalized,
|
|
422
|
-
true,
|
|
423
|
-
(pathToCreate) => this.mkdir(pathToCreate),
|
|
424
|
-
);
|
|
425
|
-
if (toParent.children.has(toName)) {
|
|
400
|
+
if (fs.existsSync(toFsPath)) {
|
|
426
401
|
throw new Error(`Destination '${toNormalized}' already exists.`);
|
|
427
402
|
}
|
|
428
403
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
node.updatedAt = new Date();
|
|
432
|
-
toParent.children.set(toName, node);
|
|
433
|
-
fromParent.updatedAt = new Date();
|
|
434
|
-
toParent.updatedAt = new Date();
|
|
435
|
-
this.dirty = true;
|
|
404
|
+
fs.mkdirSync(path.dirname(toFsPath), { recursive: true, mode: 0o755 });
|
|
405
|
+
fs.renameSync(fromFsPath, toFsPath);
|
|
436
406
|
}
|
|
437
407
|
}
|
|
438
408
|
|
|
@@ -52,6 +52,7 @@ class VirtualShell {
|
|
|
52
52
|
users: VirtualUserManager;
|
|
53
53
|
hostname: string;
|
|
54
54
|
properties: ShellProperties;
|
|
55
|
+
private initialized: Promise<void>;
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
58
|
* Creates a new virtual shell instance.
|
|
@@ -74,14 +75,24 @@ class VirtualShell {
|
|
|
74
75
|
resolveRootPassword(),
|
|
75
76
|
resolveAutoSudoForNewUsers(),
|
|
76
77
|
);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
|
|
79
|
+
// Store references to avoid TypeScript "used before assigned" errors
|
|
80
|
+
const vfs = this.vfs;
|
|
81
|
+
const users = this.users;
|
|
82
|
+
|
|
83
|
+
// Initialize both VFS mirror and users, ensuring all is ready before auth
|
|
84
|
+
this.initialized = (async () => {
|
|
85
|
+
await vfs.restoreMirror();
|
|
86
|
+
await users.initialize();
|
|
87
|
+
})();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Ensures initialization is complete before allowing operations.
|
|
92
|
+
* Call this before any authentication or command execution.
|
|
93
|
+
*/
|
|
94
|
+
public async ensureInitialized(): Promise<void> {
|
|
95
|
+
await this.initialized;
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
/**
|
|
@@ -467,6 +467,13 @@ export function startShell(
|
|
|
467
467
|
const ch = input[i]!;
|
|
468
468
|
|
|
469
469
|
if (ch === "\u0004") {
|
|
470
|
+
lineBuffer = "";
|
|
471
|
+
cursorPos = 0;
|
|
472
|
+
historyIndex = null;
|
|
473
|
+
historyDraft = "";
|
|
474
|
+
stream.write("bye\r\n");
|
|
475
|
+
pushHistory("bye");
|
|
476
|
+
await shell.vfs.flushMirror();
|
|
470
477
|
stream.write("logout\r\n");
|
|
471
478
|
stream.exit(0);
|
|
472
479
|
stream.end();
|
|
@@ -57,6 +57,7 @@ export class VirtualUserManager {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Loads users/sudoers from disk and ensures root account exists.
|
|
60
|
+
* Also creates the current system user if not already present.
|
|
60
61
|
*/
|
|
61
62
|
public async initialize(): Promise<void> {
|
|
62
63
|
this.loadFromVfs();
|
|
@@ -67,6 +68,25 @@ export class VirtualUserManager {
|
|
|
67
68
|
|
|
68
69
|
this.sudoers.add("root");
|
|
69
70
|
|
|
71
|
+
// Auto-create current system user for easier authentication
|
|
72
|
+
const currentUser = process.env.USER || process.env.USERNAME;
|
|
73
|
+
if (currentUser && currentUser !== "root" && !this.users.has(currentUser)) {
|
|
74
|
+
// Use same password as root for convenience, or a generic default
|
|
75
|
+
const userPassword = this.defaultRootPassword;
|
|
76
|
+
this.users.set(currentUser, this.createRecord(currentUser, userPassword));
|
|
77
|
+
this.sudoers.add(currentUser);
|
|
78
|
+
|
|
79
|
+
// Create home directory for the system user
|
|
80
|
+
const homePath = `/home/${currentUser}`;
|
|
81
|
+
if (!this.vfs.exists(homePath)) {
|
|
82
|
+
this.vfs.mkdir(homePath, 0o755);
|
|
83
|
+
this.vfs.writeFile(
|
|
84
|
+
`${homePath}/README.txt`,
|
|
85
|
+
`Welcome to the virtual environment, ${currentUser}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
await this.persist();
|
|
71
91
|
}
|
|
72
92
|
|
|
@@ -222,6 +242,24 @@ export class VirtualUserManager {
|
|
|
222
242
|
await this.persist();
|
|
223
243
|
}
|
|
224
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Updates password for an existing user account.
|
|
247
|
+
*
|
|
248
|
+
* @param username Username to update.
|
|
249
|
+
* @param password New plaintext password.
|
|
250
|
+
*/
|
|
251
|
+
public async setPassword(username: string, password: string): Promise<void> {
|
|
252
|
+
this.validateUsername(username);
|
|
253
|
+
this.validatePassword(password);
|
|
254
|
+
|
|
255
|
+
if (!this.users.has(username)) {
|
|
256
|
+
throw new Error(`passwd: user '${username}' does not exist`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.users.set(username, this.createRecord(username, password));
|
|
260
|
+
await this.persist();
|
|
261
|
+
}
|
|
262
|
+
|
|
225
263
|
/**
|
|
226
264
|
* Deletes existing non-root user account.
|
|
227
265
|
*
|
package/src/commands/exit.ts
CHANGED
package/src/commands/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { lsCommand } from "./ls";
|
|
|
23
23
|
import { mkdirCommand } from "./mkdir";
|
|
24
24
|
import { nanoCommand } from "./nano";
|
|
25
25
|
import { neofetchCommand } from "./neofetch";
|
|
26
|
+
import { passwdCommand } from "./passwd";
|
|
26
27
|
import { pwdCommand } from "./pwd";
|
|
27
28
|
import { rmCommand } from "./rm";
|
|
28
29
|
import { setCommand } from "./set";
|
|
@@ -53,6 +54,7 @@ const BASE_COMMANDS: ShellModule[] = [
|
|
|
53
54
|
neofetchCommand,
|
|
54
55
|
htopCommand,
|
|
55
56
|
adduserCommand,
|
|
57
|
+
passwdCommand,
|
|
56
58
|
deluserCommand,
|
|
57
59
|
sudoCommand,
|
|
58
60
|
suCommand,
|