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
package/index.js CHANGED
@@ -1,19 +1,4 @@
1
- import { GitHubApiError, OnePasswordError, ResolveError, SyncError } from "./errors.js";
2
- import { encryptSecret } from "./lib/crypto.js";
3
- import { CleanupSchema, CleanupScopeSchema, SecretGroupSchema, VariableGroupSchema } from "./schemas/common.js";
4
- import { BypassActorSchema, ResolvedRefSchema, RulesetSchema, buildRulesetPayload } from "./schemas/ruleset.js";
5
- import { ConfigSchema, GroupSchema, LogLevelSchema } from "./schemas/config.js";
6
- import { CredentialProfileSchema, CredentialsSchema, ResolveSectionSchema } from "./schemas/credentials.js";
7
- import { CONFIG_FILENAME, CREDENTIALS_FILENAME, ConfigFilesLive, ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive, validateConfigRefs } from "./services/ConfigFiles.js";
8
- import { OnePasswordClient, OnePasswordClientLive, OnePasswordClientTest } from "./services/OnePasswordClient.js";
9
- import { CredentialResolver, CredentialResolverLive } from "./services/CredentialResolver.js";
10
- import { GitHubClient, GitHubClientLive, GitHubClientTest } from "./services/GitHubClient.js";
11
- import { SyncLogger, SyncLoggerLive } from "./services/SyncLogger.js";
12
- import { SyncEngine, SyncEngineLive } from "./services/SyncEngine.js";
13
- import { AppDirs, ConfigError as XdgConfigError, ConfigFile } from "xdg-effect";
1
+ import { ConfigSchema } from "./schemas/config.js";
2
+ import { CONFIG_FILENAME, ConfigFlagNotFound, ReposetsConfigFile, makeConfigFilesLive } from "./services/ConfigFiles.js";
14
3
 
15
- //#region src/index.ts
16
- /* v8 ignore stop */
17
-
18
- //#endregion
19
- export { AppDirs, BypassActorSchema, CONFIG_FILENAME, CREDENTIALS_FILENAME, CleanupSchema, CleanupScopeSchema, ConfigFile, ConfigFilesLive, ConfigSchema, CredentialProfileSchema, CredentialResolver, CredentialResolverLive, CredentialsSchema, GitHubApiError, GitHubClient, GitHubClientLive, GitHubClientTest, GroupSchema, LogLevelSchema, OnePasswordClient, OnePasswordClientLive, OnePasswordClientTest, OnePasswordError, ReposetsConfigFile, ReposetsCredentialsFile, ResolveError, ResolveSectionSchema, ResolvedRefSchema, RulesetSchema, SecretGroupSchema, SyncEngine, SyncEngineLive, SyncError, SyncLogger, SyncLoggerLive, VariableGroupSchema, XdgConfigError, buildRulesetPayload, encryptSecret, makeConfigFilesLive, validateConfigRefs };
4
+ export { CONFIG_FILENAME, ConfigFlagNotFound, ConfigSchema, ReposetsConfigFile, makeConfigFilesLive };
@@ -0,0 +1,76 @@
1
+ //#region src/lib/config-refs.ts
2
+ /**
3
+ * Every reference in `[groups.*]` that points at a section which is not defined.
4
+ *
5
+ * @remarks
6
+ * **This was lost in the v4 rebuild and is restored here.** v3 registered a
7
+ * `validateConfigRefs` callback on the config spec, so a dangling reference
8
+ * failed the load for every command. The rebuild dropped it and nothing took
9
+ * its place — while every phase kept skipping unresolvable references silently
10
+ * and *citing that callback by name* as the thing that owned the check.
11
+ *
12
+ * The result was the worst shape available: `settings = ["defualt"]` synced
13
+ * nothing, reported nothing, and `validate` printed `Valid:` and exited 0. The
14
+ * same failure as a misspelled `--repo` passing green, which is fixed
15
+ * elsewhere in this codebase for exactly the reason it matters here — a filter
16
+ * or a reference that silently matches nothing turns a destructive command into
17
+ * a no-op that looks like success.
18
+ *
19
+ * Checked where the reference is **used**, not where a section is defined: an
20
+ * unreferenced `[settings.*]` section is dead config, not an error, and a
21
+ * config may legitimately define sections some groups do not take.
22
+ *
23
+ * `defined` is carried on each finding so the report can show the near-misses.
24
+ * Nearly every one of these is a typo, and a typo is fixed by seeing the
25
+ * correct spelling rather than by being told the wrong one is wrong.
26
+ *
27
+ * @public
28
+ */
29
+ const danglingReferences = (config) => {
30
+ const found = [];
31
+ const sections = {
32
+ settings: Object.keys(config.settings),
33
+ rulesets: Object.keys(config.rulesets),
34
+ environments: Object.keys(config.environments ?? {}),
35
+ security: Object.keys(config.security ?? {}),
36
+ code_scanning: Object.keys(config.code_scanning ?? {}),
37
+ secrets: Object.keys(config.secrets),
38
+ variables: Object.keys(config.variables)
39
+ };
40
+ for (const [groupName, group] of Object.entries(config.groups)) {
41
+ const check = (where, names, defined) => {
42
+ for (const name of names) {
43
+ if (defined.includes(name)) continue;
44
+ found.push({
45
+ group: groupName,
46
+ where,
47
+ name,
48
+ defined
49
+ });
50
+ }
51
+ };
52
+ check(`groups.${groupName}.settings`, group.settings ?? [], sections.settings);
53
+ check(`groups.${groupName}.rulesets`, group.rulesets ?? [], sections.rulesets);
54
+ check(`groups.${groupName}.environments`, group.environments ?? [], sections.environments);
55
+ check(`groups.${groupName}.security`, group.security ?? [], sections.security);
56
+ check(`groups.${groupName}.code_scanning`, group.code_scanning ?? [], sections.code_scanning);
57
+ for (const scope of [
58
+ "actions",
59
+ "dependabot",
60
+ "codespaces"
61
+ ]) check(`groups.${groupName}.secrets.${scope}`, group.secrets?.[scope] ?? [], sections.secrets);
62
+ check(`groups.${groupName}.variables.actions`, group.variables?.actions ?? [], sections.variables);
63
+ for (const [environment, names] of Object.entries(group.secrets?.environments ?? {})) {
64
+ check(`groups.${groupName}.secrets.environments`, [environment], sections.environments);
65
+ check(`groups.${groupName}.secrets.environments.${environment}`, names, sections.secrets);
66
+ }
67
+ for (const [environment, names] of Object.entries(group.variables?.environments ?? {})) {
68
+ check(`groups.${groupName}.variables.environments`, [environment], sections.environments);
69
+ check(`groups.${groupName}.variables.environments.${environment}`, names, sections.variables);
70
+ }
71
+ }
72
+ return found;
73
+ };
74
+
75
+ //#endregion
76
+ export { danglingReferences };
Binary file
@@ -0,0 +1,52 @@
1
+ import blakejs from "blakejs";
2
+
3
+ //#region src/lib/fingerprint.ts
4
+ const { blake2bHex } = blakejs;
5
+ /**
6
+ * Serializes a value to JSON with object keys sorted at every depth.
7
+ *
8
+ * @remarks
9
+ * Two values that differ only in key order must hash identically, or every
10
+ * config reordering would read as drift. `JSON.stringify` preserves insertion
11
+ * order, so the sort has to be explicit.
12
+ *
13
+ * Arrays keep their order — element order is meaningful in this config (a
14
+ * ruleset's `rules`, an environment's reviewers), so reordering one is a real
15
+ * change and should register as drift.
16
+ *
17
+ * `undefined` is dropped from objects, matching `JSON.stringify`, so an absent
18
+ * key and an explicitly-undefined key fingerprint the same. That is correct
19
+ * here: both mean "not configured".
20
+ */
21
+ const canonical = (value) => {
22
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
23
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
24
+ return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
25
+ };
26
+ /**
27
+ * The digest length, in bytes. 32 gives a 64-character hex string.
28
+ */
29
+ const DIGEST_BYTES = 32;
30
+ /**
31
+ * Fingerprints the resolved desired state of one resource.
32
+ *
33
+ * @remarks
34
+ * Pure, and deliberately not an `Effect` — it performs no I/O and cannot fail
35
+ * for any input reposets produces. The argument is always already-decoded config
36
+ * data, which by construction is acyclic (it came from TOML).
37
+ *
38
+ * The fingerprint records what reposets last *pushed*, so a later run can tell
39
+ * "the config changed" from "someone changed this in the GitHub UI".
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * fingerprint({ b: 1, a: 2 }) === fingerprint({ a: 2, b: 1 }) // key order is not drift
44
+ * fingerprint([1, 2]) !== fingerprint([2, 1]) // element order is
45
+ * ```
46
+ *
47
+ * @public
48
+ */
49
+ const fingerprint = (value) => blake2bHex(canonical(value), void 0, DIGEST_BYTES);
50
+
51
+ //#endregion
52
+ export { fingerprint };
@@ -0,0 +1,61 @@
1
+ import { profileOwner } from "../schemas/credentials.js";
2
+
3
+ //#region src/lib/org-only.ts
4
+ /**
5
+ * Organization-only constructs assigned to personal-account groups.
6
+ *
7
+ * @remarks
8
+ * **The whole point of declaring `username` or `org` on a profile.** Every check
9
+ * here previously needed the owner's type, and the only way to learn it was to
10
+ * ask GitHub — so `validate`, whose entire contract is that it touches no
11
+ * network, could not make them at all. They surfaced as a 422 mid-sync instead,
12
+ * after earlier repositories had already been written.
13
+ *
14
+ * Three constructs need an organization, and each is checked where it is
15
+ * *referenced* rather than where it is defined: a ruleset with a team bypass
16
+ * actor is perfectly valid sitting in `[rulesets.*]`, and only becomes an error
17
+ * when a personal-account group assigns it. Reporting the definition would name
18
+ * a section the user may share between groups and would have to leave alone.
19
+ *
20
+ * This does not replace the check against GitHub. A declaration can be wrong,
21
+ * so `sync` still verifies the owner's type before writing; this catches the
22
+ * config error earlier and without a token.
23
+ *
24
+ * @public
25
+ */
26
+ const orgOnlyViolations = (config, credentials) => {
27
+ const violations = [];
28
+ for (const [groupName, group] of Object.entries(config.groups)) {
29
+ const profile = credentials.profiles[group.credentials];
30
+ if (profile === void 0) continue;
31
+ if (profileOwner(profile).ownerType !== "User") continue;
32
+ const add = (where, detail) => {
33
+ violations.push({
34
+ group: groupName,
35
+ profile: group.credentials,
36
+ where,
37
+ detail
38
+ });
39
+ };
40
+ for (const name of group.settings ?? []) {
41
+ const settings = config.settings[name];
42
+ if (settings === void 0) continue;
43
+ if (settings.secret_scanning_delegated_bypass !== void 0) add(`settings.${name}.secret_scanning_delegated_bypass`, "delegated bypass requires an organization");
44
+ if (settings.delegated_bypass_reviewers !== void 0) add(`settings.${name}.delegated_bypass_reviewers`, "bypass reviewers require an organization");
45
+ }
46
+ for (const name of group.rulesets ?? []) {
47
+ const ruleset = config.rulesets[name];
48
+ if (ruleset === void 0) continue;
49
+ for (const actor of ruleset.bypass_actors ?? []) if (actor.actor_type === "Team") add(`rulesets.${name}.bypass_actors`, "a Team bypass actor requires an organization");
50
+ }
51
+ for (const name of group.environments ?? []) {
52
+ const environment = config.environments?.[name];
53
+ if (environment === void 0) continue;
54
+ for (const reviewer of environment.reviewers ?? []) if (reviewer.type === "Team") add(`environments.${name}.reviewers`, "a Team reviewer requires an organization");
55
+ }
56
+ }
57
+ return violations;
58
+ };
59
+
60
+ //#endregion
61
+ export { orgOnlyViolations };
@@ -0,0 +1,50 @@
1
+ import { SchemaIssue } from "effect";
2
+
3
+ //#region src/lib/schema-issues.ts
4
+ /**
5
+ * Core's structured formatter: one entry per leaf, each with its property path.
6
+ *
7
+ * @remarks
8
+ * Built once. It is a pure function of the issue tree, and `Formatter` carries
9
+ * no state.
10
+ */
11
+ const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => issue._tag === "UnexpectedKey" ? "unknown key" : SchemaIssue.defaultLeafHook(issue) });
12
+ /**
13
+ * Render a `ConfigValidationError`'s `issue` as one line per rejected value.
14
+ *
15
+ * @remarks
16
+ * **No walker of our own.** Core already ships one:
17
+ * `SchemaIssue.makeFormatterStandardSchemaV1()` flattens the tree to
18
+ * `{ message, path }` entries, which is exactly the shape this needs.
19
+ * `defaultLeafHook` handles every leaf variant — `UnexpectedKey`,
20
+ * `InvalidType`, `MissingKey`, `Forbidden`, `OneOf` — so a wrong *type* renders
21
+ * as sensibly as an unknown key, and a variant added upstream is covered
22
+ * without a change here. Hand-rolling this against the excess-property shape
23
+ * alone would have got every other cause wrong.
24
+ *
25
+ * `issue` is declared `Schema.Defect`, so it arrives as `unknown`; the
26
+ * `isIssue` guard is what makes reading it safe. Anything that is not an issue
27
+ * tree yields no lines rather than a crash — a rendering helper on an error
28
+ * path must never become the reason a command dies.
29
+ *
30
+ * Nodes are never stringified. Each carries the entire AST inline, annotations
31
+ * included, so `String(node)` would dump the schema rather than describe the
32
+ * failure. Only `message` and `path` are read.
33
+ *
34
+ * @param issue - the `issue` field of a `ConfigValidationError`, or any value
35
+ * @returns one line per rejected value, deepest path last; empty if `issue` is
36
+ * not an issue tree
37
+ *
38
+ * @public
39
+ */
40
+ const formatSchemaIssue = (issue) => {
41
+ if (!SchemaIssue.isIssue(issue)) return [];
42
+ const lines = formatter(issue).issues.map((entry) => {
43
+ const path = (entry.path ?? []).map(String).join(".");
44
+ return path === "" ? entry.message : `${entry.message} at ${path}`;
45
+ });
46
+ return [...new Set(lines)];
47
+ };
48
+
49
+ //#endregion
50
+ export { formatSchemaIssue };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reposets",
3
- "version": "0.4.2",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
5
  "description": "CLI tool to sync GitHub repo settings, secrets and rulesets across personal repositories",
6
6
  "keywords": [
@@ -38,7 +38,8 @@
38
38
  "exports": {
39
39
  ".": {
40
40
  "types": "./index.d.ts",
41
- "import": "./index.js"
41
+ "import": "./index.js",
42
+ "default": "./index.js"
42
43
  },
43
44
  "./package.json": "./package.json"
44
45
  },
@@ -47,15 +48,15 @@
47
48
  },
48
49
  "dependencies": {
49
50
  "@1password/sdk": "^0.4.0",
50
- "@effect/cli": "^0.75.2",
51
- "@effect/platform": "^0.96.1",
52
- "@effect/platform-node": "^0.107.0",
53
- "@octokit/rest": "^22.0.1",
51
+ "@effect/platform-node": "4.0.0-beta.107",
52
+ "@effected/app": "^0.10.0",
53
+ "@effected/config-file": "^0.4.0",
54
+ "@effected/github": "^0.4.0",
55
+ "@effected/store": "^0.3.0",
56
+ "@effected/toml": "^0.4.0",
57
+ "@effected/xdg": "^0.2.1",
54
58
  "blakejs": "^1.2.1",
55
- "effect": "^3.21.3",
56
- "smol-toml": ">=1.6.1",
57
- "tweetnacl": "^1.0.3",
58
- "xdg-effect": "^1.0.3"
59
+ "effect": "4.0.0-beta.107"
59
60
  },
60
61
  "engines": {
61
62
  "node": ">=24.11.0"
@@ -0,0 +1,81 @@
1
+ //#region src/schemas/annotations.ts
2
+ /**
3
+ * Build the `x-taplo` annotation key from typed options.
4
+ *
5
+ * @remarks
6
+ * The v3 tree imported this from `xdg-effect`, which re-exported it from
7
+ * `json-schema-effect`. Both are gone and `@effected/schemastore` ships no
8
+ * replacement builder — what it ships is the other half: `KeywordFamilies`
9
+ * declares `x-taplo*` and `x-tombi-*` as carried families, and
10
+ * `StoreDocument.fromSchema` wires them into core's `includeAnnotationKey`
11
+ * predicate so they survive the Draft-07 lowering. So the mechanism is intact
12
+ * and only the two constructors needed rebuilding; they live here rather than
13
+ * being inlined at ~30 call sites, which is where the exact key spellings would
14
+ * otherwise drift.
15
+ *
16
+ * The other half of the change is where the result goes. v3 nested it under a
17
+ * `jsonSchema` annotation; v4 has no such annotation, and non-standard keys are
18
+ * written at the top level of `.annotate({ ... })` instead. Spread the result
19
+ * in: `.annotate({ ...taplo({ ... }), title: "..." })`.
20
+ *
21
+ * Taplo ignores `x-taplo` on a node that also carries `$ref`. That is a Taplo
22
+ * limitation and the helper does not work around it — the v3 helper did not
23
+ * either.
24
+ *
25
+ * @public
26
+ */
27
+ const taplo = (options) => {
28
+ const out = {};
29
+ if (options.hidden !== void 0) out.hidden = options.hidden;
30
+ if (options.docs !== void 0) out.docs = options.docs;
31
+ if (options.links !== void 0) out.links = options.links;
32
+ if (options.initKeys !== void 0) out.initKeys = options.initKeys;
33
+ for (const [key, value] of Object.entries(options.custom ?? {})) out[key] = value;
34
+ return { "x-taplo": out };
35
+ };
36
+ /** The `TombiOptions` field → `x-tombi-*` key mapping, in the v3 helper's order. */
37
+ const tombiKeys = [
38
+ ["additionalKeyLabel", "x-tombi-additional-key-label"],
39
+ ["tableKeysOrder", "x-tombi-table-keys-order"],
40
+ ["arrayValuesOrder", "x-tombi-array-values-order"],
41
+ ["arrayValuesOrderBy", "x-tombi-array-values-order-by"],
42
+ ["stringFormats", "x-tombi-string-formats"],
43
+ ["tomlVersion", "x-tombi-toml-version"]
44
+ ];
45
+ /**
46
+ * Build the `x-tombi-*` annotation keys from typed options.
47
+ *
48
+ * @remarks
49
+ * See {@link taplo} for why these helpers are local. Unlike `x-taplo`, tombi's
50
+ * options are separate top-level keys rather than members of one object, so the
51
+ * result usually spreads alongside other annotations:
52
+ * `.annotate({ ...tombi({ tableKeysOrder: "schema" }), title: "..." })`.
53
+ *
54
+ * @public
55
+ */
56
+ const tombi = (options) => {
57
+ const out = {};
58
+ for (const [field, key] of tombiKeys) {
59
+ const value = options[field];
60
+ if (value !== void 0) out[key] = value;
61
+ }
62
+ for (const [key, value] of Object.entries(options.custom ?? {})) out[key] = value;
63
+ return out;
64
+ };
65
+ /**
66
+ * The URL of a page under the repository's `docs/`, for a `links.key`
67
+ * annotation.
68
+ *
69
+ * @remarks
70
+ * Every v3 call site spelled the same prefix by hand. Collapsing it here keeps
71
+ * the annotations from drifting apart when the docs move. It returns the URL
72
+ * rather than a finished `x-taplo` object on purpose: several call sites pass
73
+ * `initKeys` in the same `taplo()` call, and two separate `taplo()` results
74
+ * spread into one `annotate` would silently overwrite each other's `x-taplo`.
75
+ *
76
+ * @public
77
+ */
78
+ const docs = (page) => `https://github.com/spencerbeggs/reposets/blob/main/docs/${page}`;
79
+
80
+ //#endregion
81
+ export { docs, taplo, tombi };
package/schemas/common.js CHANGED
@@ -1,129 +1,164 @@
1
- import { Jsonifiable, taplo, tombi } from "xdg-effect";
2
- import { Schema } from "effect";
1
+ import { docs, taplo, tombi } from "./annotations.js";
2
+ import { Effect, Schema } from "effect";
3
3
 
4
4
  //#region src/schemas/common.ts
5
- const ResourceFileKind = Schema.Struct({ file: Schema.Record({
6
- key: Schema.String,
7
- value: Schema.String
8
- }).annotations({
5
+ const ResourceFileKind = Schema.Struct({ file: Schema.Record(Schema.String, Schema.String).annotate({
6
+ ...tombi({ additionalKeyLabel: "name" }),
9
7
  title: "File entries",
10
- description: "Named entries with file path values, resolved relative to config directory",
11
- jsonSchema: tombi({ additionalKeyLabel: "name" })
8
+ description: "Named entries with file path values, resolved relative to config directory"
12
9
  }) });
13
- const ResourceValueKind = Schema.Struct({ value: Schema.Record({
14
- key: Schema.String,
15
- value: Schema.Union(Schema.String, Schema.Record({
16
- key: Schema.String,
17
- value: Jsonifiable
18
- }))
19
- }).annotations({
10
+ const ResourceValueKind = Schema.Struct({ value: Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Json)])).annotate({
11
+ ...tombi({ additionalKeyLabel: "name" }),
20
12
  title: "Value entries",
21
- description: "Named entries with inline values. Strings used as-is, objects JSON-stringified.",
22
- jsonSchema: tombi({ additionalKeyLabel: "name" })
13
+ description: "Named entries with inline values. Strings used as-is, objects JSON-stringified."
23
14
  }) });
24
- const ResourceResolvedKind = Schema.Struct({ resolved: Schema.Record({
25
- key: Schema.String,
26
- value: Schema.String
27
- }).annotations({
15
+ const ResourceResolvedKind = Schema.Struct({ resolved: Schema.Record(Schema.String, Schema.String).annotate({
16
+ ...tombi({ additionalKeyLabel: "name" }),
28
17
  title: "Resolved entries",
29
- description: "Named entries mapped to credential labels. Values come from the active credential profile.",
30
- jsonSchema: tombi({ additionalKeyLabel: "name" })
18
+ description: "Named entries mapped to credential labels. Values come from the active credential profile."
31
19
  }) });
32
- const SecretGroupSchema = Schema.Union(ResourceFileKind, ResourceValueKind, ResourceResolvedKind).annotations({
20
+ /**
21
+ * A group of secrets: exactly one of `file`, `value` or `resolved`.
22
+ *
23
+ * @public
24
+ */
25
+ const SecretGroupSchema = Schema.Union([
26
+ ResourceFileKind,
27
+ ResourceValueKind,
28
+ ResourceResolvedKind
29
+ ]).annotate({
30
+ ...taplo({ links: { key: docs("05-secrets-and-variables.md") } }),
33
31
  identifier: "SecretGroup",
34
32
  title: "Secret group",
35
- description: "A group of secrets. Must be exactly one kind: file, value, or resolved.",
36
- jsonSchema: taplo({ links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/secrets-and-variables.md" } })
33
+ description: "A group of secrets. Must be exactly one kind: file, value, or resolved."
37
34
  });
38
- const VariableGroupSchema = Schema.Union(ResourceFileKind, ResourceValueKind, ResourceResolvedKind).annotations({
35
+ /**
36
+ * A group of variables: exactly one of `file`, `value` or `resolved`.
37
+ *
38
+ * @public
39
+ */
40
+ const VariableGroupSchema = Schema.Union([
41
+ ResourceFileKind,
42
+ ResourceValueKind,
43
+ ResourceResolvedKind
44
+ ]).annotate({
45
+ ...taplo({ links: { key: docs("05-secrets-and-variables.md") } }),
39
46
  identifier: "VariableGroup",
40
47
  title: "Variable group",
41
- description: "A group of variables. Must be exactly one kind: file, value, or resolved.",
42
- jsonSchema: taplo({ links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/secrets-and-variables.md" } })
48
+ description: "A group of variables. Must be exactly one kind: file, value, or resolved."
43
49
  });
44
- const CleanupScopeSchema = Schema.Union(Schema.Boolean, Schema.Struct({ preserve: Schema.Array(Schema.String).annotations({
50
+ /**
51
+ * Cleanup policy for one resource scope.
52
+ *
53
+ * @remarks
54
+ * Three-way on purpose: `false` disables cleanup, `true` deletes everything
55
+ * undeclared, and `{ preserve }` deletes everything undeclared except the named
56
+ * resources. Omitting the scope means `false` — leave it alone.
57
+ *
58
+ * @public
59
+ */
60
+ const CleanupScopeSchema = Schema.Union([Schema.Boolean, Schema.Struct({ preserve: Schema.Array(Schema.String).annotate({
45
61
  title: "Preserve list",
46
62
  description: "Resource names that should never be deleted during cleanup",
47
63
  examples: [["LEGACY_TOKEN", "DEPLOY_KEY"]]
48
- }) })).annotations({
64
+ }) })]).annotate({
49
65
  identifier: "CleanupScope",
50
66
  title: "Cleanup scope",
51
67
  description: "Controls cleanup for a single resource scope. false disables cleanup, true enables full cleanup, or specify names to preserve."
52
68
  });
69
+ /**
70
+ * A cleanup scope that defaults to `false` when the key is absent.
71
+ *
72
+ * @remarks
73
+ * v3 wrote this as `Schema.optionalWith(CleanupScopeSchema, { default: () => false })`.
74
+ * v4 splits that into `optionalKey` on the encoded side plus a decoding default;
75
+ * `withDecodingDefaultKey` is the combinator that does both, and it takes the
76
+ * default as an `Effect` rather than a thunk.
77
+ */
78
+ const cleanupScopeDefaultingToFalse = CleanupScopeSchema.pipe(Schema.withDecodingDefaultKey(Effect.succeed(false)));
53
79
  const CleanupSecretsSchema = Schema.Struct({
54
- actions: Schema.optionalWith(CleanupScopeSchema, { default: () => false }).annotations({
80
+ actions: cleanupScopeDefaultingToFalse.annotate({
55
81
  title: "Clean up Actions secrets",
56
82
  description: "Delete Actions secrets not declared in any referenced secret group",
57
83
  default: false
58
84
  }),
59
- dependabot: Schema.optionalWith(CleanupScopeSchema, { default: () => false }).annotations({
85
+ dependabot: cleanupScopeDefaultingToFalse.annotate({
60
86
  title: "Clean up Dependabot secrets",
61
87
  description: "Delete Dependabot secrets not declared in any referenced secret group",
62
88
  default: false
63
89
  }),
64
- codespaces: Schema.optionalWith(CleanupScopeSchema, { default: () => false }).annotations({
90
+ codespaces: cleanupScopeDefaultingToFalse.annotate({
65
91
  title: "Clean up Codespaces secrets",
66
92
  description: "Delete Codespaces secrets not declared in any referenced secret group",
67
93
  default: false
68
94
  }),
69
- environments: Schema.optionalWith(CleanupScopeSchema, { default: () => false }).annotations({
95
+ environments: cleanupScopeDefaultingToFalse.annotate({
70
96
  title: "Clean up environment secrets",
71
97
  description: "Delete environment secrets not declared in any referenced secret group",
72
98
  default: false
73
99
  })
74
- }).annotations({
100
+ }).annotate({
75
101
  identifier: "CleanupSecrets",
76
102
  title: "Secrets cleanup configuration",
77
103
  description: "Controls deletion of secrets by scope (Actions, Dependabot, Codespaces, environments)."
78
104
  });
79
105
  const CleanupVariablesSchema = Schema.Struct({
80
- actions: Schema.optionalWith(CleanupScopeSchema, { default: () => false }).annotations({
106
+ actions: cleanupScopeDefaultingToFalse.annotate({
81
107
  title: "Clean up Actions variables",
82
108
  description: "Delete Actions variables not declared in any referenced variable group",
83
109
  default: false
84
110
  }),
85
- environments: Schema.optionalWith(CleanupScopeSchema, { default: () => false }).annotations({
111
+ environments: cleanupScopeDefaultingToFalse.annotate({
86
112
  title: "Clean up environment variables",
87
113
  description: "Delete environment variables not declared in any referenced variable group",
88
114
  default: false
89
115
  })
90
- }).annotations({
116
+ }).annotate({
91
117
  identifier: "CleanupVariables",
92
118
  title: "Variables cleanup configuration",
93
119
  description: "Controls deletion of variables by scope (Actions, environments)."
94
120
  });
121
+ /**
122
+ * Per-group cleanup configuration.
123
+ *
124
+ * @remarks
125
+ * Everything defaults to disabled. Deleting resources a config does not declare
126
+ * is destructive and opt-in at every level.
127
+ *
128
+ * @public
129
+ */
95
130
  const CleanupSchema = Schema.Struct({
96
- secrets: Schema.optionalWith(CleanupSecretsSchema, { default: () => ({
131
+ secrets: CleanupSecretsSchema.pipe(Schema.withDecodingDefaultKey(Effect.succeed({
97
132
  actions: false,
98
133
  dependabot: false,
99
134
  codespaces: false,
100
135
  environments: false
101
- }) }).annotations({
136
+ }))).annotate({
102
137
  title: "Secrets cleanup",
103
138
  description: "Controls cleanup of secrets by scope"
104
139
  }),
105
- variables: Schema.optionalWith(CleanupVariablesSchema, { default: () => ({
140
+ variables: CleanupVariablesSchema.pipe(Schema.withDecodingDefaultKey(Effect.succeed({
106
141
  actions: false,
107
142
  environments: false
108
- }) }).annotations({
143
+ }))).annotate({
109
144
  title: "Variables cleanup",
110
145
  description: "Controls cleanup of variables by scope"
111
146
  }),
112
- rulesets: Schema.optionalWith(CleanupScopeSchema, { default: () => false }).annotations({
147
+ rulesets: cleanupScopeDefaultingToFalse.annotate({
113
148
  title: "Clean up rulesets",
114
149
  description: "Delete repository rulesets not declared in any referenced ruleset group",
115
150
  default: false
116
151
  }),
117
- environments: Schema.optionalWith(CleanupScopeSchema, { default: () => false }).annotations({
152
+ environments: cleanupScopeDefaultingToFalse.annotate({
118
153
  title: "Clean up environments",
119
154
  description: "Delete repository environments not declared in config",
120
155
  default: false
121
156
  })
122
- }).annotations({
157
+ }).annotate({
158
+ ...taplo({ links: { key: docs("08-cleanup.md") } }),
123
159
  identifier: "Cleanup",
124
160
  title: "Cleanup configuration",
125
- description: "Controls deletion of resources not declared in config. All disabled by default.",
126
- jsonSchema: taplo({ links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/cleanup.md" } })
161
+ description: "Controls deletion of resources not declared in config. All disabled by default."
127
162
  });
128
163
 
129
164
  //#endregion