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/cli/commands/doctor.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { SettingsGroupSchema } from "../../schemas/config.js";
|
|
2
|
+
import { profileOwner } from "../../schemas/credentials.js";
|
|
3
|
+
import { CONFIG_FILENAME, CREDENTIALS_FILENAME, ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
4
|
+
import { formatSchemaIssue } from "../../lib/schema-issues.js";
|
|
5
|
+
import { OP_SERVICE_ACCOUNT_TOKEN, OnePasswordClientLive } from "../../services/OnePasswordClient.js";
|
|
6
|
+
import { CredentialResolver, CredentialResolverLive } from "../../services/CredentialResolver.js";
|
|
7
|
+
import { ConfigFlag } from "../flags.js";
|
|
8
|
+
import { Effect, FileSystem, Layer, Option, Path, Result } from "effect";
|
|
9
|
+
import { Command } from "effect/unstable/cli";
|
|
10
|
+
import { AppDirs } from "@effected/xdg";
|
|
11
|
+
import { cwd, env } from "node:process";
|
|
12
|
+
import { GitHubClient } from "@effected/github";
|
|
13
|
+
import { Toml } from "@effected/toml";
|
|
6
14
|
|
|
7
15
|
//#region src/cli/commands/doctor.ts
|
|
8
|
-
const
|
|
9
|
-
const KNOWN_CONFIG_KEYS = new Set([
|
|
10
|
-
"owner",
|
|
11
|
-
"log_level",
|
|
16
|
+
const KNOWN_CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
12
17
|
"settings",
|
|
13
18
|
"secrets",
|
|
14
19
|
"variables",
|
|
@@ -18,8 +23,7 @@ const KNOWN_CONFIG_KEYS = new Set([
|
|
|
18
23
|
"code_scanning",
|
|
19
24
|
"groups"
|
|
20
25
|
]);
|
|
21
|
-
const KNOWN_GROUP_KEYS = new Set([
|
|
22
|
-
"owner",
|
|
26
|
+
const KNOWN_GROUP_KEYS = /* @__PURE__ */ new Set([
|
|
23
27
|
"repos",
|
|
24
28
|
"credentials",
|
|
25
29
|
"settings",
|
|
@@ -31,20 +35,141 @@ const KNOWN_GROUP_KEYS = new Set([
|
|
|
31
35
|
"code_scanning",
|
|
32
36
|
"cleanup"
|
|
33
37
|
]);
|
|
34
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Keys 1.0 removed, and where each one went.
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* Every config written before 1.0 carries at least one of these, so this is the
|
|
43
|
+
* upgrade path rather than an edge case. Strict decoding already rejects them —
|
|
44
|
+
* the schema section says so — but "unknown key" is the wrong sentence for a key
|
|
45
|
+
* that did not vanish so much as move, and the nearest-match hint is actively
|
|
46
|
+
* misleading for one: `owner` is three edits from `repos`.
|
|
47
|
+
*
|
|
48
|
+
* Keyed by the same `where` suffix the warning uses, because `owner` was removed
|
|
49
|
+
* from two places and means something different in each.
|
|
50
|
+
*/
|
|
51
|
+
const REMOVED_KEYS = {
|
|
52
|
+
owner: "removed in 1.0 — the owner now belongs to the credential profile, which declares `username` or `org`",
|
|
53
|
+
"owner (in groups)": "removed in 1.0 — a group's owner comes from the profile named by its `credentials`, so a group cannot override it",
|
|
54
|
+
log_level: "removed in 1.0 — output is one level; use --debug for diagnostics, or core's --log-level to filter by severity"
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The settings fields the schema names, derived rather than listed.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* `SettingsGroupSchema` is a struct **with a rest schema**, so an unrecognised
|
|
61
|
+
* field is absorbed and passed through to GitHub deliberately — that is how a
|
|
62
|
+
* setting GitHub adds tomorrow works without a release here.
|
|
63
|
+
*
|
|
64
|
+
* The cost is that a typo is indistinguishable from a new field: `has_wikkis`
|
|
65
|
+
* decodes cleanly, gets PATCHed, is ignored by GitHub because it recognises no
|
|
66
|
+
* such field, and is then **reported as applied**. Two gates miss it — strict
|
|
67
|
+
* decoding cannot object, and doctor's hand-written key sets never covered
|
|
68
|
+
* settings — so a misspelled setting silently does nothing for as long as it
|
|
69
|
+
* stays in the file.
|
|
70
|
+
*
|
|
71
|
+
* A warning rather than an error, because pass-through is intentional and this
|
|
72
|
+
* cannot tell a typo from a field newer than our schema. Read off the schema so
|
|
73
|
+
* it cannot drift from it; a second hand-maintained list is how the first one
|
|
74
|
+
* went stale.
|
|
75
|
+
*/
|
|
76
|
+
const KNOWN_SETTINGS_FIELDS = new Set(Object.keys(SettingsGroupSchema.schema.fields));
|
|
77
|
+
const KNOWN_CLEANUP_KEYS = /* @__PURE__ */ new Set([
|
|
35
78
|
"secrets",
|
|
36
79
|
"variables",
|
|
37
80
|
"rulesets",
|
|
38
81
|
"environments"
|
|
39
82
|
]);
|
|
40
|
-
const KNOWN_CLEANUP_SECRETS_KEYS = new Set([
|
|
83
|
+
const KNOWN_CLEANUP_SECRETS_KEYS = /* @__PURE__ */ new Set([
|
|
41
84
|
"actions",
|
|
42
85
|
"dependabot",
|
|
43
86
|
"codespaces",
|
|
44
87
|
"environments"
|
|
45
88
|
]);
|
|
46
|
-
const KNOWN_CLEANUP_VARIABLES_KEYS = new Set(["actions", "environments"]);
|
|
47
|
-
|
|
89
|
+
const KNOWN_CLEANUP_VARIABLES_KEYS = /* @__PURE__ */ new Set(["actions", "environments"]);
|
|
90
|
+
/**
|
|
91
|
+
* The fine-grained token permissions a full sync needs.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* Corrected from v3, which listed `Account permissions > GPG keys` as the
|
|
95
|
+
* "secrets encryption key" scope. That is wrong. The public keys used to seal
|
|
96
|
+
* secrets come from repository-scoped endpoints
|
|
97
|
+
* (`GET /repos/{o}/{r}/actions/secrets/public-key` and the dependabot,
|
|
98
|
+
* codespaces and environment equivalents) and are covered by the corresponding
|
|
99
|
+
* Secrets permissions. Account-level GPG keys governs `/user/gpg_keys` — a
|
|
100
|
+
* user's commit-signing keys — which reposets never touches. Following the old
|
|
101
|
+
* list granted an unnecessary account-wide scope and did not grant anything the
|
|
102
|
+
* tool actually used.
|
|
103
|
+
*
|
|
104
|
+
* `Metadata: Read` is mandatory for every fine-grained token and is granted
|
|
105
|
+
* automatically, so it is not listed as something to enable.
|
|
106
|
+
*/
|
|
107
|
+
const REQUIRED_PERMISSIONS = [
|
|
108
|
+
[
|
|
109
|
+
"Repository",
|
|
110
|
+
"Administration (Read and write)",
|
|
111
|
+
"settings sync"
|
|
112
|
+
],
|
|
113
|
+
[
|
|
114
|
+
"Repository",
|
|
115
|
+
"Secrets (Read and write)",
|
|
116
|
+
"Actions, Codespaces and environment secrets"
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
"Repository",
|
|
120
|
+
"Variables (Read and write)",
|
|
121
|
+
"Actions and environment variables"
|
|
122
|
+
],
|
|
123
|
+
[
|
|
124
|
+
"Repository",
|
|
125
|
+
"Environments (Read and write)",
|
|
126
|
+
"deployment environment sync"
|
|
127
|
+
],
|
|
128
|
+
[
|
|
129
|
+
"Repository",
|
|
130
|
+
"Dependabot secrets (Read and write)",
|
|
131
|
+
"Dependabot secrets"
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
"Repository",
|
|
135
|
+
"Code scanning alerts (Read and write)",
|
|
136
|
+
"CodeQL default setup"
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
"Repository",
|
|
140
|
+
"Actions (Read)",
|
|
141
|
+
"count workflow files for the CodeQL actions language"
|
|
142
|
+
],
|
|
143
|
+
[
|
|
144
|
+
"Repository",
|
|
145
|
+
"Dependabot alerts (Read and write)",
|
|
146
|
+
"vulnerability alerts and automated fixes"
|
|
147
|
+
],
|
|
148
|
+
[
|
|
149
|
+
"Repository",
|
|
150
|
+
"Secret scanning alerts (Read and write)",
|
|
151
|
+
"secret scanning and delegated bypass"
|
|
152
|
+
],
|
|
153
|
+
[
|
|
154
|
+
"Organization",
|
|
155
|
+
"Members (Read)",
|
|
156
|
+
"resolve team slugs (org-owned repos only)"
|
|
157
|
+
]
|
|
158
|
+
];
|
|
159
|
+
const levenshtein = (a, b) => {
|
|
160
|
+
const matrix = [];
|
|
161
|
+
for (let i = 0; i <= a.length; i++) matrix[i] = [i];
|
|
162
|
+
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
|
|
163
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
|
|
164
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
165
|
+
const row = matrix[i];
|
|
166
|
+
const prev = matrix[i - 1];
|
|
167
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
|
|
168
|
+
}
|
|
169
|
+
return matrix[a.length][b.length];
|
|
170
|
+
};
|
|
171
|
+
/** The nearest known key within edit distance 3, if any. */
|
|
172
|
+
const findClosestMatch = (key, known) => {
|
|
48
173
|
let best;
|
|
49
174
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
50
175
|
for (const candidate of known) {
|
|
@@ -55,100 +180,248 @@ function findClosestMatch(key, known) {
|
|
|
55
180
|
}
|
|
56
181
|
}
|
|
57
182
|
return best;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* Find the config file without decoding it.
|
|
186
|
+
*
|
|
187
|
+
* @remarks
|
|
188
|
+
* The same two tiers the loader uses — walk up from the working directory, then
|
|
189
|
+
* the XDG config directory — but testing only for existence.
|
|
190
|
+
*
|
|
191
|
+
* This was written on the belief that `discover` skips a source that fails to
|
|
192
|
+
* decode. **That was wrong** — it propagates, and a validation failure now
|
|
193
|
+
* reports its own path and its structured issue. The duplication survives for a
|
|
194
|
+
* different and narrower reason, measured rather than assumed:
|
|
195
|
+
* `ConfigCodecError` carries `codec` and `operation` and **no path**, so a TOML
|
|
196
|
+
* *syntax* error cannot name the file it failed to parse. That is the most
|
|
197
|
+
* ordinary config mistake there is, and this command exists to say which file
|
|
198
|
+
* is wrong — so it finds the file itself and parses it for the line and column.
|
|
199
|
+
*
|
|
200
|
+
* Delete this when the codec error carries a path.
|
|
201
|
+
*
|
|
202
|
+
* `appDirs.dirs.config` rather than `ensureConfig`: the latter *creates* the
|
|
203
|
+
* directory, and a read-only diagnostic must not.
|
|
204
|
+
*/
|
|
205
|
+
const locateConfig = (fs, path, appDirs, configFlag) => Effect.gen(function* () {
|
|
206
|
+
const candidates = [];
|
|
207
|
+
if (configFlag !== void 0) candidates.push(configFlag, path.join(configFlag, CONFIG_FILENAME));
|
|
208
|
+
let dir = cwd();
|
|
209
|
+
for (;;) {
|
|
210
|
+
candidates.push(path.join(dir, CONFIG_FILENAME));
|
|
211
|
+
const parent = path.dirname(dir);
|
|
212
|
+
if (parent === dir) break;
|
|
213
|
+
dir = parent;
|
|
66
214
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
215
|
+
candidates.push(path.join(appDirs.dirs.config, CONFIG_FILENAME));
|
|
216
|
+
for (const candidate of candidates) {
|
|
217
|
+
const info = yield* fs.stat(candidate).pipe(Effect.option);
|
|
218
|
+
if (info._tag === "Some" && info.value.type === "File") return candidate;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
222
|
+
/**
|
|
223
|
+
* `reposets doctor` — config diagnostics, typo detection, credential posture.
|
|
224
|
+
*
|
|
225
|
+
* @remarks
|
|
226
|
+
* The typo pass reads the **raw** TOML rather than the decoded config, because
|
|
227
|
+
* decoding silently drops keys the schema does not know. A misspelled
|
|
228
|
+
* `has_issues` would validate cleanly and then do nothing; only the raw text
|
|
229
|
+
* shows it.
|
|
230
|
+
*
|
|
231
|
+
* Nothing here prints a credential. The credentials section reports which
|
|
232
|
+
* *form* each profile uses — `op://…` addresses and environment variable
|
|
233
|
+
* *names* — and whether the 1Password service account variable is present. It
|
|
234
|
+
* never resolves a reference, so there is no value in scope to leak.
|
|
235
|
+
*
|
|
236
|
+
* @public
|
|
237
|
+
*/
|
|
238
|
+
/**
|
|
239
|
+
* What `GET /user` proves about a profile, worded for the kind it declares.
|
|
240
|
+
*
|
|
241
|
+
* @remarks
|
|
242
|
+
* The endpoint returns the account that **owns** the token, which is a user even
|
|
243
|
+
* for a token whose whole purpose is an organization. Reporting that as
|
|
244
|
+
* "authenticates as spencerbeggs" next to a profile declaring `org = "..."`
|
|
245
|
+
* reads as a contradiction, so the two kinds are worded differently.
|
|
246
|
+
*
|
|
247
|
+
* For a `username` profile the owner IS the declared owner, which makes this the
|
|
248
|
+
* one exact check available for free: a typo becomes a named error here instead
|
|
249
|
+
* of a 404 partway through a sync.
|
|
250
|
+
*
|
|
251
|
+
* For an `org` profile there is no equivalent. Nothing in `GET /user` says
|
|
252
|
+
* whether the token reaches that organization — answering it needs a request
|
|
253
|
+
* against the org, which a token without org read cannot make even when it can
|
|
254
|
+
* administer every repository in it. So the absence of a check is deliberate,
|
|
255
|
+
* and is not dressed up as reassurance.
|
|
256
|
+
*
|
|
257
|
+
* Extracted because both branches sit behind a live request: `doctor` builds its
|
|
258
|
+
* own `GitHubClient` from the resolved token, so nothing outside it can reach
|
|
259
|
+
* these lines.
|
|
260
|
+
*
|
|
261
|
+
* @public
|
|
262
|
+
*/
|
|
263
|
+
const describeIdentity = (name, profile, login) => {
|
|
264
|
+
const { owner, ownerType } = profileOwner(profile);
|
|
265
|
+
if (ownerType !== "User") return {
|
|
266
|
+
ok: true,
|
|
267
|
+
text: `Token [${name}]: resolved, owned by ${login}, for org ${owner}`
|
|
268
|
+
};
|
|
269
|
+
return login === owner ? {
|
|
270
|
+
ok: true,
|
|
271
|
+
text: `Token [${name}]: resolved, authenticates as ${login} — matches username`
|
|
272
|
+
} : {
|
|
273
|
+
ok: false,
|
|
274
|
+
text: `Token [${name}]: resolved, but authenticates as ${login} while the profile declares username = "${owner}"`
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
70
278
|
const configFile = yield* ReposetsConfigFile;
|
|
71
|
-
const
|
|
72
|
-
|
|
279
|
+
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
280
|
+
const fs = yield* FileSystem.FileSystem;
|
|
281
|
+
const path = yield* Path.Path;
|
|
282
|
+
const appDirs = yield* AppDirs;
|
|
283
|
+
const discovered = yield* configFile.discover.pipe(Effect.result);
|
|
284
|
+
const sources = discovered._tag === "Success" ? discovered.success : [];
|
|
285
|
+
const schemaValid = discovered._tag === "Success" && sources.length > 0;
|
|
286
|
+
const validationIssue = discovered._tag === "Failure" ? discovered.failure.issue : void 0;
|
|
287
|
+
const configPath = sources[0]?.path ?? (yield* locateConfig(fs, path, appDirs, configFlag));
|
|
288
|
+
if (configPath === void 0) {
|
|
73
289
|
yield* Effect.logError("No config found. Run 'reposets init' to create one.");
|
|
74
290
|
return;
|
|
75
291
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
292
|
+
yield* Effect.log(`Config: ${configPath}`);
|
|
293
|
+
const text = yield* fs.readFileString(configPath).pipe(Effect.option);
|
|
294
|
+
if (text._tag === "None") {
|
|
295
|
+
yield* Effect.logError(`Could not read ${configPath}`);
|
|
79
296
|
return;
|
|
80
297
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
raw = parse(readFileSync(configPath, "utf-8"));
|
|
85
|
-
} catch (err) {
|
|
86
|
-
yield* Effect.logError(`TOML parse error: ${err instanceof Error ? err.message : String(err)}`);
|
|
298
|
+
const parsed = Toml.parseResult(text.value);
|
|
299
|
+
if (Result.isFailure(parsed)) {
|
|
300
|
+
yield* Effect.logError(`TOML parse error: ${String(parsed.failure)}`);
|
|
87
301
|
return;
|
|
88
302
|
}
|
|
303
|
+
const raw = isRecord(parsed.success) ? parsed.success : {};
|
|
89
304
|
let warnings = 0;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
for (const key of Object.keys(group)) if (!KNOWN_GROUP_KEYS.has(key)) {
|
|
100
|
-
const suggestion = findClosestMatch(key, KNOWN_GROUP_KEYS);
|
|
101
|
-
const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
|
|
102
|
-
yield* Effect.log(`Warning: unknown key '${key}' in groups.${groupName}${hint}`);
|
|
103
|
-
warnings++;
|
|
305
|
+
const checkKeys = (target, known, where) => Effect.gen(function* () {
|
|
306
|
+
if (!isRecord(target)) return;
|
|
307
|
+
for (const key of Object.keys(target)) {
|
|
308
|
+
if (known.has(key)) continue;
|
|
309
|
+
const removedFrom = REMOVED_KEYS[where === "" ? key : `${key} (in groups)`] ?? REMOVED_KEYS[key];
|
|
310
|
+
if (removedFrom !== void 0) {
|
|
311
|
+
yield* Effect.log(`Warning: '${key}'${where} ${removedFrom}`);
|
|
312
|
+
warnings += 1;
|
|
313
|
+
continue;
|
|
104
314
|
}
|
|
315
|
+
const suggestion = findClosestMatch(key, known);
|
|
316
|
+
const hint = suggestion === void 0 ? "" : ` — did you mean '${suggestion}'?`;
|
|
317
|
+
yield* Effect.log(`Warning: unknown key '${key}'${where}${hint}`);
|
|
318
|
+
warnings += 1;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
yield* checkKeys(raw, KNOWN_CONFIG_KEYS, "");
|
|
322
|
+
const settingsSection = isRecord(raw.settings) ? raw.settings : {};
|
|
323
|
+
for (const [groupName, body] of Object.entries(settingsSection)) {
|
|
324
|
+
if (!isRecord(body)) continue;
|
|
325
|
+
for (const field of Object.keys(body)) {
|
|
326
|
+
if (KNOWN_SETTINGS_FIELDS.has(field)) continue;
|
|
327
|
+
const suggestion = findClosestMatch(field, KNOWN_SETTINGS_FIELDS);
|
|
328
|
+
const hint = suggestion === void 0 ? " — it will be sent to GitHub as-is, which ignores fields it does not recognise" : ` — did you mean '${suggestion}'?`;
|
|
329
|
+
yield* Effect.log(`Warning: unrecognised setting '${field}' in settings.${groupName}${hint}`);
|
|
330
|
+
warnings += 1;
|
|
105
331
|
}
|
|
106
332
|
}
|
|
107
|
-
|
|
108
|
-
|
|
333
|
+
const groups = raw.groups;
|
|
334
|
+
if (isRecord(groups)) for (const [groupName, group] of Object.entries(groups)) {
|
|
335
|
+
yield* checkKeys(group, KNOWN_GROUP_KEYS, ` in groups.${groupName}`);
|
|
336
|
+
if (!isRecord(group)) continue;
|
|
109
337
|
const cleanup = group.cleanup;
|
|
110
|
-
if (!cleanup
|
|
111
|
-
const cleanupObj = cleanup;
|
|
338
|
+
if (!isRecord(cleanup)) continue;
|
|
112
339
|
const prefix = `groups.${groupName}.cleanup`;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
340
|
+
yield* checkKeys(cleanup, KNOWN_CLEANUP_KEYS, ` in ${prefix}`);
|
|
341
|
+
yield* checkKeys(cleanup.secrets, KNOWN_CLEANUP_SECRETS_KEYS, ` in ${prefix}.secrets`);
|
|
342
|
+
yield* checkKeys(cleanup.variables, KNOWN_CLEANUP_VARIABLES_KEYS, ` in ${prefix}.variables`);
|
|
343
|
+
}
|
|
344
|
+
if (schemaValid) yield* Effect.log("Schema validation: passed");
|
|
345
|
+
else {
|
|
346
|
+
yield* Effect.logError("Schema validation: FAILED — these values are rejected, not ignored");
|
|
347
|
+
for (const line of formatSchemaIssue(validationIssue)) yield* Effect.logError(` ${line}`);
|
|
348
|
+
}
|
|
349
|
+
yield* Effect.log("");
|
|
350
|
+
const loaded = yield* credentialsFile.loadOrDefault({ profiles: {} }).pipe(Effect.result);
|
|
351
|
+
const credentials = loaded._tag === "Success" ? loaded.success : { profiles: {} };
|
|
352
|
+
const profileNames = Object.keys(credentials.profiles);
|
|
353
|
+
if (loaded._tag === "Failure") {
|
|
354
|
+
yield* Effect.logError(`Credentials: FAILED to load — ${String(loaded.failure.message ?? loaded.failure)}`);
|
|
355
|
+
yield* Effect.logError(" Unknown keys are rejected. `op_service_account_token` was removed — that token now comes from OP_SERVICE_ACCOUNT_TOKEN in the environment.");
|
|
356
|
+
} else if (profileNames.length === 0) yield* Effect.log("Credentials: no profiles configured");
|
|
357
|
+
else {
|
|
358
|
+
yield* Effect.log(`Credentials: ${profileNames.length} profile(s)`);
|
|
359
|
+
let usesOnePassword = false;
|
|
360
|
+
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
361
|
+
const token = "op" in profile.github_token ? `op ${profile.github_token.op}` : `env ${profile.github_token.env}`;
|
|
362
|
+
if ("op" in profile.github_token) usesOnePassword = true;
|
|
363
|
+
const sections = [];
|
|
364
|
+
for (const kind of [
|
|
365
|
+
"op",
|
|
366
|
+
"env",
|
|
367
|
+
"file"
|
|
368
|
+
]) {
|
|
369
|
+
const entries = profile.resolve?.[kind];
|
|
370
|
+
if (entries !== void 0 && Object.keys(entries).length > 0) {
|
|
371
|
+
sections.push(`${kind}:${Object.keys(entries).length}`);
|
|
372
|
+
if (kind === "op") usesOnePassword = true;
|
|
373
|
+
}
|
|
126
374
|
}
|
|
375
|
+
const resolveSummary = sections.length === 0 ? "" : `, resolve ${sections.join(" ")}`;
|
|
376
|
+
yield* Effect.log(` [${name}] github_token: ${token}${resolveSummary}`);
|
|
127
377
|
}
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
378
|
+
const hasServiceAccount = env["OP_SERVICE_ACCOUNT_TOKEN"] !== void 0 && env["OP_SERVICE_ACCOUNT_TOKEN"] !== "";
|
|
379
|
+
if (usesOnePassword) yield* Effect.log(hasServiceAccount ? ` ${OP_SERVICE_ACCOUNT_TOKEN}: set` : ` ${OP_SERVICE_ACCOUNT_TOKEN}: NOT SET — op:// references cannot be resolved`);
|
|
380
|
+
}
|
|
381
|
+
yield* Effect.log("");
|
|
382
|
+
yield* Effect.log(`Version: ${env.__PACKAGE_VERSION__ ?? "0.0.0 (dev build)"}`);
|
|
383
|
+
const credentialDiscovery = yield* credentialsFile.discover.pipe(Effect.result);
|
|
384
|
+
const credentialSources = credentialDiscovery._tag === "Success" ? credentialDiscovery.success : [];
|
|
385
|
+
const credentialsLine = credentialDiscovery._tag === "Failure" ? "(failed to load — the error above names the file)" : credentialSources.length === 0 ? `${path.join(appDirs.dirs.config, CREDENTIALS_FILENAME)} (not created yet)` : credentialSources[0]?.path ?? "";
|
|
386
|
+
yield* Effect.log(`Credentials file: ${credentialsLine}`);
|
|
387
|
+
yield* Effect.log(`State database: ${path.join(appDirs.dirs.state, "store.db")}`);
|
|
388
|
+
yield* Effect.log("");
|
|
389
|
+
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
390
|
+
const resolverLayer = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
|
|
391
|
+
const resolved = yield* Effect.gen(function* () {
|
|
392
|
+
return yield* (yield* CredentialResolver).resolveGitHubToken(profile);
|
|
393
|
+
}).pipe(Effect.provide(resolverLayer), Effect.result);
|
|
394
|
+
if (resolved._tag === "Failure") {
|
|
395
|
+
yield* Effect.logError(`Token [${name}]: could not resolve — ${String(resolved.failure)}`);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const identity = yield* Effect.gen(function* () {
|
|
399
|
+
return yield* (yield* GitHubClient).request("GET /user", {});
|
|
400
|
+
}).pipe(Effect.provide(GitHubClient.layerFromToken({ token: resolved.success })), Effect.result);
|
|
401
|
+
if (identity._tag !== "Success") {
|
|
402
|
+
yield* Effect.logError(`Token [${name}]: resolved but REJECTED by GitHub — ${String(identity.failure)}`);
|
|
403
|
+
continue;
|
|
136
404
|
}
|
|
405
|
+
const line = describeIdentity(name, profile, identity.success.login);
|
|
406
|
+
yield* line.ok ? Effect.log(line.text) : Effect.logError(line.text);
|
|
137
407
|
}
|
|
138
|
-
yield* Effect.log("
|
|
139
|
-
yield* Effect.log("
|
|
140
|
-
yield* Effect.log(
|
|
141
|
-
yield* Effect.log("
|
|
142
|
-
yield* Effect.log("
|
|
143
|
-
yield* Effect.log("
|
|
144
|
-
yield* Effect.log(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
408
|
+
yield* Effect.log("");
|
|
409
|
+
yield* Effect.log("Required fine-grained token permissions:");
|
|
410
|
+
for (const [scope, permission, why] of REQUIRED_PERMISSIONS) yield* Effect.log(` ${scope} > ${permission} — ${why}`);
|
|
411
|
+
yield* Effect.log(" (Metadata: Read is mandatory and granted automatically)");
|
|
412
|
+
yield* Effect.log(" These are NOT verified — GitHub does not expose a fine-grained token's own scopes.");
|
|
413
|
+
yield* Effect.log("");
|
|
414
|
+
yield* Effect.log(warnings === 0 ? "No unknown keys detected." : `${warnings} warning(s) found.`);
|
|
415
|
+
});
|
|
416
|
+
/**
|
|
417
|
+
* `reposets doctor`.
|
|
418
|
+
*
|
|
419
|
+
* @public
|
|
420
|
+
*/
|
|
421
|
+
const doctorCommand = Command.make("doctor", {}, () => Effect.gen(function* () {
|
|
422
|
+
const flag = yield* ConfigFlag;
|
|
423
|
+
yield* doctorHandler(Option.getOrUndefined(flag));
|
|
424
|
+
})).pipe(Command.withDescription("Diagnose the config: unknown keys, credential posture, required token permissions"));
|
|
152
425
|
|
|
153
426
|
//#endregion
|
|
154
|
-
export { doctorCommand };
|
|
427
|
+
export { describeIdentity, doctorCommand, doctorHandler };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { syncHandler } from "./sync.js";
|
|
2
|
+
import { Option } from "effect";
|
|
3
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
4
|
+
|
|
5
|
+
//#region src/cli/commands/drift.ts
|
|
6
|
+
/**
|
|
7
|
+
* `reposets drift` — report resources changed outside reposets, and change
|
|
8
|
+
* nothing.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* This is `sync --dry-run --no-cleanup --fail-on-drift` under a name that says
|
|
12
|
+
* what it is for, and it is deliberately the *same* handler rather than a
|
|
13
|
+
* second pipeline. Drift is decided by comparing three fingerprints — desired,
|
|
14
|
+
* live, and last-applied — and that comparison happens inside the phases. A
|
|
15
|
+
* separate read-only implementation would be a second copy of that decision,
|
|
16
|
+
* free to disagree with the one that actually converges, which is the worst
|
|
17
|
+
* possible outcome for a command whose entire job is to be believed.
|
|
18
|
+
*
|
|
19
|
+
* **It writes nothing, to GitHub or to the baseline.** Every phase gates its
|
|
20
|
+
* writes on `dryRun`, and that includes recording the applied-state fingerprint
|
|
21
|
+
* — so a drift check does not quietly accept the drift it just reported by
|
|
22
|
+
* adopting the live state as the new baseline. The run does appear in
|
|
23
|
+
* `reposets history`, flagged as a dry run.
|
|
24
|
+
*
|
|
25
|
+
* Cleanup is off because a resource the config never declared is not drift. It
|
|
26
|
+
* is undeclared, which is a different question and one `sync` answers.
|
|
27
|
+
*
|
|
28
|
+
* Exits non-zero when drift is found, so it works as a CI gate without a flag.
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
const driftCommand = Command.make("drift", {
|
|
33
|
+
group: Flag.string("group").pipe(Flag.withDescription("Check only this group"), Flag.optional),
|
|
34
|
+
repo: Flag.string("repo").pipe(Flag.withDescription("Check only this repository"), Flag.optional),
|
|
35
|
+
debug: Flag.boolean("debug").pipe(Flag.withDescription("Show the applied and live fingerprints behind each drift report"))
|
|
36
|
+
}, (input) => syncHandler({
|
|
37
|
+
dryRun: true,
|
|
38
|
+
noCleanup: true,
|
|
39
|
+
failOnDrift: true,
|
|
40
|
+
group: Option.getOrUndefined(input.group),
|
|
41
|
+
repo: Option.getOrUndefined(input.repo),
|
|
42
|
+
only: [],
|
|
43
|
+
skip: [],
|
|
44
|
+
debug: input.debug
|
|
45
|
+
})).pipe(Command.withDescription("Report resources changed outside reposets, without changing anything"));
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
export { driftCommand };
|