@homeflare/alchemy 0.12.0 → 0.14.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.
- package/README.md +7 -0
- package/dist/github/index.d.ts +18 -0
- package/dist/github/index.d.ts.map +1 -0
- package/dist/github/index.js +161 -0
- package/dist/github/index.js.map +12 -0
- package/dist/github/repo-policy-form.d.ts +86 -0
- package/dist/github/repo-policy-form.d.ts.map +1 -0
- package/dist/github/repo-policy-guards.d.ts +72 -0
- package/dist/github/repo-policy-guards.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +21 -0
- package/dist/github/repo-policy.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/proxmox/alertmanager-body.d.ts +54 -0
- package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +5 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +278 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +57 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +8 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +681 -183
- package/dist/proxmox/index.js.map +14 -4
- package/dist/proxmox/notification-matcher-form.d.ts +100 -0
- package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +46 -0
- package/dist/proxmox/notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target.d.ts +123 -0
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +4 -3
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopted-deploys.md +8 -0
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/proxmox.md +3 -0
- package/docs/repo-policy-ruleset-hazards.md +65 -0
- package/docs/repo-policy.md +155 -0
- package/package.json +5 -1
- package/src/github/index.ts +23 -0
- package/src/github/repo-policy-form.test.ts +239 -0
- package/src/github/repo-policy-form.ts +173 -0
- package/src/github/repo-policy-guards.ts +179 -0
- package/src/github/repo-policy.test.ts +58 -0
- package/src/github/repo-policy.ts +65 -0
- package/src/index.ts +1 -0
- package/src/proxmox/alertmanager-body.ts +125 -0
- package/src/proxmox/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/generated/pbs.ts +329 -0
- package/src/proxmox/generated/pve.ts +62 -0
- package/src/proxmox/index.test.ts +3 -0
- package/src/proxmox/index.ts +18 -0
- package/src/proxmox/notification-matcher-form.ts +221 -0
- package/src/proxmox/notification-matcher.test.ts +234 -0
- package/src/proxmox/notification-matcher.ts +76 -0
- package/src/proxmox/pbs-notification-matcher.ts +81 -0
- package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
- package/src/proxmox/pbs-notification-target-form.ts +229 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
- package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
- package/src/proxmox/pbs-notification-target-wire.ts +112 -0
- package/src/proxmox/pbs-notification-target.test.ts +226 -0
- package/src/proxmox/pbs-notification-target.ts +139 -0
- package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
- package/src/proxmox/resource.ts +6 -6
- package/src/proxmox/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- package/src/verify/fake-engine.ts +8 -1
package/README.md
CHANGED
|
@@ -162,6 +162,13 @@ with `HostFile`; `caddyProviders()` provides the transport, `http://127.0.0.1:20
|
|
|
162
162
|
PVE and PBS objects over the PVE API, `ProxmoxLxc` for containers, and the provisioning baseline
|
|
163
163
|
every cluster needs first. What it never does to a guest, and why: [docs/proxmox.md](./docs/proxmox.md).
|
|
164
164
|
|
|
165
|
+
## GitHub — `@homeflare/alchemy/github`
|
|
166
|
+
|
|
167
|
+
`declareRepoPolicy` declares one repository's merge policy and its default-branch ruleset in a
|
|
168
|
+
single call: squash-only, auto-merge, head branches deleted, no deletion or force pushes, the
|
|
169
|
+
checks you name required. ⛔ Auto-merge with nothing required merges **immediately**, and Alchemy's
|
|
170
|
+
`Ruleset` creates a duplicate rather than adopting one: [docs/repo-policy.md](./docs/repo-policy.md).
|
|
171
|
+
|
|
165
172
|
## Adopt verifier — `hf-adopt-verify` / `@homeflare/alchemy/verify`
|
|
166
173
|
|
|
167
174
|
⛔ A plan prints `adopted` for a match and for a drift alike. Before a gated deploy run
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub helpers for Alchemy — one repository's house policy in one call.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS SUBPATH SHIPS NO PROVIDER, AND DELIBERATELY SO. GitHub is first-class in
|
|
5
|
+
* Alchemy; everything here composes the vendor's own `Repository` and `Ruleset`.
|
|
6
|
+
* Provide `GitHub.providers()` exactly as you would without it.
|
|
7
|
+
* ★ WHY IT EXISTS ANYWAY. "Open a pull request, let auto-merge land it" is safe only when
|
|
8
|
+
* five repository properties and four ruleset rules all agree, and every way of getting
|
|
9
|
+
* one of them wrong is silent — an auto-merge that lands on red, a force push that
|
|
10
|
+
* rewrites the branch a release tagged, a required context nothing ever reports. One
|
|
11
|
+
* call puts the whole policy in one place, and one place is where it gets fixed.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ THIS BARREL IS THE PUBLIC API. `repo-policy-form.ts` is reachable by path if you
|
|
14
|
+
* genuinely need an internal — a deliberate act rather than an accident of barrelling.
|
|
15
|
+
*/
|
|
16
|
+
export { type RepoPolicy, type RepoPolicyBypassActor, type RepoPolicyOptions, type RepoPolicyRepositorySettings, repoPolicy, } from './repo-policy-form.ts';
|
|
17
|
+
export { type DeclareRepoPolicyOptions, declareRepoPolicy } from './repo-policy.ts';
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/github/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EACL,KAAK,UAAU,EACf,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,4BAA4B,EACjC,UAAU,GACX,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,KAAK,wBAAwB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// src/github/repo-policy-guards.ts
|
|
2
|
+
function requireName(field, value) {
|
|
3
|
+
const trimmed = value.trim();
|
|
4
|
+
if (trimmed === "")
|
|
5
|
+
throw new Error(`repoPolicy: ${field} is required`);
|
|
6
|
+
return trimmed;
|
|
7
|
+
}
|
|
8
|
+
function normalizeChecks(checks) {
|
|
9
|
+
const seen = new Set;
|
|
10
|
+
for (const raw of checks) {
|
|
11
|
+
const context = raw.trim();
|
|
12
|
+
if (context === "") {
|
|
13
|
+
throw new Error("repoPolicy: a required check context is blank — name it exactly as the job reports it");
|
|
14
|
+
}
|
|
15
|
+
seen.add(context);
|
|
16
|
+
}
|
|
17
|
+
return [...seen].sort();
|
|
18
|
+
}
|
|
19
|
+
function normalizeRefs(field, patterns) {
|
|
20
|
+
const seen = new Set;
|
|
21
|
+
for (const raw of patterns) {
|
|
22
|
+
const pattern = raw.trim();
|
|
23
|
+
if (pattern === "") {
|
|
24
|
+
throw new Error(`repoPolicy: a ${field} ref pattern is blank — a ruleset that matches no ref enforces nothing.`);
|
|
25
|
+
}
|
|
26
|
+
seen.add(pattern);
|
|
27
|
+
}
|
|
28
|
+
return [...seen].sort();
|
|
29
|
+
}
|
|
30
|
+
function normalizeInclude(include, fallback) {
|
|
31
|
+
if (include === undefined)
|
|
32
|
+
return [...fallback];
|
|
33
|
+
if (include.length === 0) {
|
|
34
|
+
throw new Error("repoPolicy: include is empty — a ruleset that matches no ref enforces nothing. Omit it for the default branch.");
|
|
35
|
+
}
|
|
36
|
+
return normalizeRefs("include", include);
|
|
37
|
+
}
|
|
38
|
+
function normalizeExclude(exclude, include) {
|
|
39
|
+
const patterns = exclude === undefined ? [] : normalizeRefs("exclude", exclude);
|
|
40
|
+
if (patterns.length === 0)
|
|
41
|
+
return patterns;
|
|
42
|
+
const excluded = new Set(patterns);
|
|
43
|
+
if (include.every((pattern) => excluded.has(pattern))) {
|
|
44
|
+
throw new Error([
|
|
45
|
+
"repoPolicy: exclude cancels every include pattern, so the ruleset matches no ref and enforces nothing.",
|
|
46
|
+
`include ${JSON.stringify(include)} is fully covered by exclude ${JSON.stringify(patterns)}.`
|
|
47
|
+
].join(" "));
|
|
48
|
+
}
|
|
49
|
+
return patterns;
|
|
50
|
+
}
|
|
51
|
+
function resolveApprovals(requested) {
|
|
52
|
+
if (requested === undefined)
|
|
53
|
+
return;
|
|
54
|
+
if (!Number.isInteger(requested) || requested < 1) {
|
|
55
|
+
throw new Error([
|
|
56
|
+
"repoPolicy: requiredApprovals must be a whole number of 1 or more.",
|
|
57
|
+
"Omit it to declare no pull_request rule.",
|
|
58
|
+
"0 is not that: it needs require_extra_approval_for_unattributed_changes: false,",
|
|
59
|
+
"which alchemy@2.0.0-beta.79 cannot send and GitHub defaults to true."
|
|
60
|
+
].join(" "));
|
|
61
|
+
}
|
|
62
|
+
return requested;
|
|
63
|
+
}
|
|
64
|
+
function assertAutoMergeWaits(input) {
|
|
65
|
+
if (!input.autoMerge)
|
|
66
|
+
return;
|
|
67
|
+
if (input.enforcement !== "active") {
|
|
68
|
+
throw new Error([
|
|
69
|
+
`repoPolicy: autoMerge with enforcement '${input.enforcement}' merges every pull request immediately.`,
|
|
70
|
+
"A ruleset that is not active enforces none of its rules, so auto-merge has nothing to wait for.",
|
|
71
|
+
"Pass autoMerge: false while the ruleset is being evaluated."
|
|
72
|
+
].join(" "));
|
|
73
|
+
}
|
|
74
|
+
if (input.checkCount === 0 && input.approvals === undefined) {
|
|
75
|
+
throw new Error([
|
|
76
|
+
"repoPolicy: autoMerge with nothing required merges every pull request immediately.",
|
|
77
|
+
"Name at least one always-reporting check context, or ask for requiredApprovals,",
|
|
78
|
+
"or pass autoMerge: false for a repository that has no green run to require yet."
|
|
79
|
+
].join(" "));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/github/repo-policy-form.ts
|
|
84
|
+
var DEFAULT_RULESET_NAME = "main";
|
|
85
|
+
var DEFAULT_INCLUDE = ["~DEFAULT_BRANCH"];
|
|
86
|
+
function repoPolicy(options) {
|
|
87
|
+
const owner = requireName("owner", options.owner);
|
|
88
|
+
const repository = requireName("repository", options.repository);
|
|
89
|
+
const checks = normalizeChecks(options.checks);
|
|
90
|
+
const include = normalizeInclude(options.include, DEFAULT_INCLUDE);
|
|
91
|
+
const exclude = normalizeExclude(options.exclude, include);
|
|
92
|
+
const autoMerge = options.autoMerge ?? true;
|
|
93
|
+
const enforcement = options.enforcement ?? "active";
|
|
94
|
+
const approvals = resolveApprovals(options.requiredApprovals);
|
|
95
|
+
assertAutoMergeWaits({ approvals, autoMerge, checkCount: checks.length, enforcement });
|
|
96
|
+
const host = options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl };
|
|
97
|
+
return {
|
|
98
|
+
repository: {
|
|
99
|
+
...options.settings,
|
|
100
|
+
...host,
|
|
101
|
+
allowAutoMerge: autoMerge,
|
|
102
|
+
allowMergeCommit: false,
|
|
103
|
+
allowRebaseMerge: false,
|
|
104
|
+
allowSquashMerge: true,
|
|
105
|
+
deleteBranchOnMerge: true,
|
|
106
|
+
name: repository,
|
|
107
|
+
owner
|
|
108
|
+
},
|
|
109
|
+
ruleset: {
|
|
110
|
+
...host,
|
|
111
|
+
bypassActors: [...options.bypassActors ?? []],
|
|
112
|
+
conditions: { exclude, include },
|
|
113
|
+
enforcement,
|
|
114
|
+
name: options.rulesetName ?? DEFAULT_RULESET_NAME,
|
|
115
|
+
owner,
|
|
116
|
+
repository,
|
|
117
|
+
rules: {
|
|
118
|
+
deletion: true,
|
|
119
|
+
nonFastForward: true,
|
|
120
|
+
...approvals === undefined ? {} : {
|
|
121
|
+
pullRequest: {
|
|
122
|
+
dismissStaleReviewsOnPush: true,
|
|
123
|
+
requiredApprovingReviewCount: approvals
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
...checks.length === 0 ? {} : {
|
|
127
|
+
requiredStatusChecks: {
|
|
128
|
+
checks: checks.map((context) => ({ context })),
|
|
129
|
+
strictRequiredStatusChecksPolicy: false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
target: "branch"
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// src/github/repo-policy.ts
|
|
138
|
+
import { adopt } from "alchemy/AdoptPolicy";
|
|
139
|
+
import * as GitHub from "alchemy/GitHub";
|
|
140
|
+
import * as RemovalPolicy from "alchemy/RemovalPolicy";
|
|
141
|
+
import * as Effect from "effect/Effect";
|
|
142
|
+
var declareRepoPolicy = (id, options) => Effect.gen(function* () {
|
|
143
|
+
const policy = yield* Effect.try({
|
|
144
|
+
catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),
|
|
145
|
+
try: () => repoPolicy(options)
|
|
146
|
+
});
|
|
147
|
+
const owned = (resource) => {
|
|
148
|
+
const kept = resource.pipe(RemovalPolicy.retain());
|
|
149
|
+
return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
|
|
150
|
+
};
|
|
151
|
+
const repository = yield* owned(GitHub.Repository(id, policy.repository));
|
|
152
|
+
const ruleset = yield* owned(GitHub.Ruleset(`${id}-ruleset`, policy.ruleset));
|
|
153
|
+
return { policy, repository, ruleset };
|
|
154
|
+
});
|
|
155
|
+
export {
|
|
156
|
+
declareRepoPolicy,
|
|
157
|
+
repoPolicy
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
//# debugId=1A73506791DB202C64756E2164756E21
|
|
161
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/github/repo-policy-guards.ts", "../src/github/repo-policy-form.ts", "../src/github/repo-policy.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * Everything `repoPolicy` refuses, and why. Extracted from repo-policy-form.ts so the\n * shape stays readable and the reasoning stays whole.\n *\n * ⛔ FIVE DIFFERENT DOORS LEAD TO THE SAME FAILURE — auto-merge with nothing to wait for.\n * `gh pr merge --auto` is a queue only while something is outstanding; with nothing\n * outstanding GitHub merges the pull request on the spot. An empty `checks` is the\n * obvious door. `enforcement: 'disabled'` or `'evaluate'` is the quiet one: the ruleset\n * exists, the required checks are listed on it, and none of them block anything. Then\n * three ways to end up with a ruleset that matches NO REF — which enforces on no ref\n * while the GitHub UI still shows it as `active`: an empty `include`, a blank ref\n * pattern, and an `exclude` that cancels every `include`. All five are refused.\n *\n * ★ THE REF DOORS WERE FOUND BY ADVERSARIAL REVIEW, NOT BY DESIGN. The first cut of this\n * module guarded `include.length === 0` and spread `exclude` raw, so\n * `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` and `include: [' ']`\n * both sailed through with `allowAutoMerge: true` — the exact end state the empty-array\n * guard exists to prevent, reached by a door beside it. Checked by execution, not by\n * reading: see the `matches no ref` tests in repo-policy-form.test.ts.\n */\n\n/** A non-empty, trimmed identifier, or an error naming the field. */\nexport function requireName(field: string, value: string): string {\n const trimmed = value.trim();\n if (trimmed === '') throw new Error(`repoPolicy: ${field} is required`);\n return trimmed;\n}\n\n/**\n * Trim, de-duplicate, sort. ★ The sort is what makes a second deploy a no-op: GitHub\n * stores the contexts in the order they were sent, so an unsorted list would diff against\n * itself the first time a caller wrote the same contexts in a different order.\n */\nexport function normalizeChecks(checks: readonly string[]): string[] {\n const seen = new Set<string>();\n for (const raw of checks) {\n const context = raw.trim();\n // ⚠️ A blank context is a typo that reads as coverage: GitHub happily stores it and\n // no job ever reports it, so every pull request waits on a check that cannot come.\n if (context === '') {\n throw new Error(\n 'repoPolicy: a required check context is blank — name it exactly as the job reports it',\n );\n }\n seen.add(context);\n }\n return [...seen].sort();\n}\n\n/**\n * Trim, de-duplicate, sort one list of ref patterns, refusing a blank one.\n *\n * ⚠️ A BLANK PATTERN IS NOT AN EMPTY LIST, AND THAT IS THE WHOLE TRAP. `[' ']` has\n * length 1, so a guard that counts the array waves it through; GitHub then stores a ref\n * condition that matches nothing. Same end state as `[]`, reached past the guard.\n * ★ Sorted for the same reason `normalizeChecks` is: GitHub echoes the patterns back in\n * the order they were sent, so an unsorted list diffs against itself the first time a\n * caller writes the same patterns in a different order.\n */\nfunction normalizeRefs(field: string, patterns: readonly string[]): string[] {\n const seen = new Set<string>();\n for (const raw of patterns) {\n const pattern = raw.trim();\n if (pattern === '') {\n throw new Error(\n `repoPolicy: a ${field} ref pattern is blank — a ruleset that matches no ref enforces nothing.`,\n );\n }\n seen.add(pattern);\n }\n return [...seen].sort();\n}\n\n/**\n * ⛔ AN EMPTY `include` IS REFUSED. Alchemy defaults `conditions.include` to `['~ALL']`\n * when it is absent, but an explicitly empty array is sent as an empty array, and a\n * ruleset that matches no ref protects nothing while still reading as `active` in the\n * GitHub UI — the worst possible combination, because it looks like it worked.\n */\nexport function normalizeInclude(\n include: readonly string[] | undefined,\n fallback: readonly string[],\n): string[] {\n if (include === undefined) return [...fallback];\n if (include.length === 0) {\n throw new Error(\n 'repoPolicy: include is empty — a ruleset that matches no ref enforces nothing. Omit it for the default branch.',\n );\n }\n return normalizeRefs('include', include);\n}\n\n/**\n * ⛔ AN `exclude` THAT CANCELS EVERY `include` IS REFUSED. In a GitHub ruleset the\n * exclusions win, so `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` is a\n * ruleset over nothing — and it reads as a narrowing, which is what makes it worse than\n * the empty array: the caller believes they scoped the policy, not switched it off.\n *\n * ⚠️ EXACT CANCELLATION ONLY, DELIBERATELY. GitHub's patterns are globs, so deciding in\n * general whether some exclude swallows some include means implementing GitHub's\n * matcher and being wrong about it quietly. This refuses the case that is decidable —\n * every include pattern also appears verbatim in `exclude` — and leaves\n * `exclude: ['refs/heads/*']` against `include: ['~DEFAULT_BRANCH']` to the operator.\n * A narrower guard that is always right beats a broad one that false-refuses.\n */\nexport function normalizeExclude(\n exclude: readonly string[] | undefined,\n include: readonly string[],\n): string[] {\n const patterns = exclude === undefined ? [] : normalizeRefs('exclude', exclude);\n if (patterns.length === 0) return patterns;\n const excluded = new Set(patterns);\n if (include.every((pattern) => excluded.has(pattern))) {\n throw new Error(\n [\n 'repoPolicy: exclude cancels every include pattern, so the ruleset matches no ref and enforces nothing.',\n `include ${JSON.stringify(include)} is fully covered by exclude ${JSON.stringify(patterns)}.`,\n ].join(' '),\n );\n }\n return patterns;\n}\n\n/**\n * ⛔ `0` IS REFUSED, NOT TREATED AS \"NO REVIEWS\". A `pull_request` rule with zero\n * approvals is the solo-maintainer shape — every change through a PR, no second person\n * — and it only works with `require_extra_approval_for_unattributed_changes: false`.\n * alchemy@2.0.0-beta.79's `Ruleset` cannot express that field, and GitHub defaults it\n * to `true` when it is omitted (measured 2026-09-17), which blocks the very PRs the\n * zero was meant to let through. Omitting `requiredApprovals` declares no rule at all,\n * which is a different and honest thing.\n */\nexport function resolveApprovals(requested: number | undefined): number | undefined {\n if (requested === undefined) return undefined;\n if (!Number.isInteger(requested) || requested < 1) {\n throw new Error(\n [\n 'repoPolicy: requiredApprovals must be a whole number of 1 or more.',\n 'Omit it to declare no pull_request rule.',\n '0 is not that: it needs require_extra_approval_for_unattributed_changes: false,',\n 'which alchemy@2.0.0-beta.79 cannot send and GitHub defaults to true.',\n ].join(' '),\n );\n }\n return requested;\n}\n\n/**\n * ⚠️ THE TEST IS \"SOMETHING OUTSTANDING\", NOT \"SOME CHECKS\". A required review holds a\n * pull request open just as a required check does, so `requiredApprovals` with an empty\n * `checks` is a legitimate policy. What is refused is auto-merge over a ruleset that\n * will not hold anything back.\n */\nexport function assertAutoMergeWaits(input: {\n readonly autoMerge: boolean;\n readonly checkCount: number;\n readonly approvals: number | undefined;\n readonly enforcement: 'active' | 'disabled' | 'evaluate';\n}): void {\n if (!input.autoMerge) return;\n if (input.enforcement !== 'active') {\n throw new Error(\n [\n `repoPolicy: autoMerge with enforcement '${input.enforcement}' merges every pull request immediately.`,\n 'A ruleset that is not active enforces none of its rules, so auto-merge has nothing to wait for.',\n 'Pass autoMerge: false while the ruleset is being evaluated.',\n ].join(' '),\n );\n }\n if (input.checkCount === 0 && input.approvals === undefined) {\n throw new Error(\n [\n 'repoPolicy: autoMerge with nothing required merges every pull request immediately.',\n 'Name at least one always-reporting check context, or ask for requiredApprovals,',\n 'or pass autoMerge: false for a repository that has no green run to require yet.',\n ].join(' '),\n );\n }\n}\n",
|
|
6
|
+
"/**\n * `repoPolicy` — one repository's house merge policy, as plain Alchemy props.\n *\n * ★ PURE ON PURPOSE, AND TYPE-ONLY IMPORTS. The exact shape a stack will deploy can be\n * asserted in a unit test with no credentials, no network and no Effect runtime.\n * `repo-policy.ts` is the thin half that turns these props into resources, and\n * `repo-policy-guards.ts` holds everything this refuses and why.\n *\n * ⛔ AUTO-MERGE WITH NOTHING TO WAIT FOR MERGES IMMEDIATELY. `gh pr merge --auto` is a\n * queue only while something is outstanding. Five different inputs produce \"nothing\n * outstanding\" — no `checks` and no `requiredApprovals`, an `enforcement` that is not\n * `active`, and three ways to land a ruleset that matches no ref (an empty `include`, a\n * blank ref pattern, an `exclude` that cancels every `include`). All five are refused.\n * See the guards module.\n *\n * ⛔ EVERY CONTEXT IN `checks` MUST ALWAYS REPORT. A required context that never reports\n * is not \"passed\", it is \"pending\", and the pull request waits on it forever —\n * auto-merge included. Point this at an aggregate job that runs `if: always()` and\n * fails when any upstream job did, never at a job behind a `paths:` filter, a matrix\n * leg, or anything a skipped workflow can silence.\n */\nimport type { RepositoryProps, RulesetProps } from 'alchemy/GitHub';\nimport {\n assertAutoMergeWaits,\n normalizeChecks,\n normalizeExclude,\n normalizeInclude,\n requireName,\n resolveApprovals,\n} from './repo-policy-guards.ts';\n\n/** Alchemy's own bypass-actor shape, re-exported so a caller imports one module, not two. */\nexport type RepoPolicyBypassActor = NonNullable<RulesetProps['bypassActors']>[number];\n\n/**\n * Repository fields the policy does NOT own — description, topics, visibility, …\n * ⛔ Merged UNDERNEATH the policy, so a caller can describe their repo but cannot quietly\n * re-enable merge commits through this door. The omitted keys are the policy itself.\n * ⛔ `baseUrl` IS OMITTED TOO, and is a top-level option instead. Setting it on only one\n * of the two resources would point the repository at a GitHub Enterprise host and its\n * ruleset at github.com — two halves of one policy on two different instances.\n */\nexport type RepoPolicyRepositorySettings = Omit<\n RepositoryProps,\n | 'allowAutoMerge'\n | 'allowMergeCommit'\n | 'allowRebaseMerge'\n | 'allowSquashMerge'\n | 'baseUrl'\n | 'deleteBranchOnMerge'\n | 'name'\n | 'owner'\n>;\n\nexport interface RepoPolicyOptions {\n /** Repository owner — a user or organization login. */\n readonly owner: string;\n /** Repository name. */\n readonly repository: string;\n /**\n * Status-check contexts a pull request must pass, named exactly as the job reports\n * them. Trimmed, de-duplicated and sorted, so re-ordering the list is not a diff.\n */\n readonly checks: readonly string[];\n /** @default true — with nothing required it is refused, not quietly downgraded. */\n readonly autoMerge?: boolean;\n /** @default 'main' */\n readonly rulesetName?: string;\n /** Ref patterns the ruleset covers. @default ['~DEFAULT_BRANCH'] */\n readonly include?: readonly string[];\n /**\n * Ref patterns exempted from it. @default []\n * ⛔ Exclusions win over inclusions, so one that cancels every `include` is refused —\n * it reads as a narrowing and acts as an off switch.\n */\n readonly exclude?: readonly string[];\n /** Who may bypass the ruleset. @default [] — nobody, administrators included. */\n readonly bypassActors?: readonly RepoPolicyBypassActor[];\n /**\n * Approving reviews a pull request needs. Omit for none — `0` is refused rather than\n * read as \"no reviews\"; `resolveApprovals` in the guards module says why.\n */\n readonly requiredApprovals?: number;\n /** @default 'active' — anything else is refused alongside `autoMerge`. */\n readonly enforcement?: 'active' | 'disabled' | 'evaluate';\n /** GitHub Enterprise host, applied to BOTH resources or to neither. */\n readonly baseUrl?: string;\n /** Everything about the repository that is not merge policy. */\n readonly settings?: RepoPolicyRepositorySettings;\n}\n\nexport interface RepoPolicy {\n readonly repository: RepositoryProps;\n readonly ruleset: RulesetProps;\n}\n\nconst DEFAULT_RULESET_NAME = 'main';\n/**\n * ★ `~DEFAULT_BRANCH`, NOT `refs/heads/main`. GitHub resolves it per repository, so one\n * policy covers a repo whose default branch is `master` or `trunk`, and renaming the\n * default branch later does not silently leave the ruleset pointing at nothing.\n */\nconst DEFAULT_INCLUDE: readonly string[] = ['~DEFAULT_BRANCH'];\n\n/**\n * The house policy for one repository: squash-only merges, auto-merge on, head branches\n * deleted, and a ruleset over the default branch that blocks deletion and force pushes\n * and requires `checks`.\n *\n * ★ `strictRequiredStatusChecksPolicy` IS ALWAYS FALSE, AND NOT A PARAMETER. \"Require\n * branches to be up to date\" means every merge to the default branch invalidates every\n * other open pull request, which with auto-merge on is a re-run storm that converges\n * only when the queue empties. The checks still have to pass; they just do not have to\n * have passed against the very tip.\n */\nexport function repoPolicy(options: RepoPolicyOptions): RepoPolicy {\n const owner = requireName('owner', options.owner);\n const repository = requireName('repository', options.repository);\n const checks = normalizeChecks(options.checks);\n const include = normalizeInclude(options.include, DEFAULT_INCLUDE);\n const exclude = normalizeExclude(options.exclude, include);\n const autoMerge = options.autoMerge ?? true;\n const enforcement = options.enforcement ?? 'active';\n const approvals = resolveApprovals(options.requiredApprovals);\n assertAutoMergeWaits({ approvals, autoMerge, checkCount: checks.length, enforcement });\n const host = options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl };\n\n return {\n repository: {\n ...options.settings,\n ...host,\n // ⛔ Squash only. The ruleset's own `allowed_merge_methods` is not reachable through\n // Alchemy's `pullRequest` rule, so the repository is the only place this holds.\n allowAutoMerge: autoMerge,\n allowMergeCommit: false,\n allowRebaseMerge: false,\n allowSquashMerge: true,\n deleteBranchOnMerge: true,\n name: repository,\n owner,\n },\n ruleset: {\n ...host,\n bypassActors: [...(options.bypassActors ?? [])],\n conditions: { exclude, include },\n enforcement,\n name: options.rulesetName ?? DEFAULT_RULESET_NAME,\n owner,\n repository,\n rules: {\n deletion: true,\n nonFastForward: true,\n ...(approvals === undefined\n ? {}\n : {\n pullRequest: {\n dismissStaleReviewsOnPush: true,\n requiredApprovingReviewCount: approvals,\n },\n }),\n ...(checks.length === 0\n ? {}\n : {\n requiredStatusChecks: {\n checks: checks.map((context) => ({ context })),\n strictRequiredStatusChecksPolicy: false,\n },\n }),\n },\n target: 'branch',\n },\n };\n}\n",
|
|
7
|
+
"/**\n * `declareRepoPolicy` — the policy from repo-policy-form.ts, as resources.\n *\n * const policy = yield* declareRepoPolicy('api', {\n * owner: 'my-org',\n * repository: 'api',\n * checks: ['ci', 'secret scan'],\n * });\n *\n * Provide `GitHub.providers()`; this subpath adds no provider of its own.\n *\n * ⛔ THE RULESET CANNOT BE ADOPTED, AND A FIRST DEPLOY ONTO A REPO THAT ALREADY HAS ONE\n * MAKES A SECOND. Read out of alchemy@2.0.0-beta.79's own source on 2026-09-22\n * (`node_modules/alchemy/lib/github/Ruleset.js`): `read` returns `undefined` whenever\n * there is no prior output, and `reconcile` looks the live ruleset up only BY THE ID IN\n * THAT OUTPUT — with none, it calls `createRepoRuleset` unconditionally. GitHub permits\n * several rulesets with the same name on one repository, so nothing errors; the repo\n * quietly ends up with two, both enforcing.\n * ★ SO, BEFORE THE FIRST DEPLOY: `gh api repos/<owner>/<repo>/rulesets` and delete a\n * hand-made ruleset of the same name, or give this one a `rulesetName` of its own.\n * Afterwards the state carries the id and every later deploy converges that one.\n * ⚠️ `GitHub.Repository` does NOT share the problem — its `reconcile` probes by name\n * and converges onto whatever is live — so only half of this helper is adopt-safe,\n * and the halves fail differently. See docs/repo-policy.md.\n *\n * ⛔ BOTH RESOURCES RETAIN. `retain` is already the vendor default for each of them; it\n * is piped anyway so that a vendor change cannot quietly turn \"drop this call from the\n * stack\" into \"delete the repository\" or \"unprotect the default branch\".\n */\nimport { adopt } from 'alchemy/AdoptPolicy';\nimport * as GitHub from 'alchemy/GitHub';\nimport * as RemovalPolicy from 'alchemy/RemovalPolicy';\nimport * as Effect from 'effect/Effect';\nimport { type RepoPolicyOptions, repoPolicy } from './repo-policy-form.ts';\n\nexport interface DeclareRepoPolicyOptions extends RepoPolicyOptions {\n /**\n * `true`/`false` pipe `adopt(…)` onto both resources; omitted, the deploy's own policy\n * decides. ⚠️ It changes nothing about the ruleset hazard above: adoption routes on\n * what a provider's `read` reports, and this one reports nothing without prior state.\n */\n readonly adopt?: boolean;\n}\n\n/**\n * Declares the repository and its ruleset under `<id>` and `<id>-ruleset`. Fails, before\n * declaring either, on any policy the form refuses.\n */\nexport const declareRepoPolicy = (id: string, options: DeclareRepoPolicyOptions) =>\n Effect.gen(function* () {\n const policy = yield* Effect.try({\n catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),\n try: () => repoPolicy(options),\n });\n // ⚠️ `never` in the error channel, not a generic `E`: `AdoptPolicy.adopt` is typed\n // `Effect<A, never, R> => Effect<A, never, R>`, so a wider error type fails to\n // compile here rather than at the call site. Alchemy's resources declare `never`.\n const owned = <A, R>(resource: Effect.Effect<A, never, R>) => {\n const kept = resource.pipe(RemovalPolicy.retain());\n return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));\n };\n const repository = yield* owned(GitHub.Repository(id, policy.repository));\n const ruleset = yield* owned(GitHub.Ruleset(`${id}-ruleset`, policy.ruleset));\n return { policy, repository, ruleset };\n });\n"
|
|
8
|
+
],
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "1A73506791DB202C64756E2164756E21",
|
|
11
|
+
"names": []
|
|
12
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `repoPolicy` — one repository's house merge policy, as plain Alchemy props.
|
|
3
|
+
*
|
|
4
|
+
* ★ PURE ON PURPOSE, AND TYPE-ONLY IMPORTS. The exact shape a stack will deploy can be
|
|
5
|
+
* asserted in a unit test with no credentials, no network and no Effect runtime.
|
|
6
|
+
* `repo-policy.ts` is the thin half that turns these props into resources, and
|
|
7
|
+
* `repo-policy-guards.ts` holds everything this refuses and why.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ AUTO-MERGE WITH NOTHING TO WAIT FOR MERGES IMMEDIATELY. `gh pr merge --auto` is a
|
|
10
|
+
* queue only while something is outstanding. Five different inputs produce "nothing
|
|
11
|
+
* outstanding" — no `checks` and no `requiredApprovals`, an `enforcement` that is not
|
|
12
|
+
* `active`, and three ways to land a ruleset that matches no ref (an empty `include`, a
|
|
13
|
+
* blank ref pattern, an `exclude` that cancels every `include`). All five are refused.
|
|
14
|
+
* See the guards module.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ EVERY CONTEXT IN `checks` MUST ALWAYS REPORT. A required context that never reports
|
|
17
|
+
* is not "passed", it is "pending", and the pull request waits on it forever —
|
|
18
|
+
* auto-merge included. Point this at an aggregate job that runs `if: always()` and
|
|
19
|
+
* fails when any upstream job did, never at a job behind a `paths:` filter, a matrix
|
|
20
|
+
* leg, or anything a skipped workflow can silence.
|
|
21
|
+
*/
|
|
22
|
+
import type { RepositoryProps, RulesetProps } from 'alchemy/GitHub';
|
|
23
|
+
/** Alchemy's own bypass-actor shape, re-exported so a caller imports one module, not two. */
|
|
24
|
+
export type RepoPolicyBypassActor = NonNullable<RulesetProps['bypassActors']>[number];
|
|
25
|
+
/**
|
|
26
|
+
* Repository fields the policy does NOT own — description, topics, visibility, …
|
|
27
|
+
* ⛔ Merged UNDERNEATH the policy, so a caller can describe their repo but cannot quietly
|
|
28
|
+
* re-enable merge commits through this door. The omitted keys are the policy itself.
|
|
29
|
+
* ⛔ `baseUrl` IS OMITTED TOO, and is a top-level option instead. Setting it on only one
|
|
30
|
+
* of the two resources would point the repository at a GitHub Enterprise host and its
|
|
31
|
+
* ruleset at github.com — two halves of one policy on two different instances.
|
|
32
|
+
*/
|
|
33
|
+
export type RepoPolicyRepositorySettings = Omit<RepositoryProps, 'allowAutoMerge' | 'allowMergeCommit' | 'allowRebaseMerge' | 'allowSquashMerge' | 'baseUrl' | 'deleteBranchOnMerge' | 'name' | 'owner'>;
|
|
34
|
+
export interface RepoPolicyOptions {
|
|
35
|
+
/** Repository owner — a user or organization login. */
|
|
36
|
+
readonly owner: string;
|
|
37
|
+
/** Repository name. */
|
|
38
|
+
readonly repository: string;
|
|
39
|
+
/**
|
|
40
|
+
* Status-check contexts a pull request must pass, named exactly as the job reports
|
|
41
|
+
* them. Trimmed, de-duplicated and sorted, so re-ordering the list is not a diff.
|
|
42
|
+
*/
|
|
43
|
+
readonly checks: readonly string[];
|
|
44
|
+
/** @default true — with nothing required it is refused, not quietly downgraded. */
|
|
45
|
+
readonly autoMerge?: boolean;
|
|
46
|
+
/** @default 'main' */
|
|
47
|
+
readonly rulesetName?: string;
|
|
48
|
+
/** Ref patterns the ruleset covers. @default ['~DEFAULT_BRANCH'] */
|
|
49
|
+
readonly include?: readonly string[];
|
|
50
|
+
/**
|
|
51
|
+
* Ref patterns exempted from it. @default []
|
|
52
|
+
* ⛔ Exclusions win over inclusions, so one that cancels every `include` is refused —
|
|
53
|
+
* it reads as a narrowing and acts as an off switch.
|
|
54
|
+
*/
|
|
55
|
+
readonly exclude?: readonly string[];
|
|
56
|
+
/** Who may bypass the ruleset. @default [] — nobody, administrators included. */
|
|
57
|
+
readonly bypassActors?: readonly RepoPolicyBypassActor[];
|
|
58
|
+
/**
|
|
59
|
+
* Approving reviews a pull request needs. Omit for none — `0` is refused rather than
|
|
60
|
+
* read as "no reviews"; `resolveApprovals` in the guards module says why.
|
|
61
|
+
*/
|
|
62
|
+
readonly requiredApprovals?: number;
|
|
63
|
+
/** @default 'active' — anything else is refused alongside `autoMerge`. */
|
|
64
|
+
readonly enforcement?: 'active' | 'disabled' | 'evaluate';
|
|
65
|
+
/** GitHub Enterprise host, applied to BOTH resources or to neither. */
|
|
66
|
+
readonly baseUrl?: string;
|
|
67
|
+
/** Everything about the repository that is not merge policy. */
|
|
68
|
+
readonly settings?: RepoPolicyRepositorySettings;
|
|
69
|
+
}
|
|
70
|
+
export interface RepoPolicy {
|
|
71
|
+
readonly repository: RepositoryProps;
|
|
72
|
+
readonly ruleset: RulesetProps;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The house policy for one repository: squash-only merges, auto-merge on, head branches
|
|
76
|
+
* deleted, and a ruleset over the default branch that blocks deletion and force pushes
|
|
77
|
+
* and requires `checks`.
|
|
78
|
+
*
|
|
79
|
+
* ★ `strictRequiredStatusChecksPolicy` IS ALWAYS FALSE, AND NOT A PARAMETER. "Require
|
|
80
|
+
* branches to be up to date" means every merge to the default branch invalidates every
|
|
81
|
+
* other open pull request, which with auto-merge on is a re-run storm that converges
|
|
82
|
+
* only when the queue empties. The checks still have to pass; they just do not have to
|
|
83
|
+
* have passed against the very tip.
|
|
84
|
+
*/
|
|
85
|
+
export declare function repoPolicy(options: RepoPolicyOptions): RepoPolicy;
|
|
86
|
+
//# sourceMappingURL=repo-policy-form.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repo-policy-form.d.ts","sourceRoot":"","sources":["../../src/github/repo-policy-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAUpE,6FAA6F;AAC7F,MAAM,MAAM,qBAAqB,GAAG,WAAW,CAAC,YAAY,CAAC,cAAc,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;AAEtF;;;;;;;GAOG;AACH,MAAM,MAAM,4BAA4B,GAAG,IAAI,CAC7C,eAAe,EACb,gBAAgB,GAChB,kBAAkB,GAClB,kBAAkB,GAClB,kBAAkB,GAClB,SAAS,GACT,qBAAqB,GACrB,MAAM,GACN,OAAO,CACV,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC,uDAAuD;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,uBAAuB;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,mFAAmF;IACnF,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,sBAAsB;IACtB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,oEAAoE;IACpE,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,iFAAiF;IACjF,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,qBAAqB,EAAE,CAAC;IACzD;;;OAGG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,0EAA0E;IAC1E,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,UAAU,CAAC;IAC1D,uEAAuE;IACvE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,gEAAgE;IAChE,QAAQ,CAAC,QAAQ,CAAC,EAAE,4BAA4B,CAAC;CAClD;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAC;IACrC,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;CAChC;AAUD;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,UAAU,CAyDjE"}
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
/** A non-empty, trimmed identifier, or an error naming the field. */
|
|
22
|
+
export declare function requireName(field: string, value: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Trim, de-duplicate, sort. ★ The sort is what makes a second deploy a no-op: GitHub
|
|
25
|
+
* stores the contexts in the order they were sent, so an unsorted list would diff against
|
|
26
|
+
* itself the first time a caller wrote the same contexts in a different order.
|
|
27
|
+
*/
|
|
28
|
+
export declare function normalizeChecks(checks: readonly string[]): string[];
|
|
29
|
+
/**
|
|
30
|
+
* ⛔ AN EMPTY `include` IS REFUSED. Alchemy defaults `conditions.include` to `['~ALL']`
|
|
31
|
+
* when it is absent, but an explicitly empty array is sent as an empty array, and a
|
|
32
|
+
* ruleset that matches no ref protects nothing while still reading as `active` in the
|
|
33
|
+
* GitHub UI — the worst possible combination, because it looks like it worked.
|
|
34
|
+
*/
|
|
35
|
+
export declare function normalizeInclude(include: readonly string[] | undefined, fallback: readonly string[]): string[];
|
|
36
|
+
/**
|
|
37
|
+
* ⛔ AN `exclude` THAT CANCELS EVERY `include` IS REFUSED. In a GitHub ruleset the
|
|
38
|
+
* exclusions win, so `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` is a
|
|
39
|
+
* ruleset over nothing — and it reads as a narrowing, which is what makes it worse than
|
|
40
|
+
* the empty array: the caller believes they scoped the policy, not switched it off.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ EXACT CANCELLATION ONLY, DELIBERATELY. GitHub's patterns are globs, so deciding in
|
|
43
|
+
* general whether some exclude swallows some include means implementing GitHub's
|
|
44
|
+
* matcher and being wrong about it quietly. This refuses the case that is decidable —
|
|
45
|
+
* every include pattern also appears verbatim in `exclude` — and leaves
|
|
46
|
+
* `exclude: ['refs/heads/*']` against `include: ['~DEFAULT_BRANCH']` to the operator.
|
|
47
|
+
* A narrower guard that is always right beats a broad one that false-refuses.
|
|
48
|
+
*/
|
|
49
|
+
export declare function normalizeExclude(exclude: readonly string[] | undefined, include: readonly string[]): string[];
|
|
50
|
+
/**
|
|
51
|
+
* ⛔ `0` IS REFUSED, NOT TREATED AS "NO REVIEWS". A `pull_request` rule with zero
|
|
52
|
+
* approvals is the solo-maintainer shape — every change through a PR, no second person
|
|
53
|
+
* — and it only works with `require_extra_approval_for_unattributed_changes: false`.
|
|
54
|
+
* alchemy@2.0.0-beta.79's `Ruleset` cannot express that field, and GitHub defaults it
|
|
55
|
+
* to `true` when it is omitted (measured 2026-09-17), which blocks the very PRs the
|
|
56
|
+
* zero was meant to let through. Omitting `requiredApprovals` declares no rule at all,
|
|
57
|
+
* which is a different and honest thing.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resolveApprovals(requested: number | undefined): number | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* ⚠️ THE TEST IS "SOMETHING OUTSTANDING", NOT "SOME CHECKS". A required review holds a
|
|
62
|
+
* pull request open just as a required check does, so `requiredApprovals` with an empty
|
|
63
|
+
* `checks` is a legitimate policy. What is refused is auto-merge over a ruleset that
|
|
64
|
+
* will not hold anything back.
|
|
65
|
+
*/
|
|
66
|
+
export declare function assertAutoMergeWaits(input: {
|
|
67
|
+
readonly autoMerge: boolean;
|
|
68
|
+
readonly checkCount: number;
|
|
69
|
+
readonly approvals: number | undefined;
|
|
70
|
+
readonly enforcement: 'active' | 'disabled' | 'evaluate';
|
|
71
|
+
}): void;
|
|
72
|
+
//# sourceMappingURL=repo-policy-guards.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repo-policy-guards.d.ts","sourceRoot":"","sources":["../../src/github/repo-policy-guards.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,qEAAqE;AACrE,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIhE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAcnE;AA0BD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,EACtC,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,MAAM,EAAE,CAQV;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,EACtC,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,MAAM,EAAE,CAaV;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAalF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE;IAC1C,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,WAAW,EAAE,QAAQ,GAAG,UAAU,GAAG,UAAU,CAAC;CAC1D,GAAG,IAAI,CAoBP"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as GitHub from 'alchemy/GitHub';
|
|
2
|
+
import * as Effect from 'effect/Effect';
|
|
3
|
+
import { type RepoPolicyOptions } from './repo-policy-form.ts';
|
|
4
|
+
export interface DeclareRepoPolicyOptions extends RepoPolicyOptions {
|
|
5
|
+
/**
|
|
6
|
+
* `true`/`false` pipe `adopt(…)` onto both resources; omitted, the deploy's own policy
|
|
7
|
+
* decides. ⚠️ It changes nothing about the ruleset hazard above: adoption routes on
|
|
8
|
+
* what a provider's `read` reports, and this one reports nothing without prior state.
|
|
9
|
+
*/
|
|
10
|
+
readonly adopt?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Declares the repository and its ruleset under `<id>` and `<id>-ruleset`. Fails, before
|
|
14
|
+
* declaring either, on any policy the form refuses.
|
|
15
|
+
*/
|
|
16
|
+
export declare const declareRepoPolicy: (id: string, options: DeclareRepoPolicyOptions) => Effect.Effect<{
|
|
17
|
+
policy: import("./repo-policy-form.ts").RepoPolicy;
|
|
18
|
+
repository: GitHub.Repository;
|
|
19
|
+
ruleset: GitHub.Ruleset;
|
|
20
|
+
}, Error, GitHub.Providers>;
|
|
21
|
+
//# sourceMappingURL=repo-policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repo-policy.d.ts","sourceRoot":"","sources":["../../src/github/repo-policy.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,MAAM,MAAM,gBAAgB,CAAC;AAEzC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,KAAK,iBAAiB,EAAc,MAAM,uBAAuB,CAAC;AAE3E,MAAM,WAAW,wBAAyB,SAAQ,iBAAiB;IACjE;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB,OAAQ,MAAM,WAAW,wBAAwB;;;;2BAgB3E,CAAC"}
|
package/dist/index.d.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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,CAAC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PBS webhook body that posts one Alertmanager v2 alert — `POST /api/v2/alerts` takes a JSON
|
|
3
|
+
* ARRAY of `postableAlert`, each with a required `labels` map and an optional `annotations` map,
|
|
4
|
+
* every value a string (Alertmanager's api/v2/openapi.yaml, read at main 2026-09-22).
|
|
5
|
+
*
|
|
6
|
+
* ★ PURE TEXT, NOT A RENDERER. PBS renders it, with Handlebars, at send time: `{{ severity }}`,
|
|
7
|
+
* `{{ title }}`, `{{ message }}`, `{{ timestamp }}` and `{{ fields.<name> }}` are the
|
|
8
|
+
* notification, and `json`, `escape` and `url-encode` are the only helpers
|
|
9
|
+
* (proxmox-notify endpoints/webhook.rs `setup_handlebars`, read at HEAD 2026-09-22).
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THERE IS NO ESCAPING UNLESS THE TEMPLATE ASKS FOR IT. The webhook's Handlebars registers
|
|
12
|
+
* `no_escape` ("There is no escape."), so `"{{ message }}"` breaks the JSON the first time a
|
|
13
|
+
* task log holds a quote or a newline — and a failed job's message is exactly a task log. Every
|
|
14
|
+
* value below goes through `json`, which emits a complete, quoted JSON string.
|
|
15
|
+
* ⛔ A FIELD THE NOTIFICATION DOES NOT CARRY MUST NOT BE REACHED FOR BARE. `escape` on a missing
|
|
16
|
+
* field fails the whole render, and the notification is then lost — not degraded. MEASURED
|
|
17
|
+
* (2026-09-22) with handlebars-rust 5.1.2 and the webhook's three helpers restated from source:
|
|
18
|
+
* `{{ escape fields.job-id }}` over no fields errors "param at index 0 required but not found";
|
|
19
|
+
* `{{ json fields.job-id }}` renders `null`, valid JSON but not a label value. Garbage
|
|
20
|
+
* collection has no `job-id`, and the UI's "Test" button sends a notification with NO fields at
|
|
21
|
+
* all (lib.rs `test_target`). So every field-derived label sits inside
|
|
22
|
+
* `{{#if}}`, ends with its own comma, and the labels after them are always there: whichever
|
|
23
|
+
* fields are present, the object stays valid JSON.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ ALERTMANAGER RESOLVES IT BY ITSELF. With no `endsAt` it sets `now + resolve_timeout` (5m by
|
|
26
|
+
* default, api/v2/api.go `postAlertsHandler`), and PBS never sends a resolve — so a receiver
|
|
27
|
+
* with `send_resolved: true` reports "resolved" five minutes after a failure that nobody fixed.
|
|
28
|
+
* Page on the firing notification; do not read the resolve as a recovery.
|
|
29
|
+
* ⚠️ `severity` IS PBS'S WORD, not Alertmanager convention: `info`, `notice`, `warning`, `error`,
|
|
30
|
+
* `unknown`. Route on `severity="error"`, or relabel it in the Alertmanager config.
|
|
31
|
+
*/
|
|
32
|
+
export interface AlertmanagerBodyOptions {
|
|
33
|
+
/** The `alertname` label. Default `PbsNotification`; `job_type` says which job it was. */
|
|
34
|
+
readonly alertname?: string;
|
|
35
|
+
/** The `source` label. Default `pbs`. */
|
|
36
|
+
readonly source?: string;
|
|
37
|
+
/** Optional `generatorURL` — an absolute link back to the PBS UI. */
|
|
38
|
+
readonly generatorURL?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The body template. Declare it on a webhook target together with `Content-Type:
|
|
42
|
+
* application/json`, method `post`, and the URL `<alertmanager>/api/v2/alerts`.
|
|
43
|
+
*
|
|
44
|
+
* ★ `fields.job-id` IS A VALID PATH: Handlebars-rust 5 (the version proxmox-notify pins) allows
|
|
45
|
+
* `-` in an identifier (grammar.pest `symbol_char`), as handlebars.js does. The same scratch
|
|
46
|
+
* build rendered this template for a GC failure, a verify failure and a field-less test
|
|
47
|
+
* notification, and each parsed as JSON with string-only labels. The adversarial review re-ran
|
|
48
|
+
* it over the test file's whole matrix — every PBS event's fields x five severities x hostile
|
|
49
|
+
* text, 840 renders with and without `generatorURL` — and every one parsed (2026-09-22).
|
|
50
|
+
* ⚠️ THAT BUILD IS NOT PBS. The first real notification is the measurement that counts: send one
|
|
51
|
+
* with the target's "Test" button and read what Alertmanager received.
|
|
52
|
+
*/
|
|
53
|
+
export declare const alertmanagerAlertBody: (options?: AlertmanagerBodyOptions) => string;
|
|
54
|
+
//# sourceMappingURL=alertmanager-body.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"alertmanager-body.d.ts","sourceRoot":"","sources":["../../src/proxmox/alertmanager-body.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,WAAW,uBAAuB;IACtC,0FAA0F;IAC1F,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,yCAAyC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,qEAAqE;IACrE,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AA2CD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,aAAa,uBAAuB,KAAQ,MA6B7E,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential written as a LITERAL in a plain prop — refused, because a plain prop is a value in
|
|
3
|
+
* the state store.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE HOLE THIS CLOSES, MEASURED 2026-09-22 (adversarial review of PR 95). write-only.ts keeps
|
|
6
|
+
* every `{ fromEnv }` value out of state, and the doc WARNED that a plain header value is a prop.
|
|
7
|
+
* A warning is not a guard: `header: { Authorization: 'Bearer <token>' }` planned, deployed, and
|
|
8
|
+
* left the token in the dumped state store — and so did `url: 'https://…?token=<token>'`. Alchemy
|
|
9
|
+
* stores props unencrypted (write-only.ts has the StateEncoding reading), so the only safe
|
|
10
|
+
* literal is one that is not a credential.
|
|
11
|
+
*
|
|
12
|
+
* ★ WHAT COUNTS AS A CREDENTIAL IS A NAME, NOT A VALUE. A value cannot be recognised — a token is
|
|
13
|
+
* any string — but where it sits can: an `Authorization`/`Cookie` header, a header or query
|
|
14
|
+
* parameter whose name has a word like `token`, `key`, `secret`, `password` or `signature`, and a
|
|
15
|
+
* password in the URL's userinfo. A false positive costs one edit (declare it `{ fromEnv }`, or
|
|
16
|
+
* hold it in `secret` and write `{{ secrets.<name> }}`); a false negative costs a leaked token.
|
|
17
|
+
* ★ A TEMPLATE THAT READS A SECRET IS NOT A LITERAL. `Bearer {{ secrets.token }}`, and
|
|
18
|
+
* `{{ url-encode secrets.token }}` in a query, render on PBS from the root-only secret store —
|
|
19
|
+
* the prop holds the reference, not the value.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ WHAT THIS CANNOT SEE: a token in a URL PATH (a Slack-style `/services/T…/B…/<token>`), in the
|
|
22
|
+
* `body`, or in a `comment`. Those are plain props too; put the token in `secret` and reference
|
|
23
|
+
* it from the template. docs/pbs-notifications.md says the same to the reader.
|
|
24
|
+
* ⛔ A REFUSAL NAMES THE FIELD, NEVER THE VALUE — the message lands in a terminal and a CI log.
|
|
25
|
+
*/
|
|
26
|
+
import type { FromEnv } from './write-only.ts';
|
|
27
|
+
export declare const isCredentialName: (name: string) => boolean;
|
|
28
|
+
/** Plain header values that are credentials by their header's name. `{ fromEnv }` is never one. */
|
|
29
|
+
export declare const literalCredentialHeaders: (header: Readonly<Record<string, FromEnv | string>> | undefined) => string[];
|
|
30
|
+
/**
|
|
31
|
+
* Where a URL carries a literal credential: `userinfo` (a password before the `@`), or the names
|
|
32
|
+
* of credential-shaped query (or fragment) parameters. Parsed by hand, not with `new URL`: a URL here is a
|
|
33
|
+
* TEMPLATE, and `{{ … }}` in the host or the userinfo is not a URL `new URL` will accept.
|
|
34
|
+
*/
|
|
35
|
+
export declare const literalCredentialsInUrl: (url: string | undefined) => string[];
|
|
36
|
+
//# sourceMappingURL=credential-literals.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential-literals.d.ts","sourceRoot":"","sources":["../../src/proxmox/credential-literals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAU/C,eAAO,MAAM,gBAAgB,SAAU,MAAM,KAAG,OACY,CAAC;AAI7D,mGAAmG;AACnG,eAAO,MAAM,wBAAwB,WAC3B,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC,CAAC,GAAG,SAAS,KAC7D,MAAM,EAKE,CAAC;AAEZ;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,QAAS,MAAM,GAAG,SAAS,KAAG,MAAM,EAkBvE,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PBS (or PVE) notification config that remembers writes — enough of proxmox-notify's CRUD for
|
|
3
|
+
* a family's REAL provider to create, drift, rotate and adopt through Alchemy's engine.
|
|
4
|
+
*
|
|
5
|
+
* ★ STATEFUL, UNLIKE `fakePve`'s bare answer function, because the questions here are about the
|
|
6
|
+
* SECOND deploy: whether a rotated secret is written, whether an unchanged one is not. That needs
|
|
7
|
+
* a server that holds what the first deploy wrote.
|
|
8
|
+
* ★ IT KEEPS proxmox-notify's TWO RULES THAT MATTER HERE (api/webhook.rs, read at HEAD 2026-09-22):
|
|
9
|
+
* 1. a read returns secret NAMES only; values live in a separate private store;
|
|
10
|
+
* 2. a PUT that carries `secret` REPLACES the list, a name without a value keeping the old
|
|
11
|
+
* one; a PUT without `secret` leaves the list alone. `delete` entries clear a field.
|
|
12
|
+
* Repeated form keys arrive as arrays, as the real urlencoded decoder builds them.
|
|
13
|
+
* ⛔ TEST-ONLY. No provider imports this file.
|
|
14
|
+
*/
|
|
15
|
+
import { type FakePve } from './fake-pve.ts';
|
|
16
|
+
export interface FakeNotify extends FakePve {
|
|
17
|
+
/** Public config by path, as a GET would see it apart from secrets. Mutable, for drift. */
|
|
18
|
+
readonly objects: Map<string, Record<string, unknown>>;
|
|
19
|
+
/** Secret items with values (`name=…,value=<base64>`), by path — the root-only file. */
|
|
20
|
+
readonly secrets: Map<string, string[]>;
|
|
21
|
+
}
|
|
22
|
+
export declare const fakeNotify: (seed?: Readonly<Record<string, Record<string, unknown>>>) => FakeNotify;
|
|
23
|
+
//# sourceMappingURL=fake-pbs-notify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake-pbs-notify.d.ts","sourceRoot":"","sources":["../../src/proxmox/fake-pbs-notify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAE,KAAK,OAAO,EAAyB,MAAM,eAAe,CAAC;AAepE,MAAM,WAAW,UAAW,SAAQ,OAAO;IACzC,2FAA2F;IAC3F,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACvD,wFAAwF;IACxF,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;CACzC;AA4BD,eAAO,MAAM,UAAU,UACf,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,KACtD,UA2CF,CAAC"}
|
|
@@ -19,6 +19,11 @@ export interface PveCall {
|
|
|
19
19
|
/** Relative to `/api2/json/`, query included — the string a family passes to `pve()`. */
|
|
20
20
|
readonly path: string;
|
|
21
21
|
readonly form: Readonly<Record<string, string>>;
|
|
22
|
+
/**
|
|
23
|
+
* Every key/value pair in wire order. ⚠️ `form` keeps only the LAST value of a repeated key, and
|
|
24
|
+
* a PBS list IS a repeated key (client.ts `encode`) — assert a list on `pairs`, never `form`.
|
|
25
|
+
*/
|
|
26
|
+
readonly pairs: readonly (readonly [string, string])[];
|
|
22
27
|
}
|
|
23
28
|
/** Answers one call with its `data`; `undefined` is PVE's `{"data": null}`. */
|
|
24
29
|
export type PveAnswer = (call: PveCall) => unknown;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fake-pve.d.ts","sourceRoot":"","sources":["../../src/proxmox/fake-pve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAEtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAIlD,eAAO,MAAM,WAAW,EAAE,SAIzB,CAAC;AAEF,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"fake-pve.d.ts","sourceRoot":"","sources":["../../src/proxmox/fake-pve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAEtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAIlD,eAAO,MAAM,WAAW,EAAE,SAIzB,CAAC;AAEF,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;CACxD;AAED,+EAA+E;AAC/E,MAAM,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;AAEnD,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,KAAK,EAAE,OAAO,EAAE,CAAC;IAC1B,sDAAsD;IACtD,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,EAAE,CAAC;IAChC,wFAAwF;IACxF,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;CAC9C;AAED,0FAA0F;AAC1F,QAAA,MAAM,SAAS,SAAU,OAAO,KAAK,oFACuD,CAAC;AAE7F,eAAO,MAAM,OAAO,WAAY,SAAS,KAAG,OA4B3C,CAAC;AAEF,sGAAsG;AACtG,eAAO,MAAM,UAAU,GAAI,CAAC,QAAQ,MAAM,OAAO,CAAC,CAAC,CAAC,KAAG,OAAO,CAAC,CAAC,CACO,CAAC"}
|