@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,84 @@
1
+ /**
2
+ * Recorded output, and where each line came from.
3
+ *
4
+ * Nothing in this package's suite asks the machine it runs on. CI is a Linux
5
+ * container with no desktop at all, this was written on macOS, and a parser
6
+ * checked against whatever the test host happens to answer is a parser
7
+ * checked against nothing. So every classifier is fed from here.
8
+ *
9
+ * Each constant says whether it was RECORDED (somebody ran the command and
10
+ * pasted the bytes) or DOCUMENTED (written from the program's source or
11
+ * manual because nobody here has that platform). The distinction is kept
12
+ * deliberately: a documented fixture proves the parser handles the shape it
13
+ * was told about, and claims nothing more.
14
+ */
15
+ /** No X server at all. Exit 1, empty stdout. */
16
+ export declare const XCLIP_NO_DISPLAY_STDERR = "Error: Can't open display: (null)\n";
17
+ /**
18
+ * A reachable display with nothing on the selection. Exit 1, empty stdout —
19
+ * the SAME exit code and the SAME empty stdout as the line above, and the
20
+ * opposite meaning. This pair is why `classifyClipboardRead` reads stderr.
21
+ */
22
+ export declare const XCLIP_EMPTY_SELECTION_STDERR = "Error: target STRING not available\n";
23
+ /** The happy path. */
24
+ export declare const XCLIP_TEXT_STDOUT = "ssh-rsa AAAAB3NzaC1yc2EA... deploy@example\n";
25
+ /** `clipboard info` against an EMPTY pasteboard: empty stdout, exit 0. */
26
+ export declare const CLIPBOARD_INFO_EMPTY_STDOUT = "";
27
+ /**
28
+ * `clipboard info` with a screenshot on the pasteboard: flavour names and
29
+ * byte counts, and no content. This is what tells "empty" from "there is
30
+ * something here pbpaste cannot give you".
31
+ */
32
+ export declare const CLIPBOARD_INFO_IMAGE_STDOUT = "\u00ABclass PNGf\u00BB, 284913, \u00ABclass 8BPS\u00BB, 1049928, TIFF picture, 1063968";
33
+ export declare const NOTIFY_SEND_NO_BUS_STDERR = "Cannot autolaunch D-Bus without X11 $DISPLAY\n";
34
+ /**
35
+ * The argument-injection this package's `--` exists to prevent: with the
36
+ * summary passed as a bare positional, `-u` was consumed as the urgency flag
37
+ * and the body became its argument.
38
+ */
39
+ export declare const NOTIFY_SEND_FLAG_INJECTION_STDERR = "Unknown urgency body specified\n";
40
+ export declare const OSASCRIPT_NO_ACCESSIBILITY_STDERR = "44:87: execution error: System Events got an error: osascript is not allowed assistive access. (-25211)\n";
41
+ /** The OTHER grant, in a different System Settings pane. */
42
+ export declare const OSASCRIPT_NO_AUTOMATION_STDERR = "48:52: execution error: Not authorized to send Apple events to System Events. (-1743)\n";
43
+ /**
44
+ * `wmctrl -l -G -p -x`. Nine fixed columns then a title with spaces in it;
45
+ * the second row's title repeats a column's text on purpose, because a parser
46
+ * that re-joins the split fields gets that one wrong.
47
+ */
48
+ export declare const WMCTRL_STDOUT: string;
49
+ /**
50
+ * `ioreg -r -c IOHIDSystem -d 1`. NANOSECONDS: 3_254_341_351_000 ns is 3254
51
+ * seconds. Reading it as milliseconds would report 3254 seconds of idleness
52
+ * as three, and make a locked machine look freshly used.
53
+ *
54
+ * The `-r` matters as much as the unit. `ioreg -c IOHIDSystem -d 1` — the
55
+ * form the survey sketch gave — prints this key NOWHERE on this OS version,
56
+ * because without `-r` the depth is measured from the root.
57
+ */
58
+ export declare const IOREG_HIDIDLE_STDOUT: string;
59
+ /** The same machine, `ioreg -n Root -d1`, with the screen locked. */
60
+ export declare const IOREG_ROOT_LOCKED_STDOUT: string;
61
+ /** An unlocked console, same keys. */
62
+ export declare const IOREG_ROOT_UNLOCKED_STDOUT: string;
63
+ /** A macOS version that reports lock state somewhere this parser cannot see. */
64
+ export declare const IOREG_ROOT_NO_LOCK_KEY_STDOUT = " \"IORegistryPlanes\" = {\"IOService\"=\"IOService\"}";
65
+ /** DOCUMENTED: `loginctl show-session self -p LockedHint -p Type -p Active`. */
66
+ export declare const LOGINCTL_STDOUT = "LockedHint=no\nType=x11\nActive=yes\n";
67
+ /** DOCUMENTED: the Windows presence script's two lines. */
68
+ export declare const WINDOWS_PRESENCE_STDOUT = "idleMs=42000\nlocked=no\n";
69
+ /**
70
+ * `lpstat -p -d`, verbatim. Two printers in two DIFFERENT sentence shapes,
71
+ * an INDENTED continuation line that belongs to the row above it, and the
72
+ * default-destination line.
73
+ */
74
+ export declare const LPSTAT_STDOUT: string;
75
+ /**
76
+ * A host whose scheduler is down. It lists NOTHING, which reads exactly like
77
+ * a host with no printers — the rule-6 case this fixture exists for.
78
+ */
79
+ export declare const LPSTAT_NO_SCHEDULER_STDERR = "lpstat: Scheduler is not running.\n";
80
+ /** DOCUMENTED: `lp` answers with prose and the job id is inside it. */
81
+ export declare const LP_REQUEST_ID_STDOUT = "request id is Canon_MX490_series-34 (1 file(s))\n";
82
+ export declare const PS_CAFFEINATE_STDOUT = "caffeinate -i -m -t 1800\n";
83
+ /** The same pid, now owned by something else entirely. */
84
+ export declare const PS_REUSED_PID_STDOUT = "/usr/sbin/cupsd -l\n";
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Recorded output, and where each line came from.
3
+ *
4
+ * Nothing in this package's suite asks the machine it runs on. CI is a Linux
5
+ * container with no desktop at all, this was written on macOS, and a parser
6
+ * checked against whatever the test host happens to answer is a parser
7
+ * checked against nothing. So every classifier is fed from here.
8
+ *
9
+ * Each constant says whether it was RECORDED (somebody ran the command and
10
+ * pasted the bytes) or DOCUMENTED (written from the program's source or
11
+ * manual because nobody here has that platform). The distinction is kept
12
+ * deliberately: a documented fixture proves the parser handles the shape it
13
+ * was told about, and claims nothing more.
14
+ */
15
+ // ---------------------------------------------------------------------------
16
+ // clipboard — Linux, RECORDED on Debian 12 / xclip 0.13
17
+ // ---------------------------------------------------------------------------
18
+ /** No X server at all. Exit 1, empty stdout. */
19
+ export const XCLIP_NO_DISPLAY_STDERR = "Error: Can't open display: (null)\n";
20
+ /**
21
+ * A reachable display with nothing on the selection. Exit 1, empty stdout —
22
+ * the SAME exit code and the SAME empty stdout as the line above, and the
23
+ * opposite meaning. This pair is why `classifyClipboardRead` reads stderr.
24
+ */
25
+ export const XCLIP_EMPTY_SELECTION_STDERR = "Error: target STRING not available\n";
26
+ /** The happy path. */
27
+ export const XCLIP_TEXT_STDOUT = "ssh-rsa AAAAB3NzaC1yc2EA... deploy@example\n";
28
+ // ---------------------------------------------------------------------------
29
+ // clipboard — macOS, RECORDED on macOS 26.6.2
30
+ // ---------------------------------------------------------------------------
31
+ /** `clipboard info` against an EMPTY pasteboard: empty stdout, exit 0. */
32
+ export const CLIPBOARD_INFO_EMPTY_STDOUT = "";
33
+ /**
34
+ * `clipboard info` with a screenshot on the pasteboard: flavour names and
35
+ * byte counts, and no content. This is what tells "empty" from "there is
36
+ * something here pbpaste cannot give you".
37
+ */
38
+ export const CLIPBOARD_INFO_IMAGE_STDOUT = "«class PNGf», 284913, «class 8BPS», 1049928, TIFF picture, 1063968";
39
+ // ---------------------------------------------------------------------------
40
+ // notifications — Linux, RECORDED on Debian 12 / libnotify 0.8.1
41
+ // ---------------------------------------------------------------------------
42
+ export const NOTIFY_SEND_NO_BUS_STDERR = "Cannot autolaunch D-Bus without X11 $DISPLAY\n";
43
+ /**
44
+ * The argument-injection this package's `--` exists to prevent: with the
45
+ * summary passed as a bare positional, `-u` was consumed as the urgency flag
46
+ * and the body became its argument.
47
+ */
48
+ export const NOTIFY_SEND_FLAG_INJECTION_STDERR = "Unknown urgency body specified\n";
49
+ // ---------------------------------------------------------------------------
50
+ // AppleScript — RECORDED on macOS 26.6.2 with Accessibility NOT granted
51
+ // ---------------------------------------------------------------------------
52
+ export const OSASCRIPT_NO_ACCESSIBILITY_STDERR = "44:87: execution error: System Events got an error: osascript is not allowed assistive access. (-25211)\n";
53
+ /** The OTHER grant, in a different System Settings pane. */
54
+ export const OSASCRIPT_NO_AUTOMATION_STDERR = "48:52: execution error: Not authorized to send Apple events to System Events. (-1743)\n";
55
+ // ---------------------------------------------------------------------------
56
+ // windows — DOCUMENTED (wmctrl 1.07 output format)
57
+ // ---------------------------------------------------------------------------
58
+ /**
59
+ * `wmctrl -l -G -p -x`. Nine fixed columns then a title with spaces in it;
60
+ * the second row's title repeats a column's text on purpose, because a parser
61
+ * that re-joins the split fields gets that one wrong.
62
+ */
63
+ export const WMCTRL_STDOUT = [
64
+ "0x03c00003 0 2841 64 27 1280 800 Navigator.firefox buildbox Mozilla Firefox",
65
+ "0x04200005 0 3120 0 0 2560 1440 gnome-terminal-server.Gnome-terminal buildbox 0x04200005 build log",
66
+ "0x05000007 -1 4001 200 180 900 600 code.Code buildbox index.ts - factory",
67
+ ].join("\n");
68
+ // ---------------------------------------------------------------------------
69
+ // presence — RECORDED on macOS 26.6.2, screen locked, ~54 minutes idle
70
+ // ---------------------------------------------------------------------------
71
+ /**
72
+ * `ioreg -r -c IOHIDSystem -d 1`. NANOSECONDS: 3_254_341_351_000 ns is 3254
73
+ * seconds. Reading it as milliseconds would report 3254 seconds of idleness
74
+ * as three, and make a locked machine look freshly used.
75
+ *
76
+ * The `-r` matters as much as the unit. `ioreg -c IOHIDSystem -d 1` — the
77
+ * form the survey sketch gave — prints this key NOWHERE on this OS version,
78
+ * because without `-r` the depth is measured from the root.
79
+ */
80
+ export const IOREG_HIDIDLE_STDOUT = [
81
+ "+-o IOHIDSystem <class IOHIDSystem, id 0x1000005b6, registered, matched, active>",
82
+ " | {",
83
+ ' | "IOClass" = "IOHIDSystem"',
84
+ ' | "HIDIdleTime" = 3254341351000',
85
+ ' | "IOProviderClass" = "IOResources"',
86
+ " | }",
87
+ ].join("\n");
88
+ /** The same machine, `ioreg -n Root -d1`, with the screen locked. */
89
+ export const IOREG_ROOT_LOCKED_STDOUT = [
90
+ ' "IOConsoleLocked" = Yes',
91
+ ' "IOConsoleUsers" = ({"kCGSSessionOnConsoleKey"=Yes,"kCGSSessionIDKey"=257,"CGSSessionScreenIsLocked"=Yes,"kCGSSessionUserIDKey"=506})',
92
+ ].join("\n");
93
+ /** An unlocked console, same keys. */
94
+ export const IOREG_ROOT_UNLOCKED_STDOUT = [
95
+ ' "IOConsoleLocked" = No',
96
+ ' "IOConsoleUsers" = ({"kCGSSessionOnConsoleKey"=Yes,"CGSSessionScreenIsLocked"=No})',
97
+ ].join("\n");
98
+ /** A macOS version that reports lock state somewhere this parser cannot see. */
99
+ export const IOREG_ROOT_NO_LOCK_KEY_STDOUT = ' "IORegistryPlanes" = {"IOService"="IOService"}';
100
+ /** DOCUMENTED: `loginctl show-session self -p LockedHint -p Type -p Active`. */
101
+ export const LOGINCTL_STDOUT = "LockedHint=no\nType=x11\nActive=yes\n";
102
+ /** DOCUMENTED: the Windows presence script's two lines. */
103
+ export const WINDOWS_PRESENCE_STDOUT = "idleMs=42000\nlocked=no\n";
104
+ // ---------------------------------------------------------------------------
105
+ // printing — RECORDED on macOS 26.6.2, CUPS 2.4
106
+ // ---------------------------------------------------------------------------
107
+ /**
108
+ * `lpstat -p -d`, verbatim. Two printers in two DIFFERENT sentence shapes,
109
+ * an INDENTED continuation line that belongs to the row above it, and the
110
+ * default-destination line.
111
+ */
112
+ export const LPSTAT_STDOUT = [
113
+ "printer Canon_MX490_series is idle. enabled since Sun Jul 26 10:59:45 2026",
114
+ "printer Canon_TS5300_series now printing Canon_TS5300_series-33. enabled since Sat Sep 12 00:00:20 2026",
115
+ "\tLooking for printer.",
116
+ "system default destination: Canon_MX490_series",
117
+ ].join("\n");
118
+ /**
119
+ * A host whose scheduler is down. It lists NOTHING, which reads exactly like
120
+ * a host with no printers — the rule-6 case this fixture exists for.
121
+ */
122
+ export const LPSTAT_NO_SCHEDULER_STDERR = "lpstat: Scheduler is not running.\n";
123
+ /** DOCUMENTED: `lp` answers with prose and the job id is inside it. */
124
+ export const LP_REQUEST_ID_STDOUT = "request id is Canon_MX490_series-34 (1 file(s))\n";
125
+ // ---------------------------------------------------------------------------
126
+ // power — DOCUMENTED (`ps -o command= -p <pid>`)
127
+ // ---------------------------------------------------------------------------
128
+ export const PS_CAFFEINATE_STDOUT = "caffeinate -i -m -t 1800\n";
129
+ /** The same pid, now owned by something else entirely. */
130
+ export const PS_REUSED_PID_STDOUT = "/usr/sbin/cupsd -l\n";
package/dist/host.d.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The platforms this package makes decisions about.
3
+ *
4
+ * `"unsupported"` is a real member, not a placeholder: it is what every tool
5
+ * here returns `unavailable` for, and it is what an un-injected seam answers
6
+ * under test. Keeping it in the union means the exhaustive switch in each
7
+ * backend selector has to handle it, so a new tool cannot forget.
8
+ */
9
+ export type HostPlatform = "darwin" | "linux" | "win32" | "unsupported";
10
+ /** What `process.platform` actually says. Only the smoke test asks. */
11
+ export declare function realHostPlatform(): HostPlatform;
12
+ export declare function hostPlatform(): HostPlatform;
13
+ /** Test seam: pretend to be another OS. */
14
+ export declare function _setPlatform(platform: HostPlatform | undefined): void;
15
+ /**
16
+ * The parts of the environment that decide whether a desktop is reachable.
17
+ *
18
+ * These are not configuration, they are *facts about the session*, and every
19
+ * one of them changes a tool's answer from "here it is" to "there is nothing
20
+ * to ask". Reading `process.env` directly inside a tool would mean a test's
21
+ * answer depended on whether the developer happened to be in an SSH session.
22
+ */
23
+ export type SessionEnv = {
24
+ /** X11 display. Absent ⇒ `xclip`/`wmctrl` cannot connect at all. */
25
+ readonly DISPLAY: string | undefined;
26
+ /** Wayland socket name. */
27
+ readonly WAYLAND_DISPLAY: string | undefined;
28
+ /** `wl-paste` refuses without it even when WAYLAND_DISPLAY is set. */
29
+ readonly XDG_RUNTIME_DIR: string | undefined;
30
+ /** "x11" | "wayland" | "tty" on a logind session. */
31
+ readonly XDG_SESSION_TYPE: string | undefined;
32
+ /**
33
+ * The session bus `notify-send` posts to.
34
+ *
35
+ * Not in the first draft of this file, and its absence was a real gap: a
36
+ * notification does not go to the display server, it goes to a D-Bus
37
+ * service. With `DISPLAY` forwarded and this one dropped, libnotify falls
38
+ * back to autolaunching a bus — which on a headless box prints "Cannot
39
+ * autolaunch D-Bus without X11 $DISPLAY" and on a desktop box can start a
40
+ * SECOND private bus that no notification daemon is listening on, so the
41
+ * call succeeds and nothing is ever shown.
42
+ */
43
+ readonly DBUS_SESSION_BUS_ADDRESS: string | undefined;
44
+ /** Set by sshd ⇒ this is a remote shell, not the console. */
45
+ readonly SSH_CONNECTION: string | undefined;
46
+ readonly SSH_TTY: string | undefined;
47
+ };
48
+ /** An environment with no desktop in it — the hostile default under test. */
49
+ export declare const HEADLESS_ENV: SessionEnv;
50
+ export declare function sessionEnv(): SessionEnv;
51
+ export declare function _setSessionEnv(env: SessionEnv | undefined): void;
52
+ /** True when this process is talking to a machine over ssh, not sitting at it. */
53
+ export declare function isRemoteShell(env?: SessionEnv): boolean;
54
+ /**
55
+ * Is there a graphical session a Linux backend could talk to, and which kind?
56
+ *
57
+ * Four answers, never two. "I could not tell" is not "there is no desktop":
58
+ * a Wayland session whose `XDG_RUNTIME_DIR` is unset really has no reachable
59
+ * compositor (recorded: `wl-paste` prints "XDG_RUNTIME_DIR is invalid or not
60
+ * set in the environment"), but a session with neither variable set may
61
+ * simply be a service manager that scrubbed the environment.
62
+ */
63
+ export type LinuxSession = {
64
+ readonly kind: "x11";
65
+ readonly display: string;
66
+ } | {
67
+ readonly kind: "wayland";
68
+ readonly display: string;
69
+ readonly runtimeDir: string;
70
+ } | {
71
+ readonly kind: "wayland-unreachable";
72
+ readonly reason: string;
73
+ } | {
74
+ readonly kind: "none";
75
+ readonly reason: string;
76
+ };
77
+ export declare function linuxSession(env?: SessionEnv): LinuxSession;
78
+ /**
79
+ * The session variables a Linux desktop backend needs, forwarded explicitly.
80
+ *
81
+ * Explicitly, because `childEnv` copies nothing from `process.env` — and
82
+ * because an X11 client with no `DISPLAY` does not fail fast, it fails after
83
+ * a connection attempt or, on some builds, blocks until the deadline.
84
+ */
85
+ export declare function linuxDisplayEnv(env: SessionEnv): Record<string, string>;
86
+ export type Clock = () => number;
87
+ /**
88
+ * Epoch milliseconds.
89
+ *
90
+ * `PowerAssertion` writes a deadline into a state file and reports when an
91
+ * assertion expires, so its whole contract is clock arithmetic. Reading
92
+ * `Date.now()` inside the tool would make every assertion about an expiry a
93
+ * race with the wall clock (house rule: never assert wall-clock timing —
94
+ * inject the clock instead).
95
+ */
96
+ export declare function now(): number;
97
+ export declare function _setClock(clock: Clock | undefined): void;
98
+ /** Put every seam in this file back. Called from `afterEach`. */
99
+ export declare function _resetHostSeams(): void;
package/dist/host.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The seams, and the hostile default behind them.
3
+ *
4
+ * Every tool in this package reaches the operator's real desktop. That makes
5
+ * the machine the code runs on part of the answer twice over: the platform
6
+ * decides which backend exists at all (`pbpaste` is macOS-only, `wmctrl` is
7
+ * X11-only, `Out-Printer` is Windows-only), and the *session* decides whether
8
+ * that backend can do anything (an X11 clipboard with no `$DISPLAY` is not a
9
+ * slow clipboard, it is no clipboard).
10
+ *
11
+ * So the platform, the session environment, the clock and the child processes
12
+ * are all injected, and the suite never learns anything about the box it runs
13
+ * on except through a fixture somebody captured and pasted into `./fixtures`.
14
+ *
15
+ * ── THE HOSTILE DEFAULT ────────────────────────────────────────────────────
16
+ *
17
+ * `hostPlatform()` does not fall back to `process.platform`. It answers
18
+ * `"unsupported"` — a platform no backend in this package serves, on every
19
+ * machine, identically — whenever EITHER of two things is true:
20
+ *
21
+ * 1. A RUNNER IS INSTALLED. This is `@crewhaus/tool-pkgmgr`'s rule, and the
22
+ * primary guard. A test that has installed a recorded runner has already
23
+ * declared "I am not talking to this host"; if it then forgets to
24
+ * declare which host it is pretending to be, every backend refuses
25
+ * identically rather than taking the author's branch on macOS and a
26
+ * different one on a two-core Linux CI box.
27
+ * 2. THE REAL-HOST GATE IS SHUT (`run.ts`'s `_realHostAllowed`, false under
28
+ * `bun test`). The belt under the braces, for a test that never
29
+ * installed the seam at all. See `run.ts` for why this package is
30
+ * stricter than tool-pkgmgr about that case.
31
+ *
32
+ * That bug is not hypothetical: it cost this project a CI round in
33
+ * `tool-hostfs`, where one test relied on the ambient `process.platform`,
34
+ * passed on macOS and took a different branch on Linux. The cure is to make
35
+ * the ambient answer useless rather than to remember not to use it.
36
+ *
37
+ * `integration.test.ts` — the single test allowed near the real host — says so
38
+ * out loud by calling `_allowRealHost(true)`, and puts it back. It also
39
+ * asserts that the un-injected path then DOES read the real platform, so the
40
+ * guard above is provably a test-only condition rather than a package that
41
+ * refuses everywhere.
42
+ */
43
+ import { _realHostAllowed, _runnerInstalled } from "./run";
44
+ let platformOverride;
45
+ function classifyPlatform(raw) {
46
+ if (raw === "darwin" || raw === "linux" || raw === "win32")
47
+ return raw;
48
+ return "unsupported";
49
+ }
50
+ /** What `process.platform` actually says. Only the smoke test asks. */
51
+ export function realHostPlatform() {
52
+ return classifyPlatform(process.platform);
53
+ }
54
+ export function hostPlatform() {
55
+ if (platformOverride !== undefined)
56
+ return platformOverride;
57
+ if (_runnerInstalled())
58
+ return "unsupported";
59
+ return _realHostAllowed() ? classifyPlatform(process.platform) : "unsupported";
60
+ }
61
+ /** Test seam: pretend to be another OS. */
62
+ export function _setPlatform(platform) {
63
+ platformOverride = platform;
64
+ }
65
+ const SESSION_KEYS = [
66
+ "DISPLAY",
67
+ "WAYLAND_DISPLAY",
68
+ "XDG_RUNTIME_DIR",
69
+ "XDG_SESSION_TYPE",
70
+ "DBUS_SESSION_BUS_ADDRESS",
71
+ "SSH_CONNECTION",
72
+ "SSH_TTY",
73
+ ];
74
+ /** An environment with no desktop in it — the hostile default under test. */
75
+ export const HEADLESS_ENV = Object.freeze({
76
+ DISPLAY: undefined,
77
+ WAYLAND_DISPLAY: undefined,
78
+ XDG_RUNTIME_DIR: undefined,
79
+ XDG_SESSION_TYPE: undefined,
80
+ DBUS_SESSION_BUS_ADDRESS: undefined,
81
+ SSH_CONNECTION: undefined,
82
+ SSH_TTY: undefined,
83
+ });
84
+ let envOverride;
85
+ export function sessionEnv() {
86
+ if (envOverride !== undefined)
87
+ return envOverride;
88
+ if (_runnerInstalled() || !_realHostAllowed())
89
+ return HEADLESS_ENV;
90
+ const read = (key) => {
91
+ const value = process.env[key];
92
+ // An EMPTY `DISPLAY` is not a display. Treating `""` as set is how a
93
+ // headless host gets classified as an X11 session and then blocks on a
94
+ // clipboard read until its deadline.
95
+ return value === undefined || value === "" ? undefined : value;
96
+ };
97
+ const out = {};
98
+ for (const key of SESSION_KEYS)
99
+ out[key] = read(key);
100
+ return out;
101
+ }
102
+ export function _setSessionEnv(env) {
103
+ envOverride = env;
104
+ }
105
+ /** True when this process is talking to a machine over ssh, not sitting at it. */
106
+ export function isRemoteShell(env = sessionEnv()) {
107
+ return env.SSH_CONNECTION !== undefined || env.SSH_TTY !== undefined;
108
+ }
109
+ export function linuxSession(env = sessionEnv()) {
110
+ if (env.WAYLAND_DISPLAY !== undefined) {
111
+ if (env.XDG_RUNTIME_DIR === undefined) {
112
+ return {
113
+ kind: "wayland-unreachable",
114
+ reason: "WAYLAND_DISPLAY is set but XDG_RUNTIME_DIR is not, so the compositor socket cannot be located",
115
+ };
116
+ }
117
+ return {
118
+ kind: "wayland",
119
+ display: env.WAYLAND_DISPLAY,
120
+ runtimeDir: env.XDG_RUNTIME_DIR,
121
+ };
122
+ }
123
+ if (env.DISPLAY !== undefined)
124
+ return { kind: "x11", display: env.DISPLAY };
125
+ return {
126
+ kind: "none",
127
+ reason: "neither DISPLAY nor WAYLAND_DISPLAY is set in this process's environment, so there is no graphical session to reach",
128
+ };
129
+ }
130
+ /**
131
+ * The session variables a Linux desktop backend needs, forwarded explicitly.
132
+ *
133
+ * Explicitly, because `childEnv` copies nothing from `process.env` — and
134
+ * because an X11 client with no `DISPLAY` does not fail fast, it fails after
135
+ * a connection attempt or, on some builds, blocks until the deadline.
136
+ */
137
+ export function linuxDisplayEnv(env) {
138
+ const out = {};
139
+ if (env.DISPLAY !== undefined)
140
+ out["DISPLAY"] = env.DISPLAY;
141
+ if (env.WAYLAND_DISPLAY !== undefined)
142
+ out["WAYLAND_DISPLAY"] = env.WAYLAND_DISPLAY;
143
+ if (env.XDG_RUNTIME_DIR !== undefined)
144
+ out["XDG_RUNTIME_DIR"] = env.XDG_RUNTIME_DIR;
145
+ if (env.DBUS_SESSION_BUS_ADDRESS !== undefined) {
146
+ out["DBUS_SESSION_BUS_ADDRESS"] = env.DBUS_SESSION_BUS_ADDRESS;
147
+ }
148
+ return out;
149
+ }
150
+ let clockOverride;
151
+ /**
152
+ * Epoch milliseconds.
153
+ *
154
+ * `PowerAssertion` writes a deadline into a state file and reports when an
155
+ * assertion expires, so its whole contract is clock arithmetic. Reading
156
+ * `Date.now()` inside the tool would make every assertion about an expiry a
157
+ * race with the wall clock (house rule: never assert wall-clock timing —
158
+ * inject the clock instead).
159
+ */
160
+ export function now() {
161
+ return clockOverride === undefined ? Date.now() : clockOverride();
162
+ }
163
+ export function _setClock(clock) {
164
+ clockOverride = clock;
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // reset
168
+ // ---------------------------------------------------------------------------
169
+ /** Put every seam in this file back. Called from `afterEach`. */
170
+ export function _resetHostSeams() {
171
+ platformOverride = undefined;
172
+ envOverride = undefined;
173
+ clockOverride = undefined;
174
+ }
@@ -0,0 +1,21 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ import { type HostPlatform } from "./host";
3
+ import { type UnknownFact } from "./lib/outcome";
4
+ export { _setRunner, _setDetacher, _resetRunSeams, _allowRealHost } from "./run";
5
+ export type { Runner, Detacher, RunRequest, RunResult, DetachResult } from "./run";
6
+ export { _setPlatform, _setSessionEnv, _setClock, _resetHostSeams, realHostPlatform, HEADLESS_ENV, } from "./host";
7
+ export type { HostPlatform, SessionEnv } from "./host";
8
+ export { _setFs } from "./lib/fsseam";
9
+ export type { HostFs, PathFacts } from "./lib/fsseam";
10
+ export { ToolPermissionError } from "./paths";
11
+ export declare const clipboardRead: RegisteredTool;
12
+ export declare const clipboardWrite: RegisteredTool;
13
+ export declare const desktopNotify: RegisteredTool;
14
+ export declare const openExternal: RegisteredTool;
15
+ export declare const printDocument: RegisteredTool;
16
+ export declare const windowList: RegisteredTool;
17
+ export declare const userPresence: RegisteredTool;
18
+ export declare const powerAssertion: RegisteredTool;
19
+ /** Everything this package registers, in one place for the catalog wiring. */
20
+ export declare const DESKTOP_TOOLS: ReadonlyArray<RegisteredTool>;
21
+ export type { UnknownFact, HostPlatform as DesktopPlatform };