@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,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&amp;b</key>\n\t<string>&lt;x&gt; &amp; "y" \'z\'</string>',
98
+ );
99
+ });
100
+
101
+ test('a would-be CDATA end or entity is inert', () => {
102
+ expect(escapeText(']]>&amp;', 'p')).toBe(']]&gt;&amp;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&#13;\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
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * An XML property list serializer — the subset launchd.plist(5) reads, and nothing else.
3
+ *
4
+ * ★ WHY NOT AN npm LIBRARY. Nothing in this workspace depends on one (checked 2026-09-21: no
5
+ * `plist`, `xmlbuilder` or `@xmldom/xmldom` anywhere under node_modules), and the common one pulls
6
+ * an XML DOM in for PARSING, which a provider that only RENDERS never does. The subset a launchd
7
+ * job needs is five node types, so this is ~100 lines plus exhaustive tests (plist.test.ts), which
8
+ * also round-trip every case through Apple's own `plutil` when the tests run on macOS.
9
+ *
10
+ * ⛔ THE OUTPUT MUST BE DETERMINISTIC. LaunchdJob diffs by the SHA-256 of these bytes, so two
11
+ * renders of one declaration must be byte-identical: dict keys are sorted, indentation is fixed,
12
+ * and nothing depends on insertion order. A non-deterministic render is a forever-`update`.
13
+ * ⚠️ AND A CHANGE TO THIS FILE'S OUTPUT RESTARTS EVERY JOB. A new header, indent or escape changes
14
+ * every digest, so the next deploy after the kit upgrade updates — restarts — every declared
15
+ * job at once. Treat any byte change here as a breaking change and say so in its changeset.
16
+ *
17
+ * ⛔ REFUSE, NEVER COERCE. `undefined`, `null`, a fraction, NaN, a class instance or a character XML
18
+ * 1.0 cannot carry is a thrown PlistError naming the path, not a silently dropped key. A dropped
19
+ * `KeepAlive` is a daemon that never restarts, and nothing downstream would say why.
20
+ */
21
+
22
+ /** Every value launchd reads. ⚠️ No `<real>`, `<date>` or `<data>`: no launchd key needs one. */
23
+ export type PlistValue =
24
+ | string
25
+ | number
26
+ | boolean
27
+ | readonly PlistValue[]
28
+ | { readonly [key: string]: PlistValue };
29
+
30
+ export type PlistDict = { readonly [key: string]: PlistValue };
31
+
32
+ export class PlistError extends Error {
33
+ constructor(
34
+ readonly path: string,
35
+ detail: string,
36
+ ) {
37
+ super(`plist ${path === '' ? '(root)' : path}: ${detail}`);
38
+ this.name = 'PlistError';
39
+ }
40
+ }
41
+
42
+ const HEADER =
43
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
44
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ' +
45
+ '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
46
+ '<plist version="1.0">\n';
47
+
48
+ /** ⚠️ A cycle, or a runaway generator, would otherwise recurse until the stack dies. */
49
+ const MAX_DEPTH = 32;
50
+
51
+ /**
52
+ * ⛔ CHARACTERS XML 1.0 CANNOT CARRY AT ALL — not even escaped (XML 1.0 §2.2 `Char`). C0 controls
53
+ * other than tab, LF and CR; U+FFFE / U+FFFF; and an unpaired surrogate, which has no UTF-8 form.
54
+ */
55
+ // ★ The `u` flag makes a VALID surrogate pair one code point above U+FFFF, outside this class, so
56
+ // only an unpaired half matches the \uD800-\uDFFF range.
57
+ // oxlint-disable-next-line no-control-regex -- matching control characters is the point
58
+ const FORBIDDEN = /[\x00-\x08\x0B\x0C\x0E-\x1F\uD800-\uDFFF\uFFFE\uFFFF]/u;
59
+
60
+ /**
61
+ * Escape text content.
62
+ * ⚠️ CR IS A CHARACTER REFERENCE, NOT A LITERAL. An XML parser normalises a literal CR (and CRLF)
63
+ * to LF before the application sees it (XML 1.0 §2.11), so `a\r\nb` would come back as `a\nb` and
64
+ * the argument launchd passes would differ from the one declared. `&#13;` survives normalisation.
65
+ */
66
+ export const escapeText = (text: string, path: string): string => {
67
+ const bad = FORBIDDEN.exec(text);
68
+ if (bad !== null) {
69
+ const code = bad[0].codePointAt(0) ?? 0;
70
+ throw new PlistError(
71
+ path,
72
+ `U+${code.toString(16).toUpperCase().padStart(4, '0')} cannot appear in XML 1.0`,
73
+ );
74
+ }
75
+ return text
76
+ .replaceAll('&', '&amp;')
77
+ .replaceAll('<', '&lt;')
78
+ .replaceAll('>', '&gt;')
79
+ .replaceAll('\r', '&#13;');
80
+ };
81
+
82
+ const isPlainObject = (value: object): boolean => {
83
+ const proto = Object.getPrototypeOf(value) as unknown;
84
+ return proto === Object.prototype || proto === null;
85
+ };
86
+
87
+ const renderValue = (value: unknown, path: string, depth: number, out: string[]): void => {
88
+ const pad = '\t'.repeat(depth);
89
+ if (depth > MAX_DEPTH) throw new PlistError(path, `nested deeper than ${String(MAX_DEPTH)}`);
90
+ if (typeof value === 'string') {
91
+ out.push(`${pad}<string>${escapeText(value, path)}</string>`);
92
+ return;
93
+ }
94
+ if (typeof value === 'boolean') {
95
+ out.push(`${pad}<${value ? 'true' : 'false'}/>`);
96
+ return;
97
+ }
98
+ if (typeof value === 'number') {
99
+ // ⛔ launchd reads every numeric key as an integer; 1.5 would be a silently different job.
100
+ if (!Number.isSafeInteger(value)) {
101
+ throw new PlistError(path, `${String(value)} is not a safe integer (no <real> support)`);
102
+ }
103
+ out.push(`${pad}<integer>${String(value === 0 ? 0 : value)}</integer>`);
104
+ return;
105
+ }
106
+ if (Array.isArray(value)) {
107
+ if (value.length === 0) {
108
+ out.push(`${pad}<array/>`);
109
+ return;
110
+ }
111
+ out.push(`${pad}<array>`);
112
+ for (const [index, item] of (value as readonly unknown[]).entries()) {
113
+ renderValue(item, `${path}[${String(index)}]`, depth + 1, out);
114
+ }
115
+ out.push(`${pad}</array>`);
116
+ return;
117
+ }
118
+ if (typeof value === 'object' && value !== null && isPlainObject(value)) {
119
+ renderDict(value as Record<string, unknown>, path, depth, out);
120
+ return;
121
+ }
122
+ const kind = value === null ? 'null' : typeof value;
123
+ throw new PlistError(path, `${kind} has no plist form — omit the key instead`);
124
+ };
125
+
126
+ const renderDict = (
127
+ dict: Record<string, unknown>,
128
+ path: string,
129
+ depth: number,
130
+ out: string[],
131
+ ): void => {
132
+ const pad = '\t'.repeat(depth);
133
+ // ★ Code-unit order, not localeCompare: the same bytes on every machine and every locale.
134
+ const keys = Object.keys(dict).sort();
135
+ if (keys.length === 0) {
136
+ out.push(`${pad}<dict/>`);
137
+ return;
138
+ }
139
+ out.push(`${pad}<dict>`);
140
+ for (const key of keys) {
141
+ const child = path === '' ? key : `${path}.${key}`;
142
+ out.push(`${pad}\t<key>${escapeText(key, child)}</key>`);
143
+ renderValue(dict[key], child, depth + 1, out);
144
+ }
145
+ out.push(`${pad}</dict>`);
146
+ };
147
+
148
+ /** Render a top-level dict as a complete XML plist document, ending in a newline. */
149
+ export const renderPlist = (dict: PlistDict): string => {
150
+ if (typeof dict !== 'object' || dict === null || Array.isArray(dict) || !isPlainObject(dict)) {
151
+ throw new PlistError('', 'the top level of a launchd plist must be a dict');
152
+ }
153
+ const out: string[] = [];
154
+ renderDict(dict as Record<string, unknown>, '', 0, out);
155
+ return `${HEADER}${out.join('\n')}\n</plist>\n`;
156
+ };