@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.
Files changed (180) hide show
  1. package/README.md +17 -48
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/launchd/fake-runner.d.ts +36 -0
  5. package/dist/launchd/fake-runner.d.ts.map +1 -0
  6. package/dist/launchd/host-effect.d.ts +19 -0
  7. package/dist/launchd/host-effect.d.ts.map +1 -0
  8. package/dist/launchd/host-file-form.d.ts +24 -0
  9. package/dist/launchd/host-file-form.d.ts.map +1 -0
  10. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  11. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  12. package/dist/launchd/host-file.d.ts +25 -0
  13. package/dist/launchd/host-file.d.ts.map +1 -0
  14. package/dist/launchd/host-lookup.d.ts +27 -0
  15. package/dist/launchd/host-lookup.d.ts.map +1 -0
  16. package/dist/launchd/index.d.ts +21 -0
  17. package/dist/launchd/index.d.ts.map +1 -0
  18. package/dist/launchd/index.js +969 -0
  19. package/dist/launchd/index.js.map +25 -0
  20. package/dist/launchd/job-form.d.ts +90 -0
  21. package/dist/launchd/job-form.d.ts.map +1 -0
  22. package/dist/launchd/job-lifecycle.d.ts +34 -0
  23. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  24. package/dist/launchd/job-preflight.d.ts +50 -0
  25. package/dist/launchd/job-preflight.d.ts.map +1 -0
  26. package/dist/launchd/job-validate.d.ts +25 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -0
  28. package/dist/launchd/job.d.ts +34 -0
  29. package/dist/launchd/job.d.ts.map +1 -0
  30. package/dist/launchd/launchctl.d.ts +62 -0
  31. package/dist/launchd/launchctl.d.ts.map +1 -0
  32. package/dist/launchd/local-runner.d.ts +7 -0
  33. package/dist/launchd/local-runner.d.ts.map +1 -0
  34. package/dist/launchd/plist.d.ts +41 -0
  35. package/dist/launchd/plist.d.ts.map +1 -0
  36. package/dist/launchd/providers.d.ts +15 -0
  37. package/dist/launchd/providers.d.ts.map +1 -0
  38. package/dist/launchd/runner.d.ts +91 -0
  39. package/dist/launchd/runner.d.ts.map +1 -0
  40. package/dist/launchd/secret-tripwire.d.ts +23 -0
  41. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  42. package/dist/openbao/auth-method-form.d.ts +7 -0
  43. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  45. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  46. package/dist/openbao/auth-method.d.ts +6 -0
  47. package/dist/openbao/auth-method.d.ts.map +1 -1
  48. package/dist/openbao/auth-role-form.d.ts +9 -0
  49. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-role.d.ts.map +1 -1
  51. package/dist/openbao/auth-token-form.d.ts +48 -0
  52. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  53. package/dist/openbao/bao-identity.d.ts +81 -0
  54. package/dist/openbao/bao-identity.d.ts.map +1 -0
  55. package/dist/openbao/bao-namespace.d.ts +20 -0
  56. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  57. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  58. package/dist/openbao/host-approles.d.ts +62 -0
  59. package/dist/openbao/host-approles.d.ts.map +1 -0
  60. package/dist/openbao/index.d.ts +19 -0
  61. package/dist/openbao/index.d.ts.map +1 -1
  62. package/dist/openbao/index.js +1433 -305
  63. package/dist/openbao/index.js.map +34 -13
  64. package/dist/openbao/jwt-config-form.d.ts +51 -0
  65. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  66. package/dist/openbao/jwt-config.d.ts +25 -0
  67. package/dist/openbao/jwt-config.d.ts.map +1 -0
  68. package/dist/openbao/jwt-role-form.d.ts +91 -0
  69. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  70. package/dist/openbao/jwt-role.d.ts +31 -0
  71. package/dist/openbao/jwt-role.d.ts.map +1 -0
  72. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  73. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  74. package/dist/openbao/kubernetes-role.d.ts +25 -0
  75. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  76. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  77. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  78. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  79. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  80. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  81. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  82. package/dist/openbao/mfa-totp.d.ts +33 -0
  83. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  84. package/dist/openbao/mfa-wire.d.ts +63 -0
  85. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  86. package/dist/openbao/mount-form.d.ts +7 -0
  87. package/dist/openbao/mount-form.d.ts.map +1 -1
  88. package/dist/openbao/mount-move.d.ts +49 -0
  89. package/dist/openbao/mount-move.d.ts.map +1 -0
  90. package/dist/openbao/mount-path.d.ts +8 -4
  91. package/dist/openbao/mount-path.d.ts.map +1 -1
  92. package/dist/openbao/mount-reconcile.d.ts +16 -0
  93. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/mount.d.ts +9 -0
  95. package/dist/openbao/mount.d.ts.map +1 -1
  96. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  97. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  98. package/dist/openbao/pki-role-form.d.ts +38 -30
  99. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  100. package/dist/openbao/pki-role.d.ts.map +1 -1
  101. package/dist/openbao/remount-wire.d.ts +44 -0
  102. package/dist/openbao/remount-wire.d.ts.map +1 -0
  103. package/dist/openbao/role-reconcile.d.ts +40 -0
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  105. package/docs/launchd.md +167 -0
  106. package/package.json +7 -2
  107. package/src/index.ts +1 -0
  108. package/src/launchd/fake-runner.ts +176 -0
  109. package/src/launchd/host-effect.ts +28 -0
  110. package/src/launchd/host-file-form.ts +71 -0
  111. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  112. package/src/launchd/host-file-lifecycle.ts +164 -0
  113. package/src/launchd/host-file.ts +64 -0
  114. package/src/launchd/host-lookup.test.ts +92 -0
  115. package/src/launchd/host-lookup.ts +94 -0
  116. package/src/launchd/index.ts +26 -0
  117. package/src/launchd/job-form.test.ts +171 -0
  118. package/src/launchd/job-form.ts +180 -0
  119. package/src/launchd/job-identity.test.ts +147 -0
  120. package/src/launchd/job-lifecycle.test.ts +220 -0
  121. package/src/launchd/job-lifecycle.ts +194 -0
  122. package/src/launchd/job-preflight.ts +152 -0
  123. package/src/launchd/job-validate.ts +134 -0
  124. package/src/launchd/job.ts +96 -0
  125. package/src/launchd/launchctl.test.ts +181 -0
  126. package/src/launchd/launchctl.ts +188 -0
  127. package/src/launchd/local-runner.test.ts +125 -0
  128. package/src/launchd/local-runner.ts +143 -0
  129. package/src/launchd/plist.test.ts +186 -0
  130. package/src/launchd/plist.ts +156 -0
  131. package/src/launchd/providers.test.ts +222 -0
  132. package/src/launchd/providers.ts +21 -0
  133. package/src/launchd/runner.ts +95 -0
  134. package/src/launchd/secret-tripwire.test.ts +110 -0
  135. package/src/launchd/secret-tripwire.ts +81 -0
  136. package/src/openbao/README.md +181 -0
  137. package/src/openbao/REPLACE.md +87 -0
  138. package/src/openbao/auth-method-form.ts +7 -0
  139. package/src/openbao/auth-method-reconcile.ts +74 -0
  140. package/src/openbao/auth-method.ts +20 -41
  141. package/src/openbao/auth-role-form.test.ts +16 -1
  142. package/src/openbao/auth-role-form.ts +11 -0
  143. package/src/openbao/auth-role.ts +12 -0
  144. package/src/openbao/auth-token-form.ts +161 -0
  145. package/src/openbao/bao-identity.test.ts +131 -0
  146. package/src/openbao/bao-identity.ts +188 -0
  147. package/src/openbao/bao-namespace.ts +32 -0
  148. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  149. package/src/openbao/host-approles.test.ts +121 -0
  150. package/src/openbao/host-approles.ts +126 -0
  151. package/src/openbao/index.ts +19 -0
  152. package/src/openbao/jwt-config-form.ts +161 -0
  153. package/src/openbao/jwt-config.ts +85 -0
  154. package/src/openbao/jwt-role-form.test.ts +130 -0
  155. package/src/openbao/jwt-role-form.ts +231 -0
  156. package/src/openbao/jwt-role.ts +102 -0
  157. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  158. package/src/openbao/kubernetes-role-form.ts +149 -0
  159. package/src/openbao/kubernetes-role.ts +94 -0
  160. package/src/openbao/mfa-enforcement-form.ts +147 -0
  161. package/src/openbao/mfa-enforcement.ts +143 -0
  162. package/src/openbao/mfa-totp-form.ts +155 -0
  163. package/src/openbao/mfa-totp.ts +118 -0
  164. package/src/openbao/mfa-wire.ts +117 -0
  165. package/src/openbao/mfa.test.ts +200 -0
  166. package/src/openbao/mount-form.ts +9 -1
  167. package/src/openbao/mount-move.test.ts +164 -0
  168. package/src/openbao/mount-move.ts +113 -0
  169. package/src/openbao/mount-path.test.ts +11 -1
  170. package/src/openbao/mount-path.ts +17 -11
  171. package/src/openbao/mount-reconcile.ts +75 -0
  172. package/src/openbao/mount.ts +26 -43
  173. package/src/openbao/pki-role-canonical.ts +163 -0
  174. package/src/openbao/pki-role-form.test.ts +112 -0
  175. package/src/openbao/pki-role-form.ts +76 -110
  176. package/src/openbao/pki-role.ts +12 -27
  177. package/src/openbao/remount-wire.test.ts +87 -0
  178. package/src/openbao/remount-wire.ts +112 -0
  179. package/src/openbao/role-reconcile.test.ts +104 -0
  180. package/src/openbao/role-reconcile.ts +84 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Everything LaunchdJob checks before it touches the host — split from job-lifecycle.ts so that a
3
+ * reconcile and a plan-time REPLACE run the very same checks.
4
+ *
5
+ * ★ WHY A REPLACE IS CHECKED AT PLAN TIME. A label or domain change is a delete-first replace, and
6
+ * Alchemy runs `delete` on the old job BEFORE `reconcile` on the new one (Apply.ts,
7
+ * deleteOldGenerations under `node.deleteFirst`; read in alchemy 2.0.0-beta.79 on 2026-09-21).
8
+ * A refusal that fired only in reconcile would therefore arrive after the old job was already
9
+ * booted out: nothing running, and a plan that had promised a clean swap.
10
+ * ★ EVERY CHECK HERE IS READ-ONLY: validation, a user lookup, `launchctl print` and
11
+ * `print-disabled` — none of which needs root (measured, see launchctl.ts).
12
+ */
13
+ import {
14
+ type LaunchdJobAttributes,
15
+ type LaunchdJobProps,
16
+ type ParsedDomain,
17
+ parseDomain,
18
+ plistPathFor,
19
+ serviceTarget,
20
+ sha256Hex,
21
+ } from './job-form.ts';
22
+ import { identityProblems, jobProblems } from './job-validate.ts';
23
+ import { type ServiceStatus, isDisabled, printService } from './launchctl.ts';
24
+ import { type HostRunner, canActAsRoot } from './runner.ts';
25
+
26
+ export type Location = {
27
+ readonly domain: ParsedDomain;
28
+ readonly plistPath: string;
29
+ readonly target: string;
30
+ };
31
+
32
+ export type Identity = Pick<LaunchdJobProps, 'label' | 'domain'>;
33
+
34
+ export const refuse = (label: string, message: string): Error =>
35
+ new Error(`Launchd.Job ${label}: ${message}`);
36
+
37
+ /** Throw every refusal at once, so one plan shows the whole list. */
38
+ export const assertValid = (props: LaunchdJobProps): void => {
39
+ const found = jobProblems(props);
40
+ if (found.length > 0) throw refuse(props.label, found.join('; '));
41
+ };
42
+
43
+ export const locate = async (runner: HostRunner, props: Identity): Promise<Location> => {
44
+ const domain = parseDomain(props.domain);
45
+ if (domain === undefined) throw refuse(props.label, `unknown domain ${props.domain}`);
46
+ const home =
47
+ domain.kind === 'gui' ? (await runner.lookupUser(String(domain.uid)))?.home : undefined;
48
+ return {
49
+ domain,
50
+ plistPath: plistPathFor(props.label, domain, home),
51
+ target: serviceTarget(props.label, props.domain),
52
+ };
53
+ };
54
+
55
+ /**
56
+ * ⛔ NO SILENT SUDO. The system domain, and another user's gui domain, need root. Refuse up front
57
+ * with the two ways out, rather than let launchctl or rename(2) fail halfway through.
58
+ */
59
+ export const assertMayWrite = (runner: HostRunner, label: string, domain: ParsedDomain): void => {
60
+ if (canActAsRoot(runner)) return;
61
+ if (domain.kind === 'system') {
62
+ throw refuse(
63
+ label,
64
+ 'the system domain needs root. Run the deploy as root, or provide a HostRunner that is ' +
65
+ 'deliberately privileged (privileged: true). This provider never calls sudo.',
66
+ );
67
+ }
68
+ if (domain.uid !== runner.effectiveUid()) {
69
+ throw refuse(
70
+ label,
71
+ `gui/${String(domain.uid)} belongs to another user; only root may write it. ` +
72
+ 'Deploy as that user, as root, or through a privileged HostRunner.',
73
+ );
74
+ }
75
+ };
76
+
77
+ /** Where the job goes, whether we may write it there, and launchd's current view of it. */
78
+ export const preflight = async (
79
+ runner: HostRunner,
80
+ props: Identity,
81
+ ): Promise<{ location: Location; status: ServiceStatus }> => {
82
+ const location = await locate(runner, props);
83
+ assertMayWrite(runner, props.label, location.domain);
84
+ const status = await printService(runner, location.target);
85
+ if (status.domainMissing === true) {
86
+ throw refuse(
87
+ props.label,
88
+ `${props.domain} does not exist: that user has no login session, and launchd creates a gui ` +
89
+ 'domain only at login. Log the user in (or declare a system-domain job with userName), ' +
90
+ 'then redeploy. Nothing was written.',
91
+ );
92
+ }
93
+ /**
94
+ * ⛔ A DISABLED LABEL IS SOMEONE'S DECISION, NOT DRIFT. `launchctl disable` persists across boots
95
+ * and makes bootstrap fail; enabling it here would silently overrule whoever disabled it.
96
+ */
97
+ if (await isDisabled(runner, props.domain, props.label)) {
98
+ throw refuse(
99
+ props.label,
100
+ `${location.target} is disabled (launchctl print-disabled ${props.domain}). ` +
101
+ `If that is stale, run \`launchctl enable ${location.target}\` deliberately, then redeploy.`,
102
+ );
103
+ }
104
+ return { location, status };
105
+ };
106
+
107
+ /**
108
+ * ⛔ A JOB THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. Alchemy's adoption probe guards a
109
+ * create, but NOT the new label of a replace — the engine reads nothing there — so a rename onto
110
+ * a label someone already loaded would boot their job out and write ours over its plist.
111
+ * ★ A plist byte-identical to our render is exempt: it is this declaration's own leftover (a
112
+ * deploy that died between write and bootstrap), and rewriting it changes nothing.
113
+ */
114
+ export const assertUnclaimed = async (
115
+ runner: HostRunner,
116
+ label: string,
117
+ location: Location,
118
+ status: ServiceStatus,
119
+ renderedSha256: string | undefined,
120
+ ): Promise<void> => {
121
+ const bytes = await runner.readFile(location.plistPath);
122
+ const foreignFile = bytes !== undefined && sha256Hex(bytes) !== renderedSha256;
123
+ if (foreignFile || (bytes === undefined && status.loaded)) {
124
+ throw refuse(
125
+ label,
126
+ `${location.target} is already on this host (${foreignFile ? location.plistPath : 'loaded'})` +
127
+ ' and is not this resource. Remove it, or declare it as a new resource and deploy with ' +
128
+ '--adopt.',
129
+ );
130
+ }
131
+ };
132
+
133
+ /**
134
+ * A rename, checked at plan time: the new identity must be valid, writable, bootstrappable and
135
+ * free, and the old one deletable — before the plan may promise a delete-first replace.
136
+ * `full` is the whole declaration when it is already resolved; otherwise only the identity is.
137
+ */
138
+ export const assertReplaceable = async (
139
+ runner: HostRunner,
140
+ next: Identity,
141
+ full: LaunchdJobProps | undefined,
142
+ old: LaunchdJobAttributes,
143
+ renderedSha256: string | undefined,
144
+ ): Promise<void> => {
145
+ const found = full === undefined ? identityProblems(next.label, next.domain) : jobProblems(full);
146
+ if (found.length > 0) throw refuse(next.label, found.join('; '));
147
+ const oldDomain = parseDomain(old.domain);
148
+ if (oldDomain === undefined) throw refuse(old.label, `unknown domain ${old.domain}`);
149
+ assertMayWrite(runner, old.label, oldDomain);
150
+ const { location, status } = await preflight(runner, next);
151
+ await assertUnclaimed(runner, next.label, location, status, renderedSha256);
152
+ };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Everything LaunchdJob refuses before it touches the host. Run at plan time (diff) and again at
3
+ * reconcile, so a bad declaration fails the plan instead of half-applying.
4
+ *
5
+ * ★ REFUSE WHAT launchd WOULD SILENTLY IGNORE OR MISREAD. launchd loads a plist with an out-of-range
6
+ * calendar field or a UserName on an agent without complaint and then simply never does what the
7
+ * declaration says; a plan that fails is cheaper than a job that quietly does nothing.
8
+ */
9
+ import {
10
+ type CalendarInterval,
11
+ type LaunchdJobProps,
12
+ OWNED_KEYS,
13
+ parseDomain,
14
+ } from './job-form.ts';
15
+ import { jobSecretProblems } from './secret-tripwire.ts';
16
+
17
+ /** ★ Labels become file names: no `/`, no leading dot, nothing a path could re-interpret. */
18
+ const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
19
+
20
+ /**
21
+ * ⛔ LABELS OTHER TOOLS OWN ARE NEVER DECLARED HERE. nix-darwin (`org.nixos.*`) rewrites its plists
22
+ * on every activation and unloads any it no longer lists; macOS owns `com.apple.*`; `brew
23
+ * services` owns `homebrew.mxcl.*`. Declaring one of those makes two tools fight over one file.
24
+ * To move a job off such a tool, declare a NEW label and cut over (docs/launchd.md).
25
+ */
26
+ export const RESERVED_LABEL_PREFIXES: readonly string[] = [
27
+ 'org.nixos.',
28
+ 'com.apple.',
29
+ 'homebrew.mxcl.',
30
+ ];
31
+
32
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
+
34
+ const RANGES: ReadonlyArray<readonly [keyof CalendarInterval, number, number]> = [
35
+ ['minute', 0, 59],
36
+ ['hour', 0, 23],
37
+ ['day', 1, 31],
38
+ ['weekday', 0, 7],
39
+ ['month', 1, 12],
40
+ ];
41
+
42
+ const isInt = (value: number, min: number, max = Number.MAX_SAFE_INTEGER) =>
43
+ Number.isSafeInteger(value) && value >= min && value <= max;
44
+
45
+ const calendarProblems = (entries: readonly CalendarInterval[]): string[] => {
46
+ if (entries.length === 0) return ['startCalendarInterval is an empty list; omit it instead'];
47
+ const found: string[] = [];
48
+ for (const [index, entry] of entries.entries()) {
49
+ for (const [field, min, max] of RANGES) {
50
+ const value = entry[field];
51
+ if (value !== undefined && !isInt(value, min, max)) {
52
+ found.push(
53
+ `startCalendarInterval[${String(index)}].${field} must be ${String(min)}–${String(max)}`,
54
+ );
55
+ }
56
+ }
57
+ }
58
+ return found;
59
+ };
60
+
61
+ const absolute = (name: string, value: string | undefined): string[] =>
62
+ value === undefined || value.startsWith('/') ? [] : [`${name} must be an absolute path`];
63
+
64
+ /**
65
+ * The label and domain alone — the job's identity. ★ Split out because a rename is a delete-first
66
+ * replace, and it must be refusable at plan time even while other props are still unresolved
67
+ * Outputs (see job.ts diff): otherwise the old job is deleted and only then is the new label
68
+ * found to be invalid.
69
+ */
70
+ export const identityProblems = (label: string, domainText: string): string[] => {
71
+ const found: string[] = [];
72
+ if (!LABEL.test(label)) {
73
+ found.push('label must be 1–200 of [A-Za-z0-9._-], starting with a letter or digit');
74
+ }
75
+ const reserved = RESERVED_LABEL_PREFIXES.find((prefix) => label.startsWith(prefix));
76
+ if (reserved !== undefined) {
77
+ found.push(`label ${label} is under ${reserved}, which another tool owns; declare a new label`);
78
+ }
79
+ if (parseDomain(domainText) === undefined)
80
+ found.push(`domain must be 'system' or 'gui/<uid>', not ${domainText}`);
81
+ return found;
82
+ };
83
+
84
+ export const jobProblems = (props: LaunchdJobProps): string[] => {
85
+ const found = identityProblems(props.label, props.domain);
86
+ const domain = parseDomain(props.domain);
87
+
88
+ const [program] = props.programArguments;
89
+ // ★ Absolute, because launchd's PATH for a job is not the deploying shell's PATH.
90
+ if (program === undefined) found.push('programArguments must name a program');
91
+ else if (!program.startsWith('/')) found.push('programArguments[0] must be an absolute path');
92
+
93
+ for (const name of Object.keys(props.environment ?? {})) {
94
+ if (!ENV_NAME.test(name))
95
+ found.push(`environment name ${JSON.stringify(name)} is not a valid variable name`);
96
+ }
97
+ found.push(...jobSecretProblems(props.environment, props.programArguments, props.extraKeys));
98
+
99
+ if (props.startInterval !== undefined && !isInt(props.startInterval, 1)) {
100
+ found.push('startInterval must be a positive whole number of seconds');
101
+ }
102
+ if (props.throttleInterval !== undefined && !isInt(props.throttleInterval, 0)) {
103
+ found.push('throttleInterval must be a non-negative whole number of seconds');
104
+ }
105
+ const calendar = props.startCalendarInterval;
106
+ if (calendar !== undefined) {
107
+ found.push(
108
+ ...calendarProblems(Array.isArray(calendar) ? calendar : [calendar as CalendarInterval]),
109
+ );
110
+ }
111
+ const keepAlive = props.keepAlive;
112
+ if (
113
+ typeof keepAlive === 'object' &&
114
+ keepAlive.successfulExit === undefined &&
115
+ keepAlive.crashed === undefined
116
+ ) {
117
+ found.push('keepAlive {} names no condition; use true, false, or a condition');
118
+ }
119
+
120
+ found.push(...absolute('standardOutPath', props.standardOutPath));
121
+ found.push(...absolute('standardErrorPath', props.standardErrorPath));
122
+ found.push(...absolute('workingDirectory', props.workingDirectory));
123
+
124
+ if (domain?.kind === 'gui' && (props.userName !== undefined || props.groupName !== undefined)) {
125
+ found.push(
126
+ 'userName/groupName apply only to the system domain; launchd ignores them for agents',
127
+ );
128
+ }
129
+ for (const key of Object.keys(props.extraKeys ?? {})) {
130
+ if (OWNED_KEYS.includes(key))
131
+ found.push(`extraKeys.${key} is owned by a typed prop; use that instead`);
132
+ }
133
+ return found;
134
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `Launchd.Job` — one launchd job (a LaunchDaemon in `system`, a LaunchAgent in `gui/<uid>`),
3
+ * declared rather than hand-loaded.
4
+ *
5
+ * ★ launchd HAS NO SDK. The provider renders the plist (plist.ts), writes it where launchd looks
6
+ * (job-form.ts plistPathFor), and drives `launchctl` (launchctl.ts) — all through the HostRunner
7
+ * the stack provides (runner.ts), so none of it is reachable except through that seam.
8
+ *
9
+ * Lifecycle (job-lifecycle.ts has the reasoning for each step):
10
+ * - create / update — write the plist atomically, boot out the old job if loaded, bootstrap.
11
+ * An update is therefore a RESTART; a converged job is left alone.
12
+ * - read — `launchctl print` (loaded? pid? last exit?) plus the plist's SHA-256.
13
+ * - diff — the rendered plist's SHA-256 against the stored AND the on-disk digest.
14
+ * - replace — only when `label` or `domain` changes, and DELETE FIRST (labels are unique); so
15
+ * everything the new job would be refused for is refused at plan time (job-preflight.ts).
16
+ * - delete — boot out, remove the plist.
17
+ *
18
+ * ⛔ ENVIRONMENT IS NON-SECRET ONLY — see the ⛔ on LaunchdJobProps.environment.
19
+ * ⛔ NO SILENT SUDO — see assertMayWrite in job-preflight.ts.
20
+ * ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`. A label already loaded, or a plist already on disk, reads
21
+ * as `Unowned`, so Alchemy refuses to take it over unless asked. Labels under `org.nixos.`,
22
+ * `com.apple.` and `homebrew.mxcl.` are refused outright (job-validate.ts) — declare a new label
23
+ * and cut over instead (docs/launchd.md).
24
+ */
25
+ import { Resource } from 'alchemy';
26
+ import { Unowned } from 'alchemy/AdoptPolicy';
27
+ import { isResolved } from 'alchemy/Diff';
28
+ import * as Provider from 'alchemy/Provider';
29
+ import * as Effect from 'effect/Effect';
30
+ import { lift, resolvedString } from './host-effect.ts';
31
+ import type { LaunchdJobAttributes, LaunchdJobProps } from './job-form.ts';
32
+ import { deleteJob, diffJob, readJob, reconcileJob, replaceDiff } from './job-lifecycle.ts';
33
+ import { HostRunnerService } from './runner.ts';
34
+
35
+ export type {
36
+ CalendarInterval,
37
+ KeepAlive,
38
+ LaunchdDomain,
39
+ LaunchdJobAttributes,
40
+ LaunchdJobProps,
41
+ } from './job-form.ts';
42
+
43
+ export interface LaunchdJob extends Resource<
44
+ 'Launchd.Job',
45
+ LaunchdJobProps,
46
+ LaunchdJobAttributes
47
+ > {}
48
+
49
+ export const LaunchdJob = Resource<LaunchdJob>('Launchd.Job');
50
+
51
+ export const LaunchdJobProvider = () =>
52
+ Provider.effect(
53
+ LaunchdJob,
54
+ Effect.gen(function* () {
55
+ const runner = yield* HostRunnerService;
56
+ return LaunchdJob.Provider.of({
57
+ /**
58
+ * ⛔ launchd's job list is not a list of things this stack owns — it is every Apple, Nix
59
+ * and hand-loaded job on the host. Returning them would invite a nuke to boot them out.
60
+ */
61
+ list: () => Effect.succeed([]),
62
+
63
+ read: ({ olds, output }) =>
64
+ lift(async () => {
65
+ const found = await readJob(runner, olds);
66
+ if (found === undefined) return undefined;
67
+ return output === undefined ? Unowned(found) : found;
68
+ }),
69
+
70
+ diff: ({ news, output }) => {
71
+ if (output === undefined) return Effect.succeed(undefined);
72
+ if (isResolved(news)) return lift(() => diffJob(runner, news, output));
73
+ /**
74
+ * ⛔ A RENAME MUST BE SEEN EVEN WHILE OTHER PROPS ARE UNRESOLVED. Returning undefined
75
+ * hands the call to the engine's default — `update` for any prop change (Plan.ts,
76
+ * havePropsChanged) — and an update under a new label bootstraps the new job and
77
+ * orphans the old one, still running. `config.path` in programArguments (the pattern
78
+ * docs/launchd.md recommends) is unresolved exactly when its HostFile changes in the
79
+ * same deploy. reconcileJob also defends against this, for a label that is itself an
80
+ * Output; seeing it here is what gets it planned as the replace it is.
81
+ */
82
+ const label = resolvedString(news, 'label');
83
+ const domain = resolvedString(news, 'domain') as LaunchdJobProps['domain'] | undefined;
84
+ return label !== undefined &&
85
+ domain !== undefined &&
86
+ (label !== output.label || domain !== output.domain)
87
+ ? lift(() => replaceDiff(runner, { domain, label }, undefined, output))
88
+ : Effect.succeed(undefined);
89
+ },
90
+
91
+ reconcile: ({ news, output }) => lift(() => reconcileJob(runner, news, output)),
92
+
93
+ delete: ({ output }) => lift(() => deleteJob(runner, output)),
94
+ });
95
+ }),
96
+ );
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The launchctl parsers against the shapes measured on macOS 27.2 (launchctl.ts header), and the
3
+ * bootout sequence against the fake launchd.
4
+ *
5
+ * ★ The fixture keeps the two traps from the real output: a multi-line shell argument that prints
6
+ * raw (with a `}` in column 0), and nested blocks that repeat `state = ` two tabs deep.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { fakeRunner } from './fake-runner.ts';
10
+ import {
11
+ LAUNCHCTL,
12
+ bootoutIfLoaded,
13
+ parseDisabled,
14
+ parsePrint,
15
+ printFields,
16
+ printService,
17
+ } from './launchctl.ts';
18
+
19
+ const RUNNING = [
20
+ 'system/com.example.job = {',
21
+ '\tactive count = 1',
22
+ '\tpath = /Library/LaunchDaemons/com.example.job.plist',
23
+ '\ttype = LaunchDaemon',
24
+ '\tstate = running',
25
+ '',
26
+ '\tprogram = /bin/sh',
27
+ '\targuments = {',
28
+ '\t\t/bin/sh',
29
+ '\t\t-c',
30
+ '\t\tprog=/opt/example/bin/job',
31
+ '[ -x "$prog" ] || {',
32
+ ' pid = 99',
33
+ '}',
34
+ 'exec "$prog"',
35
+ '',
36
+ '\t}',
37
+ '',
38
+ '\tstdout path = /var/log/example.log',
39
+ '\tresource coalition = {',
40
+ '\t\tID = 777',
41
+ '\t\ttype = resource',
42
+ '\t\tstate = active',
43
+ '\t}',
44
+ '',
45
+ '\tdomain = system',
46
+ '\truns = 1',
47
+ '\tpid = 1219',
48
+ '\tlast exit code = (never exited)',
49
+ '\tspawn type = daemon (3)',
50
+ '\tjob state = running',
51
+ '}',
52
+ ].join('\n');
53
+
54
+ describe('printFields / parsePrint', () => {
55
+ test('reads the job block, not the nested blocks or the raw argument text', () => {
56
+ const fields = printFields(RUNNING);
57
+ expect(fields.get('state')).toBe('running');
58
+ expect(fields.get('pid')).toBe('1219');
59
+ expect(fields.get('type')).toBe('LaunchDaemon');
60
+ expect(fields.get('path')).toBe('/Library/LaunchDaemons/com.example.job.plist');
61
+ expect(fields.has('ID')).toBe(false);
62
+ });
63
+
64
+ test('a running job', () => {
65
+ expect(parsePrint(RUNNING)).toEqual({ loaded: true, pid: 1219, state: 'running' });
66
+ });
67
+
68
+ test('a stopped job with an exit code, and the newer "78: EX_CONFIG" form', () => {
69
+ const stopped = RUNNING.replace('\tstate = running', '\tstate = not running')
70
+ .replace('\tpid = 1219\n', '')
71
+ .replace('(never exited)', '78: EX_CONFIG');
72
+ expect(parsePrint(stopped)).toEqual({ lastExitCode: 78, loaded: true, state: 'not running' });
73
+ expect(parsePrint(stopped.replace('78: EX_CONFIG', '0')).lastExitCode).toBe(0);
74
+ });
75
+
76
+ test('empty output parses to loaded with no details (the caller decides loaded from the exit code)', () => {
77
+ expect(parsePrint('')).toEqual({ loaded: true });
78
+ });
79
+ });
80
+
81
+ describe('parseDisabled', () => {
82
+ test('the measured `=> enabled|disabled` form, and the older `=> true|false`', () => {
83
+ const out = [
84
+ '\tdisabled services = {',
85
+ '\t\t"com.example.on" => enabled',
86
+ '\t\t"com.example.off" => disabled',
87
+ '\t\t"com.example.legacy-off" => true',
88
+ '\t\t"com.example.legacy-on" => false',
89
+ '\t}',
90
+ ].join('\n');
91
+ expect([...parseDisabled(out)].sort()).toEqual(['com.example.legacy-off', 'com.example.off']);
92
+ });
93
+ });
94
+
95
+ describe('printService', () => {
96
+ test('exit 113 is "not loaded", not an error', async () => {
97
+ const fake = fakeRunner();
98
+ expect(await printService(fake.runner, 'system/com.example.none')).toEqual({ loaded: false });
99
+ });
100
+
101
+ test('exit 112 "Could not find domain" (a gui uid with no login session) is "not loaded"', async () => {
102
+ const fake = fakeRunner();
103
+ fake.state.loggedOut.add(502);
104
+ expect(await printService(fake.runner, 'gui/502/com.example.x')).toEqual({
105
+ domainMissing: true,
106
+ loaded: false,
107
+ });
108
+ });
109
+
110
+ test('exit 112 without that text stays an error', async () => {
111
+ const runner = {
112
+ ...fakeRunner().runner,
113
+ exec: async () => ({ exitCode: 112, stderr: 'Bad request.', stdout: '' }),
114
+ };
115
+ await expect(printService(runner, 'system/x')).rejects.toThrow('-> 112');
116
+ });
117
+
118
+ test('any other non-zero exit is an error carrying stderr, never stdout', async () => {
119
+ const runner = {
120
+ ...fakeRunner().runner,
121
+ exec: async () => ({ exitCode: 1, stderr: 'Bad request.', stdout: 'SECRET_ENV=leak' }),
122
+ };
123
+ const error = await printService(runner, 'system/x').then(
124
+ () => new Error('expected a rejection'),
125
+ (cause: unknown) => cause as Error,
126
+ );
127
+ expect(error.message).toContain('Bad request.');
128
+ expect(error.message).not.toContain('leak');
129
+ });
130
+ });
131
+
132
+ describe('bootoutIfLoaded', () => {
133
+ test('does nothing when the job is not loaded (bootout of an absent job exits non-zero)', async () => {
134
+ const fake = fakeRunner();
135
+ await bootoutIfLoaded(fake.runner, 'system/com.example.none');
136
+ expect(fake.calls.map((call) => call[1])).toEqual(['print']);
137
+ });
138
+
139
+ test('boots out, then polls until launchd lets go', async () => {
140
+ const fake = fakeRunner();
141
+ fake.loaded.set('system/com.example.job', 1);
142
+ fake.state.lingerPrints = 3;
143
+ await bootoutIfLoaded(fake.runner, 'system/com.example.job');
144
+ expect(fake.loaded.has('system/com.example.job')).toBe(false);
145
+ const subs = fake.calls.map((call) => call[1]);
146
+ expect(subs.slice(0, 2)).toEqual(['print', 'bootout']);
147
+ expect(subs.filter((sub) => sub === 'print').length).toBeGreaterThan(3);
148
+ expect(fake.calls.every((call) => call[0] === LAUNCHCTL)).toBe(true);
149
+ });
150
+
151
+ test('a non-zero bootout for a job that is still exiting (EINPROGRESS) waits, not fails', async () => {
152
+ const fake = fakeRunner();
153
+ fake.loaded.set('system/com.example.slow', 1);
154
+ fake.state.lingerPrints = 3;
155
+ fake.state.bootoutFailure = {
156
+ exitCode: 36,
157
+ stderr: 'Boot-out failed: 36: Operation now in progress',
158
+ stdout: '',
159
+ };
160
+ await bootoutIfLoaded(fake.runner, 'system/com.example.slow');
161
+ expect(fake.loaded.has('system/com.example.slow')).toBe(false);
162
+ });
163
+
164
+ test('a non-zero bootout for a job that never leaves fails with bootout’s own error', async () => {
165
+ const fake = fakeRunner();
166
+ fake.loaded.set('system/com.example.stuck', 1);
167
+ fake.state.bootoutKeepsJob = true;
168
+ await expect(bootoutIfLoaded(fake.runner, 'system/com.example.stuck')).rejects.toThrow(
169
+ /bootout system\/com\.example\.stuck -> 5: .*still loaded after waiting/,
170
+ );
171
+ });
172
+
173
+ test('gives up with a clear error when the job never goes away', async () => {
174
+ const fake = fakeRunner();
175
+ fake.loaded.set('system/com.example.stuck', 1);
176
+ fake.state.lingerPrints = 1_000;
177
+ await expect(bootoutIfLoaded(fake.runner, 'system/com.example.stuck')).rejects.toThrow(
178
+ 'still loaded',
179
+ );
180
+ });
181
+ });