@homeflare/alchemy 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/README.md +26 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/launchd/fake-runner.d.ts +36 -0
  5. package/dist/launchd/fake-runner.d.ts.map +1 -0
  6. package/dist/launchd/host-effect.d.ts +19 -0
  7. package/dist/launchd/host-effect.d.ts.map +1 -0
  8. package/dist/launchd/host-file-form.d.ts +24 -0
  9. package/dist/launchd/host-file-form.d.ts.map +1 -0
  10. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  11. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  12. package/dist/launchd/host-file.d.ts +25 -0
  13. package/dist/launchd/host-file.d.ts.map +1 -0
  14. package/dist/launchd/host-lookup.d.ts +27 -0
  15. package/dist/launchd/host-lookup.d.ts.map +1 -0
  16. package/dist/launchd/index.d.ts +21 -0
  17. package/dist/launchd/index.d.ts.map +1 -0
  18. package/dist/launchd/index.js +969 -0
  19. package/dist/launchd/index.js.map +25 -0
  20. package/dist/launchd/job-form.d.ts +90 -0
  21. package/dist/launchd/job-form.d.ts.map +1 -0
  22. package/dist/launchd/job-lifecycle.d.ts +34 -0
  23. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  24. package/dist/launchd/job-preflight.d.ts +50 -0
  25. package/dist/launchd/job-preflight.d.ts.map +1 -0
  26. package/dist/launchd/job-validate.d.ts +25 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -0
  28. package/dist/launchd/job.d.ts +34 -0
  29. package/dist/launchd/job.d.ts.map +1 -0
  30. package/dist/launchd/launchctl.d.ts +62 -0
  31. package/dist/launchd/launchctl.d.ts.map +1 -0
  32. package/dist/launchd/local-runner.d.ts +7 -0
  33. package/dist/launchd/local-runner.d.ts.map +1 -0
  34. package/dist/launchd/plist.d.ts +41 -0
  35. package/dist/launchd/plist.d.ts.map +1 -0
  36. package/dist/launchd/providers.d.ts +15 -0
  37. package/dist/launchd/providers.d.ts.map +1 -0
  38. package/dist/launchd/runner.d.ts +91 -0
  39. package/dist/launchd/runner.d.ts.map +1 -0
  40. package/dist/launchd/secret-tripwire.d.ts +23 -0
  41. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  42. package/dist/openbao/approle-login-form.d.ts +72 -0
  43. package/dist/openbao/approle-login-form.d.ts.map +1 -0
  44. package/dist/openbao/approle-login-result.d.ts +39 -0
  45. package/dist/openbao/approle-login-result.d.ts.map +1 -0
  46. package/dist/openbao/approle-login.d.ts +77 -0
  47. package/dist/openbao/approle-login.d.ts.map +1 -0
  48. package/dist/openbao/auth-method-form.d.ts +7 -0
  49. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  51. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  52. package/dist/openbao/auth-method.d.ts +6 -0
  53. package/dist/openbao/auth-method.d.ts.map +1 -1
  54. package/dist/openbao/auth-role-form.d.ts +9 -0
  55. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  56. package/dist/openbao/auth-role.d.ts.map +1 -1
  57. package/dist/openbao/auth-token-form.d.ts +48 -0
  58. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  59. package/dist/openbao/bao-http.d.ts +2 -0
  60. package/dist/openbao/bao-http.d.ts.map +1 -1
  61. package/dist/openbao/bao-identity.d.ts +81 -0
  62. package/dist/openbao/bao-identity.d.ts.map +1 -0
  63. package/dist/openbao/bao-namespace.d.ts +20 -0
  64. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  65. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  66. package/dist/openbao/host-approles.d.ts +62 -0
  67. package/dist/openbao/host-approles.d.ts.map +1 -0
  68. package/dist/openbao/index.d.ts +26 -0
  69. package/dist/openbao/index.d.ts.map +1 -1
  70. package/dist/openbao/index.js +1889 -477
  71. package/dist/openbao/index.js.map +46 -17
  72. package/dist/openbao/jwt-config-form.d.ts +51 -0
  73. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  74. package/dist/openbao/jwt-config.d.ts +25 -0
  75. package/dist/openbao/jwt-config.d.ts.map +1 -0
  76. package/dist/openbao/jwt-role-form.d.ts +91 -0
  77. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  78. package/dist/openbao/jwt-role.d.ts +31 -0
  79. package/dist/openbao/jwt-role.d.ts.map +1 -0
  80. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  81. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  82. package/dist/openbao/kubernetes-role.d.ts +25 -0
  83. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  84. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  85. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  86. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  88. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  89. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  90. package/dist/openbao/mfa-totp.d.ts +33 -0
  91. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  92. package/dist/openbao/mfa-wire.d.ts +63 -0
  93. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  94. package/dist/openbao/mount-form.d.ts +7 -0
  95. package/dist/openbao/mount-form.d.ts.map +1 -1
  96. package/dist/openbao/mount-move.d.ts +49 -0
  97. package/dist/openbao/mount-move.d.ts.map +1 -0
  98. package/dist/openbao/mount-path.d.ts +18 -0
  99. package/dist/openbao/mount-path.d.ts.map +1 -0
  100. package/dist/openbao/mount-reconcile.d.ts +16 -0
  101. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  102. package/dist/openbao/mount.d.ts +9 -0
  103. package/dist/openbao/mount.d.ts.map +1 -1
  104. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  105. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  106. package/dist/openbao/pki-role-form.d.ts +38 -30
  107. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  108. package/dist/openbao/pki-role.d.ts.map +1 -1
  109. package/dist/openbao/plugin-form.d.ts +60 -0
  110. package/dist/openbao/plugin-form.d.ts.map +1 -0
  111. package/dist/openbao/plugin-reconcile.d.ts +18 -0
  112. package/dist/openbao/plugin-reconcile.d.ts.map +1 -0
  113. package/dist/openbao/plugin-wire.d.ts +45 -0
  114. package/dist/openbao/plugin-wire.d.ts.map +1 -0
  115. package/dist/openbao/plugin.d.ts +31 -0
  116. package/dist/openbao/plugin.d.ts.map +1 -0
  117. package/dist/openbao/remount-wire.d.ts +44 -0
  118. package/dist/openbao/remount-wire.d.ts.map +1 -0
  119. package/dist/openbao/role-reconcile.d.ts +40 -0
  120. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  121. package/dist/openbao/ssh-role-form.d.ts +6 -5
  122. package/dist/openbao/ssh-role-form.d.ts.map +1 -1
  123. package/dist/openbao/ssh-role.d.ts.map +1 -1
  124. package/docs/launchd.md +167 -0
  125. package/package.json +7 -2
  126. package/src/index.ts +1 -0
  127. package/src/launchd/fake-runner.ts +176 -0
  128. package/src/launchd/host-effect.ts +28 -0
  129. package/src/launchd/host-file-form.ts +71 -0
  130. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  131. package/src/launchd/host-file-lifecycle.ts +164 -0
  132. package/src/launchd/host-file.ts +64 -0
  133. package/src/launchd/host-lookup.test.ts +92 -0
  134. package/src/launchd/host-lookup.ts +94 -0
  135. package/src/launchd/index.ts +26 -0
  136. package/src/launchd/job-form.test.ts +171 -0
  137. package/src/launchd/job-form.ts +180 -0
  138. package/src/launchd/job-identity.test.ts +147 -0
  139. package/src/launchd/job-lifecycle.test.ts +220 -0
  140. package/src/launchd/job-lifecycle.ts +194 -0
  141. package/src/launchd/job-preflight.ts +152 -0
  142. package/src/launchd/job-validate.ts +134 -0
  143. package/src/launchd/job.ts +96 -0
  144. package/src/launchd/launchctl.test.ts +181 -0
  145. package/src/launchd/launchctl.ts +188 -0
  146. package/src/launchd/local-runner.test.ts +125 -0
  147. package/src/launchd/local-runner.ts +143 -0
  148. package/src/launchd/plist.test.ts +186 -0
  149. package/src/launchd/plist.ts +156 -0
  150. package/src/launchd/providers.test.ts +222 -0
  151. package/src/launchd/providers.ts +21 -0
  152. package/src/launchd/runner.ts +95 -0
  153. package/src/launchd/secret-tripwire.test.ts +110 -0
  154. package/src/launchd/secret-tripwire.ts +81 -0
  155. package/src/openbao/README.md +181 -0
  156. package/src/openbao/REPLACE.md +87 -0
  157. package/src/openbao/approle-login-form.test.ts +64 -0
  158. package/src/openbao/approle-login-form.ts +153 -0
  159. package/src/openbao/approle-login-result.ts +96 -0
  160. package/src/openbao/approle-login.test.ts +200 -0
  161. package/src/openbao/approle-login.ts +156 -0
  162. package/src/openbao/auth-method-form.ts +7 -0
  163. package/src/openbao/auth-method-reconcile.ts +74 -0
  164. package/src/openbao/auth-method.ts +20 -41
  165. package/src/openbao/auth-role-form.test.ts +57 -0
  166. package/src/openbao/auth-role-form.ts +11 -0
  167. package/src/openbao/auth-role.ts +12 -0
  168. package/src/openbao/auth-token-form.ts +161 -0
  169. package/src/openbao/bao-http.ts +2 -0
  170. package/src/openbao/bao-identity.test.ts +131 -0
  171. package/src/openbao/bao-identity.ts +188 -0
  172. package/src/openbao/bao-namespace.ts +32 -0
  173. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  174. package/src/openbao/host-approles.test.ts +121 -0
  175. package/src/openbao/host-approles.ts +126 -0
  176. package/src/openbao/index.ts +32 -0
  177. package/src/openbao/jwt-config-form.ts +161 -0
  178. package/src/openbao/jwt-config.ts +85 -0
  179. package/src/openbao/jwt-role-form.test.ts +130 -0
  180. package/src/openbao/jwt-role-form.ts +231 -0
  181. package/src/openbao/jwt-role.ts +102 -0
  182. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  183. package/src/openbao/kubernetes-role-form.ts +149 -0
  184. package/src/openbao/kubernetes-role.ts +94 -0
  185. package/src/openbao/mfa-enforcement-form.ts +147 -0
  186. package/src/openbao/mfa-enforcement.ts +143 -0
  187. package/src/openbao/mfa-totp-form.ts +155 -0
  188. package/src/openbao/mfa-totp.ts +118 -0
  189. package/src/openbao/mfa-wire.ts +117 -0
  190. package/src/openbao/mfa.test.ts +200 -0
  191. package/src/openbao/mount-form.ts +9 -1
  192. package/src/openbao/mount-move.test.ts +164 -0
  193. package/src/openbao/mount-move.ts +113 -0
  194. package/src/openbao/mount-path.test.ts +33 -0
  195. package/src/openbao/mount-path.ts +31 -0
  196. package/src/openbao/mount-reconcile.ts +75 -0
  197. package/src/openbao/mount-wire.test.ts +13 -0
  198. package/src/openbao/mount.ts +26 -43
  199. package/src/openbao/pki-role-canonical.ts +163 -0
  200. package/src/openbao/pki-role-form.test.ts +112 -0
  201. package/src/openbao/pki-role-form.ts +76 -110
  202. package/src/openbao/pki-role.ts +12 -27
  203. package/src/openbao/plugin-form.test.ts +86 -0
  204. package/src/openbao/plugin-form.ts +164 -0
  205. package/src/openbao/plugin-reconcile.ts +92 -0
  206. package/src/openbao/plugin-wire.test.ts +146 -0
  207. package/src/openbao/plugin-wire.ts +65 -0
  208. package/src/openbao/plugin.ts +100 -0
  209. package/src/openbao/remount-wire.test.ts +87 -0
  210. package/src/openbao/remount-wire.ts +112 -0
  211. package/src/openbao/role-reconcile.test.ts +104 -0
  212. package/src/openbao/role-reconcile.ts +84 -0
  213. package/src/openbao/ssh-role-form.test.ts +105 -0
  214. package/src/openbao/ssh-role-form.ts +18 -16
  215. package/src/openbao/ssh-role.ts +3 -4
@@ -1 +1 @@
1
- {"version":3,"file":"ssh-role-form.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role-form.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,eAAe;IAC9B,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,sEAAsE;IACtE,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,qFAAqF;IACrF,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,4EAA4E;IAC5E,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrD,oFAAoF;IACpF,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C,0FAA0F;IAC1F,sBAAsB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1D,gFAAgF;IAChF,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,mFAAmF;IACnF,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,eAAe,CAAC,CAAC;AAEvD,sFAAsF;AACtF,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,KAAK,CAAC;IAClF;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,wFAAwF;IACxF,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,8EAA8E;AAC9E,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,SAAS,WAAyC,CAAC;AAE3F,eAAO,MAAM,QAAQ,UAAW,MAAM,GAAG,SAAS,QAAQ,MAAM,WAC7B,CAAC;AAMpC;;;;GAIG;AACH,eAAO,MAAM,OAAO,UAAW,OAAO,KAAG,SAAS,MAAM,EAOvD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,MAAM,UAAW,OAAO,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAM5D,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,OAAO,UAAW,eAAe,KAAG,cAiB/C,CAAC;AAEH,eAAO,MAAM,YAAY,SACjB,cAAc,QACd,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC5B,oBAqBF,CAAC;AAgBF;;;;;GAKG;AACH,eAAO,MAAM,SAAS,SAAU,cAAc,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAgBpE,CAAC;AAoBH;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,cAAc,KAAG,SAAS,MAAM,EACuB,CAAC;AAE3F;;;;;;GAMG;AACH,eAAO,MAAM,OAAO,eAAgB,oBAAoB,QAAQ,cAAc,YAe9B,CAAC"}
1
+ {"version":3,"file":"ssh-role-form.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role-form.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,UAAU,IAAI,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAExD,MAAM,WAAW,eAAe;IAC9B,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,sEAAsE;IACtE,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,qFAAqF;IACrF,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,4EAA4E;IAC5E,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrD,oFAAoF;IACpF,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C,0FAA0F;IAC1F,sBAAsB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1D,gFAAgF;IAChF,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,mFAAmF;IACnF,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,eAAe,CAAC,CAAC;AAEvD,sFAAsF;AACtF,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,KAAK,CAAC;IAClF;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,wFAAwF;IACxF,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,OAAO,EAAE,OAAO,EAAE,CAAC;AAEnB,eAAO,MAAM,QAAQ,UAAW,MAAM,GAAG,SAAS,QAAQ,MAAM,WAC7B,CAAC;AAMpC;;;;GAIG;AACH,eAAO,MAAM,OAAO,UAAW,OAAO,KAAG,SAAS,MAAM,EAOvD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,MAAM,UAAW,OAAO,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAM5D,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,OAAO,UAAW,eAAe,KAAG,cAiB/C,CAAC;AAEH,eAAO,MAAM,YAAY,SACjB,cAAc,QACd,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC5B,oBAqBF,CAAC;AAiBF;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,SAAU,cAAc,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAgBrE,CAAC;AAoBH;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,cAAc,KAAG,SAAS,MAAM,EACuB,CAAC;AAE3F;;;;;;GAMG;AACH,eAAO,MAAM,OAAO,eAAgB,oBAAoB,QAAQ,cAAc,YAe9B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EAQrB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAyDH,eAAO,MAAM,kBAAkB,iGAsH5B,CAAC"}
1
+ {"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EAQrB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAyDH,eAAO,MAAM,kBAAkB,iGAqH5B,CAAC"}
@@ -0,0 +1,167 @@
1
+ # launchd — `@homeflare/alchemy/launchd`
2
+
3
+ Declare a Mac host with Alchemy: its launchd jobs (`LaunchdJob`) and the config files they read
4
+ (`HostFile`). launchd has no SDK, so the provider renders the plist itself and drives
5
+ `launchctl`. Every file read, file write and `launchctl` call goes through one injectable
6
+ `HostRunner`.
7
+
8
+ ```ts
9
+ import * as Effect from 'effect/Effect';
10
+ import { HostFile, LaunchdJob, launchdProviders } from '@homeflare/alchemy/launchd';
11
+
12
+ export const exporter = Effect.gen(function* () {
13
+ const config = yield* HostFile('exporter-config', {
14
+ path: '/usr/local/etc/example-exporter.yml',
15
+ content: 'listen: 127.0.0.1:9100\n',
16
+ mode: 0o644,
17
+ owner: 'root',
18
+ group: 'wheel',
19
+ });
20
+ yield* LaunchdJob('exporter', {
21
+ label: 'com.example.exporter',
22
+ domain: 'system', // or 'gui/<uid>' for a LaunchAgent
23
+ // ★ config.path, not the literal: the Output orders the job after its file is written.
24
+ programArguments: ['/usr/local/bin/example-exporter', '--config', config.path],
25
+ environment: { TZ: 'UTC' }, // ⛔ non-secret values only
26
+ runAtLoad: true,
27
+ keepAlive: { successfulExit: false },
28
+ throttleInterval: 30,
29
+ standardOutPath: '/var/log/example-exporter.log',
30
+ standardErrorPath: '/var/log/example-exporter.log',
31
+ });
32
+ });
33
+ // Provide launchdProviders() alongside the stack's other provider layers.
34
+ ```
35
+
36
+ ## LaunchdJob
37
+
38
+ | prop | plist key | notes |
39
+ | ----------------------------- | -------------------------------- | ------------------------------------------------------ |
40
+ | `label` | `Label` | also the file name; `[A-Za-z0-9._-]`, no `/` |
41
+ | `domain` | — | `system` (LaunchDaemons) or `gui/<uid>` (LaunchAgents) |
42
+ | `programArguments` | `ProgramArguments` | first element must be an absolute path |
43
+ | `environment` | `EnvironmentVariables` | ⛔ non-secret only (see below) |
44
+ | `runAtLoad` | `RunAtLoad` | |
45
+ | `keepAlive` | `KeepAlive` | `true`/`false` or `{ successfulExit, crashed }` |
46
+ | `startInterval` | `StartInterval` | seconds, > 0 |
47
+ | `startCalendarInterval` | `StartCalendarInterval` | one entry or a list; fields are range-checked |
48
+ | `throttleInterval` | `ThrottleInterval` | seconds, ≥ 0 |
49
+ | `standardOutPath` / `…Error…` | `StandardOutPath` / `…ErrorPath` | absolute |
50
+ | `workingDirectory` | `WorkingDirectory` | absolute |
51
+ | `userName` / `groupName` | `UserName` / `GroupName` | system domain only — launchd ignores them for agents |
52
+ | `extraKeys` | anything else, verbatim | may not repeat a key a typed prop owns |
53
+
54
+ The plist path is derived, never declared: `/Library/LaunchDaemons/<label>.plist` for `system`,
55
+ `<home of uid>/Library/LaunchAgents/<label>.plist` for `gui/<uid>`.
56
+
57
+ | step | what happens |
58
+ | ------- | --------------------------------------------------------------------------------------------------- |
59
+ | create | write the plist atomically (system: `root:wheel 0644`), then `launchctl bootstrap` |
60
+ | update | write, `bootout` the running job (and wait until launchd lets go), `bootstrap` — **a restart** |
61
+ | read | `launchctl print` (loaded? state, pid, last exit) plus the SHA-256 of the plist on disk |
62
+ | diff | the rendered plist's SHA-256 against the stored digest **and** the on-disk one, plus "is it loaded" |
63
+ | replace | only when `label` or `domain` changes — **delete first**, so two copies never run at once |
64
+ | delete | `bootout` if loaded, remove the plist; idempotent |
65
+
66
+ - A converged job is not restarted: a retried deploy that finds the right plist loaded does nothing.
67
+ - ⛔ **A rename is checked at plan time.** Alchemy deletes the old job before it creates the new
68
+ one, so everything the new one would be refused for (an invalid or reserved label, a domain you
69
+ may not write, a disabled label, a label another job already holds) fails the **plan**, with the
70
+ old job still running. A rename is seen even while other props are unresolved Outputs.
71
+ - ⚠️ A failed `bootstrap` leaves the job **down**. On an update the new plist stays on disk, the
72
+ state keeps the old digest, and the next deploy retries. On a first create the plist is removed
73
+ again, so the next plan does not find its own file and call it someone else's.
74
+ There is no rollback to an older plist.
75
+ - ⛔ A label disabled with `launchctl disable` is refused, not re-enabled: that was someone's
76
+ decision. The error names the `launchctl enable` command to run if it is stale.
77
+ - ⚠️ A `gui/<uid>` job needs that user logged in: without a login session the domain does not
78
+ exist (`launchctl print` exits 112, "Could not find domain"; measured). Deploying refuses before
79
+ it writes anything. Deleting still removes the plist, which stops launchd loading it at login.
80
+ - ⚠️ The digest covers the rendered bytes, so a kit upgrade that changes the renderer's output
81
+ restarts every declared job on the next deploy. Such a release says so in its changelog.
82
+
83
+ ## HostFile
84
+
85
+ `path` (absolute, normalised), `content` (UTF-8 text), `mode` (default `0o644`), `owner` and
86
+ `group` (names or ids). Written as a temp file in the same directory — mode and owner set before
87
+ it becomes visible — then renamed over the path. Read back after every write. Diffed by SHA-256,
88
+ mode and owner. A new `path` is a create-before-delete replace. Delete removes the file.
89
+
90
+ - ⛔ A different file already at a new `path` is refused, not overwritten: the adoption probe
91
+ never looks at a replace's new path, so a typo there would otherwise overwrite a system file.
92
+
93
+ - ⛔ A symlink or directory at the path is refused, never replaced: rename over a symlink replaces
94
+ the link, which takes the path from whatever tool owns it (nix-darwin's `/etc` entries point
95
+ into `/nix/store`).
96
+ - ⚠️ The parent directory must exist. Creating it would invent an owner and mode nobody declared.
97
+
98
+ ## ⛔ No secrets in props
99
+
100
+ Alchemy stores props and attributes **unencrypted** in its state store. A job's `environment`,
101
+ `programArguments` and `extraKeys`, and a file's `content`, land there, in every plan diff, and on
102
+ disk.
103
+
104
+ **Keep secret files rendered by a secret renderer** — `openbao-agent` templates on a HomeFlare
105
+ host — and declare only the path: `FOO_TOKEN_FILE=/path`, `--token-file /path`. A tripwire refuses
106
+ the obvious mistakes (a `*_TOKEN`/`*_PASSWORD`-style variable, `--token=…`, a PEM private key); it
107
+ is not a scanner, and passing it does not make a value safe to declare.
108
+
109
+ ## ⛔ No silent sudo
110
+
111
+ The `system` domain, and another user's `gui/<uid>`, need root. The provider refuses up front
112
+ unless the runner is root (`effectiveUid() === 0`) or was deliberately built with
113
+ `privileged: true`. `localRunner()` — the default — is never privileged and never calls `sudo`, so:
114
+
115
+ - **system daemons:** start the whole deploy as root; or pass `launchdProviders(yourRunner)` where
116
+ `yourRunner` is a `HostRunner` you wrote that already holds root (a root helper, say).
117
+ - **your own agents** (`gui/<your uid>`): no root needed.
118
+
119
+ Reading (`launchctl print`, `print-disabled`) needs no root. ⚠️ A `HostFile` whose mode hides it
120
+ from the planning user (a root-owned `0600`, say) fails the plan with `EACCES`: plan as the user
121
+ you deploy as.
122
+
123
+ ## Adoption: nothing is taken over silently
124
+
125
+ `read` with no prior state reports anything it finds — a loaded label, a plist, a file — as
126
+ `Unowned`, so Alchemy refuses to take it over without `--adopt`.
127
+
128
+ Labels under `org.nixos.`, `com.apple.` and `homebrew.mxcl.` are **refused outright**. nix-darwin
129
+ rewrites its plists on every activation and unloads any it no longer lists, macOS owns
130
+ `com.apple.*`, and `brew services` owns `homebrew.mxcl.*`. Adopting one would give a file two
131
+ owners. Move the job instead.
132
+
133
+ ## Cutover from nix-darwin
134
+
135
+ Nix-managed `org.nixos.*` jobs are **not adopted**. The host stack declares a **new** label, and the
136
+ Nix job is booted out first. Per job:
137
+
138
+ 1. **Declare** the replacement in the host stack under a new label (`com.example.<job>`), with its
139
+ config as `HostFile`s. ⚠️ Nothing it references may live in `/nix/store` — the next garbage
140
+ collection deletes it. Plan it: expect creates only.
141
+ 2. **Remove the job from the nix-darwin config and activate.** Activation unloads and deletes every
142
+ daemon it no longer lists — checked 2026-09-21 in a nix-darwin activation script: it prints
143
+ `removing service <label>`, runs `launchctl unload`, then removes the plist. Confirm with
144
+ `launchctl print system/org.nixos.<job>` → exit 113 ("Could not find service").
145
+ 3. **Deploy** the host stack. The new label bootstraps.
146
+ 4. **Verify** `launchctl print system/com.example.<job>` shows `state = running`, then check the
147
+ service itself (port, metrics, logs).
148
+
149
+ - ★ **Why this order.** Both jobs usually bind the same port and write the same files; the old one
150
+ must be gone before the new one starts. Downtime is one deploy.
151
+ - ⚠️ **`sudo launchctl bootout system/org.nixos.<job>` is not a cutover.** The plist stays in
152
+ `/Library/LaunchDaemons`, so launchd loads it again at the next boot; and activation (checked in
153
+ the same script) copies back and `load -w`s any Nix plist that is missing or differs from the
154
+ store. Either way two copies end up fighting over the port.
155
+ - ⚠️ **Check the host's own activation for labels it removes.** An activation script can boot out,
156
+ `disable` and delete jobs by name on every run; a new label that collides with one is disabled
157
+ under you. `grep -n 'bootout\|disable' /run/current-system/activate` before choosing labels.
158
+ - **Rolling back** is the same three steps reversed: destroy (or remove) the resource, restore the
159
+ job in the Nix config, activate.
160
+
161
+ ## The runner
162
+
163
+ `HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
164
+ `stat` (lstat), `writeFileAtomic`, `removeFile`, `lookupUser`, `lookupGroup`, `sleep`, plus
165
+ `privileged` and `effectiveUid()`. `localRunner()` implements it with `node:fs` and
166
+ `node:child_process`; tests use an in-memory fake, so nothing in the test suite runs `launchctl` or
167
+ writes outside a temp directory.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.4.1",
4
- "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo and Talos.",
3
+ "version": "0.6.0",
4
+ "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos and launchd.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
7
7
  "license": "MIT",
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
+ "docs",
16
17
  "src",
17
18
  "README.md",
18
19
  "LICENSE"
@@ -44,6 +45,10 @@
44
45
  "types": "./dist/proxmox/index.d.ts",
45
46
  "default": "./dist/proxmox/index.js"
46
47
  },
48
+ "./launchd": {
49
+ "types": "./dist/launchd/index.d.ts",
50
+ "default": "./dist/launchd/index.js"
51
+ },
47
52
  "./package.json": "./package.json"
48
53
  },
49
54
  "publishConfig": {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * import { BaoMount } from '@homeflare/alchemy/openbao';
10
10
  * import { TalosCluster } from '@homeflare/alchemy/talos';
11
11
  * import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
12
+ * import { LaunchdJob } from '@homeflare/alchemy/launchd';
12
13
  *
13
14
  * ★ WHY THESE EXIST AT ALL. When Alchemy has no resource for something, the alternative
14
15
  * is a runbook step a human runs once — and a plan can never show a missing runbook
@@ -0,0 +1,176 @@
1
+ /**
2
+ * An in-memory host for the tests beside it: a filesystem in a Map and a scripted launchd.
3
+ *
4
+ * ⛔ TEST-ONLY. No provider imports this file, it is not on the barrel, and it never touches the
5
+ * real filesystem or runs a real program — which is the only way the lifecycle tests can prove
6
+ * "bootout before bootstrap" without writing /Library or loading a job.
7
+ * ★ IT MODELS THE BEHAVIOURS THE PROVIDER DEPENDS ON, as launchctl.ts documents them: `print` of an
8
+ * unknown target exits 113; bootstrap of a loaded or disabled label fails; bootout of an absent
9
+ * job exits non-zero; a job can linger after bootout (`lingerPrints`); a gui uid with no login
10
+ * session answers 112 (`loggedOut`); writes into a root-owned directory, or a chown to another
11
+ * uid, need root.
12
+ */
13
+ import { LAUNCHCTL, NOT_FOUND, NO_DOMAIN } from './launchctl.ts';
14
+ import type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
15
+
16
+ type Entry = { bytes: Uint8Array; mode: number; uid: number; gid: number; kind: FileStat['kind'] };
17
+
18
+ export type FakeOptions = {
19
+ euid?: number;
20
+ privileged?: boolean;
21
+ /** Directories that exist, and their owner uid. */
22
+ dirs?: Record<string, number>;
23
+ users?: Record<string, HostUser>;
24
+ groups?: Record<string, number>;
25
+ };
26
+
27
+ const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
28
+ const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
29
+ const errno = (code: string, path: string) =>
30
+ Object.assign(new Error(`${code}: ${path}`), { code });
31
+ const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
32
+
33
+ /** The shape `launchctl print` produced on macOS 27.2 (see launchctl.ts), trimmed. */
34
+ const printBlock = (target: string, pid: number | undefined, lastExit: string) =>
35
+ [
36
+ `${target} = {`,
37
+ '\tactive count = 1',
38
+ `\tstate = ${pid === undefined ? 'not running' : 'running'}`,
39
+ '\targuments = {',
40
+ '\t\t/bin/sh',
41
+ '}',
42
+ '\t}',
43
+ '\tresource coalition = {',
44
+ '\t\tstate = active',
45
+ '\t}',
46
+ ...(pid === undefined ? [] : [`\tpid = ${String(pid)}`]),
47
+ `\tlast exit code = ${lastExit}`,
48
+ '}',
49
+ ].join('\n');
50
+
51
+ export const fakeRunner = (options: FakeOptions = {}) => {
52
+ const euid = options.euid ?? 501;
53
+ const privileged = options.privileged ?? false;
54
+ const root = privileged || euid === 0;
55
+ const dirs = new Map(Object.entries(options.dirs ?? {}));
56
+ const files = new Map<string, Entry>();
57
+ const calls: string[][] = [];
58
+ /** target → pid (undefined = loaded, not running). */
59
+ const loaded = new Map<string, number | undefined>();
60
+ const disabled = new Set<string>();
61
+ const state = {
62
+ lingerPrints: 0,
63
+ nextPid: 4000,
64
+ bootstrapFailure: undefined as ExecResult | undefined,
65
+ /** Returned by bootout in place of success. The job still goes, unless `bootoutKeepsJob`. */
66
+ bootoutFailure: undefined as ExecResult | undefined,
67
+ bootoutKeepsJob: false,
68
+ /** gui uids with no login session: every launchctl call into `gui/<uid>` exits 112. */
69
+ loggedOut: new Set<number>(),
70
+ };
71
+ /** Booted-out targets launchd has not let go of yet → prints left before they vanish. */
72
+ const lingering = new Map<string, number>();
73
+
74
+ const labelIn = (path: string) =>
75
+ /<key>Label<\/key>\s*<string>([^<]*)<\/string>/.exec(
76
+ new TextDecoder().decode(files.get(path)?.bytes),
77
+ )?.[1];
78
+
79
+ const launchctl = (args: readonly string[]): ExecResult => {
80
+ const [sub, first, second] = args;
81
+ const gui = /^gui\/(\d+)(?:\/|$)/.exec(first ?? '')?.[1];
82
+ if (gui !== undefined && state.loggedOut.has(Number(gui))) {
83
+ // The measured shape (launchctl.ts): 112, "Bad request." then the domain line.
84
+ return fail(NO_DOMAIN, `Bad request.\nCould not find domain for user gui: ${gui}`);
85
+ }
86
+ if (sub === 'print' && first !== undefined) {
87
+ const left = lingering.get(first);
88
+ if (left !== undefined) {
89
+ if (left <= 0) {
90
+ lingering.delete(first);
91
+ loaded.delete(first);
92
+ } else lingering.set(first, left - 1);
93
+ }
94
+ if (!loaded.has(first)) return fail(NOT_FOUND, `Could not find service "${first}"`);
95
+ return ok(printBlock(first, loaded.get(first), '(never exited)'));
96
+ }
97
+ if (sub === 'print-disabled' && first !== undefined) {
98
+ const lines = [...disabled]
99
+ .filter((target) => target.startsWith(`${first}/`))
100
+ .map((target) => `\t\t"${target.slice(first.length + 1)}" => disabled`);
101
+ return ok(['\tdisabled services = {', ...lines, '\t}'].join('\n'));
102
+ }
103
+ if (sub === 'bootstrap' && first !== undefined && second !== undefined) {
104
+ if (state.bootstrapFailure !== undefined) return state.bootstrapFailure;
105
+ const label = labelIn(second);
106
+ if (label === undefined) return fail(5, 'Bootstrap failed: 5: Input/output error');
107
+ const target = `${first}/${label}`;
108
+ if (loaded.has(target) || disabled.has(target))
109
+ return fail(5, 'Bootstrap failed: 5: Input/output error');
110
+ state.nextPid += 1;
111
+ loaded.set(target, state.nextPid);
112
+ return ok();
113
+ }
114
+ if (sub === 'bootout' && first !== undefined) {
115
+ if (!loaded.has(first) || lingering.has(first))
116
+ return fail(3, 'Boot-out failed: 3: No such process');
117
+ if (state.bootoutKeepsJob) return state.bootoutFailure ?? fail(5, 'Boot-out failed: 5');
118
+ // ⚠️ Model launchd letting go late: the job stays visible for `lingerPrints` more prints.
119
+ if (state.lingerPrints === 0) loaded.delete(first);
120
+ else lingering.set(first, state.lingerPrints);
121
+ return state.bootoutFailure ?? ok();
122
+ }
123
+ return fail(64, `fake launchctl: unsupported ${args.join(' ')}`);
124
+ };
125
+
126
+ const runner: HostRunner = {
127
+ effectiveUid: () => euid,
128
+ exec: async (argv) => {
129
+ calls.push([...argv]);
130
+ if (argv[0] === LAUNCHCTL) return launchctl(argv.slice(1));
131
+ return fail(127, `fake: no program ${String(argv[0])}`);
132
+ },
133
+ lookupGroup: async (nameOrId) => options.groups?.[nameOrId],
134
+ lookupUser: async (nameOrId) =>
135
+ options.users?.[nameOrId] ??
136
+ Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
137
+ privileged,
138
+ readFile: async (path) => files.get(path)?.bytes,
139
+ removeFile: async (path) => {
140
+ if (files.has(path) && (dirs.get(parent(path)) ?? 0) !== euid && !root)
141
+ throw errno('EACCES', path);
142
+ files.delete(path);
143
+ },
144
+ // ★ No real waiting: a lingering job drains one `print` per poll instead.
145
+ sleep: async () => undefined,
146
+ stat: async (path) => {
147
+ const entry = files.get(path);
148
+ if (entry !== undefined)
149
+ return {
150
+ gid: entry.gid,
151
+ kind: entry.kind,
152
+ mode: entry.mode,
153
+ size: entry.bytes.length,
154
+ uid: entry.uid,
155
+ };
156
+ return dirs.has(path)
157
+ ? { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 }
158
+ : undefined;
159
+ },
160
+ writeFileAtomic: async (path: string, bytes: Uint8Array, write: WriteOptions) => {
161
+ calls.push(['write', path]);
162
+ const owner = dirs.get(parent(path));
163
+ if (owner === undefined) throw errno('ENOENT', parent(path));
164
+ if (owner !== euid && !root) throw errno('EACCES', path);
165
+ if (write.uid !== undefined && write.uid !== euid && !root) throw errno('EPERM', path);
166
+ files.set(path, {
167
+ bytes,
168
+ gid: write.gid ?? 20,
169
+ kind: 'file',
170
+ mode: write.mode,
171
+ uid: write.uid ?? euid,
172
+ });
173
+ },
174
+ };
175
+ return { calls, disabled, dirs, files, loaded, runner, state };
176
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
3
+ * `news` both providers' diffs share.
4
+ *
5
+ * ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
6
+ * runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
7
+ * ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
8
+ * thrown is wrapped, so the failure channel is always an Error with a readable message.
9
+ */
10
+ import * as Effect from 'effect/Effect';
11
+
12
+ export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
13
+ Effect.tryPromise({
14
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
15
+ try: step,
16
+ });
17
+
18
+ /**
19
+ * A string prop of a diff's `news`, when that one prop is already resolved; `undefined` while it
20
+ * is still an Output (a function-typed proxy), an Effect or a Config.
21
+ * ★ For the identity props a diff must see even when the rest of `news` is unresolved — a plain
22
+ * string is resolved by definition, so `typeof` is the whole test.
23
+ */
24
+ export const resolvedString = (news: unknown, key: string): string | undefined => {
25
+ if (typeof news !== 'object' || news === null) return undefined;
26
+ const value = (news as Record<string, unknown>)[key];
27
+ return typeof value === 'string' ? value : undefined;
28
+ };
@@ -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
+ };