@homeflare/alchemy 0.5.0 → 0.6.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.
- package/README.md +17 -48
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +23 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1433 -305
- package/dist/openbao/index.js.map +34 -13
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/launchd.md +167 -0
- package/package.json +7 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +87 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four `launchctl` subcommands LaunchdJob uses — print, print-disabled, bootstrap, bootout —
|
|
3
|
+
* over a HostRunner, plus the parsers for the two read-only ones.
|
|
4
|
+
*
|
|
5
|
+
* ★ MEASURED 2026-09-21 ON macOS 27.2 (read-only `print` / `print-disabled` only; nothing was
|
|
6
|
+
* bootstrapped or booted out to write this):
|
|
7
|
+
* - `print system/<unknown>` exits **113** with "Could not find service … in domain for system"
|
|
8
|
+
* on stderr. 113 is therefore "not loaded", not an error.
|
|
9
|
+
* - `print` of a loaded job is a `<target> = {` block. Its own fields sit one tab deep
|
|
10
|
+
* (`\tstate = running`, `\tpid = 1219`, `\tlast exit code = 0` or `(never exited)`); nested
|
|
11
|
+
* blocks (`resource coalition`, `semaphores`) repeat names like `state` two tabs deep.
|
|
12
|
+
* - `print-disabled <domain>` lists `"label" => enabled|disabled`.
|
|
13
|
+
* - reading needs no root: a uid-501 shell printed system-domain jobs.
|
|
14
|
+
* - `print gui/<uid>/<label>` for a uid with NO LOGIN SESSION exits **112** with "Could not find
|
|
15
|
+
* domain for user gui: <uid>" (and so does `print-disabled gui/<uid>`); a malformed target is
|
|
16
|
+
* 64, a missing service in a live gui domain is 113. So 112 + that text is "the domain does
|
|
17
|
+
* not exist", which means the job cannot be loaded — not an error for a read or a delete.
|
|
18
|
+
* ⚠️ REASONED, NOT MEASURED: bootstrap/bootout exit codes. nix-darwin's own activation script on the
|
|
19
|
+
* same host notes "`bootout` on an already-absent job is a no-op that returns non-zero", which is
|
|
20
|
+
* why bootout here is always preceded by a print and followed by a poll, never trusted alone.
|
|
21
|
+
* ⛔ NEVER PUT `print` STDOUT IN AN ERROR. It contains the job's whole environment.
|
|
22
|
+
*/
|
|
23
|
+
import type { HostRunner } from './runner.ts';
|
|
24
|
+
|
|
25
|
+
export const LAUNCHCTL = '/bin/launchctl';
|
|
26
|
+
/** `launchctl print` of an unknown service — measured, see above. */
|
|
27
|
+
export const NOT_FOUND = 113;
|
|
28
|
+
/** `launchctl print` into a gui domain with no login session — measured, see above. */
|
|
29
|
+
export const NO_DOMAIN = 112;
|
|
30
|
+
const NO_DOMAIN_TEXT = 'Could not find domain';
|
|
31
|
+
|
|
32
|
+
export class LaunchctlError extends Error {
|
|
33
|
+
constructor(command: string, exitCode: number, stderr: string) {
|
|
34
|
+
super(`launchctl ${command} -> ${String(exitCode)}: ${stderr.trim().slice(0, 300)}`);
|
|
35
|
+
this.name = 'LaunchctlError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ServiceStatus = {
|
|
40
|
+
readonly loaded: boolean;
|
|
41
|
+
/** The domain itself is absent (a gui/<uid> with no login session). Implies `loaded: false`. */
|
|
42
|
+
readonly domainMissing?: true;
|
|
43
|
+
readonly state?: string;
|
|
44
|
+
readonly pid?: number;
|
|
45
|
+
readonly lastExitCode?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The job block's own `key = value` lines, ignoring nested blocks.
|
|
50
|
+
* ⚠️ A multi-line ProgramArguments entry is printed raw inside `arguments = {`, so a close is only
|
|
51
|
+
* accepted when its indentation matches the open. An argument that itself contains a line of
|
|
52
|
+
* exactly one tab and `}` would still desynchronise this; no real job has one.
|
|
53
|
+
*/
|
|
54
|
+
export const printFields = (stdout: string): Map<string, string> => {
|
|
55
|
+
const fields = new Map<string, string>();
|
|
56
|
+
const closers: string[] = [];
|
|
57
|
+
for (const line of stdout.split('\n')) {
|
|
58
|
+
if (closers.length === 0) {
|
|
59
|
+
if (fields.size === 0 && line.endsWith(' = {')) closers.push('}');
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (line === closers[closers.length - 1]) {
|
|
63
|
+
closers.pop();
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const opened = /^(\t+)\S.* = \{$/.exec(line);
|
|
67
|
+
if (opened?.[1] !== undefined && opened[1].length === closers.length) {
|
|
68
|
+
closers.push(`${opened[1]}}`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const field = closers.length === 1 ? /^\t([a-z][a-z ]*[a-z]) = (.*)$/.exec(line) : null;
|
|
72
|
+
if (field?.[1] !== undefined && field[2] !== undefined && !fields.has(field[1])) {
|
|
73
|
+
fields.set(field[1], field[2]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return fields;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const leadingInt = (text: string | undefined): number | undefined => {
|
|
80
|
+
const match = text === undefined ? null : /^(-?\d+)/.exec(text);
|
|
81
|
+
return match?.[1] === undefined ? undefined : Number(match[1]);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const parsePrint = (stdout: string): ServiceStatus => {
|
|
85
|
+
const fields = printFields(stdout);
|
|
86
|
+
const state = fields.get('state');
|
|
87
|
+
const pid = leadingInt(fields.get('pid'));
|
|
88
|
+
// "(never exited)" parses to undefined; "78: EX_CONFIG" to 78.
|
|
89
|
+
const lastExitCode = leadingInt(fields.get('last exit code'));
|
|
90
|
+
return {
|
|
91
|
+
loaded: true,
|
|
92
|
+
...(state === undefined ? {} : { state }),
|
|
93
|
+
...(pid === undefined ? {} : { pid }),
|
|
94
|
+
...(lastExitCode === undefined ? {} : { lastExitCode }),
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Labels `print-disabled` reports as disabled. Older releases printed `=> true` for disabled. */
|
|
99
|
+
export const parseDisabled = (stdout: string): Set<string> => {
|
|
100
|
+
const disabled = new Set<string>();
|
|
101
|
+
for (const match of stdout.matchAll(/^\s*"([^"]+)" => (\w+)\s*$/gm)) {
|
|
102
|
+
if (match[1] !== undefined && (match[2] === 'disabled' || match[2] === 'true')) {
|
|
103
|
+
disabled.add(match[1]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return disabled;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const printService = async (runner: HostRunner, target: string): Promise<ServiceStatus> => {
|
|
110
|
+
const result = await runner.exec([LAUNCHCTL, 'print', target]);
|
|
111
|
+
if (result.exitCode === NOT_FOUND) return { loaded: false };
|
|
112
|
+
// ⚠️ Without this, deleting an agent while its user is logged out fails on the read, and the
|
|
113
|
+
// plist stays in LaunchAgents — where launchd loads it again at that user's next login.
|
|
114
|
+
if (result.exitCode === NO_DOMAIN && result.stderr.includes(NO_DOMAIN_TEXT)) {
|
|
115
|
+
return { domainMissing: true, loaded: false };
|
|
116
|
+
}
|
|
117
|
+
if (result.exitCode !== 0)
|
|
118
|
+
throw new LaunchctlError(`print ${target}`, result.exitCode, result.stderr);
|
|
119
|
+
return parsePrint(result.stdout);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const isDisabled = async (
|
|
123
|
+
runner: HostRunner,
|
|
124
|
+
domain: string,
|
|
125
|
+
label: string,
|
|
126
|
+
): Promise<boolean> => {
|
|
127
|
+
const result = await runner.exec([LAUNCHCTL, 'print-disabled', domain]);
|
|
128
|
+
if (result.exitCode !== 0) {
|
|
129
|
+
throw new LaunchctlError(`print-disabled ${domain}`, result.exitCode, result.stderr);
|
|
130
|
+
}
|
|
131
|
+
return parseDisabled(result.stdout).has(label);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const bootstrap = async (
|
|
135
|
+
runner: HostRunner,
|
|
136
|
+
domain: string,
|
|
137
|
+
plistPath: string,
|
|
138
|
+
): Promise<void> => {
|
|
139
|
+
const result = await runner.exec([LAUNCHCTL, 'bootstrap', domain, plistPath]);
|
|
140
|
+
if (result.exitCode !== 0) {
|
|
141
|
+
throw new LaunchctlError(`bootstrap ${domain} ${plistPath}`, result.exitCode, result.stderr);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Poll `print` until the service is gone. ⚠️ bootout can return before launchd lets go of it.
|
|
147
|
+
* ★ 30 s, because a job gets SIGTERM and then its ExitTimeOut before SIGKILL: launchd.plist(5)
|
|
148
|
+
* documents a 20 s default (a macOS 27.2 `print` showed `exit timeout = 5` for a job that sets
|
|
149
|
+
* none). Giving up sooner would fail a deploy over a job that was, correctly, still exiting.
|
|
150
|
+
*/
|
|
151
|
+
export const waitUnloaded = async (
|
|
152
|
+
runner: HostRunner,
|
|
153
|
+
target: string,
|
|
154
|
+
attempts = 120,
|
|
155
|
+
intervalMs = 250,
|
|
156
|
+
): Promise<void> => {
|
|
157
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
158
|
+
if (!(await printService(runner, target)).loaded) return;
|
|
159
|
+
await runner.sleep(intervalMs);
|
|
160
|
+
}
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${target} is still loaded ${String((attempts * intervalMs) / 1000)}s after bootout`,
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Boot a service out if it is loaded, and wait until launchd has let go. Idempotent. */
|
|
167
|
+
export const bootoutIfLoaded = async (runner: HostRunner, target: string): Promise<void> => {
|
|
168
|
+
if (!(await printService(runner, target)).loaded) return;
|
|
169
|
+
const result = await runner.exec([LAUNCHCTL, 'bootout', target]);
|
|
170
|
+
/**
|
|
171
|
+
* ⚠️ A non-zero bootout is only an error if the service is STILL there after the wait (see
|
|
172
|
+
* header) — so wait FIRST, then judge. A job slower to exit than launchctl's own wait is widely
|
|
173
|
+
* reported to fail bootout with `36: Operation now in progress` (EINPROGRESS) while it is
|
|
174
|
+
* still going away; REASONED from those reports, not measured here. Judging on an immediate
|
|
175
|
+
* print would fail the deploy with the new plist written and the old job already exiting —
|
|
176
|
+
* the job ends up down either way, and the error would blame the wrong step.
|
|
177
|
+
*/
|
|
178
|
+
try {
|
|
179
|
+
await waitUnloaded(runner, target);
|
|
180
|
+
} catch (cause) {
|
|
181
|
+
if (result.exitCode === 0) throw cause;
|
|
182
|
+
throw new LaunchctlError(
|
|
183
|
+
`bootout ${target}`,
|
|
184
|
+
result.exitCode,
|
|
185
|
+
`${result.stderr.trim()} (and still loaded after waiting)`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real runner, confined to a fresh temp directory. It never runs launchctl and never writes
|
|
3
|
+
* outside `mkdtemp`; the only programs it spawns are echo/false and a read-only user lookup.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
6
|
+
import { mkdtemp, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { localRunner } from './local-runner.ts';
|
|
10
|
+
|
|
11
|
+
const runner = localRunner();
|
|
12
|
+
let dir = '';
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
dir = await mkdtemp(join(tmpdir(), 'hf-local-runner-'));
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await rm(dir, { force: true, recursive: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const bytes = (text: string) => new TextEncoder().encode(text);
|
|
22
|
+
|
|
23
|
+
describe('writeFileAtomic', () => {
|
|
24
|
+
test('writes the bytes with the declared mode and leaves no temp file behind', async () => {
|
|
25
|
+
const path = join(dir, 'a.conf');
|
|
26
|
+
await runner.writeFileAtomic(path, bytes('one\n'), { mode: 0o640 });
|
|
27
|
+
expect(await Bun.file(path).text()).toBe('one\n');
|
|
28
|
+
expect((await stat(path)).mode & 0o7777).toBe(0o640);
|
|
29
|
+
expect(await readdir(dir)).toEqual(['a.conf']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('replaces by rename — a new inode, never an in-place rewrite', async () => {
|
|
33
|
+
const path = join(dir, 'a.conf');
|
|
34
|
+
await runner.writeFileAtomic(path, bytes('one\n'), { mode: 0o644 });
|
|
35
|
+
const before = (await stat(path)).ino;
|
|
36
|
+
await runner.writeFileAtomic(path, bytes('two\n'), { mode: 0o644 });
|
|
37
|
+
expect(await Bun.file(path).text()).toBe('two\n');
|
|
38
|
+
expect((await stat(path)).ino).not.toBe(before);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a missing parent directory fails and creates nothing', async () => {
|
|
42
|
+
await expect(
|
|
43
|
+
runner.writeFileAtomic(join(dir, 'no/such/a'), bytes('x'), { mode: 0o644 }),
|
|
44
|
+
).rejects.toThrow();
|
|
45
|
+
expect(await readdir(dir)).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test.skipIf(process.geteuid?.() === 0)('a refused chown cleans up its temp file', async () => {
|
|
49
|
+
const path = join(dir, 'owned');
|
|
50
|
+
await expect(
|
|
51
|
+
runner.writeFileAtomic(path, bytes('x'), { mode: 0o644, uid: 0 }),
|
|
52
|
+
).rejects.toThrow();
|
|
53
|
+
expect(await readdir(dir)).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('chowning to yourself is allowed, and is how an agent plist is written', async () => {
|
|
57
|
+
const path = join(dir, 'mine');
|
|
58
|
+
await runner.writeFileAtomic(path, bytes('x'), { mode: 0o600, uid: runner.effectiveUid() });
|
|
59
|
+
expect((await stat(path)).uid).toBe(runner.effectiveUid());
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('read, stat, remove', () => {
|
|
64
|
+
test('absent paths read as undefined, including under a file', async () => {
|
|
65
|
+
expect(await runner.readFile(join(dir, 'none'))).toBeUndefined();
|
|
66
|
+
expect(await runner.stat(join(dir, 'none'))).toBeUndefined();
|
|
67
|
+
await writeFile(join(dir, 'f'), 'x');
|
|
68
|
+
expect(await runner.stat(join(dir, 'f', 'under'))).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('stat is lstat: a symlink is reported as one, and mode carries no type bits', async () => {
|
|
72
|
+
await writeFile(join(dir, 'target'), 'x', { mode: 0o600 });
|
|
73
|
+
await symlink(join(dir, 'target'), join(dir, 'link'));
|
|
74
|
+
expect((await runner.stat(join(dir, 'link')))?.kind).toBe('symlink');
|
|
75
|
+
expect(await runner.stat(join(dir, 'target'))).toMatchObject({
|
|
76
|
+
kind: 'file',
|
|
77
|
+
mode: 0o600,
|
|
78
|
+
size: 1,
|
|
79
|
+
});
|
|
80
|
+
expect((await runner.stat(dir))?.kind).toBe('directory');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('readFile returns the bytes; removeFile is idempotent', async () => {
|
|
84
|
+
await writeFile(join(dir, 'f'), 'hello');
|
|
85
|
+
expect(new TextDecoder().decode(await runner.readFile(join(dir, 'f')))).toBe('hello');
|
|
86
|
+
await runner.removeFile(join(dir, 'f'));
|
|
87
|
+
await runner.removeFile(join(dir, 'f'));
|
|
88
|
+
expect(await readdir(dir)).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('exec', () => {
|
|
93
|
+
test('argv is passed verbatim — no shell ever sees it', async () => {
|
|
94
|
+
const result = await runner.exec(['/bin/echo', '$HOME;`id`|*']);
|
|
95
|
+
expect(result).toEqual({ exitCode: 0, stderr: '', stdout: '$HOME;`id`|*\n' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a non-zero exit is a result, not a throw', async () => {
|
|
99
|
+
expect((await runner.exec(['/usr/bin/false'])).exitCode).not.toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a program that does not exist rejects with its name', async () => {
|
|
103
|
+
await expect(runner.exec([join(dir, 'missing')])).rejects.toThrow('missing');
|
|
104
|
+
await expect(runner.exec([])).rejects.toThrow('empty argv');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('never privileged, and reports the real euid', () => {
|
|
108
|
+
expect(runner.privileged).toBe(false);
|
|
109
|
+
expect(runner.effectiveUid()).toBe(process.geteuid?.() ?? -1);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe.skipIf(!['darwin', 'linux'].includes(process.platform))('lookups (read-only)', () => {
|
|
114
|
+
test('the current user resolves by uid, with a home directory', async () => {
|
|
115
|
+
const uid = process.getuid?.() ?? -1;
|
|
116
|
+
const user = await runner.lookupUser(String(uid));
|
|
117
|
+
expect(user?.uid).toBe(uid);
|
|
118
|
+
expect(user?.home.startsWith('/')).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('an unknown user and group are undefined, not errors', async () => {
|
|
122
|
+
expect(await runner.lookupUser('hf-no-such-user-xyz')).toBeUndefined();
|
|
123
|
+
expect(await runner.lookupGroup('hf-no-such-group-xyz')).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real HostRunner: this process, this machine, this uid.
|
|
3
|
+
*
|
|
4
|
+
* ★ node:fs AND node:child_process, NOT Bun APIs. An Alchemy stack usually runs under bun, but
|
|
5
|
+
* the published dist also loads under node (the package's smoke test and the kit's AGENTS.md:
|
|
6
|
+
* consumers run dist/, not src/), and both runtimes implement these modules.
|
|
7
|
+
* ⛔ NO ELEVATION, EVER. `privileged` is false; a system-domain write succeeds only when the whole
|
|
8
|
+
* deploy was started as root. See the ⛔ on HostRunner.privileged for why nothing here calls sudo.
|
|
9
|
+
* ⛔ NO SHELL. `exec` spawns argv directly, so a label, path or argument can never be re-parsed
|
|
10
|
+
* as shell syntax.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
15
|
+
import { basename, dirname, join } from 'node:path';
|
|
16
|
+
import { groupQuery, parseGroup, parseUser, userQuery } from './host-lookup.ts';
|
|
17
|
+
import type { ExecResult, FileStat, HostRunner, WriteOptions } from './runner.ts';
|
|
18
|
+
|
|
19
|
+
export type LocalRunnerOptions = {
|
|
20
|
+
/** Kill a spawned program after this long. @default 120_000 */
|
|
21
|
+
readonly execTimeoutMs?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const code = (cause: unknown): string | undefined =>
|
|
25
|
+
typeof cause === 'object' && cause !== null && 'code' in cause
|
|
26
|
+
? String((cause as { code: unknown }).code)
|
|
27
|
+
: undefined;
|
|
28
|
+
|
|
29
|
+
/** ENOENT and ENOTDIR both mean "nothing is at this path". */
|
|
30
|
+
const absent = (cause: unknown): boolean => code(cause) === 'ENOENT' || code(cause) === 'ENOTDIR';
|
|
31
|
+
|
|
32
|
+
const run = (argv: readonly string[], timeoutMs: number): Promise<ExecResult> =>
|
|
33
|
+
new Promise((resolve, reject) => {
|
|
34
|
+
const [program, ...args] = argv;
|
|
35
|
+
if (program === undefined) {
|
|
36
|
+
reject(new Error('exec: empty argv'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const child = spawn(program, args, {
|
|
40
|
+
shell: false,
|
|
41
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
42
|
+
timeout: timeoutMs,
|
|
43
|
+
});
|
|
44
|
+
const out: Buffer[] = [];
|
|
45
|
+
const err: Buffer[] = [];
|
|
46
|
+
child.stdout.on('data', (chunk: Buffer) => out.push(chunk));
|
|
47
|
+
child.stderr.on('data', (chunk: Buffer) => err.push(chunk));
|
|
48
|
+
child.on('error', (cause) => {
|
|
49
|
+
reject(new Error(`exec ${program}: ${cause.message}`));
|
|
50
|
+
});
|
|
51
|
+
child.on('close', (exitCode, signal) => {
|
|
52
|
+
const stderr = Buffer.concat(err).toString('utf8');
|
|
53
|
+
resolve({
|
|
54
|
+
// ⚠️ A signal (including our own timeout) has no exit code; -1 keeps it a failure.
|
|
55
|
+
exitCode: exitCode ?? -1,
|
|
56
|
+
stderr: signal === null ? stderr : `${stderr}\n(killed by ${signal})`.trim(),
|
|
57
|
+
stdout: Buffer.concat(out).toString('utf8'),
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const kindOf = (stats: Awaited<ReturnType<typeof lstat>>): FileStat['kind'] => {
|
|
63
|
+
if (stats.isSymbolicLink()) return 'symlink';
|
|
64
|
+
if (stats.isFile()) return 'file';
|
|
65
|
+
if (stats.isDirectory()) return 'directory';
|
|
66
|
+
return 'other';
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const writeAtomic = async (path: string, bytes: Uint8Array, options: WriteOptions) => {
|
|
70
|
+
// ★ Same directory as the target, so rename(2) never crosses a filesystem (which would EXDEV).
|
|
71
|
+
const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString('hex')}.tmp`);
|
|
72
|
+
// ⛔ 'wx' = O_CREAT|O_EXCL, and 0600 until the final mode: the temp file is never readable by
|
|
73
|
+
// anyone else in the moment before its mode is set, and never follows a planted symlink.
|
|
74
|
+
const handle = await open(temp, 'wx', 0o600);
|
|
75
|
+
try {
|
|
76
|
+
try {
|
|
77
|
+
await handle.writeFile(bytes);
|
|
78
|
+
// ⚠️ chown BEFORE chmod: a chown by root clears setuid/setgid bits, so the other order
|
|
79
|
+
// would silently drop a declared 4755.
|
|
80
|
+
if (options.uid !== undefined || options.gid !== undefined) {
|
|
81
|
+
await handle.chown(options.uid ?? -1, options.gid ?? -1);
|
|
82
|
+
}
|
|
83
|
+
await handle.chmod(options.mode);
|
|
84
|
+
await handle.sync();
|
|
85
|
+
} finally {
|
|
86
|
+
await handle.close();
|
|
87
|
+
}
|
|
88
|
+
await rename(temp, path);
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
await unlink(temp).catch(() => undefined);
|
|
91
|
+
throw cause;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
|
|
96
|
+
const timeoutMs = options.execTimeoutMs ?? 120_000;
|
|
97
|
+
const platform = process.platform;
|
|
98
|
+
return {
|
|
99
|
+
effectiveUid: () => process.geteuid?.() ?? -1,
|
|
100
|
+
exec: (argv) => run(argv, timeoutMs),
|
|
101
|
+
lookupGroup: async (nameOrId) => {
|
|
102
|
+
const result = await run(groupQuery(platform, nameOrId), timeoutMs);
|
|
103
|
+
return result.exitCode === 0 ? parseGroup(platform, result.stdout) : undefined;
|
|
104
|
+
},
|
|
105
|
+
lookupUser: async (nameOrId) => {
|
|
106
|
+
const result = await run(userQuery(platform, nameOrId), timeoutMs);
|
|
107
|
+
return result.exitCode === 0 ? parseUser(platform, result.stdout) : undefined;
|
|
108
|
+
},
|
|
109
|
+
privileged: false,
|
|
110
|
+
readFile: async (path) => {
|
|
111
|
+
try {
|
|
112
|
+
return new Uint8Array(await readFile(path));
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
if (absent(cause)) return undefined;
|
|
115
|
+
throw cause;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
removeFile: async (path) => {
|
|
119
|
+
try {
|
|
120
|
+
await unlink(path);
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
if (!absent(cause)) throw cause;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
126
|
+
stat: async (path) => {
|
|
127
|
+
try {
|
|
128
|
+
const stats = await lstat(path);
|
|
129
|
+
return {
|
|
130
|
+
gid: stats.gid,
|
|
131
|
+
kind: kindOf(stats),
|
|
132
|
+
mode: stats.mode & 0o7777,
|
|
133
|
+
size: stats.size,
|
|
134
|
+
uid: stats.uid,
|
|
135
|
+
};
|
|
136
|
+
} catch (cause) {
|
|
137
|
+
if (absent(cause)) return undefined;
|
|
138
|
+
throw cause;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
writeFileAtomic: writeAtomic,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plist serializer, case by case — and, on macOS, every case round-tripped through Apple's own
|
|
3
|
+
* `plutil`, so "valid plist" is Apple's verdict rather than this file's opinion.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ plutil only READS here: it converts a file in a fresh temp directory to JSON on stdout.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { type PlistDict, PlistError, escapeText, renderPlist } from './plist.ts';
|
|
12
|
+
|
|
13
|
+
const body = (dict: PlistDict) => {
|
|
14
|
+
const text = renderPlist(dict);
|
|
15
|
+
return text.slice(text.indexOf('<dict'), text.lastIndexOf('</plist>')).trimEnd();
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('renderPlist', () => {
|
|
19
|
+
test('a complete document: XML declaration, Apple DOCTYPE, plist 1.0, trailing newline', () => {
|
|
20
|
+
const text = renderPlist({ Label: 'com.example.job' });
|
|
21
|
+
expect(text.startsWith('<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC')).toBe(
|
|
22
|
+
true,
|
|
23
|
+
);
|
|
24
|
+
expect(text).toContain(
|
|
25
|
+
'<plist version="1.0">\n<dict>\n\t<key>Label</key>\n\t<string>com.example.job</string>\n</dict>\n</plist>\n',
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('strings, integers and booleans', () => {
|
|
30
|
+
expect(body({ a: 'x', b: 42, c: -7, d: 0, e: true, f: false })).toBe(
|
|
31
|
+
[
|
|
32
|
+
'<dict>',
|
|
33
|
+
'\t<key>a</key>',
|
|
34
|
+
'\t<string>x</string>',
|
|
35
|
+
'\t<key>b</key>',
|
|
36
|
+
'\t<integer>42</integer>',
|
|
37
|
+
'\t<key>c</key>',
|
|
38
|
+
'\t<integer>-7</integer>',
|
|
39
|
+
'\t<key>d</key>',
|
|
40
|
+
'\t<integer>0</integer>',
|
|
41
|
+
'\t<key>e</key>',
|
|
42
|
+
'\t<true/>',
|
|
43
|
+
'\t<key>f</key>',
|
|
44
|
+
'\t<false/>',
|
|
45
|
+
'</dict>',
|
|
46
|
+
].join('\n'),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('negative zero renders as 0', () => {
|
|
51
|
+
expect(body({ z: -0 })).toContain('<integer>0</integer>');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('arrays and nested dicts indent one tab per level', () => {
|
|
55
|
+
expect(body({ args: ['/bin/sh', '-c'], keep: { SuccessfulExit: false } })).toBe(
|
|
56
|
+
[
|
|
57
|
+
'<dict>',
|
|
58
|
+
'\t<key>args</key>',
|
|
59
|
+
'\t<array>',
|
|
60
|
+
'\t\t<string>/bin/sh</string>',
|
|
61
|
+
'\t\t<string>-c</string>',
|
|
62
|
+
'\t</array>',
|
|
63
|
+
'\t<key>keep</key>',
|
|
64
|
+
'\t<dict>',
|
|
65
|
+
'\t\t<key>SuccessfulExit</key>',
|
|
66
|
+
'\t\t<false/>',
|
|
67
|
+
'\t</dict>',
|
|
68
|
+
'</dict>',
|
|
69
|
+
].join('\n'),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('empty containers self-close', () => {
|
|
74
|
+
expect(body({ a: [], d: {} })).toBe(
|
|
75
|
+
'<dict>\n\t<key>a</key>\n\t<array/>\n\t<key>d</key>\n\t<dict/>\n</dict>',
|
|
76
|
+
);
|
|
77
|
+
expect(body({})).toBe('<dict/>');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('dicts in arrays (StartCalendarInterval)', () => {
|
|
81
|
+
expect(body({ s: [{ Hour: 3 }, { Minute: 5 }] })).toContain(
|
|
82
|
+
'\t<array>\n\t\t<dict>\n\t\t\t<key>Hour</key>\n\t\t\t<integer>3</integer>\n\t\t</dict>',
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('keys are sorted, so insertion order never changes the bytes', () => {
|
|
87
|
+
expect(renderPlist({ b: 1, a: 2, C: 3 })).toBe(renderPlist({ C: 3, a: 2, b: 1 }));
|
|
88
|
+
expect(body({ b: 1, a: 2, C: 3 }).indexOf('<key>C</key>')).toBeLessThan(
|
|
89
|
+
body({ b: 1, a: 2, C: 3 }).indexOf('<key>a</key>'),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('escaping', () => {
|
|
95
|
+
test('&, < and > are escaped in values and keys', () => {
|
|
96
|
+
expect(body({ 'a&b': '<x> & "y" \'z\'' })).toContain(
|
|
97
|
+
'<key>a&b</key>\n\t<string><x> & "y" \'z\'</string>',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a would-be CDATA end or entity is inert', () => {
|
|
102
|
+
expect(escapeText(']]>&', 'p')).toBe(']]>&amp;');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('CR becomes a character reference; tab and LF stay literal', () => {
|
|
106
|
+
expect(escapeText('a\r\nb\tc', 'p')).toBe('a \nb\tc');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('non-ASCII and astral characters pass through as UTF-8', () => {
|
|
110
|
+
expect(escapeText('café — 日本 \u{1F600}', 'p')).toBe('café — 日本 \u{1F600}');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test.each([
|
|
114
|
+
['NUL', '\x00'],
|
|
115
|
+
['BEL', '\x07'],
|
|
116
|
+
['VT', '\x0B'],
|
|
117
|
+
['ESC', '\x1B'],
|
|
118
|
+
['U+FFFE', String.fromCharCode(0xfffe)],
|
|
119
|
+
['U+FFFF', String.fromCharCode(0xffff)],
|
|
120
|
+
['a lone high surrogate', String.fromCharCode(0xd83d)],
|
|
121
|
+
['a lone low surrogate', `a${String.fromCharCode(0xde00)}`],
|
|
122
|
+
])('%s is refused with the path, not dropped', (_name, bad) => {
|
|
123
|
+
expect(() => renderPlist({ outer: { inner: [`ok${bad}`] } })).toThrow(PlistError);
|
|
124
|
+
expect(() => renderPlist({ outer: { inner: [`ok${bad}`] } })).toThrow('outer.inner[0]');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('refusals', () => {
|
|
129
|
+
test.each([
|
|
130
|
+
['a fraction', 1.5],
|
|
131
|
+
['NaN', Number.NaN],
|
|
132
|
+
['Infinity', Number.POSITIVE_INFINITY],
|
|
133
|
+
['an unsafe integer', 2 ** 60],
|
|
134
|
+
['undefined', undefined],
|
|
135
|
+
['null', null],
|
|
136
|
+
['a Date', new Date(0)],
|
|
137
|
+
['a function', () => 1],
|
|
138
|
+
['a bigint', 1n],
|
|
139
|
+
])('%s', (_name, value) => {
|
|
140
|
+
expect(() => renderPlist({ k: value } as unknown as PlistDict)).toThrow(PlistError);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a non-dict top level', () => {
|
|
144
|
+
expect(() => renderPlist([] as unknown as PlistDict)).toThrow('top level');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a cycle stops at the depth cap instead of overflowing the stack', () => {
|
|
148
|
+
const loop: Record<string, unknown> = {};
|
|
149
|
+
loop['self'] = loop;
|
|
150
|
+
expect(() => renderPlist(loop as PlistDict)).toThrow('nested deeper');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const plutil = '/usr/bin/plutil';
|
|
155
|
+
const onMac = process.platform === 'darwin' && (await Bun.file(plutil).exists());
|
|
156
|
+
|
|
157
|
+
describe.skipIf(!onMac)("Apple's plutil reads it back identically", () => {
|
|
158
|
+
const cases: Record<string, PlistDict> = {
|
|
159
|
+
scalars: { s: 'x', i: 42, n: -7, t: true, f: false },
|
|
160
|
+
nested: { a: ['/bin/sh', '-c', 'exec "$prog"'], d: { SuccessfulExit: false }, e: [], o: {} },
|
|
161
|
+
special: { 'a&b<c>': '<&> "q" \'a\' ]]> tab\there\nnewline\r\ncrlf café \u{1F600}' },
|
|
162
|
+
launchd: {
|
|
163
|
+
Label: 'com.example.job',
|
|
164
|
+
ProgramArguments: ['/usr/bin/true'],
|
|
165
|
+
StartCalendarInterval: [{ Hour: 3, Minute: 30 }, { Weekday: 0 }],
|
|
166
|
+
KeepAlive: { SuccessfulExit: false },
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
test.each(Object.entries(cases))('%s', async (_name, dict) => {
|
|
171
|
+
const dir = await mkdtemp(join(tmpdir(), 'hf-plist-'));
|
|
172
|
+
try {
|
|
173
|
+
const file = join(dir, 'case.plist');
|
|
174
|
+
await writeFile(file, renderPlist(dict));
|
|
175
|
+
const proc = Bun.spawn([plutil, '-convert', 'json', '-o', '-', file], {
|
|
176
|
+
stderr: 'pipe',
|
|
177
|
+
stdout: 'pipe',
|
|
178
|
+
});
|
|
179
|
+
const out = await new Response(proc.stdout).text();
|
|
180
|
+
expect(await proc.exited).toBe(0);
|
|
181
|
+
expect(JSON.parse(out)).toEqual(dict);
|
|
182
|
+
} finally {
|
|
183
|
+
await rm(dir, { force: true, recursive: true });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|