@crewhaus/tool-desktop 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,421 @@
1
+ /**
2
+ * `OpenExternal` — handing something to whatever the operating system has
3
+ * registered for it, which is the widest-reaching thing in this package.
4
+ *
5
+ * "Open this" and "run this" are the SAME GESTURE to a desktop. `open` on a
6
+ * `.command` file runs it in Terminal; `xdg-open` on a file whose type is
7
+ * associated with an interpreter runs it; `Start-Process` on a `.ps1` or a
8
+ * `.bat` runs it. And the URL side is worse than the file side, because the
9
+ * scheme table on a real machine is enormous and mostly undocumented:
10
+ * `file://` reaches the filesystem with no containment, `smb://` mounts a
11
+ * remote share (and leaks an authentication handshake to whoever owns the
12
+ * host in the URL), and Windows `ms-*` handlers reach settings pages, the
13
+ * Store, and on unpatched builds have been argument-injection vectors in
14
+ * their own right.
15
+ *
16
+ * SO THE GATE IS AN ALLOW-LIST, AND IT IS SHORT.
17
+ *
18
+ * - Anything with a URI scheme must have one of `https`, `http`, `mailto`.
19
+ * Every other scheme is refused by name. There is no input that widens
20
+ * this set — `allowSchemes` can only NARROW it, because a knob that
21
+ * re-enables `smb:` is the hole with extra steps.
22
+ * - THE STRING THAT IS CHECKED IS THE STRING THAT IS OPENED. An http(s) URL
23
+ * is handed to the desktop in the WHATWG parser's NORMALISED form, not as
24
+ * the caller typed it, because a gate that validates one spelling and
25
+ * passes on another is not a gate. A backslash — where WHATWG and CFURL
26
+ * disagree outright — is refused rather than folded, and credentials
27
+ * before the `@` are refused too: they end up in a process listing and in
28
+ * the transcript, and `https://google.com@evil.example/` reads as the
29
+ * host it is not.
30
+ * - A `mailto:` may carry only the headers RFC 6068 calls safe. `attach=`
31
+ * is the reason: a client that honours it reads a local file into a
32
+ * message, which is `file:`'s hole with a mail client in the middle.
33
+ * - Anything without a scheme is a PATH, and goes through `../paths.ts`'s
34
+ * containment (the workspace root, resolved, symlinks followed) before it
35
+ * is allowed to exist. A local file is opened by naming its path, never
36
+ * by building a `file://` URL — that way there is exactly one code path
37
+ * that decides whether a path is reachable.
38
+ * - An EXECUTABLE file is refused even inside the workspace. It is the one
39
+ * case where the caller may have meant "open" and the OS will hear "run".
40
+ *
41
+ * The three openers also disagree about `--`, so this file does not assume:
42
+ * macOS `open` honours it. Verified on 26.6.2: `open -- -a` answers
43
+ * "The file /private/tmp/-a does not exist", while `open -a`
44
+ * answers "open: option requires an argument -- a".
45
+ * Linux `xdg-open` does NOT. It rejects any argument beginning with `-`
46
+ * as an unknown option, `--` included, so passing one would break
47
+ * every call. Flag-shaped targets are REFUSED instead.
48
+ * Windows argv is not the channel at all — the target crosses in the
49
+ * environment and `Start-Process -FilePath $env:…` reads it. Not
50
+ * the `start` shell builtin, which treats a first quoted argument
51
+ * as a window title and splits an unquoted one on `&`, so a URL
52
+ * with a query string opens something other than what was asked.
53
+ */
54
+ import { linuxDisplayEnv, linuxSession } from "../host";
55
+ import { looksLikeFlag, powershellArgv, registerPowerShellScript } from "./escape";
56
+ import { isExecutable } from "./fsseam";
57
+ import { unavailable, unsupportedPlatform } from "./outcome";
58
+ /**
59
+ * The schemes this package will hand to the OS, and the complete list.
60
+ *
61
+ * Deliberately three. `mailto` is here because composing a message is the one
62
+ * non-web thing a harness plausibly wants, and it reaches a compose window
63
+ * rather than a filesystem or a network share.
64
+ */
65
+ export const OPENABLE_SCHEMES = ["https", "http", "mailto"];
66
+ /**
67
+ * Schemes named in the refusal, because a caller who tried one deserves to
68
+ * know it was recognised and rejected rather than merely unparsed.
69
+ */
70
+ const NOTABLE_REFUSALS = new Map([
71
+ ["file", "a file: URL bypasses this package's path containment — pass the path itself instead"],
72
+ [
73
+ "smb",
74
+ "an smb: URL mounts a remote share and can leak an authentication handshake to the host named in it",
75
+ ],
76
+ ["nfs", "an nfs: URL mounts a remote share"],
77
+ ["ftp", "an ftp: URL is handed to a helper with no transport security"],
78
+ ["javascript", "a javascript: URL runs in whichever browser profile is registered"],
79
+ [
80
+ "data",
81
+ "a data: URL carries its payload inline and is handled by whatever claims the media type",
82
+ ],
83
+ ["vbscript", "a vbscript: URL is a script, not a document"],
84
+ ["ms-settings", "an ms-* URL reaches a Windows shell handler rather than a document"],
85
+ ["shell", "a shell: URL reaches the Windows shell namespace"],
86
+ ["search-ms", "a search-ms: URL reaches a Windows shell handler"],
87
+ ]);
88
+ export const WINDOWS_OPEN_SCRIPT = registerPowerShellScript({
89
+ name: "open-external",
90
+ // `-FilePath` takes the target as DATA from the environment. Nothing is
91
+ // interpolated into this string, so a target containing a quote, a
92
+ // semicolon or a `$(...)` is a string to Start-Process and not source.
93
+ script: "Start-Process -FilePath $env:CREWHAUS_OPEN_TARGET",
94
+ reads: ["CREWHAUS_OPEN_TARGET"],
95
+ });
96
+ /**
97
+ * Headers a `mailto:` URI may carry.
98
+ *
99
+ * RFC 6068 §5 is explicit that only a short list is safe to create or act on,
100
+ * and this is that list. Everything else is refused BY NAME, `attach` and
101
+ * `attachment` above all: a mail client that honours them reads a LOCAL FILE
102
+ * off the disk and puts it in a message addressed to whoever the URI names.
103
+ * That is the same hole `file:` is refused for at the top of this file —
104
+ * reaching the filesystem without passing this package's containment — with a
105
+ * mail client in the middle, so refusing one and not the other would be a
106
+ * gate with a door beside it.
107
+ */
108
+ const MAILTO_SAFE_HEADERS = new Set([
109
+ "to",
110
+ "cc",
111
+ "bcc",
112
+ "subject",
113
+ "body",
114
+ "in-reply-to",
115
+ ]);
116
+ /**
117
+ * Does this string carry a URI scheme?
118
+ *
119
+ * RFC 3986: `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`. The
120
+ * single-letter carve-out is for Windows: `C:\Users\…` matches the scheme
121
+ * production exactly, and classifying a drive path as a `c:` URL would refuse
122
+ * every absolute Windows path with a baffling message.
123
+ */
124
+ export function uriScheme(value) {
125
+ const match = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(value);
126
+ if (match?.[1] === undefined)
127
+ return undefined;
128
+ const scheme = match[1];
129
+ if (scheme.length === 1 && /^[A-Za-z]:[\\/]/.test(value))
130
+ return undefined;
131
+ return scheme.toLowerCase();
132
+ }
133
+ /**
134
+ * Decide what a caller's target is, or refuse it.
135
+ *
136
+ * Returns the URL case resolved; a path is handed back for the caller to
137
+ * contain, because containment needs the filesystem and this function is
138
+ * pure.
139
+ */
140
+ export function classifyTarget(target, allowed) {
141
+ const trimmed = target.trim();
142
+ if (trimmed === "")
143
+ return { ok: false, reason: "the target is empty" };
144
+ // Checked before anything else: a NUL truncates the argument at the syscall
145
+ // boundary, so the OS would open a shorter string than the one validated.
146
+ if (trimmed.includes("\u0000")) {
147
+ return { ok: false, reason: "the target contains a NUL byte" };
148
+ }
149
+ if (/[\r\n]/.test(trimmed)) {
150
+ return { ok: false, reason: "the target contains a newline" };
151
+ }
152
+ const scheme = uriScheme(trimmed);
153
+ if (scheme === undefined) {
154
+ if (looksLikeFlag(trimmed)) {
155
+ // `xdg-open` has no `--`, so this cannot be defended downstream.
156
+ return {
157
+ ok: false,
158
+ reason: 'the target begins with "-", which every opener here would read as an option rather than as a file',
159
+ };
160
+ }
161
+ return { ok: true, target: { kind: "path", value: trimmed } };
162
+ }
163
+ if (!OPENABLE_SCHEMES.includes(scheme)) {
164
+ const why = NOTABLE_REFUSALS.get(scheme);
165
+ return {
166
+ ok: false,
167
+ scheme,
168
+ reason: why === undefined
169
+ ? `the scheme "${scheme}:" is not one this tool opens; it hands only ${OPENABLE_SCHEMES.join(", ")} to the operating system`
170
+ : `the scheme "${scheme}:" is refused — ${why}`,
171
+ };
172
+ }
173
+ if (!allowed.includes(scheme)) {
174
+ return {
175
+ ok: false,
176
+ scheme,
177
+ reason: `the scheme "${scheme}:" is openable but this call narrowed the allowed set to ${allowed.join(", ")}`,
178
+ };
179
+ }
180
+ // A URL this parser cannot make sense of is refused rather than handed on:
181
+ // this parser and the OS's disagreeing is how a target that passes a check
182
+ // here opens something else there.
183
+ let parsed;
184
+ try {
185
+ parsed = new URL(trimmed);
186
+ }
187
+ catch {
188
+ return { ok: false, scheme, reason: "the URL could not be parsed" };
189
+ }
190
+ if (scheme === "mailto") {
191
+ // `new URL` does not decompose a mailto:, so the query is read by hand.
192
+ const query = trimmed.slice(trimmed.indexOf("?") + 1);
193
+ if (trimmed.includes("?")) {
194
+ for (const [rawKey] of new URLSearchParams(query)) {
195
+ const key = rawKey.toLowerCase();
196
+ if (!MAILTO_SAFE_HEADERS.has(key)) {
197
+ return {
198
+ ok: false,
199
+ scheme,
200
+ reason: key === "attach" || key === "attachment"
201
+ ? `the mailto: header "${rawKey}" asks a mail client to read a LOCAL FILE into the message, which walks around the same path containment that file: URLs are refused for — it is not one of the headers this tool passes on (${[...MAILTO_SAFE_HEADERS].join(", ")})`
202
+ : `the mailto: header "${rawKey}" is not one RFC 6068 calls safe to act on; this tool passes only ${[...MAILTO_SAFE_HEADERS].join(", ")}`,
203
+ };
204
+ }
205
+ }
206
+ }
207
+ return { ok: true, target: { kind: "url", scheme: "mailto", value: trimmed } };
208
+ }
209
+ if (parsed.hostname === "")
210
+ return { ok: false, scheme, reason: "the URL has no host" };
211
+ // CREDENTIALS BEFORE THE `@` ARE REFUSED, not passed on. Two reasons, and
212
+ // the second is the one that made this a review finding: a password in a
213
+ // URL is put into an argv (world-readable through `ps` on every platform
214
+ // here), into the result, and from there into the transcript and any log
215
+ // the harness keeps; and `https://google.com@evil.example/` reads to a
216
+ // human — and to a model checking the result — as google.com while the
217
+ // browser goes to evil.example.
218
+ if (parsed.username !== "" || parsed.password !== "") {
219
+ return {
220
+ ok: false,
221
+ scheme,
222
+ reason: `the URL carries credentials before the "@" (host: ${parsed.hostname}); they would be visible in a process listing and in this run's transcript, and a "user@host" prefix reads as the host it is not — pass the URL without them`,
223
+ };
224
+ }
225
+ // A BACKSLASH IS REFUSED RATHER THAN FOLDED. The WHATWG parser above
226
+ // rewrites `\` to `/` in a special scheme; CFURL (macOS `open`) and most
227
+ // other parsers do not. `https://example.com\@evil.example/` is therefore
228
+ // host `example.com` HERE and host `evil.example` THERE — the check passes
229
+ // on one string and the desktop opens another. Nothing legitimate needs a
230
+ // backslash in a URL, so the disagreement is refused instead of resolved.
231
+ if (trimmed.includes("\\")) {
232
+ return {
233
+ ok: false,
234
+ scheme,
235
+ reason: 'the URL contains a backslash; URL parsers disagree about whether that is a path separator, so the host this tool checked would not be the host the operating system opened — send the URL with "/"',
236
+ };
237
+ }
238
+ // THE NORMALISED FORM IS WHAT IS OPENED. Handing the OS the caller's raw
239
+ // text while validating the parse is how the two come apart
240
+ // (`https:evil.example` and `https://EVIL.example` are the parser's
241
+ // problem, not the desktop's) — so the string that passed the checks above
242
+ // is the string that travels.
243
+ const normalized = parsed.href;
244
+ return {
245
+ ok: true,
246
+ target: {
247
+ kind: "url",
248
+ scheme: scheme,
249
+ value: normalized,
250
+ ...(normalized === trimmed ? {} : { given: trimmed }),
251
+ },
252
+ };
253
+ }
254
+ /**
255
+ * Extensions a desktop RUNS, or that redirect somewhere this tool cannot see.
256
+ *
257
+ * The POSIX execute bit is not enough on its own, for three reasons found
258
+ * while reviewing this file:
259
+ *
260
+ * - Windows has no execute bit worth reading. `statSync` there reports a
261
+ * mode with no `0o111` set for a perfectly runnable `.bat`, so the mode
262
+ * check passes it and `Start-Process` runs it.
263
+ * - a macOS `.app` is a DIRECTORY, so `facts.isFile` is false and the mode
264
+ * check never even applies — while `open Foo.app` launches it.
265
+ * - `.desktop`, `.lnk`, `.url` and `.webloc` are INDIRECTION. They contain a
266
+ * target, and opening one reaches a program or a URL that never passed
267
+ * the scheme allow-list at the top of this file. A `.url` file holding
268
+ * `URL=file:///etc/passwd` walks straight around the gate.
269
+ */
270
+ const DANGEROUS_EXTENSIONS = new Set([
271
+ // runs directly
272
+ "exe",
273
+ "com",
274
+ "scr",
275
+ "bat",
276
+ "cmd",
277
+ "pif",
278
+ "msi",
279
+ "msp",
280
+ "cpl",
281
+ "hta",
282
+ "reg",
283
+ "ps1",
284
+ "psm1",
285
+ "vbs",
286
+ "vbe",
287
+ "wsf",
288
+ "wsh",
289
+ "js",
290
+ "jse",
291
+ "jar",
292
+ "sh",
293
+ "bash",
294
+ "zsh",
295
+ "fish",
296
+ "command",
297
+ "tool",
298
+ "run",
299
+ "bin",
300
+ "out",
301
+ // macOS bundles and automation
302
+ "app",
303
+ "pkg",
304
+ "dmg",
305
+ "workflow",
306
+ "scpt",
307
+ "scptd",
308
+ "applescript",
309
+ "terminal",
310
+ // indirection — the target never passes the scheme gate
311
+ "desktop",
312
+ "lnk",
313
+ "url",
314
+ "webloc",
315
+ "inetloc",
316
+ "inf",
317
+ ]);
318
+ /** The lowercase extension, or `""`. */
319
+ export function extensionOf(path) {
320
+ const base = path.split(/[\\/]/).pop() ?? path;
321
+ const dot = base.lastIndexOf(".");
322
+ return dot <= 0 ? "" : base.slice(dot + 1).toLowerCase();
323
+ }
324
+ /**
325
+ * Why a contained path is still not openable.
326
+ *
327
+ * `undefined` means it is. The two executable refusals are the substantive
328
+ * ones — see the header and `DANGEROUS_EXTENSIONS`.
329
+ */
330
+ export function refusePath(absolutePath, facts) {
331
+ if (facts === undefined || !facts.exists) {
332
+ return "the path does not exist (or its parent could not be read)";
333
+ }
334
+ const extension = extensionOf(absolutePath);
335
+ if (DANGEROUS_EXTENSIONS.has(extension)) {
336
+ return `the path ends in ".${extension}", which a desktop either RUNS or follows to a target this tool never got to check — "open this" and "run this" are the same gesture to an operating system, so it is refused`;
337
+ }
338
+ if (facts.isFile && isExecutable(facts)) {
339
+ return "the path is an executable file, and asking a desktop to OPEN one is how it gets RUN — this tool refuses, whatever the registered handler would have done";
340
+ }
341
+ return undefined;
342
+ }
343
+ /** Build the argv (or the env channel) that would open an accepted target. */
344
+ export function planOpen(platform, env, value) {
345
+ switch (platform) {
346
+ case "darwin":
347
+ // `-n` is NOT passed: it forces a new instance of the handling app,
348
+ // which for a browser means a second profile window rather than a tab.
349
+ return { ok: true, backend: "open", request: { argv: ["open", "--", value] } };
350
+ case "linux": {
351
+ const session = linuxSession(env);
352
+ if (session.kind === "none" || session.kind === "wayland-unreachable") {
353
+ return { ok: false, unavailable: unavailable("session", session.reason, "xdg-open") };
354
+ }
355
+ if (looksLikeFlag(value)) {
356
+ // Unreachable after `classifyTarget`, and asserted anyway: xdg-open
357
+ // has no `--`, so this is the last place the guarantee can hold.
358
+ return {
359
+ ok: false,
360
+ unavailable: unavailable("program", 'the target begins with "-" and xdg-open has no "--" to separate options from arguments', "xdg-open"),
361
+ };
362
+ }
363
+ return {
364
+ ok: true,
365
+ backend: "xdg-open",
366
+ request: { argv: ["xdg-open", value], env: linuxDisplayEnv(env) },
367
+ };
368
+ }
369
+ case "win32": {
370
+ const built = powershellArgv(WINDOWS_OPEN_SCRIPT, { CREWHAUS_OPEN_TARGET: value });
371
+ return {
372
+ ok: true,
373
+ backend: "powershell Start-Process",
374
+ request: { argv: built.argv, env: built.env },
375
+ };
376
+ }
377
+ default:
378
+ return { ok: false, unavailable: unsupportedPlatform(platform, "open") };
379
+ }
380
+ }
381
+ /** xdg-open's documented exit codes. Each is a different instruction. */
382
+ const XDG_EXIT = new Map([
383
+ [1, "error in the command line syntax"],
384
+ [2, "the file or URL does not exist"],
385
+ [3, "a required tool could not be found"],
386
+ [4, "the action failed"],
387
+ ]);
388
+ export function classifyOpen(backend, result) {
389
+ if (result.refused === true)
390
+ return { outcome: "failed", reason: result.stderr };
391
+ if (result.missing) {
392
+ return unavailable("program", `the opener for this platform is not installed on this host (${backend}; on Linux that is xdg-open, from xdg-utils)`, backend);
393
+ }
394
+ if (result.timedOut) {
395
+ return {
396
+ outcome: "failed",
397
+ reason: "the opener did not finish within its timeout and was killed — whether the handler was reached is unknown",
398
+ };
399
+ }
400
+ if (result.code === 0) {
401
+ // macOS `open` exits 0 even when the file does not exist, printing the
402
+ // complaint on stdout. Recorded on 26.6.2:
403
+ // $ /usr/bin/open -- /tmp/definitely-not-here-crewhaus-xyz ; echo $?
404
+ // The file /tmp/definitely-not-here-crewhaus-xyz does not exist.
405
+ // 0
406
+ // Reporting that as a success is precisely the silent success rule 5
407
+ // forbids, so stdout is read on the ZERO-exit path for this one backend.
408
+ if (/does not exist|Unable to find application/i.test(result.stdout)) {
409
+ return { outcome: "failed", reason: result.stdout.trim() };
410
+ }
411
+ return { outcome: "handedOff" };
412
+ }
413
+ const known = XDG_EXIT.get(result.code);
414
+ const detail = result.stderr.trim();
415
+ return {
416
+ outcome: "failed",
417
+ reason: known === undefined
418
+ ? `${backend} exited ${result.code}${detail === "" ? "" : `: ${detail}`}`
419
+ : `${backend} exited ${result.code} (${known})${detail === "" ? "" : `: ${detail}`}`,
420
+ };
421
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The vocabulary for "there was nothing to ask" and "I could not find out".
3
+ *
4
+ * House rule 6, and the class of bug that has dominated every wave of this
5
+ * project: "could not determine" is not "no". Every tool in this package has
6
+ * an empty-looking answer that a caller will act on, and in every case
7
+ * "nothing" has more than one cause:
8
+ *
9
+ * - an EMPTY clipboard is not an UNREADABLE one. Recorded on Debian 12:
10
+ * `xclip -selection primary -o` on an empty selection exits 1 with
11
+ * "Error: target STRING not available", while with no `$DISPLAY` it exits
12
+ * 1 with "Error: Can't open display: (null)". Same exit code, same empty
13
+ * stdout, opposite meanings.
14
+ * - a presence probe that FAILED is not "the user is idle". Returning
15
+ * `idle: false` there tells a workflow the human is at the keyboard.
16
+ * - a window list that could not be READ is not "no windows open".
17
+ * Recorded: without Accessibility, System Events answers every window
18
+ * query with an error, not with an empty list.
19
+ * - a printer query that TIMED OUT is not "no printers". Recorded: with no
20
+ * scheduler, `lpstat -p -d` prints "Scheduler is not running." and lists
21
+ * nothing - which reads exactly like a host with zero queues.
22
+ *
23
+ * So each tool answers with an `outcome` naming which of those it is, and a
24
+ * field that could not be established is `null` PLUS an entry in `unknown`
25
+ * saying what was tried and why it did not answer. A count is never defaulted
26
+ * to 0, a boolean never to false, a list never to empty.
27
+ */
28
+ export type UnknownFact = {
29
+ /** Dotted path of the field in the result, e.g. "idleSeconds". */
30
+ readonly field: string;
31
+ /** What was tried: a command, a file, or a runtime call. */
32
+ readonly probe: string;
33
+ /** Why it did not answer, in a sentence a caller can act on. */
34
+ readonly reason: string;
35
+ };
36
+ /** Collects unknowns for one tool call. */
37
+ export declare class Unknowns {
38
+ private readonly facts;
39
+ add(field: string, probe: string, reason: string): void;
40
+ has(field: string): boolean;
41
+ get size(): number;
42
+ /**
43
+ * Sorted by field, so two calls against the same machine return the same
44
+ * bytes rather than whatever order the probes happened to fail in.
45
+ */
46
+ list(): ReadonlyArray<UnknownFact>;
47
+ }
48
+ /**
49
+ * Why a tool could not even try.
50
+ *
51
+ * `missing` names the THING that was not there - a program, a display, an OS
52
+ * grant - so a caller (or an operator reading a log) knows what to install or
53
+ * switch on. `unsupported` is separate from `missing` because "this package
54
+ * has no backend for your OS" is a different instruction from "install
55
+ * wmctrl".
56
+ */
57
+ export type Unavailable = {
58
+ readonly outcome: "unavailable";
59
+ /**
60
+ * What was absent.
61
+ *
62
+ * `"unknown"` is a member in its own right and is NOT a spare: a probe that
63
+ * timed out did not establish that a session, a program or a grant was
64
+ * missing, and naming one of those would be the same class of false claim
65
+ * rule 6 is about, one level down in the result. The reason string says
66
+ * what could not be established.
67
+ */
68
+ readonly missing: "platform" | "program" | "session" | "permission" | "unknown";
69
+ readonly reason: string;
70
+ /** The program that would have been run, when there was one. */
71
+ readonly program?: string;
72
+ };
73
+ export declare function unavailable(missing: Unavailable["missing"], reason: string, program?: string): Unavailable;
74
+ /** The refusal every tool gives on a platform this package has no backend for. */
75
+ export declare function unsupportedPlatform(platform: string, what: string): Unavailable;
76
+ /** The first non-empty line, capped - a usage message can be twenty lines. */
77
+ export declare function firstLine(text: string, max?: number): string;
78
+ /**
79
+ * Why a command failed, keeping the cases apart that a caller acts on
80
+ * differently: the program is NOT INSTALLED (install it, or use another
81
+ * backend), it TIMED OUT (the desktop may be wedged - notably an X11
82
+ * clipboard read with no server, which blocks rather than exiting), the seam
83
+ * was not wired (a test author's mistake), or it ran and refused.
84
+ */
85
+ export declare function commandFailure(result: {
86
+ readonly code: number;
87
+ readonly stderr: string;
88
+ readonly timedOut: boolean;
89
+ readonly missing: boolean;
90
+ readonly refused?: boolean;
91
+ }): {
92
+ readonly kind: string;
93
+ readonly reason: string;
94
+ };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The vocabulary for "there was nothing to ask" and "I could not find out".
3
+ *
4
+ * House rule 6, and the class of bug that has dominated every wave of this
5
+ * project: "could not determine" is not "no". Every tool in this package has
6
+ * an empty-looking answer that a caller will act on, and in every case
7
+ * "nothing" has more than one cause:
8
+ *
9
+ * - an EMPTY clipboard is not an UNREADABLE one. Recorded on Debian 12:
10
+ * `xclip -selection primary -o` on an empty selection exits 1 with
11
+ * "Error: target STRING not available", while with no `$DISPLAY` it exits
12
+ * 1 with "Error: Can't open display: (null)". Same exit code, same empty
13
+ * stdout, opposite meanings.
14
+ * - a presence probe that FAILED is not "the user is idle". Returning
15
+ * `idle: false` there tells a workflow the human is at the keyboard.
16
+ * - a window list that could not be READ is not "no windows open".
17
+ * Recorded: without Accessibility, System Events answers every window
18
+ * query with an error, not with an empty list.
19
+ * - a printer query that TIMED OUT is not "no printers". Recorded: with no
20
+ * scheduler, `lpstat -p -d` prints "Scheduler is not running." and lists
21
+ * nothing - which reads exactly like a host with zero queues.
22
+ *
23
+ * So each tool answers with an `outcome` naming which of those it is, and a
24
+ * field that could not be established is `null` PLUS an entry in `unknown`
25
+ * saying what was tried and why it did not answer. A count is never defaulted
26
+ * to 0, a boolean never to false, a list never to empty.
27
+ */
28
+ /** Collects unknowns for one tool call. */
29
+ export class Unknowns {
30
+ facts = new Map();
31
+ add(field, probe, reason) {
32
+ // First writer wins: the innermost probe that failed is the specific one,
33
+ // and a later generic "section unavailable" must not overwrite it.
34
+ if (!this.facts.has(field))
35
+ this.facts.set(field, { field, probe, reason });
36
+ }
37
+ has(field) {
38
+ return this.facts.has(field);
39
+ }
40
+ get size() {
41
+ return this.facts.size;
42
+ }
43
+ /**
44
+ * Sorted by field, so two calls against the same machine return the same
45
+ * bytes rather than whatever order the probes happened to fail in.
46
+ */
47
+ list() {
48
+ return [...this.facts.values()].sort((a, b) => (a.field < b.field ? -1 : 1));
49
+ }
50
+ }
51
+ export function unavailable(missing, reason, program) {
52
+ return {
53
+ outcome: "unavailable",
54
+ missing,
55
+ reason,
56
+ ...(program === undefined ? {} : { program }),
57
+ };
58
+ }
59
+ /** The refusal every tool gives on a platform this package has no backend for. */
60
+ export function unsupportedPlatform(platform, what) {
61
+ return unavailable("platform", `this package has no ${what} backend for platform "${platform}"`);
62
+ }
63
+ /** The first non-empty line, capped - a usage message can be twenty lines. */
64
+ export function firstLine(text, max = 200) {
65
+ const line = text
66
+ .split("\n")
67
+ .map((l) => l.trim())
68
+ .find((l) => l.length > 0);
69
+ if (line === undefined)
70
+ return "";
71
+ return line.length <= max ? line : `${line.slice(0, max - 1)}...`;
72
+ }
73
+ /**
74
+ * Why a command failed, keeping the cases apart that a caller acts on
75
+ * differently: the program is NOT INSTALLED (install it, or use another
76
+ * backend), it TIMED OUT (the desktop may be wedged - notably an X11
77
+ * clipboard read with no server, which blocks rather than exiting), the seam
78
+ * was not wired (a test author's mistake), or it ran and refused.
79
+ */
80
+ export function commandFailure(result) {
81
+ if (result.refused === true) {
82
+ return { kind: "no-runner", reason: result.stderr };
83
+ }
84
+ if (result.missing) {
85
+ return { kind: "not-installed", reason: "the program is not installed on this host" };
86
+ }
87
+ if (result.timedOut) {
88
+ return {
89
+ kind: "timed-out",
90
+ reason: "the program did not finish within its timeout - it was killed, and nothing can be concluded about what it would have said",
91
+ };
92
+ }
93
+ const detail = firstLine(result.stderr);
94
+ return {
95
+ kind: "exit-nonzero",
96
+ reason: detail === ""
97
+ ? `the program exited ${result.code} and said nothing`
98
+ : `the program exited ${result.code}: ${detail}`,
99
+ };
100
+ }