@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,308 @@
1
+ /**
2
+ * Every parser in this package, and nothing else.
3
+ *
4
+ * Each one takes a string that a real manager printed — recorded in
5
+ * `../fixtures.ts` — and returns a shape, or `undefined` when the bytes are
6
+ * not what it was told to expect. None of them reads the host, none of them
7
+ * spawns anything, and none of them guesses: a parser that cannot find what
8
+ * it is looking for says so, and the caller turns that into an `unknown` with
9
+ * a reason rather than into a `false`.
10
+ *
11
+ * Two traps recur across the six managers and are worth stating once.
12
+ *
13
+ * LOCALISATION. `apt-cache policy`, `dnf`, `pacman -Si` and `brew` all
14
+ * translate their labels through gettext. Every parser below reads ENGLISH
15
+ * keywords, which is sound only because `../run.ts` pins `LC_ALL=C` on every
16
+ * child. If that pin is ever removed these parsers do not fail loudly — they
17
+ * find nothing, and "nothing" reads as "not installed". The suite includes a
18
+ * German `apt-cache policy` capture precisely so that failure has a name.
19
+ *
20
+ * COLUMN OUTPUT. `winget list` and `dnf list` print aligned columns, and a
21
+ * column layout is not a data format. winget truncates a wide field with an
22
+ * ellipsis (U+2026) and pads with spaces indistinguishable from the
23
+ * separator, so the parser below refuses a row it cannot split unambiguously
24
+ * rather than returning a version that is a prefix of the real one.
25
+ */
26
+ /** Split into lines, tolerating CRLF from a Windows manager. */
27
+ export declare function lines(text: string): string[];
28
+ export type BrewEntry = {
29
+ readonly name: string;
30
+ readonly kind: "formula" | "cask";
31
+ /** Versions on disk, as brew prints them. Empty when absent. */
32
+ readonly installedVersions: readonly string[];
33
+ /** What the tap currently offers, or null when the document omits it. */
34
+ readonly stableVersion: string | null;
35
+ readonly pinned: boolean;
36
+ readonly outdated: boolean;
37
+ readonly dependencies: readonly string[];
38
+ readonly tap: string | null;
39
+ };
40
+ export type BrewInfo = {
41
+ readonly formula?: BrewEntry;
42
+ readonly cask?: BrewEntry;
43
+ };
44
+ /**
45
+ * `brew info --json=v2` — the one call that answers both halves of a query.
46
+ *
47
+ * The document always has both arrays, and a name resolves into exactly one
48
+ * of them, so a caller does not have to know whether it is asking about a
49
+ * formula or a cask before it asks. A cask spells everything differently
50
+ * (`token` not `name`, a scalar `version` not a `versions` object, a scalar
51
+ * `installed` not an array), which is why the two are normalised here rather
52
+ * than at the call site.
53
+ *
54
+ * Returns `undefined` when the bytes are not a v2 document at all — which is
55
+ * what a failed `brew` prints, and must not be read as "no such package".
56
+ */
57
+ export declare function parseBrewInfoJson(stdout: string): BrewInfo | undefined;
58
+ /**
59
+ * `brew list --formula --versions` — `name version [version…]`, one per line.
60
+ *
61
+ * A keg-only formula with several versions kegged prints all of them, which
62
+ * is why the value is an array: reporting the first as "the" installed
63
+ * version would be wrong for exactly the formulae (python@3.x, openssl@3) a
64
+ * caller is most likely to ask about.
65
+ */
66
+ export declare function parseBrewListVersions(stdout: string): Map<string, string[]>;
67
+ /** `brew deps --formula <name>` — one dependency per line, already transitive. */
68
+ export declare function parseBrewDeps(stdout: string): string[];
69
+ /**
70
+ * Homebrew's own "no such package" signal.
71
+ *
72
+ * Recorded verbatim: `brew info --json=v2 nope-nope` exits 1 and prints
73
+ * `Error: No available formula with the name "nope-nope".` on stderr.
74
+ * Matching that specific sentence rather than "exit code 1" is what keeps a
75
+ * tap that failed to load from being reported as a missing package.
76
+ */
77
+ export declare function brewSaysNoSuchPackage(stderr: string): boolean;
78
+ export type DpkgRow = {
79
+ readonly name: string;
80
+ readonly version: string;
81
+ /** dpkg's status abbreviation, e.g. "ii", "un", "rc". */
82
+ readonly statusAbbrev: string;
83
+ };
84
+ /**
85
+ * `dpkg-query -W -f='${binary:Package}\t${Version}\t${db:Status-Abbrev}\n'`.
86
+ *
87
+ * Tab-separated because a Debian version can contain almost anything except a
88
+ * tab, and named fields because the abbrev's first character is the fact that
89
+ * matters: `i` means the files are on disk, while `u` (unknown) and `r`
90
+ * (removed, config files left) are packages dpkg KNOWS ABOUT but does not
91
+ * have installed. An `rc` package read as installed is the classic dpkg
92
+ * mistake — the binary is gone and only `/etc` remains.
93
+ */
94
+ export declare function parseDpkgQuery(stdout: string): DpkgRow[];
95
+ /** True when dpkg's status abbreviation means the package is actually on disk. */
96
+ export declare function dpkgStatusIsInstalled(abbrev: string): boolean;
97
+ /** dpkg's own "I have never heard of this package" signal, on stderr. */
98
+ export declare function dpkgSaysNoSuchPackage(stderr: string): boolean;
99
+ export type AptPolicy = {
100
+ readonly installed: string | null;
101
+ readonly candidate: string | null;
102
+ };
103
+ /**
104
+ * `apt-cache policy <name>` under `LC_ALL=C`.
105
+ *
106
+ * curl:
107
+ * Installed: 7.81.0-1ubuntu1.15
108
+ * Candidate: 7.81.0-1ubuntu1.16
109
+ * Version table: …
110
+ *
111
+ * `(none)` is apt's word for "not installed" and becomes `null` here — which
112
+ * is a DIFFERENT null from "the label was not found at all", and the caller
113
+ * tells them apart by whether the parse returned undefined.
114
+ *
115
+ * An unknown package produces an empty document (apt-cache prints nothing and
116
+ * exits 0), which parses to `undefined` rather than to two nulls, so "apt has
117
+ * never heard of this" cannot be confused with "apt has it, not installed".
118
+ */
119
+ export declare function parseAptCachePolicy(stdout: string): AptPolicy | undefined;
120
+ export type AptSimulatedChange = {
121
+ readonly action: "install" | "remove";
122
+ readonly name: string;
123
+ readonly version: string | null;
124
+ };
125
+ /**
126
+ * `apt-get install -s` — the best dry run of the six managers.
127
+ *
128
+ * It resolves the whole transaction without root and prints a line per
129
+ * package:
130
+ *
131
+ * Inst libnghttp2-14 (1.43.0-1 Debian:12 [amd64])
132
+ * Inst curl [7.88.1-10] (7.88.1-10+deb12u5 Debian-Security:12 [amd64])
133
+ * Conf curl (7.88.1-10+deb12u5 Debian-Security:12 [amd64])
134
+ * Remv oldthing [1.0-1]
135
+ *
136
+ * `Conf` lines are dropped: they name the same packages as `Inst`, and
137
+ * counting both would double every transitive addition. `Remv` is kept,
138
+ * because a transaction that REMOVES something is exactly the case an
139
+ * operator has to see before approving an install. The optional `[old]`
140
+ * bracket between the name and the parenthesis is what an UPGRADE looks like,
141
+ * and is skipped so the version reported is the one being installed.
142
+ */
143
+ export declare function parseAptSimulate(stdout: string): AptSimulatedChange[];
144
+ /** apt's "there is no such package" signal, which it prints on stderr. */
145
+ export declare function aptSaysNoSuchPackage(stderr: string): boolean;
146
+ /** apt's "you are not root" signal. */
147
+ export declare function aptSaysNeedsRoot(text: string): boolean;
148
+ export type RpmRow = {
149
+ readonly name: string;
150
+ /** epoch:version-release, as rpm's `%{EVR}` renders it. */
151
+ readonly evr: string;
152
+ readonly arch: string;
153
+ };
154
+ /**
155
+ * `rpm -q --queryformat '%{NAME}\t%{EVR}\t%{ARCH}\n' <name>`.
156
+ *
157
+ * rpm prints "package foo is not installed" on STDOUT (not stderr) and exits
158
+ * 1, so that sentence has to be filtered out of the rows rather than detected
159
+ * by exit code alone — a query for several names prints some rows and some of
160
+ * those sentences in the same stream.
161
+ */
162
+ export declare function parseRpmQuery(stdout: string): RpmRow[];
163
+ /** rpm's own not-installed sentence, which it prints on stdout. */
164
+ export declare function rpmSaysNotInstalled(stdout: string): boolean;
165
+ export type DnfListRow = {
166
+ readonly name: string;
167
+ readonly arch: string;
168
+ readonly version: string;
169
+ readonly repo: string;
170
+ };
171
+ /**
172
+ * `dnf list --available <name>` — three columns, `name.arch version repo`.
173
+ *
174
+ * Header lines ("Available Packages", "Last metadata expiration check: …")
175
+ * are skipped by SHAPE rather than by text: a line qualifies only when it
176
+ * splits into exactly three whitespace-separated fields, the first contains a
177
+ * `.` separating a name from an architecture, and the second starts with a
178
+ * digit. Matching on the header text would break the moment dnf renames it,
179
+ * and dnf5 did exactly that.
180
+ */
181
+ export declare function parseDnfList(stdout: string): DnfListRow[];
182
+ /**
183
+ * dnf's "the cache is empty and I was told not to fetch" complaint.
184
+ *
185
+ * This is the difference between "not in the repositories" and "I do not
186
+ * know", and `--cacheonly` makes the second common on a fresh container.
187
+ */
188
+ export declare function dnfSaysNoCache(text: string): boolean;
189
+ /** dnf's own "no such package" signal. */
190
+ export declare function dnfSaysNoMatch(text: string): boolean;
191
+ /** dnf's "you are not root" signal — checked before a transaction is read. */
192
+ export declare function dnfSaysNeedsRoot(text: string): boolean;
193
+ export type DnfTransactionRow = {
194
+ readonly name: string;
195
+ readonly arch: string;
196
+ readonly version: string;
197
+ readonly repo: string;
198
+ };
199
+ /**
200
+ * The transaction table `dnf install --assumeno` prints before it declines.
201
+ *
202
+ * Installing:
203
+ * tree x86_64 1.8.0-10.el9 baseos 55 k
204
+ * Installing dependencies:
205
+ * libfoo x86_64 1.2-3 baseos 10 k
206
+ *
207
+ * Section headers are matched on the English words, which is sound under the
208
+ * pinned `LC_ALL=C` and nowhere else. A row must have at least five fields;
209
+ * the size is two of them ("55 k") and is dropped, since a caller who wants a
210
+ * download total has dnf's own summary line.
211
+ */
212
+ export declare function parseDnfTransaction(stdout: string): DnfTransactionRow[];
213
+ export type PacmanRow = {
214
+ readonly name: string;
215
+ readonly version: string;
216
+ };
217
+ /** `pacman -Q <name>` — `name version`, one per line. */
218
+ export declare function parsePacmanQuery(stdout: string): PacmanRow[];
219
+ /**
220
+ * `pacman -Si <name>` — a `Key : Value` block with indented continuations.
221
+ *
222
+ * Only the FIRST block is read: a name that matches in two repositories
223
+ * prints two blocks, and merging them would report `extra`'s version against
224
+ * `core`'s repository. The caller reports the first block and says the name
225
+ * was ambiguous.
226
+ */
227
+ export declare function parsePacmanInfo(stdout: string): Map<string, string>;
228
+ export type PacmanTarget = {
229
+ readonly repo: string | null;
230
+ readonly name: string;
231
+ readonly version: string;
232
+ };
233
+ /**
234
+ * `pacman -S --print --print-format '%r/%n %v'` — every target, dependencies
235
+ * included, with versions, no root and no transaction.
236
+ *
237
+ * A package coming from a local file rather than a repository prints an empty
238
+ * repo, which becomes `null` rather than a made-up repository name.
239
+ */
240
+ export declare function parsePacmanSimulate(stdout: string): PacmanTarget[];
241
+ /** pacman's own "no such package" signal. */
242
+ export declare function pacmanSaysNoSuchPackage(stderr: string): boolean;
243
+ /** pacman's "you are not root" signal. */
244
+ export declare function pacmanSaysNeedsRoot(text: string): boolean;
245
+ export type WingetRow = {
246
+ readonly name: string;
247
+ readonly id: string;
248
+ /** null when the row could not be split safely — see `parseWingetList`. */
249
+ readonly version: string | null;
250
+ readonly available: string | null;
251
+ /** Why `version` or `available` is null, when it is. */
252
+ readonly unparsedReason?: string;
253
+ };
254
+ /**
255
+ * `winget list --exact --query <name>` — an aligned table, which is not a
256
+ * data format.
257
+ *
258
+ * winget has no JSON output for `list`. The survey sketch proposed splitting
259
+ * by the header's character offsets; that is wrong, and this is the improved
260
+ * version. Offsets break on two things winget does routinely: it TRUNCATES a
261
+ * field that does not fit and marks the cut with `…`, and it pads with
262
+ * ordinary spaces, so a CJK or emoji display name — double-width in the
263
+ * console but one or two UTF-16 code units in the string — shifts every
264
+ * offset after it. A version read from a shifted offset is not an error, it
265
+ * is a WRONG VERSION, which is the worst answer a query tool can give.
266
+ *
267
+ * So rows are split on runs of TWO OR MORE spaces (one space is inside a
268
+ * display name; two is padding), and a row is refused rather than guessed at
269
+ * when it does not split cleanly:
270
+ *
271
+ * - any field containing `…` was truncated, so nothing in the row is
272
+ * trusted;
273
+ * - fewer than three fields is not a `Name Id Version` row at all;
274
+ * - the column ORDER is fixed across locales (`Name Id Version [Available]
275
+ * Source`) even though the header TEXT is localised, so position 2 is the
276
+ * installed version — but position 3 is ambiguous: a four-field row is
277
+ * either `…Version Source` or `…Version Available`, with no way to tell.
278
+ * Four fields therefore yields a null `available` with a reason.
279
+ */
280
+ export declare function parseWingetList(stdout: string): WingetRow[];
281
+ /**
282
+ * winget's "no installed package matched" exit code, 0x8A15002B as a signed
283
+ * 32-bit integer.
284
+ *
285
+ * `winget list` exits with this rather than 0-and-empty, which makes presence
286
+ * decidable from the EXIT CODE alone — the one part of winget's answer that
287
+ * is neither localised nor column-aligned.
288
+ */
289
+ export declare const WINGET_NO_PACKAGES_FOUND = -1978335189;
290
+ /**
291
+ * `choco list --limit-output` — `name|version`, one per line, no header.
292
+ *
293
+ * `--limit-output` is chocolatey's documented machine-readable mode and the
294
+ * reason choco is the easiest of the six to read. A trailing summary line
295
+ * ("2 packages installed.") does not appear in this mode, and is dropped by
296
+ * shape anyway in case an older build prints one.
297
+ */
298
+ export declare function parseChocoList(stdout: string): PacmanRow[];
299
+ /**
300
+ * The major version of `choco --version`.
301
+ *
302
+ * Chocolatey 2.0 removed `--local-only` from `choco list`, so which flags are
303
+ * legal depends on this number. Guessing would mean either an unknown-option
304
+ * error on v2 or a REMOTE search on v1 — and a remote search answering "yes,
305
+ * 8.7.1" for a package that is not installed at all is precisely the wrong
306
+ * answer this package exists to avoid.
307
+ */
308
+ export declare function parseChocoMajorVersion(stdout: string): number | undefined;