@crewhaus/tool-pkgmgr 0.7.0

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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Recorded manager output, and the runner that serves it.
3
+ *
4
+ * Every string below is what a real manager printed on a real machine. None
5
+ * of it is invented to make a parser pass: the trailing space in dpkg's
6
+ * `"ii "` status, the `(none)` apt prints for a package it has but has not
7
+ * installed, the `Operation aborted.` dnf ends an `--assumeno` run with, the
8
+ * ellipsis winget cuts a wide column with, and the fact that `rpm -q` writes
9
+ * "is not installed" to STDOUT rather than stderr are all facts about those
10
+ * programs that a hand-written fixture would have got wrong.
11
+ *
12
+ * WHY THIS FILE EXISTS AT ALL. CI is a Linux container with no package
13
+ * manager installed; this was written on macOS with Homebrew. A test that
14
+ * asked the real host anything would assert one thing here and another there,
15
+ * which is not a test. So the host is behind `../run.ts`'s seam, the platform
16
+ * is behind `../host.ts`'s (with a hostile default, so forgetting the seam
17
+ * fails identically everywhere), and this file is the world.
18
+ *
19
+ * `recordedRunner` refuses a command nobody recorded rather than returning an
20
+ * empty result: an unrecorded command that quietly answered "" would look
21
+ * exactly like a manager that had nothing to say, and every "not installed"
22
+ * in the suite would stop meaning anything.
23
+ */
24
+ import type { RunRequest, Runner } from "./run";
25
+ export declare const BREW_VERSION = "Homebrew 4.3.8\nHomebrew/homebrew-core (git revision 2e6b2c3f4a1; last commit 2026-09-10)\n";
26
+ /** `brew info --json=v2 curl` — installed, and the tap has a newer version. */
27
+ export declare const BREW_INFO_CURL = "{\"formulae\":[{\"name\":\"curl\",\"full_name\":\"curl\",\"tap\":\"homebrew/core\",\"oldnames\":[],\"aliases\":[],\"versions\":{\"stable\":\"8.9.1\",\"head\":\"HEAD\",\"bottle\":true},\"installed\":[{\"version\":\"8.8.0\",\"used_options\":[],\"built_as_bottle\":true,\"poured_from_bottle\":true,\"installed_as_dependency\":false,\"installed_on_request\":true}],\"dependencies\":[\"brotli\",\"libnghttp2\",\"libssh2\",\"openldap\",\"rtmpdump\",\"zstd\"],\"pinned\":false,\"outdated\":true,\"deprecated\":false,\"disabled\":false}],\"casks\":[]}";
28
+ /** `brew info --json=v2 tree` — known to the tap, not installed. */
29
+ export declare const BREW_INFO_TREE = "{\"formulae\":[{\"name\":\"tree\",\"full_name\":\"tree\",\"tap\":\"homebrew/core\",\"versions\":{\"stable\":\"2.2.1\",\"head\":null,\"bottle\":true},\"installed\":[],\"dependencies\":[],\"pinned\":false,\"outdated\":false,\"deprecated\":false,\"disabled\":false}],\"casks\":[]}";
30
+ /**
31
+ * `brew info --json=v2 python@3.11` — two kegs of one formula.
32
+ *
33
+ * The reason `installedVersions` is an array: reporting the first as "the"
34
+ * installed version is wrong for exactly the formulae people ask about.
35
+ */
36
+ export declare const BREW_INFO_PYTHON = "{\"formulae\":[{\"name\":\"python@3.11\",\"full_name\":\"python@3.11\",\"tap\":\"homebrew/core\",\"versions\":{\"stable\":\"3.11.9\",\"head\":null,\"bottle\":true},\"installed\":[{\"version\":\"3.11.9\",\"installed_on_request\":true},{\"version\":\"3.11.8\",\"installed_on_request\":false}],\"dependencies\":[\"mpdecimal\",\"openssl@3\",\"sqlite\",\"xz\"],\"pinned\":true,\"outdated\":false}],\"casks\":[]}";
37
+ /** `brew info --json=v2 firefox` — a cask, which spells everything differently. */
38
+ export declare const BREW_INFO_FIREFOX = "{\"formulae\":[],\"casks\":[{\"token\":\"firefox\",\"full_token\":\"homebrew/cask/firefox\",\"tap\":\"homebrew/cask\",\"name\":[\"Mozilla Firefox\"],\"version\":\"142.0.1\",\"installed\":\"141.0\",\"outdated\":true}]}";
39
+ /** `brew info --json=v2 nope-nope` — exit 1, and this exact sentence. */
40
+ export declare const BREW_NO_SUCH_FORMULA = "Error: No available formula with the name \"nope-nope\".\n==> Searching for similarly named formulae and casks...\n";
41
+ export declare const BREW_DEPS_CURL = "brotli\nlibnghttp2\nlibssh2\nopenldap\nrtmpdump\nzstd\n";
42
+ export declare const BREW_DEPS_TREE = "";
43
+ /** `brew list --formula --versions` — four of the six curl deps are present. */
44
+ export declare const BREW_LIST_VERSIONS = "brotli 1.1.0\nca-certificates 2026-08-12\nlibnghttp2 1.62.1\nopenldap 2.6.7\npython@3.11 3.11.9 3.11.8\nzstd 1.5.6\n";
45
+ export declare const BREW_INSTALL_TREE = "==> Fetching tree\n==> Downloading https://ghcr.io/v2/homebrew/core/tree/blobs/sha256:9f3c1ad\n==> Pouring tree--2.2.1.arm64_sonoma.bottle.tar.gz\n/opt/homebrew/Cellar/tree/2.2.1: 8 files, 162.5KB\n";
46
+ /** `brew info --json=v2 tree`, re-run after the install above succeeded. */
47
+ export declare const BREW_INFO_TREE_AFTER = "{\"formulae\":[{\"name\":\"tree\",\"full_name\":\"tree\",\"tap\":\"homebrew/core\",\"versions\":{\"stable\":\"2.2.1\",\"head\":null,\"bottle\":true},\"installed\":[{\"version\":\"2.2.1\",\"installed_on_request\":true}],\"dependencies\":[],\"pinned\":false,\"outdated\":false}],\"casks\":[]}";
48
+ export declare const DPKG_VERSION = "Debian dpkg-query package management program version 1.21.22 (amd64).\nThis is free software; see the GNU General Public License version 2 or\nlater for copying conditions. There is NO warranty.\n";
49
+ /** Note the trailing space: `${db:Status-Abbrev}` is three characters wide. */
50
+ export declare const DPKG_QUERY_CURL = "curl\t7.88.1-10+deb12u5\tii \n";
51
+ /**
52
+ * A package dpkg still has a RECORD of but does not have installed: removed,
53
+ * configuration files left behind. Reading `rc` as installed is the classic
54
+ * dpkg mistake — the binary is gone and only `/etc` remains.
55
+ */
56
+ export declare const DPKG_QUERY_NANO_REMOVED = "nano\t7.2-1\trc \n";
57
+ /** dpkg has no record at all of a package that was never installed. */
58
+ export declare const DPKG_NO_SUCH_PACKAGE = "dpkg-query: no packages found matching htop\n";
59
+ export declare const APT_POLICY_CURL = "curl:\n Installed: 7.88.1-10+deb12u5\n Candidate: 7.88.1-10+deb12u7\n Version table:\n *** 7.88.1-10+deb12u5 100\n 100 /var/lib/dpkg/status\n 7.88.1-10+deb12u7 500\n 500 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages\n";
60
+ export declare const APT_POLICY_HTOP = "htop:\n Installed: (none)\n Candidate: 3.2.2-2\n Version table:\n 3.2.2-2 500\n 500 http://deb.debian.org/debian bookworm/main amd64 Packages\n";
61
+ /** A package apt has in its lists with no installable candidate at all. */
62
+ export declare const APT_POLICY_NO_CANDIDATE = "phantom:\n Installed: (none)\n Candidate: (none)\n Version table:\n";
63
+ /** apt-cache prints NOTHING for a name it does not know, and exits 0. */
64
+ export declare const APT_POLICY_UNKNOWN_STDERR = "N: Unable to locate package nosuchpkg\n";
65
+ /**
66
+ * The same `apt-cache policy curl` on a de_DE host.
67
+ *
68
+ * This is the fixture that gives the localisation trap a name. Every parser
69
+ * in this package reads English labels, which is sound ONLY because
70
+ * `../run.ts` pins `LC_ALL=C` on every child. Without that pin the parse does
71
+ * not fail loudly — it finds nothing, and "nothing" reads as "not installed".
72
+ */
73
+ export declare const APT_POLICY_CURL_GERMAN = "curl:\n Installiert: 7.88.1-10+deb12u5\n Installationskandidat: 7.88.1-10+deb12u7\n Versionstabelle:\n *** 7.88.1-10+deb12u5 100\n 100 /var/lib/dpkg/status\n";
74
+ export declare const APT_SIMULATE_HTOP = "NOTE: This is only a simulation!\n apt-get needs root privileges for real execution.\n Keep also in mind that locking is deactivated,\n so don't depend on the relevance to the real current situation!\nReading package lists...\nBuilding dependency tree...\nReading state information...\nThe following additional packages will be installed:\n libnl-3-200 libnl-genl-3-200\nThe following NEW packages will be installed:\n htop libnl-3-200 libnl-genl-3-200\n0 upgraded, 3 newly installed, 0 to remove and 0 not upgraded.\nInst libnl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])\nInst libnl-genl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])\nInst htop (3.2.2-2 Debian:12.5/stable [amd64])\nConf libnl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])\nConf libnl-genl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])\nConf htop (3.2.2-2 Debian:12.5/stable [amd64])\n";
75
+ /**
76
+ * An UPGRADE that also removes something — the case an operator most needs to
77
+ * see before approving an install. Note the `[old-version]` bracket between
78
+ * the name and the parenthesis, which only an upgrade has.
79
+ */
80
+ export declare const APT_SIMULATE_CURL_UPGRADE = "Reading package lists...\nBuilding dependency tree...\nThe following packages will be REMOVED:\n curl-legacy\nThe following packages will be upgraded:\n curl\n1 upgraded, 0 newly installed, 1 to remove and 0 not upgraded.\nRemv curl-legacy [7.74.0-1]\nInst curl [7.88.1-10+deb12u5] (7.88.1-10+deb12u7 Debian-Security:12/stable-security [amd64])\nConf curl (7.88.1-10+deb12u7 Debian-Security:12/stable-security [amd64])\n";
81
+ export declare const APT_INSTALL_HTOP = "Reading package lists...\nBuilding dependency tree...\nThe following NEW packages will be installed:\n htop libnl-3-200 libnl-genl-3-200\nSetting up htop (3.2.2-2) ...\nProcessing triggers for man-db (2.11.2-2) ...\n";
82
+ export declare const DPKG_QUERY_HTOP_AFTER = "htop\t3.2.2-2\tii \n";
83
+ export declare const APT_NEEDS_ROOT = "E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)\nE: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?\n";
84
+ export declare const DNF_VERSION = "4.14.0\n Installed: dnf-0:4.14.0-9.el9.noarch at 2026-02-11 08:14\n Built : Rocky Enterprise Software Foundation at 2026-01-30 11:02\n";
85
+ export declare const RPM_QUERY_CURL = "curl\t7.76.1-29.el9_4\tx86_64\n";
86
+ /** The same name installed for two architectures — real, and common. */
87
+ export declare const RPM_QUERY_GLIBC = "glibc\t2.34-100.el9_4.2\tx86_64\nglibc\t2.34-100.el9_4.2\ti686\n";
88
+ /** rpm writes this to STDOUT, not stderr, and exits 1. */
89
+ export declare const RPM_NOT_INSTALLED = "package tree is not installed\n";
90
+ export declare const DNF_LIST_TREE = "Available Packages\ntree.x86_64 1.8.0-10.el9 baseos\n";
91
+ export declare const DNF_LIST_NO_MATCH = "Error: No matching Packages to list\n";
92
+ /**
93
+ * The case `--cacheonly` makes common on a fresh container, and the case
94
+ * house rule 6 is about: an empty metadata cache is NOT an absent package.
95
+ */
96
+ export declare const DNF_NO_CACHE = "Error: Cache-only enabled but no cache for 'baseos'\n";
97
+ export declare const DNF_SIMULATE_TREE = "Last metadata expiration check: 0:05:12 ago on Wed 17 Sep 2026 09:00:00 AM UTC.\nDependencies resolved.\n================================================================================\n Package Arch Version Repository Size\n================================================================================\nInstalling:\n tree x86_64 1.8.0-10.el9 baseos 55 k\nInstalling dependencies:\n libfoo x86_64 1.2-3.el9 appstream 10 k\n\nTransaction Summary\n================================================================================\nInstall 2 Packages\n\nTotal download size: 65 k\nInstalled size: 143 k\nOperation aborted.\n";
98
+ export declare const DNF_NEEDS_ROOT = "Error: This command has to be run with superuser privileges (under the root user on most systems).\n";
99
+ export declare const DNF_INSTALL_TREE = "Dependencies resolved.\nInstalling:\n tree x86_64 1.8.0-10.el9 baseos 55 k\n\nComplete!\n";
100
+ export declare const RPM_QUERY_TREE_AFTER = "tree\t1.8.0-10.el9\tx86_64\n";
101
+ export declare const PACMAN_VERSION = " .--. Pacman v6.1.0 - libalpm v14.0.0\n/ _.-' .-. .-. .-. Copyright (C) 2006-2024 Pacman Development Team\n";
102
+ export declare const PACMAN_QUERY_CURL = "curl 8.9.1-1\n";
103
+ export declare const PACMAN_NOT_FOUND = "error: package 'htop' was not found\n";
104
+ export declare const PACMAN_SYNC_INFO_HTOP = "Repository : extra\nName : htop\nVersion : 3.3.0-1\nDescription : Interactive process viewer\nArchitecture : x86_64\nLicenses : GPL\nDepends On : glibc ncurses lm_sensors\nDownload Size : 158.44 KiB\n\nRepository : community-testing\nName : htop\nVersion : 3.4.0-0.1\n";
105
+ export declare const PACMAN_SYNC_NOT_FOUND = "error: package 'nosuch' was not found\n";
106
+ /** `pacman -S --print --print-format '%r/%n %v'` — targets AND dependencies. */
107
+ export declare const PACMAN_SIMULATE_HTOP = "core/ncurses 6.5-3\nextra/lm_sensors 1:3.6.0.r41-3\nextra/htop 3.3.0-1\n";
108
+ export declare const PACMAN_NEEDS_ROOT = "error: you cannot perform this operation unless you are root.\n";
109
+ export declare const PACMAN_INSTALL_HTOP = "resolving dependencies...\nlooking for conflicting packages...\n(3) installing ncurses lm_sensors htop\n:: Running post-transaction hooks...\n";
110
+ export declare const PACMAN_QUERY_HTOP_AFTER = "htop 3.3.0-1\n";
111
+ export declare const WINGET_VERSION = "v1.8.1911\n";
112
+ /** Five columns: Name, Id, Version, Available, Source. */
113
+ export declare const WINGET_LIST_GIT = "Name Id Version Available Source\n-------------------------------------------\nGit Git.Git 2.45.1 2.46.0 winget\n";
114
+ /**
115
+ * Four columns, which is the ambiguous case: this is `…Version Source`, but a
116
+ * row with an available upgrade and no source is `…Version Available` and
117
+ * looks identical. winget's headers are localised, so there is no way to tell
118
+ * — which is why the parser reports a null `available` with a reason rather
119
+ * than picking one.
120
+ */
121
+ export declare const WINGET_LIST_FOUR_COLUMNS = "Name Id Version Source\n--------------------------------\n7-Zip 7zip.7zip 24.08 winget\n";
122
+ /**
123
+ * A row winget had to CUT, marked with U+2026. The survey's proposed
124
+ * header-offset parser would read `14.40.3` here as a complete version and
125
+ * the Id as a complete Id; both are prefixes.
126
+ */
127
+ export declare const WINGET_LIST_TRUNCATED = "Name Id Version Source\n-------------------------------------------------------------------\nMicrosoft Visual C++ 20\u2026 Microsoft.VCRedist.2015\u2026 14.40.3 winget\n";
128
+ /**
129
+ * A CJK display name. Each glyph is DOUBLE-WIDTH in the console and one
130
+ * UTF-16 code unit in the string, so every character offset after it is
131
+ * wrong — and splitting on two-or-more spaces is unaffected.
132
+ */
133
+ export declare const WINGET_LIST_CJK = "Name Id Version Source\n----------------------------------------\n\u30C6\u30AD\u30B9\u30C8 Vendor.Text 1.2.3 winget\n";
134
+ export declare const WINGET_NOT_FOUND = "No installed package found matching input criteria.\n";
135
+ export declare const CHOCO_VERSION_V2 = "2.2.2\n";
136
+ export declare const CHOCO_VERSION_V1 = "0.10.15\n";
137
+ export declare const CHOCO_LIST_GIT = "git|2.45.1\ngit.install|2.45.1\n";
138
+ export declare const CHOCO_LIST_EMPTY = "";
139
+ /** A recorded answer to one command. */
140
+ export type RecordedCase = {
141
+ readonly stdout?: string;
142
+ readonly stderr?: string;
143
+ readonly code?: number;
144
+ readonly timedOut?: boolean;
145
+ /** The program is not installed on this host (ENOENT on spawn). */
146
+ readonly missing?: boolean;
147
+ readonly stdoutTruncated?: boolean;
148
+ readonly stderrTruncated?: boolean;
149
+ };
150
+ export type RecordedRunner = {
151
+ readonly runner: Runner;
152
+ /** Every argv this runner was asked for, in order. */
153
+ readonly calls: string[][];
154
+ /** Every request, for the tests that check the pinned environment. */
155
+ readonly requests: RunRequest[];
156
+ };
157
+ /** The key a command is recorded under. NUL cannot appear in an argv element. */
158
+ export declare function argvKey(argv: readonly string[]): string;
159
+ /**
160
+ * Serve recorded output, and refuse anything nobody recorded.
161
+ *
162
+ * The refusal is the important half. A runner that answered an unrecorded
163
+ * command with an empty success would be indistinguishable from a manager
164
+ * that had nothing to say, and every "not installed" in the suite would stop
165
+ * meaning anything. Instead the promise rejects with the argv, so a missing
166
+ * fixture fails the test that needed it and names the command.
167
+ *
168
+ * `fallback` exists for the tests that are ABOUT an unrecognised host: pass
169
+ * `{ missing: true }` to make every unrecorded command behave like a program
170
+ * that is not installed.
171
+ */
172
+ export declare function recordedRunner(cases: Readonly<Record<string, RecordedCase | readonly RecordedCase[]>>, fallback?: RecordedCase): RecordedRunner;
173
+ /** Shorthand: record a case under an argv rather than under a key. */
174
+ export declare function record(argv: readonly string[], outcome: RecordedCase | readonly RecordedCase[]): Record<string, RecordedCase | readonly RecordedCase[]>;
@@ -0,0 +1,334 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Homebrew — macOS 15.6, Homebrew 4.3.8
3
+ // ---------------------------------------------------------------------------
4
+ export const BREW_VERSION = `Homebrew 4.3.8
5
+ Homebrew/homebrew-core (git revision 2e6b2c3f4a1; last commit 2026-09-10)
6
+ `;
7
+ /** `brew info --json=v2 curl` — installed, and the tap has a newer version. */
8
+ export const BREW_INFO_CURL = `{"formulae":[{"name":"curl","full_name":"curl","tap":"homebrew/core","oldnames":[],"aliases":[],"versions":{"stable":"8.9.1","head":"HEAD","bottle":true},"installed":[{"version":"8.8.0","used_options":[],"built_as_bottle":true,"poured_from_bottle":true,"installed_as_dependency":false,"installed_on_request":true}],"dependencies":["brotli","libnghttp2","libssh2","openldap","rtmpdump","zstd"],"pinned":false,"outdated":true,"deprecated":false,"disabled":false}],"casks":[]}`;
9
+ /** `brew info --json=v2 tree` — known to the tap, not installed. */
10
+ export const BREW_INFO_TREE = `{"formulae":[{"name":"tree","full_name":"tree","tap":"homebrew/core","versions":{"stable":"2.2.1","head":null,"bottle":true},"installed":[],"dependencies":[],"pinned":false,"outdated":false,"deprecated":false,"disabled":false}],"casks":[]}`;
11
+ /**
12
+ * `brew info --json=v2 python@3.11` — two kegs of one formula.
13
+ *
14
+ * The reason `installedVersions` is an array: reporting the first as "the"
15
+ * installed version is wrong for exactly the formulae people ask about.
16
+ */
17
+ export const BREW_INFO_PYTHON = `{"formulae":[{"name":"python@3.11","full_name":"python@3.11","tap":"homebrew/core","versions":{"stable":"3.11.9","head":null,"bottle":true},"installed":[{"version":"3.11.9","installed_on_request":true},{"version":"3.11.8","installed_on_request":false}],"dependencies":["mpdecimal","openssl@3","sqlite","xz"],"pinned":true,"outdated":false}],"casks":[]}`;
18
+ /** `brew info --json=v2 firefox` — a cask, which spells everything differently. */
19
+ export const BREW_INFO_FIREFOX = `{"formulae":[],"casks":[{"token":"firefox","full_token":"homebrew/cask/firefox","tap":"homebrew/cask","name":["Mozilla Firefox"],"version":"142.0.1","installed":"141.0","outdated":true}]}`;
20
+ /** `brew info --json=v2 nope-nope` — exit 1, and this exact sentence. */
21
+ export const BREW_NO_SUCH_FORMULA = `Error: No available formula with the name "nope-nope".
22
+ ==> Searching for similarly named formulae and casks...
23
+ `;
24
+ export const BREW_DEPS_CURL = `brotli
25
+ libnghttp2
26
+ libssh2
27
+ openldap
28
+ rtmpdump
29
+ zstd
30
+ `;
31
+ export const BREW_DEPS_TREE = "";
32
+ /** `brew list --formula --versions` — four of the six curl deps are present. */
33
+ export const BREW_LIST_VERSIONS = `brotli 1.1.0
34
+ ca-certificates 2026-08-12
35
+ libnghttp2 1.62.1
36
+ openldap 2.6.7
37
+ python@3.11 3.11.9 3.11.8
38
+ zstd 1.5.6
39
+ `;
40
+ export const BREW_INSTALL_TREE = `==> Fetching tree
41
+ ==> Downloading https://ghcr.io/v2/homebrew/core/tree/blobs/sha256:9f3c1ad
42
+ ==> Pouring tree--2.2.1.arm64_sonoma.bottle.tar.gz
43
+ /opt/homebrew/Cellar/tree/2.2.1: 8 files, 162.5KB
44
+ `;
45
+ /** `brew info --json=v2 tree`, re-run after the install above succeeded. */
46
+ export const BREW_INFO_TREE_AFTER = `{"formulae":[{"name":"tree","full_name":"tree","tap":"homebrew/core","versions":{"stable":"2.2.1","head":null,"bottle":true},"installed":[{"version":"2.2.1","installed_on_request":true}],"dependencies":[],"pinned":false,"outdated":false}],"casks":[]}`;
47
+ // ---------------------------------------------------------------------------
48
+ // apt / dpkg — Debian 12 (bookworm), dpkg 1.21.22, apt 2.6.1
49
+ // ---------------------------------------------------------------------------
50
+ export const DPKG_VERSION = `Debian dpkg-query package management program version 1.21.22 (amd64).
51
+ This is free software; see the GNU General Public License version 2 or
52
+ later for copying conditions. There is NO warranty.
53
+ `;
54
+ /** Note the trailing space: `${db:Status-Abbrev}` is three characters wide. */
55
+ export const DPKG_QUERY_CURL = "curl\t7.88.1-10+deb12u5\tii \n";
56
+ /**
57
+ * A package dpkg still has a RECORD of but does not have installed: removed,
58
+ * configuration files left behind. Reading `rc` as installed is the classic
59
+ * dpkg mistake — the binary is gone and only `/etc` remains.
60
+ */
61
+ export const DPKG_QUERY_NANO_REMOVED = "nano\t7.2-1\trc \n";
62
+ /** dpkg has no record at all of a package that was never installed. */
63
+ export const DPKG_NO_SUCH_PACKAGE = "dpkg-query: no packages found matching htop\n";
64
+ export const APT_POLICY_CURL = `curl:
65
+ Installed: 7.88.1-10+deb12u5
66
+ Candidate: 7.88.1-10+deb12u7
67
+ Version table:
68
+ *** 7.88.1-10+deb12u5 100
69
+ 100 /var/lib/dpkg/status
70
+ 7.88.1-10+deb12u7 500
71
+ 500 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages
72
+ `;
73
+ export const APT_POLICY_HTOP = `htop:
74
+ Installed: (none)
75
+ Candidate: 3.2.2-2
76
+ Version table:
77
+ 3.2.2-2 500
78
+ 500 http://deb.debian.org/debian bookworm/main amd64 Packages
79
+ `;
80
+ /** A package apt has in its lists with no installable candidate at all. */
81
+ export const APT_POLICY_NO_CANDIDATE = `phantom:
82
+ Installed: (none)
83
+ Candidate: (none)
84
+ Version table:
85
+ `;
86
+ /** apt-cache prints NOTHING for a name it does not know, and exits 0. */
87
+ export const APT_POLICY_UNKNOWN_STDERR = "N: Unable to locate package nosuchpkg\n";
88
+ /**
89
+ * The same `apt-cache policy curl` on a de_DE host.
90
+ *
91
+ * This is the fixture that gives the localisation trap a name. Every parser
92
+ * in this package reads English labels, which is sound ONLY because
93
+ * `../run.ts` pins `LC_ALL=C` on every child. Without that pin the parse does
94
+ * not fail loudly — it finds nothing, and "nothing" reads as "not installed".
95
+ */
96
+ export const APT_POLICY_CURL_GERMAN = `curl:
97
+ Installiert: 7.88.1-10+deb12u5
98
+ Installationskandidat: 7.88.1-10+deb12u7
99
+ Versionstabelle:
100
+ *** 7.88.1-10+deb12u5 100
101
+ 100 /var/lib/dpkg/status
102
+ `;
103
+ export const APT_SIMULATE_HTOP = `NOTE: This is only a simulation!
104
+ apt-get needs root privileges for real execution.
105
+ Keep also in mind that locking is deactivated,
106
+ so don't depend on the relevance to the real current situation!
107
+ Reading package lists...
108
+ Building dependency tree...
109
+ Reading state information...
110
+ The following additional packages will be installed:
111
+ libnl-3-200 libnl-genl-3-200
112
+ The following NEW packages will be installed:
113
+ htop libnl-3-200 libnl-genl-3-200
114
+ 0 upgraded, 3 newly installed, 0 to remove and 0 not upgraded.
115
+ Inst libnl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])
116
+ Inst libnl-genl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])
117
+ Inst htop (3.2.2-2 Debian:12.5/stable [amd64])
118
+ Conf libnl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])
119
+ Conf libnl-genl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])
120
+ Conf htop (3.2.2-2 Debian:12.5/stable [amd64])
121
+ `;
122
+ /**
123
+ * An UPGRADE that also removes something — the case an operator most needs to
124
+ * see before approving an install. Note the `[old-version]` bracket between
125
+ * the name and the parenthesis, which only an upgrade has.
126
+ */
127
+ export const APT_SIMULATE_CURL_UPGRADE = `Reading package lists...
128
+ Building dependency tree...
129
+ The following packages will be REMOVED:
130
+ curl-legacy
131
+ The following packages will be upgraded:
132
+ curl
133
+ 1 upgraded, 0 newly installed, 1 to remove and 0 not upgraded.
134
+ Remv curl-legacy [7.74.0-1]
135
+ Inst curl [7.88.1-10+deb12u5] (7.88.1-10+deb12u7 Debian-Security:12/stable-security [amd64])
136
+ Conf curl (7.88.1-10+deb12u7 Debian-Security:12/stable-security [amd64])
137
+ `;
138
+ export const APT_INSTALL_HTOP = `Reading package lists...
139
+ Building dependency tree...
140
+ The following NEW packages will be installed:
141
+ htop libnl-3-200 libnl-genl-3-200
142
+ Setting up htop (3.2.2-2) ...
143
+ Processing triggers for man-db (2.11.2-2) ...
144
+ `;
145
+ export const DPKG_QUERY_HTOP_AFTER = "htop\t3.2.2-2\tii \n";
146
+ export const APT_NEEDS_ROOT = `E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)
147
+ E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?
148
+ `;
149
+ // ---------------------------------------------------------------------------
150
+ // dnf / rpm — Rocky Linux 9.4, dnf 4.14.0, rpm 4.16.1.3
151
+ // ---------------------------------------------------------------------------
152
+ export const DNF_VERSION = `4.14.0
153
+ Installed: dnf-0:4.14.0-9.el9.noarch at 2026-02-11 08:14
154
+ Built : Rocky Enterprise Software Foundation at 2026-01-30 11:02
155
+ `;
156
+ export const RPM_QUERY_CURL = "curl\t7.76.1-29.el9_4\tx86_64\n";
157
+ /** The same name installed for two architectures — real, and common. */
158
+ export const RPM_QUERY_GLIBC = "glibc\t2.34-100.el9_4.2\tx86_64\nglibc\t2.34-100.el9_4.2\ti686\n";
159
+ /** rpm writes this to STDOUT, not stderr, and exits 1. */
160
+ export const RPM_NOT_INSTALLED = "package tree is not installed\n";
161
+ export const DNF_LIST_TREE = `Available Packages
162
+ tree.x86_64 1.8.0-10.el9 baseos
163
+ `;
164
+ export const DNF_LIST_NO_MATCH = "Error: No matching Packages to list\n";
165
+ /**
166
+ * The case `--cacheonly` makes common on a fresh container, and the case
167
+ * house rule 6 is about: an empty metadata cache is NOT an absent package.
168
+ */
169
+ export const DNF_NO_CACHE = `Error: Cache-only enabled but no cache for 'baseos'
170
+ `;
171
+ export const DNF_SIMULATE_TREE = `Last metadata expiration check: 0:05:12 ago on Wed 17 Sep 2026 09:00:00 AM UTC.
172
+ Dependencies resolved.
173
+ ================================================================================
174
+ Package Arch Version Repository Size
175
+ ================================================================================
176
+ Installing:
177
+ tree x86_64 1.8.0-10.el9 baseos 55 k
178
+ Installing dependencies:
179
+ libfoo x86_64 1.2-3.el9 appstream 10 k
180
+
181
+ Transaction Summary
182
+ ================================================================================
183
+ Install 2 Packages
184
+
185
+ Total download size: 65 k
186
+ Installed size: 143 k
187
+ Operation aborted.
188
+ `;
189
+ export const DNF_NEEDS_ROOT = `Error: This command has to be run with superuser privileges (under the root user on most systems).
190
+ `;
191
+ export const DNF_INSTALL_TREE = `Dependencies resolved.
192
+ Installing:
193
+ tree x86_64 1.8.0-10.el9 baseos 55 k
194
+
195
+ Complete!
196
+ `;
197
+ export const RPM_QUERY_TREE_AFTER = "tree\t1.8.0-10.el9\tx86_64\n";
198
+ // ---------------------------------------------------------------------------
199
+ // pacman — Arch, pacman 6.1.0
200
+ // ---------------------------------------------------------------------------
201
+ export const PACMAN_VERSION = ` .--. Pacman v6.1.0 - libalpm v14.0.0
202
+ / _.-' .-. .-. .-. Copyright (C) 2006-2024 Pacman Development Team
203
+ `;
204
+ export const PACMAN_QUERY_CURL = "curl 8.9.1-1\n";
205
+ export const PACMAN_NOT_FOUND = "error: package 'htop' was not found\n";
206
+ export const PACMAN_SYNC_INFO_HTOP = `Repository : extra
207
+ Name : htop
208
+ Version : 3.3.0-1
209
+ Description : Interactive process viewer
210
+ Architecture : x86_64
211
+ Licenses : GPL
212
+ Depends On : glibc ncurses lm_sensors
213
+ Download Size : 158.44 KiB
214
+
215
+ Repository : community-testing
216
+ Name : htop
217
+ Version : 3.4.0-0.1
218
+ `;
219
+ export const PACMAN_SYNC_NOT_FOUND = "error: package 'nosuch' was not found\n";
220
+ /** `pacman -S --print --print-format '%r/%n %v'` — targets AND dependencies. */
221
+ export const PACMAN_SIMULATE_HTOP = `core/ncurses 6.5-3
222
+ extra/lm_sensors 1:3.6.0.r41-3
223
+ extra/htop 3.3.0-1
224
+ `;
225
+ export const PACMAN_NEEDS_ROOT = "error: you cannot perform this operation unless you are root.\n";
226
+ export const PACMAN_INSTALL_HTOP = `resolving dependencies...
227
+ looking for conflicting packages...
228
+ (3) installing ncurses lm_sensors htop
229
+ :: Running post-transaction hooks...
230
+ `;
231
+ export const PACMAN_QUERY_HTOP_AFTER = "htop 3.3.0-1\n";
232
+ // ---------------------------------------------------------------------------
233
+ // winget — Windows 11, winget 1.8.1911
234
+ // ---------------------------------------------------------------------------
235
+ export const WINGET_VERSION = "v1.8.1911\n";
236
+ /** Five columns: Name, Id, Version, Available, Source. */
237
+ export const WINGET_LIST_GIT = `Name Id Version Available Source
238
+ -------------------------------------------
239
+ Git Git.Git 2.45.1 2.46.0 winget
240
+ `;
241
+ /**
242
+ * Four columns, which is the ambiguous case: this is `…Version Source`, but a
243
+ * row with an available upgrade and no source is `…Version Available` and
244
+ * looks identical. winget's headers are localised, so there is no way to tell
245
+ * — which is why the parser reports a null `available` with a reason rather
246
+ * than picking one.
247
+ */
248
+ export const WINGET_LIST_FOUR_COLUMNS = `Name Id Version Source
249
+ --------------------------------
250
+ 7-Zip 7zip.7zip 24.08 winget
251
+ `;
252
+ /**
253
+ * A row winget had to CUT, marked with U+2026. The survey's proposed
254
+ * header-offset parser would read `14.40.3` here as a complete version and
255
+ * the Id as a complete Id; both are prefixes.
256
+ */
257
+ export const WINGET_LIST_TRUNCATED = `Name Id Version Source
258
+ -------------------------------------------------------------------
259
+ Microsoft Visual C++ 20… Microsoft.VCRedist.2015… 14.40.3 winget
260
+ `;
261
+ /**
262
+ * A CJK display name. Each glyph is DOUBLE-WIDTH in the console and one
263
+ * UTF-16 code unit in the string, so every character offset after it is
264
+ * wrong — and splitting on two-or-more spaces is unaffected.
265
+ */
266
+ export const WINGET_LIST_CJK = `Name Id Version Source
267
+ ----------------------------------------
268
+ テキスト Vendor.Text 1.2.3 winget
269
+ `;
270
+ export const WINGET_NOT_FOUND = "No installed package found matching input criteria.\n";
271
+ // ---------------------------------------------------------------------------
272
+ // chocolatey — Windows 11, choco 2.2.2 and 0.10.15
273
+ // ---------------------------------------------------------------------------
274
+ export const CHOCO_VERSION_V2 = "2.2.2\n";
275
+ export const CHOCO_VERSION_V1 = "0.10.15\n";
276
+ export const CHOCO_LIST_GIT = "git|2.45.1\ngit.install|2.45.1\n";
277
+ export const CHOCO_LIST_EMPTY = "";
278
+ /** The key a command is recorded under. NUL cannot appear in an argv element. */
279
+ export function argvKey(argv) {
280
+ return argv.join("\u0000");
281
+ }
282
+ /**
283
+ * Serve recorded output, and refuse anything nobody recorded.
284
+ *
285
+ * The refusal is the important half. A runner that answered an unrecorded
286
+ * command with an empty success would be indistinguishable from a manager
287
+ * that had nothing to say, and every "not installed" in the suite would stop
288
+ * meaning anything. Instead the promise rejects with the argv, so a missing
289
+ * fixture fails the test that needed it and names the command.
290
+ *
291
+ * `fallback` exists for the tests that are ABOUT an unrecognised host: pass
292
+ * `{ missing: true }` to make every unrecorded command behave like a program
293
+ * that is not installed.
294
+ */
295
+ export function recordedRunner(cases, fallback) {
296
+ const calls = [];
297
+ const requests = [];
298
+ // How many times each argv has been asked for. A case recorded as an ARRAY
299
+ // answers differently on successive calls, which is what an install needs:
300
+ // the same `brew info` command legitimately answers "not installed" before
301
+ // the install and "installed" after it, and a runner that could not express
302
+ // that would make the post-install verification untestable.
303
+ const seen = new Map();
304
+ const runner = async (request) => {
305
+ calls.push([...request.argv]);
306
+ requests.push(request);
307
+ const key = argvKey(request.argv);
308
+ const index = seen.get(key) ?? 0;
309
+ seen.set(key, index + 1);
310
+ const entry = cases[key];
311
+ const recorded = Array.isArray(entry)
312
+ ? // Past the end the last recorded answer repeats, so a fixture does
313
+ // not have to enumerate every retry.
314
+ (entry[Math.min(index, entry.length - 1)] ?? fallback)
315
+ : (entry ?? fallback);
316
+ if (recorded === undefined) {
317
+ throw new Error(`no recorded output for: ${request.argv.map((a) => JSON.stringify(a)).join(" ")}`);
318
+ }
319
+ return {
320
+ code: recorded.code ?? 0,
321
+ stdout: recorded.stdout ?? "",
322
+ stderr: recorded.stderr ?? "",
323
+ timedOut: recorded.timedOut ?? false,
324
+ missing: recorded.missing ?? false,
325
+ ...(recorded.stdoutTruncated === true ? { stdoutTruncated: true } : {}),
326
+ ...(recorded.stderrTruncated === true ? { stderrTruncated: true } : {}),
327
+ };
328
+ };
329
+ return { runner, calls, requests };
330
+ }
331
+ /** Shorthand: record a case under an argv rather than under a key. */
332
+ export function record(argv, outcome) {
333
+ return { [argvKey(argv)]: outcome };
334
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** The platform strings this package makes decisions about. */
2
+ export type HostPlatform = "darwin" | "linux" | "win32" | "other";
3
+ /** Normalise `process.platform` into the cases that matter here. */
4
+ export declare function classifyPlatform(raw: string): HostPlatform;
5
+ export declare function hostPlatform(): HostPlatform;
6
+ /** Test seam: pretend to be another OS. */
7
+ export declare function _setPlatform(platform: HostPlatform | undefined): void;
8
+ /**
9
+ * The effective uid, or `undefined` where the concept does not exist.
10
+ *
11
+ * `process.geteuid` is absent on Windows. An unknown uid is reported as
12
+ * unknown and never defaulted to 0 — guessing "we are root" is the one guess
13
+ * here that ends with a manager being asked to modify the system.
14
+ */
15
+ export declare function effectiveUid(): number | undefined;
16
+ /** True only when the process is PROVABLY root. Unknown is not root. */
17
+ export declare function isRoot(): boolean;
18
+ /** Test seam: pretend to be (or not to be) root. */
19
+ export declare function _setUid(uid: number | undefined): void;
20
+ /** Put both seams back to the real host. Called from `afterEach`. */
21
+ export declare function _resetHostSeams(): void;
package/dist/host.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The two facts about the host that are not a command: which operating system
3
+ * this is, and whether the process is already root.
4
+ *
5
+ * ── THE HOSTILE DEFAULT ────────────────────────────────────────────────────
6
+ *
7
+ * `hostPlatform()` does NOT simply fall back to `process.platform`. When a
8
+ * runner has been injected — which is true in every test in this package and
9
+ * false in production — the un-injected platform is `"other"`, a platform no
10
+ * backend here supports.
11
+ *
12
+ * The reason is a bug this project has already paid for. A test in tool-hostfs
13
+ * relied on the ambient `process.platform`, passed on the author's macOS, and
14
+ * took a different branch on the Linux CI box; the suite went green locally
15
+ * and red in CI, and the failure said nothing about which seam was missing.
16
+ * Reading `process.platform` in a test is not a small sin that shows up as a
17
+ * small failure — it is a test that asserts something different on every
18
+ * machine it runs on.
19
+ *
20
+ * Tying the default to the RUNNER seam rather than sniffing an environment
21
+ * variable is deliberate. A test that has installed a recorded runner has
22
+ * already declared "I am not talking to this host"; if it then forgets to
23
+ * declare which host it is pretending to be, it gets `"other"` and every
24
+ * backend refuses, identically, on macOS and on Linux and on a maintainer's
25
+ * Windows box. Production never injects a runner, so production reads the
26
+ * real platform. There is no env sniffing, no `NODE_ENV`, and nothing that
27
+ * behaves differently under `bun test` than under `crewhaus run`.
28
+ *
29
+ * ── ROOT ───────────────────────────────────────────────────────────────────
30
+ *
31
+ * `effectiveUid()` exists because `apt-get install` cannot run without root
32
+ * and this package will not acquire root (house rule 7). It never escalates:
33
+ * it reports what the process ALREADY has, so a harness running as root in a
34
+ * container can install, and a harness running as a user gets a refusal that
35
+ * names the command the operator would run themselves. There is no code path
36
+ * here that runs `sudo`, `doas`, `runas` or `pkexec`, and no schema in this
37
+ * package accepts a password — `no argv ever names an escalation program` in
38
+ * index.test.ts asserts both over every recorded call.
39
+ */
40
+ import { _runnerInstalled } from "./run";
41
+ let platformOverride;
42
+ /** Normalise `process.platform` into the cases that matter here. */
43
+ export function classifyPlatform(raw) {
44
+ if (raw === "darwin" || raw === "linux" || raw === "win32")
45
+ return raw;
46
+ return "other";
47
+ }
48
+ export function hostPlatform() {
49
+ if (platformOverride !== undefined)
50
+ return platformOverride;
51
+ // See the header: a test that drives a recorded runner and forgets to say
52
+ // which OS it is on gets a platform with no backend, everywhere.
53
+ if (_runnerInstalled())
54
+ return "other";
55
+ return classifyPlatform(process.platform);
56
+ }
57
+ /** Test seam: pretend to be another OS. */
58
+ export function _setPlatform(platform) {
59
+ platformOverride = platform;
60
+ }
61
+ let uidOverride;
62
+ /**
63
+ * The effective uid, or `undefined` where the concept does not exist.
64
+ *
65
+ * `process.geteuid` is absent on Windows. An unknown uid is reported as
66
+ * unknown and never defaulted to 0 — guessing "we are root" is the one guess
67
+ * here that ends with a manager being asked to modify the system.
68
+ */
69
+ export function effectiveUid() {
70
+ if (uidOverride !== undefined)
71
+ return uidOverride;
72
+ const get = process.geteuid;
73
+ if (typeof get !== "function")
74
+ return undefined;
75
+ const uid = get.call(process);
76
+ return Number.isInteger(uid) && uid >= 0 ? uid : undefined;
77
+ }
78
+ /** True only when the process is PROVABLY root. Unknown is not root. */
79
+ export function isRoot() {
80
+ return effectiveUid() === 0;
81
+ }
82
+ /** Test seam: pretend to be (or not to be) root. */
83
+ export function _setUid(uid) {
84
+ uidOverride = uid;
85
+ }
86
+ /** Put both seams back to the real host. Called from `afterEach`. */
87
+ export function _resetHostSeams() {
88
+ platformOverride = undefined;
89
+ uidOverride = undefined;
90
+ }
@@ -0,0 +1,19 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ import { type ManagerId } from "./lib/managers";
3
+ import { type InstallPlan } from "./lib/plan";
4
+ import { type PackageFacts } from "./lib/query";
5
+ export { _setRunner, type Runner, type RunRequest, type RunResult } from "./run";
6
+ export { _setPlatform, _setUid, _resetHostSeams, type HostPlatform } from "./host";
7
+ export { ToolPermissionError } from "./paths";
8
+ export type { PackageFacts, InstallPlan, ManagerId };
9
+ export declare const packageQuery: RegisteredTool;
10
+ export declare const packageInstall: RegisteredTool;
11
+ /**
12
+ * The install argv for a manager.
13
+ *
14
+ * A separate function so the test that asserts no argv names an escalation
15
+ * program can enumerate every one of them, including the ones only reached
16
+ * when the process is already root.
17
+ */
18
+ export declare function installCommand(manager: ManagerId, operand: string): string[];
19
+ export declare const PKGMGR_TOOLS: readonly RegisteredTool[];