@homeflare/alchemy 0.8.0 → 0.10.1

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 (247) hide show
  1. package/README.md +22 -11
  2. package/dist/caddy/config-lifecycle.d.ts +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +221 -53
  5. package/dist/caddy/index.js.map +13 -9
  6. package/dist/launchd/fake-runner.d.ts +1 -0
  7. package/dist/launchd/fake-runner.d.ts.map +1 -1
  8. package/dist/launchd/fake-sudo.d.ts +1 -0
  9. package/dist/launchd/fake-sudo.d.ts.map +1 -1
  10. package/dist/launchd/host-effect.d.ts +9 -18
  11. package/dist/launchd/host-effect.d.ts.map +1 -1
  12. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  13. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  14. package/dist/launchd/host-file.d.ts +2 -1
  15. package/dist/launchd/host-file.d.ts.map +1 -1
  16. package/dist/launchd/index.js +345 -86
  17. package/dist/launchd/index.js.map +18 -12
  18. package/dist/launchd/job-lifecycle.d.ts +8 -1
  19. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  20. package/dist/launchd/job-preflight.d.ts +13 -1
  21. package/dist/launchd/job-preflight.d.ts.map +1 -1
  22. package/dist/launchd/job.d.ts +2 -1
  23. package/dist/launchd/job.d.ts.map +1 -1
  24. package/dist/launchd/runner.d.ts +7 -0
  25. package/dist/launchd/runner.d.ts.map +1 -1
  26. package/dist/launchd/sudo-acl.d.ts +25 -0
  27. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  28. package/dist/launchd/sudo-guard.d.ts +31 -4
  29. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  30. package/dist/launchd/sudo-runner.d.ts +3 -3
  31. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-said.d.ts +10 -0
  33. package/dist/launchd/sudo-said.d.ts.map +1 -0
  34. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  35. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  36. package/dist/openbao/auth-method.d.ts.map +1 -1
  37. package/dist/openbao/auth-role.d.ts.map +1 -1
  38. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  39. package/dist/openbao/fake-bao.d.ts +18 -0
  40. package/dist/openbao/fake-bao.d.ts.map +1 -1
  41. package/dist/openbao/fake-core.d.ts +39 -0
  42. package/dist/openbao/fake-core.d.ts.map +1 -0
  43. package/dist/openbao/fake-families.d.ts +6 -3
  44. package/dist/openbao/fake-families.d.ts.map +1 -1
  45. package/dist/openbao/fake-mounts.d.ts +16 -0
  46. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  47. package/dist/openbao/fake-stack.d.ts +5 -1
  48. package/dist/openbao/fake-stack.d.ts.map +1 -1
  49. package/dist/openbao/index.js +541 -270
  50. package/dist/openbao/index.js.map +26 -21
  51. package/dist/openbao/jwt-config.d.ts.map +1 -1
  52. package/dist/openbao/jwt-role.d.ts.map +1 -1
  53. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  54. package/dist/openbao/mfa-enforcement.d.ts +6 -2
  55. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  56. package/dist/openbao/mfa-totp.d.ts +6 -2
  57. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  58. package/dist/openbao/mount-reconcile.d.ts +5 -1
  59. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  60. package/dist/openbao/mount.d.ts.map +1 -1
  61. package/dist/openbao/pki-role.d.ts.map +1 -1
  62. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  63. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  64. package/dist/openbao/plugin.d.ts.map +1 -1
  65. package/dist/openbao/policy.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  67. package/dist/openbao/role-reconcile.d.ts +11 -1
  68. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  69. package/dist/openbao/ssh-role.d.ts.map +1 -1
  70. package/dist/ownership/adopt.d.ts +52 -0
  71. package/dist/ownership/adopt.d.ts.map +1 -0
  72. package/dist/ownership/probe.d.ts +13 -0
  73. package/dist/ownership/probe.d.ts.map +1 -0
  74. package/dist/ownership/resume.d.ts +40 -0
  75. package/dist/ownership/resume.d.ts.map +1 -0
  76. package/dist/ownership/rows.d.ts +33 -0
  77. package/dist/ownership/rows.d.ts.map +1 -0
  78. package/dist/ownership/whole.d.ts +14 -0
  79. package/dist/ownership/whole.d.ts.map +1 -0
  80. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  81. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  82. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  83. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  84. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  85. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  86. package/dist/proxmox/fake-pve.d.ts +38 -0
  87. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  88. package/dist/proxmox/index.d.ts +6 -2
  89. package/dist/proxmox/index.d.ts.map +1 -1
  90. package/dist/proxmox/index.js +970 -186
  91. package/dist/proxmox/index.js.map +22 -9
  92. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  93. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  94. package/dist/proxmox/lxc-harness.d.ts +34 -0
  95. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  96. package/dist/proxmox/lxc-identity.d.ts +18 -0
  97. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  98. package/dist/proxmox/lxc-judge.d.ts +46 -0
  99. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  100. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  101. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  102. package/dist/proxmox/lxc-props.d.ts +139 -0
  103. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  104. package/dist/proxmox/lxc-volume.d.ts +29 -0
  105. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  106. package/dist/proxmox/lxc-wire.d.ts +51 -0
  107. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  108. package/dist/proxmox/lxc.d.ts +27 -47
  109. package/dist/proxmox/lxc.d.ts.map +1 -1
  110. package/dist/proxmox/network-apply-read.d.ts +1 -1
  111. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  112. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  113. package/dist/proxmox/resource.d.ts.map +1 -1
  114. package/dist/proxmox/update-guard.d.ts +27 -0
  115. package/dist/proxmox/update-guard.d.ts.map +1 -0
  116. package/dist/verify/args.d.ts +16 -0
  117. package/dist/verify/args.d.ts.map +1 -0
  118. package/dist/verify/cli.d.ts +3 -0
  119. package/dist/verify/cli.d.ts.map +1 -0
  120. package/dist/verify/cli.js +370 -0
  121. package/dist/verify/cli.js.map +17 -0
  122. package/dist/verify/fake-engine.d.ts +65 -0
  123. package/dist/verify/fake-engine.d.ts.map +1 -0
  124. package/dist/verify/index.d.ts +16 -0
  125. package/dist/verify/index.d.ts.map +1 -0
  126. package/dist/verify/index.js +347 -0
  127. package/dist/verify/index.js.map +16 -0
  128. package/dist/verify/plan-scope.d.ts +14 -0
  129. package/dist/verify/plan-scope.d.ts.map +1 -0
  130. package/dist/verify/recheck.d.ts +42 -0
  131. package/dist/verify/recheck.d.ts.map +1 -0
  132. package/dist/verify/report.d.ts +6 -0
  133. package/dist/verify/report.d.ts.map +1 -0
  134. package/dist/verify/rows.d.ts +67 -0
  135. package/dist/verify/rows.d.ts.map +1 -0
  136. package/dist/verify/spy.d.ts +43 -0
  137. package/dist/verify/spy.d.ts.map +1 -0
  138. package/dist/verify/verify.d.ts +43 -0
  139. package/dist/verify/verify.d.ts.map +1 -0
  140. package/docs/adopt-verify.md +119 -0
  141. package/docs/adopted-deploys.md +85 -0
  142. package/docs/credentials.md +15 -0
  143. package/docs/launchd-sudo.md +31 -6
  144. package/docs/launchd.md +7 -3
  145. package/docs/ownership.md +113 -0
  146. package/docs/proxmox-lxc.md +196 -0
  147. package/docs/proxmox.md +12 -0
  148. package/package.json +9 -2
  149. package/src/caddy/adopt-scope.test.ts +1 -1
  150. package/src/caddy/config-lifecycle.ts +1 -1
  151. package/src/caddy/config.ts +2 -1
  152. package/src/launchd/adopt-apply.test.ts +166 -0
  153. package/src/launchd/fake-runner.ts +26 -4
  154. package/src/launchd/host-effect.ts +1 -33
  155. package/src/launchd/host-file-lifecycle.ts +39 -14
  156. package/src/launchd/host-file.ts +13 -4
  157. package/src/launchd/job-lifecycle.ts +22 -14
  158. package/src/launchd/job-preflight.ts +18 -1
  159. package/src/launchd/job.ts +13 -4
  160. package/src/launchd/runner.ts +7 -0
  161. package/src/launchd/sudo-acl.ts +68 -0
  162. package/src/launchd/sudo-chain.test.ts +121 -0
  163. package/src/launchd/sudo-guard.ts +89 -10
  164. package/src/launchd/sudo-modes.test.ts +135 -0
  165. package/src/launchd/sudo-runner.ts +40 -59
  166. package/src/launchd/sudo-said.ts +58 -0
  167. package/src/openbao/README.md +5 -2
  168. package/src/openbao/REPLACE.md +13 -11
  169. package/src/openbao/adopt-core.test.ts +119 -0
  170. package/src/openbao/adopt-families.test.ts +97 -0
  171. package/src/openbao/adopt-holes.test.ts +168 -0
  172. package/src/openbao/adopt-recovery.test.ts +71 -0
  173. package/src/openbao/auth-method-reconcile.ts +15 -3
  174. package/src/openbao/auth-method.ts +15 -6
  175. package/src/openbao/auth-role.ts +14 -4
  176. package/src/openbao/cloudflare-role.ts +17 -4
  177. package/src/openbao/fake-bao.ts +52 -0
  178. package/src/openbao/fake-core.ts +141 -0
  179. package/src/openbao/fake-families.ts +11 -5
  180. package/src/openbao/fake-mounts.ts +74 -0
  181. package/src/openbao/fake-stack.ts +17 -4
  182. package/src/openbao/jwt-config.ts +10 -7
  183. package/src/openbao/jwt-role.ts +10 -7
  184. package/src/openbao/kubernetes-role.ts +13 -7
  185. package/src/openbao/mfa-enforcement.ts +20 -6
  186. package/src/openbao/mfa-totp.ts +22 -7
  187. package/src/openbao/mount-move.test.ts +2 -35
  188. package/src/openbao/mount-reconcile.ts +13 -2
  189. package/src/openbao/mount.ts +15 -6
  190. package/src/openbao/pki-role.ts +15 -4
  191. package/src/openbao/plugin-reconcile.ts +16 -5
  192. package/src/openbao/plugin.ts +17 -6
  193. package/src/openbao/policy.ts +17 -4
  194. package/src/openbao/proxmox-role.ts +13 -4
  195. package/src/openbao/rename-adoption.test.ts +32 -19
  196. package/src/openbao/role-reconcile.ts +23 -0
  197. package/src/openbao/ssh-role.ts +14 -4
  198. package/src/ownership/adopt.ts +122 -0
  199. package/src/ownership/ownership.test.ts +200 -0
  200. package/src/ownership/probe.ts +65 -0
  201. package/src/ownership/resume.ts +118 -0
  202. package/src/ownership/rows.ts +117 -0
  203. package/src/ownership/whole.test.ts +39 -0
  204. package/src/ownership/whole.ts +60 -0
  205. package/src/proxmox/adopt-noop.test.ts +112 -0
  206. package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
  207. package/src/proxmox/ceph-pool-form.ts +3 -3
  208. package/src/proxmox/ceph-pool.ts +10 -3
  209. package/src/proxmox/fake-bao-env.ts +28 -0
  210. package/src/proxmox/fake-pve-lxc.ts +210 -0
  211. package/src/proxmox/fake-pve.ts +75 -0
  212. package/src/proxmox/index.test.ts +6 -6
  213. package/src/proxmox/index.ts +6 -2
  214. package/src/proxmox/lxc-adopt.test.ts +170 -0
  215. package/src/proxmox/lxc-create-form.ts +76 -0
  216. package/src/proxmox/lxc-create.test.ts +126 -0
  217. package/src/proxmox/lxc-guards.test.ts +158 -0
  218. package/src/proxmox/lxc-harness.ts +150 -0
  219. package/src/proxmox/lxc-identity.ts +43 -0
  220. package/src/proxmox/lxc-judge.test.ts +175 -0
  221. package/src/proxmox/lxc-judge.ts +196 -0
  222. package/src/proxmox/lxc-lifecycle.ts +200 -0
  223. package/src/proxmox/lxc-ownership.test.ts +159 -0
  224. package/src/proxmox/lxc-props.ts +215 -0
  225. package/src/proxmox/lxc-read.test.ts +60 -0
  226. package/src/proxmox/lxc-volume.test.ts +96 -0
  227. package/src/proxmox/lxc-volume.ts +153 -0
  228. package/src/proxmox/lxc-wire.ts +210 -0
  229. package/src/proxmox/lxc.ts +216 -93
  230. package/src/proxmox/network-apply-read.ts +3 -1
  231. package/src/proxmox/pbs-datastore.ts +4 -3
  232. package/src/proxmox/resource.ts +7 -7
  233. package/src/proxmox/update-guard.ts +31 -0
  234. package/src/verify/args.ts +82 -0
  235. package/src/verify/cli.test.ts +86 -0
  236. package/src/verify/cli.ts +49 -0
  237. package/src/verify/fake-engine.ts +182 -0
  238. package/src/verify/index.ts +37 -0
  239. package/src/verify/plan-scope.ts +34 -0
  240. package/src/verify/recheck.test.ts +56 -0
  241. package/src/verify/recheck.ts +71 -0
  242. package/src/verify/report.ts +52 -0
  243. package/src/verify/rows.ts +210 -0
  244. package/src/verify/spy.test.ts +141 -0
  245. package/src/verify/spy.ts +160 -0
  246. package/src/verify/verify.test.ts +146 -0
  247. package/src/verify/verify.ts +185 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The verifier's answer, for a terminal and for a gate.
3
+ *
4
+ * ⛔ NAMES, NEVER VALUES. A row lists which declared fields differ from the live object, not what
5
+ * either side holds: the tool runs over any stack, and a prop or an attribute can be a secret.
6
+ * `alchemy plan --detailed` shows declared values; the family's own read shows live ones.
7
+ */
8
+ import type { AdoptRow } from './rows.ts';
9
+ import type { AdoptReport } from './verify.ts';
10
+
11
+ /** 0 when every reported row is a no-op, 1 when any is not. (2 is the CLI's: it could not tell.) */
12
+ export const exitCodeOf = (report: AdoptReport): 0 | 1 =>
13
+ report.rows.every((row) => row.ok) ? 0 : 1;
14
+
15
+ const pad = (text: string, width: number) => text.padEnd(width);
16
+
17
+ const line = (row: AdoptRow, widths: { planned: number; type: number }) => {
18
+ const head = [
19
+ row.ok ? 'ok ' : 'FAIL',
20
+ pad(row.planned, widths.planned),
21
+ pad(row.diff, 8),
22
+ pad(row.read, 9),
23
+ pad(row.type, widths.type),
24
+ row.fqn,
25
+ ].join(' ');
26
+ const changed = row.changed.length > 0 ? ` changed: ${row.changed.join(', ')}` : '';
27
+ return row.why === '' ? `${head}${changed}` : `${head}${changed}\n ${row.why}`;
28
+ };
29
+
30
+ /** Plain text, one row per line, with the reason under any row that is not a no-op. */
31
+ export const formatReport = (report: AdoptReport, all: boolean): string => {
32
+ const scope = all ? 'every row' : 'rows without state';
33
+ const title = `hf-adopt-verify ${report.stack}/${report.stage}: ${String(report.rows.length)} ${scope} (of ${String(report.declared)} declared)`;
34
+ if (report.rows.length === 0) {
35
+ return `${title}\nnothing to verify — every declared row already has state. --all checks those too.\n`;
36
+ }
37
+ const widths = {
38
+ planned: Math.max(7, ...report.rows.map((row) => row.planned.length)),
39
+ type: Math.max(4, ...report.rows.map((row) => row.type.length)),
40
+ };
41
+ const header = [' ', pad('planned', widths.planned), pad('diff', 8), pad('read', 9)]
42
+ .concat([pad('type', widths.type), 'fqn'])
43
+ .join(' ');
44
+ const failed = report.rows.filter((row) => !row.ok).length;
45
+ const verdict =
46
+ failed === 0
47
+ ? 'every row is a no-op: the deploy writes nothing to them'
48
+ : `${String(failed)} row(s) are NOT a no-op: the deploy would write, create or delete`;
49
+ return [title, '', header, ...report.rows.map((row) => line(row, widths)), '', verdict, ''].join(
50
+ '\n',
51
+ );
52
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * One report row per planned resource: what the engine planned, what the provider said, and
3
+ * whether that adds up to "this deploy leaves the object alone".
4
+ *
5
+ * ★ PURE, SO THE VERDICT IS TESTABLE WITHOUT AN ENGINE. verify.ts gathers the three inputs — the
6
+ * plan, which FQNs have a state row, and what the watched providers answered (spy.ts) — and
7
+ * this file only decides.
8
+ */
9
+ import { deepEqual, isResolved } from 'alchemy/Diff';
10
+ import type { DiffAnswer, Observations, ReadAnswer, RecheckAnswer } from './spy.ts';
11
+
12
+ export interface AdoptRow {
13
+ readonly fqn: string;
14
+ readonly type: string;
15
+ /**
16
+ * Whether the row plans from a row the state store holds for it (verify.ts `withState`). An
17
+ * adoption is a row without one.
18
+ */
19
+ readonly stateRow: boolean;
20
+ /** The engine's action — what `alchemy plan` prints (`adopted`, `create`, `noop`, …). */
21
+ readonly planned: string;
22
+ /** What the provider's `read` answered, or `not-read` when nothing asked it. */
23
+ readonly read: ReadAnswer;
24
+ /** What the provider's own `diff` answered BEFORE Plan.ts forced it (spy.ts). */
25
+ readonly diff: DiffAnswer;
26
+ /**
27
+ * Declared fields whose live value differs, by NAME only — values can be secrets. Compared on
28
+ * same-named keys of the declaration and the read's attributes, so a family whose attributes
29
+ * rename a field cannot show it here. The diff answer is the verdict; a name here beside an
30
+ * adopted `noop` only makes recheck.ts ask that diff again.
31
+ */
32
+ readonly changed: readonly string[];
33
+ /**
34
+ * What the provider's `diff` answered when asked again with the live values of `changed` as its
35
+ * recorded props (recheck.ts). Absent when it was not asked: only an adopted `noop` with
36
+ * something in `changed` is.
37
+ */
38
+ readonly recheck?: RecheckAnswer;
39
+ /** Bindings the engine will create, update or delete — reconcile applies them even on `noop`. */
40
+ readonly bindings: number;
41
+ readonly ok: boolean;
42
+ /** Why the row is not a no-op, or a note on one that is; empty when there is nothing to say. */
43
+ readonly why: string;
44
+ }
45
+
46
+ /** The slice of an Alchemy plan node this file reads. */
47
+ export interface NodeView {
48
+ readonly action: string;
49
+ readonly resource: { readonly Type: string };
50
+ readonly bindings?: readonly { readonly action: string }[];
51
+ }
52
+
53
+ /** A stack task (an Alchemy action): `run`, `noop` or `delete`. It has no provider to ask. */
54
+ export interface TaskView {
55
+ readonly action: string;
56
+ readonly def: { readonly Type: string };
57
+ }
58
+
59
+ /** The slice of an Alchemy `Plan` this file reads. */
60
+ export interface PlanView {
61
+ readonly resources: Readonly<Record<string, NodeView>>;
62
+ readonly deletions: Readonly<Record<string, NodeView | undefined>>;
63
+ readonly actions?: Readonly<Record<string, TaskView | undefined>>;
64
+ }
65
+
66
+ /** Same-named, declared, resolved fields where the live value differs. */
67
+ export const changedFields = (declared: unknown, live: unknown): string[] => {
68
+ if (typeof declared !== 'object' || declared === null) return [];
69
+ if (typeof live !== 'object' || live === null) return [];
70
+ const actual = live as Record<string, unknown>;
71
+ return Object.entries(declared as Record<string, unknown>)
72
+ .filter(([key, value]) => value !== undefined && key in actual && isResolved(value))
73
+ .filter(([key, value]) => !deepEqual(value, actual[key]))
74
+ .map(([key]) => key)
75
+ .sort();
76
+ };
77
+
78
+ const STILL_LIVE = new Set(['adopted', 'noop']);
79
+
80
+ /**
81
+ * ⛔ `adopted` NEEDS THE PROVIDER'S OWN `noop`. With no diff (`none`) the engine compared the
82
+ * declaration with the declaration — an adopted row's state carries `props: news` (Plan.ts) — so
83
+ * its noop proves nothing. A row with state is different: there the engine compared against the
84
+ * props it last deployed, which is the same check `alchemy plan` makes.
85
+ */
86
+ const isNoop = (planned: string, diff: DiffAnswer, stateRow: boolean) =>
87
+ STILL_LIVE.has(planned) &&
88
+ (diff === 'noop' || (diff === 'none' && stateRow && planned === 'noop'));
89
+
90
+ /**
91
+ * ⛔ AND A `noop` MUST SURVIVE BEING ASKED WITH THE LIVE VALUES. A diff that compares recorded props
92
+ * answers an adopted row `noop` whatever the cloud holds; recheck.ts asks it again, and only a
93
+ * second `noop` (or no need to ask) proves anything.
94
+ */
95
+ const proven = (recheck: RecheckAnswer | undefined) => recheck === undefined || recheck === 'noop';
96
+
97
+ /** Notes on a row that passes: nothing to say is the common case. */
98
+ const notes = (row: Omit<AdoptRow, 'ok' | 'why'>): string =>
99
+ [
100
+ row.read === 'unowned' ? 'unowned: the deploy needs --adopt or adopt(true)' : '',
101
+ row.recheck === 'noop'
102
+ ? `${row.changed.join(', ')} differ, but the diff says noop even given the live values: ` +
103
+ 'the family does not manage them, and the deploy leaves them alone'
104
+ : '',
105
+ ]
106
+ .filter((note) => note !== '')
107
+ .join('; ');
108
+
109
+ const reason = (row: Omit<AdoptRow, 'ok' | 'why'>, ok: boolean): string => {
110
+ if (ok) return notes(row);
111
+ if (isNoop(row.planned, row.diff, row.stateRow)) {
112
+ const live = `the live ${row.changed.join(', ')}`;
113
+ if (row.recheck === 'failed') {
114
+ return `asked again with ${live} as recorded props, the provider's diff failed: not proven`;
115
+ }
116
+ if (!proven(row.recheck)) {
117
+ return (
118
+ `the provider's diff compares recorded props, not the live object: given ${live} it ` +
119
+ `says ${String(row.recheck)}, and the deploy's forced reconcile writes`
120
+ );
121
+ }
122
+ return `${String(row.bindings)} binding(s) change: the deploy reconciles them though the diff is noop`;
123
+ }
124
+ if (row.planned === 'delete') return 'no longer declared: the deploy deletes it';
125
+ if (row.planned === 'orphaned') return 'no longer declared: the deploy drops its state (retain)';
126
+ if (row.read === 'failed') return "the verifier's own read of it failed";
127
+ if (row.planned === 'create') {
128
+ if (row.stateRow) return 'an interrupted create: the deploy finishes creating it';
129
+ return row.read === 'absent'
130
+ ? 'the read found nothing, so the deploy CREATES it (a lane that cannot see it reads the same)'
131
+ : 'never probed (a prop is an Output of another resource, or the provider has no read): ' +
132
+ 'the deploy CREATES it';
133
+ }
134
+ if (row.diff === 'update') return "the provider's diff says update: the deploy writes";
135
+ if (row.diff === 'replace') return "the provider's diff says replace: a new one, then a delete";
136
+ if (row.diff === 'none') {
137
+ return 'the provider has no diff, so the engine compared the declaration with itself';
138
+ }
139
+ return `the engine plans ${row.planned} although the diff says ${row.diff} (a renamed row, changed bindings or --force)`;
140
+ };
141
+
142
+ export const rowFor = (
143
+ fqn: string,
144
+ node: NodeView,
145
+ stateRow: boolean,
146
+ seen: Observations,
147
+ ): AdoptRow => {
148
+ const observed = seen.get(fqn);
149
+ const recheck = observed?.recheck;
150
+ const base = {
151
+ bindings: (node.bindings ?? []).filter((binding) => binding.action !== 'noop').length,
152
+ changed: changedFields(observed?.diff?.news, observed?.read?.attributes),
153
+ diff: observed?.diff?.answer ?? 'not-run',
154
+ fqn,
155
+ planned: node.action,
156
+ read: observed?.read?.answer ?? 'not-read',
157
+ ...(recheck === undefined ? {} : { recheck }),
158
+ stateRow,
159
+ type: node.resource.Type,
160
+ } satisfies Omit<AdoptRow, 'ok' | 'why'>;
161
+ /**
162
+ * ⚠️ A NOOP DIFF DOES NOT COVER BINDINGS. Plan.ts diffs them separately and hands every
163
+ * non-noop binding to `reconcile`, which an adopted row reaches anyway — a Worker adopted with
164
+ * a new binding writes although its own diff said nothing changed.
165
+ */
166
+ const ok = isNoop(base.planned, base.diff, stateRow) && proven(recheck) && base.bindings === 0;
167
+ return { ...base, ok, why: reason(base, ok) };
168
+ };
169
+
170
+ /**
171
+ * ⚠️ A TASK THAT RUNS IS A WRITE THE PROVIDERS NEVER SEE, so it is reported in both modes; a task
172
+ * that would not run appears only with `all`.
173
+ */
174
+ const taskRow = (fqn: string, task: TaskView): AdoptRow => ({
175
+ bindings: 0,
176
+ changed: [],
177
+ diff: 'not-run',
178
+ fqn,
179
+ ok: task.action === 'noop',
180
+ planned: task.action,
181
+ read: 'not-read',
182
+ stateRow: task.action !== 'run',
183
+ type: task.def.Type,
184
+ why: task.action === 'noop' ? '' : `a stack task: the deploy will ${task.action} it`,
185
+ });
186
+
187
+ /**
188
+ * The rows to report. Without `all`, only rows WITHOUT a state row — the adoptions and creates a
189
+ * deploy would perform — which is the question this tool exists for, plus any task that runs. With
190
+ * `all`, every declared row, every pending deletion and every task.
191
+ */
192
+ export const rowsOf = (
193
+ plan: PlanView,
194
+ stateRows: ReadonlySet<string>,
195
+ seen: Observations,
196
+ all: boolean,
197
+ ): AdoptRow[] => {
198
+ const declared = Object.entries(plan.resources)
199
+ .filter(([fqn]) => all || !stateRows.has(fqn))
200
+ .map(([fqn, node]) => rowFor(fqn, node, stateRows.has(fqn), seen));
201
+ const removed = all
202
+ ? Object.entries(plan.deletions).flatMap(([fqn, node]) =>
203
+ node === undefined ? [] : [rowFor(fqn, node, true, seen)],
204
+ )
205
+ : [];
206
+ const tasks = Object.entries(plan.actions ?? {}).flatMap(([fqn, task]) =>
207
+ task === undefined || (!all && task.action === 'noop') ? [] : [taskRow(fqn, task)],
208
+ );
209
+ return [...declared, ...removed, ...tasks].sort((a, b) => a.fqn.localeCompare(b.fqn));
210
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The watched providers (spy.ts) and the verdict (rows.ts), without an engine.
3
+ *
4
+ * ⛔ THE REFUSALS ARE THE POINT. A verifier that could reach `reconcile` is a deploy with a
5
+ * reassuring name; these pin that every write path dies with the same message, on a direct
6
+ * service, inside a collection, and on a dual registration's per-mode variant.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { Unowned } from 'alchemy/AdoptPolicy';
10
+ import type { ProviderService } from 'alchemy/Provider';
11
+ import * as Context from 'effect/Context';
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Exit from 'effect/Exit';
14
+ import { changedFields, rowsOf } from './rows.ts';
15
+ import { type Observations, WRITE_REFUSED, spyContext } from './spy.ts';
16
+
17
+ const calls: string[] = [];
18
+ const service = (): ProviderService =>
19
+ ({
20
+ delete: () => Effect.sync(() => void calls.push('delete')),
21
+ diff: () => Effect.succeed({ action: 'update' }),
22
+ precreate: () => Effect.sync(() => calls.push('precreate')),
23
+ read: () => Effect.succeed(Unowned({ comment: 'live', name: 'a' })),
24
+ reconcile: () => Effect.sync(() => calls.push('reconcile')),
25
+ }) as unknown as ProviderService;
26
+
27
+ const input = { fqn: 'a', id: 'a', instanceId: 'i-1', news: {}, olds: {}, output: undefined };
28
+
29
+ const dies = async (effect: unknown) => {
30
+ const exit = await Effect.runPromiseExit(effect as Effect.Effect<unknown, unknown>);
31
+ expect(Exit.isFailure(exit)).toBe(true);
32
+ expect(String(Exit.isFailure(exit) ? exit.cause : '')).toContain(WRITE_REFUSED);
33
+ };
34
+
35
+ const watched = (value: unknown, key = 'Test.Thing') => {
36
+ const seen: Observations = new Map();
37
+ const context = spyContext(Context.makeUnsafe(new Map([[key, value]])), seen);
38
+ return { seen, service: context.mapUnsafe.get(key) as ProviderService & Record<string, unknown> };
39
+ };
40
+
41
+ describe('spyContext', () => {
42
+ test('every write path dies and nothing reaches the provider', async () => {
43
+ const { service: spied } = watched(service());
44
+ await dies(spied.reconcile(input as never));
45
+ await dies(spied.delete(input as never));
46
+ await dies(spied.precreate?.(input as never));
47
+ expect(calls).toEqual([]);
48
+ });
49
+
50
+ test('read and diff pass through and are recorded, Unowned included', async () => {
51
+ const { seen, service: spied } = watched(service());
52
+ await Effect.runPromise(spied.read?.(input as never) as Effect.Effect<unknown>);
53
+ await Effect.runPromise(spied.diff?.(input as never) as Effect.Effect<unknown>);
54
+ expect(seen.get('a')?.read?.answer).toBe('unowned');
55
+ expect(seen.get('a')?.read?.attributes).toEqual({ comment: 'live', name: 'a' });
56
+ expect(seen.get('a')?.diff?.answer).toBe('update');
57
+ });
58
+
59
+ test('a collection and a dual registration are watched too', async () => {
60
+ const collection = {
61
+ get: () => service(),
62
+ kind: 'ProviderCollection',
63
+ providers: { 'Test.Thing': service() },
64
+ };
65
+ const inner = watched(collection, 'Test/Collection').service as unknown as {
66
+ get: (type: string) => ProviderService;
67
+ };
68
+ await dies(inner.get('Test.Thing').reconcile(input as never));
69
+
70
+ const dual = {
71
+ ...service(),
72
+ modes: { live: Effect.sync(service), local: Effect.sync(service) },
73
+ };
74
+ const modes = (watched(dual).service as ProviderService).modes;
75
+ const live = await Effect.runPromise(modes?.live as Effect.Effect<ProviderService>);
76
+ await dies(live.delete(input as never));
77
+ expect(calls).toEqual([]);
78
+ });
79
+
80
+ test('anything that is not a provider is handed over untouched', () => {
81
+ const value = { some: 'service' };
82
+ expect(watched(value, 'Other').service).toBe(value as never);
83
+ });
84
+ });
85
+
86
+ describe('rows', () => {
87
+ test('changedFields compares declared, same-named fields by value and names them only', () => {
88
+ expect(
89
+ changedFields(
90
+ { comment: 'new', name: 'a', size: 3, target: { mount: 'm' }, unset: undefined },
91
+ { comment: 'old', name: 'a', size: 3, unset: 'x' },
92
+ ),
93
+ ).toEqual(['comment']);
94
+ expect(changedFields(undefined, { a: 1 })).toEqual([]);
95
+ });
96
+
97
+ test('a noop row with state and no diff is trusted; an adopted one is not', () => {
98
+ const seen: Observations = new Map([
99
+ ['kept', { diff: { answer: 'none', news: {} } }],
100
+ ['new', { diff: { answer: 'none', news: {} } }],
101
+ ]);
102
+ const plan = {
103
+ deletions: {},
104
+ resources: {
105
+ kept: { action: 'noop', resource: { Type: 'T' } },
106
+ new: { action: 'adopted', resource: { Type: 'T' } },
107
+ },
108
+ };
109
+ const rows = rowsOf(plan, new Set(['kept']), seen, true);
110
+ expect(rows.map((row) => [row.fqn, row.ok])).toEqual([
111
+ ['kept', true],
112
+ ['new', false],
113
+ ]);
114
+ });
115
+
116
+ test('a stack task that runs is reported in both modes; one that does not, only with all', () => {
117
+ const plan = {
118
+ actions: {
119
+ idle: { action: 'noop', def: { Type: 'Task' } },
120
+ seed: { action: 'run', def: { Type: 'Task' } },
121
+ },
122
+ deletions: {},
123
+ resources: {},
124
+ };
125
+ expect(rowsOf(plan, new Set(), new Map(), false).map((row) => [row.fqn, row.ok])).toEqual([
126
+ ['seed', false],
127
+ ]);
128
+ expect(rowsOf(plan, new Set(), new Map(), true).map((row) => row.fqn)).toEqual([
129
+ 'idle',
130
+ 'seed',
131
+ ]);
132
+ });
133
+
134
+ test('a noop adoption that brings a binding is not a no-op', () => {
135
+ const seen: Observations = new Map([['worker', { diff: { answer: 'noop', news: {} } }]]);
136
+ const node = { action: 'adopted', bindings: [{ action: 'create' }], resource: { Type: 'W' } };
137
+ const [row] = rowsOf({ deletions: {}, resources: { worker: node } }, new Set(), seen, false);
138
+ expect(row).toMatchObject({ bindings: 1, ok: false });
139
+ expect(row?.why).toContain('binding');
140
+ });
141
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The providers a stack registered, wrapped so the plan can be watched and can never write.
3
+ *
4
+ * ★ WHY A WRAPPER AND NOT A LOG LINE. alchemy beta.79 Plan.ts loses the provider's answer on the one
5
+ * path that matters: after the cold-start adoption probe it sets `forceUpdateAfterAdoption` and
6
+ * maps a `noop` diff to `update`, and the node says `adopted` either way. Nothing is logged, and
7
+ * `alchemy plan` has no JSON output — `describePlan` carries only the forced action. The engine
8
+ * resolves every provider from the Effect context by resource type (Provider.ts
9
+ * `tryFindProviderRegistrationByType`), so the one honest place to hear the raw answer is the
10
+ * provider service itself. This hands Alchemy's own `Plan.make` a context whose providers record
11
+ * what `read` and `diff` answered, per FQN, and otherwise behave exactly as registered.
12
+ *
13
+ * ⛔ EVERY WRITE PATH DIES. `reconcile`, `delete` and `precreate` are replaced, not wrapped: the
14
+ * planner never calls them, and a verifier that could reach one by accident is worse than none.
15
+ * `read`, `diff`, `list`, `tail` and `logs` pass through — each is a read by Alchemy's contract.
16
+ *
17
+ * ⚠️ A MISSING `diff` IS WRAPPED AS ONE ANSWERING `undefined`, which is exactly what the planner
18
+ * does without one (`provider?.diff?.(…)` then its own props compare), so behaviour is unchanged
19
+ * and the report can say `none` instead of guessing. A missing `read` stays missing: adding one
20
+ * would make the planner probe where it otherwise would not.
21
+ */
22
+ import { Unowned, stripUnowned } from 'alchemy/AdoptPolicy';
23
+ import {
24
+ type ProviderCollectionService,
25
+ type ProviderService,
26
+ isProviderCollectionService,
27
+ isProviderService,
28
+ } from 'alchemy/Provider';
29
+ import * as Context from 'effect/Context';
30
+ import * as Effect from 'effect/Effect';
31
+
32
+ export type ReadAnswer = 'found' | 'unowned' | 'absent' | 'failed' | 'not-read';
33
+ export type DiffAnswer = 'noop' | 'update' | 'replace' | 'none' | 'not-run';
34
+ /** What the diff answered when asked again with the live values as its recorded props (recheck.ts). */
35
+ export type RecheckAnswer = DiffAnswer | 'failed';
36
+
37
+ /** Exactly what the planner handed a provider's `diff`. */
38
+ export type DiffInput = Parameters<NonNullable<ProviderService['diff']>>[0];
39
+
40
+ /** What one FQN's provider answered during the plan — the LAST answer of each kind. */
41
+ export interface Seen {
42
+ read?: { readonly answer: ReadAnswer; readonly attributes: unknown };
43
+ /**
44
+ * ★ `input` IS THE WHOLE CALL, NOT ONLY `news`, so recheck.ts can ask the same question again
45
+ * with one field changed. In memory only; the report carries field names, never values. An
46
+ * observation built by hand without it (rows.ts's tests) is simply never rechecked.
47
+ */
48
+ diff?: { readonly answer: DiffAnswer; readonly news: unknown; readonly input?: DiffInput };
49
+ recheck?: RecheckAnswer;
50
+ }
51
+
52
+ /** Keyed by FQN. Filled while the plan runs; read once it is done. */
53
+ export type Observations = Map<string, Seen>;
54
+
55
+ const seenFor = (seen: Observations, fqn: string): Seen => {
56
+ const existing = seen.get(fqn);
57
+ if (existing !== undefined) return existing;
58
+ const fresh: Seen = {};
59
+ seen.set(fqn, fresh);
60
+ return fresh;
61
+ };
62
+
63
+ /** The refusal every write path answers with. Exported so a test can match it. */
64
+ export const WRITE_REFUSED = 'hf-adopt-verify never writes';
65
+
66
+ const refuse = (type: string, operation: string) => () =>
67
+ Effect.die(
68
+ new Error(
69
+ `${WRITE_REFUSED}: ${type}.${operation} was called during a verification. Alchemy's planner ` +
70
+ 'does not call it, so something other than Plan.make is driving these providers.',
71
+ ),
72
+ );
73
+
74
+ const readAnswer = (attributes: unknown): ReadAnswer =>
75
+ attributes === undefined ? 'absent' : Unowned.is(attributes) ? 'unowned' : 'found';
76
+
77
+ export const diffAnswer = (diff: unknown): DiffAnswer => {
78
+ const action = (diff as { action?: unknown } | undefined)?.action;
79
+ return action === 'noop' || action === 'update' || action === 'replace' ? action : 'none';
80
+ };
81
+
82
+ /**
83
+ * One provider service, watched. ⚠️ The spread keeps `version`, `aliases`, `mode`, `stables`,
84
+ * `list` and the rest exactly as registered — the planner reads several of them.
85
+ */
86
+ export const spyService = (type: string, service: ProviderService, seen: Observations) => {
87
+ const read = service.read;
88
+ const diff = service.diff;
89
+ const modes = service.modes;
90
+ const watched: ProviderService = {
91
+ ...service,
92
+ diff: (input) =>
93
+ (diff === undefined ? Effect.succeed(undefined) : diff(input)).pipe(
94
+ Effect.tap((answer) =>
95
+ Effect.sync(() => {
96
+ seenFor(seen, input.fqn).diff = { answer: diffAnswer(answer), input, news: input.news };
97
+ }),
98
+ ),
99
+ ),
100
+ reconcile: refuse(type, 'reconcile'),
101
+ delete: refuse(type, 'delete'),
102
+ };
103
+ if (read !== undefined) {
104
+ watched.read = (input) =>
105
+ read(input).pipe(
106
+ Effect.tap((answer) =>
107
+ Effect.sync(() => {
108
+ const attributes = answer === undefined ? undefined : stripUnowned(answer);
109
+ seenFor(seen, input.fqn).read = { answer: readAnswer(answer), attributes };
110
+ }),
111
+ ),
112
+ );
113
+ }
114
+ if (service.precreate !== undefined) watched.precreate = refuse(type, 'precreate');
115
+ // ⚠️ A `ProviderLayer.dual` registration resolves a per-mode variant at plan time
116
+ // (Provider.ts `providerForMode`); wrapping only the registration would miss it.
117
+ if (modes !== undefined) {
118
+ watched.modes = {
119
+ live: Effect.map(modes.live, (variant) => spyService(type, variant, seen)),
120
+ local: Effect.map(modes.local, (variant) => spyService(type, variant, seen)),
121
+ };
122
+ }
123
+ return watched;
124
+ };
125
+
126
+ const spyCollection = (
127
+ collection: ProviderCollectionService,
128
+ seen: Observations,
129
+ ): ProviderCollectionService => {
130
+ const providers = Object.fromEntries(
131
+ Object.entries(collection.providers).map(([type, service]) => [
132
+ type,
133
+ spyService(type, service, seen),
134
+ ]),
135
+ );
136
+ return { ...collection, get: (type: string) => providers[type] as never, providers };
137
+ };
138
+
139
+ /**
140
+ * The same context with every provider service — direct or inside a `Provider.collection` —
141
+ * replaced by its watched copy. Everything else (the state store, the stack, credentials) is the
142
+ * very object the session built.
143
+ */
144
+ export const spyContext = <R>(
145
+ context: Context.Context<R>,
146
+ seen: Observations,
147
+ ): Context.Context<R> => {
148
+ const services = new Map<string, unknown>();
149
+ for (const [key, value] of context.mapUnsafe) {
150
+ services.set(
151
+ key,
152
+ isProviderService(value)
153
+ ? spyService(key, value, seen)
154
+ : isProviderCollectionService(value)
155
+ ? spyCollection(value, seen)
156
+ : value,
157
+ );
158
+ }
159
+ return Context.makeUnsafe<R>(services);
160
+ };