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,14 +1,19 @@
1
- import { ReposetsConfigFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
2
- import { Effect } from "effect";
3
- import { readFileSync } from "node:fs";
4
- import { Command, Options } from "@effect/cli";
5
- import { parse } from "smol-toml";
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 configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
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
- const KNOWN_CLEANUP_KEYS = new Set([
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
- function findClosestMatch(key, known) {
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
- function levenshtein(a, b) {
60
- const matrix = [];
61
- for (let i = 0; i <= a.length; i++) matrix[i] = [i];
62
- for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
63
- for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
64
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
65
- matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
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
- return matrix[a.length][b.length];
68
- }
69
- const doctorCommand = Command.make("doctor", { config: configOption }, ({ config }) => Effect.gen(function* () {
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 discoverResult = yield* Effect.either(configFile.discover);
72
- if (discoverResult._tag === "Left") {
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
- const sources = discoverResult.right;
77
- if (sources.length === 0) {
78
- yield* Effect.logError("No config found. Run 'reposets init' to create one.");
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 configPath = sources[0].path;
82
- let raw;
83
- try {
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
- for (const key of Object.keys(raw)) if (!KNOWN_CONFIG_KEYS.has(key)) {
91
- const suggestion = findClosestMatch(key, KNOWN_CONFIG_KEYS);
92
- const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
93
- yield* Effect.log(`Warning: unknown top-level key '${key}'${hint}`);
94
- warnings++;
95
- }
96
- const groups = raw.groups;
97
- if (groups && typeof groups === "object") {
98
- for (const [groupName, group] of Object.entries(groups)) if (group && typeof group === "object") {
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
- if (groups && typeof groups === "object") for (const [groupName, group] of Object.entries(groups)) {
108
- if (!group || typeof group !== "object") continue;
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 || typeof cleanup !== "object") continue;
111
- const cleanupObj = cleanup;
338
+ if (!isRecord(cleanup)) continue;
112
339
  const prefix = `groups.${groupName}.cleanup`;
113
- for (const key of Object.keys(cleanupObj)) if (!KNOWN_CLEANUP_KEYS.has(key)) {
114
- const suggestion = findClosestMatch(key, KNOWN_CLEANUP_KEYS);
115
- const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
116
- yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}${hint}`);
117
- warnings++;
118
- }
119
- const secrets = cleanupObj.secrets;
120
- if (secrets && typeof secrets === "object") {
121
- for (const key of Object.keys(secrets)) if (!KNOWN_CLEANUP_SECRETS_KEYS.has(key)) {
122
- const suggestion = findClosestMatch(key, KNOWN_CLEANUP_SECRETS_KEYS);
123
- const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
124
- yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}.secrets${hint}`);
125
- warnings++;
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 variables = cleanupObj.variables;
129
- if (variables && typeof variables === "object") {
130
- for (const key of Object.keys(variables)) if (!KNOWN_CLEANUP_VARIABLES_KEYS.has(key)) {
131
- const suggestion = findClosestMatch(key, KNOWN_CLEANUP_VARIABLES_KEYS);
132
- const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
133
- yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}.variables${hint}`);
134
- warnings++;
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("Schema validation: passed");
139
- yield* Effect.log("\nRequired fine-grained token permissions:");
140
- yield* Effect.log(" Repository permissions > Administration (Read and write) -- settings sync");
141
- yield* Effect.log(" Repository permissions > Secrets (Read and write) -- Actions secrets");
142
- yield* Effect.log(" Repository permissions > Variables (Read and write) -- Actions variables");
143
- yield* Effect.log(" Repository permissions > Environments (Read and write) -- environment sync");
144
- yield* Effect.log(" Repository permissions > Code scanning alerts (Read and write) -- code_scanning sync");
145
- yield* Effect.log(" Repository permissions > Dependabot alerts (Read and write) -- security feature sync");
146
- yield* Effect.log(" Repository permissions > Secret scanning alerts (Read and write) -- secret scanning delegation");
147
- yield* Effect.log(" Account permissions > GPG keys (Read and write) -- secrets encryption key");
148
- yield* Effect.log(" Organization permissions > Members (Read) -- resolve team slugs (org-level only)");
149
- if (warnings === 0) yield* Effect.log("\nNo unknown keys detected.");
150
- else yield* Effect.log(`\n${warnings} warning(s) found.`);
151
- }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Deep config diagnostics with typo detection"));
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 };