@homeflare/alchemy 0.39.0 → 0.41.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 (279) hide show
  1. package/README.md +10 -8
  2. package/dist/caddy/index.js.map +2 -2
  3. package/dist/ceph/auth-entity.d.ts +34 -0
  4. package/dist/ceph/auth-entity.d.ts.map +1 -0
  5. package/dist/ceph/ceph-argv-config.d.ts +10 -0
  6. package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
  7. package/dist/ceph/ceph-argv.d.ts +19 -0
  8. package/dist/ceph/ceph-argv.d.ts.map +1 -0
  9. package/dist/ceph/ceph-auth-form.d.ts +54 -0
  10. package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
  11. package/dist/ceph/ceph-auth-parse.d.ts +56 -0
  12. package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
  13. package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
  14. package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
  15. package/dist/ceph/ceph-config-options.d.ts +23 -0
  16. package/dist/ceph/ceph-config-options.d.ts.map +1 -0
  17. package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
  18. package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
  19. package/dist/ceph/ceph-transport.d.ts +37 -0
  20. package/dist/ceph/ceph-transport.d.ts.map +1 -0
  21. package/dist/ceph/fake-ceph-dial.d.ts +35 -0
  22. package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
  23. package/dist/ceph/index.d.ts +13 -0
  24. package/dist/ceph/index.d.ts.map +1 -0
  25. package/dist/ceph/index.js +1610 -0
  26. package/dist/ceph/index.js.map +38 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/launchd/fake-runner.d.ts.map +1 -1
  30. package/dist/launchd/index.d.ts +1 -1
  31. package/dist/launchd/index.d.ts.map +1 -1
  32. package/dist/launchd/index.js +7 -1
  33. package/dist/launchd/index.js.map +5 -5
  34. package/dist/launchd/local-runner.d.ts.map +1 -1
  35. package/dist/launchd/runner.d.ts +21 -0
  36. package/dist/launchd/runner.d.ts.map +1 -1
  37. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  38. package/dist/linux/directory-lifecycle.d.ts.map +1 -1
  39. package/dist/linux/fake-linux-host.d.ts.map +1 -1
  40. package/dist/linux/index.d.ts +1 -1
  41. package/dist/linux/index.d.ts.map +1 -1
  42. package/dist/linux/index.js +16 -4
  43. package/dist/linux/index.js.map +7 -7
  44. package/dist/linux/ssh-runner.d.ts.map +1 -1
  45. package/dist/linux/sudo-runner.d.ts.map +1 -1
  46. package/dist/opnsense/alias-form.d.ts +14 -1
  47. package/dist/opnsense/alias-form.d.ts.map +1 -1
  48. package/dist/opnsense/alias.d.ts +1 -1
  49. package/dist/opnsense/alias.d.ts.map +1 -1
  50. package/dist/opnsense/group-form.d.ts +8 -2
  51. package/dist/opnsense/group-form.d.ts.map +1 -1
  52. package/dist/opnsense/group.d.ts +1 -1
  53. package/dist/opnsense/group.d.ts.map +1 -1
  54. package/dist/opnsense/index.js +15 -7
  55. package/dist/opnsense/index.js.map +7 -7
  56. package/dist/opnsense/wire.d.ts +42 -12
  57. package/dist/opnsense/wire.d.ts.map +1 -1
  58. package/dist/proxmox/index.js +62 -39
  59. package/dist/proxmox/index.js.map +8 -7
  60. package/dist/proxmox/qemu-volume.d.ts +12 -2
  61. package/dist/proxmox/qemu-volume.d.ts.map +1 -1
  62. package/dist/proxmox/storage-form.d.ts +28 -3
  63. package/dist/proxmox/storage-form.d.ts.map +1 -1
  64. package/dist/proxmox/storage-plugin-options.d.ts +2 -0
  65. package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
  66. package/dist/proxmox/storage-wire.d.ts +28 -1
  67. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  68. package/dist/proxmox/storage.d.ts +6 -1
  69. package/dist/proxmox/storage.d.ts.map +1 -1
  70. package/dist/release/index.d.ts +1 -1
  71. package/dist/release/index.d.ts.map +1 -1
  72. package/dist/release/index.js.map +2 -2
  73. package/dist/talos/credentials-write.d.ts +46 -0
  74. package/dist/talos/credentials-write.d.ts.map +1 -0
  75. package/dist/talos/credentials.d.ts +51 -9
  76. package/dist/talos/credentials.d.ts.map +1 -1
  77. package/dist/talos/fake-process.d.ts +56 -0
  78. package/dist/talos/fake-process.d.ts.map +1 -0
  79. package/dist/talos/index.d.ts +19 -4
  80. package/dist/talos/index.d.ts.map +1 -1
  81. package/dist/talos/index.js +357 -173
  82. package/dist/talos/index.js.map +16 -10
  83. package/dist/talos/kubeconfig-attrs.d.ts +4 -0
  84. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
  85. package/dist/talos/kubeconfig.d.ts +41 -23
  86. package/dist/talos/kubeconfig.d.ts.map +1 -1
  87. package/dist/talos/machine-config-fixtures.d.ts +60 -0
  88. package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
  89. package/dist/talos/machine-config-poll.d.ts +43 -0
  90. package/dist/talos/machine-config-poll.d.ts.map +1 -0
  91. package/dist/talos/machine-config-read.d.ts +49 -0
  92. package/dist/talos/machine-config-read.d.ts.map +1 -0
  93. package/dist/talos/resource.d.ts +18 -0
  94. package/dist/talos/resource.d.ts.map +1 -1
  95. package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
  96. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
  97. package/dist/talos/talos-bootstrap.d.ts +87 -0
  98. package/dist/talos/talos-bootstrap.d.ts.map +1 -1
  99. package/dist/talos/talos-cluster-health.d.ts +43 -1
  100. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  101. package/dist/talos/talos-errors.d.ts +68 -0
  102. package/dist/talos/talos-errors.d.ts.map +1 -0
  103. package/dist/talos/talos-machine-config.d.ts +51 -15
  104. package/dist/talos/talos-machine-config.d.ts.map +1 -1
  105. package/dist/talos/talosctl.d.ts +7 -0
  106. package/dist/talos/talosctl.d.ts.map +1 -1
  107. package/dist/talos/values.d.ts +23 -3
  108. package/dist/talos/values.d.ts.map +1 -1
  109. package/dist/unifi/acl-rule-drift.d.ts +22 -0
  110. package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
  111. package/dist/unifi/acl-rule-form.d.ts +90 -0
  112. package/dist/unifi/acl-rule-form.d.ts.map +1 -0
  113. package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
  114. package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
  115. package/dist/unifi/acl-rule-ordering.d.ts +54 -0
  116. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
  117. package/dist/unifi/acl-rule.d.ts +49 -0
  118. package/dist/unifi/acl-rule.d.ts.map +1 -0
  119. package/dist/unifi/dns-policy-drift.d.ts +21 -0
  120. package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
  121. package/dist/unifi/dns-policy-form.d.ts +97 -0
  122. package/dist/unifi/dns-policy-form.d.ts.map +1 -0
  123. package/dist/unifi/dns-policy.d.ts +55 -0
  124. package/dist/unifi/dns-policy.d.ts.map +1 -0
  125. package/dist/unifi/firewall-policy-drift.d.ts +26 -0
  126. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
  127. package/dist/unifi/firewall-policy-form.d.ts +102 -0
  128. package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
  129. package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
  130. package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
  131. package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
  132. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
  133. package/dist/unifi/firewall-policy.d.ts +57 -0
  134. package/dist/unifi/firewall-policy.d.ts.map +1 -0
  135. package/dist/unifi/index.d.ts +7 -0
  136. package/dist/unifi/index.d.ts.map +1 -1
  137. package/dist/unifi/index.js +588 -3
  138. package/dist/unifi/index.js.map +24 -4
  139. package/dist/unifi/paginate.d.ts +0 -12
  140. package/dist/unifi/paginate.d.ts.map +1 -1
  141. package/dist/unifi/providers.d.ts +1 -1
  142. package/dist/unifi/providers.d.ts.map +1 -1
  143. package/dist/unifi/scan-source.d.ts +11 -0
  144. package/dist/unifi/scan-source.d.ts.map +1 -0
  145. package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
  146. package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
  147. package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
  148. package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
  149. package/dist/unifi/traffic-matching-list.d.ts +51 -0
  150. package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
  151. package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
  152. package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
  153. package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
  154. package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
  155. package/dist/unifi/wifi-broadcast.d.ts +73 -0
  156. package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
  157. package/docs/distilled-interim.md +7 -0
  158. package/docs/launchd.md +5 -3
  159. package/docs/provider-standard.md +5 -1
  160. package/docs/talos-argocd-dogfood.md +160 -0
  161. package/docs/unifi-acl-rule.md +122 -0
  162. package/docs/unifi-dns-policy.md +75 -0
  163. package/docs/unifi-firewall-policy.md +109 -0
  164. package/docs/unifi-traffic-matching-list.md +82 -0
  165. package/docs/unifi-wifi.md +120 -0
  166. package/docs/unifi.md +10 -5
  167. package/package.json +7 -3
  168. package/src/ceph/auth-entity.ts +74 -0
  169. package/src/ceph/ceph-argv-config.ts +78 -0
  170. package/src/ceph/ceph-argv.test.ts +226 -0
  171. package/src/ceph/ceph-argv.ts +189 -0
  172. package/src/ceph/ceph-auth-form.test.ts +103 -0
  173. package/src/ceph/ceph-auth-form.ts +107 -0
  174. package/src/ceph/ceph-auth-parse.test.ts +155 -0
  175. package/src/ceph/ceph-auth-parse.ts +121 -0
  176. package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
  177. package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
  178. package/src/ceph/ceph-auth-reconcile.ts +119 -0
  179. package/src/ceph/ceph-config-options.test.ts +44 -0
  180. package/src/ceph/ceph-config-options.ts +36 -0
  181. package/src/ceph/ceph-key-leak-assert.ts +35 -0
  182. package/src/ceph/ceph-no-output-logging.test.ts +52 -0
  183. package/src/ceph/ceph-transport.test.ts +125 -0
  184. package/src/ceph/ceph-transport.ts +117 -0
  185. package/src/ceph/fake-ceph-dial.ts +55 -0
  186. package/src/ceph/index.ts +12 -0
  187. package/src/index.ts +1 -0
  188. package/src/launchd/fake-runner.ts +2 -0
  189. package/src/launchd/index.ts +8 -1
  190. package/src/launchd/local-runner.ts +16 -1
  191. package/src/launchd/runner.ts +22 -0
  192. package/src/launchd/sudo-runner.ts +3 -0
  193. package/src/linux/directory-lifecycle.ts +18 -3
  194. package/src/linux/directory-sudo.test.ts +59 -0
  195. package/src/linux/directory.test.ts +34 -8
  196. package/src/linux/fake-linux-host.ts +2 -0
  197. package/src/linux/index.ts +1 -0
  198. package/src/linux/ssh-runner.ts +4 -0
  199. package/src/linux/sudo-runner.ts +3 -0
  200. package/src/opnsense/alias-form.test.ts +24 -12
  201. package/src/opnsense/alias-form.ts +20 -7
  202. package/src/opnsense/alias.test.ts +13 -6
  203. package/src/opnsense/alias.ts +16 -10
  204. package/src/opnsense/group-form.test.ts +12 -8
  205. package/src/opnsense/group-form.ts +13 -4
  206. package/src/opnsense/group.test.ts +18 -7
  207. package/src/opnsense/group.ts +10 -3
  208. package/src/opnsense/wire.ts +57 -12
  209. package/src/proxmox/qemu-volume.test.ts +153 -4
  210. package/src/proxmox/qemu-volume.ts +71 -14
  211. package/src/proxmox/storage-form.ts +48 -8
  212. package/src/proxmox/storage-plugin-options.ts +45 -0
  213. package/src/proxmox/storage-update.test.ts +183 -0
  214. package/src/proxmox/storage-wire.ts +54 -7
  215. package/src/proxmox/storage.ts +18 -6
  216. package/src/release/index.ts +1 -0
  217. package/src/talos/credentials-write.test.ts +81 -0
  218. package/src/talos/credentials-write.ts +95 -0
  219. package/src/talos/credentials.test.ts +157 -0
  220. package/src/talos/credentials.ts +160 -90
  221. package/src/talos/fake-process.ts +94 -0
  222. package/src/talos/index.ts +39 -4
  223. package/src/talos/kubeconfig-attrs.ts +55 -0
  224. package/src/talos/kubeconfig.test.ts +256 -0
  225. package/src/talos/kubeconfig.ts +170 -90
  226. package/src/talos/machine-config-fixtures.ts +86 -0
  227. package/src/talos/machine-config-poll.test.ts +57 -0
  228. package/src/talos/machine-config-poll.ts +82 -0
  229. package/src/talos/machine-config-read.ts +118 -0
  230. package/src/talos/resource.ts +19 -0
  231. package/src/talos/secrets-gate.test.ts +63 -0
  232. package/src/talos/talos-bootstrap-etcd.ts +71 -0
  233. package/src/talos/talos-bootstrap.test.ts +218 -0
  234. package/src/talos/talos-bootstrap.ts +151 -45
  235. package/src/talos/talos-cluster-health.test.ts +143 -0
  236. package/src/talos/talos-cluster-health.ts +91 -28
  237. package/src/talos/talos-errors.ts +85 -0
  238. package/src/talos/talos-machine-config.test.ts +189 -0
  239. package/src/talos/talos-machine-config.ts +102 -88
  240. package/src/talos/talosctl.ts +8 -0
  241. package/src/talos/values.test.ts +34 -6
  242. package/src/talos/values.ts +41 -7
  243. package/src/unifi/acl-rule-drift.ts +57 -0
  244. package/src/unifi/acl-rule-form.ts +135 -0
  245. package/src/unifi/acl-rule-ordering-form.ts +79 -0
  246. package/src/unifi/acl-rule-ordering.test.ts +141 -0
  247. package/src/unifi/acl-rule-ordering.ts +70 -0
  248. package/src/unifi/acl-rule.test.ts +183 -0
  249. package/src/unifi/acl-rule.ts +61 -0
  250. package/src/unifi/discriminated-filter-decode.test.ts +134 -0
  251. package/src/unifi/dns-policy-drift.ts +48 -0
  252. package/src/unifi/dns-policy-form.ts +142 -0
  253. package/src/unifi/dns-policy.test.ts +154 -0
  254. package/src/unifi/dns-policy.ts +67 -0
  255. package/src/unifi/firewall-policy-drift.test.ts +151 -0
  256. package/src/unifi/firewall-policy-drift.ts +55 -0
  257. package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
  258. package/src/unifi/firewall-policy-form.ts +146 -0
  259. package/src/unifi/firewall-policy-ordering-form.ts +111 -0
  260. package/src/unifi/firewall-policy-ordering.test.ts +185 -0
  261. package/src/unifi/firewall-policy-ordering.ts +87 -0
  262. package/src/unifi/firewall-policy.test.ts +183 -0
  263. package/src/unifi/firewall-policy.ts +73 -0
  264. package/src/unifi/index.ts +63 -0
  265. package/src/unifi/paginate.test.ts +24 -0
  266. package/src/unifi/paginate.ts +15 -3
  267. package/src/unifi/providers.ts +19 -1
  268. package/src/unifi/scan-source.ts +64 -0
  269. package/src/unifi/traffic-matching-list-drift.ts +40 -0
  270. package/src/unifi/traffic-matching-list-form.ts +77 -0
  271. package/src/unifi/traffic-matching-list.test.ts +202 -0
  272. package/src/unifi/traffic-matching-list.ts +78 -0
  273. package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
  274. package/src/unifi/wifi-broadcast-drift.ts +69 -0
  275. package/src/unifi/wifi-broadcast-form.ts +211 -0
  276. package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
  277. package/src/unifi/wifi-broadcast.test.ts +155 -0
  278. package/src/unifi/wifi-broadcast.ts +90 -0
  279. package/src/unifi/write-op-reference.test.ts +8 -54
@@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto';
15
15
  import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
16
16
  import { basename, dirname, join } from 'node:path';
17
17
  import { groupQuery, parseGroup, parseUser, userQuery } from './host-lookup.ts';
18
- import type { ExecResult, FileStat, HostRunner, WriteOptions } from './runner.ts';
18
+ import type { ExecResult, FileStat, HostPlatform, HostRunner, WriteOptions } from './runner.ts';
19
19
 
20
20
  export type LocalRunnerOptions = {
21
21
  /** Kill a spawned program after this long. @default 120_000 */
@@ -98,6 +98,20 @@ const writeAtomic = async (path: string, bytes: Uint8Array, options: WriteOption
98
98
  export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
99
99
  const timeoutMs = options.execTimeoutMs ?? 120_000;
100
100
  const platform = process.platform;
101
+ // ★ THE ONE PLACE `process.platform` MAY DECIDE HOST BEHAVIOUR: here, the target IS this
102
+ // process's own machine, so `process.platform` and `HostRunner.platform` name the same host.
103
+ // Everywhere else (chmodChownEnd, host-lookup's callers over ssh) reads `runner.platform`
104
+ // instead — see the incident on `HostRunner.platform`'s own doc comment.
105
+ // ⛔ DECLARED, NEVER GUESSED, past these two: silently folding freebsd/win32/etc. into 'linux'
106
+ // would hand a BSD host GNU's `--`, which macOS's own (BSD-flavoured) chmod/chown already
107
+ // reject — the same shape of bug this PR fixes. sshRunner's own `uname -s` probe refuses a
108
+ // non-Linux target the same way; localRunner refuses here instead of guessing.
109
+ if (platform !== 'darwin' && platform !== 'linux') {
110
+ throw new Error(
111
+ `localRunner(): unsupported process.platform ${JSON.stringify(platform)} — HostRunner only targets darwin and linux.`,
112
+ );
113
+ }
114
+ const hostPlatform: HostPlatform = platform;
101
115
  return {
102
116
  effectiveUid: () => process.geteuid?.() ?? -1,
103
117
  exec: (argv) => run(argv, timeoutMs),
@@ -109,6 +123,7 @@ export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
109
123
  const result = await run(userQuery(platform, nameOrId), timeoutMs);
110
124
  return result.exitCode === 0 ? parseUser(platform, result.stdout) : undefined;
111
125
  },
126
+ platform: hostPlatform,
112
127
  privileged: false,
113
128
  readFile: async (path) => {
114
129
  try {
@@ -59,7 +59,29 @@ export type WriteOptions = {
59
59
 
60
60
  export type HostUser = { readonly uid: number; readonly gid: number; readonly home: string };
61
61
 
62
+ /**
63
+ * The two host families a HostRunner ever actually targets. ★ Not `NodeJS.Platform`: nothing
64
+ * here runs against `win32`/`aix`/etc, and a narrower type is what makes `runner.platform ===
65
+ * 'darwin'` exhaustive rather than a string comparison that silently matches nothing.
66
+ */
67
+ export type HostPlatform = 'darwin' | 'linux';
68
+
62
69
  export interface HostRunner {
70
+ /**
71
+ * ⛔ DECLARED, NEVER INFERRED — same rule as `privileged`, and the same failure mode. The OS of
72
+ * the TARGET this runner writes to, never of the process running Alchemy: `localRunner()` may
73
+ * run ON either, but `sshRunner()`/`sshSudoRunner()` always reach a Linux host from wherever
74
+ * Alchemy itself happens to run.
75
+ * 🔴 INCIDENT, homeflare-ct100 2026-09-27: `Host.Directory`'s chmod/chown end-of-options token
76
+ * (directory-lifecycle.ts `chmodChownEnd`) read `process.platform` — the Mac mini's own OS —
77
+ * instead of the runner's target. Deploying from the Mac to CT100 (a Debian LXC over
78
+ * `sshSudoRunner`) dropped GNU's required `--`, and `sudo-allowlist-dir.ts`'s `dirProgramProblem`
79
+ * refused every chown with `SudoRefusedError` even though the path was under a declared prefix.
80
+ * `localRunner()`'s own local deploys never showed it: there, `process.platform` and the
81
+ * target happen to be the same machine. This field is the fix: every call site that needs to
82
+ * know the target's OS reads `runner.platform`, never `process.platform`.
83
+ */
84
+ readonly platform: HostPlatform;
63
85
  /**
64
86
  * ⛔ DECLARED, NEVER INFERRED. `true` only when this runner can do every root-needing call the
65
87
  * providers make of it: every call already runs as root (a runner that talks to a root helper,
@@ -162,6 +162,9 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
162
162
  },
163
163
  lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
164
164
  lookupUser: (nameOrId) => base.lookupUser(nameOrId),
165
+ // ★ Forwarded, never hardcoded: this runner elevates on the SAME target `base` already
166
+ // declared (localRunner's own machine), so it carries no opinion of its own.
167
+ platform: base.platform,
165
168
  // ★ True, so the providers let a system-domain job through to this runner (canActAsRoot).
166
169
  privileged: true,
167
170
  readFile: (path) => base.readFile(path),
@@ -100,8 +100,18 @@ const octal = (mode: number) => mode.toString(8).padStart(3, '0');
100
100
  * keep it. These paths are absolute, so the token is only an option guard.
101
101
  * ⛔ Linux keeps `--`. The sudo allowlist requires that exact token; dropping it there
102
102
  * refuses the elevation.
103
+ * 🔴 INCIDENT, homeflare-ct100 2026-09-27: this used to decide on `process.platform` — the OS of
104
+ * the machine RUNNING Alchemy — instead of `runner.platform`, the OS of the TARGET the runner
105
+ * actually writes to. Deploying from the Mac mini (`darwin`) to CT100, a Debian LXC reached
106
+ * over `sshSudoRunner`, dropped the required `--` and `sudo-allowlist-dir.ts`'s
107
+ * `dirProgramProblem` refused every chown with `SudoRefusedError`, even though the path was
108
+ * under a declared prefix — `localRunner()`'s own local (Mac-to-Mac) deploys never showed it,
109
+ * because there `process.platform` and the target happen to be the same machine. Reading
110
+ * `runner.platform` instead (declared by every HostRunner — see its own doc comment) makes the
111
+ * decision follow the target, not the caller.
103
112
  */
104
- const chmodChownEnd = (): readonly string[] => (process.platform === 'darwin' ? [] : ['--']);
113
+ const chmodChownEnd = (runner: HostRunner): readonly string[] =>
114
+ runner.platform === 'darwin' ? [] : ['--'];
105
115
 
106
116
  /** One program, checked: anything but exit 0 is an Error naming the argv and the host's words. */
107
117
  const must = async (runner: HostRunner, path: string, argv: readonly string[]): Promise<void> => {
@@ -160,7 +170,12 @@ export const reconcileDirectory = async (
160
170
  }
161
171
  await must(runner, props.path, ['mkdir', '-m', octal(want.mode), '--', props.path]);
162
172
  } else if (stat.mode !== want.mode) {
163
- await must(runner, props.path, ['chmod', octal(want.mode), ...chmodChownEnd(), props.path]);
173
+ await must(runner, props.path, [
174
+ 'chmod',
175
+ octal(want.mode),
176
+ ...chmodChownEnd(runner),
177
+ props.path,
178
+ ]);
164
179
  }
165
180
  /**
166
181
  * ★ Only when it is actually wrong. A `chown` that changes nothing is still a write on the host
@@ -174,7 +189,7 @@ export const reconcileDirectory = async (
174
189
  ? undefined
175
190
  : ownerArg(want);
176
191
  if (owner !== undefined)
177
- await must(runner, props.path, ['chown', owner, ...chmodChownEnd(), props.path]);
192
+ await must(runner, props.path, ['chown', owner, ...chmodChownEnd(runner), props.path]);
178
193
  const after = await readDirectory(runner, props.path);
179
194
  // ⚠️ READ BACK: a runner that ignored the mode shows up here, not as a forever-`update`.
180
195
  if (after === undefined || after.mode !== want.mode || !ownerMatches(after, want)) {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Host.Directory driven through sshSudoRunner — the exact path the homeflare-ct100 incident
3
+ * (2026-09-27) broke: a deploy running on a Mac (darwin) targeting a Linux host over ssh dropped
4
+ * the sudo allowlist's required `--`, because directory-lifecycle.ts's `chmodChownEnd()` read
5
+ * `process.platform` — the Mac's own OS — instead of the runner's own declared target.
6
+ *
7
+ * ★ WHY A SEPARATE FILE FROM directory.test.ts. Those tests exercise the lifecycle directly
8
+ * against a bare fake host; these go through the REAL `sshSudoRunner` + `sudo-allowlist.ts`
9
+ * layer (`canonicalize`, `dirProgramProblem`), which is what actually enforces the `--` token
10
+ * and is what refused the live deploy with `SudoRefusedError`.
11
+ * ⛔ NEITHER TEST BELOW READS `process.platform`. `fakeLinuxHost()` (via `fakeSudoHost()`)
12
+ * declares `platform: 'linux'` itself — sshRunner's own probe requires it — so this suite
13
+ * catches the regression on every machine that runs `bun test`, regardless of that machine's
14
+ * own OS. (This repo's own `check` job runs on `ubuntu-latest`, not the mini, so CI alone never
15
+ * reproduces the "darwin process, Linux target" split the live incident measured; this suite's
16
+ * fixed, declared target is what actually proves the fix, on any OS.)
17
+ */
18
+ import { describe, expect, test } from 'bun:test';
19
+ import { reconcileDirectory } from './directory-lifecycle.ts';
20
+ import { fakeSudoHost } from './fake-sudo.ts';
21
+
22
+ describe('Host.Directory through sshSudoRunner (a Linux target)', () => {
23
+ test('the elevated chown and chmod carry -- and the allowlist accepts them', async () => {
24
+ const { privileged, runner } = fakeSudoHost();
25
+ expect(runner.platform).toBe('linux'); // ★ declared by sshRunner's own uname probe.
26
+
27
+ // A fresh create under a declared prefix: mkdir runs as root, then chown (owner/group
28
+ // declared) also runs as root — both routed through routeExec()/elevatedExec().
29
+ const created = await reconcileDirectory(runner, {
30
+ group: 0,
31
+ mode: 0o755,
32
+ owner: 0,
33
+ path: '/etc/systemd/system/hf-test-dir',
34
+ });
35
+ expect(created).toEqual({
36
+ gid: 0,
37
+ mode: 0o755,
38
+ path: '/etc/systemd/system/hf-test-dir',
39
+ uid: 0,
40
+ });
41
+
42
+ // A mode drift on the next reconcile routes an elevated chmod too.
43
+ await reconcileDirectory(
44
+ runner,
45
+ { group: 0, mode: 0o750, owner: 0, path: '/etc/systemd/system/hf-test-dir' },
46
+ created,
47
+ );
48
+
49
+ const chown = privileged().find((call) => call[0] === '/usr/bin/chown');
50
+ const chmod = privileged().find((call) => call[0] === '/usr/bin/chmod');
51
+ // ⛔ REGRESSION: before the fix, these two calls carried no `--` whenever `bun test` ran on a
52
+ // Mac, and `sudo-allowlist-dir.ts`'s `dirProgramProblem` refused them with
53
+ // `SudoRefusedError` — the exact failure measured against homeflare-ct100 2026-09-27.
54
+ expect(chown).toBeDefined();
55
+ expect(chown).toContain('--');
56
+ expect(chmod).toBeDefined();
57
+ expect(chmod).toContain('--');
58
+ });
59
+ });
@@ -3,6 +3,7 @@
3
3
  * to create a parent.
4
4
  */
5
5
  import { describe, expect, test } from 'bun:test';
6
+ import type { HostRunner } from '../launchd/runner.ts';
6
7
  import {
7
8
  deleteDirectory,
8
9
  diffDirectory,
@@ -105,8 +106,16 @@ describe('drift', () => {
105
106
  });
106
107
 
107
108
  describe('argv', () => {
108
- test('chmod and chown omit -- on Darwin; mkdir keeps it on every platform', async () => {
109
+ // ⛔ REGRESSION, 2026-09-27: this used to read `process.platform !== 'darwin'` to decide what
110
+ // the fake (a LINUX host) should have produced — so it only ever proved the real behaviour
111
+ // when `bun test` itself happened to run on Linux (matching the fake's target by
112
+ // coincidence), and passed just as happily on a Mac while asserting the bug itself (no `--`
113
+ // for a Linux target). `runner.platform` is declared by the fixture instead, so these two
114
+ // tests hold regardless of what OS actually runs the suite — the exact "darwin process,
115
+ // Linux target" split the homeflare-ct100 incident measured.
116
+ test('mkdir keeps --, and chmod/chown carry -- for a Linux target', async () => {
109
117
  const fake = host();
118
+ expect(fake.runner.platform).toBe('linux'); // ★ fakeLinuxHost's own declaration.
110
119
  const created = await reconcileDirectory(fake.runner, {
111
120
  group: 0,
112
121
  mode: 0o755,
@@ -118,13 +127,30 @@ describe('argv', () => {
118
127
  { group: 0, mode: 0o750, owner: 0, path: '/opt/app/bin' },
119
128
  created,
120
129
  );
121
- const gnu = process.platform !== 'darwin';
122
- const chown = fake.calls.find((call) => call[0] === 'chown');
123
- const chmod = fake.calls.find((call) => call[0] === 'chmod');
124
- const mkdir = fake.calls.find((call) => call[0] === 'mkdir');
125
- expect(chown?.includes('--')).toBe(gnu);
126
- expect(chmod?.includes('--')).toBe(gnu);
127
- expect(mkdir).toContain('--');
130
+ expect(fake.calls.find((call) => call[0] === 'mkdir')).toContain('--');
131
+ expect(fake.calls.find((call) => call[0] === 'chown')).toContain('--');
132
+ expect(fake.calls.find((call) => call[0] === 'chmod')).toContain('--');
133
+ });
134
+
135
+ test('mkdir still keeps --, but chmod/chown omit it for a runner declared as Darwin', async () => {
136
+ const fake = host();
137
+ // ★ Same fake host, only the declared target changes — proves chmodChownEnd() reads
138
+ // `runner.platform`, never the machine `bun test` itself happens to run on.
139
+ const darwinRunner: HostRunner = { ...fake.runner, platform: 'darwin' };
140
+ const created = await reconcileDirectory(darwinRunner, {
141
+ group: 0,
142
+ mode: 0o755,
143
+ owner: 0,
144
+ path: '/opt/app/bin',
145
+ });
146
+ await reconcileDirectory(
147
+ darwinRunner,
148
+ { group: 0, mode: 0o750, owner: 0, path: '/opt/app/bin' },
149
+ created,
150
+ );
151
+ expect(fake.calls.find((call) => call[0] === 'mkdir')).toContain('--');
152
+ expect(fake.calls.find((call) => call[0] === 'chown')?.includes('--')).toBe(false);
153
+ expect(fake.calls.find((call) => call[0] === 'chmod')?.includes('--')).toBe(false);
128
154
  });
129
155
  });
130
156
 
@@ -127,6 +127,8 @@ export const fakeLinuxHost = (options: FakeLinuxOptions = {}) => {
127
127
  lookupUser: async (nameOrId) =>
128
128
  options.users?.[nameOrId] ??
129
129
  Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
130
+ // ★ This fake models sshRunner()'s own target, which is always Linux — fixed, not an option.
131
+ platform: 'linux',
130
132
  privileged,
131
133
  readFile: async (path) => files.get(path)?.bytes,
132
134
  removeFile: async (path) => {
@@ -24,6 +24,7 @@ export { sshSudoRunner } from './sudo-runner.ts';
24
24
  export type {
25
25
  ExecResult,
26
26
  FileStat,
27
+ HostPlatform,
27
28
  HostRunner,
28
29
  HostUser,
29
30
  WriteOptions,
@@ -154,6 +154,10 @@ export const sshRunner = async (options: SshRunnerOptions): Promise<HostRunner>
154
154
  const result = await run(execScript(userQuery('linux', nameOrId)));
155
155
  return result.exitCode === 0 ? parseUser('linux', result.stdout) : undefined;
156
156
  },
157
+ // ★ Fixed, not read from the caller's own `process.platform`: the probe above already
158
+ // refused anything but `uname -s` = Linux, so this is the one true answer for this runner —
159
+ // whatever OS is running Alchemy itself is irrelevant (HostRunner.platform's doc comment).
160
+ platform: 'linux',
157
161
  privileged: false,
158
162
  readFile: async (path) => {
159
163
  const result = await run(readScript(path));
@@ -99,6 +99,9 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
99
99
  },
100
100
  lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
101
101
  lookupUser: (nameOrId) => base.lookupUser(nameOrId),
102
+ // ★ Forwarded, never hardcoded — same reasoning as ../launchd/sudo-runner.ts's own forward:
103
+ // this runner elevates on the SAME target `base` (sshRunner) already declared.
104
+ platform: base.platform,
102
105
  privileged: true,
103
106
  readFile: (path) => base.readFile(path),
104
107
  removeFile: async (path) => {
@@ -1,19 +1,21 @@
1
1
  /**
2
- * `alias-form.ts`'s pure functions against literal `AliasItem` fixtures — no network, no fake
3
- * server. Mirrors `../discord/command-form.ts`'s own test file in spirit.
2
+ * `alias-form.ts`'s pure functions against literal `ModelAliasReadItem` fixtures (the WHOLE-MODEL
3
+ * `get()`'s read-shaped item, option maps and all — OPNSENSE-2) — no network, no fake server.
4
+ * Mirrors `../discord/command-form.ts`'s own test file in spirit.
4
5
  */
5
6
  import { describe, expect, test } from 'bun:test';
6
7
  import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
7
8
  import { attributesOf, matches } from './alias-form.ts';
8
9
  import type { AliasProps } from './alias.ts';
10
+ import { optionMap } from './wire.ts';
9
11
 
10
12
  const UUID = '11111111-1111-1111-1111-111111111111';
11
13
 
12
- const liveHost = (overrides: Partial<alias.AliasItem> = {}): alias.AliasItem => ({
13
- content: '10.20.10.1',
14
+ const liveHost = (overrides: Partial<alias.ModelAliasReadItem> = {}): alias.ModelAliasReadItem => ({
15
+ content: optionMap('10.20.10.1'),
14
16
  enabled: '1',
15
17
  name: 'homeflare_edge',
16
- type: 'host',
18
+ type: optionMap('host'),
17
19
  ...overrides,
18
20
  });
19
21
 
@@ -38,10 +40,20 @@ describe('alias-form attributesOf', () => {
38
40
  expect(attributesOf(UUID, liveHost({ enabled: '0' })).enabled).toBe(false);
39
41
  });
40
42
 
41
- test('categories are split, sorted and deduplicated', () => {
42
- const live = liveHost({ categories: 'c-two,c-one,c-one' });
43
+ test('categories are the selected keys, sorted and deduplicated', () => {
44
+ const live = liveHost({ categories: optionMap('c-two', 'c-one') });
43
45
  expect(attributesOf(UUID, live).categories).toEqual(['c-one', 'c-two']);
44
46
  });
47
+
48
+ test('an unselected option-map entry is not returned (single-select `type`)', () => {
49
+ const live = liveHost({
50
+ type: {
51
+ host: { value: 'Host(s)', selected: 1 },
52
+ network: { value: 'Network(s)', selected: 0 },
53
+ },
54
+ });
55
+ expect(attributesOf(UUID, live).type).toBe('host');
56
+ });
45
57
  });
46
58
 
47
59
  describe('alias-form matches', () => {
@@ -57,12 +69,12 @@ describe('alias-form matches', () => {
57
69
  });
58
70
 
59
71
  test('content drift is caught', () => {
60
- const drifted = attributesOf(UUID, liveHost({ content: '10.20.10.2' }));
72
+ const drifted = attributesOf(UUID, liveHost({ content: optionMap('10.20.10.2') }));
61
73
  expect(matches(drifted, props)).toBe(false);
62
74
  });
63
75
 
64
76
  test('category order alone is not drift — both sides normalise through the same set', () => {
65
- const live = attributesOf(UUID, liveHost({ categories: 'b,a' }));
77
+ const live = attributesOf(UUID, liveHost({ categories: optionMap('b', 'a') }));
66
78
  const declared: AliasProps = { ...props, categories: ['a', 'b'] };
67
79
  expect(matches(live, declared)).toBe(true);
68
80
  });
@@ -79,9 +91,9 @@ describe('propsFromLive round-trip — the declaration renderer whole contract',
79
91
  // alias.ts re-exports `attributesOf` itself as `propsFromLive`; tested directly here since
80
92
  // alias.ts also wires up the Resource machinery this file does not need to import.
81
93
  const live = liveHost({
82
- categories: 'c-two,c-one',
83
- content: '203.0.113.0/24',
84
- type: 'network',
94
+ categories: optionMap('c-two', 'c-one'),
95
+ content: optionMap('203.0.113.0/24'),
96
+ type: optionMap('network'),
85
97
  });
86
98
  const rendered = attributesOf(UUID, live);
87
99
  expect(matches(attributesOf(UUID, live), rendered)).toBe(true);
@@ -18,24 +18,37 @@
18
18
  */
19
19
  import type { AliasAttributes, AliasProps } from './alias.ts';
20
20
  import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
21
- import { bool01, csvSet, csvSetOf, numField } from './wire.ts';
21
+ import { bool01, csvSetOf, numField, selectedOf, selectedOneOf } from './wire.ts';
22
22
 
23
23
  /**
24
24
  * ★ ATTRIBUTES AND THE DECLARATION RENDERER ARE ONE FUNCTION. Attributes ARE exactly the props a
25
25
  * noop declaration needs — see the exported `propsFromLive` alias in alias.ts's barrel and
26
26
  * `alias-form.test.ts`'s round-trip test (`matches(attributesOf(uuid, live), attributesOf(uuid,
27
27
  * live))` is always true by construction, and so is the general case).
28
+ *
29
+ * `live` is `ModelAliasReadItem` (the WHOLE-MODEL `get()`'s read-shaped item), not `AliasItem` —
30
+ * `type`/`interface`/`proto`/`categories` decode as option maps, not strings (OPNSENSE-2; see
31
+ * wire.ts's module doc). `selectedOf`/`selectedOneOf` extract the selected key(s) back to the
32
+ * same plain-string/string[] shape `AliasAttributes` already declared.
33
+ *
34
+ * `content` is `.\AliasContentField`, which overrides `getNodeData()` independently of
35
+ * `BaseListField` (`Firewall/FieldTypes/AliasContentField.php:69-76`) — it still decodes as a map,
36
+ * but every entry is unconditionally `selected: 1` (it is a raw newline-separated list, not a
37
+ * real option set), and ITS wire separator is `\n`, not `,`. `Object.keys` preserves the
38
+ * insertion order OPNsense's own `explode()` built the map in, which matters for a host/URL list
39
+ * in a way `selectedOf`'s alphabetical sort does not preserve — so this one field is reconstructed
40
+ * directly rather than through `selectedOf`.
28
41
  */
29
- export const attributesOf = (uuid: string, live: alias.AliasItem): AliasAttributes => ({
30
- categories: csvSet(live.categories),
31
- content: live.content ?? '',
42
+ export const attributesOf = (uuid: string, live: alias.ModelAliasReadItem): AliasAttributes => ({
43
+ categories: selectedOf(live.categories),
44
+ content: Object.keys(live.content ?? {}).join('\n'),
32
45
  counters: bool01(live.counters, false),
33
46
  description: live.description ?? '',
34
47
  enabled: bool01(live.enabled, true),
35
- interface: live.interface ?? '',
48
+ interface: selectedOneOf(live.interface),
36
49
  name: live.name,
37
- proto: live.proto ?? '',
38
- type: live.type,
50
+ proto: selectedOf(live.proto).join(','),
51
+ type: selectedOneOf(live.type),
39
52
  updatefreq: numField(live.updatefreq, 0),
40
53
  uuid,
41
54
  });
@@ -16,6 +16,7 @@ import { spec } from './alias.ts';
16
16
  import { fakeFailure, fakeOpnsense, fakeOpnsenseLayer, getOnlyOrFail } from './fake-opnsense.ts';
17
17
  import type { OpnsenseWriteRefused } from './policy.ts';
18
18
  import { opnsenseOperations } from './resource.ts';
19
+ import { optionMap } from './wire.ts';
19
20
 
20
21
  /** `Effect.flip`'s promise resolves to the union of every error this spec can raise; every
21
22
  * refusal test below is asserting specifically on the `OpnsenseWriteRefused` member. */
@@ -30,15 +31,17 @@ const PROPS: AliasProps = {
30
31
  uuid: UUID,
31
32
  };
32
33
 
33
- const liveItem = (overrides: Partial<alias.AliasItem> = {}): alias.AliasItem => ({
34
- content: '10.20.10.1',
34
+ // `liveItem` builds a `ModelAliasReadItem` — the WHOLE-MODEL `get()`'s read-shaped item, whose
35
+ // `content`/`type` decode as option maps, not strings (OPNSENSE-2 — see alias-form.ts).
36
+ const liveItem = (overrides: Partial<alias.ModelAliasReadItem> = {}): alias.ModelAliasReadItem => ({
37
+ content: optionMap('10.20.10.1'),
35
38
  enabled: '1',
36
39
  name: 'homeflare_edge',
37
- type: 'host',
40
+ type: optionMap('host'),
38
41
  ...overrides,
39
42
  });
40
43
 
41
- const getResponse = (items: Record<string, alias.AliasItem>) =>
44
+ const getResponse = (items: Record<string, alias.ModelAliasReadItem>) =>
42
45
  Response.json({ alias: { aliases: { alias: items } } });
43
46
 
44
47
  describe('Opnsense.Firewall.Alias spec.fetchLive', () => {
@@ -126,7 +129,9 @@ describe('opnsenseOperations(spec).diff', () => {
126
129
  });
127
130
 
128
131
  test('update when live content drifted from the declaration — never from a read error', async () => {
129
- const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ content: '10.20.10.9' }) }));
132
+ const fake = getOnlyOrFail(() =>
133
+ getResponse({ [UUID]: liveItem({ content: optionMap('10.20.10.9') }) }),
134
+ );
130
135
  const result = await Effect.runPromise(
131
136
  opnsenseOperations(spec)
132
137
  .diff(PROPS, attributesOf(UUID, liveItem()))
@@ -138,7 +143,9 @@ describe('opnsenseOperations(spec).diff', () => {
138
143
 
139
144
  describe('opnsenseOperations(spec).reconcile and destroy — the read-only policy', () => {
140
145
  test('reconcile observes with one GET, then refuses — never a write', async () => {
141
- const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ content: 'drifted' }) }));
146
+ const fake = getOnlyOrFail(() =>
147
+ getResponse({ [UUID]: liveItem({ content: optionMap('drifted') }) }),
148
+ );
142
149
  const failure = asRefusal(
143
150
  await Effect.runPromise(
144
151
  Effect.flip(
@@ -3,15 +3,16 @@
3
3
  * READ-ONLY BY DESIGN — see policy.ts's header. `reconcile`/`delete` always refuse.
4
4
  *
5
5
  * ★ READ GOES THROUGH `get()`, NEVER `getItem`/`searchItem`. `get()` (`firewall_alias.ts#get`)
6
- * returns the WHOLE model tree — `{ alias: { aliases: { alias: { <uuid>: AliasItem, ... } } } }`
7
- * — so finding one item by uuid is an object-key lookup, not a request the vendor could answer
8
- * ambiguously. That sidesteps two real gaps in this early SDK, both measured against the
9
- * generated `firewall_alias.ts`: (1) there is no per-item `getAlias` operation at all — only
10
- * `get` (whole model) and `searchAlias` (paginated, see below) exist; (2) `firewall_category.ts`
11
- * and `firewall_group.ts` DO export a `getCategory`/`getGroup`, but their generated request
12
- * schema carries no `uuid` path parameter (`GetCategoryRequest`/`GetGroupRequest` are both
13
- * `S.Struct({})` against a `uri` with no `{uuid}` placeholder) — a generation gap, not a design
14
- * choice, so this family does not rely on them for any of the three resources, uniformly.
6
+ * returns the WHOLE model tree — `{ alias: { aliases: { alias: { <uuid>: ModelAliasReadItem,
7
+ * ... } } } }` — so finding one item by uuid is an object-key lookup, not a request the vendor
8
+ * could answer ambiguously. `Alias` has no per-item `getAlias` operation at all (`getItemAction`
9
+ * decorates its result — see the distilled clone's package README) — only `get` (whole model)
10
+ * and `searchAlias` (paginated, see below) exist, so this family's whole-model read is the only
11
+ * option here (unlike Category/Group, whose `getCategory`/`getGroup` per-item operations now
12
+ * exist correctly — OPNSENSE-1 — but this file still uses `get()` uniformly across the three
13
+ * resources; switching Category/Group to their per-item operation is a separate, later PR).
14
+ * `ModelAliasReadItem`'s list-shaped fields (`type`/`interface`/`proto`/`categories`) decode as
15
+ * option maps, not strings (OPNSENSE-2) — see `alias-form.ts`'s `attributesOf`.
15
16
  *
16
17
  * ⛔ NOT `searchAlias`. It is POST-shaped (a read, per OPNsense's own MVC convention — see the
17
18
  * family's docs page) and paginated with no typed limit/offset the generated request schema
@@ -87,7 +88,12 @@ export const isOpnsenseFirewallAlias = (value: unknown): value is OpnsenseFirewa
87
88
  (value as { Type?: unknown }).Type === 'Opnsense.Firewall.Alias';
88
89
 
89
90
  /** Exported for direct testing against `fake-opnsense.ts` — the same seam `../discord/*.ts` uses. */
90
- export const spec: OpnsenseSpec<AliasProps, Alias.AliasItem, AliasAttributes, OpnsenseOpError> = {
91
+ export const spec: OpnsenseSpec<
92
+ AliasProps,
93
+ Alias.ModelAliasReadItem,
94
+ AliasAttributes,
95
+ OpnsenseOpError
96
+ > = {
91
97
  attributes: attributesOf,
92
98
  fetchLive: (props) =>
93
99
  Alias.get({}).pipe(Effect.map((res) => res.alias?.aliases?.alias?.[props.uuid])),
@@ -1,16 +1,20 @@
1
1
  /**
2
- * `group-form.ts`'s pure functions against literal `GroupItem` fixtures — no network.
2
+ * `group-form.ts`'s pure functions against literal `ModelIfgroupentryReadItem` fixtures (the
3
+ * WHOLE-MODEL `get()`'s read-shaped item — `members` is an option map, OPNSENSE-2) — no network.
3
4
  */
4
5
  import { describe, expect, test } from 'bun:test';
5
6
  import type * as group from '@distilled.cloud/opnsense/firewall_group';
6
7
  import { attributesOf, matches } from './group-form.ts';
7
8
  import type { GroupProps } from './group.ts';
9
+ import { optionMap } from './wire.ts';
8
10
 
9
11
  const UUID = '33333333-3333-3333-3333-333333333333';
10
12
 
11
- const liveGroup = (overrides: Partial<group.GroupItem> = {}): group.GroupItem => ({
13
+ const liveGroup = (
14
+ overrides: Partial<group.ModelIfgroupentryReadItem> = {},
15
+ ): group.ModelIfgroupentryReadItem => ({
12
16
  ifname: 'IOT_DEVICES',
13
- members: 'opt1,opt2',
17
+ members: optionMap('opt1', 'opt2'),
14
18
  sequence: '1',
15
19
  ...overrides,
16
20
  });
@@ -27,8 +31,8 @@ describe('group-form attributesOf', () => {
27
31
  });
28
32
  });
29
33
 
30
- test('members are split, sorted and deduplicated', () => {
31
- const live = liveGroup({ members: 'opt2,opt1,opt1' });
34
+ test('members are the selected keys, sorted and deduplicated', () => {
35
+ const live = liveGroup({ members: optionMap('opt2', 'opt1') });
32
36
  expect(attributesOf(UUID, live).members).toEqual(['opt1', 'opt2']);
33
37
  });
34
38
 
@@ -50,13 +54,13 @@ describe('group-form matches', () => {
50
54
  });
51
55
 
52
56
  test('member-list order alone is not drift', () => {
53
- const live = attributesOf(UUID, liveGroup({ members: 'opt2,opt1' }));
57
+ const live = attributesOf(UUID, liveGroup({ members: optionMap('opt2', 'opt1') }));
54
58
  const declared: GroupProps = { ...props, members: ['opt1', 'opt2'] };
55
59
  expect(matches(live, declared)).toBe(true);
56
60
  });
57
61
 
58
62
  test('a removed member is drift', () => {
59
- const live = attributesOf(UUID, liveGroup({ members: 'opt1' }));
63
+ const live = attributesOf(UUID, liveGroup({ members: optionMap('opt1') }));
60
64
  expect(matches(live, props)).toBe(false);
61
65
  });
62
66
  });
@@ -65,7 +69,7 @@ describe('propsFromLive round-trip', () => {
65
69
  test('the props it returns plan noop against the exact live object they came from', () => {
66
70
  const live = liveGroup({
67
71
  descr: 'IoT VLANs',
68
- members: 'opt3,opt1,opt2',
72
+ members: optionMap('opt3', 'opt1', 'opt2'),
69
73
  nogroup: '1',
70
74
  sequence: '5',
71
75
  });
@@ -5,13 +5,22 @@
5
5
  */
6
6
  import type { GroupAttributes, GroupProps } from './group.ts';
7
7
  import type * as group from '@distilled.cloud/opnsense/firewall_group';
8
- import { bool01, csvSet, csvSetOf, numField } from './wire.ts';
8
+ import { bool01, csvSetOf, numField, selectedOf } from './wire.ts';
9
9
 
10
- /** ★ Attributes and the declaration renderer are one function — see alias-form.ts's header note. */
11
- export const attributesOf = (uuid: string, live: group.GroupItem): GroupAttributes => ({
10
+ /**
11
+ * ★ Attributes and the declaration renderer are one function — see alias-form.ts's header note.
12
+ *
13
+ * `live` is `ModelIfgroupentryReadItem` (the WHOLE-MODEL `get()`'s read-shaped item), not
14
+ * `GroupItem` — `members` (`.\InterfaceField`, `Multiple="Y"`) decodes as an option map, not a
15
+ * comma string (OPNSENSE-2; see wire.ts's module doc).
16
+ */
17
+ export const attributesOf = (
18
+ uuid: string,
19
+ live: group.ModelIfgroupentryReadItem,
20
+ ): GroupAttributes => ({
12
21
  description: live.descr ?? '',
13
22
  ifname: live.ifname,
14
- members: csvSet(live.members),
23
+ members: selectedOf(live.members),
15
24
  nogroup: bool01(live.nogroup, false),
16
25
  sequence: numField(live.sequence, 0),
17
26
  uuid,