reposets 0.4.2 → 1.0.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 (54) hide show
  1. package/README.md +87 -75
  2. package/bin/reposets.js +68 -17
  3. package/cli/commands/credentials.js +170 -49
  4. package/cli/commands/doctor.js +364 -91
  5. package/cli/commands/drift.js +48 -0
  6. package/cli/commands/history.js +203 -0
  7. package/cli/commands/init.js +110 -104
  8. package/cli/commands/list.js +60 -39
  9. package/cli/commands/nuke.js +127 -0
  10. package/cli/commands/sync.js +219 -59
  11. package/cli/commands/validate.js +57 -41
  12. package/cli/flags.js +36 -0
  13. package/cli/logger.js +48 -0
  14. package/index.d.ts +471 -1499
  15. package/index.js +3 -18
  16. package/lib/config-refs.js +76 -0
  17. package/lib/credential-labels.js +0 -0
  18. package/lib/fingerprint.js +52 -0
  19. package/lib/org-only.js +61 -0
  20. package/lib/schema-issues.js +50 -0
  21. package/package.json +11 -10
  22. package/schemas/annotations.js +81 -0
  23. package/schemas/common.js +83 -48
  24. package/schemas/config.js +214 -212
  25. package/schemas/credentials.js +190 -54
  26. package/schemas/environment.js +27 -21
  27. package/schemas/ruleset.js +235 -139
  28. package/services/ConfigFiles.js +126 -104
  29. package/services/CredentialResolver.js +97 -33
  30. package/services/OnePasswordClient.js +88 -16
  31. package/services/SyncLogger.js +107 -76
  32. package/store/AppliedState.js +0 -0
  33. package/store/RepoCache.js +86 -0
  34. package/store/SyncJournal.js +92 -0
  35. package/store/migrations.js +86 -0
  36. package/sync/SyncEngine.js +156 -0
  37. package/sync/decide.js +56 -0
  38. package/sync/phase.js +55 -0
  39. package/sync/phases/cleanup.js +220 -0
  40. package/sync/phases/code-scanning.js +187 -0
  41. package/sync/phases/environments.js +106 -0
  42. package/sync/phases/index.js +39 -0
  43. package/sync/phases/resource.js +149 -0
  44. package/sync/phases/rulesets.js +186 -0
  45. package/sync/phases/secrets.js +138 -0
  46. package/sync/phases/security.js +129 -0
  47. package/sync/phases/settings.js +274 -0
  48. package/sync/phases/variables.js +132 -0
  49. package/tsdoc-metadata.json +1 -1
  50. package/bin/reposets.d.ts +0 -1
  51. package/errors.js +0 -12
  52. package/lib/crypto.js +0 -27
  53. package/services/GitHubClient.js +0 -875
  54. package/services/SyncEngine.js +0 -580
@@ -0,0 +1,274 @@
1
+ import { SyncLogger } from "../../services/SyncLogger.js";
2
+ import { AppliedState, lookup } from "../../store/AppliedState.js";
3
+ import { RepoCache } from "../../store/RepoCache.js";
4
+ import { read } from "./resource.js";
5
+ import { fingerprint } from "../../lib/fingerprint.js";
6
+ import { decide, isDrift, needsApply } from "../decide.js";
7
+ import { Effect, Option } from "effect";
8
+ import { GitHubRepository, Ruleset } from "@effected/github";
9
+
10
+ //#region src/sync/phases/settings.ts
11
+ /**
12
+ * Stands in for live state this phase cannot read.
13
+ *
14
+ * @remarks
15
+ * `decide` needs three fingerprints, and GitHub exposes no read of these
16
+ * resources comparable to what is written — the PATCH surface and the GET
17
+ * surface differ, and some fields are not on the GET at all. So live is taken
18
+ * to be the last applied value, and this sentinel covers the case where there
19
+ * is no last applied value.
20
+ *
21
+ * It must never equal a real fingerprint. Using the desired fingerprint here
22
+ * instead would make `FirstSync` compute `needsApply: desired !== live` as
23
+ * `false` — and the very first sync of a repository would write nothing at all.
24
+ *
25
+ * The consequence, stated plainly: **this phase reports config change, not
26
+ * drift.** A baseline that differs reads as `ConfigChanged`, never as `Drift`,
27
+ * because there is no independent observation to attribute the difference to.
28
+ */
29
+ const UNREADABLE = "\0unreadable";
30
+ const KIND = "settings";
31
+ /**
32
+ * A repository has exactly one settings resource, so its drift key is fixed.
33
+ *
34
+ * @remarks
35
+ * The alternative — one resource per field — would report twenty drift lines
36
+ * for one person toggling twenty checkboxes in the UI. Settings move together
37
+ * and are reported together.
38
+ */
39
+ const NAME = "settings";
40
+ /**
41
+ * `security_and_analysis` fields GitHub only accepts on organization-owned
42
+ * repositories.
43
+ *
44
+ * @remarks
45
+ * Sending one to a personal repository is rejected outright, taking the whole
46
+ * PATCH with it — so they are stripped and reported rather than attempted.
47
+ */
48
+ const ORG_ONLY_SAA_FIELDS = /* @__PURE__ */ new Set([
49
+ "secret_scanning_delegated_alert_dismissal",
50
+ "secret_scanning_delegated_bypass",
51
+ "delegated_bypass_reviewers"
52
+ ]);
53
+ /**
54
+ * Settings fields only valid on organization-owned repositories.
55
+ *
56
+ * @remarks
57
+ * Checkable from `ownerType` alone, which the engine already resolved — so
58
+ * these cost nothing to gate.
59
+ */
60
+ const ORG_ONLY_SETTINGS = /* @__PURE__ */ new Set();
61
+ /**
62
+ * Fields needing an organization owner **and** a private repository.
63
+ *
64
+ * @remarks
65
+ * `allow_forking` lived in {@link ORG_ONLY_SETTINGS} and was gated on owner type
66
+ * alone, which is half of GitHub's rule: it answers
67
+ *
68
+ * > Allow forks setting can only be changed on org-owned private repositories
69
+ *
70
+ * with a 422 that takes the **whole PATCH** with it — so one unsettable field
71
+ * costs the repository every other setting in the same request.
72
+ *
73
+ * Visibility is not in the config and not in `ownerType`, so gating these means
74
+ * asking GitHub. The read happens only when one of these fields actually
75
+ * survives the merge, so a config that never mentions them never pays for it.
76
+ */
77
+ const ORG_PRIVATE_ONLY_SETTINGS = /* @__PURE__ */ new Set(["allow_forking"]);
78
+ /**
79
+ * Merge every settings group the repository's group references.
80
+ *
81
+ * @remarks
82
+ * Last write wins, and `security_and_analysis` is merged separately: it is a
83
+ * nested block, so `Object.assign` across groups would replace it wholesale
84
+ * rather than merging its fields.
85
+ */
86
+ const mergeSettings = (ctx) => {
87
+ const refs = ctx.config.groups[ctx.group]?.settings ?? [];
88
+ const settings = {};
89
+ const saa = {};
90
+ let sawSaa = false;
91
+ for (const ref of refs) {
92
+ const group = ctx.config.settings[ref];
93
+ if (group === void 0) continue;
94
+ const { security_and_analysis, ...rest } = group;
95
+ Object.assign(settings, rest);
96
+ if (security_and_analysis !== void 0) {
97
+ sawSaa = true;
98
+ for (const [key, value] of Object.entries(security_and_analysis)) if (value !== void 0) saa[key] = value;
99
+ }
100
+ }
101
+ const skipped = [];
102
+ if (ctx.ownerType === "User") {
103
+ for (const key of ORG_PRIVATE_ONLY_SETTINGS) if (key in settings) {
104
+ delete settings[key];
105
+ skipped.push({
106
+ where: "setting",
107
+ key,
108
+ reason: "org-only, owner is a personal account"
109
+ });
110
+ }
111
+ for (const key of ORG_ONLY_SETTINGS) if (key in settings) {
112
+ delete settings[key];
113
+ skipped.push({
114
+ where: "setting",
115
+ key,
116
+ reason: "org-only, owner is a personal account"
117
+ });
118
+ }
119
+ for (const key of ORG_ONLY_SAA_FIELDS) if (key in saa) {
120
+ delete saa[key];
121
+ skipped.push({
122
+ where: "security_and_analysis",
123
+ key,
124
+ reason: "org-only, owner is a personal account"
125
+ });
126
+ }
127
+ }
128
+ return {
129
+ settings,
130
+ securityAndAnalysis: sawSaa && Object.keys(saa).length > 0 ? saa : void 0,
131
+ skipped,
132
+ conditional: [...ORG_PRIVATE_ONLY_SETTINGS].filter((key) => key in settings)
133
+ };
134
+ };
135
+ /**
136
+ * Repository settings, over REST and the GraphQL mutation.
137
+ *
138
+ * @remarks
139
+ * The whole settings block is one drift resource — see {@link NAME}.
140
+ *
141
+ * Delegated-bypass reviewers are config-level `{ team }` or `{ role }` names
142
+ * that GitHub wants as numeric `reviewer_id`. Resolution happens here, before
143
+ * the fingerprint is taken, so the fingerprint covers what was actually sent: a
144
+ * team renamed under the same id must not read as drift, and a team pointing at
145
+ * a different id must.
146
+ *
147
+ * @public
148
+ */
149
+ const settingsPhase = Effect.gen(function* () {
150
+ const settingsClient = yield* GitHubRepository;
151
+ const rulesets = yield* Ruleset;
152
+ const cache = yield* RepoCache;
153
+ const applied = yield* AppliedState;
154
+ const logger = yield* SyncLogger;
155
+ /** Turn `{ team }` / `{ role }` reviewer entries into `{ reviewer_id, reviewer_type }`. */
156
+ const resolveReviewers = (ctx, reviewers) => Effect.gen(function* () {
157
+ const resolved = [];
158
+ const errors = [];
159
+ for (const reviewer of reviewers) {
160
+ const entry = {};
161
+ if (typeof reviewer.team === "string") {
162
+ const slug = reviewer.team;
163
+ const id = yield* cache.teamId(ctx.owner, slug, rulesets.teamId(slug)).pipe(Effect.map(Option.some), Effect.orElseSucceed(() => Option.none()));
164
+ if (Option.isNone(id)) {
165
+ errors.push({
166
+ context: `resolve team '${slug}'`,
167
+ message: "could not resolve team slug"
168
+ });
169
+ continue;
170
+ }
171
+ entry.reviewer_id = id.value;
172
+ entry.reviewer_type = "TEAM";
173
+ } else if (typeof reviewer.role === "string") {
174
+ const name = reviewer.role;
175
+ const id = yield* rulesets.roleId(name).pipe(Effect.map(Option.some), Effect.orElseSucceed(() => Option.none()));
176
+ if (Option.isNone(id)) {
177
+ errors.push({
178
+ context: `resolve role '${name}'`,
179
+ message: "could not resolve organization role"
180
+ });
181
+ continue;
182
+ }
183
+ entry.reviewer_id = id.value;
184
+ entry.reviewer_type = "ROLE";
185
+ } else continue;
186
+ if (reviewer.mode !== void 0) entry.mode = reviewer.mode;
187
+ resolved.push(entry);
188
+ }
189
+ return {
190
+ resolved,
191
+ errors
192
+ };
193
+ });
194
+ const run = (ctx) => Effect.gen(function* () {
195
+ const merged = mergeSettings(ctx);
196
+ const errors = [];
197
+ const desired = { ...merged.settings };
198
+ const skipped = [...merged.skipped];
199
+ if (merged.conditional.length > 0) {
200
+ const repository = yield* read(cache.repoPrivate(ctx.owner, ctx.repo, settingsClient.settings));
201
+ if ("failed" in repository) errors.push({
202
+ context: "settings",
203
+ message: `could not read repository visibility: ${repository.failed}`
204
+ });
205
+ else if (!repository.value) for (const key of merged.conditional) {
206
+ delete desired[key];
207
+ skipped.push({
208
+ where: "setting",
209
+ key,
210
+ reason: "org-owned private repositories only; this one is public"
211
+ });
212
+ }
213
+ }
214
+ for (const { where, key, reason } of skipped) yield* logger.syncOperation("skip", where, key, `(${reason})`);
215
+ if (merged.securityAndAnalysis !== void 0) {
216
+ const saa = { ...merged.securityAndAnalysis };
217
+ const reviewers = saa.delegated_bypass_reviewers;
218
+ if (Array.isArray(reviewers)) {
219
+ const outcome = yield* resolveReviewers(ctx, reviewers);
220
+ errors.push(...outcome.errors);
221
+ saa.delegated_bypass_reviewers = outcome.resolved;
222
+ }
223
+ if (Object.keys(saa).length > 0) desired.security_and_analysis = saa;
224
+ }
225
+ if (Object.keys(desired).length === 0) return {
226
+ changes: [],
227
+ errors
228
+ };
229
+ const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
230
+ const baseline = Option.map(lookup(baselines, KIND, NAME), (record) => record.fingerprint);
231
+ const desiredPrint = fingerprint(desired);
232
+ const decision = decide(desiredPrint, Option.getOrElse(baseline, () => UNREADABLE), baseline);
233
+ if (isDrift(decision)) yield* logger.driftDetected(KIND, NAME, decision);
234
+ if (!needsApply(decision)) return {
235
+ changes: [],
236
+ errors
237
+ };
238
+ let sent;
239
+ if (!ctx.dryRun) {
240
+ const outcome = yield* settingsClient.applySettings(desired).pipe(Effect.map((applied) => ({ applied })), Effect.catch((error) => Effect.succeed({ failed: error.message ?? String(error) })));
241
+ if ("failed" in outcome) return {
242
+ changes: [],
243
+ errors: [...errors, {
244
+ context: KIND,
245
+ message: outcome.failed
246
+ }]
247
+ };
248
+ sent = [...outcome.applied.rest, ...outcome.applied.graphql];
249
+ yield* applied.record({
250
+ repo: ctx.slug,
251
+ kind: KIND,
252
+ name: NAME
253
+ }, desiredPrint, ctx.runId).pipe(Effect.ignore);
254
+ }
255
+ yield* logger.settingsApplied(sent ?? Object.keys(desired));
256
+ return {
257
+ changes: [{
258
+ repo: ctx.slug,
259
+ kind: KIND,
260
+ name: NAME,
261
+ action: isDrift(decision) ? "drift-overwritten" : "updated"
262
+ }],
263
+ errors
264
+ };
265
+ });
266
+ return {
267
+ name: "settings",
268
+ appliesTo: (ctx) => (ctx.config.groups[ctx.group]?.settings?.length ?? 0) > 0,
269
+ run
270
+ };
271
+ });
272
+
273
+ //#endregion
274
+ export { settingsPhase };
@@ -0,0 +1,132 @@
1
+ import { SyncLogger } from "../../services/SyncLogger.js";
2
+ import { AppliedState, lookup } from "../../store/AppliedState.js";
3
+ import { capture, resolveGroups } from "./resource.js";
4
+ import { fingerprint } from "../../lib/fingerprint.js";
5
+ import { decide, isDrift, needsApply } from "../decide.js";
6
+ import { Effect, Option, Redacted } from "effect";
7
+ import { RepositoryVariable } from "@effected/github";
8
+
9
+ //#region src/sync/phases/variables.ts
10
+ const KIND = "variable";
11
+ /**
12
+ * Stands in for a variable that is not on the repository at all.
13
+ *
14
+ * @remarks
15
+ * Absence is a live state like any other, and it needs a fingerprint distinct
16
+ * from every real value so `decide` can compare against it. Without it, a
17
+ * variable someone deleted out of band would compare equal to whatever we last
18
+ * applied and never be restored.
19
+ */
20
+ const ABSENT = "\0absent";
21
+ /** An environment-scoped variable is named `env/name` so the two never collide. */
22
+ const resourceName = (name, environment) => environment === void 0 ? name : `${environment}/${name}`;
23
+ /**
24
+ * Repository and environment variables.
25
+ *
26
+ * @remarks
27
+ * Unlike secrets, variables are readable — `listVariables` returns what is
28
+ * actually on the repository — so this phase does **real** three-way drift
29
+ * detection rather than the config-change-only comparison `settings` is limited
30
+ * to. Someone editing a variable in the UI is reported as drift and overwritten;
31
+ * someone deleting one is reported and restored.
32
+ *
33
+ * The listing carries values, not just names — variables are not secret, so
34
+ * GitHub returns them — which makes this a full three-way comparison at no
35
+ * extra request: an *edited* variable is as detectable as a deleted one. That
36
+ * is the real difference from `secrets`, where no projection change could help
37
+ * because GitHub never returns a secret value at any endpoint.
38
+ *
39
+ * Values arrive {@link Redacted.Redacted} from the shared resolver even though
40
+ * variables are not secret, so `Redacted.value` appears exactly once per write
41
+ * and never near a log line.
42
+ *
43
+ * @public
44
+ */
45
+ const variablesPhase = Effect.gen(function* () {
46
+ const variables = yield* RepositoryVariable;
47
+ const applied = yield* AppliedState;
48
+ const logger = yield* SyncLogger;
49
+ const run = (ctx) => Effect.gen(function* () {
50
+ const scopes = ctx.config.groups[ctx.group]?.variables;
51
+ if (scopes === void 0) return {
52
+ changes: [],
53
+ errors: []
54
+ };
55
+ const changes = [];
56
+ const errors = [];
57
+ const actions = resolveGroups(scopes.actions ?? [], ctx.config.variables, ctx);
58
+ errors.push(...actions.errors);
59
+ /** Every environment's resolved entries, by environment name. */
60
+ const byEnvironment = /* @__PURE__ */ new Map();
61
+ for (const [environment, refs] of Object.entries(scopes.environments ?? {})) {
62
+ const resolved = resolveGroups(refs, ctx.config.variables, ctx);
63
+ errors.push(...resolved.errors);
64
+ byEnvironment.set(environment, resolved.entries);
65
+ }
66
+ if (actions.entries.size === 0 && byEnvironment.size === 0) return {
67
+ changes,
68
+ errors
69
+ };
70
+ const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
71
+ /** What is currently on the repository, name to value, for one scope. */
72
+ const liveValues = (environment) => (environment === void 0 ? variables.list() : variables.listForEnvironment(environment)).pipe(Effect.map((list) => Option.some(new Map(list.map((entry) => [entry.name, entry.value])))), Effect.orElseSucceed(() => Option.none()));
73
+ const syncScope = (entries, environment) => Effect.gen(function* () {
74
+ if (entries.size === 0) return;
75
+ const live = yield* liveValues(environment);
76
+ if (Option.isNone(live)) {
77
+ errors.push({
78
+ context: environment === void 0 ? "list variables" : `list variables (env: ${environment})`,
79
+ message: "could not read current variables"
80
+ });
81
+ return;
82
+ }
83
+ for (const [name, value] of entries) {
84
+ const resource = resourceName(name, environment);
85
+ const desiredPrint = fingerprint(Redacted.value(value));
86
+ const baseline = Option.map(lookup(baselines, KIND, resource), (record) => record.fingerprint);
87
+ const liveValue = live.value.get(name);
88
+ const livePrint = liveValue === void 0 ? fingerprint(ABSENT) : fingerprint(liveValue);
89
+ const decision = decide(desiredPrint, livePrint, baseline);
90
+ if (isDrift(decision)) yield* logger.driftDetected(KIND, resource, decision);
91
+ if (!needsApply(decision)) continue;
92
+ if (!ctx.dryRun) {
93
+ const failure = yield* capture(`variable ${resource}`, environment === void 0 ? variables.set(name, Redacted.value(value)) : variables.setForEnvironment(environment, name, Redacted.value(value)));
94
+ if (failure !== void 0) {
95
+ errors.push(failure);
96
+ continue;
97
+ }
98
+ yield* applied.record({
99
+ repo: ctx.slug,
100
+ kind: KIND,
101
+ name: resource
102
+ }, desiredPrint, ctx.runId).pipe(Effect.ignore);
103
+ }
104
+ yield* logger.syncOperation("sync", KIND, name, environment === void 0 ? void 0 : `(env: ${environment})`);
105
+ changes.push({
106
+ repo: ctx.slug,
107
+ kind: KIND,
108
+ name: resource,
109
+ action: isDrift(decision) ? "drift-overwritten" : "updated"
110
+ });
111
+ }
112
+ });
113
+ yield* syncScope(actions.entries);
114
+ for (const [environment, entries] of byEnvironment) yield* syncScope(entries, environment);
115
+ return {
116
+ changes,
117
+ errors
118
+ };
119
+ });
120
+ return {
121
+ name: "variables",
122
+ appliesTo: (ctx) => {
123
+ const scopes = ctx.config.groups[ctx.group]?.variables;
124
+ if (scopes === void 0) return false;
125
+ return (scopes.actions?.length ?? 0) > 0 || Object.keys(scopes.environments ?? {}).length > 0;
126
+ },
127
+ run
128
+ };
129
+ });
130
+
131
+ //#endregion
132
+ export { variablesPhase };
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.58.9"
8
+ "packageVersion": "7.58.12"
9
9
  }
10
10
  ]
11
11
  }
package/bin/reposets.d.ts DELETED
@@ -1 +0,0 @@
1
- export { };
package/errors.js DELETED
@@ -1,12 +0,0 @@
1
- import { Data } from "effect";
2
-
3
- //#region src/errors.ts
4
- /* v8 ignore start -- TaggedError class declarations, covered via errors.test.ts */
5
- var ResolveError = class extends Data.TaggedError("ResolveError") {};
6
- var OnePasswordError = class extends Data.TaggedError("OnePasswordError") {};
7
- var GitHubApiError = class extends Data.TaggedError("GitHubApiError") {};
8
- var SyncError = class extends Data.TaggedError("SyncError") {};
9
- /* v8 ignore stop */
10
-
11
- //#endregion
12
- export { GitHubApiError, OnePasswordError, ResolveError, SyncError };
package/lib/crypto.js DELETED
@@ -1,27 +0,0 @@
1
- import { blake2b } from "blakejs";
2
- import nacl from "tweetnacl";
3
-
4
- //#region src/lib/crypto.ts
5
- /**
6
- * Encrypt a secret using libsodium's sealed box algorithm.
7
- * Implementation based on tweetsodium using tweetnacl + blakejs.
8
- *
9
- * The sealed box format is: ephemeral_public_key (32 bytes) || ciphertext
10
- */
11
- function encryptSecret(publicKey, secretValue) {
12
- const messageBytes = Buffer.from(secretValue);
13
- const publicKeyBytes = Buffer.from(publicKey, "base64");
14
- const ephemeralKeyPair = nacl.box.keyPair();
15
- const nonceInput = new Uint8Array(64);
16
- nonceInput.set(ephemeralKeyPair.publicKey);
17
- nonceInput.set(publicKeyBytes, 32);
18
- const nonce = blake2b(nonceInput, void 0, 24);
19
- const ciphertext = nacl.box(messageBytes, nonce, publicKeyBytes, ephemeralKeyPair.secretKey);
20
- const sealed = new Uint8Array(ephemeralKeyPair.publicKey.length + ciphertext.length);
21
- sealed.set(ephemeralKeyPair.publicKey);
22
- sealed.set(ciphertext, ephemeralKeyPair.publicKey.length);
23
- return Buffer.from(sealed).toString("base64");
24
- }
25
-
26
- //#endregion
27
- export { encryptSecret };