typescript-virtual-container 1.5.5 → 1.5.7

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 (64) hide show
  1. package/README.md +117 -35
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/SSHMimic/index.d.ts +5 -1
  4. package/dist/SSHMimic/index.js +27 -3
  5. package/dist/SSHMimic/scp.d.ts +34 -0
  6. package/dist/SSHMimic/scp.js +285 -0
  7. package/dist/SSHMimic/sftp.d.ts +53 -3
  8. package/dist/SSHMimic/sftp.js +9 -3
  9. package/dist/VirtualFileSystem/binaryPack.d.ts +7 -0
  10. package/dist/VirtualFileSystem/binaryPack.js +37 -1
  11. package/dist/VirtualFileSystem/index.d.ts +7 -0
  12. package/dist/VirtualFileSystem/index.js +67 -27
  13. package/dist/VirtualFileSystem/internalTypes.d.ts +2 -0
  14. package/dist/VirtualFileSystem/path.d.ts +5 -0
  15. package/dist/VirtualFileSystem/path.js +24 -11
  16. package/dist/VirtualPackageManager/index.d.ts +4 -2
  17. package/dist/VirtualPackageManager/index.js +24 -4
  18. package/dist/VirtualShell/index.d.ts +4 -0
  19. package/dist/VirtualShell/index.js +1 -7
  20. package/dist/VirtualShell/shell.js +40 -10
  21. package/dist/VirtualShell/shellParser.js +1 -22
  22. package/dist/commands/awk.d.ts +6 -11
  23. package/dist/commands/awk.js +462 -109
  24. package/dist/commands/bzip2.d.ts +11 -0
  25. package/dist/commands/bzip2.js +91 -0
  26. package/dist/commands/exit.js +1 -1
  27. package/dist/commands/find.d.ts +2 -2
  28. package/dist/commands/find.js +209 -37
  29. package/dist/commands/helpers.d.ts +0 -20
  30. package/dist/commands/helpers.js +0 -97
  31. package/dist/commands/lsof.d.ts +6 -0
  32. package/dist/commands/lsof.js +30 -0
  33. package/dist/commands/perl.d.ts +6 -0
  34. package/dist/commands/perl.js +76 -0
  35. package/dist/commands/python.js +5 -2
  36. package/dist/commands/registry.js +19 -1
  37. package/dist/commands/runtime.js +65 -87
  38. package/dist/commands/sed.d.ts +2 -2
  39. package/dist/commands/sed.js +216 -34
  40. package/dist/commands/sh.js +42 -0
  41. package/dist/commands/strace.d.ts +6 -0
  42. package/dist/commands/strace.js +26 -0
  43. package/dist/commands/tar.d.ts +2 -1
  44. package/dist/commands/tar.js +138 -52
  45. package/dist/commands/test.js +2 -2
  46. package/dist/commands/zip.d.ts +11 -0
  47. package/dist/commands/zip.js +232 -0
  48. package/dist/modules/linuxRootfs.js +1 -4
  49. package/dist/modules/neofetch.js +2 -2
  50. package/dist/types/commands.d.ts +4 -0
  51. package/dist/utils/argv.d.ts +6 -0
  52. package/dist/utils/argv.js +32 -0
  53. package/dist/utils/expand.d.ts +5 -2
  54. package/dist/utils/expand.js +112 -45
  55. package/dist/utils/glob.d.ts +6 -0
  56. package/dist/utils/glob.js +34 -0
  57. package/dist/utils/tokenize.js +13 -13
  58. package/package.json +9 -7
  59. package/dist/self-standalone.d.ts +0 -1
  60. package/dist/self-standalone.js +0 -444
  61. package/dist/standalone-wo-sftp.d.ts +0 -1
  62. package/dist/standalone-wo-sftp.js +0 -30
  63. package/dist/standalone.d.ts +0 -1
  64. package/dist/standalone.js +0 -61
@@ -5,7 +5,7 @@ import * as path from "node:path";
5
5
  import { gunzipSync, gzipSync } from "node:zlib";
6
6
  import { decodeVfs, encodeVfs, isBinarySnapshot } from "./binaryPack";
7
7
  import { appendJournalEntry, JournalOp, readJournal, truncateJournal } from "./journal";
8
- import { getNode, getParentDirectory, normalizePath } from "./path";
8
+ import { getNodeNormalized, getParentDirectory, normalizePath } from "./path";
9
9
  // ── VirtualFileSystem ─────────────────────────────────────────────────────────
10
10
  /**
11
11
  * In-memory virtual filesystem with optional JSON-snapshot persistence.
@@ -48,6 +48,8 @@ class VirtualFileSystem extends EventEmitter {
48
48
  _dirty = false;
49
49
  /** Active host-directory mounts: vPath → { hostPath, readOnly } */
50
50
  mounts = new Map();
51
+ /** Sorted mounts cache (longest-path-first). Rebuilt lazily on mount/unmount. */
52
+ _sortedMounts = null;
51
53
  /** True when running in a browser environment (no host FS access). */
52
54
  static isBrowser = typeof process === "undefined" || typeof process.versions?.node === "undefined";
53
55
  constructor(options = {}) {
@@ -93,6 +95,7 @@ class VirtualFileSystem extends EventEmitter {
93
95
  updatedAt: now,
94
96
  children: Object.create(null),
95
97
  _childCount: 0,
98
+ _sortedKeys: null,
96
99
  };
97
100
  }
98
101
  makeFile(name, content, mode, compressed) {
@@ -127,8 +130,10 @@ class VirtualFileSystem extends EventEmitter {
127
130
  // Don't overwrite a real file or an already-promoted node
128
131
  if (existing?.type === "file")
129
132
  return;
130
- if (!existing)
133
+ if (!existing) {
131
134
  parent._childCount++;
135
+ parent._sortedKeys = null;
136
+ }
132
137
  parent.children[name] = this.makeStub(name, content, mode);
133
138
  }
134
139
  mkdirRecursive(targetPath, mode) {
@@ -145,6 +150,7 @@ class VirtualFileSystem extends EventEmitter {
145
150
  child = this.makeDir(part, mode);
146
151
  current.children[part] = child;
147
152
  current._childCount++;
153
+ current._sortedKeys = null;
148
154
  this.emit("dir:create", { path: builtPath, mode });
149
155
  this._journal({ op: JournalOp.MKDIR, path: builtPath, mode });
150
156
  }
@@ -316,21 +322,19 @@ class VirtualFileSystem extends EventEmitter {
316
322
  const existing = live.children[name];
317
323
  if (node.type === "directory") {
318
324
  if (!existing) {
319
- // Dir doesn't exist yet — add it
320
325
  live.children[name] = node;
321
326
  live._childCount++;
327
+ live._sortedKeys = null;
322
328
  }
323
329
  else if (existing.type === "directory") {
324
- // Both dirs — recurse
325
330
  this._mergeDir(existing, node);
326
331
  }
327
- // existing is a file where dir expected — leave user file alone
328
332
  }
329
333
  else {
330
- // File or stub — only add if not already present
331
334
  if (!existing) {
332
335
  live.children[name] = node;
333
336
  live._childCount++;
337
+ live._sortedKeys = null;
334
338
  }
335
339
  }
336
340
  }
@@ -495,6 +499,7 @@ class VirtualFileSystem extends EventEmitter {
495
499
  // Ensure the mount point exists in the VFS tree
496
500
  this.mkdir(normalized);
497
501
  this.mounts.set(normalized, { hostPath: resolved, readOnly });
502
+ this._sortedMounts = null;
498
503
  this.emit("mount", { vPath: normalized, hostPath: resolved, readOnly });
499
504
  }
500
505
  /**
@@ -505,6 +510,7 @@ class VirtualFileSystem extends EventEmitter {
505
510
  unmount(vPath) {
506
511
  const normalized = normalizePath(vPath);
507
512
  if (this.mounts.delete(normalized)) {
513
+ this._sortedMounts = null;
508
514
  this.emit("unmount", { vPath: normalized });
509
515
  }
510
516
  }
@@ -521,9 +527,11 @@ class VirtualFileSystem extends EventEmitter {
521
527
  */
522
528
  resolveMount(targetPath) {
523
529
  const normalized = normalizePath(targetPath);
524
- // Iterate mounts from most specific to least specific
525
- const sorted = [...this.mounts.entries()].sort(([a], [b]) => b.length - a.length);
526
- for (const [vBase, opts] of sorted) {
530
+ // Iterate mounts from most specific to least specific (cached, rebuilt on mount/unmount)
531
+ if (!this._sortedMounts) {
532
+ this._sortedMounts = [...this.mounts.entries()].sort(([a], [b]) => b.length - a.length);
533
+ }
534
+ for (const [vBase, opts] of this._sortedMounts) {
527
535
  if (normalized === vBase || normalized.startsWith(`${vBase}/`)) {
528
536
  const relPath = normalized.slice(vBase.length).replace(/^\//, "");
529
537
  const fullHostPath = relPath ? path.join(opts.hostPath, relPath) : opts.hostPath;
@@ -536,7 +544,7 @@ class VirtualFileSystem extends EventEmitter {
536
544
  const normalized = normalizePath(targetPath);
537
545
  const existing = (() => {
538
546
  try {
539
- return getNode(this.root, normalized);
547
+ return getNodeNormalized(this.root, normalized);
540
548
  }
541
549
  catch {
542
550
  return null;
@@ -585,8 +593,10 @@ class VirtualFileSystem extends EventEmitter {
585
593
  }
586
594
  else {
587
595
  // Create new real file — also promotes stubs (no _childCount change for stubs)
588
- if (!existing)
596
+ if (!existing) {
589
597
  parent._childCount++;
598
+ parent._sortedKeys = null;
599
+ }
590
600
  parent.children[name] = this.makeFile(name, storedContent, mode, shouldCompress);
591
601
  }
592
602
  this.emit("file:write", { path: normalized, size: storedContent.length });
@@ -604,7 +614,7 @@ class VirtualFileSystem extends EventEmitter {
604
614
  return fsSync.readFileSync(m.fullHostPath, "utf8");
605
615
  }
606
616
  const normalized = normalizePath(targetPath);
607
- const node = getNode(this.root, normalized);
617
+ const node = getNodeNormalized(this.root, normalized);
608
618
  if (node.type === "stub") {
609
619
  this.emit("file:read", { path: normalized, size: node.stubContent.length });
610
620
  return node.stubContent;
@@ -628,7 +638,7 @@ class VirtualFileSystem extends EventEmitter {
628
638
  return fsSync.readFileSync(m.fullHostPath);
629
639
  }
630
640
  const normalized = normalizePath(targetPath);
631
- const node = getNode(this.root, normalized);
641
+ const node = getNodeNormalized(this.root, normalized);
632
642
  if (node.type === "stub") {
633
643
  const buf = Buffer.from(node.stubContent, "utf8");
634
644
  this.emit("file:read", { path: normalized, size: buf.length });
@@ -650,7 +660,7 @@ class VirtualFileSystem extends EventEmitter {
650
660
  if (m)
651
661
  return fsSync.existsSync(m.fullHostPath);
652
662
  try {
653
- getNode(this.root, normalizePath(targetPath));
663
+ getNodeNormalized(this.root, normalizePath(targetPath));
654
664
  return true;
655
665
  }
656
666
  catch {
@@ -660,7 +670,7 @@ class VirtualFileSystem extends EventEmitter {
660
670
  /** Updates mode bits on a node. */
661
671
  chmod(targetPath, mode) {
662
672
  const normalized = normalizePath(targetPath);
663
- getNode(this.root, normalized).mode = mode;
673
+ getNodeNormalized(this.root, normalized).mode = mode;
664
674
  this._journal({ op: JournalOp.CHMOD, path: normalized, mode });
665
675
  }
666
676
  /** Returns metadata for a file or directory. */
@@ -695,7 +705,7 @@ class VirtualFileSystem extends EventEmitter {
695
705
  };
696
706
  }
697
707
  const normalized = normalizePath(targetPath);
698
- const node = getNode(this.root, normalized);
708
+ const node = getNodeNormalized(this.root, normalized);
699
709
  const name = normalized === "/" ? "" : path.posix.basename(normalized);
700
710
  if (node.type === "stub") {
701
711
  const s = node;
@@ -734,6 +744,26 @@ class VirtualFileSystem extends EventEmitter {
734
744
  childrenCount: d._childCount,
735
745
  };
736
746
  }
747
+ /**
748
+ * Fast type-only check — no Date/string allocation.
749
+ * Use instead of `stat().type` when that's all you need.
750
+ */
751
+ statType(targetPath) {
752
+ try {
753
+ const m = this.resolveMount(targetPath);
754
+ if (m) {
755
+ const s = fsSync.statSync(m.fullHostPath, { throwIfNoEntry: false });
756
+ if (!s)
757
+ return null;
758
+ return s.isDirectory() ? "directory" : "file";
759
+ }
760
+ const node = getNodeNormalized(this.root, normalizePath(targetPath));
761
+ return node.type === "directory" ? "directory" : "file";
762
+ }
763
+ catch {
764
+ return null;
765
+ }
766
+ }
737
767
  /** Lists direct children names of a directory (sorted). */
738
768
  list(dirPath = "/") {
739
769
  const m = this.resolveMount(dirPath);
@@ -748,16 +778,19 @@ class VirtualFileSystem extends EventEmitter {
748
778
  }
749
779
  }
750
780
  const normalized = normalizePath(dirPath);
751
- const node = getNode(this.root, normalized);
781
+ const node = getNodeNormalized(this.root, normalized);
752
782
  if (node.type !== "directory") {
753
783
  throw new Error(`Cannot list '${dirPath}': not a directory.`);
754
784
  }
755
- return Object.keys(node.children).sort();
785
+ const dir = node;
786
+ if (!dir._sortedKeys)
787
+ dir._sortedKeys = Object.keys(dir.children).sort();
788
+ return dir._sortedKeys;
756
789
  }
757
790
  /** Renders ASCII tree view of a directory hierarchy. */
758
791
  tree(dirPath = "/") {
759
792
  const normalized = normalizePath(dirPath);
760
- const node = getNode(this.root, normalized);
793
+ const node = getNodeNormalized(this.root, normalized);
761
794
  if (node.type !== "directory") {
762
795
  throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
763
796
  }
@@ -766,7 +799,9 @@ class VirtualFileSystem extends EventEmitter {
766
799
  }
767
800
  renderTreeLines(dir, label) {
768
801
  const lines = [label];
769
- const entries = Object.keys(dir.children).sort();
802
+ if (!dir._sortedKeys)
803
+ dir._sortedKeys = Object.keys(dir.children).sort();
804
+ const entries = dir._sortedKeys;
770
805
  for (let i = 0; i < entries.length; i++) {
771
806
  const name = entries[i];
772
807
  const child = dir.children[name];
@@ -786,7 +821,7 @@ class VirtualFileSystem extends EventEmitter {
786
821
  }
787
822
  /** Computes total stored bytes under a path. */
788
823
  getUsageBytes(targetPath = "/") {
789
- return this.computeUsage(getNode(this.root, normalizePath(targetPath)));
824
+ return this.computeUsage(getNodeNormalized(this.root, normalizePath(targetPath)));
790
825
  }
791
826
  computeUsage(node) {
792
827
  if (node.type === "file")
@@ -801,7 +836,7 @@ class VirtualFileSystem extends EventEmitter {
801
836
  }
802
837
  /** Compresses a file's content with gzip in place. */
803
838
  compressFile(targetPath) {
804
- const node = getNode(this.root, normalizePath(targetPath));
839
+ const node = getNodeNormalized(this.root, normalizePath(targetPath));
805
840
  if (node.type !== "file")
806
841
  throw new Error(`Cannot compress '${targetPath}': not a file.`);
807
842
  const f = node;
@@ -813,7 +848,7 @@ class VirtualFileSystem extends EventEmitter {
813
848
  }
814
849
  /** Decompresses a gzip-compressed file in place. */
815
850
  decompressFile(targetPath) {
816
- const node = getNode(this.root, normalizePath(targetPath));
851
+ const node = getNodeNormalized(this.root, normalizePath(targetPath));
817
852
  if (node.type !== "file")
818
853
  throw new Error(`Cannot decompress '${targetPath}': not a file.`);
819
854
  const f = node;
@@ -844,6 +879,7 @@ class VirtualFileSystem extends EventEmitter {
844
879
  };
845
880
  parent.children[name] = symNode;
846
881
  parent._childCount++;
882
+ parent._sortedKeys = null;
847
883
  // Journal before emit
848
884
  this._journal({ op: JournalOp.SYMLINK, path: normalizedLink, dest: normalizedTarget });
849
885
  this.emit("symlink:create", {
@@ -854,7 +890,7 @@ class VirtualFileSystem extends EventEmitter {
854
890
  /** Returns true when the path is a symbolic link node. */
855
891
  isSymlink(targetPath) {
856
892
  try {
857
- const node = getNode(this.root, normalizePath(targetPath));
893
+ const node = getNodeNormalized(this.root, normalizePath(targetPath));
858
894
  return node.type === "file" && node.mode === 0o120777;
859
895
  }
860
896
  catch {
@@ -869,7 +905,7 @@ class VirtualFileSystem extends EventEmitter {
869
905
  let current = normalizePath(linkPath);
870
906
  for (let depth = 0; depth < maxDepth; depth++) {
871
907
  try {
872
- const node = getNode(this.root, current);
908
+ const node = getNodeNormalized(this.root, current);
873
909
  if (node.type === "file" && node.mode === 0o120777) {
874
910
  const target = node.content.toString("utf8");
875
911
  current = target.startsWith("/")
@@ -905,7 +941,7 @@ class VirtualFileSystem extends EventEmitter {
905
941
  const normalized = normalizePath(targetPath);
906
942
  if (normalized === "/")
907
943
  throw new Error("Cannot remove root directory.");
908
- const node = getNode(this.root, normalized);
944
+ const node = getNodeNormalized(this.root, normalized);
909
945
  if (node.type === "directory") {
910
946
  const dir = node;
911
947
  if (!options.recursive && dir._childCount > 0) {
@@ -915,6 +951,7 @@ class VirtualFileSystem extends EventEmitter {
915
951
  const { parent, name } = getParentDirectory(this.root, normalized, false, () => { });
916
952
  delete parent.children[name];
917
953
  parent._childCount--;
954
+ parent._sortedKeys = null;
918
955
  this.emit("node:remove", { path: normalized });
919
956
  this._journal({ op: JournalOp.REMOVE, path: normalized });
920
957
  }
@@ -925,7 +962,7 @@ class VirtualFileSystem extends EventEmitter {
925
962
  if (fromNormalized === "/" || toNormalized === "/") {
926
963
  throw new Error("Cannot move root directory.");
927
964
  }
928
- const node = getNode(this.root, fromNormalized);
965
+ const node = getNodeNormalized(this.root, fromNormalized);
929
966
  if (this.exists(toNormalized)) {
930
967
  throw new Error(`Destination '${toNormalized}' already exists.`);
931
968
  }
@@ -934,9 +971,11 @@ class VirtualFileSystem extends EventEmitter {
934
971
  const { parent: srcParent, name: srcName } = getParentDirectory(this.root, fromNormalized, false, () => { });
935
972
  delete srcParent.children[srcName];
936
973
  srcParent._childCount--;
974
+ srcParent._sortedKeys = null;
937
975
  node.name = destName;
938
976
  destParent.children[destName] = node;
939
977
  destParent._childCount++;
978
+ destParent._sortedKeys = null;
940
979
  this._journal({ op: JournalOp.MOVE, path: fromNormalized, dest: toNormalized });
941
980
  }
942
981
  // ── Snapshot serialisation ─────────────────────────────────────────────────
@@ -1026,6 +1065,7 @@ class VirtualFileSystem extends EventEmitter {
1026
1065
  updatedAt: Date.parse(snap.updatedAt),
1027
1066
  children: Object.create(null),
1028
1067
  _childCount: 0,
1068
+ _sortedKeys: null,
1029
1069
  };
1030
1070
  for (const child of snap.children) {
1031
1071
  if (child.type === "file") {
@@ -36,5 +36,7 @@ export interface InternalDirectoryNode extends InternalBaseNode {
36
36
  children: Record<string, InternalNode>;
37
37
  /** Cached child count — avoids O(n) Object.keys() on hot paths. */
38
38
  _childCount: number;
39
+ /** Cached sorted child names — null means stale, rebuilt lazily by list(). */
40
+ _sortedKeys: string[] | null;
39
41
  }
40
42
  export {};
@@ -2,6 +2,11 @@ import type { InternalDirectoryNode, InternalNode } from "./internalTypes";
2
2
  export declare function normalizePath(rawPath: string): string;
3
3
  export declare function splitPath(normalizedPath: string): string[];
4
4
  export declare function getNode(root: InternalDirectoryNode, targetPath: string): InternalNode;
5
+ /**
6
+ * Like getNode but skips normalization — caller must pass an already-normalized path.
7
+ * Avoids double normalizePath() when the caller has already normalized.
8
+ */
9
+ export declare function getNodeNormalized(root: InternalDirectoryNode, normalized: string): InternalNode;
5
10
  export declare function getParentDirectory(root: InternalDirectoryNode, targetPath: string, createIfMissing: boolean, createPath: (pathToCreate: string) => void): {
6
11
  parent: InternalDirectoryNode;
7
12
  name: string;
@@ -11,20 +11,33 @@ export function splitPath(normalizedPath) {
11
11
  }
12
12
  export function getNode(root, targetPath) {
13
13
  const normalized = normalizePath(targetPath);
14
- if (normalized === "/") {
14
+ return getNodeNormalized(root, normalized);
15
+ }
16
+ /**
17
+ * Like getNode but skips normalization — caller must pass an already-normalized path.
18
+ * Avoids double normalizePath() when the caller has already normalized.
19
+ */
20
+ export function getNodeNormalized(root, normalized) {
21
+ if (normalized === "/")
15
22
  return root;
16
- }
17
- const parts = splitPath(normalized);
18
23
  let current = root;
19
- for (const part of parts) {
20
- if (current.type !== "directory") {
21
- throw new Error(`Path '${normalized}' does not exist.`);
22
- }
23
- const next = current.children[part];
24
- if (!next) {
25
- throw new Error(`Path '${normalized}' does not exist.`);
24
+ let i = 1; // skip leading "/"
25
+ while (i <= normalized.length) {
26
+ const slash = normalized.indexOf("/", i);
27
+ const end = slash === -1 ? normalized.length : slash;
28
+ const part = normalized.slice(i, end);
29
+ if (part) {
30
+ if (current.type !== "directory") {
31
+ throw new Error(`Path '${normalized}' does not exist.`);
32
+ }
33
+ const next = current.children[part];
34
+ if (!next)
35
+ throw new Error(`Path '${normalized}' does not exist.`);
36
+ current = next;
26
37
  }
27
- current = next;
38
+ if (slash === -1)
39
+ break;
40
+ i = slash + 1;
28
41
  }
29
42
  return current;
30
43
  }
@@ -94,18 +94,20 @@ export declare class VirtualPackageManager {
94
94
  private readonly registryPath;
95
95
  private readonly logPath;
96
96
  private readonly aptLogPath;
97
+ private _loaded;
97
98
  /**
98
99
  * @param vfs Backing virtual filesystem for file I/O and dpkg status persistence.
99
100
  * @param users User manager reference passed to `onInstall` hooks.
100
101
  */
101
102
  constructor(vfs: VirtualFileSystem, users: VirtualUserManager);
103
+ /** Ensure dpkg/status is parsed. Called lazily on first package operation. */
104
+ private _ensureLoaded;
102
105
  /**
103
106
  * Loads installed package state from `/var/lib/dpkg/status` in the VFS.
104
- *
105
- * Called automatically by `VirtualShell` after `bootstrapLinuxRootfs`.
106
107
  * Safe to call again to reload state after a snapshot restore.
107
108
  */
108
109
  load(): void;
110
+ private _parseStatus;
109
111
  /** Persist installed state to /var/lib/dpkg/status. */
110
112
  private persist;
111
113
  private parseFields;
@@ -502,6 +502,10 @@ const PACKAGE_REGISTRY = [
502
502
  ],
503
503
  }
504
504
  ];
505
+ // O(1) name lookup — built once at module load, avoids O(n) linear scan per command
506
+ const _REGISTRY_MAP = new Map(PACKAGE_REGISTRY.map((p) => [p.name.toLowerCase(), p]));
507
+ // Pre-sorted for listAvailable() — avoids O(n log n) sort on every apt list/search
508
+ const _REGISTRY_SORTED = PACKAGE_REGISTRY.slice().sort((a, b) => a.name.localeCompare(b.name));
505
509
  /**
506
510
  * Pure-TypeScript APT/dpkg package manager backed by a built-in registry.
507
511
  *
@@ -526,6 +530,7 @@ export class VirtualPackageManager {
526
530
  registryPath = "/var/lib/dpkg/status";
527
531
  logPath = "/var/log/dpkg.log";
528
532
  aptLogPath = "/var/log/apt/history.log";
533
+ _loaded = false;
529
534
  /**
530
535
  * @param vfs Backing virtual filesystem for file I/O and dpkg status persistence.
531
536
  * @param users User manager reference passed to `onInstall` hooks.
@@ -534,13 +539,22 @@ export class VirtualPackageManager {
534
539
  this.vfs = vfs;
535
540
  this.users = users;
536
541
  }
542
+ /** Ensure dpkg/status is parsed. Called lazily on first package operation. */
543
+ _ensureLoaded() {
544
+ if (this._loaded)
545
+ return;
546
+ this._loaded = true;
547
+ this._parseStatus();
548
+ }
537
549
  /**
538
550
  * Loads installed package state from `/var/lib/dpkg/status` in the VFS.
539
- *
540
- * Called automatically by `VirtualShell` after `bootstrapLinuxRootfs`.
541
551
  * Safe to call again to reload state after a snapshot restore.
542
552
  */
543
553
  load() {
554
+ this._loaded = false;
555
+ this._ensureLoaded();
556
+ }
557
+ _parseStatus() {
544
558
  if (!this.vfs.exists(this.registryPath))
545
559
  return;
546
560
  const status = this.vfs.readFile(this.registryPath);
@@ -626,7 +640,7 @@ export class VirtualPackageManager {
626
640
  * @returns The matching `PackageDefinition`, or `undefined` if not found.
627
641
  */
628
642
  findInRegistry(name) {
629
- return PACKAGE_REGISTRY.find((p) => p.name.toLowerCase() === name.toLowerCase());
643
+ return _REGISTRY_MAP.get(name.toLowerCase());
630
644
  }
631
645
  /**
632
646
  * Returns all packages in the built-in registry, sorted alphabetically.
@@ -634,7 +648,7 @@ export class VirtualPackageManager {
634
648
  * @returns Array of `PackageDefinition` entries.
635
649
  */
636
650
  listAvailable() {
637
- return [...PACKAGE_REGISTRY].sort((a, b) => a.name.localeCompare(b.name));
651
+ return _REGISTRY_SORTED;
638
652
  }
639
653
  /**
640
654
  * Returns all currently installed packages, sorted alphabetically.
@@ -642,6 +656,7 @@ export class VirtualPackageManager {
642
656
  * @returns Array of `InstalledPackage` records.
643
657
  */
644
658
  listInstalled() {
659
+ this._ensureLoaded();
645
660
  return [...this.installed.values()].sort((a, b) => a.name.localeCompare(b.name));
646
661
  }
647
662
  /**
@@ -650,6 +665,7 @@ export class VirtualPackageManager {
650
665
  * @param name Package name (case-insensitive).
651
666
  */
652
667
  isInstalled(name) {
668
+ this._ensureLoaded();
653
669
  return this.installed.has(name.toLowerCase());
654
670
  }
655
671
  /**
@@ -658,6 +674,7 @@ export class VirtualPackageManager {
658
674
  * Used by `neofetch` to populate the `Packages:` field.
659
675
  */
660
676
  installedCount() {
677
+ this._ensureLoaded();
661
678
  return this.installed.size;
662
679
  }
663
680
  /**
@@ -675,6 +692,7 @@ export class VirtualPackageManager {
675
692
  * (`0` on success, `100` when a package is not found).
676
693
  */
677
694
  install(names, opts = {}) {
695
+ this._ensureLoaded();
678
696
  const lines = [];
679
697
  const toInstall = [];
680
698
  const notFound = [];
@@ -770,6 +788,7 @@ export class VirtualPackageManager {
770
788
  * @returns Terminal-style `output` string and exit code (`0` on success).
771
789
  */
772
790
  remove(names, opts = {}) {
791
+ this._ensureLoaded();
773
792
  const lines = [];
774
793
  const toRemove = [];
775
794
  for (const name of names) {
@@ -833,6 +852,7 @@ export class VirtualPackageManager {
833
852
  * @returns Multi-line metadata string, or `null` if not in the registry.
834
853
  */
835
854
  show(name) {
855
+ this._ensureLoaded();
836
856
  const def = this.findInRegistry(name);
837
857
  if (!def)
838
858
  return null;
@@ -29,6 +29,10 @@ export interface ShellProperties {
29
29
  os: string;
30
30
  /** CPU architecture label (e.g. `"x86_64"`, `"aarch64"`). */
31
31
  arch: string;
32
+ /** Display resolution (e.g. `"1920x1080"`). */
33
+ resolution?: string;
34
+ /** GPU label (e.g. `"WebGL Renderer"`). */
35
+ gpu?: string;
32
36
  }
33
37
  /**
34
38
  * Minimal VFS interface accepted by {@link VirtualShell} as a drop-in replacement
@@ -27,10 +27,7 @@ function isVirtualShellVfsLike(value) {
27
27
  typeof candidate.exists === "function" &&
28
28
  typeof candidate.stat === "function" &&
29
29
  typeof candidate.list === "function" &&
30
- typeof candidate.remove === "function" &&
31
- typeof candidate.copy === "function" &&
32
- typeof candidate.move === "function" &&
33
- typeof candidate.touch === "function");
30
+ typeof candidate.remove === "function");
34
31
  }
35
32
  const defaultShellProperties = {
36
33
  kernel: "1.0.0+itsrealfortune+1-amd64",
@@ -110,7 +107,6 @@ class VirtualShell extends EventEmitter {
110
107
  // Store references to avoid TypeScript "used before assigned" errors
111
108
  const vfs = this.vfs;
112
109
  const users = this.users;
113
- const pm = this.packageManager;
114
110
  const shellProps = this.properties;
115
111
  const shellHostname = this.hostname;
116
112
  const startTime = this.startTime;
@@ -120,8 +116,6 @@ class VirtualShell extends EventEmitter {
120
116
  await users.initialize();
121
117
  // Bootstrap Linux rootfs (idempotent)
122
118
  bootstrapLinuxRootfs(vfs, users, shellHostname, shellProps, startTime);
123
- // Load installed packages from dpkg status
124
- pm.load();
125
119
  this.emit("initialized");
126
120
  })();
127
121
  }
@@ -14,6 +14,7 @@ export function startShell(properties, stream, authUser, hostname, sessionId, re
14
14
  let cwd = userHome(authUser);
15
15
  let pendingHeredoc = null;
16
16
  const shellEnv = makeDefaultEnv(authUser, hostname);
17
+ const sessionStack = [];
17
18
  let nanoSession = null;
18
19
  let pendingSudo = null;
19
20
  const buildCurrentPrompt = () => {
@@ -112,6 +113,7 @@ export function startShell(properties, stream, authUser, hostname, sessionId, re
112
113
  stream.write(`${toTtyLines(result.stderr)}\r\n`);
113
114
  }
114
115
  if (result.switchUser) {
116
+ sessionStack.push({ authUser, cwd });
115
117
  authUser = result.switchUser;
116
118
  cwd = result.nextCwd ?? userHome(authUser);
117
119
  shell.users.updateSession(sessionId, authUser, remoteAddress);
@@ -396,13 +398,24 @@ export function startShell(properties, stream, authUser, hostname, sessionId, re
396
398
  cursorPos = 0;
397
399
  historyIndex = null;
398
400
  historyDraft = "";
399
- stream.write("bye\r\n");
400
- pushHistory("bye");
401
- // WAL: checkpoint handled by auto-flush timer
402
401
  stream.write("logout\r\n");
403
- stream.exit(0);
404
- stream.end();
405
- return;
402
+ if (sessionStack.length > 0) {
403
+ const prev = sessionStack.pop();
404
+ authUser = prev.authUser;
405
+ cwd = prev.cwd;
406
+ shellEnv.vars.USER = authUser;
407
+ shellEnv.vars.LOGNAME = authUser;
408
+ shellEnv.vars.HOME = userHome(authUser);
409
+ shellEnv.vars.PWD = cwd;
410
+ shell.users.updateSession(sessionId, authUser, remoteAddress);
411
+ renderLine();
412
+ }
413
+ else {
414
+ stream.exit(0);
415
+ stream.end();
416
+ return;
417
+ }
418
+ continue;
406
419
  }
407
420
  if (ch === "\t") {
408
421
  handleTabCompletion();
@@ -598,16 +611,33 @@ export function startShell(properties, stream, authUser, hostname, sessionId, re
598
611
  }
599
612
  if (result.closeSession) {
600
613
  stream.write("logout\r\n");
601
- stream.exit(result.exitCode ?? 0);
602
- stream.end();
603
- return;
614
+ if (sessionStack.length > 0) {
615
+ const prev = sessionStack.pop();
616
+ authUser = prev.authUser;
617
+ cwd = prev.cwd;
618
+ shellEnv.vars.USER = authUser;
619
+ shellEnv.vars.LOGNAME = authUser;
620
+ shellEnv.vars.HOME = userHome(authUser);
621
+ shellEnv.vars.PWD = cwd;
622
+ shell.users.updateSession(sessionId, authUser, remoteAddress);
623
+ }
624
+ else {
625
+ stream.exit(result.exitCode ?? 0);
626
+ stream.end();
627
+ return;
628
+ }
604
629
  }
605
- if (result.nextCwd) {
630
+ if (result.nextCwd && !result.closeSession) {
606
631
  cwd = result.nextCwd;
607
632
  }
608
633
  if (result.switchUser) {
634
+ sessionStack.push({ authUser, cwd });
609
635
  authUser = result.switchUser;
610
636
  cwd = result.nextCwd ?? userHome(authUser);
637
+ shellEnv.vars.USER = authUser;
638
+ shellEnv.vars.LOGNAME = authUser;
639
+ shellEnv.vars.HOME = userHome(authUser);
640
+ shellEnv.vars.PWD = cwd;
611
641
  shell.users.updateSession(sessionId, authUser, remoteAddress);
612
642
  lineBuffer = "";
613
643
  cursorPos = 0;