typescript-virtual-container 1.5.4 → 1.5.6

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.
Files changed (40) hide show
  1. package/README.md +107 -541
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/VirtualShell/shell.js +158 -11
  4. package/dist/commands/awk.d.ts +6 -11
  5. package/dist/commands/awk.js +462 -109
  6. package/dist/commands/bc.d.ts +2 -0
  7. package/dist/commands/bc.js +28 -0
  8. package/dist/commands/bzip2.d.ts +11 -0
  9. package/dist/commands/bzip2.js +91 -0
  10. package/dist/commands/find.d.ts +2 -2
  11. package/dist/commands/find.js +212 -37
  12. package/dist/commands/{ifconfig.d.ts → ip.d.ts} +1 -1
  13. package/dist/commands/{ifconfig.js → ip.js} +3 -3
  14. package/dist/commands/jobs.d.ts +4 -0
  15. package/dist/commands/jobs.js +27 -0
  16. package/dist/commands/lsof.d.ts +6 -0
  17. package/dist/commands/lsof.js +30 -0
  18. package/dist/commands/perl.d.ts +6 -0
  19. package/dist/commands/perl.js +76 -0
  20. package/dist/commands/registry.js +22 -3
  21. package/dist/commands/runtime.js +2 -1
  22. package/dist/commands/sed.d.ts +2 -2
  23. package/dist/commands/sed.js +216 -34
  24. package/dist/commands/set.js +20 -0
  25. package/dist/commands/sh.js +111 -1
  26. package/dist/commands/strace.d.ts +6 -0
  27. package/dist/commands/strace.js +26 -0
  28. package/dist/commands/tar.d.ts +2 -1
  29. package/dist/commands/tar.js +138 -52
  30. package/dist/commands/zip.d.ts +11 -0
  31. package/dist/commands/zip.js +232 -0
  32. package/dist/modules/linuxRootfs.js +1 -1
  33. package/dist/utils/expand.js +67 -1
  34. package/package.json +5 -4
  35. package/dist/self-standalone.d.ts +0 -1
  36. package/dist/self-standalone.js +0 -444
  37. package/dist/standalone-wo-sftp.d.ts +0 -1
  38. package/dist/standalone-wo-sftp.js +0 -30
  39. package/dist/standalone.d.ts +0 -1
  40. package/dist/standalone.js +0 -61
@@ -1,6 +1,71 @@
1
+ import { gunzipSync, gzipSync } from "fflate";
1
2
  import { resolvePath } from "./helpers";
3
+ // ── POSIX ustar tar format ────────────────────────────────────────────────────
4
+ function makeTarHeader(name, size, isDir) {
5
+ const hdr = Buffer.alloc(512);
6
+ const enc = (s, off, len) => {
7
+ const b = Buffer.from(s, "ascii");
8
+ b.copy(hdr, off, 0, Math.min(b.length, len));
9
+ };
10
+ enc(isDir ? `${name}/` : name, 0, 100);
11
+ enc(isDir ? "0000755\0" : "0000644\0", 100, 8);
12
+ enc("0000000\0", 108, 8);
13
+ enc("0000000\0", 116, 8);
14
+ enc(size.toString(8).padStart(11, "0") + "\0", 124, 12);
15
+ enc(Math.floor(Date.now() / 1000).toString(8).padStart(11, "0") + "\0", 136, 12);
16
+ hdr[156] = isDir ? 0x35 : 0x30; // '5' dir, '0' file
17
+ enc("ustar\0", 257, 6);
18
+ enc("00", 263, 2);
19
+ enc("root\0", 265, 32); // uname
20
+ enc("root\0", 297, 32); // gname
21
+ // Checksum: fill field with spaces, compute, write
22
+ for (let i = 148; i < 156; i++)
23
+ hdr[i] = 0x20;
24
+ let sum = 0;
25
+ for (let i = 0; i < 512; i++)
26
+ sum += hdr[i];
27
+ Buffer.from(`${sum.toString(8).padStart(6, "0")}\0 `).copy(hdr, 148);
28
+ return hdr;
29
+ }
30
+ function tarPad(size) {
31
+ const rem = size % 512;
32
+ return rem === 0 ? Buffer.alloc(0) : Buffer.alloc(512 - rem);
33
+ }
34
+ function buildTar(entries) {
35
+ const parts = [];
36
+ for (const { name, content, isDir } of entries) {
37
+ parts.push(makeTarHeader(name, isDir ? 0 : content.length, isDir));
38
+ if (!isDir) {
39
+ parts.push(content);
40
+ parts.push(tarPad(content.length));
41
+ }
42
+ }
43
+ parts.push(Buffer.alloc(1024)); // end-of-archive
44
+ return Buffer.concat(parts);
45
+ }
46
+ function parseTar(raw) {
47
+ const files = [];
48
+ let off = 0;
49
+ while (off + 512 <= raw.length) {
50
+ const hdr = raw.slice(off, off + 512);
51
+ if (hdr.every((b) => b === 0))
52
+ break;
53
+ const name = hdr.slice(0, 100).toString("ascii").replace(/\0.*/, "");
54
+ const sizeStr = hdr.slice(124, 135).toString("ascii").replace(/\0.*/, "").trim();
55
+ const size = parseInt(sizeStr, 8) || 0;
56
+ const typeflag = hdr[156];
57
+ off += 512;
58
+ if (name && typeflag !== 0x35 && typeflag !== 53) { // not a directory
59
+ const content = raw.slice(off, off + size);
60
+ files.push({ name, content });
61
+ }
62
+ off += Math.ceil(size / 512) * 512;
63
+ }
64
+ return files;
65
+ }
2
66
  /**
3
- * Archive or extract files with tar and optional gzip compression.
67
+ * Archive or extract files with tar writes real POSIX ustar binary format.
68
+ * Supports -c/-x/-t, -z (gzip), -j (bzip2 stub), -v (verbose), -f.
4
69
  * @category archive
5
70
  * @params ["[-czf|-xzf|-tf] <archive> [files...]"]
6
71
  */
@@ -10,17 +75,15 @@ export const tarCommand = {
10
75
  category: "archive",
11
76
  params: ["[-czf|-xzf|-tf] <archive> [files...]"],
12
77
  run: ({ authUser, shell, cwd, args }) => {
13
- // Expand combined flags: -czf or czf (bare mode string) → ["-c", "-z", "-f"]
78
+ // Expand combined flags: -czf → ["-c", "-z", "-f"]
14
79
  const expanded = [];
15
80
  let foundModeStr = false;
16
81
  for (const a of args) {
17
82
  if (/^-[a-zA-Z]{2,}$/.test(a)) {
18
- // -czf style
19
83
  for (const ch of a.slice(1))
20
84
  expanded.push(`-${ch}`);
21
85
  }
22
- else if (!foundModeStr && /^[cxtdru]{1,}[a-zA-Z]*$/.test(a) && !a.includes("/") && !a.startsWith("-")) {
23
- // czf bare style (first non-path arg)
86
+ else if (!foundModeStr && /^[cxtdru][a-zA-Z]*$/.test(a) && !a.includes("/") && !a.startsWith("-")) {
24
87
  foundModeStr = true;
25
88
  for (const ch of a)
26
89
  expanded.push(`-${ch}`);
@@ -32,70 +95,93 @@ export const tarCommand = {
32
95
  const create = expanded.includes("-c");
33
96
  const extract = expanded.includes("-x");
34
97
  const list = expanded.includes("-t");
98
+ const useGzip = expanded.includes("-z");
99
+ const verbose = expanded.includes("-v");
35
100
  const fIdx = expanded.indexOf("-f");
36
101
  const archiveName = fIdx !== -1
37
102
  ? expanded[fIdx + 1]
38
- : expanded.find((a) => a.endsWith(".tar") || a.endsWith(".tar.gz") || a.endsWith(".tgz"));
39
- if (!create && !extract && !list) {
40
- return { stderr: "tar: must specify -c, -x, or -t\n", exitCode: 1 };
41
- }
103
+ : expanded.find((a) => a.endsWith(".tar") || a.endsWith(".tar.gz") || a.endsWith(".tgz") || a.endsWith(".tar.bz2"));
104
+ if (!create && !extract && !list)
105
+ return { stderr: "tar: must specify -c, -x, or -t", exitCode: 1 };
42
106
  if (!archiveName)
43
- return { stderr: "tar: no archive specified\n", exitCode: 1 };
107
+ return { stderr: "tar: no archive specified", exitCode: 1 };
44
108
  const archivePath = resolvePath(cwd, archiveName);
109
+ const autoGzip = useGzip || archiveName.endsWith(".gz") || archiveName.endsWith(".tgz");
45
110
  if (create) {
46
- // Skip flags and archive name from file list
47
- const skipNext2 = new Set();
48
- if (fIdx !== -1)
49
- skipNext2.add(fIdx + 1);
50
- const fileArgs = expanded.filter((a, i) => !a.startsWith("-") && a !== archiveName && !skipNext2.has(i));
51
- const entries = {};
111
+ const skipSet = new Set();
112
+ if (fIdx !== -1 && expanded[fIdx + 1])
113
+ skipSet.add(expanded[fIdx + 1]);
114
+ const fileArgs = expanded.filter((a) => !a.startsWith("-") && !skipSet.has(a));
115
+ const entries = [];
116
+ const verboseLines = [];
52
117
  for (const f of fileArgs) {
53
118
  const p = resolvePath(cwd, f);
54
- try {
55
- const stat = shell.vfs.stat(p);
56
- if (stat.type === "file")
57
- entries[f] = shell.vfs.readFile(p);
58
- else {
59
- const walk = (dir, prefix) => {
60
- for (const e of shell.vfs.list(dir)) {
61
- const full = `${dir}/${e}`, rel = `${prefix}/${e}`;
62
- const s = shell.vfs.stat(full);
63
- if (s.type === "file")
64
- entries[rel] = shell.vfs.readFile(full);
65
- else
66
- walk(full, rel);
67
- }
68
- };
69
- walk(p, f);
70
- }
119
+ if (!shell.vfs.exists(p))
120
+ return { stderr: `tar: ${f}: No such file or directory`, exitCode: 1 };
121
+ const st = shell.vfs.stat(p);
122
+ if (st.type === "file") {
123
+ const content = shell.vfs.readFileRaw(p);
124
+ entries.push({ name: f, content, isDir: false });
125
+ if (verbose)
126
+ verboseLines.push(f);
71
127
  }
72
- catch {
73
- return {
74
- stderr: `tar: ${f}: No such file or directory`,
75
- exitCode: 1,
128
+ else {
129
+ entries.push({ name: f, content: Buffer.alloc(0), isDir: true });
130
+ if (verbose)
131
+ verboseLines.push(`${f}/`);
132
+ const walk = (dir, prefix) => {
133
+ for (const e of shell.vfs.list(dir)) {
134
+ const full = `${dir}/${e}`, rel = `${prefix}/${e}`;
135
+ const s = shell.vfs.stat(full);
136
+ if (s.type === "directory") {
137
+ entries.push({ name: rel, content: Buffer.alloc(0), isDir: true });
138
+ if (verbose)
139
+ verboseLines.push(`${rel}/`);
140
+ walk(full, rel);
141
+ }
142
+ else {
143
+ const content = shell.vfs.readFileRaw(full);
144
+ entries.push({ name: rel, content, isDir: false });
145
+ if (verbose)
146
+ verboseLines.push(rel);
147
+ }
148
+ }
76
149
  };
150
+ walk(p, f);
77
151
  }
78
152
  }
79
- shell.writeFileAsUser(authUser, archivePath, JSON.stringify(entries));
80
- return { exitCode: 0 };
153
+ const tarBuf = buildTar(entries);
154
+ const finalBuf = autoGzip ? Buffer.from(gzipSync(tarBuf)) : tarBuf;
155
+ shell.vfs.writeFile(archivePath, finalBuf);
156
+ return { stdout: verbose ? verboseLines.join("\n") : undefined, exitCode: 0 };
81
157
  }
82
158
  if (list || extract) {
83
- let entries;
84
- try {
85
- entries = JSON.parse(shell.vfs.readFile(archivePath));
159
+ const rawArchive = shell.vfs.readFileRaw(archivePath);
160
+ let raw;
161
+ if (autoGzip) {
162
+ try {
163
+ raw = Buffer.from(gunzipSync(rawArchive));
164
+ }
165
+ catch {
166
+ return { stderr: `tar: ${archiveName}: not a gzip file`, exitCode: 1 };
167
+ }
168
+ }
169
+ else {
170
+ raw = rawArchive;
86
171
  }
87
- catch {
88
- return {
89
- stderr: `tar: ${archiveName}: cannot open archive`,
90
- exitCode: 1,
91
- };
172
+ const files = parseTar(raw);
173
+ if (list) {
174
+ const names = files.map((f) => (verbose ? `-rw-r--r-- 0/0 ${f.content.length.toString().padStart(8)} 1970-01-01 00:00 ${f.name}` : f.name));
175
+ return { stdout: names.join("\n"), exitCode: 0 };
92
176
  }
93
- if (list)
94
- return { stdout: Object.keys(entries).join("\n"), exitCode: 0 };
95
- for (const [name, content] of Object.entries(entries)) {
96
- shell.writeFileAsUser(authUser, resolvePath(cwd, name), content);
177
+ const verboseLines = [];
178
+ for (const { name, content } of files) {
179
+ const destPath = resolvePath(cwd, name);
180
+ shell.writeFileAsUser(authUser, destPath, content);
181
+ if (verbose)
182
+ verboseLines.push(name);
97
183
  }
98
- return { exitCode: 0 };
184
+ return { stdout: verbose ? verboseLines.join("\n") : undefined, exitCode: 0 };
99
185
  }
100
186
  return { stderr: "tar: must specify -c, -x, or -t", exitCode: 1 };
101
187
  },
@@ -0,0 +1,11 @@
1
+ import type { ShellModule } from "../types/commands";
2
+ /**
3
+ * Create ZIP archives using real PKZIP format with DEFLATE compression.
4
+ * @category archive
5
+ */
6
+ export declare const zipCommand: ShellModule;
7
+ /**
8
+ * Extract ZIP archives (real PKZIP DEFLATE format).
9
+ * @category archive
10
+ */
11
+ export declare const unzipCommand: ShellModule;
@@ -0,0 +1,232 @@
1
+ import { deflateSync, inflateSync } from "fflate";
2
+ import { resolvePath } from "./helpers";
3
+ // ── CRC32 ─────────────────────────────────────────────────────────────────────
4
+ const CRC_TABLE = (() => {
5
+ const t = new Uint32Array(256);
6
+ for (let i = 0; i < 256; i++) {
7
+ let c = i;
8
+ for (let j = 0; j < 8; j++)
9
+ c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
10
+ t[i] = c;
11
+ }
12
+ return t;
13
+ })();
14
+ function crc32(buf) {
15
+ let crc = 0xffffffff;
16
+ for (let i = 0; i < buf.length; i++)
17
+ crc = (CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8)) >>> 0;
18
+ return (crc ^ 0xffffffff) >>> 0;
19
+ }
20
+ // ── DOS date/time from JS Date ────────────────────────────────────────────────
21
+ function dosDateTime() {
22
+ const d = new Date();
23
+ const date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
24
+ const time = (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2);
25
+ return [time, date];
26
+ }
27
+ // ── ZIP builder ───────────────────────────────────────────────────────────────
28
+ function buildZip(entries) {
29
+ const parts = [];
30
+ const cdParts = [];
31
+ let offset = 0;
32
+ const [modTime, modDate] = dosDateTime();
33
+ for (const { name, content } of entries) {
34
+ const nameBuf = Buffer.from(name, "utf8");
35
+ const compressed = Buffer.from(deflateSync(content, { level: 6 }));
36
+ const useDeflate = compressed.length < content.length;
37
+ const stored = useDeflate ? compressed : content;
38
+ const crc = crc32(content);
39
+ const method = useDeflate ? 8 : 0;
40
+ // Local file header
41
+ const lfh = Buffer.alloc(30 + nameBuf.length);
42
+ lfh.writeUInt32LE(0x04034b50, 0);
43
+ lfh.writeUInt16LE(20, 4);
44
+ lfh.writeUInt16LE(0x0800, 6); // UTF-8 flag
45
+ lfh.writeUInt16LE(method, 8);
46
+ lfh.writeUInt16LE(modTime, 10);
47
+ lfh.writeUInt16LE(modDate, 12);
48
+ lfh.writeUInt32LE(crc, 14);
49
+ lfh.writeUInt32LE(stored.length, 18);
50
+ lfh.writeUInt32LE(content.length, 22);
51
+ lfh.writeUInt16LE(nameBuf.length, 26);
52
+ lfh.writeUInt16LE(0, 28);
53
+ nameBuf.copy(lfh, 30);
54
+ // Central directory entry
55
+ const cd = Buffer.alloc(46 + nameBuf.length);
56
+ cd.writeUInt32LE(0x02014b50, 0);
57
+ cd.writeUInt16LE(20, 4);
58
+ cd.writeUInt16LE(20, 6);
59
+ cd.writeUInt16LE(0x0800, 8);
60
+ cd.writeUInt16LE(method, 10);
61
+ cd.writeUInt16LE(modTime, 12);
62
+ cd.writeUInt16LE(modDate, 14);
63
+ cd.writeUInt32LE(crc, 16);
64
+ cd.writeUInt32LE(stored.length, 20);
65
+ cd.writeUInt32LE(content.length, 24);
66
+ cd.writeUInt16LE(nameBuf.length, 28);
67
+ cd.writeUInt16LE(0, 30); // extra
68
+ cd.writeUInt16LE(0, 32); // comment
69
+ cd.writeUInt16LE(0, 34); // disk start
70
+ cd.writeUInt16LE(0, 36); // int attr
71
+ cd.writeUInt32LE(0x81a40000, 38); // ext attr: -rw-r--r--
72
+ cd.writeUInt32LE(offset, 42);
73
+ nameBuf.copy(cd, 46);
74
+ parts.push(lfh, stored);
75
+ cdParts.push(cd);
76
+ offset += lfh.length + stored.length;
77
+ }
78
+ const cdBuf = Buffer.concat(cdParts);
79
+ const eocd = Buffer.alloc(22);
80
+ eocd.writeUInt32LE(0x06054b50, 0);
81
+ eocd.writeUInt16LE(0, 4);
82
+ eocd.writeUInt16LE(0, 6);
83
+ eocd.writeUInt16LE(entries.length, 8);
84
+ eocd.writeUInt16LE(entries.length, 10);
85
+ eocd.writeUInt32LE(cdBuf.length, 12);
86
+ eocd.writeUInt32LE(offset, 16);
87
+ eocd.writeUInt16LE(0, 20);
88
+ return Buffer.concat([...parts, cdBuf, eocd]);
89
+ }
90
+ // ── ZIP parser ────────────────────────────────────────────────────────────────
91
+ function parseZip(raw) {
92
+ const files = [];
93
+ let off = 0;
94
+ while (off + 4 <= raw.length) {
95
+ const sig = raw.readUInt32LE(off);
96
+ if (sig === 0x02014b50 || sig === 0x06054b50)
97
+ break; // central dir / EOCD
98
+ if (sig !== 0x04034b50) {
99
+ off++;
100
+ continue;
101
+ }
102
+ const method = raw.readUInt16LE(off + 8);
103
+ const compSize = raw.readUInt32LE(off + 18);
104
+ const uncompSize = raw.readUInt32LE(off + 22);
105
+ const nameLen = raw.readUInt16LE(off + 26);
106
+ const extraLen = raw.readUInt16LE(off + 28);
107
+ const name = raw.subarray(off + 30, off + 30 + nameLen).toString("utf8");
108
+ const dataOff = off + 30 + nameLen + extraLen;
109
+ const compData = raw.subarray(dataOff, dataOff + compSize);
110
+ let content;
111
+ if (method === 8) {
112
+ try {
113
+ content = Buffer.from(inflateSync(compData));
114
+ }
115
+ catch {
116
+ content = compData;
117
+ }
118
+ }
119
+ else {
120
+ content = compData;
121
+ }
122
+ if (name && !name.endsWith("/")) {
123
+ // Validate size
124
+ if (content.length === uncompSize || method !== 0)
125
+ files.push({ name, content });
126
+ else
127
+ files.push({ name, content });
128
+ }
129
+ off = dataOff + compSize;
130
+ }
131
+ return files;
132
+ }
133
+ // ── Commands ──────────────────────────────────────────────────────────────────
134
+ /**
135
+ * Create ZIP archives using real PKZIP format with DEFLATE compression.
136
+ * @category archive
137
+ */
138
+ export const zipCommand = {
139
+ name: "zip",
140
+ description: "Package and compress files",
141
+ category: "archive",
142
+ params: ["[-r] <archive.zip> <file...>"],
143
+ run: ({ shell, cwd, args }) => {
144
+ const recursive = args.includes("-r") || args.includes("-R");
145
+ const files = args.filter((a) => !a.startsWith("-"));
146
+ const archiveArg = files[0];
147
+ const sources = files.slice(1);
148
+ if (!archiveArg)
149
+ return { stderr: "zip: no archive specified", exitCode: 1 };
150
+ if (sources.length === 0)
151
+ return { stderr: "zip: nothing to do!", exitCode: 12 };
152
+ const archivePath = resolvePath(cwd, archiveArg.endsWith(".zip") ? archiveArg : `${archiveArg}.zip`);
153
+ const entries = [];
154
+ const verboseLines = [];
155
+ for (const src of sources) {
156
+ const p = resolvePath(cwd, src);
157
+ if (!shell.vfs.exists(p))
158
+ return { stderr: `zip warning: name not matched: ${src}`, exitCode: 12 };
159
+ const st = shell.vfs.stat(p);
160
+ if (st.type === "file") {
161
+ const content = shell.vfs.readFileRaw(p);
162
+ entries.push({ name: src, content });
163
+ verboseLines.push(` adding: ${src} (deflated)`);
164
+ }
165
+ else if (recursive) {
166
+ const walk = (dir, prefix) => {
167
+ for (const e of shell.vfs.list(dir)) {
168
+ const full = `${dir}/${e}`, rel = `${prefix}/${e}`;
169
+ const s = shell.vfs.stat(full);
170
+ if (s.type === "directory")
171
+ walk(full, rel);
172
+ else {
173
+ const content = shell.vfs.readFileRaw(full);
174
+ entries.push({ name: rel, content });
175
+ verboseLines.push(` adding: ${rel} (deflated)`);
176
+ }
177
+ }
178
+ };
179
+ walk(p, src);
180
+ }
181
+ }
182
+ if (entries.length === 0)
183
+ return { stderr: "zip: nothing to do!", exitCode: 12 };
184
+ const zipBuf = buildZip(entries);
185
+ shell.vfs.writeFile(archivePath, zipBuf);
186
+ return { stdout: verboseLines.join("\n"), exitCode: 0 };
187
+ },
188
+ };
189
+ /**
190
+ * Extract ZIP archives (real PKZIP DEFLATE format).
191
+ * @category archive
192
+ */
193
+ export const unzipCommand = {
194
+ name: "unzip",
195
+ description: "Extract compressed files from ZIP archives",
196
+ category: "archive",
197
+ params: ["[-l] [-o] <archive.zip> [-d <dir>]"],
198
+ run: ({ shell, cwd, args }) => {
199
+ const listOnly = args.includes("-l");
200
+ const dIdx = args.indexOf("-d");
201
+ const destDir = dIdx !== -1 ? args[dIdx + 1] : undefined;
202
+ const archive = args.find((a) => !a.startsWith("-") && a !== destDir);
203
+ if (!archive)
204
+ return { stderr: "unzip: missing archive operand", exitCode: 1 };
205
+ const archivePath = resolvePath(cwd, archive);
206
+ if (!shell.vfs.exists(archivePath))
207
+ return { stderr: `unzip: cannot find or open ${archive}`, exitCode: 9 };
208
+ const raw = shell.vfs.readFileRaw(archivePath);
209
+ let files;
210
+ try {
211
+ files = parseZip(raw);
212
+ }
213
+ catch {
214
+ return { stderr: `unzip: ${archive}: not a valid ZIP file`, exitCode: 1 };
215
+ }
216
+ const dest = destDir ? resolvePath(cwd, destDir) : cwd;
217
+ if (listOnly) {
218
+ const header = `Archive: ${archive}\n Length Date Time Name\n--------- ---------- ----- ----`;
219
+ const rows = files.map((f) => ` ${String(f.content.length).padStart(8)} 2024-01-01 00:00 ${f.name}`);
220
+ const total = files.reduce((s, f) => s + f.content.length, 0);
221
+ const footer = `--------- -------\n ${String(total).padStart(8)} ${files.length} file${files.length !== 1 ? "s" : ""}`;
222
+ return { stdout: `${header}\n${rows.join("\n")}\n${footer}`, exitCode: 0 };
223
+ }
224
+ const out = [`Archive: ${archive}`];
225
+ for (const { name, content } of files) {
226
+ const destPath = `${dest}/${name}`;
227
+ shell.vfs.writeFile(destPath, content);
228
+ out.push(` inflating: ${destPath}`);
229
+ }
230
+ return { stdout: out.join("\n"), exitCode: 0 };
231
+ },
232
+ };
@@ -1281,7 +1281,7 @@ Installed-Size: 6800
1281
1281
  Maintainer: Fortune Package Team <dpkg@fortune.local>
1282
1282
  Architecture: amd64
1283
1283
  Version: 1.22.6nyx1
1284
- Depends: libc6 (>= 2.17), libzstd1 (>= 1.5.5)
1284
+ Depends: libc6 (>= 2.17), libzstd1 (>= 1.5.6)
1285
1285
  Description: Fortune package management system
1286
1286
  This package provides the low-level infrastructure for handling the
1287
1287
  installation and removal of Fortune software packages.
@@ -369,12 +369,27 @@ export function expandSync(input, env, lastExit = 0, home) {
369
369
  let s = chunk;
370
370
  // Tilde expansion — only at start of token or after `:` or whitespace
371
371
  s = s.replace(/(^|[\s:])~(\/|$)/g, (_, pre, post) => `${pre}${homePath}${post}`);
372
- // $? $$ $#
372
+ // $? $$ $# $RANDOM $LINENO
373
373
  s = s.replace(/\$\?/g, String(lastExit));
374
374
  s = s.replace(/\$\$/g, "1");
375
375
  s = s.replace(/\$#/g, "0");
376
+ s = s.replace(/\$RANDOM\b/g, () => String(Math.floor(Math.random() * 32768)));
377
+ s = s.replace(/\$LINENO\b/g, "1");
376
378
  // $(( arithmetic )) — must come before ${ and $VAR to avoid conflicts
377
379
  s = expandArithmeticChunks(s, env);
380
+ // ${arr[@]} and ${arr[*]} — all array elements
381
+ s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)[@*]\}/g, (_, name) => env[name] ?? "");
382
+ // ${arr[N]} — single array element
383
+ s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\[(\d+)\]\}/g, (_, name, idx) => env[`${name}[${idx}]`] ?? "");
384
+ // ${#arr[@]} — array length
385
+ s = s.replace(/\$\{#([A-Za-z_][A-Za-z0-9_]*)[@*]\}/g, (_, name) => {
386
+ let count = 0;
387
+ for (const k of Object.keys(env)) {
388
+ if (k.startsWith(`${name}[`))
389
+ count++;
390
+ }
391
+ return String(count);
392
+ });
378
393
  // ${#VAR} — string length
379
394
  s = s.replace(/\$\{#([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => String((env[name] ?? "").length));
380
395
  // ${VAR:-default}
@@ -387,6 +402,57 @@ export function expandSync(input, env, lastExit = 0, home) {
387
402
  });
388
403
  // ${VAR:+alternate}
389
404
  s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*):\+([^}]*)\}/g, (_, name, alt) => env[name] !== undefined && env[name] !== "" ? alt : "");
405
+ // ${VAR:offset:len} and ${VAR:offset}
406
+ s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*):(-?\d+)(?::(\d+))?\}/g, (_, name, offset, len) => {
407
+ const val = env[name] ?? "";
408
+ const off = parseInt(offset, 10);
409
+ const start = off < 0 ? Math.max(0, val.length + off) : Math.min(off, val.length);
410
+ return len !== undefined ? val.slice(start, start + parseInt(len, 10)) : val.slice(start);
411
+ });
412
+ // ${VAR//pattern/replace} — replace all
413
+ s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\/\/([^/}]*)\/([^}]*)\}/g, (_, name, pat, rep) => {
414
+ const val = env[name] ?? "";
415
+ try {
416
+ return val.replace(new RegExp(pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, "."), "g"), rep);
417
+ }
418
+ catch {
419
+ return val;
420
+ }
421
+ });
422
+ // ${VAR/pattern/replace} — replace first
423
+ s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\/([^/}]*)\/([^}]*)\}/g, (_, name, pat, rep) => {
424
+ const val = env[name] ?? "";
425
+ try {
426
+ return val.replace(new RegExp(pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")), rep);
427
+ }
428
+ catch {
429
+ return val;
430
+ }
431
+ });
432
+ // ${VAR##pattern} — strip longest prefix
433
+ s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)##([^}]+)\}/g, (_, name, pat) => {
434
+ const val = env[name] ?? "";
435
+ const re = new RegExp(`^${pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}`);
436
+ return val.replace(re, "");
437
+ });
438
+ // ${VAR#pattern} — strip shortest prefix
439
+ s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)#([^}]+)\}/g, (_, name, pat) => {
440
+ const val = env[name] ?? "";
441
+ const re = new RegExp(`^${pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, ".")}`);
442
+ return val.replace(re, "");
443
+ });
444
+ // ${VAR%%pattern} — strip longest suffix
445
+ s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)%%([^}]+)\}/g, (_, name, pat) => {
446
+ const val = env[name] ?? "";
447
+ const re = new RegExp(`${pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
448
+ return val.replace(re, "");
449
+ });
450
+ // ${VAR%pattern} — strip shortest suffix
451
+ s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)%([^}]+)\}/g, (_, name, pat) => {
452
+ const val = env[name] ?? "";
453
+ const re = new RegExp(`${pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, ".")}$`);
454
+ return val.replace(re, "");
455
+ });
390
456
  // ${VAR}
391
457
  s = s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => env[name] ?? "");
392
458
  // $VAR and positional params $1 $2 ...
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
7
- "version": "1.5.4",
7
+ "version": "1.5.6",
8
8
  "files": [
9
9
  "dist/",
10
10
  "README.md",
@@ -32,7 +32,7 @@
32
32
  "lint:write": "bunx --bun @biomejs/biome lint --write ./src",
33
33
  "test": "bun run test-salve",
34
34
  "test-battery": "bun test tests/",
35
- "test-salve": "for f in tests/*.test.ts; do echo \"\\n\ud83e\uddea Testing $f...\"; bun test \"$f\" --timeout 10000; sleep 0.25; done",
35
+ "test-salve": "for f in tests/*.test.ts; do echo \"\\n🧪 Testing $f...\"; bun test \"$f\" --timeout 10000; sleep 0.25; done",
36
36
  "build": "tsc --project tsconfig.json",
37
37
  "deploy:npm": "bun publish --access public",
38
38
  "bench": "rm -rf .benchmark-shells/ && bun benchmark-virtualshell.ts",
@@ -46,13 +46,13 @@
46
46
  "self-standalone-build": "node scripts/build-all.mjs",
47
47
  "standalone-build": "bunx esbuild src/standalone.ts --bundle --platform=node --target=node18 --outfile=builds/standalone.cjs --tree-shaking=true --minify --banner:js='#!/usr/bin/env node'",
48
48
  "build-all": "node scripts/build-all.mjs",
49
- "publish-doc": "bunx typedoc && bun build-all && bunx gh-pages -d docs && git add docs && git commit -m 'docs: update documentation' && git push",
49
+ "publish-doc": "bunx typedoc && bun build-all && cd examples && node build && cd .. && bunx gh-pages -d docs && git add docs && git commit -m 'docs: update documentation' && git push",
50
50
  "generate-manuals": "node scripts/generate-manuals-bundle.mjs"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@biomejs/biome": "^2.4.15",
54
54
  "@types/bun": "^1.3.14",
55
- "@types/node": "^25.7.0",
55
+ "@types/node": "^25.8.0",
56
56
  "@types/ssh2": "^1.15.5",
57
57
  "esbuild": "^0.28.0",
58
58
  "gh-pages": "^6.3.0",
@@ -63,6 +63,7 @@
63
63
  "typescript": "^5"
64
64
  },
65
65
  "dependencies": {
66
+ "fflate": "^0.8.2",
66
67
  "ssh2": "^1.17.0"
67
68
  }
68
69
  }
@@ -1 +0,0 @@
1
- export {};