@homeflare/alchemy 0.20.0 → 0.22.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 (191) hide show
  1. package/README.md +27 -0
  2. package/dist/caddy/access-forward-auth.d.ts +56 -0
  3. package/dist/caddy/access-forward-auth.d.ts.map +1 -0
  4. package/dist/caddy/index.d.ts +6 -0
  5. package/dist/caddy/index.d.ts.map +1 -1
  6. package/dist/caddy/index.js +169 -75
  7. package/dist/caddy/index.js.map +10 -7
  8. package/dist/cloudflare/mcp-server-api.d.ts +135 -0
  9. package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
  10. package/dist/cloudflare/mcp-server-form.d.ts +116 -0
  11. package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/launchd/file-converge.d.ts +70 -0
  15. package/dist/launchd/file-converge.d.ts.map +1 -0
  16. package/dist/launchd/file-identity.d.ts +32 -0
  17. package/dist/launchd/file-identity.d.ts.map +1 -0
  18. package/dist/launchd/host-file-form.d.ts +2 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts +11 -24
  21. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  22. package/dist/launchd/host-file.d.ts.map +1 -1
  23. package/dist/launchd/index.d.ts +2 -0
  24. package/dist/launchd/index.d.ts.map +1 -1
  25. package/dist/launchd/index.js +225 -120
  26. package/dist/launchd/index.js.map +13 -10
  27. package/dist/launchd/local-runner.d.ts.map +1 -1
  28. package/dist/launchd/port-claims.d.ts +31 -0
  29. package/dist/launchd/port-claims.d.ts.map +1 -0
  30. package/dist/launchd/runner.d.ts +17 -2
  31. package/dist/launchd/runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-runner.d.ts +11 -0
  33. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  34. package/dist/linux/index.js.map +3 -3
  35. package/dist/linux/sudo-listing.d.ts +47 -0
  36. package/dist/linux/sudo-listing.d.ts.map +1 -0
  37. package/dist/proxmox/credential-literals.d.ts +1 -1
  38. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  39. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  40. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  41. package/dist/proxmox/generated/pbs.d.ts +7 -0
  42. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  43. package/dist/proxmox/generated/pve.d.ts +7 -0
  44. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  45. package/dist/proxmox/index.d.ts +1 -1
  46. package/dist/proxmox/index.d.ts.map +1 -1
  47. package/dist/proxmox/index.js +2 -2
  48. package/dist/proxmox/index.js.map +9 -9
  49. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  50. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  51. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  52. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  53. package/dist/release/archive.d.ts +11 -0
  54. package/dist/release/archive.d.ts.map +1 -0
  55. package/dist/release/binary-claim.d.ts +53 -0
  56. package/dist/release/binary-claim.d.ts.map +1 -0
  57. package/dist/release/binary-diff.d.ts +38 -0
  58. package/dist/release/binary-diff.d.ts.map +1 -0
  59. package/dist/release/binary-form.d.ts +118 -0
  60. package/dist/release/binary-form.d.ts.map +1 -0
  61. package/dist/release/binary-lifecycle.d.ts +55 -0
  62. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  63. package/dist/release/binary-read.d.ts +11 -0
  64. package/dist/release/binary-read.d.ts.map +1 -0
  65. package/dist/release/binary.d.ts +68 -0
  66. package/dist/release/binary.d.ts.map +1 -0
  67. package/dist/release/catalog.d.ts +113 -0
  68. package/dist/release/catalog.d.ts.map +1 -0
  69. package/dist/release/checksums.d.ts +22 -0
  70. package/dist/release/checksums.d.ts.map +1 -0
  71. package/dist/release/declared-pins.d.ts +4 -0
  72. package/dist/release/declared-pins.d.ts.map +1 -0
  73. package/dist/release/download.d.ts +44 -0
  74. package/dist/release/download.d.ts.map +1 -0
  75. package/dist/release/fake-deploy.d.ts +48 -0
  76. package/dist/release/fake-deploy.d.ts.map +1 -0
  77. package/dist/release/fake-engine.d.ts +8 -0
  78. package/dist/release/fake-engine.d.ts.map +1 -0
  79. package/dist/release/fake-install.d.ts +39 -0
  80. package/dist/release/fake-install.d.ts.map +1 -0
  81. package/dist/release/fake-provider.d.ts +58 -0
  82. package/dist/release/fake-provider.d.ts.map +1 -0
  83. package/dist/release/fake-release.d.ts +95 -0
  84. package/dist/release/fake-release.d.ts.map +1 -0
  85. package/dist/release/index.d.ts +25 -0
  86. package/dist/release/index.d.ts.map +1 -0
  87. package/dist/release/index.js +1425 -0
  88. package/dist/release/index.js.map +36 -0
  89. package/dist/release/openbao.d.ts +35 -0
  90. package/dist/release/openbao.d.ts.map +1 -0
  91. package/dist/release/providers.d.ts +19 -0
  92. package/dist/release/providers.d.ts.map +1 -0
  93. package/dist/release/refused.d.ts +39 -0
  94. package/dist/release/refused.d.ts.map +1 -0
  95. package/dist/release/tar.d.ts +14 -0
  96. package/dist/release/tar.d.ts.map +1 -0
  97. package/dist/release/victoria.d.ts +36 -0
  98. package/dist/release/victoria.d.ts.map +1 -0
  99. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  100. package/docs/launchd-ports.md +109 -0
  101. package/docs/launchd.md +5 -0
  102. package/docs/release-binary-catalogs.md +138 -0
  103. package/docs/release-binary-measured.md +163 -0
  104. package/docs/release-binary-openbao.md +151 -0
  105. package/docs/release-binary-refusals.md +57 -0
  106. package/docs/release-binary-upstream.md +163 -0
  107. package/docs/release-binary.md +165 -0
  108. package/package.json +5 -1
  109. package/src/caddy/access-forward-auth.test.ts +63 -0
  110. package/src/caddy/access-forward-auth.ts +120 -0
  111. package/src/caddy/index.ts +10 -0
  112. package/src/caddy/providers.test.ts +4 -0
  113. package/src/cloudflare/mcp-server-api.ts +294 -0
  114. package/src/cloudflare/mcp-server-form.ts +219 -0
  115. package/src/index.ts +1 -0
  116. package/src/launchd/file-converge.ts +233 -0
  117. package/src/launchd/file-identity.ts +69 -0
  118. package/src/launchd/host-file-alias.test.ts +65 -0
  119. package/src/launchd/host-file-form.ts +2 -1
  120. package/src/launchd/host-file-lifecycle.ts +25 -144
  121. package/src/launchd/host-file.ts +8 -2
  122. package/src/launchd/index.ts +2 -0
  123. package/src/launchd/local-runner.test.ts +12 -1
  124. package/src/launchd/local-runner.ts +2 -0
  125. package/src/launchd/port-claims.test.ts +124 -0
  126. package/src/launchd/port-claims.ts +102 -0
  127. package/src/launchd/providers.test.ts +3 -0
  128. package/src/launchd/runner.ts +17 -2
  129. package/src/launchd/sudo-runner.ts +11 -0
  130. package/src/linux/sudo-listing.ts +122 -0
  131. package/src/proxmox/credential-literals.ts +1 -1
  132. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  133. package/src/proxmox/generated/pbs.ts +7 -0
  134. package/src/proxmox/generated/pve.ts +7 -0
  135. package/src/proxmox/index.ts +1 -1
  136. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  137. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  138. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  139. package/src/proxmox/pbs-notification-target.ts +1 -1
  140. package/src/release/adopt-parity.test.ts +106 -0
  141. package/src/release/apply-pins.test.ts +128 -0
  142. package/src/release/archive.ts +94 -0
  143. package/src/release/binary-alias.test.ts +109 -0
  144. package/src/release/binary-claim.ts +80 -0
  145. package/src/release/binary-claims.test.ts +47 -0
  146. package/src/release/binary-diff.ts +73 -0
  147. package/src/release/binary-form.ts +229 -0
  148. package/src/release/binary-lifecycle.test.ts +90 -0
  149. package/src/release/binary-lifecycle.ts +202 -0
  150. package/src/release/binary-read.ts +55 -0
  151. package/src/release/binary-refusals.test.ts +192 -0
  152. package/src/release/binary-sudo.test.ts +93 -0
  153. package/src/release/binary.ts +181 -0
  154. package/src/release/catalog.test.ts +167 -0
  155. package/src/release/catalog.ts +238 -0
  156. package/src/release/checksums.test.ts +35 -0
  157. package/src/release/checksums.ts +43 -0
  158. package/src/release/declared-pins.ts +39 -0
  159. package/src/release/download.test.ts +138 -0
  160. package/src/release/download.ts +110 -0
  161. package/src/release/error-channel.test.ts +69 -0
  162. package/src/release/fake-deploy.ts +87 -0
  163. package/src/release/fake-engine.ts +50 -0
  164. package/src/release/fake-install.ts +59 -0
  165. package/src/release/fake-provider.ts +68 -0
  166. package/src/release/fake-release.ts +185 -0
  167. package/src/release/fixtures/openbao/checksums.txt +83 -0
  168. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  169. package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
  170. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
  171. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  172. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  173. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  174. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  175. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  176. package/src/release/index.ts +44 -0
  177. package/src/release/openbao.test.ts +120 -0
  178. package/src/release/openbao.ts +86 -0
  179. package/src/release/plan.test.ts +189 -0
  180. package/src/release/provider-refusals.test.ts +48 -0
  181. package/src/release/providers.test.ts +175 -0
  182. package/src/release/providers.ts +24 -0
  183. package/src/release/recovery-read.test.ts +44 -0
  184. package/src/release/refused.ts +40 -0
  185. package/src/release/tar.test.ts +142 -0
  186. package/src/release/tar.ts +195 -0
  187. package/src/release/victoria.test.ts +108 -0
  188. package/src/release/victoria.ts +154 -0
  189. package/src/{proxmox → secrets}/write-only.ts +6 -0
  190. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  191. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -182,6 +182,31 @@ var refuseTakeover = (owner, what) => Effect4.gen(function* () {
182
182
  });
183
183
  var claimFor = (owner) => (what) => refuseTakeover(owner, what);
184
184
 
185
+ // src/launchd/file-identity.ts
186
+ var sameEntry = (a, b) => {
187
+ if (a === undefined || b === undefined)
188
+ return false;
189
+ if (a.dev === undefined || a.ino === undefined || b.dev === undefined || b.ino === undefined) {
190
+ return;
191
+ }
192
+ return a.dev === b.dev && a.ino === b.ino;
193
+ };
194
+ var oneFile = async (runner, a, b) => {
195
+ if (a === b)
196
+ return true;
197
+ const [left, right] = await Promise.all([runner.stat(a), runner.stat(b)]);
198
+ return sameEntry(left, right);
199
+ };
200
+ var leaveOldPath = async (runner, oldPath, path, remove, refuse) => {
201
+ const same = await oneFile(runner, oldPath, path);
202
+ if (same === true)
203
+ return;
204
+ await remove();
205
+ if (same === undefined && await runner.stat(path) === undefined) {
206
+ throw refuse(path, `removing the old path ${oldPath} removed this file too: they are one file under two ` + "spellings (a symlinked parent, or case on a case-insensitive volume). Deploy again to " + "reinstall it, and keep one spelling.");
207
+ }
208
+ };
209
+
185
210
  // src/launchd/host-effect.ts
186
211
  import * as Effect5 from "effect/Effect";
187
212
  var lift = (step) => Effect5.tryPromise({
@@ -195,79 +220,6 @@ var resolvedString = (news, key) => {
195
220
  return typeof value === "string" ? value : undefined;
196
221
  };
197
222
 
198
- // src/launchd/secret-tripwire.ts
199
- var ALTERNATIVE = "props are stored unencrypted in Alchemy state. Have a secret renderer (e.g. openbao-agent) " + "write it to a root-owned file and declare the file path instead (FOO_FILE=/path, --foo-file /path).";
200
- var SECRET_NAME = /(?:^|_)(?:TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_KEY|PRIVATE_KEY|SECRET_KEY|CREDENTIALS?)$/i;
201
- var INLINE_FLAG = /^--?[a-z0-9-]*(?:token|password|passwd|passphrase|secret|api-?key)=./i;
202
- var BARE_FLAG = /^--?(?:[a-z0-9]+-)*(?:token|password|passwd|passphrase|secret|api-?key)$/i;
203
- var PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
204
- var keyLeaves = (value, path) => {
205
- if (typeof value === "string")
206
- return PRIVATE_KEY.test(value) ? [path] : [];
207
- if (Array.isArray(value)) {
208
- return value.flatMap((item, index) => keyLeaves(item, `${path}[${String(index)}]`));
209
- }
210
- if (typeof value === "object" && value !== null) {
211
- return Object.entries(value).flatMap(([key, item]) => keyLeaves(item, `${path}.${key}`));
212
- }
213
- return [];
214
- };
215
- var jobSecretProblems = (environment, argv, extraKeys) => {
216
- const found = [];
217
- for (const [name, value] of Object.entries(environment ?? {})) {
218
- if (SECRET_NAME.test(name))
219
- found.push(`environment ${name} looks like a secret: ${ALTERNATIVE}`);
220
- else if (PRIVATE_KEY.test(value))
221
- found.push(`environment ${name} holds a private key: ${ALTERNATIVE}`);
222
- }
223
- for (const [index, arg] of argv.entries()) {
224
- if (INLINE_FLAG.test(arg)) {
225
- found.push(`programArguments[${String(index)}] passes a secret inline: ${ALTERNATIVE}`);
226
- } else if (BARE_FLAG.test(arg) && index + 1 < argv.length) {
227
- found.push(`programArguments[${String(index + 1)}] follows a secret flag: ${ALTERNATIVE}`);
228
- } else if (PRIVATE_KEY.test(arg)) {
229
- found.push(`programArguments[${String(index)}] holds a private key: ${ALTERNATIVE}`);
230
- }
231
- }
232
- for (const path of keyLeaves(extraKeys ?? {}, "extraKeys")) {
233
- found.push(`${path} holds a private key: ${ALTERNATIVE}`);
234
- }
235
- return found;
236
- };
237
- var fileSecretProblems = (content) => PRIVATE_KEY.test(content) ? [`content holds a private key: ${ALTERNATIVE}`] : [];
238
-
239
- // src/launchd/host-file-form.ts
240
- var DEFAULT_MODE = 420;
241
- var identityProblems = (name, value) => {
242
- if (value === undefined)
243
- return [];
244
- if (typeof value === "number") {
245
- return Number.isSafeInteger(value) && value >= 0 ? [] : [`${name} must be a non-negative integer id`];
246
- }
247
- return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value) || /^\d+$/.test(value) ? [] : [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
248
- };
249
- var pathProblems = (path) => {
250
- const found = [];
251
- if (!path.startsWith("/"))
252
- found.push("path must be absolute");
253
- if (path.endsWith("/") || path.split("/").slice(1).some((part) => part === "" || part === "." || part === "..")) {
254
- found.push('path must be normalised (no trailing slash, no empty, "." or ".." segments)');
255
- }
256
- if (path.includes("\x00"))
257
- found.push("path contains NUL");
258
- return found;
259
- };
260
- var fileProblems = (props) => {
261
- const found = pathProblems(props.path);
262
- const mode = props.mode ?? DEFAULT_MODE;
263
- if (!Number.isSafeInteger(mode) || mode < 0 || mode > 4095)
264
- found.push("mode must be 0–0o7777");
265
- found.push(...identityProblems("owner", props.owner));
266
- found.push(...identityProblems("group", props.group));
267
- found.push(...fileSecretProblems(props.content));
268
- return found;
269
- };
270
-
271
223
  // src/launchd/job-form.ts
272
224
  import { createHash } from "node:crypto";
273
225
 
@@ -466,33 +418,22 @@ class HostRunnerService extends Context.Service()("homeflare/launchd/HostRunner"
466
418
  var hostRunnerLayer = (runner) => Layer.succeed(HostRunnerService, runner);
467
419
  var canActAsRoot = (runner) => runner.privileged || runner.effectiveUid() === 0;
468
420
 
469
- // src/launchd/host-file-lifecycle.ts
470
- var refuse = (path, message) => new Error(`Host.File ${path}: ${message}`);
421
+ // src/launchd/file-converge.ts
471
422
  var numericId = (value) => typeof value === "number" ? value : /^\d+$/.test(value) ? Number(value) : undefined;
472
- var desiredFile = async (runner, props) => {
473
- const found = fileProblems(props);
474
- if (found.length > 0)
475
- throw refuse(props.path, found.join("; "));
476
- const bytes = new TextEncoder().encode(props.content);
423
+ var resolveIds = async (runner, declared, path, refuse) => {
477
424
  let uid;
478
425
  let gid;
479
- if (props.owner !== undefined) {
480
- uid = numericId(props.owner) ?? (await runner.lookupUser(String(props.owner)))?.uid;
426
+ if (declared.owner !== undefined) {
427
+ uid = numericId(declared.owner) ?? (await runner.lookupUser(String(declared.owner)))?.uid;
481
428
  if (uid === undefined)
482
- throw refuse(props.path, `no user ${String(props.owner)} on this host`);
429
+ throw refuse(path, `no user ${String(declared.owner)} on this host`);
483
430
  }
484
- if (props.group !== undefined) {
485
- gid = numericId(props.group) ?? await runner.lookupGroup(String(props.group));
431
+ if (declared.group !== undefined) {
432
+ gid = numericId(declared.group) ?? await runner.lookupGroup(String(declared.group));
486
433
  if (gid === undefined)
487
- throw refuse(props.path, `no group ${String(props.group)} on this host`);
434
+ throw refuse(path, `no group ${String(declared.group)} on this host`);
488
435
  }
489
- return {
490
- bytes,
491
- mode: props.mode ?? DEFAULT_MODE,
492
- sha256: sha256Hex(bytes),
493
- ...uid === undefined ? {} : { uid },
494
- ...gid === undefined ? {} : { gid }
495
- };
436
+ return { ...uid === undefined ? {} : { uid }, ...gid === undefined ? {} : { gid } };
496
437
  };
497
438
  var readFileAttributes = async (runner, path) => {
498
439
  const stat = await runner.stat(path);
@@ -513,52 +454,64 @@ var writeOptionsOf = (want) => ({
513
454
  ...want.uid === undefined ? {} : { uid: want.uid },
514
455
  ...want.gid === undefined ? {} : { gid: want.gid }
515
456
  });
516
- var matches = (live, want) => live.sha256 === want.sha256 && live.mode === want.mode && (want.uid === undefined || live.uid === want.uid) && (want.gid === undefined || live.gid === want.gid);
517
- var diffFile = async (runner, news, output) => {
518
- const want = await desiredFile(runner, news);
457
+ var fileMatches = (live, want) => live.sha256 === want.sha256 && live.mode === want.mode && (want.uid === undefined || live.uid === want.uid) && (want.gid === undefined || live.gid === want.gid);
458
+ var diffTarget = async (runner, want, output) => {
519
459
  const writes = async (diff) => {
520
- await runner.checkWrite?.(news.path, writeOptionsOf(want));
460
+ await runner.checkWrite?.(want.path, writeOptionsOf(want));
521
461
  return diff;
522
462
  };
523
- if (news.path !== output.path)
524
- return writes({ action: "replace" });
463
+ if (want.path !== output.path) {
464
+ const same = await oneFile(runner, want.path, output.path);
465
+ return writes({ action: same === true ? "update" : "replace" });
466
+ }
525
467
  if (want.sha256 !== output.sha256)
526
468
  return writes({ action: "update" });
527
- const live = await readFileAttributes(runner, news.path);
528
- return live !== undefined && matches(live, want) ? { action: "noop" } : writes({ action: "update" });
469
+ const live = await readFileAttributes(runner, want.path);
470
+ return live !== undefined && fileMatches(live, want) ? { action: "noop" } : writes({ action: "update" });
529
471
  };
530
- var reconcileFile = async (runner, props, output, adopt = false) => {
531
- const want = await desiredFile(runner, props);
472
+ var convergeFile = async (runner, spec, output, adopt = false) => {
473
+ const { want, refuse } = spec;
474
+ const path = want.path;
532
475
  if (want.uid !== undefined && want.uid !== runner.effectiveUid() && !canActAsRoot(runner)) {
533
- throw refuse(props.path, `owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` + "or through a privileged HostRunner (sudoRunner() with this path under a prefix). This " + "provider never calls sudo itself.");
476
+ throw refuse(path, `owner ${String(spec.owner)} is not the deploying user; only root may chown. Deploy as root ` + "or through a privileged HostRunner (sudoRunner() with this path under a prefix). This " + "provider never calls sudo itself.");
534
477
  }
535
- const stat = await runner.stat(props.path);
478
+ const stat = await runner.stat(path);
536
479
  if (stat !== undefined && stat.kind !== "file") {
537
- throw refuse(props.path, `is a ${stat.kind}; remove it from whatever owns it before declaring it here`);
480
+ throw refuse(path, `is a ${stat.kind}; remove it from whatever owns it before declaring it here`);
538
481
  }
539
- const before = stat === undefined ? undefined : await readFileAttributes(runner, props.path);
540
- const moved = output !== undefined && output.path !== props.path;
482
+ const before = stat === undefined ? undefined : await readFileAttributes(runner, path);
483
+ const moved = output !== undefined && output.path !== path;
541
484
  const prior = moved ? undefined : output;
542
485
  const takeOver = adopt && output === undefined;
543
- if (prior === undefined && before !== undefined && !matches(before, want) && !takeOver) {
544
- throw refuse(props.path, "already exists and is not this resource. Remove it, or declare it as a new resource and " + "deploy with --adopt.");
486
+ if (prior === undefined && before !== undefined && !fileMatches(before, want) && !takeOver) {
487
+ throw refuse(path, "already exists and is not this resource. Remove it, or declare it as a new resource and " + "deploy with --adopt.");
545
488
  }
546
- if (before === undefined || !matches(before, want)) {
547
- await runner.writeFileAtomic(props.path, want.bytes, writeOptionsOf(want));
489
+ if (before === undefined || !fileMatches(before, want)) {
490
+ const bytes = await spec.bytesFor(before);
491
+ if (sha256Hex(bytes) !== want.sha256) {
492
+ throw refuse(path, `the bytes to write do not hash to ${want.sha256}. Nothing was written.`);
493
+ }
494
+ await runner.writeFileAtomic(path, bytes, writeOptionsOf(want));
548
495
  }
549
- const after = await readFileAttributes(runner, props.path);
550
- if (after === undefined || !matches(after, want)) {
496
+ const rollBack = async () => {
551
497
  if (before === undefined)
552
- await runner.removeFile(props.path).catch(() => {
498
+ await runner.removeFile(path).catch(() => {
553
499
  return;
554
500
  });
555
- throw refuse(props.path, "the write returned but the file on disk does not match the declaration");
501
+ };
502
+ const after = await readFileAttributes(runner, path).catch(async (cause) => {
503
+ await rollBack();
504
+ throw refuse(path, `the write returned but reading it back failed: ${String(cause)}`);
505
+ });
506
+ if (after === undefined || !fileMatches(after, want)) {
507
+ await rollBack();
508
+ throw refuse(path, "the write returned but the file on disk does not match the declaration");
556
509
  }
557
510
  if (moved)
558
- await deleteFile(runner, output);
511
+ await leaveOldPath(runner, output.path, path, () => removeWholeFile(runner, output, refuse), refuse);
559
512
  return after;
560
513
  };
561
- var deleteFile = async (runner, output) => {
514
+ var removeWholeFile = async (runner, output, refuse) => {
562
515
  const stat = await runner.stat(output.path);
563
516
  if (stat === undefined)
564
517
  return;
@@ -567,6 +520,102 @@ var deleteFile = async (runner, output) => {
567
520
  await runner.removeFile(output.path);
568
521
  };
569
522
 
523
+ // src/launchd/secret-tripwire.ts
524
+ var ALTERNATIVE = "props are stored unencrypted in Alchemy state. Have a secret renderer (e.g. openbao-agent) " + "write it to a root-owned file and declare the file path instead (FOO_FILE=/path, --foo-file /path).";
525
+ var SECRET_NAME = /(?:^|_)(?:TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_KEY|PRIVATE_KEY|SECRET_KEY|CREDENTIALS?)$/i;
526
+ var INLINE_FLAG = /^--?[a-z0-9-]*(?:token|password|passwd|passphrase|secret|api-?key)=./i;
527
+ var BARE_FLAG = /^--?(?:[a-z0-9]+-)*(?:token|password|passwd|passphrase|secret|api-?key)$/i;
528
+ var PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
529
+ var keyLeaves = (value, path) => {
530
+ if (typeof value === "string")
531
+ return PRIVATE_KEY.test(value) ? [path] : [];
532
+ if (Array.isArray(value)) {
533
+ return value.flatMap((item, index) => keyLeaves(item, `${path}[${String(index)}]`));
534
+ }
535
+ if (typeof value === "object" && value !== null) {
536
+ return Object.entries(value).flatMap(([key, item]) => keyLeaves(item, `${path}.${key}`));
537
+ }
538
+ return [];
539
+ };
540
+ var jobSecretProblems = (environment, argv, extraKeys) => {
541
+ const found = [];
542
+ for (const [name, value] of Object.entries(environment ?? {})) {
543
+ if (SECRET_NAME.test(name))
544
+ found.push(`environment ${name} looks like a secret: ${ALTERNATIVE}`);
545
+ else if (PRIVATE_KEY.test(value))
546
+ found.push(`environment ${name} holds a private key: ${ALTERNATIVE}`);
547
+ }
548
+ for (const [index, arg] of argv.entries()) {
549
+ if (INLINE_FLAG.test(arg)) {
550
+ found.push(`programArguments[${String(index)}] passes a secret inline: ${ALTERNATIVE}`);
551
+ } else if (BARE_FLAG.test(arg) && index + 1 < argv.length) {
552
+ found.push(`programArguments[${String(index + 1)}] follows a secret flag: ${ALTERNATIVE}`);
553
+ } else if (PRIVATE_KEY.test(arg)) {
554
+ found.push(`programArguments[${String(index)}] holds a private key: ${ALTERNATIVE}`);
555
+ }
556
+ }
557
+ for (const path of keyLeaves(extraKeys ?? {}, "extraKeys")) {
558
+ found.push(`${path} holds a private key: ${ALTERNATIVE}`);
559
+ }
560
+ return found;
561
+ };
562
+ var fileSecretProblems = (content) => PRIVATE_KEY.test(content) ? [`content holds a private key: ${ALTERNATIVE}`] : [];
563
+
564
+ // src/launchd/host-file-form.ts
565
+ var DEFAULT_MODE = 420;
566
+ var identityProblems = (name, value) => {
567
+ if (value === undefined)
568
+ return [];
569
+ if (typeof value === "number") {
570
+ return Number.isSafeInteger(value) && value >= 0 ? [] : [`${name} must be a non-negative integer id`];
571
+ }
572
+ return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value) || /^\d+$/.test(value) ? [] : [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
573
+ };
574
+ var pathProblems = (path) => {
575
+ const found = [];
576
+ if (!path.startsWith("/"))
577
+ found.push("path must be absolute");
578
+ if (path.endsWith("/") || path.split("/").slice(1).some((part) => part === "" || part === "." || part === "..")) {
579
+ found.push('path must be normalised (no trailing slash, no empty, "." or ".." segments)');
580
+ }
581
+ if (path.includes("\x00"))
582
+ found.push("path contains NUL");
583
+ return found;
584
+ };
585
+ var fileProblems = (props) => {
586
+ const found = pathProblems(props.path);
587
+ const mode = props.mode ?? DEFAULT_MODE;
588
+ if (!Number.isSafeInteger(mode) || mode < 0 || mode > 4095)
589
+ found.push("mode must be 0–0o7777");
590
+ found.push(...identityProblems("owner", props.owner));
591
+ found.push(...identityProblems("group", props.group));
592
+ found.push(...fileSecretProblems(props.content));
593
+ return found;
594
+ };
595
+
596
+ // src/launchd/host-file-lifecycle.ts
597
+ var refuse = (path, message) => new Error(`Host.File ${path}: ${message}`);
598
+ var desiredFile = async (runner, props) => {
599
+ const found = fileProblems(props);
600
+ if (found.length > 0)
601
+ throw refuse(props.path, found.join("; "));
602
+ const bytes = new TextEncoder().encode(props.content);
603
+ return {
604
+ bytes,
605
+ mode: props.mode ?? DEFAULT_MODE,
606
+ path: props.path,
607
+ sha256: sha256Hex(bytes),
608
+ ...await resolveIds(runner, props, props.path, refuse)
609
+ };
610
+ };
611
+ var diffFile = async (runner, news, output) => diffTarget(runner, await desiredFile(runner, news), output);
612
+ var reconcileFile = async (runner, props, output, adopt = false) => {
613
+ const want = await desiredFile(runner, props);
614
+ const spec = { bytesFor: async () => want.bytes, owner: props.owner, refuse, want };
615
+ return convergeFile(runner, spec, output, adopt);
616
+ };
617
+ var deleteFile = (runner, output) => removeWholeFile(runner, output, refuse);
618
+
570
619
  // src/launchd/host-file.ts
571
620
  var HostFile = Resource("Host.File");
572
621
  var HostFileProvider = () => Provider.effect(HostFile, Effect6.gen(function* () {
@@ -585,7 +634,9 @@ var HostFileProvider = () => Provider.effect(HostFile, Effect6.gen(function* ()
585
634
  if (isResolved(news))
586
635
  return lift(() => diffFile(runner, news, output));
587
636
  const path = resolvedString(news, "path");
588
- return Effect6.succeed(path !== undefined && path !== output.path ? { action: "replace" } : undefined);
637
+ if (path === undefined || path === output.path)
638
+ return Effect6.succeed(undefined);
639
+ return lift(async () => await oneFile(runner, path, output.path) === true ? undefined : { action: "replace" });
589
640
  },
590
641
  reconcile: ({ fqn, instanceId, news, output }) => Effect6.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) => lift(() => reconcileFile(runner, news, output, adopt))),
591
642
  delete: ({ output }) => lift(() => deleteFile(runner, output))
@@ -1126,7 +1177,9 @@ var localRunner = (options = {}) => {
1126
1177
  try {
1127
1178
  const stats = await lstat(path);
1128
1179
  return {
1180
+ dev: stats.dev,
1129
1181
  gid: stats.gid,
1182
+ ino: stats.ino,
1130
1183
  kind: kindOf(stats),
1131
1184
  mode: stats.mode & 4095,
1132
1185
  size: stats.size,
@@ -1141,6 +1194,55 @@ var localRunner = (options = {}) => {
1141
1194
  writeFileAtomic: writeAtomic
1142
1195
  };
1143
1196
  };
1197
+ // src/launchd/port-claims.ts
1198
+ import * as Data from "effect/Data";
1199
+ var describeAddress = (address) => address ?? "unspecified address";
1200
+ var describeOwner = (owner) => owner === "" ? "(empty owner)" : owner;
1201
+ var portClaimProblems = (claims) => {
1202
+ const found = [];
1203
+ const byPort = new Map;
1204
+ for (const claim of claims) {
1205
+ if (!Number.isSafeInteger(claim.port) || claim.port < 1 || claim.port > 65535) {
1206
+ found.push(`${describeOwner(claim.owner)}: port must be an integer from 1 to 65535, got ${String(claim.port)}`);
1207
+ }
1208
+ if (claim.owner === "") {
1209
+ found.push(`a claim on port ${String(claim.port)} has an empty owner`);
1210
+ }
1211
+ const list = byPort.get(claim.port) ?? [];
1212
+ list.push(claim);
1213
+ byPort.set(claim.port, list);
1214
+ }
1215
+ for (const [port, list] of byPort) {
1216
+ if (list.length < 2)
1217
+ continue;
1218
+ const seen = new Set;
1219
+ for (const claim of list) {
1220
+ if (claim.owner === "")
1221
+ continue;
1222
+ if (seen.has(claim.owner)) {
1223
+ found.push(`${claim.owner} claims port ${String(port)} more than once`);
1224
+ }
1225
+ seen.add(claim.owner);
1226
+ }
1227
+ const distinctOwners = [...new Set(list.map((claim) => claim.owner).filter((o) => o !== ""))];
1228
+ if (distinctOwners.length > 1) {
1229
+ const described = list.map((claim) => `${describeOwner(claim.owner)} (${describeAddress(claim.address)})`).join(" and ");
1230
+ found.push(`port ${String(port)} is claimed by more than one owner: ${described}`);
1231
+ }
1232
+ }
1233
+ return found;
1234
+ };
1235
+
1236
+ class PortRefused extends Data.TaggedError("PortRefused") {
1237
+ }
1238
+ var claimPorts = (claims) => {
1239
+ const problems = portClaimProblems(claims);
1240
+ if (problems.length > 0) {
1241
+ throw new PortRefused({
1242
+ message: `Launchd ports: ${problems.join("; ")}. Nothing was declared.`
1243
+ });
1244
+ }
1245
+ };
1144
1246
  // src/launchd/providers.ts
1145
1247
  import * as Layer2 from "effect/Layer";
1146
1248
  var launchdProviders = (runner = localRunner()) => Layer2.mergeAll(LaunchdJobProvider(), HostFileProvider()).pipe(Layer2.provide(hostRunnerLayer(runner)));
@@ -1549,14 +1651,17 @@ export {
1549
1651
  LaunchdJob,
1550
1652
  LaunchdJobProvider,
1551
1653
  PlistError,
1654
+ PortRefused,
1552
1655
  SudoRefusedError,
1553
1656
  canActAsRoot,
1657
+ claimPorts,
1554
1658
  hostRunnerLayer,
1555
1659
  launchdProviders,
1556
1660
  localRunner,
1661
+ portClaimProblems,
1557
1662
  renderPlist,
1558
1663
  sudoRunner
1559
1664
  };
1560
1665
 
1561
- //# debugId=59F08ED5EC139A6064756E2164756E21
1666
+ //# debugId=F7BE590DAFFAF66B64756E2164756E21
1562
1667
  //# sourceMappingURL=index.js.map