@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,135 @@
1
+ /**
2
+ * sudoRunner's 2026-09-21 refusals, over the fake host (fake-sudo.ts): no group/world-writable,
3
+ * setuid or setgid root-owned file under a prefix, and no directory from the prefix down that root
4
+ * does not own alone. Each is refused before anything is staged or run as root — and, through
5
+ * `checkWrite`, at PLAN time, still without sudo.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { OPERATOR, fakeSudoHost } from './fake-sudo.ts';
9
+ import { diffFile, reconcileFile } from './host-file-lifecycle.ts';
10
+ import type { LaunchdJobProps } from './job-form.ts';
11
+ import { diffJob, reconcileJob } from './job-lifecycle.ts';
12
+ import { SudoRefusedError } from './sudo-allowlist.ts';
13
+ import { modeProblem } from './sudo-guard.ts';
14
+
15
+ const CONF = '/opt/example/app/a.conf';
16
+ const bytes = (text: string) => new TextEncoder().encode(text);
17
+ type Host = ReturnType<typeof fakeSudoHost>;
18
+ const dir = (mode: number, uid = 0) => ({
19
+ bytes: bytes(''),
20
+ gid: 0,
21
+ kind: 'directory' as const,
22
+ mode,
23
+ uid,
24
+ });
25
+
26
+ describe('modeProblem', () => {
27
+ test.each([
28
+ ['0644 root', { mode: 0o644, uid: 0 }, undefined],
29
+ ['0755 with the owner omitted (root under a prefix)', { mode: 0o755 }, undefined],
30
+ ['0640 root', { mode: 0o640, uid: 0 }, undefined],
31
+ ['sticky 1755 changes nothing on a file', { mode: 0o1755 }, undefined],
32
+ ['0664 root is group-writable', { mode: 0o664 }, 'writable by group or other'],
33
+ ['0646 root is world-writable', { mode: 0o646, uid: 0 }, 'writable by group or other'],
34
+ ['4755 root is setuid', { mode: 0o4755 }, 'setuid/setgid'],
35
+ ['2755 root is setgid', { mode: 0o2755, uid: 0 }, 'setuid/setgid'],
36
+ ['6777 root is both', { mode: 0o6777 }, 'setuid/setgid and writable by group or other'],
37
+ ['0664 handed to another user is theirs to change', { mode: 0o664, uid: OPERATOR }, undefined],
38
+ ] as const)('%s', (_name, options, found) => {
39
+ const problem = modeProblem(options);
40
+ if (found === undefined) expect(problem).toBeUndefined();
41
+ else expect(problem).toContain(found);
42
+ });
43
+ });
44
+
45
+ describe('a root-owned install under a prefix', () => {
46
+ test.each([0o664, 0o646, 0o4755, 0o2755])(
47
+ 'mode %o is refused before staging or sudo',
48
+ async (mode) => {
49
+ const host = fakeSudoHost();
50
+ const write = host.runner.writeFileAtomic(CONF, bytes('x'), { mode });
51
+ await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
52
+ await expect(write).rejects.toThrow('on a root-owned file');
53
+ expect(host.state.stagedCount).toBe(0);
54
+ expect(host.sudoCalls).toEqual([]);
55
+ },
56
+ );
57
+
58
+ test('an omitted-owner 0644 still installs, as root', async () => {
59
+ const host = fakeSudoHost();
60
+ await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
61
+ expect(host.fake.files.get(CONF)).toMatchObject({ mode: 0o644, uid: 0 });
62
+ });
63
+ });
64
+
65
+ describe('every directory from the prefix down to the file', () => {
66
+ const below: [string, (h: Host) => void, string][] = [
67
+ ['owned by the operator', (h) => h.fake.dirs.set('/opt/example/app', OPERATOR), 'uid 501'],
68
+ ['group-writable', (h) => h.fake.files.set('/opt/example/app', dir(0o775)), '0775'],
69
+ ['world-writable', (h) => h.fake.files.set('/opt/example/app', dir(0o757)), '0757'],
70
+ ];
71
+
72
+ test.each(below)('%s is refused for a write, before staging or sudo', async (_n, setup, says) => {
73
+ const host = fakeSudoHost();
74
+ setup(host);
75
+ const write = host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
76
+ await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
77
+ await expect(write).rejects.toThrow(
78
+ /\/opt\/example\/app is .*(uid 501|0775|0757).*Every directory from the prefix/s,
79
+ );
80
+ await expect(write).rejects.toThrow(says);
81
+ expect(host.state.stagedCount).toBe(0);
82
+ expect(host.sudoCalls).toEqual([]);
83
+ });
84
+
85
+ test.each(below)('%s is refused for a remove too', async (_n, setup, says) => {
86
+ const host = fakeSudoHost();
87
+ host.fake.files.set(CONF, { ...dir(0o644), kind: 'file' });
88
+ setup(host);
89
+ await expect(host.runner.removeFile(CONF)).rejects.toThrow(says);
90
+ expect(host.sudoCalls).toEqual([]);
91
+ });
92
+ });
93
+
94
+ describe('at plan time, through checkWrite — reads only, never sudo', () => {
95
+ test('HostFile: a diff that would write a setuid root file fails the plan', async () => {
96
+ const host = fakeSudoHost();
97
+ const output = await reconcileFile(host.runner, { content: 'x', mode: 0o755, path: CONF });
98
+ const before = host.sudoCalls.length;
99
+ const plan = diffFile(host.runner, { content: 'x', mode: 0o4755, path: CONF }, output);
100
+ await expect(plan).rejects.toThrow('setuid/setgid');
101
+ expect(host.sudoCalls).toHaveLength(before);
102
+ // ★ A converged file plans noop and asks nothing, so an unrelated plan is never blocked by it.
103
+ expect(await diffFile(host.runner, { content: 'x', mode: 0o755, path: CONF }, output)).toEqual({
104
+ action: 'noop',
105
+ });
106
+ });
107
+
108
+ test('HostFile: a new path under a directory another user may write fails the plan', async () => {
109
+ const host = fakeSudoHost();
110
+ const output = await reconcileFile(host.runner, { content: 'x', path: CONF });
111
+ host.fake.dirs.set('/opt/example/other', OPERATOR);
112
+ const moved = { content: 'x', path: '/opt/example/other/a.conf' };
113
+ await expect(diffFile(host.runner, moved, output)).rejects.toThrow('uid 501');
114
+ });
115
+
116
+ test.each([
117
+ ['an update', { runAtLoad: true }],
118
+ ['a rename (the delete-first replace)', { label: 'com.example.renamed' }],
119
+ ] as const)(
120
+ 'LaunchdJob: %s into a group-writable LaunchDaemons fails the plan',
121
+ async (_n, change) => {
122
+ const job: LaunchdJobProps = {
123
+ domain: 'system',
124
+ label: 'com.example.job',
125
+ programArguments: ['/usr/local/bin/job'],
126
+ };
127
+ const host = fakeSudoHost();
128
+ const attrs = await reconcileJob(host.runner, job, undefined);
129
+ host.fake.files.set('/Library/LaunchDaemons', dir(0o775));
130
+ const before = host.sudoCalls.length;
131
+ await expect(diffJob(host.runner, { ...job, ...change }, attrs)).rejects.toThrow('0775');
132
+ expect(host.sudoCalls).toHaveLength(before);
133
+ },
134
+ );
135
+ });
@@ -21,12 +21,12 @@
21
21
  * ★ `install -S`: install(1) on macOS 27.2 (read 2026-09-21) always writes a temp file in the
22
22
  * target directory and renames it, so the file is never torn, and -S adds the fsync that
23
23
  * localRunner's `handle.sync()` does.
24
- * ⚠️ REASONED, NOT MEASURED: sudo's failure text. The kit never runs sudo. The strings below follow
25
- * sudoers(5) for sudo 1.9.17p2 on macOS 27.2 ("a password is required": -n was given but a
26
- * password was needed) and sudo(8) (sudo exits 1 when it fails itself).
24
+ * ⚠️ REASONED, NOT MEASURED: sudo's failure text (sudo-said.ts has the strings and their sources).
25
+ * ⛔ EVERY CHECK BEFORE SUDO ALSO RUNS AT PLAN TIME (`checkWrite`, 2026-09-21): the mode, the
26
+ * directories from the prefix down, the read-back. Reads only, as the operator — never sudo.
27
27
  */
28
- import { type LocalRunnerOptions, errnoCode, localRunner } from './local-runner.ts';
29
- import type { ExecResult, HostRunner } from './runner.ts';
28
+ import { type LocalRunnerOptions, localRunner } from './local-runner.ts';
29
+ import type { ExecResult, HostRunner, WriteOptions } from './runner.ts';
30
30
  import {
31
31
  INSTALL,
32
32
  RM,
@@ -38,7 +38,8 @@ import {
38
38
  privilegedProblem,
39
39
  routeExec,
40
40
  } from './sudo-allowlist.ts';
41
- import { assertPlainPath, assertReadableBack, operatorGroups } from './sudo-guard.ts';
41
+ import { assertInstallable, assertPlainPath, operatorGroups } from './sudo-guard.ts';
42
+ import { sudoRefusal, undeclared } from './sudo-said.ts';
42
43
  import { type Staged, stageFile } from './sudo-stage.ts';
43
44
 
44
45
  export { SudoRefusedError };
@@ -66,31 +67,6 @@ export type SudoDeps = {
66
67
  readonly log: (line: string) => void;
67
68
  };
68
69
 
69
- /**
70
- * ⚠️ ANCHORED TO sudo'S OWN MESSAGE FORMS (`sudo: …` lines, `Sorry, user …`), so a launchctl or
71
- * install error that happens to say "not allowed" is never reported as "the command did not run".
72
- * The refusals are the three a sudoers denial prints: the command not allowed, the user not in
73
- * sudoers, and the user not allowed on this host (`<user> is not allowed to run sudo on <host>.`).
74
- * REASONED from sudo 1.9's sudoers plugin, like the rest (header).
75
- */
76
- const PASSWORD =
77
- /^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
78
- const NOT_ALLOWED =
79
- /^(?:Sorry, user \S+ (?:is not allowed to execute|may not run sudo)|\S+ is not (?:in the sudoers file|allowed to run sudo on ))/m;
80
-
81
- /** ★ An EACCES outside every prefix is almost always a directory the stack forgot to declare. */
82
- const undeclared =
83
- (path: string, prefixes: readonly string[]) =>
84
- (cause: unknown): never => {
85
- const code = errnoCode(cause);
86
- if (code !== 'EACCES' && code !== 'EPERM') throw cause;
87
- throw new Error(
88
- `sudoRunner ${path}: ${code} as the deploying user, and the path is under no declared prefix ` +
89
- `(${prefixes.join(', ')}). If root owns its directory, declare that directory as a prefix.`,
90
- { cause },
91
- );
92
- };
93
-
94
70
  export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): HostRunner => {
95
71
  const prefixes = checkPrefixes(prefixList);
96
72
  const { base } = deps;
@@ -113,22 +89,35 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
113
89
  await before?.();
114
90
  deps.log(`homeflare/launchd sudo -n ${shown}`);
115
91
  const result = await base.exec([SUDO, '-n', '--', ...argv]);
116
- if (result.exitCode !== 1) return result;
117
- if (PASSWORD.test(result.stderr)) {
118
- throw new SudoRefusedError(
119
- `sudo -n ${shown}: a password is required, and this runner never prompts. Run \`sudo -v\` ` +
120
- 'in the deploying terminal just before the deploy, or grant exactly these commands ' +
121
- 'NOPASSWD (docs/launchd-sudo.md). The command did not run.',
122
- );
123
- }
124
- if (NOT_ALLOWED.test(result.stderr)) {
125
- throw new SudoRefusedError(
126
- `sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`,
127
- );
128
- }
92
+ const refused = sudoRefusal(shown, result);
93
+ if (refused !== undefined) throw refused;
129
94
  return result;
130
95
  };
131
96
 
97
+ /**
98
+ * Every refusal a write can meet before sudo is asked — as the deploying user, reading only — so
99
+ * the same checks run at plan time (`checkWrite`) and at the write. `undefined` outside every
100
+ * prefix, where the operator writes; else the prefix and the mode as `install -m` takes it.
101
+ */
102
+ const vetWrite = async (path: string, options: WriteOptions) => {
103
+ const prefix = prefixOf(path, prefixes);
104
+ const operator = base.effectiveUid();
105
+ if (prefix === undefined) {
106
+ if (options.uid !== undefined && options.uid !== operator && operator !== 0) {
107
+ throw new SudoRefusedError(
108
+ `sudoRunner ${path}: owner ${String(options.uid)} needs root, and the path is under no ` +
109
+ `declared prefix (${prefixes.join(', ')}). Declare its directory, or deploy as root. ` +
110
+ 'Nothing was written.',
111
+ );
112
+ }
113
+ return undefined;
114
+ }
115
+ const mode = octalMode(options.mode);
116
+ if (mode === undefined) throw new SudoRefusedError(`sudoRunner ${path}: mode must be 0–0o7777`);
117
+ await assertInstallable(base, prefix, path, options, await deps.groups());
118
+ return { mode, prefix };
119
+ };
120
+
132
121
  /** A privileged file operation: anything but exit 0 is an error. ⚠️ stderr names paths only. */
133
122
  const mustSucceed = async (argv: readonly string[], staged?: string): Promise<void> => {
134
123
  const result = await elevate(argv, staged === undefined ? {} : { staged });
@@ -139,6 +128,10 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
139
128
  };
140
129
 
141
130
  return {
131
+ // ★ The plan-time half of vetWrite: the providers' diffs call it, and it never elevates.
132
+ checkWrite: async (path, options) => {
133
+ await vetWrite(path, options);
134
+ },
142
135
  effectiveUid: () => base.effectiveUid(),
143
136
  exec: async (argv) => {
144
137
  const route = routeExec(argv, base.effectiveUid());
@@ -177,23 +170,11 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
177
170
  sleep: (ms) => base.sleep(ms),
178
171
  stat: (path) => base.stat(path),
179
172
  writeFileAtomic: async (path, bytes, options) => {
180
- const prefix = prefixOf(path, prefixes);
181
- const operator = base.effectiveUid();
182
- if (prefix === undefined) {
183
- if (options.uid !== undefined && options.uid !== operator && operator !== 0) {
184
- throw new SudoRefusedError(
185
- `sudoRunner ${path}: owner ${String(options.uid)} needs root, and the path is under no ` +
186
- `declared prefix (${prefixes.join(', ')}). Declare its directory, or deploy as root. ` +
187
- 'Nothing was written.',
188
- );
189
- }
173
+ const vetted = await vetWrite(path, options);
174
+ if (vetted === undefined) {
190
175
  return base.writeFileAtomic(path, bytes, options).catch(undeclared(path, prefixes));
191
176
  }
192
- const mode = octalMode(options.mode);
193
- if (mode === undefined)
194
- throw new SudoRefusedError(`sudoRunner ${path}: mode must be 0–0o7777`);
195
- await assertPlainPath(base, prefix, path, 'file-or-absent');
196
- await assertReadableBack(base, path, options, await deps.groups());
177
+ const { mode } = vetted;
197
178
  const staged = await deps.stage(bytes);
198
179
  try {
199
180
  await mustSucceed(
@@ -0,0 +1,58 @@
1
+ /**
2
+ * What sudo's own failure means, for sudo-runner.ts: which exit-1 answers are sudo refusing to run
3
+ * the command (so NOTHING ran as root), and what an EACCES outside every prefix most likely means.
4
+ * Split out of sudo-runner.ts, which keeps only the runner.
5
+ *
6
+ * ⚠️ REASONED, NOT MEASURED: sudo's failure text. The kit never runs sudo. The strings below follow
7
+ * sudoers(5) for sudo 1.9.17p2 on macOS 27.2 ("a password is required": -n was given but a
8
+ * password was needed) and sudo(8) (sudo exits 1 when it fails itself).
9
+ */
10
+ import { errnoCode } from './local-runner.ts';
11
+ import type { ExecResult } from './runner.ts';
12
+ import { SudoRefusedError } from './sudo-allowlist.ts';
13
+
14
+ /**
15
+ * ⚠️ ANCHORED TO sudo'S OWN MESSAGE FORMS (`sudo: …` lines, `Sorry, user …`), so a launchctl or
16
+ * install error that happens to say "not allowed" is never reported as "the command did not run".
17
+ * The refusals are the three a sudoers denial prints: the command not allowed, the user not in
18
+ * sudoers, and the user not allowed on this host (`<user> is not allowed to run sudo on <host>.`).
19
+ * REASONED from sudo 1.9's sudoers plugin, like the rest (header).
20
+ */
21
+ const PASSWORD =
22
+ /^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
23
+ const NOT_ALLOWED =
24
+ /^(?:Sorry, user \S+ (?:is not allowed to execute|may not run sudo)|\S+ is not (?:in the sudoers file|allowed to run sudo on ))/m;
25
+
26
+ /** ★ An EACCES outside every prefix is almost always a directory the stack forgot to declare. */
27
+ export const undeclared =
28
+ (path: string, prefixes: readonly string[]) =>
29
+ (cause: unknown): never => {
30
+ const code = errnoCode(cause);
31
+ if (code !== 'EACCES' && code !== 'EPERM') throw cause;
32
+ throw new Error(
33
+ `sudoRunner ${path}: ${code} as the deploying user, and the path is under no declared prefix ` +
34
+ `(${prefixes.join(', ')}). If root owns its directory, declare that directory as a prefix.`,
35
+ { cause },
36
+ );
37
+ };
38
+
39
+ /**
40
+ * The refusal sudo itself answered for `shown` (the argv as JSON), or `undefined` when the command
41
+ * ran — whatever its own exit code. ★ `SudoRefusedError` always means nothing ran as root.
42
+ */
43
+ export const sudoRefusal = (shown: string, result: ExecResult): SudoRefusedError | undefined => {
44
+ if (result.exitCode !== 1) return undefined;
45
+ if (PASSWORD.test(result.stderr)) {
46
+ return new SudoRefusedError(
47
+ `sudo -n ${shown}: a password is required, and this runner never prompts. Run \`sudo -v\` ` +
48
+ 'in the deploying terminal just before the deploy, or grant exactly these commands ' +
49
+ 'NOPASSWD (docs/launchd-sudo.md). The command did not run.',
50
+ );
51
+ }
52
+ if (NOT_ALLOWED.test(result.stderr)) {
53
+ return new SudoRefusedError(
54
+ `sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`,
55
+ );
56
+ }
57
+ return undefined;
58
+ };
@@ -17,8 +17,11 @@ plus `FetchHttpClient.layer`.
17
17
  - ⛔ A rename or move onto a name or path that already exists fails the plan, before anything is
18
18
  written (every family but the mounts, whose moves are below). Move through a free name, in two
19
19
  deploys, or remove the target first.
20
- - ⚠️ A NEW logical id for a live name adopts it, and under `destroy` the old id's delete then
21
- removes it. Change a logical id with Alchemy's `renamedFrom` (REPLACE.md).
20
+ - ⛔ **Nothing is adopted without `--adopt`** (0.9.0), not even an object identical to the
21
+ declaration: a live name with no state fails the plan, or the apply when a prop was an Output.
22
+ Our own interrupted create still resumes when its row can prove it — not one killed while a
23
+ prop was still an Output. [docs/ownership.md](../../docs/ownership.md). Change
24
+ a logical id with Alchemy's `renamedFrom` (REPLACE.md), never by adopting.
22
25
 
23
26
  ## assertBaoIdentity: call it first
24
27
 
@@ -139,17 +139,19 @@ retained."). It is live and unmanaged from then on:
139
139
  `declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
140
140
  orphan delete, which `retain` also keeps live.
141
141
 
142
- ## A new declaration of a live name: the swap the guard cannot see
143
-
144
- ⚠️ A NEW logical id whose name already exists takes that object over and rewrites it: these
145
- families' `read` never answers `Unowned`, so Alchemy silently adopts it. Nothing moved, so
146
- `judgeMove` never runs. 🔴 Measured (`rename-adoption.test.ts`): under `destroy` the old owner's
147
- delete then runs after the adoption wrote, and removes the object the new id now claims, in a
148
- green deploy. It happened when a logical id changed with the name kept, to each of the nine
149
- batch-2 families whose delete runs (a TOTP method and its enrolments included), and when a new
150
- `Bao.AuthRole` took a name another one moved off in the same deploy. Change a logical id with
151
- Alchemy's `renamedFrom('<old id>')`, which migrates the state row and plans an `update`. Take a
152
- freed name in the deploy after the move.
142
+ ## A new declaration of a live name: refused unless adopted
143
+
144
+ ⛔ A NEW logical id whose name already exists now FAILS THE PLAN (0.9.0): with no state, `read`
145
+ answers `Unowned` for any live object, identical or not (`src/ownership/probe.ts`,
146
+ [docs/ownership.md](../../docs/ownership.md)). Nothing moved, so `judgeMove` never runs; the
147
+ probe is what catches it. 🔴 Measured before (`rename-adoption.test.ts`): the families' `read`
148
+ answered plain attributes, so Alchemy silently adopted the object, and under `destroy` the old
149
+ owner's delete then ran after the adoption wrote and removed the object the new id claimed, in a
150
+ green deploy — for each of the nine batch-2 families whose delete runs (a TOTP method and its
151
+ enrolments included), and when a new `Bao.AuthRole` took a name another one moved off in the same
152
+ deploy. ⚠️ `--adopt` is exactly that takeover again, on purpose. Change a logical id with Alchemy's
153
+ `renamedFrom('<old id>')`, which migrates the state row and plans an `update`. Take a freed name
154
+ in the deploy after the move.
153
155
 
154
156
  ## Why the moves fail instead of replacing
155
157
 
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The ownership rule for the five families fake-core.ts carries — Bao.Policy, Bao.CloudflareRole,
3
+ * Bao.ProxmoxRole, Bao.Mount and Bao.AuthMethod — through Alchemy's own plan and apply: the same
4
+ * claims adopt-families.test.ts and adopt-recovery.test.ts make for the other nine.
5
+ *
6
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { describe, it } from 'node:test';
10
+ import * as Effect from 'effect/Effect';
11
+ import { BaoAuthRole } from './auth-role.ts';
12
+ import { crashAfterWrite } from './fake-bao.ts';
13
+ import { CORE, type CoreRow, withCore } from './fake-core.ts';
14
+ import { writesOf } from './fake-stack.ts';
15
+ import { BaoMount } from './mount.ts';
16
+
17
+ const NOT_OWNED = /Cannot adopt resource 'Z'.*not owned by this stack.*--adopt/s;
18
+ const TAKEOVER = /: already exists, and this stack holds no state for it.*Nothing was written/s;
19
+
20
+ /** `Z`, with its knob an Output until apply: an upstream AppRole's `tokenTtl`, created with it. */
21
+ const pending = (row: CoreRow) =>
22
+ Effect.gen(function* () {
23
+ const up = yield* BaoAuthRole('Upstream', {
24
+ name: 'upstream',
25
+ secretIdTtl: '24h',
26
+ tokenMaxTtl: '1h',
27
+ tokenPolicies: ['default'],
28
+ tokenTtl: '15m',
29
+ });
30
+ yield* row.declare('Z', up.tokenTtl);
31
+ });
32
+
33
+ for (const row of CORE) {
34
+ describe(`${row.family}: ownership`, () => {
35
+ it('a live object another stack owns, identical, fails the plan; nothing is written', async () => {
36
+ await withCore(async (stack, estate, seen, owner) => {
37
+ await owner.deploy(row.declare('X', '15m'));
38
+ seen.length = 0;
39
+ await assert.rejects(stack.deploy(row.declare('Z', '15m')), NOT_OWNED);
40
+ assert.deepEqual(writesOf(seen), []);
41
+ assert.ok(row.has(estate));
42
+ assert.deepEqual(await stack.deploy(row.declare('Z', '15m'), { adopt: true }), {
43
+ Z: 'adopted',
44
+ });
45
+ assert.deepEqual(writesOf(seen), []);
46
+ });
47
+ });
48
+
49
+ it('with a prop still an Output, the apply refuses it; the next plan refuses too', async () => {
50
+ await withCore(async (stack, _estate, seen, owner) => {
51
+ await owner.deploy(row.declare('X', '15m'));
52
+ seen.length = 0;
53
+ await assert.rejects(stack.deploy(pending(row)), TAKEOVER);
54
+ // ★ The refused create forgot its `creating` row, so the next plan probes: still refused.
55
+ await assert.rejects(stack.deploy(pending(row)), NOT_OWNED);
56
+ assert.deepEqual(
57
+ writesOf(seen).filter((line) => !line.endsWith('/upstream')),
58
+ [],
59
+ );
60
+ });
61
+ });
62
+
63
+ it('with a prop still an Output, --adopt takes it over at apply', async () => {
64
+ await withCore(async (stack, _estate, _seen, owner) => {
65
+ await owner.deploy(row.declare('X', '15m'));
66
+ assert.deepEqual(await stack.deploy(pending(row), { adopt: true }), {
67
+ Upstream: 'create',
68
+ Z: 'create',
69
+ });
70
+ });
71
+ });
72
+
73
+ it('an interrupted create is resumed without --adopt, and nothing is rewritten', async () => {
74
+ const fault = crashAfterWrite();
75
+ await withCore(async (stack, estate, seen) => {
76
+ // ★ Bao.Policy reads nothing back after its write, so the crash is the write's own reply.
77
+ fault.crashNext(row.family === 'Bao.Policy' ? 'write' : 'read-back');
78
+ await assert.rejects(stack.deploy(row.declare('X', '15m')), /injected/);
79
+ assert.ok(row.has(estate));
80
+ seen.length = 0;
81
+ assert.deepEqual(await stack.deploy(row.declare('X', '15m')), { X: 'create' });
82
+ assert.deepEqual(writesOf(seen), []);
83
+ }, fault.wrap);
84
+ });
85
+ });
86
+ }
87
+
88
+ describe('Bao.Mount: remountFrom on a create', () => {
89
+ const moved = BaoMount('Kv', { path: 'kv-moved', remountFrom: 'kv', type: 'kv' });
90
+ const SOURCE = /Bao\.Mount kv \(the remountFrom source\): already exists/;
91
+
92
+ it('moving a live mount this stack holds no state for is refused before the move', async () => {
93
+ await withCore(async (stack, estate, seen, owner) => {
94
+ await owner.deploy(BaoMount('Src', { path: 'kv', type: 'kv' }));
95
+ seen.length = 0;
96
+ // ★ The probe reads only the target, which is free: the plan says create.
97
+ await assert.rejects(stack.deploy(moved), SOURCE);
98
+ assert.deepEqual(writesOf(seen), []);
99
+ assert.deepEqual([...estate.mounts.table.keys()], ['kv/']);
100
+ assert.deepEqual(await stack.deploy(moved, { adopt: true }), { Kv: 'create' });
101
+ assert.deepEqual([...estate.mounts.table.keys()], ['kv-moved/']);
102
+ });
103
+ });
104
+ });
105
+
106
+ describe('Bao.Mount: a create interrupted between its enable and its tune', () => {
107
+ it('is not proven ours — the mount is not what was declared — so it resumes with --adopt', async () => {
108
+ const fault = crashAfterWrite();
109
+ const kv = BaoMount('Kv', { defaultLeaseTtl: '15m', path: 'kv', type: 'kv' });
110
+ await withCore(async (stack, estate) => {
111
+ fault.crashNext('write');
112
+ await assert.rejects(stack.deploy(kv), /injected/);
113
+ assert.ok(estate.mounts.table.has('kv/'));
114
+ await assert.rejects(stack.deploy(kv), /Cannot resume creating resource 'Kv'.*--adopt/s);
115
+ assert.deepEqual(await stack.deploy(kv, { adopt: true }), { Kv: 'create' });
116
+ assert.deepEqual(await stack.deploy(kv), { Kv: 'noop' });
117
+ }, fault.wrap);
118
+ });
119
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * NOTHING IS ADOPTED WITHOUT `--adopt` (decided 2026-09-21), for the nine batch-2 families, through
3
+ * Alchemy's own plan and apply (fake-stack.ts). The object is put there by ANOTHER stack over the
4
+ * same fake (`owner`) — the case the rule exists for: identical to this declaration, and still
5
+ * not ours.
6
+ * · plan time: the adoption probe answers `Unowned` (ownership/probe.ts);
7
+ * · apply time: where the probe never ran — a prop still an Output — reconcile refuses before any
8
+ * write (ownership/adopt.ts).
9
+ * adopt-recovery.test.ts pins the other side: our own interrupted create is still resumed.
10
+ *
11
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
12
+ */
13
+ import assert from 'node:assert/strict';
14
+ import { describe, it } from 'node:test';
15
+ import { adopt } from 'alchemy/AdoptPolicy';
16
+ import * as Effect from 'effect/Effect';
17
+ import type { Seen } from './fake-bao.ts';
18
+ import { FAMILIES, type Family, upstream, withEstate } from './fake-families.ts';
19
+ import { writesOf } from './fake-stack.ts';
20
+
21
+ const NOT_OWNED = /Cannot adopt resource 'Z'.*not owned by this stack.*--adopt/s;
22
+ const TAKEOVER = /: already exists, and this stack holds no state for it.*Nothing was written/s;
23
+ const STILL_REFUSED = /Cannot adopt resource 'Z'.*--adopt/s;
24
+
25
+ /** `Z` names `a`, with its knob an Output until apply: the upstream AppRole is created with it. */
26
+ const pending = (row: Family, scoped?: boolean) =>
27
+ Effect.gen(function* () {
28
+ const up = yield* upstream('15m');
29
+ const declared = row.declare('Z', 'a', up.tokenTtl);
30
+ yield* scoped === undefined ? declared : declared.pipe(adopt(scoped));
31
+ });
32
+
33
+ /** Writes other than the upstream AppRole's own create. */
34
+ const writesToA = (seen: readonly Seen[]) =>
35
+ writesOf(seen).filter((line) => !line.endsWith('/upstream'));
36
+
37
+ for (const row of FAMILIES) {
38
+ describe(`${row.family}: a live object another stack owns`, () => {
39
+ it('identical to the declaration, still fails the plan; nothing is written', async () => {
40
+ await withEstate(async (stack, estate, seen, owner) => {
41
+ await owner.deploy(row.declare('X', 'a', '15m'));
42
+ seen.length = 0;
43
+ await assert.rejects(stack.deploy(row.declare('Z', 'a', '15m')), NOT_OWNED);
44
+ assert.deepEqual(writesOf(seen), []);
45
+ assert.ok(row.has(estate, 'a'));
46
+ });
47
+ });
48
+
49
+ it('is adopted with --adopt, and with adopt(true) — identical, so nothing is written', async () => {
50
+ for (const deploy of ['flag', 'scope'] as const) {
51
+ await withEstate(async (stack, _estate, seen, owner) => {
52
+ await owner.deploy(row.declare('X', 'a', '15m'));
53
+ seen.length = 0;
54
+ const body =
55
+ deploy === 'flag'
56
+ ? stack.deploy(row.declare('Z', 'a', '15m'), { adopt: true })
57
+ : stack.deploy(row.declare('Z', 'a', '15m').pipe(adopt(true)));
58
+ assert.deepEqual(await body, { Z: 'adopted' });
59
+ assert.deepEqual(writesOf(seen), []);
60
+ });
61
+ }
62
+ });
63
+
64
+ it('with a prop still an Output, the plan cannot ask, so the apply refuses', async () => {
65
+ await withEstate(async (stack, estate, seen, owner) => {
66
+ await owner.deploy(row.declare('X', 'a', '15m'));
67
+ seen.length = 0;
68
+ await assert.rejects(stack.deploy(pending(row)), TAKEOVER);
69
+ assert.deepEqual(writesToA(seen), []);
70
+ assert.ok(row.has(estate, 'a'));
71
+ // ★ The refused create forgot its `creating` row, so the next plan probes: still refused.
72
+ await assert.rejects(stack.deploy(pending(row)), STILL_REFUSED);
73
+ assert.deepEqual(writesToA(seen), []);
74
+ });
75
+ });
76
+
77
+ it('with a prop still an Output, --adopt and adopt(true) take it over at apply', async () => {
78
+ for (const deploy of ['flag', 'scope'] as const) {
79
+ await withEstate(async (stack, _estate, _seen, owner) => {
80
+ await owner.deploy(row.declare('X', 'a', '15m'));
81
+ const planned =
82
+ deploy === 'flag'
83
+ ? await stack.deploy(pending(row), { adopt: true })
84
+ : await stack.deploy(pending(row, true));
85
+ assert.deepEqual(planned, { Upstream: 'create', Z: 'create' });
86
+ });
87
+ }
88
+ });
89
+
90
+ it('adopt(false) on the resource wins over --adopt, at apply as at plan', async () => {
91
+ await withEstate(async (stack, _estate, _seen, owner) => {
92
+ await owner.deploy(row.declare('X', 'a', '15m'));
93
+ await assert.rejects(stack.deploy(pending(row, false), { adopt: true }), TAKEOVER);
94
+ });
95
+ });
96
+ });
97
+ }