@homeflare/alchemy 0.11.0 → 0.12.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 +3 -3
  2. package/dist/caddy/admin-guard.d.ts +1 -1
  3. package/dist/caddy/index.js.map +2 -2
  4. package/dist/cloudflare/client.d.ts +1 -1
  5. package/dist/cloudflare/index.js.map +2 -2
  6. package/dist/cloudflare/lock-rules.d.ts +1 -1
  7. package/dist/forgejo/branch-protection-form.d.ts +1 -1
  8. package/dist/forgejo/branch-protection.d.ts +1 -1
  9. package/dist/forgejo/index.js.map +10 -10
  10. package/dist/forgejo/org-actions-secrets.d.ts +1 -1
  11. package/dist/forgejo/org-label.d.ts +1 -1
  12. package/dist/forgejo/org-team.d.ts +1 -1
  13. package/dist/forgejo/repo-webhook.d.ts +1 -1
  14. package/dist/forgejo/repository.d.ts +2 -2
  15. package/dist/forgejo/repository.d.ts.map +1 -1
  16. package/dist/forgejo/resource.d.ts +2 -2
  17. package/dist/forgejo/team-member.d.ts +1 -1
  18. package/dist/openbao/auth-role.d.ts +1 -1
  19. package/dist/openbao/bao-http.d.ts +4 -4
  20. package/dist/openbao/cloudflare-permission-groups.d.ts +1 -1
  21. package/dist/openbao/cloudflare-role-form.d.ts +2 -2
  22. package/dist/openbao/cloudflare-role-form.d.ts.map +1 -1
  23. package/dist/openbao/cloudflare-roles-expand.d.ts +1 -1
  24. package/dist/openbao/digest.d.ts +1 -1
  25. package/dist/openbao/forgejo-bootstrap-parse.d.ts +2 -2
  26. package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -1
  27. package/dist/openbao/forgejo-bootstrap.d.ts +1 -1
  28. package/dist/openbao/index.js.map +14 -14
  29. package/dist/openbao/mount.d.ts +1 -1
  30. package/dist/openbao/pki-role.d.ts +1 -1
  31. package/dist/openbao/policy.d.ts +2 -2
  32. package/dist/openbao/proxmox-role-form.d.ts +2 -2
  33. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  34. package/dist/openbao/proxmox-role.d.ts +10 -10
  35. package/dist/openbao/ssh-role.d.ts +3 -3
  36. package/dist/proxmox/acl.d.ts +2 -2
  37. package/dist/proxmox/acl.d.ts.map +1 -1
  38. package/dist/proxmox/api-token-form.d.ts +2 -2
  39. package/dist/proxmox/api-token.d.ts +11 -11
  40. package/dist/proxmox/ceph-daemon-form.d.ts +3 -3
  41. package/dist/proxmox/ceph-daemon.d.ts +19 -19
  42. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  43. package/dist/proxmox/ceph-flag.d.ts +5 -5
  44. package/dist/proxmox/ceph-fs-wire.d.ts +5 -5
  45. package/dist/proxmox/ceph-fs.d.ts +16 -16
  46. package/dist/proxmox/ceph-osd-tree.d.ts +16 -16
  47. package/dist/proxmox/ceph-osd.d.ts +5 -5
  48. package/dist/proxmox/ceph-pool-form.d.ts +13 -13
  49. package/dist/proxmox/ceph-pool-form.d.ts.map +1 -1
  50. package/dist/proxmox/ceph-pool.d.ts +3 -3
  51. package/dist/proxmox/client.d.ts +2 -2
  52. package/dist/proxmox/credentials.d.ts +8 -8
  53. package/dist/proxmox/credentials.d.ts.map +1 -1
  54. package/dist/proxmox/firewall-alias-form.d.ts +1 -1
  55. package/dist/proxmox/firewall-alias.d.ts +1 -1
  56. package/dist/proxmox/generated/pbs.d.ts +1 -1
  57. package/dist/proxmox/generated/pve.d.ts +1 -1
  58. package/dist/proxmox/group.d.ts +8 -8
  59. package/dist/proxmox/ha-resource.d.ts +3 -2
  60. package/dist/proxmox/ha-resource.d.ts.map +1 -1
  61. package/dist/proxmox/ha-rule-form.d.ts +5 -5
  62. package/dist/proxmox/ha-rule.d.ts +3 -3
  63. package/dist/proxmox/index.d.ts +6 -0
  64. package/dist/proxmox/index.d.ts.map +1 -1
  65. package/dist/proxmox/index.js +356 -142
  66. package/dist/proxmox/index.js.map +59 -56
  67. package/dist/proxmox/lease-cache.d.ts +4 -4
  68. package/dist/proxmox/lxc-harness.d.ts +1 -1
  69. package/dist/proxmox/lxc-props.d.ts +2 -2
  70. package/dist/proxmox/lxc.d.ts +3 -3
  71. package/dist/proxmox/members.d.ts +3 -3
  72. package/dist/proxmox/network-apply-read.d.ts +5 -5
  73. package/dist/proxmox/network-apply.d.ts +9 -9
  74. package/dist/proxmox/node-network-form.d.ts +6 -6
  75. package/dist/proxmox/node-network.d.ts +17 -17
  76. package/dist/proxmox/node-network.d.ts.map +1 -1
  77. package/dist/proxmox/notification-target.d.ts +3 -2
  78. package/dist/proxmox/notification-target.d.ts.map +1 -1
  79. package/dist/proxmox/pbs-datastore-guard.d.ts +1 -1
  80. package/dist/proxmox/pbs-datastore.d.ts +10 -10
  81. package/dist/proxmox/pbs-prune-job.d.ts +2 -2
  82. package/dist/proxmox/pbs-sync-job.d.ts +4 -4
  83. package/dist/proxmox/pbs-verify-job.d.ts +1 -1
  84. package/dist/proxmox/pool.d.ts +4 -4
  85. package/dist/proxmox/provision-baseline.d.ts +74 -0
  86. package/dist/proxmox/provision-baseline.d.ts.map +1 -0
  87. package/dist/proxmox/provision-bootstrap.d.ts +33 -0
  88. package/dist/proxmox/provision-bootstrap.d.ts.map +1 -0
  89. package/dist/proxmox/provision-cli-fake.d.ts +38 -0
  90. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -0
  91. package/dist/proxmox/provision-declare.d.ts +25 -0
  92. package/dist/proxmox/provision-declare.d.ts.map +1 -0
  93. package/dist/proxmox/replication-job.d.ts +9 -8
  94. package/dist/proxmox/replication-job.d.ts.map +1 -1
  95. package/dist/proxmox/resource.d.ts +2 -2
  96. package/dist/proxmox/resource.d.ts.map +1 -1
  97. package/dist/proxmox/sdn-apply-read.d.ts +7 -7
  98. package/dist/proxmox/sdn-apply.d.ts +1 -1
  99. package/dist/proxmox/sdn-subnet-form.d.ts +1 -1
  100. package/dist/proxmox/sdn-subnet.d.ts +5 -5
  101. package/dist/proxmox/sdn-vnet.d.ts +5 -6
  102. package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
  103. package/dist/proxmox/storage.d.ts +4 -3
  104. package/dist/proxmox/storage.d.ts.map +1 -1
  105. package/dist/proxmox/user.d.ts +3 -2
  106. package/dist/proxmox/user.d.ts.map +1 -1
  107. package/dist/proxmox/values.d.ts +1 -1
  108. package/dist/proxmox/zfs-pool-write.d.ts +1 -1
  109. package/dist/proxmox/zfs-pool.d.ts +5 -5
  110. package/dist/talos/credentials.d.ts +1 -1
  111. package/dist/talos/credentials.d.ts.map +1 -1
  112. package/dist/talos/index.js.map +1 -1
  113. package/docs/adopt-verify.md +21 -5
  114. package/docs/adopted-deploys.md +1 -1
  115. package/docs/ownership.md +26 -1
  116. package/docs/provision-baseline.md +106 -0
  117. package/docs/proxmox-lxc-adopt.md +2 -2
  118. package/docs/proxmox-lxc.md +5 -4
  119. package/docs/proxmox.md +6 -0
  120. package/package.json +1 -1
  121. package/src/caddy/admin-guard.ts +1 -1
  122. package/src/caddy/local-admin.ts +1 -1
  123. package/src/cloudflare/client.ts +1 -1
  124. package/src/cloudflare/lock-rules.ts +1 -1
  125. package/src/forgejo/branch-protection-form.ts +1 -1
  126. package/src/forgejo/branch-protection.ts +1 -1
  127. package/src/forgejo/client.ts +1 -1
  128. package/src/forgejo/org-actions-secrets.ts +1 -1
  129. package/src/forgejo/org-label.ts +1 -1
  130. package/src/forgejo/org-team.ts +1 -1
  131. package/src/forgejo/repo-webhook.ts +1 -1
  132. package/src/forgejo/repository.ts +2 -2
  133. package/src/forgejo/resource.ts +2 -2
  134. package/src/forgejo/team-member.ts +1 -1
  135. package/src/openbao/README.md +4 -4
  136. package/src/openbao/auth-role.ts +1 -1
  137. package/src/openbao/bao-http.test.ts +2 -2
  138. package/src/openbao/bao-http.ts +6 -6
  139. package/src/openbao/bao-status.test.ts +1 -1
  140. package/src/openbao/bao-status.ts +1 -1
  141. package/src/openbao/cloudflare-group-scope.test.ts +1 -1
  142. package/src/openbao/cloudflare-permission-groups.test.ts +1 -1
  143. package/src/openbao/cloudflare-permission-groups.ts +3 -3
  144. package/src/openbao/cloudflare-policy.ts +1 -1
  145. package/src/openbao/cloudflare-role-form.ts +2 -2
  146. package/src/openbao/cloudflare-roles-expand.ts +1 -1
  147. package/src/openbao/digest.ts +1 -1
  148. package/src/openbao/forgejo-bootstrap-parse.test.ts +3 -3
  149. package/src/openbao/forgejo-bootstrap-parse.ts +2 -2
  150. package/src/openbao/forgejo-bootstrap.ts +4 -4
  151. package/src/openbao/kubernetes-role-form.test.ts +5 -5
  152. package/src/openbao/mount.ts +1 -1
  153. package/src/openbao/pki-role.ts +1 -1
  154. package/src/openbao/policy-wire.test.ts +3 -3
  155. package/src/openbao/policy.ts +2 -2
  156. package/src/openbao/proxmox-role-form.ts +5 -5
  157. package/src/openbao/proxmox-role.ts +11 -11
  158. package/src/openbao/ssh-role.ts +3 -3
  159. package/src/proxmox/acl.ts +4 -4
  160. package/src/proxmox/adopt-noop.test.ts +6 -6
  161. package/src/proxmox/api-token-form.ts +4 -4
  162. package/src/proxmox/api-token.ts +13 -13
  163. package/src/proxmox/ceph-daemon-form.ts +3 -3
  164. package/src/proxmox/ceph-daemon.ts +20 -20
  165. package/src/proxmox/ceph-flag.ts +7 -7
  166. package/src/proxmox/ceph-fs-wire.ts +7 -7
  167. package/src/proxmox/ceph-fs.ts +18 -18
  168. package/src/proxmox/ceph-osd-tree.ts +16 -16
  169. package/src/proxmox/ceph-osd.ts +6 -6
  170. package/src/proxmox/ceph-pool-adopt.test.ts +14 -14
  171. package/src/proxmox/ceph-pool-form.ts +13 -13
  172. package/src/proxmox/ceph-pool.ts +6 -6
  173. package/src/proxmox/client.test.ts +3 -3
  174. package/src/proxmox/client.ts +3 -3
  175. package/src/proxmox/credentials.test.ts +6 -8
  176. package/src/proxmox/credentials.ts +8 -8
  177. package/src/proxmox/firewall-alias-form.ts +1 -1
  178. package/src/proxmox/firewall-alias.ts +1 -1
  179. package/src/proxmox/generated/pbs.ts +1 -1
  180. package/src/proxmox/generated/pve.ts +1 -1
  181. package/src/proxmox/group.ts +10 -10
  182. package/src/proxmox/ha-resource.ts +3 -2
  183. package/src/proxmox/ha-rule-form.ts +5 -5
  184. package/src/proxmox/ha-rule.ts +3 -3
  185. package/src/proxmox/index.test.ts +7 -0
  186. package/src/proxmox/index.ts +14 -0
  187. package/src/proxmox/lease-cache.test.ts +19 -19
  188. package/src/proxmox/lease-cache.ts +4 -4
  189. package/src/proxmox/lxc-adopt.test.ts +3 -3
  190. package/src/proxmox/lxc-create-form.ts +1 -1
  191. package/src/proxmox/lxc-guards.test.ts +4 -4
  192. package/src/proxmox/lxc-harness.ts +3 -3
  193. package/src/proxmox/lxc-judge.test.ts +5 -5
  194. package/src/proxmox/lxc-lifecycle.ts +1 -1
  195. package/src/proxmox/lxc-props.ts +2 -2
  196. package/src/proxmox/lxc-read.test.ts +3 -3
  197. package/src/proxmox/lxc-strict-adopt.test.ts +1 -1
  198. package/src/proxmox/lxc-volume.test.ts +9 -9
  199. package/src/proxmox/lxc-volume.ts +1 -1
  200. package/src/proxmox/lxc-wire.ts +1 -1
  201. package/src/proxmox/lxc.ts +3 -3
  202. package/src/proxmox/members.test.ts +1 -1
  203. package/src/proxmox/members.ts +3 -3
  204. package/src/proxmox/metric-server-otel.ts +2 -2
  205. package/src/proxmox/network-apply-read.ts +10 -10
  206. package/src/proxmox/network-apply.ts +10 -10
  207. package/src/proxmox/node-network-form.ts +7 -7
  208. package/src/proxmox/node-network.ts +18 -18
  209. package/src/proxmox/notification-target.ts +3 -2
  210. package/src/proxmox/pbs-datastore-guard.ts +1 -1
  211. package/src/proxmox/pbs-datastore.ts +10 -10
  212. package/src/proxmox/pbs-prune-job.ts +2 -2
  213. package/src/proxmox/pbs-sync-job.ts +4 -4
  214. package/src/proxmox/pbs-verify-job.ts +1 -1
  215. package/src/proxmox/pool.test.ts +2 -2
  216. package/src/proxmox/pool.ts +4 -4
  217. package/src/proxmox/provision-baseline.test.ts +87 -0
  218. package/src/proxmox/provision-baseline.ts +162 -0
  219. package/src/proxmox/provision-bootstrap.test.ts +132 -0
  220. package/src/proxmox/provision-bootstrap.ts +135 -0
  221. package/src/proxmox/provision-cli-fake.ts +217 -0
  222. package/src/proxmox/provision-declare.test.ts +111 -0
  223. package/src/proxmox/provision-declare.ts +103 -0
  224. package/src/proxmox/replication-job.ts +9 -8
  225. package/src/proxmox/resource.ts +3 -3
  226. package/src/proxmox/sdn-apply-read.test.ts +16 -16
  227. package/src/proxmox/sdn-apply-read.ts +7 -7
  228. package/src/proxmox/sdn-apply.ts +3 -3
  229. package/src/proxmox/sdn-subnet-form.ts +1 -1
  230. package/src/proxmox/sdn-subnet.ts +5 -5
  231. package/src/proxmox/sdn-vnet.ts +5 -6
  232. package/src/proxmox/storage.ts +4 -3
  233. package/src/proxmox/user.ts +3 -2
  234. package/src/proxmox/values.test.ts +2 -2
  235. package/src/proxmox/values.ts +1 -1
  236. package/src/proxmox/zfs-pool-write.ts +1 -1
  237. package/src/proxmox/zfs-pool.ts +6 -6
  238. package/src/talos/credentials.ts +3 -3
  239. package/src/talos/values.test.ts +9 -9
  240. package/src/verify/cli.test.ts +1 -1
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The provisioning baseline: the access every PVE cluster and node needs before a stack built on
3
+ * this package can manage it — one role, the users OpenBao mints tokens for, the group that fences
4
+ * the mint, and the grants that bind them. ONE description, read by both halves:
5
+ * · provision-declare.ts turns it into `Proxmox.Role`/`Group`/`User`/`Acl` resources, so a stack
6
+ * adopts the baseline and keeps it (drift is a plan, not a 403 weeks later);
7
+ * · provision-bootstrap.ts turns it into the one-time root commands for a NEW cluster or node.
8
+ * The two cannot drift apart because neither holds a name or a privilege of its own.
9
+ *
10
+ * ⛔ THE PROVISION LANE CANNOT CREATE ITSELF. Every write here runs as a token OpenBao mints for the
11
+ * provision user, and minting needs that user to exist, in the mint group, bound to this role.
12
+ * So a new cluster is bootstrapped ONCE by root@pam with the generated commands, and the stack
13
+ * then adopts what they made (docs/provision-baseline.md).
14
+ *
15
+ * ★ ONE PRIVILEGE LIST FOR EVERY FAMILY, NOT A ROLE PER RESOURCE. The list is what the families'
16
+ * reconciles need, unioned — the set a hand-kept provision role reached one 403 at a time, each
17
+ * step recorded where it was hit (pool.ts, sdn-vnet.ts, ha-rule.ts). It includes what the lane needs
18
+ * to manage THIS baseline (`Sys.Modify` for the role, `Realm.AllocateUser`/`User.Modify` for the
19
+ * users, `Group.Allocate`, `Permissions.Modify` for the grants), so declaring it can never narrow
20
+ * the lane out of its own repair (role.ts: "A ROLE CAN LOCK ITS OWN PROVIDER OUT").
21
+ * ⚠️ NOT EVERY FAMILY: root@pam-only writes (device passthrough, bind mounts — lxc-judge.ts) and
22
+ * privileges a family names as still missing (`VM.Replicate`, replication-job.ts) are outside it.
23
+ */
24
+
25
+ /** The provision role's privileges, sorted — the exact set, never an addition to a live one. */
26
+ export const PROVISION_PRIVILEGES: readonly string[] = Object.freeze([
27
+ 'Datastore.Allocate',
28
+ 'Datastore.AllocateSpace',
29
+ 'Datastore.Audit',
30
+ 'Group.Allocate',
31
+ 'Mapping.Audit',
32
+ 'Mapping.Modify',
33
+ 'Permissions.Modify',
34
+ 'Pool.Allocate',
35
+ 'Pool.Audit',
36
+ 'Realm.AllocateUser',
37
+ 'SDN.Allocate',
38
+ 'SDN.Audit',
39
+ 'SDN.Use',
40
+ 'Sys.Audit',
41
+ 'Sys.Console',
42
+ 'Sys.Modify',
43
+ 'User.Modify',
44
+ 'VM.Allocate',
45
+ 'VM.Audit',
46
+ 'VM.Backup',
47
+ 'VM.Config.CPU',
48
+ 'VM.Config.Disk',
49
+ 'VM.Config.HWType',
50
+ 'VM.Config.Memory',
51
+ 'VM.Config.Network',
52
+ 'VM.Config.Options',
53
+ 'VM.PowerMgmt',
54
+ ]);
55
+
56
+ /** The names a baseline uses. Every field has a generic default (`PROVISION_DEFAULTS`). */
57
+ export interface ProvisionNames {
58
+ /** The role holding `PROVISION_PRIVILEGES` on `/`. */
59
+ readonly role?: string | undefined;
60
+ /** The user OpenBao's `provision` role mints tokens for, realm-qualified. */
61
+ readonly provisionUser?: string | undefined;
62
+ /** The group whose members OpenBao may mint for — the fence around the mint. */
63
+ readonly mintGroup?: string | undefined;
64
+ /** The user OpenBao's `read` role mints for, or `null` for a baseline with no read lane. */
65
+ readonly readUser?: string | null | undefined;
66
+ /** The role the read user holds on `/`. ⛔ It must already exist: a built-in, or one you declare. */
67
+ readonly readRole?: string | undefined;
68
+ /** The comment on the group and both users — how a person on the node learns what they are. */
69
+ readonly comment?: string | undefined;
70
+ }
71
+
72
+ /** Every name, resolved: what `PROVISION_DEFAULTS` holds and what a baseline is built from. */
73
+ export type ResolvedProvisionNames = {
74
+ readonly [K in keyof ProvisionNames]-?: Exclude<ProvisionNames[K], undefined>;
75
+ };
76
+
77
+ /** ★ Generic, kit-prefixed names: nothing here is any one estate's. */
78
+ export const PROVISION_DEFAULTS: ResolvedProvisionNames = Object.freeze({
79
+ comment:
80
+ 'provision baseline from @homeflare/alchemy - OpenBao mints short-lived tokens for members',
81
+ mintGroup: 'hf-mint',
82
+ provisionUser: 'hf-provision@pve',
83
+ readRole: 'PVEAuditor',
84
+ readUser: 'hf-read@pve',
85
+ role: 'HfProvisioner',
86
+ });
87
+
88
+ /** Which credential lane a user or a grant belongs to — the OpenBao role that mints for it. */
89
+ export type ProvisionLane = 'provision' | 'read';
90
+
91
+ /** A baseline, resolved: exactly what the declaration holds and the bootstrap makes. */
92
+ export interface ProvisionBaseline {
93
+ readonly role: { readonly roleid: string; readonly privs: readonly string[] };
94
+ readonly group: { readonly groupid: string; readonly comment: string };
95
+ readonly users: readonly {
96
+ readonly lane: ProvisionLane;
97
+ readonly userid: string;
98
+ readonly comment: string;
99
+ readonly groups: readonly string[];
100
+ }[];
101
+ /** Each binds one user to one role on `/`, with propagate — the only shape the lanes need. */
102
+ readonly grants: readonly {
103
+ readonly lane: ProvisionLane;
104
+ readonly userid: string;
105
+ readonly roleid: string;
106
+ }[];
107
+ }
108
+
109
+ /**
110
+ * ⛔ NAMES ARE CHECKED HERE, ONCE, FOR BOTH HALVES — and conservatively. The bootstrap pastes them
111
+ * into a root shell, so a name that would need quoting is refused rather than quoted: PVE's own
112
+ * ids (`pve-roleid`, `pve-groupid`, `pve-userid`) are narrower than what a shell tolerates.
113
+ */
114
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
115
+ const USERID = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z][A-Za-z0-9._-]*$/;
116
+ const COMMENT = /^[A-Za-z0-9 ._,()@/+-]*$/;
117
+
118
+ const problems = (names: ResolvedProvisionNames): string[] => {
119
+ const found: string[] = [];
120
+ if (!ID.test(names.role)) found.push(`role \`${names.role}\` is not a PVE role id`);
121
+ if (!ID.test(names.mintGroup)) found.push(`mintGroup \`${names.mintGroup}\` is not a group id`);
122
+ if (!ID.test(names.readRole)) found.push(`readRole \`${names.readRole}\` is not a PVE role id`);
123
+ for (const [field, userid] of [
124
+ ['provisionUser', names.provisionUser],
125
+ ['readUser', names.readUser],
126
+ ] as const) {
127
+ if (userid !== null && !USERID.test(userid)) {
128
+ found.push(`${field} \`${userid}\` is not a realm-qualified user id (name@realm)`);
129
+ }
130
+ }
131
+ if (names.readUser === names.provisionUser) found.push('readUser and provisionUser are one user');
132
+ if (!COMMENT.test(names.comment))
133
+ found.push('comment holds a character the bootstrap will not quote');
134
+ return found;
135
+ };
136
+
137
+ /** Resolve `names` over the defaults and check them. Throws with every problem at once. */
138
+ export const provisionBaseline = (names: ProvisionNames = {}): ProvisionBaseline => {
139
+ // ⚠️ An explicit `undefined` means "the default", not "blank": spread alone would keep it.
140
+ const given = Object.entries(names).filter(([, value]) => value !== undefined);
141
+ const resolved: ResolvedProvisionNames = {
142
+ ...PROVISION_DEFAULTS,
143
+ ...Object.fromEntries(given),
144
+ };
145
+ const found = problems(resolved);
146
+ if (found.length > 0) throw new Error(`provision baseline: ${found.join('; ')}`);
147
+ const lanes: [ProvisionLane, string, string][] = [
148
+ ['provision', resolved.provisionUser, resolved.role],
149
+ ];
150
+ if (resolved.readUser !== null) lanes.push(['read', resolved.readUser, resolved.readRole]);
151
+ return {
152
+ grants: lanes.map(([lane, userid, roleid]) => ({ lane, roleid, userid })),
153
+ group: { comment: resolved.comment, groupid: resolved.mintGroup },
154
+ role: { privs: PROVISION_PRIVILEGES, roleid: resolved.role },
155
+ users: lanes.map(([lane, userid]) => ({
156
+ comment: resolved.comment,
157
+ groups: [resolved.mintGroup],
158
+ lane,
159
+ userid,
160
+ })),
161
+ };
162
+ };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The generated bootstrap, RUN: under `sh`, with real Perl, against provision-cli-fake.ts's
3
+ * `pvesh`/`pveum`. What these pin: a fresh cluster gets exactly the baseline in an order PVE accepts;
4
+ * a second run (or a node joining a cluster that has it) writes nothing; drift converges with only
5
+ * the writes it needs; a read role that is missing stops the script before any write; and the
6
+ * script never makes a token or sets a password.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { type CliState, freshCluster, runScript } from './provision-cli-fake.ts';
10
+ import { PROVISION_DEFAULTS, PROVISION_PRIVILEGES } from './provision-baseline.ts';
11
+ import { provisionBootstrap } from './provision-bootstrap.ts';
12
+
13
+ const COMMENT = PROVISION_DEFAULTS.comment;
14
+ const lane = (groups: string[]) => ({
15
+ comment: COMMENT,
16
+ email: '',
17
+ enable: 1,
18
+ expire: 0,
19
+ firstname: '',
20
+ groups,
21
+ lastname: '',
22
+ });
23
+
24
+ /** What a fresh cluster holds after the default script: the baseline, and nothing else. */
25
+ const BOOTSTRAPPED: CliState = {
26
+ acl: [
27
+ { path: '/', propagate: 1, roleid: 'HfProvisioner', type: 'user', ugid: 'hf-provision@pve' },
28
+ { path: '/', propagate: 1, roleid: 'PVEAuditor', type: 'user', ugid: 'hf-read@pve' },
29
+ ],
30
+ groups: { 'hf-mint': { comment: COMMENT } },
31
+ roles: { ...freshCluster().roles, HfProvisioner: [...PROVISION_PRIVILEGES] },
32
+ users: { 'hf-provision@pve': lane(['hf-mint']), 'hf-read@pve': lane(['hf-mint']) },
33
+ };
34
+
35
+ describe('the script as text', () => {
36
+ const script = provisionBootstrap();
37
+
38
+ test('never makes a token or sets a password: its only writes are add/modify of the four', () => {
39
+ const calls = new Set(script.match(/pveum \w+ \w+/g));
40
+ expect([...calls].sort()).toEqual([
41
+ 'pveum acl modify',
42
+ 'pveum group add',
43
+ 'pveum group modify',
44
+ 'pveum role add',
45
+ 'pveum role modify',
46
+ 'pveum user add',
47
+ 'pveum user modify',
48
+ ]);
49
+ expect(script).not.toMatch(/--password|passwd|user token/);
50
+ expect(script).toStartWith('#!/bin/sh\n');
51
+ expect(script).toContain('\nset -eu\n');
52
+ });
53
+
54
+ test('carries the one privilege list, sorted, as the role it adds', () => {
55
+ expect(script).toContain(`want='${PROVISION_PRIVILEGES.join(',')}'`);
56
+ expect(script).toContain('pveum role add HfProvisioner --privs "$want"');
57
+ });
58
+ });
59
+
60
+ describe('run against a cluster', () => {
61
+ test('a fresh cluster gets exactly the baseline, role and group before what needs them', async () => {
62
+ const run = await runScript(provisionBootstrap(), freshCluster());
63
+ expect(run.stderr).toBe('');
64
+ expect(run.code).toBe(0);
65
+ expect(run.writes.map((w) => w.split(' --')[0])).toEqual([
66
+ 'pveum role add HfProvisioner',
67
+ 'pveum group add hf-mint',
68
+ 'pveum user add hf-provision@pve',
69
+ 'pveum user add hf-read@pve',
70
+ 'pveum acl modify /',
71
+ 'pveum acl modify /',
72
+ ]);
73
+ expect(run.state).toEqual(BOOTSTRAPPED);
74
+ });
75
+
76
+ test('a second run, or a node joining a cluster that has it, writes nothing', async () => {
77
+ const run = await runScript(provisionBootstrap(), structuredClone(BOOTSTRAPPED));
78
+ expect(run.code).toBe(0);
79
+ expect(run.writes).toEqual([]);
80
+ expect(run.stdout.split('\n').filter((line) => line.endsWith(': ok'))).toHaveLength(6);
81
+ });
82
+
83
+ test('drift converges with only the writes it needs', async () => {
84
+ const drifted = structuredClone(BOOTSTRAPPED);
85
+ drifted.roles['HfProvisioner'] = [...PROVISION_PRIVILEGES.slice(1), 'VM.Console'];
86
+ drifted.groups['admins'] = { comment: '' };
87
+ drifted.users['hf-read@pve'] = { ...lane(['admins', 'hf-mint']), email: 'x@example.com' };
88
+ drifted.acl = drifted.acl.slice(0, 1);
89
+ const run = await runScript(provisionBootstrap(), drifted);
90
+ expect(run.code).toBe(0);
91
+ expect(run.writes.map((w) => w.split(' --')[0])).toEqual([
92
+ 'pveum role modify HfProvisioner',
93
+ 'pveum user modify hf-read@pve',
94
+ 'pveum acl modify /',
95
+ ]);
96
+ expect(run.state).toEqual({ ...BOOTSTRAPPED, groups: drifted.groups });
97
+ });
98
+
99
+ test('a read role that does not exist stops the script before any write', async () => {
100
+ const run = await runScript(provisionBootstrap({ readRole: 'Auditor' }), freshCluster());
101
+ expect(run.code).toBe(1);
102
+ expect(run.stderr).toContain('role Auditor does not exist');
103
+ expect(run.writes).toEqual([]);
104
+ });
105
+
106
+ test('no read lane: one user, one grant, no read role needed', async () => {
107
+ const run = await runScript(provisionBootstrap({ readUser: null }), {
108
+ ...freshCluster(),
109
+ roles: {},
110
+ });
111
+ expect(run.code).toBe(0);
112
+ expect(Object.keys(run.state.users)).toEqual(['hf-provision@pve']);
113
+ expect(run.state.acl.map((g) => g.ugid)).toEqual(['hf-provision@pve']);
114
+ });
115
+
116
+ test("a site's own names flow through every step", async () => {
117
+ const names = {
118
+ mintGroup: 'mint',
119
+ provisionUser: 'provisioner@pve',
120
+ readUser: 'auditor@pve',
121
+ role: 'Provisioner',
122
+ };
123
+ const run = await runScript(provisionBootstrap(names), freshCluster());
124
+ expect(run.code).toBe(0);
125
+ expect(Object.keys(run.state.roles).sort()).toEqual(['PVEAuditor', 'Provisioner']);
126
+ expect(run.state.users['provisioner@pve']?.groups).toEqual(['mint']);
127
+ expect(run.state.acl.map((g) => `${g.ugid} ${g.roleid}`)).toEqual([
128
+ 'provisioner@pve Provisioner',
129
+ 'auditor@pve PVEAuditor',
130
+ ]);
131
+ });
132
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `provisionBootstrap` — the provisioning baseline (provision-baseline.ts) as the one-time root
3
+ * commands for a NEW cluster or node: a POSIX `sh` script, printed, never run from here.
4
+ *
5
+ * bun -e "import { provisionBootstrap } from '@homeflare/alchemy/proxmox';
6
+ * process.stdout.write(provisionBootstrap())" | ssh root@<node> sh
7
+ *
8
+ * ⛔ WHY IT EXISTS: THE PROVISION LANE CANNOT CREATE ITSELF. Every resource in this package runs as a
9
+ * token OpenBao mints for the provision user, and a cluster without that user, its group and its
10
+ * grant has nothing to mint for — the plan that would create them cannot authenticate. So root
11
+ * makes them once, and the stack adopts and keeps them (provision-declare.ts). This is the only
12
+ * hand step, and it is generated from the same baseline the stack declares.
13
+ * ⛔ NEVER A TOKEN, NEVER A PASSWORD. The users are created with neither: OpenBao mints short-lived
14
+ * tokens for them later, and an `@pve` user with no password cannot log in to the UI. Nothing
15
+ * secret is in the script, so it can be read, reviewed and pasted.
16
+ * ★ CHECK, THEN CHANGE — IDEMPOTENT BY CONSTRUCTION. Each object is read first (`pvesh get … json`,
17
+ * parsed with Perl's core JSON::PP, which every PVE node has), compared with the baseline, and
18
+ * written only when it differs, naming what it does. A second run prints `ok` for everything and
19
+ * writes nothing. It also CONVERGES: a role with other privileges is set to exactly the baseline,
20
+ * as the stack's `Proxmox.Role` would set it (role.ts: a declaration replaces the set).
21
+ * ★ ONE NODE PER CLUSTER. Users, groups, roles and ACLs live in `/etc/pve/user.cfg`, which pmxcfs
22
+ * shares across the cluster: a node joining a cluster that has the baseline needs nothing, and a
23
+ * run there is all `ok`. A new standalone node or a new cluster is where it creates.
24
+ * ⚠️ NAMES ARE NOT QUOTED INTO SAFETY, THEY ARE REFUSED INTO IT: provision-baseline.ts admits only
25
+ * characters that need no quoting in `sh` or in a Perl `q{}`.
26
+ */
27
+ import {
28
+ type ProvisionBaseline,
29
+ type ProvisionNames,
30
+ provisionBaseline,
31
+ } from './provision-baseline.ts';
32
+
33
+ const HEADER = `#!/bin/sh
34
+ # Provisioning baseline bootstrap, generated by @homeflare/alchemy (provisionBootstrap).
35
+ # Run ONCE, as root, on one node of a NEW cluster (or a new standalone node). Every step reads
36
+ # first and writes only what differs; a second run changes nothing. No token, no password.
37
+ set -eu
38
+ [ "$(id -u)" -eq 0 ] || { echo 'bootstrap: run this as root' >&2; exit 1; }
39
+ command -v pvesh >/dev/null 2>&1 || { echo 'bootstrap: no pvesh; run this on a PVE node' >&2; exit 1; }
40
+ perl -MJSON::PP -e 1 || { echo 'bootstrap: perl cannot load JSON::PP' >&2; exit 1; }
41
+
42
+ # get <api-path> <perl>: print <perl> evaluated over the object at <api-path> (as $d).
43
+ # Fails, silently, when the object does not exist: the empty input is not JSON.
44
+ get() {
45
+ pvesh get "$1" --output-format json 2>/dev/null | perl -MJSON::PP -0777 -ne "\\$d = decode_json(\\$_); $2" 2>/dev/null
46
+ }
47
+ `;
48
+
49
+ /** A Perl string literal with no interpolation — `@pve` in a double-quoted one is an array. */
50
+ const perlText = (text: string) => `q{${text}}`;
51
+
52
+ /** The user fields `Proxmox.User` compares, as one line both sides can produce. */
53
+ const USER_STATE =
54
+ 'my $g = $d->{groups} // []; $g = [split /,/, $g] unless ref $g; ' +
55
+ 'print join(";", "groups=" . join(",", sort @$g), "enable=" . (($d->{enable} // 1) ? 1 : 0), ' +
56
+ '"expire=" . ($d->{expire} // 0), "email=" . ($d->{email} // ""), ' +
57
+ '"firstname=" . ($d->{firstname} // ""), "lastname=" . ($d->{lastname} // ""), ' +
58
+ '"comment=" . ($d->{comment} // ""))';
59
+
60
+ const roleStep = ({ role }: ProvisionBaseline): string => {
61
+ const privs = [...role.privs].sort().join(',');
62
+ return `
63
+ # Role ${role.roleid}: exactly the provision privileges, on no path yet.
64
+ want='${privs}'
65
+ if have=$(get /access/roles/${role.roleid} 'print join(",", sort grep { /\\./ } keys %$d)'); then
66
+ if [ "$have" = "$want" ]; then echo 'role ${role.roleid}: ok'
67
+ else echo 'role ${role.roleid}: setting the baseline privileges'; pveum role modify ${role.roleid} --privs "$want"; fi
68
+ else
69
+ echo 'role ${role.roleid}: adding'; pveum role add ${role.roleid} --privs "$want"
70
+ fi
71
+ `;
72
+ };
73
+
74
+ const groupStep = ({ group }: ProvisionBaseline): string => `
75
+ # Group ${group.groupid}: OpenBao may mint only for its members.
76
+ if have=$(get /access/groups/${group.groupid} 'print $d->{comment} // ""'); then
77
+ if [ "$have" = '${group.comment}' ]; then echo 'group ${group.groupid}: ok'
78
+ else echo 'group ${group.groupid}: setting the comment'; pveum group modify ${group.groupid} --comment '${group.comment}'; fi
79
+ else
80
+ echo 'group ${group.groupid}: adding'; pveum group add ${group.groupid} --comment '${group.comment}'
81
+ fi
82
+ `;
83
+
84
+ const userStep = (user: ProvisionBaseline['users'][number]): string => {
85
+ const groups = [...user.groups].sort().join(',');
86
+ const want = `groups=${groups};enable=1;expire=0;email=;firstname=;lastname=;comment=${user.comment}`;
87
+ const fields = `--groups ${groups} --comment '${user.comment}'`;
88
+ return `
89
+ # User ${user.userid} (the ${user.lane} lane): in ${groups}, enabled, no password.
90
+ if have=$(get /access/users/${user.userid} '${USER_STATE}'); then
91
+ if [ "$have" = '${want}' ]; then echo 'user ${user.userid}: ok'
92
+ else echo 'user ${user.userid}: setting groups, comment and account fields'
93
+ pveum user modify ${user.userid} ${fields} --enable 1 --expire 0 --email '' --firstname '' --lastname ''; fi
94
+ else
95
+ echo 'user ${user.userid}: adding'; pveum user add ${user.userid} ${fields}
96
+ fi
97
+ `;
98
+ };
99
+
100
+ const grantStep = (grant: ProvisionBaseline['grants'][number]): string => {
101
+ const match =
102
+ `print scalar grep { $_->{path} eq "/" && $_->{type} eq "user" && ` +
103
+ `$_->{ugid} eq ${perlText(grant.userid)} && $_->{roleid} eq ${perlText(grant.roleid)} && ` +
104
+ `($_->{propagate} // 1) } @$d`;
105
+ return `
106
+ # Grant: ${grant.userid} holds ${grant.roleid} on /, propagated (the ${grant.lane} lane).
107
+ if [ "$(get /access/acl '${match}')" = 1 ]; then echo 'grant ${grant.userid} ${grant.roleid} /: ok'
108
+ else echo 'grant ${grant.userid} ${grant.roleid} /: adding'; pveum acl modify / --users ${grant.userid} --roles ${grant.roleid} --propagate 1; fi
109
+ `;
110
+ };
111
+
112
+ /** A role the baseline binds but does not own — the read lane's — must already exist. */
113
+ const requireRole = (roleid: string): string => `
114
+ get /access/roles/${roleid} 'print 1' >/dev/null || { echo 'bootstrap: role ${roleid} does not exist; create it, or name another readRole' >&2; exit 1; }
115
+ `;
116
+
117
+ /**
118
+ * The script for `names` (defaults: `PROVISION_DEFAULTS`). Pure: it reads nothing and runs nothing.
119
+ * Throws, like `provisionBaseline`, on a name the baseline refuses.
120
+ */
121
+ export const provisionBootstrap = (names: ProvisionNames = {}): string => {
122
+ const baseline = provisionBaseline(names);
123
+ const borrowed = baseline.grants
124
+ .map((grant) => grant.roleid)
125
+ .filter((roleid) => roleid !== baseline.role.roleid);
126
+ return [
127
+ HEADER,
128
+ ...[...new Set(borrowed)].map(requireRole),
129
+ roleStep(baseline),
130
+ groupStep(baseline),
131
+ ...baseline.users.map(userStep),
132
+ ...baseline.grants.map(grantStep),
133
+ `\necho 'provision baseline: done. Next: point the OpenBao proxmox mount at these users, then deploy the stack that declares this baseline with --adopt.'\n`,
134
+ ].join('');
135
+ };
@@ -0,0 +1,217 @@
1
+ /**
2
+ * A fake `pvesh` / `pveum` / `id` for the bootstrap tests: the generated script runs for real, under
3
+ * `sh`, with real Perl parsing the JSON, against an access config held in one JSON file — so what is
4
+ * tested is the script a person pipes to a node, not a model of it.
5
+ *
6
+ * ⛔ TEST-ONLY. No provider imports this file. Run as a program (`import.meta.main`), it IS the fake
7
+ * CLI; imported, it is the harness that writes the wrappers and runs a script against them.
8
+ * ★ IT REFUSES WHAT PVE REFUSES WHERE ORDER MATTERS: adding an object that exists, modifying one
9
+ * that does not, a user in a group that does not exist, a grant of a role that does not exist.
10
+ * A script that runs its steps in the wrong order fails here as it would on a node.
11
+ * ★ READS ANSWER IN THE ITEM SHAPES access/*.ts DOCUMENT: a role is a privilege map, a user's
12
+ * groups an array, empty strings omitted. `apiView` is also what the engine test's fake PVE
13
+ * serves, so the bootstrap's result and the declaration are compared on one picture.
14
+ */
15
+ import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ export type CliUser = {
20
+ comment: string;
21
+ email: string;
22
+ enable: number;
23
+ expire: number;
24
+ firstname: string;
25
+ lastname: string;
26
+ groups: string[];
27
+ };
28
+ export type CliGrant = {
29
+ path: string;
30
+ type: string;
31
+ ugid: string;
32
+ roleid: string;
33
+ propagate: number;
34
+ };
35
+ export type CliState = {
36
+ roles: Record<string, string[]>;
37
+ groups: Record<string, { comment: string }>;
38
+ users: Record<string, CliUser>;
39
+ acl: CliGrant[];
40
+ };
41
+
42
+ /** A cluster as PVE installs it: the built-in auditor role, and nothing of the baseline. */
43
+ export const freshCluster = (): CliState => ({
44
+ acl: [],
45
+ groups: {},
46
+ roles: {
47
+ PVEAuditor: [
48
+ 'Datastore.Audit',
49
+ 'Mapping.Audit',
50
+ 'Pool.Audit',
51
+ 'SDN.Audit',
52
+ 'Sys.Audit',
53
+ 'VM.Audit',
54
+ ],
55
+ },
56
+ users: {},
57
+ });
58
+
59
+ const text = (key: string, value: string) => (value === '' ? {} : { [key]: value });
60
+
61
+ /** Every item GET the scripts and the providers make, keyed `access/…` without a leading slash. */
62
+ export const apiView = (state: CliState): Record<string, unknown> => {
63
+ const view: Record<string, unknown> = { 'access/acl': state.acl };
64
+ for (const [id, privs] of Object.entries(state.roles)) {
65
+ view[`access/roles/${id}`] = Object.fromEntries(privs.map((priv) => [priv, 1]));
66
+ }
67
+ for (const [id, group] of Object.entries(state.groups)) {
68
+ const members = Object.keys(state.users).filter((u) => state.users[u]?.groups.includes(id));
69
+ view[`access/groups/${id}`] = { ...text('comment', group.comment), members };
70
+ }
71
+ for (const [id, user] of Object.entries(state.users)) {
72
+ view[`access/users/${id}`] = {
73
+ ...text('comment', user.comment),
74
+ ...text('email', user.email),
75
+ ...text('firstname', user.firstname),
76
+ ...text('lastname', user.lastname),
77
+ enable: user.enable,
78
+ expire: user.expire,
79
+ groups: user.groups,
80
+ tokens: {},
81
+ };
82
+ }
83
+ return view;
84
+ };
85
+
86
+ const flags = (args: string[]) => {
87
+ const out: Record<string, string> = {};
88
+ for (let i = 0; i < args.length; i += 2)
89
+ out[(args[i] ?? '').replace(/^--/, '')] = args[i + 1] ?? '';
90
+ return out;
91
+ };
92
+
93
+ /** One pveum call against `state`; returns the refusal PVE would print, or undefined. */
94
+ const pveum = (state: CliState, [kind, verb, id = '', ...rest]: string[]): string | undefined => {
95
+ const f = flags(rest);
96
+ const list = (csv: string | undefined) => (csv ?? '').split(',').filter((s) => s !== '');
97
+ if (kind === 'role') {
98
+ if ((verb === 'add') === id in state.roles) return `role '${id}': wrong state for ${verb}`;
99
+ state.roles[id] = list(f['privs']);
100
+ } else if (kind === 'group') {
101
+ if ((verb === 'add') === id in state.groups) return `group '${id}': wrong state for ${verb}`;
102
+ state.groups[id] = { comment: f['comment'] ?? '' };
103
+ } else if (kind === 'user') {
104
+ const had = state.users[id];
105
+ if ((verb === 'add') === (had !== undefined)) return `user '${id}': wrong state for ${verb}`;
106
+ const groups = f['groups'] === undefined ? (had?.groups ?? []) : list(f['groups']);
107
+ const missing = groups.find((g) => !(g in state.groups));
108
+ if (missing !== undefined) return `no such group '${missing}'`;
109
+ const base: CliUser = had ?? {
110
+ comment: '',
111
+ email: '',
112
+ enable: 1,
113
+ expire: 0,
114
+ firstname: '',
115
+ lastname: '',
116
+ groups: [],
117
+ };
118
+ state.users[id] = {
119
+ comment: f['comment'] ?? base.comment,
120
+ email: f['email'] ?? base.email,
121
+ enable: f['enable'] === undefined ? base.enable : Number(f['enable']),
122
+ expire: f['expire'] === undefined ? base.expire : Number(f['expire']),
123
+ firstname: f['firstname'] ?? base.firstname,
124
+ groups,
125
+ lastname: f['lastname'] ?? base.lastname,
126
+ };
127
+ } else if (kind === 'acl' && verb === 'modify') {
128
+ const roleid = f['roles'] ?? '';
129
+ if (!(roleid in state.roles)) return `role '${roleid}' does not exist`;
130
+ const grant = {
131
+ path: id,
132
+ propagate: Number(f['propagate'] ?? 1),
133
+ roleid,
134
+ type: 'user',
135
+ ugid: f['users'] ?? '',
136
+ };
137
+ state.acl = state.acl.filter(
138
+ (g) => !(g.path === id && g.ugid === grant.ugid && g.roleid === roleid),
139
+ );
140
+ state.acl.push(grant);
141
+ } else return `unknown pveum call: ${kind} ${verb}`;
142
+ return undefined;
143
+ };
144
+
145
+ /** The fake CLI's entry point: `<tool> <args…>`, with FAKE_PVE_STATE and FAKE_PVE_LOG set. */
146
+ const runCli = ([tool, ...args]: string[]): number => {
147
+ if (tool === 'id') {
148
+ process.stdout.write('0\n');
149
+ return 0;
150
+ }
151
+ const file = process.env['FAKE_PVE_STATE'] ?? '';
152
+ const state = JSON.parse(readFileSync(file, 'utf8')) as CliState;
153
+ if (tool === 'pvesh') {
154
+ const found = apiView(state)[(args[1] ?? '').replace(/^\//, '')];
155
+ if (found === undefined) {
156
+ process.stderr.write(`no such object ${args[1] ?? ''}\n`);
157
+ return 2;
158
+ }
159
+ process.stdout.write(JSON.stringify(found));
160
+ return 0;
161
+ }
162
+ appendFileSync(process.env['FAKE_PVE_LOG'] ?? '', `pveum ${args.join(' ')}\n`);
163
+ const refused = pveum(state, args);
164
+ if (refused !== undefined) {
165
+ process.stderr.write(`${refused}\n`);
166
+ return 255;
167
+ }
168
+ writeFileSync(file, JSON.stringify(state));
169
+ return 0;
170
+ };
171
+
172
+ if (import.meta.main) process.exit(runCli(process.argv.slice(2)));
173
+
174
+ export type ScriptRun = {
175
+ code: number;
176
+ stdout: string;
177
+ stderr: string;
178
+ writes: string[];
179
+ state: CliState;
180
+ };
181
+
182
+ /** Run `script` under `sh` against `state`, with the fakes first on PATH. */
183
+ export const runScript = async (script: string, state: CliState): Promise<ScriptRun> => {
184
+ const dir = mkdtempSync(join(tmpdir(), 'hf-bootstrap-'));
185
+ for (const tool of ['id', 'pvesh', 'pveum']) {
186
+ const wrapper = `#!/bin/sh\nexec '${process.execPath}' '${import.meta.path}' ${tool} "$@"\n`;
187
+ writeFileSync(join(dir, tool), wrapper, { mode: 0o755 });
188
+ }
189
+ const stateFile = join(dir, 'state.json');
190
+ const log = join(dir, 'pveum.log');
191
+ writeFileSync(stateFile, JSON.stringify(state));
192
+ writeFileSync(log, '');
193
+ try {
194
+ const child = Bun.spawn(['sh', '-s'], {
195
+ env: {
196
+ FAKE_PVE_LOG: log,
197
+ FAKE_PVE_STATE: stateFile,
198
+ PATH: `${dir}:${process.env['PATH'] ?? ''}`,
199
+ },
200
+ stderr: 'pipe',
201
+ stdin: new TextEncoder().encode(script),
202
+ stdout: 'pipe',
203
+ });
204
+ const [stdout, stderr, code] = await Promise.all([
205
+ new Response(child.stdout).text(),
206
+ new Response(child.stderr).text(),
207
+ child.exited,
208
+ ]);
209
+ const writes = readFileSync(log, 'utf8')
210
+ .split('\n')
211
+ .filter((line) => line !== '');
212
+ const after = JSON.parse(readFileSync(stateFile, 'utf8')) as CliState;
213
+ return { code, state: after, stderr, stdout, writes };
214
+ } finally {
215
+ rmSync(dir, { force: true, recursive: true });
216
+ }
217
+ };