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.
- package/README.md +87 -75
- package/bin/reposets.js +68 -17
- package/cli/commands/credentials.js +170 -49
- package/cli/commands/doctor.js +364 -91
- package/cli/commands/drift.js +48 -0
- package/cli/commands/history.js +203 -0
- package/cli/commands/init.js +110 -104
- package/cli/commands/list.js +60 -39
- package/cli/commands/nuke.js +127 -0
- package/cli/commands/sync.js +219 -59
- package/cli/commands/validate.js +57 -41
- package/cli/flags.js +36 -0
- package/cli/logger.js +48 -0
- package/index.d.ts +471 -1499
- package/index.js +3 -18
- package/lib/config-refs.js +76 -0
- package/lib/credential-labels.js +0 -0
- package/lib/fingerprint.js +52 -0
- package/lib/org-only.js +61 -0
- package/lib/schema-issues.js +50 -0
- package/package.json +11 -10
- package/schemas/annotations.js +81 -0
- package/schemas/common.js +83 -48
- package/schemas/config.js +214 -212
- package/schemas/credentials.js +190 -54
- package/schemas/environment.js +27 -21
- package/schemas/ruleset.js +235 -139
- package/services/ConfigFiles.js +126 -104
- package/services/CredentialResolver.js +97 -33
- package/services/OnePasswordClient.js +88 -16
- package/services/SyncLogger.js +107 -76
- package/store/AppliedState.js +0 -0
- package/store/RepoCache.js +86 -0
- package/store/SyncJournal.js +92 -0
- package/store/migrations.js +86 -0
- package/sync/SyncEngine.js +156 -0
- package/sync/decide.js +56 -0
- package/sync/phase.js +55 -0
- package/sync/phases/cleanup.js +220 -0
- package/sync/phases/code-scanning.js +187 -0
- package/sync/phases/environments.js +106 -0
- package/sync/phases/index.js +39 -0
- package/sync/phases/resource.js +149 -0
- package/sync/phases/rulesets.js +186 -0
- package/sync/phases/secrets.js +138 -0
- package/sync/phases/security.js +129 -0
- package/sync/phases/settings.js +274 -0
- package/sync/phases/variables.js +132 -0
- package/tsdoc-metadata.json +1 -1
- package/bin/reposets.d.ts +0 -1
- package/errors.js +0 -12
- package/lib/crypto.js +0 -27
- package/services/GitHubClient.js +0 -875
- package/services/SyncEngine.js +0 -580
package/schemas/credentials.js
CHANGED
|
@@ -1,78 +1,214 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}).
|
|
91
|
+
}).annotate({
|
|
92
|
+
...taplo({ links: { key: CREDENTIALS_DOCS } }),
|
|
34
93
|
identifier: "ResolveSection",
|
|
35
94
|
title: "Resolve section",
|
|
36
|
-
description: "Named values resolved from 1Password,
|
|
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
|
-
|
|
40
|
-
|
|
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: "
|
|
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
|
-
}
|
|
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: "
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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.
|
|
66
|
-
|
|
67
|
-
|
|
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: "
|
|
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 };
|
package/schemas/environment.js
CHANGED
|
@@ -1,69 +1,75 @@
|
|
|
1
|
-
import { taplo, tombi } from "
|
|
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.
|
|
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.
|
|
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
|
-
}).
|
|
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.
|
|
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.
|
|
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
|
-
})
|
|
29
|
-
}).
|
|
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.
|
|
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).
|
|
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
|
-
})).
|
|
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.
|
|
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.
|
|
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).
|
|
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
|
-
}).
|
|
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
|