@homeflare/alchemy 0.5.0 → 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.
Files changed (288) hide show
  1. package/README.md +68 -55
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-runner.d.ts +36 -0
  53. package/dist/launchd/fake-runner.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +19 -0
  55. package/dist/launchd/host-effect.d.ts.map +1 -0
  56. package/dist/launchd/host-file-form.d.ts +24 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -0
  58. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  59. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  60. package/dist/launchd/host-file.d.ts +25 -0
  61. package/dist/launchd/host-file.d.ts.map +1 -0
  62. package/dist/launchd/host-lookup.d.ts +27 -0
  63. package/dist/launchd/host-lookup.d.ts.map +1 -0
  64. package/dist/launchd/index.d.ts +21 -0
  65. package/dist/launchd/index.d.ts.map +1 -0
  66. package/dist/launchd/index.js +969 -0
  67. package/dist/launchd/index.js.map +25 -0
  68. package/dist/launchd/job-form.d.ts +90 -0
  69. package/dist/launchd/job-form.d.ts.map +1 -0
  70. package/dist/launchd/job-lifecycle.d.ts +34 -0
  71. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  72. package/dist/launchd/job-preflight.d.ts +50 -0
  73. package/dist/launchd/job-preflight.d.ts.map +1 -0
  74. package/dist/launchd/job-validate.d.ts +25 -0
  75. package/dist/launchd/job-validate.d.ts.map +1 -0
  76. package/dist/launchd/job.d.ts +34 -0
  77. package/dist/launchd/job.d.ts.map +1 -0
  78. package/dist/launchd/launchctl.d.ts +62 -0
  79. package/dist/launchd/launchctl.d.ts.map +1 -0
  80. package/dist/launchd/local-runner.d.ts +7 -0
  81. package/dist/launchd/local-runner.d.ts.map +1 -0
  82. package/dist/launchd/plist.d.ts +41 -0
  83. package/dist/launchd/plist.d.ts.map +1 -0
  84. package/dist/launchd/providers.d.ts +15 -0
  85. package/dist/launchd/providers.d.ts.map +1 -0
  86. package/dist/launchd/runner.d.ts +91 -0
  87. package/dist/launchd/runner.d.ts.map +1 -0
  88. package/dist/launchd/secret-tripwire.d.ts +24 -0
  89. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  90. package/dist/openbao/auth-method-form.d.ts +7 -0
  91. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  92. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  93. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/auth-method.d.ts +6 -0
  95. package/dist/openbao/auth-method.d.ts.map +1 -1
  96. package/dist/openbao/auth-role-form.d.ts +9 -0
  97. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  98. package/dist/openbao/auth-role.d.ts.map +1 -1
  99. package/dist/openbao/auth-token-form.d.ts +48 -0
  100. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  101. package/dist/openbao/bao-identity.d.ts +81 -0
  102. package/dist/openbao/bao-identity.d.ts.map +1 -0
  103. package/dist/openbao/bao-namespace.d.ts +20 -0
  104. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  105. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  106. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  107. package/dist/openbao/fake-engines.d.ts +30 -0
  108. package/dist/openbao/fake-engines.d.ts.map +1 -0
  109. package/dist/openbao/fake-stack.d.ts +29 -0
  110. package/dist/openbao/fake-stack.d.ts.map +1 -0
  111. package/dist/openbao/host-approles.d.ts +62 -0
  112. package/dist/openbao/host-approles.d.ts.map +1 -0
  113. package/dist/openbao/index.d.ts +19 -0
  114. package/dist/openbao/index.d.ts.map +1 -1
  115. package/dist/openbao/index.js +1518 -310
  116. package/dist/openbao/index.js.map +40 -17
  117. package/dist/openbao/jwt-config-form.d.ts +51 -0
  118. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  119. package/dist/openbao/jwt-config.d.ts +25 -0
  120. package/dist/openbao/jwt-config.d.ts.map +1 -0
  121. package/dist/openbao/jwt-role-form.d.ts +91 -0
  122. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  123. package/dist/openbao/jwt-role.d.ts +31 -0
  124. package/dist/openbao/jwt-role.d.ts.map +1 -0
  125. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  126. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  127. package/dist/openbao/kubernetes-role.d.ts +25 -0
  128. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  129. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  130. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  131. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  132. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  133. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  134. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  135. package/dist/openbao/mfa-totp.d.ts +33 -0
  136. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  137. package/dist/openbao/mfa-wire.d.ts +63 -0
  138. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  139. package/dist/openbao/mount-form.d.ts +7 -0
  140. package/dist/openbao/mount-form.d.ts.map +1 -1
  141. package/dist/openbao/mount-move.d.ts +49 -0
  142. package/dist/openbao/mount-move.d.ts.map +1 -0
  143. package/dist/openbao/mount-path.d.ts +8 -4
  144. package/dist/openbao/mount-path.d.ts.map +1 -1
  145. package/dist/openbao/mount-reconcile.d.ts +16 -0
  146. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  147. package/dist/openbao/mount.d.ts +9 -0
  148. package/dist/openbao/mount.d.ts.map +1 -1
  149. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  150. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  151. package/dist/openbao/pki-role-form.d.ts +38 -30
  152. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  153. package/dist/openbao/pki-role.d.ts.map +1 -1
  154. package/dist/openbao/policy.d.ts +2 -0
  155. package/dist/openbao/policy.d.ts.map +1 -1
  156. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  157. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  158. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  159. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  160. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  161. package/dist/openbao/remount-wire.d.ts +44 -0
  162. package/dist/openbao/remount-wire.d.ts.map +1 -0
  163. package/dist/openbao/rename.d.ts +106 -0
  164. package/dist/openbao/rename.d.ts.map +1 -0
  165. package/dist/openbao/role-reconcile.d.ts +40 -0
  166. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  167. package/docs/caddy.md +180 -0
  168. package/docs/launchd.md +167 -0
  169. package/docs/mesh-node.md +188 -0
  170. package/package.json +12 -2
  171. package/src/caddy/admin-calls.ts +128 -0
  172. package/src/caddy/admin-guard.ts +170 -0
  173. package/src/caddy/admin.ts +74 -0
  174. package/src/caddy/caddyfile-tripwire.ts +133 -0
  175. package/src/caddy/config-form.ts +64 -0
  176. package/src/caddy/config-lifecycle.test.ts +218 -0
  177. package/src/caddy/config-lifecycle.ts +148 -0
  178. package/src/caddy/config.ts +116 -0
  179. package/src/caddy/digest.ts +44 -0
  180. package/src/caddy/fake-caddy.ts +160 -0
  181. package/src/caddy/guards.test.ts +156 -0
  182. package/src/caddy/index.ts +25 -0
  183. package/src/caddy/local-admin.test.ts +160 -0
  184. package/src/caddy/local-admin.ts +189 -0
  185. package/src/caddy/providers.test.ts +244 -0
  186. package/src/caddy/providers.ts +17 -0
  187. package/src/caddy/with-file.ts +62 -0
  188. package/src/cloudflare/fake-mesh.ts +181 -0
  189. package/src/cloudflare/index.ts +9 -3
  190. package/src/cloudflare/mesh-node-api.ts +127 -0
  191. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  192. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  193. package/src/cloudflare/mesh-node-form.ts +115 -0
  194. package/src/cloudflare/mesh-node-harness.ts +124 -0
  195. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  196. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  197. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  198. package/src/cloudflare/mesh-node-token.ts +125 -0
  199. package/src/cloudflare/mesh-node.test.ts +172 -0
  200. package/src/cloudflare/mesh-node.ts +120 -0
  201. package/src/cloudflare/providers.ts +20 -2
  202. package/src/index.ts +2 -0
  203. package/src/launchd/fake-runner.ts +176 -0
  204. package/src/launchd/host-effect.ts +28 -0
  205. package/src/launchd/host-file-form.ts +71 -0
  206. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  207. package/src/launchd/host-file-lifecycle.ts +164 -0
  208. package/src/launchd/host-file.ts +64 -0
  209. package/src/launchd/host-lookup.test.ts +92 -0
  210. package/src/launchd/host-lookup.ts +94 -0
  211. package/src/launchd/index.ts +26 -0
  212. package/src/launchd/job-form.test.ts +171 -0
  213. package/src/launchd/job-form.ts +180 -0
  214. package/src/launchd/job-identity.test.ts +147 -0
  215. package/src/launchd/job-lifecycle.test.ts +220 -0
  216. package/src/launchd/job-lifecycle.ts +194 -0
  217. package/src/launchd/job-preflight.ts +152 -0
  218. package/src/launchd/job-validate.ts +134 -0
  219. package/src/launchd/job.ts +96 -0
  220. package/src/launchd/launchctl.test.ts +181 -0
  221. package/src/launchd/launchctl.ts +188 -0
  222. package/src/launchd/local-runner.test.ts +125 -0
  223. package/src/launchd/local-runner.ts +143 -0
  224. package/src/launchd/plist.test.ts +186 -0
  225. package/src/launchd/plist.ts +156 -0
  226. package/src/launchd/providers.test.ts +222 -0
  227. package/src/launchd/providers.ts +21 -0
  228. package/src/launchd/runner.ts +95 -0
  229. package/src/launchd/secret-tripwire.test.ts +110 -0
  230. package/src/launchd/secret-tripwire.ts +81 -0
  231. package/src/openbao/README.md +181 -0
  232. package/src/openbao/REPLACE.md +163 -0
  233. package/src/openbao/auth-method-form.ts +7 -0
  234. package/src/openbao/auth-method-reconcile.ts +74 -0
  235. package/src/openbao/auth-method.ts +20 -41
  236. package/src/openbao/auth-role-form.test.ts +16 -1
  237. package/src/openbao/auth-role-form.ts +11 -0
  238. package/src/openbao/auth-role.ts +12 -0
  239. package/src/openbao/auth-token-form.ts +161 -0
  240. package/src/openbao/bao-identity.test.ts +131 -0
  241. package/src/openbao/bao-identity.ts +188 -0
  242. package/src/openbao/bao-namespace.ts +32 -0
  243. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  244. package/src/openbao/cloudflare-role.ts +30 -8
  245. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  246. package/src/openbao/fake-engines.ts +119 -0
  247. package/src/openbao/fake-stack.ts +112 -0
  248. package/src/openbao/host-approles.test.ts +121 -0
  249. package/src/openbao/host-approles.ts +126 -0
  250. package/src/openbao/index.ts +19 -0
  251. package/src/openbao/jwt-config-form.ts +161 -0
  252. package/src/openbao/jwt-config.ts +85 -0
  253. package/src/openbao/jwt-role-form.test.ts +130 -0
  254. package/src/openbao/jwt-role-form.ts +231 -0
  255. package/src/openbao/jwt-role.ts +102 -0
  256. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  257. package/src/openbao/kubernetes-role-form.ts +149 -0
  258. package/src/openbao/kubernetes-role.ts +94 -0
  259. package/src/openbao/mfa-enforcement-form.ts +147 -0
  260. package/src/openbao/mfa-enforcement.ts +143 -0
  261. package/src/openbao/mfa-totp-form.ts +155 -0
  262. package/src/openbao/mfa-totp.ts +118 -0
  263. package/src/openbao/mfa-wire.ts +117 -0
  264. package/src/openbao/mfa.test.ts +200 -0
  265. package/src/openbao/mount-form.ts +9 -1
  266. package/src/openbao/mount-move.test.ts +164 -0
  267. package/src/openbao/mount-move.ts +113 -0
  268. package/src/openbao/mount-path.test.ts +11 -1
  269. package/src/openbao/mount-path.ts +17 -11
  270. package/src/openbao/mount-reconcile.ts +75 -0
  271. package/src/openbao/mount.ts +26 -43
  272. package/src/openbao/pki-role-canonical.ts +163 -0
  273. package/src/openbao/pki-role-form.test.ts +112 -0
  274. package/src/openbao/pki-role-form.ts +76 -110
  275. package/src/openbao/pki-role.ts +12 -27
  276. package/src/openbao/policy-rename.test.ts +112 -0
  277. package/src/openbao/policy-wire.test.ts +7 -26
  278. package/src/openbao/policy.ts +27 -4
  279. package/src/openbao/proxmox-role-form.ts +27 -8
  280. package/src/openbao/proxmox-role-wire.ts +24 -0
  281. package/src/openbao/proxmox-role.ts +62 -37
  282. package/src/openbao/remount-wire.test.ts +87 -0
  283. package/src/openbao/remount-wire.ts +112 -0
  284. package/src/openbao/rename-occupied.test.ts +166 -0
  285. package/src/openbao/rename.ts +162 -0
  286. package/src/openbao/role-reconcile.test.ts +104 -0
  287. package/src/openbao/role-reconcile.ts +84 -0
  288. package/src/openbao/role-rename.test.ts +205 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * HostFile's props, attributes and validation — the pure half of the provider.
3
+ *
4
+ * ⛔ `content` IS NEVER A SECRET. Alchemy stores props unencrypted, so the content of every HostFile
5
+ * sits in the state store and in every plan diff. A secret file stays RENDERED BY A SECRET
6
+ * RENDERER (openbao-agent templates, on this estate) that fetches it at run time; the stack
7
+ * declares only non-secret config, and at most the PATH the renderer writes to.
8
+ * secret-tripwire.ts refuses the obvious case (a PEM private key); nothing can catch all of them.
9
+ */
10
+ import { fileSecretProblems } from './secret-tripwire.ts';
11
+
12
+ export interface HostFileProps {
13
+ /** Absolute path. The parent directory must already exist. */
14
+ path: string;
15
+ /** UTF-8 text. ⛔ Never a secret — see the file header. */
16
+ content: string;
17
+ /** Permission bits. @default 0o644 */
18
+ mode?: number;
19
+ /** User name or numeric uid. Omitted: whoever the runner writes as (a rewrite keeps no owner). */
20
+ owner?: string | number;
21
+ /** Group name or numeric gid. Omitted: the directory's default group. */
22
+ group?: string | number;
23
+ }
24
+
25
+ export interface HostFileAttributes {
26
+ path: string;
27
+ /** SHA-256 of the bytes on disk. */
28
+ sha256: string;
29
+ mode: number;
30
+ uid: number;
31
+ gid: number;
32
+ size: number;
33
+ }
34
+
35
+ export const DEFAULT_MODE = 0o644;
36
+
37
+ const identityProblems = (name: string, value: string | number | undefined): string[] => {
38
+ if (value === undefined) return [];
39
+ if (typeof value === 'number') {
40
+ return Number.isSafeInteger(value) && value >= 0
41
+ ? []
42
+ : [`${name} must be a non-negative integer id`];
43
+ }
44
+ return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value) || /^\d+$/.test(value)
45
+ ? []
46
+ : [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
47
+ };
48
+
49
+ export const fileProblems = (props: HostFileProps): string[] => {
50
+ const found: string[] = [];
51
+ const { path } = props;
52
+ if (!path.startsWith('/')) found.push('path must be absolute');
53
+ // ★ One spelling per file: `a/./b`, `a//b` and `a/../b` would each be a second resource for the
54
+ // same inode, and `..` is how a derived path walks out of the directory it was meant for.
55
+ if (
56
+ path.endsWith('/') ||
57
+ path
58
+ .split('/')
59
+ .slice(1)
60
+ .some((part) => part === '' || part === '.' || part === '..')
61
+ ) {
62
+ found.push('path must be normalised (no trailing slash, no empty, "." or ".." segments)');
63
+ }
64
+ if (path.includes('\x00')) found.push('path contains NUL');
65
+ const mode = props.mode ?? DEFAULT_MODE;
66
+ if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) found.push('mode must be 0–0o7777');
67
+ found.push(...identityProblems('owner', props.owner));
68
+ found.push(...identityProblems('group', props.group));
69
+ found.push(...fileSecretProblems(props.content));
70
+ return found;
71
+ };
@@ -0,0 +1,198 @@
1
+ /**
2
+ * HostFile's lifecycle against the fake host (no real writes), plus its validation.
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import { fakeRunner } from './fake-runner.ts';
6
+ import { type HostFileProps, fileProblems } from './host-file-form.ts';
7
+ import { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';
8
+
9
+ const PATH = '/etc/example/app.conf';
10
+ const file: HostFileProps = { content: 'listen = 127.0.0.1:9000\n', path: PATH };
11
+
12
+ const host = (euid = 0) =>
13
+ fakeRunner({
14
+ dirs: { '/etc/example': 0, '/Users/someone/cfg': 501 },
15
+ euid,
16
+ groups: { staff: 20, wheel: 0 },
17
+ users: {
18
+ root: { gid: 0, home: '/var/root', uid: 0 },
19
+ someone: { gid: 20, home: '/Users/someone', uid: 501 },
20
+ },
21
+ });
22
+
23
+ const writeCount = (calls: string[][]) => calls.filter((call) => call[0] === 'write').length;
24
+
25
+ describe('create and read back', () => {
26
+ test('writes the content with the default mode 0644 and returns what is on disk', async () => {
27
+ const fake = host();
28
+ const attrs = await reconcileFile(fake.runner, file);
29
+ expect(attrs).toMatchObject({ mode: 0o644, path: PATH, size: file.content.length, uid: 0 });
30
+ expect(new TextDecoder().decode(fake.files.get(PATH)?.bytes)).toBe(file.content);
31
+ expect(await readFileAttributes(fake.runner, PATH)).toEqual(attrs);
32
+ });
33
+
34
+ test('owner and group resolve by name or by id', async () => {
35
+ const fake = host();
36
+ const attrs = await reconcileFile(fake.runner, {
37
+ ...file,
38
+ group: 'staff',
39
+ mode: 0o640,
40
+ owner: 'someone',
41
+ });
42
+ expect(attrs).toMatchObject({ gid: 20, mode: 0o640, uid: 501 });
43
+ const byId = await reconcileFile(fake.runner, { ...file, group: 0, owner: '0' }, attrs);
44
+ expect(byId).toMatchObject({ gid: 0, uid: 0 });
45
+ });
46
+
47
+ test('an already-matching file is not rewritten', async () => {
48
+ const fake = host();
49
+ await reconcileFile(fake.runner, file);
50
+ await reconcileFile(fake.runner, file);
51
+ expect(writeCount(fake.calls)).toBe(1);
52
+ });
53
+ });
54
+
55
+ describe('diff', () => {
56
+ test('noop, then update on content, mode or owner drift', async () => {
57
+ const fake = host();
58
+ const output = await reconcileFile(fake.runner, { ...file, owner: 'root' });
59
+ expect(await diffFile(fake.runner, { ...file, owner: 'root' }, output)).toEqual({
60
+ action: 'noop',
61
+ });
62
+ expect(await diffFile(fake.runner, { ...file, content: 'x\n' }, output)).toEqual({
63
+ action: 'update',
64
+ });
65
+ expect(await diffFile(fake.runner, { ...file, mode: 0o600 }, output)).toEqual({
66
+ action: 'update',
67
+ });
68
+ expect(await diffFile(fake.runner, { ...file, owner: 'someone' }, output)).toEqual({
69
+ action: 'update',
70
+ });
71
+ const entry = fake.files.get(PATH);
72
+ if (entry === undefined) throw new Error('no file');
73
+ fake.files.set(PATH, { ...entry, mode: 0o666 });
74
+ expect(await diffFile(fake.runner, { ...file, owner: 'root' }, output)).toEqual({
75
+ action: 'update',
76
+ });
77
+ });
78
+
79
+ test('a file deleted behind our back is an update', async () => {
80
+ const fake = host();
81
+ const output = await reconcileFile(fake.runner, file);
82
+ fake.files.delete(PATH);
83
+ expect(await diffFile(fake.runner, file, output)).toEqual({ action: 'update' });
84
+ });
85
+
86
+ test('a new path is a create-before-delete replace', async () => {
87
+ const fake = host();
88
+ const output = await reconcileFile(fake.runner, file);
89
+ expect(
90
+ await diffFile(fake.runner, { ...file, path: '/etc/example/other.conf' }, output),
91
+ ).toEqual({ action: 'replace' });
92
+ });
93
+ });
94
+
95
+ describe('refusals', () => {
96
+ test('chown to another user as a non-root deployer', async () => {
97
+ const fake = host(501);
98
+ await expect(
99
+ reconcileFile(fake.runner, { ...file, owner: 'root', path: '/Users/someone/cfg/a' }),
100
+ ).rejects.toThrow('only root may chown');
101
+ });
102
+
103
+ test('a symlink or a directory at the path is never replaced', async () => {
104
+ const fake = host();
105
+ fake.files.set(PATH, { bytes: new Uint8Array(), gid: 0, kind: 'symlink', mode: 0o755, uid: 0 });
106
+ await expect(reconcileFile(fake.runner, file)).rejects.toThrow('is a symlink');
107
+ await expect(reconcileFile(fake.runner, { ...file, path: '/etc/example' })).rejects.toThrow(
108
+ 'is a directory',
109
+ );
110
+ });
111
+
112
+ test('an unknown owner or group', async () => {
113
+ const fake = host();
114
+ await expect(reconcileFile(fake.runner, { ...file, owner: 'nobody-here' })).rejects.toThrow(
115
+ 'no user',
116
+ );
117
+ await expect(reconcileFile(fake.runner, { ...file, group: 'nogroup' })).rejects.toThrow(
118
+ 'no group',
119
+ );
120
+ });
121
+
122
+ test('a missing parent directory surfaces as the error it is', async () => {
123
+ await expect(reconcileFile(host().runner, { ...file, path: '/nowhere/x' })).rejects.toThrow(
124
+ 'ENOENT',
125
+ );
126
+ });
127
+
128
+ test.each([
129
+ ['a relative path', { path: 'etc/x' }, 'absolute'],
130
+ ['a dot-dot segment', { path: '/etc/../x' }, 'normalised'],
131
+ ['a trailing slash', { path: '/etc/x/' }, 'normalised'],
132
+ ['a double slash', { path: '/etc//x' }, 'normalised'],
133
+ ['a mode above 0o7777', { mode: 0o10000 }, 'mode'],
134
+ ['a bad owner name', { owner: 'a b' }, 'not a valid user'],
135
+ ['a negative gid', { group: -1 }, 'non-negative'],
136
+ // ⚠️ Split so gitleaks' private-key rule does not flag the fixture (secret-tripwire.test.ts).
137
+ ['a private key', { content: `-----BEGIN EC ${'PRIVATE'} KEY-----\n` }, 'openbao-agent'],
138
+ ])('%s', (_name, patch, message) => {
139
+ expect(fileProblems({ ...file, ...patch } as HostFileProps).join('\n')).toContain(message);
140
+ });
141
+ });
142
+
143
+ describe('files this resource does not own, and moves', () => {
144
+ const OTHER = '/etc/example/other.conf';
145
+
146
+ test('a different file at a path with no prior state is refused and left alone', async () => {
147
+ // ★ The new path of a replace: the engine's adoption probe never looked there.
148
+ const fake = host();
149
+ fake.files.set(OTHER, {
150
+ bytes: new Uint8Array([7]),
151
+ gid: 0,
152
+ kind: 'file',
153
+ mode: 0o600,
154
+ uid: 0,
155
+ });
156
+ await expect(reconcileFile(fake.runner, { ...file, path: OTHER })).rejects.toThrow(
157
+ 'not this resource',
158
+ );
159
+ expect(fake.files.get(OTHER)?.bytes).toEqual(new Uint8Array([7]));
160
+ });
161
+
162
+ test('a move that reaches reconcile as an update writes the new path, then removes the old', async () => {
163
+ const fake = host();
164
+ const output = await reconcileFile(fake.runner, file);
165
+ const moved = await reconcileFile(fake.runner, { ...file, path: OTHER }, output);
166
+ expect(moved.path).toBe(OTHER);
167
+ expect(fake.files.has(OTHER)).toBe(true);
168
+ expect(fake.files.has(PATH)).toBe(false);
169
+ });
170
+
171
+ test('a create whose read-back fails is rolled back, so the next plan finds nothing', async () => {
172
+ const fake = host();
173
+ const runner = {
174
+ ...fake.runner,
175
+ writeFileAtomic: (path: string, _bytes: Uint8Array, options: { mode: number }) =>
176
+ fake.runner.writeFileAtomic(path, new Uint8Array([0]), options),
177
+ };
178
+ await expect(reconcileFile(runner, file)).rejects.toThrow('does not match');
179
+ expect(fake.files.has(PATH)).toBe(false);
180
+ });
181
+ });
182
+
183
+ describe('delete', () => {
184
+ test('removes the file and is idempotent', async () => {
185
+ const fake = host();
186
+ const output = await reconcileFile(fake.runner, file);
187
+ await deleteFile(fake.runner, output);
188
+ expect(fake.files.has(PATH)).toBe(false);
189
+ await deleteFile(fake.runner, output);
190
+ });
191
+
192
+ test('refuses once the path has become something other than a file', async () => {
193
+ const fake = host();
194
+ const output = await reconcileFile(fake.runner, file);
195
+ fake.files.set(PATH, { bytes: new Uint8Array(), gid: 0, kind: 'symlink', mode: 0o755, uid: 0 });
196
+ await expect(deleteFile(fake.runner, output)).rejects.toThrow('not removing');
197
+ });
198
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * HostFile's read / diff / reconcile / delete as plain async functions over a HostRunner, so the
3
+ * lifecycle runs against fake-runner.ts in tests.
4
+ *
5
+ * ⛔ SYMLINKS AND DIRECTORIES ARE REFUSED, NOT REPLACED. rename(2) over a symlink replaces the LINK,
6
+ * so a path that is a symlink (nix-darwin's /etc entries point into /nix/store) would be quietly
7
+ * taken from the tool that owns it — which puts it back, or refuses its next activation. Remove
8
+ * the path from its owner first, then declare it here.
9
+ */
10
+ import type { Diff } from 'alchemy/Diff';
11
+ import {
12
+ DEFAULT_MODE,
13
+ type HostFileAttributes,
14
+ type HostFileProps,
15
+ fileProblems,
16
+ } from './host-file-form.ts';
17
+ import { sha256Hex } from './job-form.ts';
18
+ import { type HostRunner, canActAsRoot } from './runner.ts';
19
+
20
+ type Desired = {
21
+ readonly bytes: Uint8Array;
22
+ readonly sha256: string;
23
+ readonly mode: number;
24
+ readonly uid?: number;
25
+ readonly gid?: number;
26
+ };
27
+
28
+ const refuse = (path: string, message: string): Error => new Error(`Host.File ${path}: ${message}`);
29
+
30
+ const numericId = (value: string | number): number | undefined =>
31
+ typeof value === 'number' ? value : /^\d+$/.test(value) ? Number(value) : undefined;
32
+
33
+ /** Validate, then resolve owner/group names to ids through the runner. */
34
+ export const desiredFile = async (runner: HostRunner, props: HostFileProps): Promise<Desired> => {
35
+ const found = fileProblems(props);
36
+ if (found.length > 0) throw refuse(props.path, found.join('; '));
37
+ const bytes = new TextEncoder().encode(props.content);
38
+ let uid: number | undefined;
39
+ let gid: number | undefined;
40
+ if (props.owner !== undefined) {
41
+ uid = numericId(props.owner) ?? (await runner.lookupUser(String(props.owner)))?.uid;
42
+ if (uid === undefined) throw refuse(props.path, `no user ${String(props.owner)} on this host`);
43
+ }
44
+ if (props.group !== undefined) {
45
+ gid = numericId(props.group) ?? (await runner.lookupGroup(String(props.group)));
46
+ if (gid === undefined) throw refuse(props.path, `no group ${String(props.group)} on this host`);
47
+ }
48
+ return {
49
+ bytes,
50
+ mode: props.mode ?? DEFAULT_MODE,
51
+ sha256: sha256Hex(bytes),
52
+ ...(uid === undefined ? {} : { uid }),
53
+ ...(gid === undefined ? {} : { gid }),
54
+ };
55
+ };
56
+
57
+ /** The file as it is now, or `undefined` when nothing is at the path. Non-files read with no digest. */
58
+ export const readFileAttributes = async (
59
+ runner: HostRunner,
60
+ path: string,
61
+ ): Promise<HostFileAttributes | undefined> => {
62
+ const stat = await runner.stat(path);
63
+ if (stat === undefined) return undefined;
64
+ const bytes = stat.kind === 'file' ? await runner.readFile(path) : undefined;
65
+ return {
66
+ gid: stat.gid,
67
+ mode: stat.mode,
68
+ path,
69
+ sha256: bytes === undefined ? '' : sha256Hex(bytes),
70
+ size: stat.size,
71
+ uid: stat.uid,
72
+ };
73
+ };
74
+
75
+ const matches = (live: HostFileAttributes, want: Desired): boolean =>
76
+ live.sha256 === want.sha256 &&
77
+ live.mode === want.mode &&
78
+ (want.uid === undefined || live.uid === want.uid) &&
79
+ (want.gid === undefined || live.gid === want.gid);
80
+
81
+ export const diffFile = async (
82
+ runner: HostRunner,
83
+ news: HostFileProps,
84
+ output: HostFileAttributes,
85
+ ): Promise<Diff> => {
86
+ // ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
87
+ if (news.path !== output.path) return { action: 'replace' };
88
+ const want = await desiredFile(runner, news);
89
+ if (want.sha256 !== output.sha256) return { action: 'update' };
90
+ const live = await readFileAttributes(runner, news.path);
91
+ return live !== undefined && matches(live, want) ? { action: 'noop' } : { action: 'update' };
92
+ };
93
+
94
+ export const reconcileFile = async (
95
+ runner: HostRunner,
96
+ props: HostFileProps,
97
+ output?: HostFileAttributes,
98
+ ): Promise<HostFileAttributes> => {
99
+ const want = await desiredFile(runner, props);
100
+ // ⛔ NO SILENT SUDO: handing a file to another user is root's call (chown(2)), so say so up front.
101
+ if (want.uid !== undefined && want.uid !== runner.effectiveUid() && !canActAsRoot(runner)) {
102
+ throw refuse(
103
+ props.path,
104
+ `owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` +
105
+ 'or through a privileged HostRunner (privileged: true). This provider never calls sudo.',
106
+ );
107
+ }
108
+ const stat = await runner.stat(props.path);
109
+ if (stat !== undefined && stat.kind !== 'file') {
110
+ throw refuse(
111
+ props.path,
112
+ `is a ${stat.kind}; remove it from whatever owns it before declaring it here`,
113
+ );
114
+ }
115
+ const before = stat === undefined ? undefined : await readFileAttributes(runner, props.path);
116
+ // ⚠️ An `output` at another path: the engine planned an UPDATE across a move, which it does
117
+ // when diff could not see the new path (host-file.ts). Finish it the way a replace would.
118
+ const moved = output !== undefined && output.path !== props.path;
119
+ const prior = moved ? undefined : output;
120
+ /**
121
+ * ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
122
+ * the engine's adoption probe has already refused anything it found — except at the new path of
123
+ * a replace, where it reads nothing. A path typo there would otherwise overwrite, say, a system
124
+ * file that a fresh declaration of the same path would have been refused as `Unowned`.
125
+ */
126
+ if (prior === undefined && before !== undefined && !matches(before, want)) {
127
+ throw refuse(
128
+ props.path,
129
+ 'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
130
+ 'deploy with --adopt.',
131
+ );
132
+ }
133
+ if (before === undefined || !matches(before, want)) {
134
+ await runner.writeFileAtomic(props.path, want.bytes, {
135
+ mode: want.mode,
136
+ ...(want.uid === undefined ? {} : { uid: want.uid }),
137
+ ...(want.gid === undefined ? {} : { gid: want.gid }),
138
+ });
139
+ }
140
+ // ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
141
+ // up here as a refusal instead of as a forever-`update`.
142
+ const after = await readFileAttributes(runner, props.path);
143
+ if (after === undefined || !matches(after, want)) {
144
+ // ⚠️ Roll back a CREATE: left behind, the next plan's recovery `read` finds a file with no
145
+ // state, reports it `Unowned`, and every later deploy demands --adopt for our own file.
146
+ if (before === undefined) await runner.removeFile(props.path).catch(() => undefined);
147
+ throw refuse(
148
+ props.path,
149
+ 'the write returned but the file on disk does not match the declaration',
150
+ );
151
+ }
152
+ // ★ Create-before-delete, as the replace would have been: the old path goes only once the new
153
+ // one is written and verified.
154
+ if (moved) await deleteFile(runner, output);
155
+ return after;
156
+ };
157
+
158
+ /** Remove the file. Idempotent. ⛔ Refuses a path that has since become a symlink or directory. */
159
+ export const deleteFile = async (runner: HostRunner, output: HostFileAttributes): Promise<void> => {
160
+ const stat = await runner.stat(output.path);
161
+ if (stat === undefined) return;
162
+ if (stat.kind !== 'file') throw refuse(output.path, `is now a ${stat.kind}; not removing it`);
163
+ await runner.removeFile(output.path);
164
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `Host.File` — one text file on the host, declared: content, mode, owner, group.
3
+ *
4
+ * - create / update — atomic write (temp file in the same directory, mode and owner set, rename),
5
+ * skipped when the file already matches; then read back and compared.
6
+ * - read — lstat plus the SHA-256 of the bytes.
7
+ * - diff — declared SHA-256 against the stored and on-disk digest, plus mode and owner.
8
+ * - replace — only when `path` changes; create-before-delete.
9
+ * - delete — removes the file.
10
+ *
11
+ * ⛔ NEVER A SECRET — see host-file-form.ts. Secret files stay rendered by openbao-agent.
12
+ * ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`: a file already at the path reads as `Unowned`.
13
+ * ★ IT LIVES IN THE launchd SUBPATH because the two travel together: a Mac host stack declares a
14
+ * daemon's config file and the daemon that reads it, through the same HostRunner.
15
+ */
16
+ import { Resource } from 'alchemy';
17
+ import { Unowned } from 'alchemy/AdoptPolicy';
18
+ import { isResolved } from 'alchemy/Diff';
19
+ import * as Provider from 'alchemy/Provider';
20
+ import * as Effect from 'effect/Effect';
21
+ import { lift, resolvedString } from './host-effect.ts';
22
+ import type { HostFileAttributes, HostFileProps } from './host-file-form.ts';
23
+ import { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';
24
+ import { HostRunnerService } from './runner.ts';
25
+
26
+ export type { HostFileAttributes, HostFileProps } from './host-file-form.ts';
27
+
28
+ export interface HostFile extends Resource<'Host.File', HostFileProps, HostFileAttributes> {}
29
+
30
+ export const HostFile = Resource<HostFile>('Host.File');
31
+
32
+ export const HostFileProvider = () =>
33
+ Provider.effect(
34
+ HostFile,
35
+ Effect.gen(function* () {
36
+ const runner = yield* HostRunnerService;
37
+ return HostFile.Provider.of({
38
+ list: () => Effect.succeed([]),
39
+
40
+ read: ({ olds, output }) =>
41
+ lift(async () => {
42
+ const found = await readFileAttributes(runner, olds.path);
43
+ if (found === undefined) return undefined;
44
+ return output === undefined ? Unowned(found) : found;
45
+ }),
46
+
47
+ diff: ({ news, output }) => {
48
+ if (output === undefined) return Effect.succeed(undefined);
49
+ if (isResolved(news)) return lift(() => diffFile(runner, news, output));
50
+ // ⛔ A new path is a replace even while `content` is unresolved (an Output templated in):
51
+ // the engine's default would be an update, which writes the new path and never
52
+ // removes the old one. reconcileFile also defends, for a path that is an Output.
53
+ const path = resolvedString(news, 'path');
54
+ return Effect.succeed(
55
+ path !== undefined && path !== output.path ? { action: 'replace' as const } : undefined,
56
+ );
57
+ },
58
+
59
+ reconcile: ({ news, output }) => lift(() => reconcileFile(runner, news, output)),
60
+
61
+ delete: ({ output }) => lift(() => deleteFile(runner, output)),
62
+ });
63
+ }),
64
+ );
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The lookup parsers against the dscacheutil shape measured on macOS 27.2 and getent's documented
3
+ * one — pure strings, nothing runs.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { groupQuery, parseGroup, parseUser, userQuery } from './host-lookup.ts';
7
+
8
+ const DSCACHE_USER = [
9
+ 'name: someone',
10
+ 'password: ********',
11
+ 'uid: 501',
12
+ 'gid: 20',
13
+ 'dir: /Users/someone',
14
+ 'shell: /bin/zsh',
15
+ 'gecos: Some One',
16
+ '',
17
+ 'name: second',
18
+ 'uid: 502',
19
+ 'gid: 20',
20
+ 'dir: /Users/second',
21
+ '',
22
+ ].join('\n');
23
+
24
+ describe('queries', () => {
25
+ test('macOS asks dscacheutil by uid or by name; elsewhere getent', () => {
26
+ expect(userQuery('darwin', '501')).toEqual([
27
+ '/usr/bin/dscacheutil',
28
+ '-q',
29
+ 'user',
30
+ '-a',
31
+ 'uid',
32
+ '501',
33
+ ]);
34
+ expect(userQuery('darwin', 'someone')).toEqual([
35
+ '/usr/bin/dscacheutil',
36
+ '-q',
37
+ 'user',
38
+ '-a',
39
+ 'name',
40
+ 'someone',
41
+ ]);
42
+ expect(groupQuery('darwin', 'wheel')).toEqual([
43
+ '/usr/bin/dscacheutil',
44
+ '-q',
45
+ 'group',
46
+ '-a',
47
+ 'name',
48
+ 'wheel',
49
+ ]);
50
+ expect(groupQuery('darwin', '0')).toEqual([
51
+ '/usr/bin/dscacheutil',
52
+ '-q',
53
+ 'group',
54
+ '-a',
55
+ 'gid',
56
+ '0',
57
+ ]);
58
+ expect(userQuery('linux', 'someone')).toEqual(['getent', 'passwd', 'someone']);
59
+ expect(groupQuery('linux', 'wheel')).toEqual(['getent', 'group', 'wheel']);
60
+ });
61
+ });
62
+
63
+ describe('parseUser', () => {
64
+ test('dscacheutil: the first record only, and never the password field', () => {
65
+ const user = parseUser('darwin', DSCACHE_USER);
66
+ expect(user).toEqual({ gid: 20, home: '/Users/someone', uid: 501 });
67
+ expect(JSON.stringify(user)).not.toContain('*');
68
+ });
69
+
70
+ test('dscacheutil: an unknown user prints nothing (measured) and parses to undefined', () => {
71
+ expect(parseUser('darwin', '')).toBeUndefined();
72
+ expect(parseUser('darwin', 'name: partial\nuid: 1\n')).toBeUndefined();
73
+ });
74
+
75
+ test('getent passwd', () => {
76
+ expect(parseUser('linux', 'someone:x:1000:1000:Some One:/home/someone:/bin/bash\n')).toEqual({
77
+ gid: 1000,
78
+ home: '/home/someone',
79
+ uid: 1000,
80
+ });
81
+ expect(parseUser('linux', '')).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ describe('parseGroup', () => {
86
+ test('dscacheutil and getent', () => {
87
+ expect(parseGroup('darwin', 'name: wheel\npassword: *\ngid: 0\nusers: root\n')).toBe(0);
88
+ expect(parseGroup('darwin', '')).toBeUndefined();
89
+ expect(parseGroup('linux', 'staff:x:50:someone\n')).toBe(50);
90
+ expect(parseGroup('linux', '')).toBeUndefined();
91
+ });
92
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * User and group lookups, as argv plus a parser — split out so the parsing is tested against the
3
+ * real output shapes without running anything.
4
+ *
5
+ * ★ macOS FIRST, getent ELSEWHERE. macOS has no `getent`; its directory service answers through
6
+ * `dscacheutil`. Shape MEASURED 2026-09-21 on macOS 27.2 (read-only query):
7
+ *
8
+ * name: someone
9
+ * password: ********
10
+ * uid: 501
11
+ * gid: 20
12
+ * dir: /Users/someone
13
+ * shell: /bin/zsh
14
+ *
15
+ * An unknown uid printed NOTHING and exited 0 — so "no record" is an empty parse, never an
16
+ * exit code. `getent` exits 2 for an unknown key; that also parses as no record.
17
+ * ⛔ NEVER PARSE OR KEEP THE `password:` LINE. It is a placeholder on macOS, but a parser that
18
+ * copies every field would carry a hash from any host where it is not.
19
+ */
20
+ import type { HostUser } from './runner.ts';
21
+
22
+ const NUMERIC = /^\d+$/;
23
+
24
+ /** argv for looking up a user, by name or numeric id. */
25
+ export const userQuery = (platform: string, nameOrId: string): readonly string[] =>
26
+ platform === 'darwin'
27
+ ? [
28
+ '/usr/bin/dscacheutil',
29
+ '-q',
30
+ 'user',
31
+ '-a',
32
+ NUMERIC.test(nameOrId) ? 'uid' : 'name',
33
+ nameOrId,
34
+ ]
35
+ : ['getent', 'passwd', nameOrId];
36
+
37
+ /** argv for looking up a group, by name or numeric id. */
38
+ export const groupQuery = (platform: string, nameOrId: string): readonly string[] =>
39
+ platform === 'darwin'
40
+ ? [
41
+ '/usr/bin/dscacheutil',
42
+ '-q',
43
+ 'group',
44
+ '-a',
45
+ NUMERIC.test(nameOrId) ? 'gid' : 'name',
46
+ nameOrId,
47
+ ]
48
+ : ['getent', 'group', nameOrId];
49
+
50
+ /** The first `key: value` record in dscacheutil output, minus any password field. */
51
+ const firstRecord = (stdout: string): Map<string, string> => {
52
+ const fields = new Map<string, string>();
53
+ for (const line of stdout.split('\n')) {
54
+ if (line.trim() === '') {
55
+ if (fields.size > 0) break; // ⚠️ records are blank-line separated; keep the first only
56
+ continue;
57
+ }
58
+ const at = line.indexOf(': ');
59
+ if (at <= 0) continue;
60
+ const key = line.slice(0, at);
61
+ if (key === 'password') continue;
62
+ fields.set(key, line.slice(at + 2).trim());
63
+ }
64
+ return fields;
65
+ };
66
+
67
+ const toId = (text: string | undefined): number | undefined =>
68
+ text !== undefined && NUMERIC.test(text) ? Number(text) : undefined;
69
+
70
+ export const parseUser = (platform: string, stdout: string): HostUser | undefined => {
71
+ if (platform === 'darwin') {
72
+ const record = firstRecord(stdout);
73
+ const uid = toId(record.get('uid'));
74
+ const gid = toId(record.get('gid'));
75
+ const home = record.get('dir');
76
+ return uid === undefined || gid === undefined || home === undefined
77
+ ? undefined
78
+ : { gid, home, uid };
79
+ }
80
+ // getent passwd: name:x:uid:gid:gecos:home:shell
81
+ const fields = (stdout.split('\n')[0] ?? '').split(':');
82
+ const uid = toId(fields[2]);
83
+ const gid = toId(fields[3]);
84
+ const home = fields[5];
85
+ return uid === undefined || gid === undefined || home === undefined || home === ''
86
+ ? undefined
87
+ : { gid, home, uid };
88
+ };
89
+
90
+ export const parseGroup = (platform: string, stdout: string): number | undefined => {
91
+ if (platform === 'darwin') return toId(firstRecord(stdout).get('gid'));
92
+ // getent group: name:x:gid:members
93
+ return toId((stdout.split('\n')[0] ?? '').split(':')[2]);
94
+ };