@homeflare/alchemy 0.13.0 → 0.15.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 (112) hide show
  1. package/README.md +7 -0
  2. package/dist/github/index.d.ts +18 -0
  3. package/dist/github/index.d.ts.map +1 -0
  4. package/dist/github/index.js +161 -0
  5. package/dist/github/index.js.map +12 -0
  6. package/dist/github/repo-policy-form.d.ts +86 -0
  7. package/dist/github/repo-policy-form.d.ts.map +1 -0
  8. package/dist/github/repo-policy-guards.d.ts +72 -0
  9. package/dist/github/repo-policy-guards.d.ts.map +1 -0
  10. package/dist/github/repo-policy.d.ts +21 -0
  11. package/dist/github/repo-policy.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/openbao/index.d.ts +10 -3
  15. package/dist/openbao/index.d.ts.map +1 -1
  16. package/dist/proxmox/acl.d.ts.map +1 -1
  17. package/dist/proxmox/backup-job.d.ts.map +1 -1
  18. package/dist/proxmox/constraint-guard.d.ts +28 -0
  19. package/dist/proxmox/constraint-guard.d.ts.map +1 -0
  20. package/dist/proxmox/constraints.d.ts +71 -0
  21. package/dist/proxmox/constraints.d.ts.map +1 -0
  22. package/dist/proxmox/firewall-alias.d.ts.map +1 -1
  23. package/dist/proxmox/generated/constraints/index.d.ts +19 -0
  24. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -0
  25. package/dist/proxmox/generated/constraints/pbs-config.d.ts +18 -0
  26. package/dist/proxmox/generated/constraints/pbs-config.d.ts.map +1 -0
  27. package/dist/proxmox/generated/constraints/pve-access.d.ts +18 -0
  28. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -0
  29. package/dist/proxmox/generated/constraints/pve-cluster.d.ts +18 -0
  30. package/dist/proxmox/generated/constraints/pve-cluster.d.ts.map +1 -0
  31. package/dist/proxmox/generated/constraints/pve-pools.d.ts +18 -0
  32. package/dist/proxmox/generated/constraints/pve-pools.d.ts.map +1 -0
  33. package/dist/proxmox/generated/constraints/pve-storage.d.ts +18 -0
  34. package/dist/proxmox/generated/constraints/pve-storage.d.ts.map +1 -0
  35. package/dist/proxmox/group.d.ts.map +1 -1
  36. package/dist/proxmox/ha-resource.d.ts.map +1 -1
  37. package/dist/proxmox/ha-rule.d.ts.map +1 -1
  38. package/dist/proxmox/index.d.ts +6 -1
  39. package/dist/proxmox/index.d.ts.map +1 -1
  40. package/dist/proxmox/index.js +816 -257
  41. package/dist/proxmox/index.js.map +33 -24
  42. package/dist/proxmox/notification-matcher.d.ts.map +1 -1
  43. package/dist/proxmox/pbs-datastore-endpoint.d.ts +22 -0
  44. package/dist/proxmox/pbs-datastore-endpoint.d.ts.map +1 -0
  45. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  46. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
  47. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
  48. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
  49. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
  50. package/dist/proxmox/pool.d.ts.map +1 -1
  51. package/dist/proxmox/provision-baseline.d.ts +30 -5
  52. package/dist/proxmox/provision-baseline.d.ts.map +1 -1
  53. package/dist/proxmox/replication-job.d.ts.map +1 -1
  54. package/dist/proxmox/resource-spec.d.ts +120 -0
  55. package/dist/proxmox/resource-spec.d.ts.map +1 -0
  56. package/dist/proxmox/resource.d.ts +10 -91
  57. package/dist/proxmox/resource.d.ts.map +1 -1
  58. package/dist/proxmox/role.d.ts.map +1 -1
  59. package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
  60. package/dist/proxmox/sdn-zone.d.ts.map +1 -1
  61. package/dist/proxmox/storage.d.ts.map +1 -1
  62. package/dist/proxmox/user.d.ts.map +1 -1
  63. package/docs/provision-baseline.md +34 -7
  64. package/docs/proxmox-lxc.md +40 -0
  65. package/docs/repo-policy-ruleset-hazards.md +65 -0
  66. package/docs/repo-policy.md +155 -0
  67. package/package.json +5 -1
  68. package/src/github/index.ts +23 -0
  69. package/src/github/repo-policy-form.test.ts +239 -0
  70. package/src/github/repo-policy-form.ts +173 -0
  71. package/src/github/repo-policy-guards.ts +179 -0
  72. package/src/github/repo-policy.test.ts +58 -0
  73. package/src/github/repo-policy.ts +65 -0
  74. package/src/index.ts +1 -0
  75. package/src/openbao/index-types.test.ts +87 -0
  76. package/src/openbao/index.ts +15 -3
  77. package/src/proxmox/acl.ts +2 -0
  78. package/src/proxmox/backup-job.ts +2 -0
  79. package/src/proxmox/constraint-guard.ts +53 -0
  80. package/src/proxmox/constraints.test.ts +260 -0
  81. package/src/proxmox/constraints.ts +135 -0
  82. package/src/proxmox/firewall-alias.ts +5 -0
  83. package/src/proxmox/generated/constraints/index.ts +31 -0
  84. package/src/proxmox/generated/constraints/pbs-config.ts +141 -0
  85. package/src/proxmox/generated/constraints/pve-access.ts +57 -0
  86. package/src/proxmox/generated/constraints/pve-cluster.ts +174 -0
  87. package/src/proxmox/generated/constraints/pve-pools.ts +26 -0
  88. package/src/proxmox/generated/constraints/pve-storage.ts +75 -0
  89. package/src/proxmox/group.ts +2 -0
  90. package/src/proxmox/ha-resource.ts +5 -0
  91. package/src/proxmox/ha-rule.ts +2 -0
  92. package/src/proxmox/index.ts +12 -0
  93. package/src/proxmox/lxc-interior.test.ts +61 -0
  94. package/src/proxmox/notification-matcher.ts +5 -0
  95. package/src/proxmox/pbs-datastore-endpoint.ts +29 -0
  96. package/src/proxmox/pbs-datastore.ts +8 -1
  97. package/src/proxmox/pbs-notification-matcher.ts +5 -0
  98. package/src/proxmox/pbs-prune-job.ts +2 -0
  99. package/src/proxmox/pbs-sync-job-form.ts +2 -0
  100. package/src/proxmox/pbs-verify-job.ts +2 -0
  101. package/src/proxmox/pool.ts +2 -0
  102. package/src/proxmox/provision-baseline.test.ts +38 -0
  103. package/src/proxmox/provision-baseline.ts +73 -14
  104. package/src/proxmox/provision-bootstrap.test.ts +51 -0
  105. package/src/proxmox/replication-job.ts +5 -0
  106. package/src/proxmox/resource-spec.ts +117 -0
  107. package/src/proxmox/resource.ts +50 -91
  108. package/src/proxmox/role.ts +2 -0
  109. package/src/proxmox/sdn-vnet.ts +2 -0
  110. package/src/proxmox/sdn-zone.ts +2 -0
  111. package/src/proxmox/storage.ts +2 -0
  112. package/src/proxmox/user.ts +2 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Everything `repoPolicy` refuses, and why. Extracted from repo-policy-form.ts so the
3
+ * shape stays readable and the reasoning stays whole.
4
+ *
5
+ * ⛔ FIVE DIFFERENT DOORS LEAD TO THE SAME FAILURE — auto-merge with nothing to wait for.
6
+ * `gh pr merge --auto` is a queue only while something is outstanding; with nothing
7
+ * outstanding GitHub merges the pull request on the spot. An empty `checks` is the
8
+ * obvious door. `enforcement: 'disabled'` or `'evaluate'` is the quiet one: the ruleset
9
+ * exists, the required checks are listed on it, and none of them block anything. Then
10
+ * three ways to end up with a ruleset that matches NO REF — which enforces on no ref
11
+ * while the GitHub UI still shows it as `active`: an empty `include`, a blank ref
12
+ * pattern, and an `exclude` that cancels every `include`. All five are refused.
13
+ *
14
+ * ★ THE REF DOORS WERE FOUND BY ADVERSARIAL REVIEW, NOT BY DESIGN. The first cut of this
15
+ * module guarded `include.length === 0` and spread `exclude` raw, so
16
+ * `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` and `include: [' ']`
17
+ * both sailed through with `allowAutoMerge: true` — the exact end state the empty-array
18
+ * guard exists to prevent, reached by a door beside it. Checked by execution, not by
19
+ * reading: see the `matches no ref` tests in repo-policy-form.test.ts.
20
+ */
21
+
22
+ /** A non-empty, trimmed identifier, or an error naming the field. */
23
+ export function requireName(field: string, value: string): string {
24
+ const trimmed = value.trim();
25
+ if (trimmed === '') throw new Error(`repoPolicy: ${field} is required`);
26
+ return trimmed;
27
+ }
28
+
29
+ /**
30
+ * Trim, de-duplicate, sort. ★ The sort is what makes a second deploy a no-op: GitHub
31
+ * stores the contexts in the order they were sent, so an unsorted list would diff against
32
+ * itself the first time a caller wrote the same contexts in a different order.
33
+ */
34
+ export function normalizeChecks(checks: readonly string[]): string[] {
35
+ const seen = new Set<string>();
36
+ for (const raw of checks) {
37
+ const context = raw.trim();
38
+ // ⚠️ A blank context is a typo that reads as coverage: GitHub happily stores it and
39
+ // no job ever reports it, so every pull request waits on a check that cannot come.
40
+ if (context === '') {
41
+ throw new Error(
42
+ 'repoPolicy: a required check context is blank — name it exactly as the job reports it',
43
+ );
44
+ }
45
+ seen.add(context);
46
+ }
47
+ return [...seen].sort();
48
+ }
49
+
50
+ /**
51
+ * Trim, de-duplicate, sort one list of ref patterns, refusing a blank one.
52
+ *
53
+ * ⚠️ A BLANK PATTERN IS NOT AN EMPTY LIST, AND THAT IS THE WHOLE TRAP. `[' ']` has
54
+ * length 1, so a guard that counts the array waves it through; GitHub then stores a ref
55
+ * condition that matches nothing. Same end state as `[]`, reached past the guard.
56
+ * ★ Sorted for the same reason `normalizeChecks` is: GitHub echoes the patterns back in
57
+ * the order they were sent, so an unsorted list diffs against itself the first time a
58
+ * caller writes the same patterns in a different order.
59
+ */
60
+ function normalizeRefs(field: string, patterns: readonly string[]): string[] {
61
+ const seen = new Set<string>();
62
+ for (const raw of patterns) {
63
+ const pattern = raw.trim();
64
+ if (pattern === '') {
65
+ throw new Error(
66
+ `repoPolicy: a ${field} ref pattern is blank — a ruleset that matches no ref enforces nothing.`,
67
+ );
68
+ }
69
+ seen.add(pattern);
70
+ }
71
+ return [...seen].sort();
72
+ }
73
+
74
+ /**
75
+ * ⛔ AN EMPTY `include` IS REFUSED. Alchemy defaults `conditions.include` to `['~ALL']`
76
+ * when it is absent, but an explicitly empty array is sent as an empty array, and a
77
+ * ruleset that matches no ref protects nothing while still reading as `active` in the
78
+ * GitHub UI — the worst possible combination, because it looks like it worked.
79
+ */
80
+ export function normalizeInclude(
81
+ include: readonly string[] | undefined,
82
+ fallback: readonly string[],
83
+ ): string[] {
84
+ if (include === undefined) return [...fallback];
85
+ if (include.length === 0) {
86
+ throw new Error(
87
+ 'repoPolicy: include is empty — a ruleset that matches no ref enforces nothing. Omit it for the default branch.',
88
+ );
89
+ }
90
+ return normalizeRefs('include', include);
91
+ }
92
+
93
+ /**
94
+ * ⛔ AN `exclude` THAT CANCELS EVERY `include` IS REFUSED. In a GitHub ruleset the
95
+ * exclusions win, so `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` is a
96
+ * ruleset over nothing — and it reads as a narrowing, which is what makes it worse than
97
+ * the empty array: the caller believes they scoped the policy, not switched it off.
98
+ *
99
+ * ⚠️ EXACT CANCELLATION ONLY, DELIBERATELY. GitHub's patterns are globs, so deciding in
100
+ * general whether some exclude swallows some include means implementing GitHub's
101
+ * matcher and being wrong about it quietly. This refuses the case that is decidable —
102
+ * every include pattern also appears verbatim in `exclude` — and leaves
103
+ * `exclude: ['refs/heads/*']` against `include: ['~DEFAULT_BRANCH']` to the operator.
104
+ * A narrower guard that is always right beats a broad one that false-refuses.
105
+ */
106
+ export function normalizeExclude(
107
+ exclude: readonly string[] | undefined,
108
+ include: readonly string[],
109
+ ): string[] {
110
+ const patterns = exclude === undefined ? [] : normalizeRefs('exclude', exclude);
111
+ if (patterns.length === 0) return patterns;
112
+ const excluded = new Set(patterns);
113
+ if (include.every((pattern) => excluded.has(pattern))) {
114
+ throw new Error(
115
+ [
116
+ 'repoPolicy: exclude cancels every include pattern, so the ruleset matches no ref and enforces nothing.',
117
+ `include ${JSON.stringify(include)} is fully covered by exclude ${JSON.stringify(patterns)}.`,
118
+ ].join(' '),
119
+ );
120
+ }
121
+ return patterns;
122
+ }
123
+
124
+ /**
125
+ * ⛔ `0` IS REFUSED, NOT TREATED AS "NO REVIEWS". A `pull_request` rule with zero
126
+ * approvals is the solo-maintainer shape — every change through a PR, no second person
127
+ * — and it only works with `require_extra_approval_for_unattributed_changes: false`.
128
+ * alchemy@2.0.0-beta.79's `Ruleset` cannot express that field, and GitHub defaults it
129
+ * to `true` when it is omitted (measured 2026-09-17), which blocks the very PRs the
130
+ * zero was meant to let through. Omitting `requiredApprovals` declares no rule at all,
131
+ * which is a different and honest thing.
132
+ */
133
+ export function resolveApprovals(requested: number | undefined): number | undefined {
134
+ if (requested === undefined) return undefined;
135
+ if (!Number.isInteger(requested) || requested < 1) {
136
+ throw new Error(
137
+ [
138
+ 'repoPolicy: requiredApprovals must be a whole number of 1 or more.',
139
+ 'Omit it to declare no pull_request rule.',
140
+ '0 is not that: it needs require_extra_approval_for_unattributed_changes: false,',
141
+ 'which alchemy@2.0.0-beta.79 cannot send and GitHub defaults to true.',
142
+ ].join(' '),
143
+ );
144
+ }
145
+ return requested;
146
+ }
147
+
148
+ /**
149
+ * ⚠️ THE TEST IS "SOMETHING OUTSTANDING", NOT "SOME CHECKS". A required review holds a
150
+ * pull request open just as a required check does, so `requiredApprovals` with an empty
151
+ * `checks` is a legitimate policy. What is refused is auto-merge over a ruleset that
152
+ * will not hold anything back.
153
+ */
154
+ export function assertAutoMergeWaits(input: {
155
+ readonly autoMerge: boolean;
156
+ readonly checkCount: number;
157
+ readonly approvals: number | undefined;
158
+ readonly enforcement: 'active' | 'disabled' | 'evaluate';
159
+ }): void {
160
+ if (!input.autoMerge) return;
161
+ if (input.enforcement !== 'active') {
162
+ throw new Error(
163
+ [
164
+ `repoPolicy: autoMerge with enforcement '${input.enforcement}' merges every pull request immediately.`,
165
+ 'A ruleset that is not active enforces none of its rules, so auto-merge has nothing to wait for.',
166
+ 'Pass autoMerge: false while the ruleset is being evaluated.',
167
+ ].join(' '),
168
+ );
169
+ }
170
+ if (input.checkCount === 0 && input.approvals === undefined) {
171
+ throw new Error(
172
+ [
173
+ 'repoPolicy: autoMerge with nothing required merges every pull request immediately.',
174
+ 'Name at least one always-reporting check context, or ask for requiredApprovals,',
175
+ 'or pass autoMerge: false for a repository that has no green run to require yet.',
176
+ ].join(' '),
177
+ );
178
+ }
179
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The declaring half, and the two docs that carry its traps.
3
+ *
4
+ * ★ WHY TEST A DOC. Both files ship in the tarball and are the only place a consumer is
5
+ * told that the vendor ruleset adopts nothing and that auto-merge with nothing required
6
+ * merges on the spot. A silent edit that drops either warning would fail nothing else
7
+ * in this repo, and would cost the consumer a duplicate ruleset or a PR merged on red.
8
+ * ⛔ THE HAZARDS LIVE IN A SIBLING FILE, AND THE POINTER IS TESTED TOO. Extracting them
9
+ * is only safe while `repo-policy.md` still sends the reader there; a dangling pointer
10
+ * would read as "no hazard" rather than as a broken link.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { declareRepoPolicy } from './index.ts';
14
+
15
+ const doc = await Bun.file(new URL('../../docs/repo-policy.md', import.meta.url)).text();
16
+ const hazards = await Bun.file(
17
+ new URL('../../docs/repo-policy-ruleset-hazards.md', import.meta.url),
18
+ ).text();
19
+
20
+ describe('declareRepoPolicy', () => {
21
+ test('is a function, not a factory that reaches GitHub at import time', () => {
22
+ // ⚠️ Importing this module must not construct a resource or an Octokit client: the
23
+ // barrel is loaded by the consumer smoke test with no credentials in the process.
24
+ expect(typeof declareRepoPolicy).toBe('function');
25
+ expect(declareRepoPolicy.length).toBe(2);
26
+ });
27
+ });
28
+
29
+ describe('docs/repo-policy.md', () => {
30
+ test('stays under the 200-line cap', () => {
31
+ expect(doc.split('\n').length).toBeLessThanOrEqual(200);
32
+ });
33
+
34
+ test('warns that auto-merge with nothing required merges immediately', () => {
35
+ expect(doc).toContain('auto-merge');
36
+ expect(doc).toMatch(/merges? (the pull request )?immediately/i);
37
+ });
38
+
39
+ test('sends the reader to the ruleset hazards, which is where they now live', () => {
40
+ expect(doc).toContain('repo-policy-ruleset-hazards.md');
41
+ expect(doc).toMatch(/cannot be adopted/i);
42
+ });
43
+ });
44
+
45
+ describe('docs/repo-policy-ruleset-hazards.md', () => {
46
+ test('stays under the 200-line cap', () => {
47
+ expect(hazards.split('\n').length).toBeLessThanOrEqual(200);
48
+ });
49
+
50
+ test('warns that the vendor ruleset cannot be adopted and duplicates instead', () => {
51
+ expect(hazards).toContain('createRepoRuleset');
52
+ expect(hazards).toContain('rulesets');
53
+ });
54
+
55
+ test('carries the preflight that keeps a first deploy from duplicating', () => {
56
+ expect(hazards).toContain('gh api repos/<owner>/<repo>/rulesets');
57
+ });
58
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `declareRepoPolicy` — the policy from repo-policy-form.ts, as resources.
3
+ *
4
+ * const policy = yield* declareRepoPolicy('api', {
5
+ * owner: 'my-org',
6
+ * repository: 'api',
7
+ * checks: ['ci', 'secret scan'],
8
+ * });
9
+ *
10
+ * Provide `GitHub.providers()`; this subpath adds no provider of its own.
11
+ *
12
+ * ⛔ THE RULESET CANNOT BE ADOPTED, AND A FIRST DEPLOY ONTO A REPO THAT ALREADY HAS ONE
13
+ * MAKES A SECOND. Read out of alchemy@2.0.0-beta.79's own source on 2026-09-22
14
+ * (`node_modules/alchemy/lib/github/Ruleset.js`): `read` returns `undefined` whenever
15
+ * there is no prior output, and `reconcile` looks the live ruleset up only BY THE ID IN
16
+ * THAT OUTPUT — with none, it calls `createRepoRuleset` unconditionally. GitHub permits
17
+ * several rulesets with the same name on one repository, so nothing errors; the repo
18
+ * quietly ends up with two, both enforcing.
19
+ * ★ SO, BEFORE THE FIRST DEPLOY: `gh api repos/<owner>/<repo>/rulesets` and delete a
20
+ * hand-made ruleset of the same name, or give this one a `rulesetName` of its own.
21
+ * Afterwards the state carries the id and every later deploy converges that one.
22
+ * ⚠️ `GitHub.Repository` does NOT share the problem — its `reconcile` probes by name
23
+ * and converges onto whatever is live — so only half of this helper is adopt-safe,
24
+ * and the halves fail differently. See docs/repo-policy.md.
25
+ *
26
+ * ⛔ BOTH RESOURCES RETAIN. `retain` is already the vendor default for each of them; it
27
+ * is piped anyway so that a vendor change cannot quietly turn "drop this call from the
28
+ * stack" into "delete the repository" or "unprotect the default branch".
29
+ */
30
+ import { adopt } from 'alchemy/AdoptPolicy';
31
+ import * as GitHub from 'alchemy/GitHub';
32
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
33
+ import * as Effect from 'effect/Effect';
34
+ import { type RepoPolicyOptions, repoPolicy } from './repo-policy-form.ts';
35
+
36
+ export interface DeclareRepoPolicyOptions extends RepoPolicyOptions {
37
+ /**
38
+ * `true`/`false` pipe `adopt(…)` onto both resources; omitted, the deploy's own policy
39
+ * decides. ⚠️ It changes nothing about the ruleset hazard above: adoption routes on
40
+ * what a provider's `read` reports, and this one reports nothing without prior state.
41
+ */
42
+ readonly adopt?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Declares the repository and its ruleset under `<id>` and `<id>-ruleset`. Fails, before
47
+ * declaring either, on any policy the form refuses.
48
+ */
49
+ export const declareRepoPolicy = (id: string, options: DeclareRepoPolicyOptions) =>
50
+ Effect.gen(function* () {
51
+ const policy = yield* Effect.try({
52
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
53
+ try: () => repoPolicy(options),
54
+ });
55
+ // ⚠️ `never` in the error channel, not a generic `E`: `AdoptPolicy.adopt` is typed
56
+ // `Effect<A, never, R> => Effect<A, never, R>`, so a wider error type fails to
57
+ // compile here rather than at the call site. Alchemy's resources declare `never`.
58
+ const owned = <A, R>(resource: Effect.Effect<A, never, R>) => {
59
+ const kept = resource.pipe(RemovalPolicy.retain());
60
+ return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
61
+ };
62
+ const repository = yield* owned(GitHub.Repository(id, policy.repository));
63
+ const ruleset = yield* owned(GitHub.Ruleset(`${id}-ruleset`, policy.ruleset));
64
+ return { policy, repository, ruleset };
65
+ });
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * import { R2BucketLock } from '@homeflare/alchemy/cloudflare';
8
8
  * import { BranchProtection } from '@homeflare/alchemy/forgejo';
9
+ * import { declareRepoPolicy } from '@homeflare/alchemy/github';
9
10
  * import { BaoMount } from '@homeflare/alchemy/openbao';
10
11
  * import { TalosCluster } from '@homeflare/alchemy/talos';
11
12
  * import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The barrel's TYPE surface, pinned.
3
+ *
4
+ * ⛔ THIS IS THE TEST FOR A BUG THAT ONLY A CONSUMER COULD SEE. Every `Bao.*` family already
5
+ * re-exported its `…Props` and `…Attributes` from its own module, but `index.ts` forwarded only
6
+ * the VALUES for seven of them. A consumer that may not deep-import — which is every consumer,
7
+ * by the estate's adoption rules — therefore could not name the props of the very Resource the
8
+ * barrel handed it, and had to write the shape out and hope it stayed in step.
9
+ * MEASURED 2026-09-22 in homeflare-openbao, which did exactly that for `BaoProxmoxRole`.
10
+ *
11
+ * ★ A TYPE-ONLY TEST, ON PURPOSE. Nothing here runs; `tsc --noEmit` is the assertion. If a family's
12
+ * props stop being reachable from the package entry, this file stops compiling, which is the
13
+ * only moment anyone would otherwise notice — at the next consumer, in another repo.
14
+ */
15
+ import type {
16
+ BaoAuthMethodAttributes,
17
+ BaoAuthMethodProps,
18
+ BaoAuthRoleAttributes,
19
+ BaoAuthRoleProps,
20
+ BaoCloudflareRoleAttributes,
21
+ BaoCloudflareRoleProps,
22
+ BaoJwtRoleAttributes,
23
+ BaoMountAttributes,
24
+ BaoMountProps,
25
+ BaoPkiRoleAttributes,
26
+ BaoPkiRoleProps,
27
+ BaoPluginAttributes,
28
+ BaoPluginProps,
29
+ BaoPolicyAttributes,
30
+ BaoPolicyProps,
31
+ BaoProxmoxRoleAttributes,
32
+ BaoProxmoxRoleProps,
33
+ BaoSshRoleAttributes,
34
+ BaoSshRoleProps,
35
+ } from './index.ts';
36
+ import { expect, test } from 'bun:test';
37
+
38
+ /**
39
+ * ⛔ The one a consumer was already missing. `mount` + `name` + `mintUser` + `ttl` + `maxTtl` IS the
40
+ * whole role, so a consumer that writes this shape out by hand is writing the entire
41
+ * server-side state of the family — including `mintUser`, which is its security boundary.
42
+ */
43
+ const proxmoxRole: BaoProxmoxRoleProps = {
44
+ maxTtl: '30m',
45
+ mintUser: 'hf-provision@pve',
46
+ mount: 'proxmox-ops',
47
+ name: 'provision',
48
+ ttl: '5m',
49
+ };
50
+
51
+ const mount: BaoMountProps = { path: 'proxmox-ops', type: 'openbao-plugin-secrets-proxmox' };
52
+
53
+ /** ⛔ The plugin catalog key — a consumer needs this to declare a registration at all. */
54
+ const plugin: BaoPluginProps = {
55
+ command: 'openbao-plugin-secrets-proxmox',
56
+ name: 'openbao-plugin-secrets-proxmox',
57
+ sha256: 'a'.repeat(64),
58
+ type: 'secret',
59
+ version: 'v0.0.0-dev',
60
+ };
61
+
62
+ /** ★ Named so the attribute types are reachable too: a consumer reads outputs as well as writing props. */
63
+ type Reachable =
64
+ | BaoAuthMethodAttributes
65
+ | BaoAuthMethodProps
66
+ | BaoAuthRoleAttributes
67
+ | BaoAuthRoleProps
68
+ | BaoCloudflareRoleAttributes
69
+ | BaoCloudflareRoleProps
70
+ | BaoJwtRoleAttributes
71
+ | BaoMountAttributes
72
+ | BaoPkiRoleAttributes
73
+ | BaoPkiRoleProps
74
+ | BaoProxmoxRoleAttributes
75
+ | BaoPluginAttributes
76
+ | BaoPolicyAttributes
77
+ | BaoPolicyProps
78
+ | BaoSshRoleAttributes
79
+ | BaoSshRoleProps;
80
+
81
+ test("every openbao family's props are reachable from the package entry", () => {
82
+ const reachable: Reachable[] = [];
83
+ expect(reachable).toEqual([]);
84
+ expect(proxmoxRole.mintUser).toBe('hf-provision@pve');
85
+ expect(mount.type).toBe('openbao-plugin-secrets-proxmox');
86
+ expect(plugin.version).toBe('v0.0.0-dev');
87
+ });
@@ -23,11 +23,13 @@ export {
23
23
  revokeSelf,
24
24
  revokeSelfEffect,
25
25
  } from './approle-login.ts';
26
+ export type { BaoAuthMethodAttributes, BaoAuthMethodProps } from './auth-method.ts';
26
27
  export { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
27
- export type { BaoAuthRoleProps } from './auth-role.ts';
28
+ export type { BaoAuthRoleAttributes, BaoAuthRoleProps } from './auth-role.ts';
28
29
  export { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
29
30
  export type { BaoIdentity, BaoIdentityExpected, BaoIdentityFailure } from './bao-identity.ts';
30
31
  export { BaoIdentityError, assertBaoIdentity, assertBaoIdentityEffect } from './bao-identity.ts';
32
+ export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps } from './cloudflare-role.ts';
31
33
  export { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
32
34
  export {
33
35
  CloudflarePermissionGroups,
@@ -42,7 +44,12 @@ export type { HostAppRolesInput, HostRoleClass, HostRoleHost } from './host-appr
42
44
  export { HOST_ROLE_SEPARATOR, hostAppRoles, hostRoleName } from './host-approles.ts';
43
45
  export type { BaoJwtAuthConfigProps } from './jwt-config.ts';
44
46
  export { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
45
- export type { BaoJwtRoleProps, BaoJwtRoleType } from './jwt-role.ts';
47
+ export type {
48
+ BaoJwtCallbackMode,
49
+ BaoJwtRoleAttributes,
50
+ BaoJwtRoleProps,
51
+ BaoJwtRoleType,
52
+ } from './jwt-role.ts';
46
53
  export { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
47
54
  export type { BaoKubernetesAliasSource, BaoKubernetesRoleProps } from './kubernetes-role.ts';
48
55
  export { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
@@ -50,10 +57,15 @@ export type { BaoMfaLoginEnforcementProps } from './mfa-enforcement.ts';
50
57
  export { BaoMfaLoginEnforcement, BaoMfaLoginEnforcementProvider } from './mfa-enforcement.ts';
51
58
  export type { BaoMfaTotpMethodProps, BaoTotpAlgorithm } from './mfa-totp.ts';
52
59
  export { BaoMfaTotpMethod, BaoMfaTotpMethodProvider } from './mfa-totp.ts';
60
+ export type { BaoMountAttributes, BaoMountProps } from './mount.ts';
53
61
  export { BaoMount, BaoMountProvider } from './mount.ts';
62
+ export type { BaoPkiRoleAttributes, BaoPkiRoleProps } from './pki-role.ts';
54
63
  export { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
55
- export type { BaoPluginProps, BaoPluginType } from './plugin.ts';
64
+ export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin.ts';
56
65
  export { BaoPlugin, BaoPluginProvider } from './plugin.ts';
66
+ export type { BaoPolicyAttributes, BaoPolicyProps } from './policy.ts';
57
67
  export { BaoPolicy, BaoPolicyProvider } from './policy.ts';
68
+ export type { BaoProxmoxRoleAttributes, BaoProxmoxRoleProps } from './proxmox-role.ts';
58
69
  export { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
70
+ export type { BaoSshRoleAttributes, BaoSshRoleProps } from './ssh-role.ts';
59
71
  export { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
@@ -189,6 +189,8 @@ const ops = pveOperations<AclProps, AclAttributes>({
189
189
  * the props they came from is true by construction. An identity change is a DIFFERENT grant,
190
190
  * which `diff` in the provider handles.
191
191
  */
192
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
193
+ endpoint: { create: 'pve:PUT /access/acl', update: 'pve:PUT /access/acl' },
192
194
  matches: (attributes, props) =>
193
195
  attributes.bound && attributes.propagate === (props.propagate !== false),
194
196
  path: () => 'access/acl',
@@ -173,6 +173,8 @@ const handlers = pveHandlers<BackupJobProps, BackupJobAttributes>({
173
173
  /** ⛔ `id` IS SENT AND IS NOT OPTIONAL. Everything above depends on PVE not inventing one. */
174
174
  createForm: (props) => ({ ...shape(props), id: props.id }),
175
175
  /** Each line reads "not declared, or equal"; the five defaulted fields have no undeclared case. */
176
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
177
+ endpoint: { create: 'pve:POST /cluster/backup', update: 'pve:PUT /cluster/backup/{id}' },
176
178
  matches: (attributes, props) =>
177
179
  attributes.schedule === props.schedule &&
178
180
  attributes.enabled === (props.enabled !== false) &&
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The generated tables, looked up by endpoint, and the refusal that stops a plan.
3
+ *
4
+ * ★ ONE FILE BETWEEN THE PROVIDERS AND THE GENERATED DATA, so `resource.ts` gains three lines
5
+ * rather than an import of two generated modules and a merge, and so a family that writes its
6
+ * own handlers (CephPool, PbsDatastore) reaches the same check by the same name.
7
+ *
8
+ * ⛔ AN UNKNOWN KEY IS A DEFECT, NOT A REASON TO SKIP THE CHECK. Silently passing would put us back
9
+ * where this started: a declaration nothing checked, and a plan that says so in no way at all.
10
+ * The generator resolves every key in its source scan against the vendor schema, so a key that
11
+ * is absent here means the tables were not regenerated — which the message says.
12
+ */
13
+ import * as Effect from 'effect/Effect';
14
+ import type { PveForm } from './client.ts';
15
+ import { type EndpointConstraints, type EndpointKey, refusal, violations } from './constraints.ts';
16
+ import { PROXMOX_CONSTRAINTS } from './generated/constraints/index.ts';
17
+
18
+ export { PROXMOX_CONSTRAINTS };
19
+
20
+ export const constraintsFor = (key: EndpointKey): EndpointConstraints => {
21
+ const table = PROXMOX_CONSTRAINTS[key];
22
+ if (table === undefined) {
23
+ throw new Error(
24
+ `no vendor constraint table for '${key}'. It is named in this package but not generated: ` +
25
+ 'run `bun codegen/constraints.ts` (see codegen/README.md).',
26
+ );
27
+ }
28
+ return table;
29
+ };
30
+
31
+ /** Every vendor rule this form breaks. Pure — a test calls it with a literal form. */
32
+ export const formViolations = (
33
+ key: EndpointKey,
34
+ form: PveForm,
35
+ presence: boolean,
36
+ ): readonly string[] => violations(constraintsFor(key), form, { presence });
37
+
38
+ /**
39
+ * ⛔ `Effect.die`, NOT `Effect.fail`. A declaration the vendor would reject is a DEFECT in the
40
+ * stack file, not a recoverable condition: there is no retry and no fallback that makes a
41
+ * 129-character comment legal. The same call that refuses a write-back with no read-back
42
+ * (resource.ts) dies for the same reason.
43
+ */
44
+ export const guardForm = (
45
+ key: EndpointKey | undefined,
46
+ form: PveForm,
47
+ presence: boolean,
48
+ ): Effect.Effect<void> =>
49
+ Effect.suspend(() => {
50
+ if (key === undefined) return Effect.void;
51
+ const found = formViolations(key, form, presence);
52
+ return found.length === 0 ? Effect.void : Effect.die(new Error(refusal(key, found)));
53
+ });