@homeflare/alchemy 0.8.0 → 0.10.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 (240) 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 +320 -0
  121. package/dist/verify/cli.js.map +15 -0
  122. package/dist/verify/fake-engine.d.ts +57 -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 +297 -0
  127. package/dist/verify/index.js.map +14 -0
  128. package/dist/verify/report.d.ts +6 -0
  129. package/dist/verify/report.d.ts.map +1 -0
  130. package/dist/verify/rows.d.ts +57 -0
  131. package/dist/verify/rows.d.ts.map +1 -0
  132. package/dist/verify/spy.d.ts +31 -0
  133. package/dist/verify/spy.d.ts.map +1 -0
  134. package/dist/verify/verify.d.ts +43 -0
  135. package/dist/verify/verify.d.ts.map +1 -0
  136. package/docs/adopt-verify.md +108 -0
  137. package/docs/adopted-deploys.md +85 -0
  138. package/docs/credentials.md +15 -0
  139. package/docs/launchd-sudo.md +31 -6
  140. package/docs/launchd.md +7 -3
  141. package/docs/ownership.md +113 -0
  142. package/docs/proxmox-lxc.md +196 -0
  143. package/docs/proxmox.md +12 -0
  144. package/package.json +9 -2
  145. package/src/caddy/adopt-scope.test.ts +1 -1
  146. package/src/caddy/config-lifecycle.ts +1 -1
  147. package/src/caddy/config.ts +2 -1
  148. package/src/launchd/adopt-apply.test.ts +166 -0
  149. package/src/launchd/fake-runner.ts +26 -4
  150. package/src/launchd/host-effect.ts +1 -33
  151. package/src/launchd/host-file-lifecycle.ts +39 -14
  152. package/src/launchd/host-file.ts +13 -4
  153. package/src/launchd/job-lifecycle.ts +22 -14
  154. package/src/launchd/job-preflight.ts +18 -1
  155. package/src/launchd/job.ts +13 -4
  156. package/src/launchd/runner.ts +7 -0
  157. package/src/launchd/sudo-acl.ts +68 -0
  158. package/src/launchd/sudo-chain.test.ts +121 -0
  159. package/src/launchd/sudo-guard.ts +89 -10
  160. package/src/launchd/sudo-modes.test.ts +135 -0
  161. package/src/launchd/sudo-runner.ts +40 -59
  162. package/src/launchd/sudo-said.ts +58 -0
  163. package/src/openbao/README.md +5 -2
  164. package/src/openbao/REPLACE.md +13 -11
  165. package/src/openbao/adopt-core.test.ts +119 -0
  166. package/src/openbao/adopt-families.test.ts +97 -0
  167. package/src/openbao/adopt-holes.test.ts +168 -0
  168. package/src/openbao/adopt-recovery.test.ts +71 -0
  169. package/src/openbao/auth-method-reconcile.ts +15 -3
  170. package/src/openbao/auth-method.ts +15 -6
  171. package/src/openbao/auth-role.ts +14 -4
  172. package/src/openbao/cloudflare-role.ts +17 -4
  173. package/src/openbao/fake-bao.ts +52 -0
  174. package/src/openbao/fake-core.ts +141 -0
  175. package/src/openbao/fake-families.ts +11 -5
  176. package/src/openbao/fake-mounts.ts +74 -0
  177. package/src/openbao/fake-stack.ts +17 -4
  178. package/src/openbao/jwt-config.ts +10 -7
  179. package/src/openbao/jwt-role.ts +10 -7
  180. package/src/openbao/kubernetes-role.ts +13 -7
  181. package/src/openbao/mfa-enforcement.ts +20 -6
  182. package/src/openbao/mfa-totp.ts +22 -7
  183. package/src/openbao/mount-move.test.ts +2 -35
  184. package/src/openbao/mount-reconcile.ts +13 -2
  185. package/src/openbao/mount.ts +15 -6
  186. package/src/openbao/pki-role.ts +15 -4
  187. package/src/openbao/plugin-reconcile.ts +16 -5
  188. package/src/openbao/plugin.ts +17 -6
  189. package/src/openbao/policy.ts +17 -4
  190. package/src/openbao/proxmox-role.ts +13 -4
  191. package/src/openbao/rename-adoption.test.ts +32 -19
  192. package/src/openbao/role-reconcile.ts +23 -0
  193. package/src/openbao/ssh-role.ts +14 -4
  194. package/src/ownership/adopt.ts +122 -0
  195. package/src/ownership/ownership.test.ts +200 -0
  196. package/src/ownership/probe.ts +65 -0
  197. package/src/ownership/resume.ts +118 -0
  198. package/src/ownership/rows.ts +117 -0
  199. package/src/ownership/whole.test.ts +39 -0
  200. package/src/ownership/whole.ts +60 -0
  201. package/src/proxmox/adopt-noop.test.ts +112 -0
  202. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  203. package/src/proxmox/ceph-pool-form.ts +3 -3
  204. package/src/proxmox/ceph-pool.ts +10 -3
  205. package/src/proxmox/fake-bao-env.ts +28 -0
  206. package/src/proxmox/fake-pve-lxc.ts +210 -0
  207. package/src/proxmox/fake-pve.ts +75 -0
  208. package/src/proxmox/index.test.ts +6 -6
  209. package/src/proxmox/index.ts +6 -2
  210. package/src/proxmox/lxc-adopt.test.ts +170 -0
  211. package/src/proxmox/lxc-create-form.ts +76 -0
  212. package/src/proxmox/lxc-create.test.ts +126 -0
  213. package/src/proxmox/lxc-guards.test.ts +158 -0
  214. package/src/proxmox/lxc-harness.ts +150 -0
  215. package/src/proxmox/lxc-identity.ts +43 -0
  216. package/src/proxmox/lxc-judge.test.ts +175 -0
  217. package/src/proxmox/lxc-judge.ts +196 -0
  218. package/src/proxmox/lxc-lifecycle.ts +200 -0
  219. package/src/proxmox/lxc-ownership.test.ts +159 -0
  220. package/src/proxmox/lxc-props.ts +215 -0
  221. package/src/proxmox/lxc-read.test.ts +60 -0
  222. package/src/proxmox/lxc-volume.test.ts +96 -0
  223. package/src/proxmox/lxc-volume.ts +153 -0
  224. package/src/proxmox/lxc-wire.ts +210 -0
  225. package/src/proxmox/lxc.ts +216 -93
  226. package/src/proxmox/network-apply-read.ts +3 -1
  227. package/src/proxmox/pbs-datastore.ts +4 -3
  228. package/src/proxmox/resource.ts +7 -7
  229. package/src/proxmox/update-guard.ts +31 -0
  230. package/src/verify/args.ts +82 -0
  231. package/src/verify/cli.test.ts +86 -0
  232. package/src/verify/cli.ts +49 -0
  233. package/src/verify/fake-engine.ts +158 -0
  234. package/src/verify/index.ts +36 -0
  235. package/src/verify/report.ts +52 -0
  236. package/src/verify/rows.ts +169 -0
  237. package/src/verify/spy.test.ts +141 -0
  238. package/src/verify/spy.ts +149 -0
  239. package/src/verify/verify.test.ts +124 -0
  240. package/src/verify/verify.ts +172 -0
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Whether an update has anything to write — decided ONCE for every PVE and PBS family.
3
+ *
4
+ * ⛔ AN ADOPTED ROW IS RECONCILED EVEN WHEN ITS DIFF SAID `noop`, SO THIS GUARD IS WHAT MAKES
5
+ * ADOPTION FREE. alchemy beta.79 Plan.ts sets `forceUpdateAfterAdoption` after the cold-start
6
+ * probe and turns a `noop` diff into an update; Apply.ts then calls `reconcile` for the
7
+ * `adopted` action exactly as for `update`. The plan prints `adopted` either way, so nothing on
8
+ * screen tells a matching object from a drifting one. docs/adopt-verify.md has the table of
9
+ * what each family does on that path.
10
+ *
11
+ * 🔴 THE FAMILY THAT DID NOT HAVE IT. `Proxmox.CephPool` wrote its own reconcile and PUT the
12
+ * declared set whenever the pool existed, so adopting a pool that already matched sent
13
+ * `setpool` and forked a worker on the live cluster (found 2026-09-21 by tracing every family).
14
+ * resource.ts and pbs-datastore.ts each had the guard inline; three copies of one predicate is
15
+ * how the fourth family forgets it, so it lives here and all three call it.
16
+ *
17
+ * ★ `matches` IS THE SAME PREDICATE `diff` USES, so "the diff said noop" and "reconcile writes
18
+ * nothing" cannot disagree. A field left OUT of `matches` is one the family does not manage, so
19
+ * its drift is not this write's to repair.
20
+ *
21
+ * ⚠️ AN EMPTY FORM IS ALSO NOT A WRITE. `updateForm` can legitimately answer `{}` — a storage
22
+ * declaring only `storage`, `type` and its locator has no mutable field at all — and PUTting an
23
+ * empty body is a pointless write at best and a 400 that reads as a broken provider at worst.
24
+ * (Moved here from resource.ts with the guard it explains.)
25
+ */
26
+ export const formToSend = <Attributes, Props, Form extends object>(
27
+ matches: (attributes: Attributes, props: Props) => boolean,
28
+ live: Attributes,
29
+ props: Props,
30
+ form: Form,
31
+ ): Form | undefined => (matches(live, props) || Object.keys(form).length === 0 ? undefined : form);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `hf-adopt-verify`'s flags — the ones `alchemy plan` takes, so a stack script can swap one
3
+ * command for the other.
4
+ *
5
+ * ★ `node:util` parseArgs, NOT effect's CLI module. Alchemy builds its CLI on
6
+ * `effect/unstable/cli`, but that needs a CliConfig and a Terminal layer to parse six flags; the
7
+ * standard library parses them with no service at all, which keeps this pure and testable.
8
+ * ⛔ `--stage` HAS NO GUESSED DEFAULT. Alchemy falls back to `live_$USER`; a gate that silently
9
+ * verified the wrong stage would pass for the wrong reason. It comes from the flag or from
10
+ * `$ALCHEMY_STAGE`, or the run stops.
11
+ */
12
+ import { parseArgs } from 'node:util';
13
+ import type { VerifyOptions, VerifyTarget } from './verify.ts';
14
+
15
+ export const USAGE = `Usage: hf-adopt-verify --stage <stage> [--config alchemy.run.ts] [options]
16
+
17
+ Plans the stack with Alchemy's own planner, never writes, and reports each row
18
+ without a state row: what the engine planned, the provider's read and its own
19
+ diff (before Alchemy forces adopted rows to update), and the declared fields
20
+ that differ from the live object. Exits 0 when every row is a no-op, 1 when
21
+ any is not, 2 when it could not tell.
22
+
23
+ -c, --config <file> stack entrypoint (default alchemy.run.ts)
24
+ --stage <stage> stage to verify (default $ALCHEMY_STAGE; required)
25
+ --profile <name> Alchemy auth profile
26
+ --env-file <file> .env file layered under the environment
27
+ --all report every declared row and pending deletion
28
+ --json print the report as JSON
29
+ -h, --help this text
30
+ `;
31
+
32
+ export type Parsed =
33
+ | { readonly kind: 'help' }
34
+ | { readonly kind: 'error'; readonly message: string }
35
+ | {
36
+ readonly kind: 'run';
37
+ readonly target: VerifyTarget;
38
+ readonly options: VerifyOptions;
39
+ readonly json: boolean;
40
+ };
41
+
42
+ const parse = (argv: readonly string[]) =>
43
+ parseArgs({
44
+ args: [...argv],
45
+ allowPositionals: false,
46
+ options: {
47
+ all: { type: 'boolean', default: false },
48
+ config: { type: 'string', short: 'c', default: 'alchemy.run.ts' },
49
+ 'env-file': { type: 'string' },
50
+ help: { type: 'boolean', short: 'h', default: false },
51
+ json: { type: 'boolean', default: false },
52
+ profile: { type: 'string' },
53
+ stage: { type: 'string' },
54
+ },
55
+ strict: true,
56
+ });
57
+
58
+ /** Parse argv (without the runtime and script) against the environment. Never throws. */
59
+ export const parseVerifyArgs = (
60
+ argv: readonly string[],
61
+ env: Readonly<Record<string, string | undefined>>,
62
+ ): Parsed => {
63
+ let parsed: ReturnType<typeof parse>;
64
+ try {
65
+ parsed = parse(argv);
66
+ } catch (error) {
67
+ return { kind: 'error', message: error instanceof Error ? error.message : String(error) };
68
+ }
69
+ const { values } = parsed;
70
+ if (values.help) return { kind: 'help' };
71
+ const stage = values.stage ?? env['ALCHEMY_STAGE'];
72
+ if (stage === undefined || stage === '') {
73
+ return { kind: 'error', message: 'pass --stage (or set ALCHEMY_STAGE): no stage is guessed' };
74
+ }
75
+ const target: VerifyTarget = {
76
+ entrypoint: values.config,
77
+ stage,
78
+ ...(values.profile === undefined ? {} : { profile: values.profile }),
79
+ ...(values['env-file'] === undefined ? {} : { envFile: values['env-file'] }),
80
+ };
81
+ return { json: values.json, kind: 'run', options: { all: values.all }, target };
82
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The bin's two pure halves: its flags (args.ts) and its text (report.ts).
3
+ *
4
+ * ⛔ NO STAGE IS GUESSED. Alchemy defaults `--stage` to `live_$USER`; a gate that verified that by
5
+ * accident would pass while the real stage went unchecked, so a missing stage is an error here.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { parseVerifyArgs } from './args.ts';
9
+ import { exitCodeOf, formatReport } from './report.ts';
10
+ import type { AdoptRow } from './rows.ts';
11
+ import type { AdoptReport } from './verify.ts';
12
+
13
+ describe('parseVerifyArgs', () => {
14
+ test('takes the flags alchemy plan takes', () => {
15
+ const parsed = parseVerifyArgs(
16
+ ['-c', 'alchemy.pbs.ts', '--stage', 'live', '--profile', 'p', '--env-file', '.env', '--all'],
17
+ {},
18
+ );
19
+ expect(parsed).toEqual({
20
+ json: false,
21
+ kind: 'run',
22
+ options: { all: true },
23
+ target: { entrypoint: 'alchemy.pbs.ts', envFile: '.env', profile: 'p', stage: 'live' },
24
+ });
25
+ });
26
+
27
+ test('defaults the entrypoint and reads ALCHEMY_STAGE, but never guesses a stage', () => {
28
+ const parsed = parseVerifyArgs(['--json'], { ALCHEMY_STAGE: 'live' });
29
+ expect(parsed).toMatchObject({ json: true, target: { entrypoint: 'alchemy.run.ts' } });
30
+ expect(parseVerifyArgs([], { USER: 'tim' })).toMatchObject({ kind: 'error' });
31
+ });
32
+
33
+ test('an unknown flag or a positional is an error, and --help is help', () => {
34
+ expect(parseVerifyArgs(['--stage', 'live', '--yes'], {})).toMatchObject({ kind: 'error' });
35
+ expect(parseVerifyArgs(['--stage', 'live', 'alchemy.run.ts'], {})).toMatchObject({
36
+ kind: 'error',
37
+ });
38
+ expect(parseVerifyArgs(['-h'], {})).toEqual({ kind: 'help' });
39
+ });
40
+ });
41
+
42
+ const row = (over: Partial<AdoptRow>): AdoptRow => ({
43
+ bindings: 0,
44
+ changed: [],
45
+ diff: 'noop',
46
+ fqn: 'house',
47
+ ok: true,
48
+ planned: 'adopted',
49
+ read: 'found',
50
+ stateRow: false,
51
+ type: 'Proxmox.Pool',
52
+ why: '',
53
+ ...over,
54
+ });
55
+
56
+ const report = (rows: AdoptRow[]): AdoptReport => ({
57
+ declared: 5,
58
+ rows,
59
+ stack: 'HomeFlareProxmox',
60
+ stage: 'live',
61
+ });
62
+
63
+ describe('formatReport', () => {
64
+ test('a clean report exits 0 and says so', () => {
65
+ const clean = report([row({})]);
66
+ expect(exitCodeOf(clean)).toBe(0);
67
+ expect(formatReport(clean, false)).toContain('every row is a no-op');
68
+ });
69
+
70
+ test('a failing row exits 1, shows its reason and field names, never values', () => {
71
+ const dirty = report([
72
+ row({ changed: ['comment'], diff: 'update', ok: false, why: 'the deploy writes' }),
73
+ ]);
74
+ const text = formatReport(dirty, false);
75
+ expect(exitCodeOf(dirty)).toBe(1);
76
+ expect(text).toContain('FAIL');
77
+ expect(text).toContain('changed: comment');
78
+ expect(text).toContain('the deploy writes');
79
+ expect(text).toContain('1 row(s) are NOT a no-op');
80
+ });
81
+
82
+ test('nothing to verify is a pass that says why', () => {
83
+ expect(formatReport(report([]), false)).toContain('every declared row already has state');
84
+ expect(exitCodeOf(report([]))).toBe(0);
85
+ });
86
+ });
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `hf-adopt-verify` — the bin. Parse, verify, print, exit 0 / 1 / 2.
4
+ *
5
+ * ★ `runMain` IS ALCHEMY'S (alchemy/Util/PlatformServices), so this runs under bun or node the way
6
+ * `alchemy` itself does, and exits once the report is out rather than lingering on keep-alive
7
+ * sockets. Run it where the stack's `alchemy` runs: `bunx --bun hf-adopt-verify …` next to
8
+ * `bun ./node_modules/alchemy/bin/cli.js plan …`, because the entrypoint it imports is
9
+ * TypeScript.
10
+ * ⛔ EXIT 2 IS "COULD NOT TELL", NEVER A PASS. A plan that fails — a provider that dies, an
11
+ * `adopt(false)` row that is not ours, a state store it cannot read — prints the cause and exits
12
+ * 2, so a gate reading `!= 0` stops either way.
13
+ */
14
+ import { runMain } from 'alchemy/Util/PlatformServices';
15
+ import * as Cause from 'effect/Cause';
16
+ import * as Console from 'effect/Console';
17
+ import * as Effect from 'effect/Effect';
18
+ import { USAGE, parseVerifyArgs } from './args.ts';
19
+ import { exitCodeOf, formatReport } from './report.ts';
20
+ import { verifyStack } from './verify.ts';
21
+
22
+ const setExit = (code: number) =>
23
+ Effect.sync(() => {
24
+ process.exitCode = code;
25
+ });
26
+
27
+ const program = Effect.gen(function* () {
28
+ const parsed = parseVerifyArgs(process.argv.slice(2), process.env);
29
+ if (parsed.kind === 'help') return yield* Console.log(USAGE);
30
+ if (parsed.kind === 'error') {
31
+ yield* Console.error(`hf-adopt-verify: ${parsed.message}\n\n${USAGE}`);
32
+ return yield* setExit(2);
33
+ }
34
+ const report = yield* verifyStack(parsed.target, parsed.options);
35
+ yield* Console.log(
36
+ parsed.json
37
+ ? JSON.stringify(report, null, 2)
38
+ : formatReport(report, parsed.options.all === true),
39
+ );
40
+ yield* setExit(exitCodeOf(report));
41
+ }).pipe(
42
+ Effect.catchCause((cause) =>
43
+ Console.error(`hf-adopt-verify: the plan could not be verified\n${Cause.pretty(cause)}`).pipe(
44
+ Effect.andThen(setExit(2)),
45
+ ),
46
+ ),
47
+ );
48
+
49
+ runMain(program);
@@ -0,0 +1,158 @@
1
+ /**
2
+ * A test-only stack: Alchemy's real Plan and Apply over an in-memory state store, and a fake family
3
+ * whose "cloud" is a Map — so the verifier runs against the engine's own plans with no network.
4
+ *
5
+ * ★ THE ENGINE, NOT A HAND-BUILT PLAN. What the verifier claims is about alchemy beta.79: that an
6
+ * adopted row's `diff` answer is lost to `forceUpdateAfterAdoption`, that Apply then reconciles
7
+ * it, and that a watched plan never writes. openbao/fake-stack.ts measured the same engine for
8
+ * the Bao families; this one is family-neutral and records every provider call in order.
9
+ * ⛔ TEST-ONLY. No provider imports this file.
10
+ */
11
+ import * as Alchemy from 'alchemy';
12
+ import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
13
+ import { provideFreshArtifactStore } from 'alchemy/Artifacts';
14
+ import { isResolved } from 'alchemy/Diff';
15
+ import * as Provider from 'alchemy/Provider';
16
+ import * as Effect from 'effect/Effect';
17
+ import * as Layer from 'effect/Layer';
18
+ import { type AdoptReport, type VerifyOptions, verifySession } from './verify.ts';
19
+
20
+ export interface ThingProps {
21
+ name: string;
22
+ comment?: string;
23
+ }
24
+ export interface ThingAttributes {
25
+ name: string;
26
+ comment: string;
27
+ }
28
+
29
+ export interface Thing extends Alchemy.Resource<'Test.Thing', ThingProps, ThingAttributes> {}
30
+ export const Thing = Alchemy.Resource<Thing>('Test.Thing');
31
+
32
+ /** The same object with no `diff`, like a provider that never wrote one. */
33
+ export interface Blind extends Alchemy.Resource<'Test.Blind', ThingProps, ThingAttributes> {}
34
+ export const Blind = Alchemy.Resource<Blind>('Test.Blind');
35
+
36
+ /** The fake cloud: what exists, what is someone else's, and every call made to it, in order. */
37
+ export interface Cloud {
38
+ readonly live: Map<string, string>;
39
+ readonly unowned: Set<string>;
40
+ readonly calls: string[];
41
+ }
42
+
43
+ export const cloudOf = (live: Record<string, string> = {}): Cloud => ({
44
+ calls: [],
45
+ live: new Map(Object.entries(live)),
46
+ unowned: new Set(),
47
+ });
48
+
49
+ /** Every call that would change the cloud. */
50
+ export const writesOf = (cloud: Cloud): string[] =>
51
+ cloud.calls.filter((call) => call.startsWith('reconcile') || call.startsWith('delete'));
52
+
53
+ const lifecycle = (cloud: Cloud) => ({
54
+ list: () => Effect.succeed([]),
55
+ read: ({ olds }: { olds: ThingProps }) =>
56
+ Effect.sync(() => {
57
+ cloud.calls.push(`read ${olds.name}`);
58
+ const comment = cloud.live.get(olds.name);
59
+ if (comment === undefined) return undefined;
60
+ const found = { comment, name: olds.name };
61
+ return cloud.unowned.has(olds.name) ? Unowned(found) : found;
62
+ }),
63
+ reconcile: ({ news }: { news: ThingProps }) =>
64
+ Effect.sync(() => {
65
+ cloud.calls.push(`reconcile ${news.name}`);
66
+ cloud.live.set(news.name, news.comment ?? '');
67
+ return { comment: news.comment ?? '', name: news.name };
68
+ }),
69
+ delete: ({ olds }: { olds: ThingProps }) =>
70
+ Effect.sync(() => {
71
+ cloud.calls.push(`delete ${olds.name}`);
72
+ cloud.live.delete(olds.name);
73
+ }),
74
+ });
75
+
76
+ /** Both fake families over `cloud` — also what tests/fixtures/verify-stack.ts registers. */
77
+ export const thingProviders = (cloud: Cloud) =>
78
+ Layer.mergeAll(
79
+ Provider.effect(
80
+ Thing,
81
+ Effect.succeed(
82
+ Thing.Provider.of({
83
+ ...lifecycle(cloud),
84
+ diff: ({ news, output }) =>
85
+ Effect.sync(() => {
86
+ if (output === undefined || !isResolved(news)) return undefined;
87
+ cloud.calls.push(`diff ${news.name}`);
88
+ return cloud.live.get(news.name) === (news.comment ?? '')
89
+ ? ({ action: 'noop' } as const)
90
+ : ({ action: 'update' } as const);
91
+ }),
92
+ }),
93
+ ),
94
+ ),
95
+ Provider.effect(Blind, Effect.succeed(Blind.Provider.of(lifecycle(cloud)))),
96
+ );
97
+
98
+ /** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
99
+ export type Body = Effect.Effect<unknown, unknown, unknown>;
100
+
101
+ type Compiled = { readonly name: string; readonly stage: string; readonly services: never };
102
+ type PlanView = { readonly resources: Readonly<Record<string, { readonly action: string }>> };
103
+
104
+ export interface FakeEngine {
105
+ /** Plan and apply `body`, as `alchemy deploy --adopt` would; resolves to the planned actions. */
106
+ readonly deploy: (body: Body) => Promise<Record<string, string>>;
107
+ /** Verify `body` against what the earlier deploys recorded. */
108
+ readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
109
+ }
110
+
111
+ /**
112
+ * One stack over one state store and any providers, shared by every call — a family's own test
113
+ * hands in its real provider over a fake transport (proxmox/ceph-pool-adopt.test.ts).
114
+ * ⚠️ THE CASTS ARE AT THE ENGINE'S TYPED BOUNDARY — openbao/fake-stack.ts has the reasoning.
115
+ */
116
+ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): FakeEngine => {
117
+ const state = Alchemy.inMemoryState();
118
+ const stack = Alchemy.Stack as unknown as (
119
+ name: string,
120
+ options: { providers: typeof layer; state: typeof state },
121
+ body: Body,
122
+ ) => Effect.Effect<Compiled, unknown, never>;
123
+ const plan = Alchemy.Plan.make as unknown as (c: Compiled) => Effect.Effect<PlanView, unknown>;
124
+ const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
125
+
126
+ const run = <A>(body: Body, next: (compiled: Compiled) => Effect.Effect<A, unknown, unknown>) =>
127
+ Effect.runPromise(
128
+ Effect.gen(function* () {
129
+ const compiled = yield* stack('VerifyStack', { providers: layer, state }, body);
130
+ return yield* next(compiled).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
131
+ }).pipe(
132
+ Effect.provideService(AdoptPolicy, true),
133
+ Effect.provideService(Alchemy.Stage, 'test'),
134
+ Effect.provide(state),
135
+ Effect.scoped,
136
+ ) as Effect.Effect<A>,
137
+ );
138
+
139
+ return {
140
+ deploy: (body) =>
141
+ run(body, (compiled) =>
142
+ Effect.gen(function* () {
143
+ const planned = yield* plan(compiled);
144
+ yield* apply(planned);
145
+ return Object.fromEntries(
146
+ Object.entries(planned.resources).map(([fqn, node]) => [fqn, node.action]),
147
+ );
148
+ }).pipe(provideFreshArtifactStore),
149
+ ),
150
+ verify: (body, options) =>
151
+ run(body, (compiled) =>
152
+ verifySession({ context: compiled.services, stack: compiled as never }, options),
153
+ ),
154
+ };
155
+ };
156
+
157
+ /** The fake family over `cloud`. */
158
+ export const fakeEngine = (cloud: Cloud): FakeEngine => engineOver(thingProviders(cloud));
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `@homeflare/alchemy/verify` — prove a deploy's adoptions are no-ops before it runs.
3
+ *
4
+ * ★ ONE TOOL FOR EVERY STACK, NOT A TEST PER REPO. A stack runs `hf-adopt-verify --config
5
+ * alchemy.run.ts --stage live` before a gated deploy, or calls `verifyStack` from its own
6
+ * script; both plan with Alchemy's planner and never write. docs/adopt-verify.md has the why,
7
+ * the report's columns, and what each kit family does on an adopted deploy.
8
+ * ⛔ A SUBPATH OF ITS OWN: it imports the stack module from disk and reads the state store, which
9
+ * is host access, so it never rides along with the runtime-neutral root entry.
10
+ */
11
+ export { type Parsed, USAGE, parseVerifyArgs } from './args.ts';
12
+ export { exitCodeOf, formatReport } from './report.ts';
13
+ export {
14
+ type AdoptRow,
15
+ type NodeView,
16
+ type PlanView,
17
+ type TaskView,
18
+ changedFields,
19
+ rowsOf,
20
+ } from './rows.ts';
21
+ export {
22
+ type DiffAnswer,
23
+ type Observations,
24
+ type ReadAnswer,
25
+ type Seen,
26
+ WRITE_REFUSED,
27
+ spyContext,
28
+ } from './spy.ts';
29
+ export {
30
+ type AdoptReport,
31
+ type VerifyOptions,
32
+ type VerifySession,
33
+ type VerifyTarget,
34
+ verifySession,
35
+ verifyStack,
36
+ } from './verify.ts';
@@ -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,169 @@
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 } from './spy.ts';
11
+
12
+ export interface AdoptRow {
13
+ readonly fqn: string;
14
+ readonly type: string;
15
+ /** Whether the state store holds a row for this FQN. An adoption is a row without one. */
16
+ readonly stateRow: boolean;
17
+ /** The engine's action — what `alchemy plan` prints (`adopted`, `create`, `noop`, …). */
18
+ readonly planned: string;
19
+ /** What the provider's `read` answered, or `not-read` when nothing asked it. */
20
+ readonly read: ReadAnswer;
21
+ /** What the provider's own `diff` answered BEFORE Plan.ts forced it (spy.ts). */
22
+ readonly diff: DiffAnswer;
23
+ /**
24
+ * Declared fields whose live value differs, by NAME only — values can be secrets. Compared on
25
+ * same-named keys of the declaration and the read's attributes, so a family whose attributes
26
+ * rename a field cannot show it here; the diff answer, not this list, is the verdict.
27
+ */
28
+ readonly changed: readonly string[];
29
+ /** Bindings the engine will create, update or delete — reconcile applies them even on `noop`. */
30
+ readonly bindings: number;
31
+ readonly ok: boolean;
32
+ /** Why the row is not a no-op, or a note on one that is; empty when there is nothing to say. */
33
+ readonly why: string;
34
+ }
35
+
36
+ /** The slice of an Alchemy plan node this file reads. */
37
+ export interface NodeView {
38
+ readonly action: string;
39
+ readonly resource: { readonly Type: string };
40
+ readonly bindings?: readonly { readonly action: string }[];
41
+ }
42
+
43
+ /** A stack task (an Alchemy action): `run`, `noop` or `delete`. It has no provider to ask. */
44
+ export interface TaskView {
45
+ readonly action: string;
46
+ readonly def: { readonly Type: string };
47
+ }
48
+
49
+ /** The slice of an Alchemy `Plan` this file reads. */
50
+ export interface PlanView {
51
+ readonly resources: Readonly<Record<string, NodeView>>;
52
+ readonly deletions: Readonly<Record<string, NodeView | undefined>>;
53
+ readonly actions?: Readonly<Record<string, TaskView | undefined>>;
54
+ }
55
+
56
+ /** Same-named, declared, resolved fields where the live value differs. */
57
+ export const changedFields = (declared: unknown, live: unknown): string[] => {
58
+ if (typeof declared !== 'object' || declared === null) return [];
59
+ if (typeof live !== 'object' || live === null) return [];
60
+ const actual = live as Record<string, unknown>;
61
+ return Object.entries(declared as Record<string, unknown>)
62
+ .filter(([key, value]) => value !== undefined && key in actual && isResolved(value))
63
+ .filter(([key, value]) => !deepEqual(value, actual[key]))
64
+ .map(([key]) => key)
65
+ .sort();
66
+ };
67
+
68
+ const STILL_LIVE = new Set(['adopted', 'noop']);
69
+
70
+ /**
71
+ * ⛔ `adopted` NEEDS THE PROVIDER'S OWN `noop`. With no diff (`none`) the engine compared the
72
+ * declaration with the declaration — an adopted row's state carries `props: news` (Plan.ts) — so
73
+ * its noop proves nothing. A row with state is different: there the engine compared against the
74
+ * props it last deployed, which is the same check `alchemy plan` makes.
75
+ */
76
+ const isNoop = (planned: string, diff: DiffAnswer, stateRow: boolean) =>
77
+ STILL_LIVE.has(planned) &&
78
+ (diff === 'noop' || (diff === 'none' && stateRow && planned === 'noop'));
79
+
80
+ const reason = (row: Omit<AdoptRow, 'ok' | 'why'>, ok: boolean): string => {
81
+ if (ok) return row.read === 'unowned' ? 'unowned: the deploy needs --adopt or adopt(true)' : '';
82
+ if (isNoop(row.planned, row.diff, row.stateRow)) {
83
+ return `${String(row.bindings)} binding(s) change: the deploy reconciles them though the diff is noop`;
84
+ }
85
+ if (row.planned === 'delete') return 'no longer declared: the deploy deletes it';
86
+ if (row.planned === 'orphaned') return 'no longer declared: the deploy drops its state (retain)';
87
+ if (row.read === 'failed') return "the verifier's own read of it failed";
88
+ if (row.planned === 'create') {
89
+ if (row.stateRow) return 'an interrupted create: the deploy finishes creating it';
90
+ return row.read === 'absent'
91
+ ? 'the read found nothing, so the deploy CREATES it (a lane that cannot see it reads the same)'
92
+ : 'never probed (a prop is an Output of another resource, or the provider has no read): ' +
93
+ 'the deploy CREATES it';
94
+ }
95
+ if (row.diff === 'update') return "the provider's diff says update: the deploy writes";
96
+ if (row.diff === 'replace') return "the provider's diff says replace: a new one, then a delete";
97
+ if (row.diff === 'none') {
98
+ return 'the provider has no diff, so the engine compared the declaration with itself';
99
+ }
100
+ return `the engine plans ${row.planned} although the diff says ${row.diff} (a renamed row, changed bindings or --force)`;
101
+ };
102
+
103
+ export const rowFor = (
104
+ fqn: string,
105
+ node: NodeView,
106
+ stateRow: boolean,
107
+ seen: Observations,
108
+ ): AdoptRow => {
109
+ const observed = seen.get(fqn);
110
+ const base = {
111
+ bindings: (node.bindings ?? []).filter((binding) => binding.action !== 'noop').length,
112
+ changed: changedFields(observed?.diff?.news, observed?.read?.attributes),
113
+ diff: observed?.diff?.answer ?? 'not-run',
114
+ fqn,
115
+ planned: node.action,
116
+ read: observed?.read?.answer ?? 'not-read',
117
+ stateRow,
118
+ type: node.resource.Type,
119
+ } satisfies Omit<AdoptRow, 'ok' | 'why'>;
120
+ /**
121
+ * ⚠️ A NOOP DIFF DOES NOT COVER BINDINGS. Plan.ts diffs them separately and hands every
122
+ * non-noop binding to `reconcile`, which an adopted row reaches anyway — a Worker adopted with
123
+ * a new binding writes although its own diff said nothing changed.
124
+ */
125
+ const ok = isNoop(base.planned, base.diff, stateRow) && base.bindings === 0;
126
+ return { ...base, ok, why: reason(base, ok) };
127
+ };
128
+
129
+ /**
130
+ * ⚠️ A TASK THAT RUNS IS A WRITE THE PROVIDERS NEVER SEE, so it is reported in both modes; a task
131
+ * that would not run appears only with `all`.
132
+ */
133
+ const taskRow = (fqn: string, task: TaskView): AdoptRow => ({
134
+ bindings: 0,
135
+ changed: [],
136
+ diff: 'not-run',
137
+ fqn,
138
+ ok: task.action === 'noop',
139
+ planned: task.action,
140
+ read: 'not-read',
141
+ stateRow: task.action !== 'run',
142
+ type: task.def.Type,
143
+ why: task.action === 'noop' ? '' : `a stack task: the deploy will ${task.action} it`,
144
+ });
145
+
146
+ /**
147
+ * The rows to report. Without `all`, only rows WITHOUT a state row — the adoptions and creates a
148
+ * deploy would perform — which is the question this tool exists for, plus any task that runs. With
149
+ * `all`, every declared row, every pending deletion and every task.
150
+ */
151
+ export const rowsOf = (
152
+ plan: PlanView,
153
+ stateRows: ReadonlySet<string>,
154
+ seen: Observations,
155
+ all: boolean,
156
+ ): AdoptRow[] => {
157
+ const declared = Object.entries(plan.resources)
158
+ .filter(([fqn]) => all || !stateRows.has(fqn))
159
+ .map(([fqn, node]) => rowFor(fqn, node, stateRows.has(fqn), seen));
160
+ const removed = all
161
+ ? Object.entries(plan.deletions).flatMap(([fqn, node]) =>
162
+ node === undefined ? [] : [rowFor(fqn, node, true, seen)],
163
+ )
164
+ : [];
165
+ const tasks = Object.entries(plan.actions ?? {}).flatMap(([fqn, task]) =>
166
+ task === undefined || (!all && task.action === 'noop') ? [] : [taskRow(fqn, task)],
167
+ );
168
+ return [...declared, ...removed, ...tasks].sort((a, b) => a.fqn.localeCompare(b.fqn));
169
+ };