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
@@ -1,78 +1,214 @@
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/credentials.ts
5
+ const CREDENTIALS_DOCS = docs("04-credentials.md");
6
+ /**
7
+ * A 1Password secret reference.
8
+ *
9
+ * @public
10
+ */
11
+ const OpReferenceSchema = Schema.Struct({ op: Schema.String.annotate({
12
+ title: "1Password reference",
13
+ description: "An op:// secret reference, resolved through the 1Password SDK at run time",
14
+ examples: ["op://Vault/item/field", "op://Vault/item/section/field"]
15
+ }) }).annotate({
16
+ identifier: "OpReference",
17
+ title: "1Password reference",
18
+ description: "Resolved through the 1Password SDK using OP_SERVICE_ACCOUNT_TOKEN from the environment"
19
+ });
20
+ /**
21
+ * An environment-variable reference.
22
+ *
23
+ * @public
24
+ */
25
+ const EnvReferenceSchema = Schema.Struct({ env: Schema.String.annotate({
26
+ title: "Environment variable",
27
+ description: "The name of an environment variable holding the value",
28
+ examples: ["REPOSETS_GITHUB_TOKEN"]
29
+ }) }).annotate({
30
+ identifier: "EnvReference",
31
+ title: "Environment variable reference",
32
+ description: "For CI, where the platform's secret store injects the value"
33
+ });
34
+ /**
35
+ * Where a credential comes from.
36
+ *
37
+ * @remarks
38
+ * **Deliberately never an inline value.** A credentials file holds references,
39
+ * not secrets: every variant here points somewhere else, so the file itself is
40
+ * safe to read, back up, and sit in a terminal scrollback.
41
+ *
42
+ * `{ op }` is the intended form. `{ env }` exists for CI, where a platform
43
+ * secret store injects the value and no 1Password service account is available.
44
+ *
45
+ * @public
46
+ */
47
+ const CredentialSourceSchema = Schema.Union([OpReferenceSchema, EnvReferenceSchema]).annotate({
48
+ identifier: "CredentialSource",
49
+ title: "Credential source",
50
+ description: "Exactly one of op (1Password reference) or env (environment variable name)"
51
+ });
52
+ /**
53
+ * Named values referenced by `resolved` secret and variable groups.
54
+ *
55
+ * @remarks
56
+ * **`value` holds named values, not only secrets.** An earlier revision removed
57
+ * it by generalising the token rule — `github_token` and the service-account
58
+ * token are unambiguously credentials and must be references. This section is
59
+ * not: real configs use it for structured, non-secret data such as an SBOM
60
+ * supplier block or a registry list, and forcing those into 1Password buys no
61
+ * security and costs a round trip.
62
+ *
63
+ * The rule that does hold: a *credential* is never inlined. That is enforced
64
+ * where credentials are declared, not here.
65
+ *
66
+ * `file` and `env` are pointers — the value lives outside this file.
67
+ *
68
+ * @public
69
+ */
5
70
  const ResolveSectionSchema = Schema.Struct({
6
- op: Schema.optional(Schema.Record({
7
- key: Schema.String,
8
- value: Schema.String
9
- }).annotations({
71
+ op: Schema.optional(Schema.Record(Schema.String, Schema.String).annotate({
72
+ ...tombi({ additionalKeyLabel: "label" }),
10
73
  title: "1Password references",
11
- description: "Named values resolved via 1Password SDK. Values are op:// reference strings.",
12
- jsonSchema: tombi({ additionalKeyLabel: "label" })
74
+ description: "Named values resolved via the 1Password SDK. Values are op:// reference strings."
75
+ })),
76
+ env: Schema.optional(Schema.Record(Schema.String, Schema.String).annotate({
77
+ ...tombi({ additionalKeyLabel: "label" }),
78
+ title: "Environment variable references",
79
+ description: "Named values read from the environment. Values are variable names, not values."
13
80
  })),
14
- file: Schema.optional(Schema.Record({
15
- key: Schema.String,
16
- value: Schema.String
17
- }).annotations({
81
+ file: Schema.optional(Schema.Record(Schema.String, Schema.String).annotate({
82
+ ...tombi({ additionalKeyLabel: "label" }),
18
83
  title: "File references",
19
- description: "Named values read from files. Values are file paths relative to the credentials directory.",
20
- jsonSchema: tombi({ additionalKeyLabel: "label" })
84
+ description: "Named values read from files. Values are paths relative to the credentials directory."
21
85
  })),
22
- value: Schema.optional(Schema.Record({
23
- key: Schema.String,
24
- value: Schema.Union(Schema.String, Schema.Record({
25
- key: Schema.String,
26
- value: Jsonifiable
27
- }))
28
- }).annotations({
86
+ value: Schema.optional(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Json])).annotate({
87
+ ...tombi({ additionalKeyLabel: "label" }),
29
88
  title: "Inline values",
30
- description: "Named inline values. Strings are used as-is, objects are JSON-stringified.",
31
- jsonSchema: tombi({ additionalKeyLabel: "label" })
89
+ description: "Named inline values. Strings are used as-is, objects are JSON-stringified."
32
90
  }))
33
- }).annotations({
91
+ }).annotate({
92
+ ...taplo({ links: { key: CREDENTIALS_DOCS } }),
34
93
  identifier: "ResolveSection",
35
94
  title: "Resolve section",
36
- description: "Named values resolved from 1Password, files, or inline. Referenced by resolved entries in secret and variable groups.",
37
- jsonSchema: taplo({ links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/credentials.md" } })
95
+ description: "Named values resolved from 1Password, the environment, or files, for use by resolved groups"
38
96
  });
39
- const CredentialProfileSchema = Schema.Struct({
40
- github_token: Schema.String.annotations({
97
+ /** Everything a profile carries apart from who it acts as. */
98
+ const profileFields = {
99
+ github_token: CredentialSourceSchema.annotate({
100
+ ...taplo({ links: { key: CREDENTIALS_DOCS } }),
41
101
  title: "GitHub token",
42
- description: "A GitHub personal access token (fine-grained) with administration, secrets, variables, environments, and GPG keys permissions",
43
- examples: ["ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]
102
+ description: "Reference to a fine-grained personal access token. Never the token itself use { op = \"op://...\" } or { env = \"VAR\" }."
44
103
  }),
45
- op_service_account_token: Schema.optional(Schema.String.annotations({
46
- title: "1Password service account token",
47
- description: "A 1Password service account token for resolving op:// secret references",
48
- examples: ["ops_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]
49
- })),
50
104
  resolve: Schema.optional(ResolveSectionSchema)
51
- }).annotations({
105
+ };
106
+ /**
107
+ * A profile acting as a personal account.
108
+ *
109
+ * @remarks
110
+ * `org` is forbidden rather than merely absent. A plain union of two structs
111
+ * accepts a profile declaring **both** and silently drops the second — verified
112
+ * against the installed beta — so the invalid state would be representable and
113
+ * quietly resolved, which is the failure this shape exists to prevent.
114
+ */
115
+ const UserProfileSchema = Schema.Struct({
116
+ username: Schema.String.annotate({
117
+ title: "GitHub username",
118
+ description: "The personal account this profile acts as. Mutually exclusive with org.",
119
+ examples: ["spencerbeggs"]
120
+ }),
121
+ org: Schema.optional(Schema.Never),
122
+ ...profileFields
123
+ });
124
+ /** A profile acting within an organization. */
125
+ const OrgProfileSchema = Schema.Struct({
126
+ org: Schema.String.annotate({
127
+ title: "GitHub organization",
128
+ description: "The organization this profile acts within. Mutually exclusive with username.",
129
+ examples: ["savvy-web"]
130
+ }),
131
+ username: Schema.optional(Schema.Never),
132
+ ...profileFields
133
+ });
134
+ /**
135
+ * A named credential profile: who it acts as, and what it can resolve.
136
+ *
137
+ * @remarks
138
+ * **The owner belongs here, not in the config.** A token authenticates as an
139
+ * identity, so the account it acts on is a property of the credential rather
140
+ * than of the repositories being configured. Declaring it as `username` or
141
+ * `org` — two keys, not an `owner` plus an `owner_type` — makes an owner
142
+ * without a type unrepresentable.
143
+ *
144
+ * The type is what makes `validate` able to reject an organization-only
145
+ * construct without a network call: team-based ruleset actors, environment
146
+ * team reviewers and `delegated_bypass_reviewers` all need an organization, and
147
+ * before this the only way to learn the owner's type was to ask GitHub.
148
+ *
149
+ * **It is a declaration, not proof.** `sync` still verifies it against the API
150
+ * before writing, because a wrong declaration would otherwise produce exactly
151
+ * the 422 the gating exists to prevent.
152
+ *
153
+ * One owner per profile. A token reaching several owners is declared as several
154
+ * profiles sharing a token reference — repetitive, but it keeps every group's
155
+ * owner a single lookup with no fallback chain.
156
+ *
157
+ * @public
158
+ */
159
+ const CredentialProfileSchema = Schema.Union([UserProfileSchema, OrgProfileSchema]).annotate({
160
+ ...taplo({
161
+ initKeys: ["github_token"],
162
+ links: { key: CREDENTIALS_DOCS }
163
+ }),
52
164
  identifier: "CredentialProfile",
53
165
  title: "Credential profile",
54
- description: "Authentication credentials for a GitHub account with optional named values for secret and variable resolution",
55
- jsonSchema: taplo({
56
- initKeys: ["github_token"],
57
- links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/credentials.md" }
58
- })
166
+ description: "Exactly one of username or org, a GitHub token reference, and any named values to resolve"
59
167
  });
60
- const CredentialsSchema = Schema.Struct({ profiles: Schema.optionalWith(Schema.Record({
61
- key: Schema.String,
62
- value: CredentialProfileSchema
63
- }).annotations({
168
+ /**
169
+ * Who a profile acts as.
170
+ *
171
+ * @remarks
172
+ * One reader for the two-key encoding, so no consumer re-derives the owner type
173
+ * from which key happens to be present. The schema guarantees exactly one is
174
+ * set, which is what lets this return a total value rather than an option.
175
+ *
176
+ * @public
177
+ */
178
+ const profileOwner = (profile) => "username" in profile && typeof profile.username === "string" ? {
179
+ owner: profile.username,
180
+ ownerType: "User"
181
+ } : {
182
+ owner: profile.org,
183
+ ownerType: "Organization"
184
+ };
185
+ /**
186
+ * `reposets.credentials.toml`.
187
+ *
188
+ * @remarks
189
+ * **No credential is ever written to this file.** `github_token` is a reference,
190
+ * and the 1Password service-account token is not in the schema at all.
191
+ *
192
+ * `resolve.value` can still hold an inline value, because that section is for
193
+ * named values generally and its common use is non-secret structured data. So
194
+ * this file is not categorically secret-free — it is *credential*-free, which is
195
+ * the property that matters. Keep it out of version control regardless.
196
+ *
197
+ * @public
198
+ */
199
+ const CredentialsSchema = Schema.Struct({ profiles: Schema.Record(Schema.String, CredentialProfileSchema).annotate({
200
+ ...tombi({ additionalKeyLabel: "profile_name" }),
64
201
  title: "Credential profiles",
65
- description: "Named credential profiles. If only one profile is defined, it is used automatically for all repo groups.",
66
- jsonSchema: tombi({ additionalKeyLabel: "profile_name" })
67
- }), { default: () => ({}) }) }).annotations({
202
+ description: "Named credential profiles. Every group names one explicitly in its `credentials` field."
203
+ }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({}))) }).annotate({
204
+ ...taplo({
205
+ initKeys: ["profiles"],
206
+ links: { key: CREDENTIALS_DOCS }
207
+ }),
68
208
  identifier: "Credentials",
69
209
  title: "reposets Credentials",
70
- description: "Authentication profiles for reposets. This file should be gitignored.",
71
- jsonSchema: taplo({
72
- initKeys: ["profiles"],
73
- links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/credentials.md" }
74
- })
210
+ description: "Credential references for reposets. Contains no secret values."
75
211
  });
76
212
 
77
213
  //#endregion
78
- export { CredentialProfileSchema, CredentialsSchema, ResolveSectionSchema };
214
+ export { CredentialProfileSchema, CredentialSourceSchema, CredentialsSchema, EnvReferenceSchema, OpReferenceSchema, ResolveSectionSchema, profileOwner };
@@ -1,69 +1,75 @@
1
- import { 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/environment.ts
5
- const ReviewerTypeSchema = Schema.Literal("User", "Team").annotations({
5
+ const ReviewerTypeSchema = Schema.Literals(["User", "Team"]).annotate({
6
6
  title: "Reviewer type",
7
7
  description: "Whether the reviewer is an individual user or a team"
8
8
  });
9
9
  const ReviewerSchema = Schema.Struct({
10
10
  type: ReviewerTypeSchema,
11
- id: Schema.Int.annotations({
11
+ id: Schema.Int.annotate({
12
12
  title: "Reviewer ID",
13
13
  description: "The numeric GitHub ID of the user or team"
14
14
  })
15
- }).annotations({
15
+ }).annotate({
16
16
  identifier: "Reviewer",
17
17
  title: "Reviewer",
18
18
  description: "A user or team required to review deployments"
19
19
  });
20
20
  const DeploymentBranchPolicySchema = Schema.Struct({
21
- name: Schema.String.annotations({
21
+ name: Schema.String.annotate({
22
22
  title: "Pattern",
23
23
  description: "The name pattern (branch name, tag name, or glob) to allow deployments from"
24
24
  }),
25
- type: Schema.optionalWith(Schema.Literal("branch", "tag").annotations({
25
+ type: Schema.Literals(["branch", "tag"]).annotate({
26
26
  title: "Policy type",
27
27
  description: "Whether this policy matches branches or tags. Defaults to \"branch\"."
28
- }), { default: () => "branch" })
29
- }).annotations({
28
+ }).pipe(Schema.withDecodingDefaultKey(Effect.succeed("branch")))
29
+ }).annotate({
30
30
  identifier: "DeploymentBranchPolicy",
31
31
  title: "Deployment branch policy",
32
32
  description: "A custom branch or tag pattern that deployments are allowed from"
33
33
  });
34
- const DeploymentBranchesSchema = Schema.Union(Schema.Literal("all", "protected").annotations({
34
+ const DeploymentBranchesSchema = Schema.Union([Schema.Literals(["all", "protected"]).annotate({
35
35
  title: "Deployment branch preset",
36
36
  description: "\"all\" allows any branch, \"protected\" allows only protected branches"
37
- }), Schema.Array(DeploymentBranchPolicySchema).annotations({
37
+ }), Schema.Array(DeploymentBranchPolicySchema).annotate({
38
38
  title: "Custom deployment policies",
39
39
  description: "Array of branch or tag name patterns allowed to deploy to this environment"
40
- })).annotations({
40
+ })]).annotate({
41
41
  identifier: "DeploymentBranches",
42
42
  title: "Deployment branches",
43
43
  description: "Controls which branches can deploy. Use \"all\", \"protected\", or a list of custom policies."
44
44
  });
45
+ /**
46
+ * One GitHub deployment environment.
47
+ *
48
+ * @public
49
+ */
45
50
  const EnvironmentSchema = Schema.Struct({
46
- wait_timer: Schema.optional(Schema.Int.pipe(Schema.between(0, 43200)).annotations({
51
+ wait_timer: Schema.optional(Schema.Int.check(Schema.isBetween({
52
+ minimum: 0,
53
+ maximum: 43200
54
+ })).annotate({
47
55
  title: "Wait timer (minutes)",
48
56
  description: "Number of minutes to wait before allowing deployments to proceed (0-43200)"
49
57
  })),
50
- prevent_self_review: Schema.optional(Schema.Boolean.annotations({
58
+ prevent_self_review: Schema.optional(Schema.Boolean.annotate({
51
59
  title: "Prevent self-review",
52
60
  description: "Prevent the user who triggered the deployment from approving it"
53
61
  })),
54
- reviewers: Schema.optional(Schema.Array(ReviewerSchema).annotations({
62
+ reviewers: Schema.optional(Schema.Array(ReviewerSchema).annotate({
55
63
  title: "Required reviewers",
56
64
  description: "Users or teams required to approve deployments to this environment"
57
65
  })),
58
66
  deployment_branches: Schema.optional(DeploymentBranchesSchema)
59
- }).annotations({
67
+ }).annotate({
68
+ ...tombi({ tableKeysOrder: "schema" }),
69
+ ...taplo({ links: { key: docs("07-environments.md") } }),
60
70
  identifier: "Environment",
61
71
  title: "Deployment environment",
62
- description: "Configuration for a GitHub deployment environment",
63
- jsonSchema: {
64
- ...tombi({ tableKeysOrder: "schema" }),
65
- ...taplo({ links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/environments.md" } })
66
- }
72
+ description: "Configuration for a GitHub deployment environment"
67
73
  });
68
74
 
69
75
  //#endregion