reposets 0.2.1 → 0.4.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 CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
  [![TypeScript](https://img.shields.io/badge/TypeScript-6.0-blue.svg)](https://www.typescriptlang.org/)
6
6
 
7
- Declarative GitHub repository management. Define your repo settings, secrets, variables, rulesets, and deployment environments in a TOML config file, then apply them across all your repositories with a single command.
7
+ Declarative GitHub repository management. Define your repo settings, secrets, variables, rulesets, deployment environments, advanced security toggles, and CodeQL default setup in a TOML config file, then apply them across all your repositories with a single command.
8
8
 
9
9
  ## Why reposets
10
10
 
@@ -17,7 +17,8 @@ Managing repository settings by hand doesn't scale. When you have dozens of repo
17
17
  - **Multi-scope secret and variable management** — Assign the same secret group to Actions, Dependabot, Codespaces, and deployment environments with scoped targeting.
18
18
  - **Ruleset shorthand syntax** — Define branch and tag rulesets with compact inline syntax for pull request rules, status checks, and boolean flags instead of verbose API payloads.
19
19
  - **Deployment environment management** — Configure wait timers, reviewers, and branch policies for deployment environments alongside your other settings.
20
- - **Group-based targeting** — Organize repos into groups that share settings, secrets, variables, rulesets, and environments. Change the group config, sync once, and every repo updates.
20
+ - **Advanced security and CodeQL** — Toggle secret scanning, push protection, vulnerability alerts, automated security fixes, private vulnerability reporting, and CodeQL default setup. License- and ownership-aware: GHAS-licensed fields warn instead of failing on private repos without a license, and org-only fields are silently skipped on personal accounts.
21
+ - **Group-based targeting** — Organize repos into groups that share settings, secrets, variables, rulesets, environments, security toggles, and code scanning configuration. Change the group config, sync once, and every repo updates.
21
22
  - **Cleanup policies** — Automatically remove undeclared resources per scope with optional preserve lists, so your repos converge to the declared state.
22
23
  - **Dry-run and validation** — Preview changes before applying, validate config locally without touching the GitHub API, and catch typos with built-in diagnostics.
23
24
 
@@ -93,7 +94,7 @@ All commands accept `--log-level silent|info|verbose|debug`.
93
94
 
94
95
  reposets uses two TOML files:
95
96
 
96
- - `reposets.config.toml` — defines settings, secrets, variables, rulesets, environments, and groups
97
+ - `reposets.config.toml` — defines settings, secrets, variables, rulesets, environments, security, code_scanning, and groups
97
98
  - `reposets.credentials.toml` — stores GitHub tokens and optional resolve sections for named values
98
99
 
99
100
  Config lookup order (first match wins):
@@ -112,8 +113,14 @@ reposets requires a fine-grained personal access token with:
112
113
  - Repository > Secrets (Read and write)
113
114
  - Repository > Variables (Read and write)
114
115
  - Repository > Environments (Read and write)
116
+ - Repository > Code scanning alerts (Read and write) — for `[code_scanning.*]`
117
+ - Repository > Dependabot alerts (Read and write) — for `[security.*]`
118
+ - Repository > Secret scanning alerts (Read and write) — for `security_and_analysis`
119
+ - Organization > Members (Read) — for `delegated_bypass_reviewers` team slugs on org-owned repos
115
120
  - Account > GPG keys (Read and write)
116
121
 
122
+ The four security-related scopes are only required if you use the corresponding config sections.
123
+
117
124
  ## Documentation
118
125
 
119
126
  Full reference guides are available in the [`docs/`](https://github.com/spencerbeggs/reposets/tree/main/docs) folder:
@@ -124,6 +131,7 @@ Full reference guides are available in the [`docs/`](https://github.com/spencerb
124
131
  - [Secrets and Variables](https://github.com/spencerbeggs/reposets/blob/main/docs/secrets-and-variables.md) - resource groups, three kinds (file/value/resolved), and scoping
125
132
  - [Rulesets](https://github.com/spencerbeggs/reposets/blob/main/docs/rulesets.md) - branch and tag ruleset configuration
126
133
  - [Environments](https://github.com/spencerbeggs/reposets/blob/main/docs/environments.md) - deployment environment setup
134
+ - [Advanced Security](https://github.com/spencerbeggs/reposets/blob/main/docs/configuration.md#security-and-analysis-nested-block) - secret scanning, vulnerability alerts, automated security fixes, private vulnerability reporting, and CodeQL default setup
127
135
  - [Cleanup](https://github.com/spencerbeggs/reposets/blob/main/docs/cleanup.md) - automatic cleanup of undeclared resources
128
136
  - [Token Permissions](https://github.com/spencerbeggs/reposets/blob/main/docs/token-permissions.md) - GitHub PAT setup guide
129
137
 
package/bin/reposets.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Options } from "@effect/cli";
3
3
  import { NodeContext, NodeRuntime } from "@effect/platform-node";
4
- import { Console, Effect, Layer } from "effect";
4
+ import { Effect, Layer, LogLevel, Logger, Option } from "effect";
5
5
  import { AppDirs } from "xdg-effect";
6
6
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
8
7
  import { parse } from "smol-toml";
9
- import { CredentialResolverLive, ReposetsCredentialsFile, SyncEngineLive, SyncLoggerLive, OnePasswordClientLive, GitHubClientLive, loadConfigWithDir, SyncEngine, ConfigFilesLive, ReposetsConfigFile } from "../500.js";
8
+ import { dirname, join } from "node:path";
9
+ import { CredentialResolverLive, SyncEngineLive, ReposetsCredentialsFile, SyncLoggerLive, OnePasswordClientLive, GitHubClientLive, SyncEngine, makeConfigFilesLive, ReposetsConfigFile } from "../500.js";
10
10
  const EMPTY_CREDENTIALS = {
11
11
  profiles: {}
12
12
  };
@@ -26,11 +26,11 @@ const createCommand = Command.make("create", {
26
26
  yield* appDirs.ensureConfig;
27
27
  const credentialsFile = yield* ReposetsCredentialsFile;
28
28
  const creds = yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS);
29
- if (creds.profiles[profile]) return void (yield* Console.error(`Profile '${profile}' already exists. Delete it first.`));
29
+ if (creds.profiles[profile]) return void (yield* Effect.logError(`Profile '${profile}' already exists. Delete it first.`));
30
30
  const newProfile = {};
31
31
  if ("Some" === githubToken._tag) newProfile.github_token = githubToken.value;
32
32
  if ("Some" === opToken._tag) newProfile.op_service_account_token = opToken.value;
33
- if (!newProfile.github_token && !newProfile.op_service_account_token) return void (yield* Console.error("Provide at least --github-token or --op-token."));
33
+ if (!newProfile.github_token && !newProfile.op_service_account_token) return void (yield* Effect.logError("Provide at least --github-token or --op-token."));
34
34
  yield* credentialsFile.update((current)=>({
35
35
  profiles: {
36
36
  ...current.profiles,
@@ -40,19 +40,19 @@ const createCommand = Command.make("create", {
40
40
  }
41
41
  }
42
42
  }), EMPTY_CREDENTIALS);
43
- yield* Console.log(`Created profile '${profile}'.`);
44
- })).pipe(Command.withDescription("Add a credential profile"));
43
+ yield* Effect.log(`Created profile '${profile}'.`);
44
+ }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Add a credential profile"));
45
45
  const listCredsCommand = Command.make("list", {}, ()=>Effect.gen(function*() {
46
46
  const credentialsFile = yield* ReposetsCredentialsFile;
47
47
  const creds = yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS);
48
- if (0 === Object.keys(creds.profiles).length) return void (yield* Console.log("No credential profiles configured."));
48
+ if (0 === Object.keys(creds.profiles).length) return void (yield* Effect.log("No credential profiles configured."));
49
49
  for (const [name, profile] of Object.entries(creds.profiles)){
50
- yield* Console.log(`[${name}]`);
51
- if (profile.github_token) yield* Console.log(` github_token: ${redactToken(profile.github_token)}`);
52
- if (profile.op_service_account_token) yield* Console.log(` op_service_account_token: ${redactToken(profile.op_service_account_token)}`);
53
- yield* Console.log("");
50
+ yield* Effect.log(`[${name}]`);
51
+ if (profile.github_token) yield* Effect.log(` github_token: ${redactToken(profile.github_token)}`);
52
+ if (profile.op_service_account_token) yield* Effect.log(` op_service_account_token: ${redactToken(profile.op_service_account_token)}`);
53
+ yield* Effect.log("");
54
54
  }
55
- })).pipe(Command.withDescription("List profiles (tokens redacted)"));
55
+ }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("List profiles (tokens redacted)"));
56
56
  const deleteCommand = Command.make("delete", {
57
57
  profile: profileOption
58
58
  }, ({ profile })=>Effect.gen(function*() {
@@ -60,13 +60,13 @@ const deleteCommand = Command.make("delete", {
60
60
  yield* appDirs.ensureConfig;
61
61
  const credentialsFile = yield* ReposetsCredentialsFile;
62
62
  const creds = yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS);
63
- if (!creds.profiles[profile]) return void (yield* Console.error(`Profile '${profile}' not found.`));
63
+ if (!creds.profiles[profile]) return void (yield* Effect.logError(`Profile '${profile}' not found.`));
64
64
  const { [profile]: _, ...remainingProfiles } = creds.profiles;
65
65
  yield* credentialsFile.save({
66
66
  profiles: remainingProfiles
67
67
  });
68
- yield* Console.log(`Deleted profile '${profile}'.`);
69
- })).pipe(Command.withDescription("Remove a profile"));
68
+ yield* Effect.log(`Deleted profile '${profile}'.`);
69
+ }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Remove a profile"));
70
70
  const credentialsCommand = Command.make("credentials").pipe(Command.withDescription("Manage credential profiles"), Command.withSubcommands([
71
71
  createCommand,
72
72
  listCredsCommand,
@@ -81,6 +81,8 @@ const KNOWN_CONFIG_KEYS = new Set([
81
81
  "variables",
82
82
  "rulesets",
83
83
  "environments",
84
+ "security",
85
+ "code_scanning",
84
86
  "groups"
85
87
  ]);
86
88
  const KNOWN_GROUP_KEYS = new Set([
@@ -92,6 +94,8 @@ const KNOWN_GROUP_KEYS = new Set([
92
94
  "variables",
93
95
  "rulesets",
94
96
  "environments",
97
+ "security",
98
+ "code_scanning",
95
99
  "cleanup"
96
100
  ]);
97
101
  const KNOWN_CLEANUP_KEYS = new Set([
@@ -138,23 +142,24 @@ const doctorCommand = Command.make("doctor", {
138
142
  config: configOption
139
143
  }, ({ config })=>Effect.gen(function*() {
140
144
  const configFile = yield* ReposetsConfigFile;
141
- const discoverResult = yield* Effect.either(loadConfigWithDir(configFile, config));
142
- if ("Left" === discoverResult._tag) return void (yield* Console.error("No config found. Run 'reposets init' to create one."));
143
- const { configDir } = discoverResult.right;
144
- const configPath = join(configDir, "reposets.config.toml");
145
+ const discoverResult = yield* Effect.either(configFile.discover);
146
+ if ("Left" === discoverResult._tag) return void (yield* Effect.logError("No config found. Run 'reposets init' to create one."));
147
+ const sources = discoverResult.right;
148
+ if (0 === sources.length) return void (yield* Effect.logError("No config found. Run 'reposets init' to create one."));
149
+ const configPath = sources[0].path;
145
150
  let raw;
146
151
  try {
147
152
  const configToml = readFileSync(configPath, "utf-8");
148
153
  raw = parse(configToml);
149
154
  } catch (err) {
150
- yield* Console.error(`TOML parse error: ${err instanceof Error ? err.message : String(err)}`);
155
+ yield* Effect.logError(`TOML parse error: ${err instanceof Error ? err.message : String(err)}`);
151
156
  return;
152
157
  }
153
158
  let warnings = 0;
154
159
  for (const key of Object.keys(raw))if (!KNOWN_CONFIG_KEYS.has(key)) {
155
160
  const suggestion = findClosestMatch(key, KNOWN_CONFIG_KEYS);
156
161
  const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
157
- yield* Console.log(`Warning: unknown top-level key '${key}'${hint}`);
162
+ yield* Effect.log(`Warning: unknown top-level key '${key}'${hint}`);
158
163
  warnings++;
159
164
  }
160
165
  const groups = raw.groups;
@@ -163,7 +168,7 @@ const doctorCommand = Command.make("doctor", {
163
168
  for (const key of Object.keys(group))if (!KNOWN_GROUP_KEYS.has(key)) {
164
169
  const suggestion = findClosestMatch(key, KNOWN_GROUP_KEYS);
165
170
  const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
166
- yield* Console.log(`Warning: unknown key '${key}' in groups.${groupName}${hint}`);
171
+ yield* Effect.log(`Warning: unknown key '${key}' in groups.${groupName}${hint}`);
167
172
  warnings++;
168
173
  }
169
174
  }
@@ -177,7 +182,7 @@ const doctorCommand = Command.make("doctor", {
177
182
  for (const key of Object.keys(cleanupObj))if (!KNOWN_CLEANUP_KEYS.has(key)) {
178
183
  const suggestion = findClosestMatch(key, KNOWN_CLEANUP_KEYS);
179
184
  const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
180
- yield* Console.log(`Warning: unknown key '${key}' in ${prefix}${hint}`);
185
+ yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}${hint}`);
181
186
  warnings++;
182
187
  }
183
188
  const secrets = cleanupObj.secrets;
@@ -185,7 +190,7 @@ const doctorCommand = Command.make("doctor", {
185
190
  for (const key of Object.keys(secrets))if (!KNOWN_CLEANUP_SECRETS_KEYS.has(key)) {
186
191
  const suggestion = findClosestMatch(key, KNOWN_CLEANUP_SECRETS_KEYS);
187
192
  const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
188
- yield* Console.log(`Warning: unknown key '${key}' in ${prefix}.secrets${hint}`);
193
+ yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}.secrets${hint}`);
189
194
  warnings++;
190
195
  }
191
196
  }
@@ -194,21 +199,25 @@ const doctorCommand = Command.make("doctor", {
194
199
  for (const key of Object.keys(variables))if (!KNOWN_CLEANUP_VARIABLES_KEYS.has(key)) {
195
200
  const suggestion = findClosestMatch(key, KNOWN_CLEANUP_VARIABLES_KEYS);
196
201
  const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
197
- yield* Console.log(`Warning: unknown key '${key}' in ${prefix}.variables${hint}`);
202
+ yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}.variables${hint}`);
198
203
  warnings++;
199
204
  }
200
205
  }
201
206
  }
202
- yield* Console.log("Schema validation: passed");
203
- yield* Console.log("\nRequired fine-grained token permissions:");
204
- yield* Console.log(" Repository permissions > Administration (Read and write) -- settings sync");
205
- yield* Console.log(" Repository permissions > Secrets (Read and write) -- Actions secrets");
206
- yield* Console.log(" Repository permissions > Variables (Read and write) -- Actions variables");
207
- yield* Console.log(" Repository permissions > Environments (Read and write) -- environment sync");
208
- yield* Console.log(" Account permissions > GPG keys (Read and write) -- secrets encryption key");
209
- if (0 === warnings) yield* Console.log("\nNo unknown keys detected.");
210
- else yield* Console.log(`\n${warnings} warning(s) found.`);
211
- })).pipe(Command.withDescription("Deep config diagnostics with typo detection"));
207
+ yield* Effect.log("Schema validation: passed");
208
+ yield* Effect.log("\nRequired fine-grained token permissions:");
209
+ yield* Effect.log(" Repository permissions > Administration (Read and write) -- settings sync");
210
+ yield* Effect.log(" Repository permissions > Secrets (Read and write) -- Actions secrets");
211
+ yield* Effect.log(" Repository permissions > Variables (Read and write) -- Actions variables");
212
+ yield* Effect.log(" Repository permissions > Environments (Read and write) -- environment sync");
213
+ yield* Effect.log(" Repository permissions > Code scanning alerts (Read and write) -- code_scanning sync");
214
+ yield* Effect.log(" Repository permissions > Dependabot alerts (Read and write) -- security feature sync");
215
+ yield* Effect.log(" Repository permissions > Secret scanning alerts (Read and write) -- secret scanning delegation");
216
+ yield* Effect.log(" Account permissions > GPG keys (Read and write) -- secrets encryption key");
217
+ yield* Effect.log(" Organization permissions > Members (Read) -- resolve team slugs (org-level only)");
218
+ if (0 === warnings) yield* Effect.log("\nNo unknown keys detected.");
219
+ else yield* Effect.log(`\n${warnings} warning(s) found.`);
220
+ }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Deep config diagnostics with typo detection"));
212
221
  const projectOption = Options.boolean("project").pipe(Options.withDescription("Create config in current directory instead of XDG/home location"), Options.withDefault(false));
213
222
  const CONFIG_TEMPLATE = `# reposets configuration
214
223
  # See: https://github.com/spencerbeggs/reposets
@@ -255,6 +264,35 @@ const CONFIG_TEMPLATE = `# reposets configuration
255
264
  # [[rulesets.default-branch.rules]]
256
265
  # type = "deletion"
257
266
 
267
+ # --- Advanced security ---
268
+ # Nested inside a settings group (folded into the same PATCH /repos call).
269
+ # Some fields are GHAS-licensed and only work on public repos or
270
+ # private repos with a GHAS subscription. Org-only fields are silently
271
+ # skipped on personal accounts.
272
+ #
273
+ # [settings.defaults.security_and_analysis]
274
+ # secret_scanning = "enabled"
275
+ # secret_scanning_push_protection = "enabled"
276
+ # dependabot_security_updates = "enabled"
277
+
278
+ # --- Security feature toggles ---
279
+ # Dedicated PUT/DELETE endpoints; omit a key to leave it untouched.
280
+ #
281
+ # [security.oss-defaults]
282
+ # vulnerability_alerts = true
283
+ # automated_security_fixes = true
284
+ # private_vulnerability_reporting = true
285
+
286
+ # --- CodeQL default setup ---
287
+ # Applies via PATCH /repos/{o}/{r}/code-scanning/default-setup.
288
+ # Languages not detected in the repo are skipped with a warning.
289
+ #
290
+ # [code_scanning.oss-defaults]
291
+ # state = "configured"
292
+ # languages = ["javascript-typescript", "python"]
293
+ # query_suite = "extended"
294
+ # threat_model = "remote"
295
+
258
296
  # --- Cleanup defaults ---
259
297
  # [cleanup]
260
298
  # secrets = false
@@ -268,6 +306,8 @@ const CONFIG_TEMPLATE = `# reposets configuration
268
306
  # secrets = { actions = ["from-files", "from-creds"] }
269
307
  # variables = { actions = ["turbo", "bot"] }
270
308
  # rulesets = ["default-branch"]
309
+ # security = ["oss-defaults"]
310
+ # code_scanning = ["oss-defaults"]
271
311
  `;
272
312
  const CREDENTIALS_TEMPLATE = `# reposets credentials (keep this file private)
273
313
  # See: https://github.com/spencerbeggs/reposets
@@ -289,15 +329,15 @@ const initCommand = Command.make("init", {
289
329
  });
290
330
  const configPath = join(targetDir, CONFIG_FILE);
291
331
  const credsPath = join(targetDir, CREDENTIALS_FILE);
292
- if (existsSync(configPath)) yield* Console.log(`Config already exists: ${configPath}`);
332
+ if (existsSync(configPath)) yield* Effect.log(`Config already exists: ${configPath}`);
293
333
  else {
294
334
  writeFileSync(configPath, CONFIG_TEMPLATE);
295
- yield* Console.log(`Created: ${configPath}`);
335
+ yield* Effect.log(`Created: ${configPath}`);
296
336
  }
297
- if (existsSync(credsPath)) yield* Console.log(`Credentials already exists: ${credsPath}`);
337
+ if (existsSync(credsPath)) yield* Effect.log(`Credentials already exists: ${credsPath}`);
298
338
  else {
299
339
  writeFileSync(credsPath, CREDENTIALS_TEMPLATE);
300
- yield* Console.log(`Created: ${credsPath}`);
340
+ yield* Effect.log(`Created: ${credsPath}`);
301
341
  }
302
342
  if (project) {
303
343
  const gitignorePath = join(targetDir, ".gitignore");
@@ -305,35 +345,37 @@ const initCommand = Command.make("init", {
305
345
  const content = readFileSync(gitignorePath, "utf-8");
306
346
  if (!content.includes(CREDENTIALS_FILE)) {
307
347
  appendFileSync(gitignorePath, `\n${CREDENTIALS_FILE}\n`);
308
- yield* Console.log(`Added ${CREDENTIALS_FILE} to .gitignore`);
348
+ yield* Effect.log(`Added ${CREDENTIALS_FILE} to .gitignore`);
309
349
  }
310
350
  } else {
311
351
  writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
312
- yield* Console.log(`Created .gitignore with ${CREDENTIALS_FILE}`);
352
+ yield* Effect.log(`Created .gitignore with ${CREDENTIALS_FILE}`);
313
353
  }
314
354
  } else {
315
355
  const gitignorePath = join(targetDir, ".gitignore");
316
356
  if (!existsSync(gitignorePath)) {
317
357
  writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
318
- yield* Console.log(`Created .gitignore in ${targetDir}`);
358
+ yield* Effect.log(`Created .gitignore in ${targetDir}`);
319
359
  }
320
360
  }
321
- yield* Console.log("\nDone! Edit your config and credentials files to get started.");
322
- })).pipe(Command.withDescription("Scaffold config files"));
361
+ yield* Effect.log("\nDone! Edit your config and credentials files to get started.");
362
+ }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Scaffold config files"));
323
363
  const list_configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
324
364
  const listCommand = Command.make("list", {
325
365
  config: list_configOption
326
366
  }, ({ config })=>Effect.gen(function*() {
327
367
  const configFile = yield* ReposetsConfigFile;
328
- const { config: parsedConfig } = yield* loadConfigWithDir(configFile, config);
368
+ const sources = yield* configFile.discover;
369
+ if (0 === sources.length) return void (yield* Effect.logError("No config file found."));
370
+ const parsedConfig = sources[0].value;
329
371
  const defaultOwner = parsedConfig.owner ?? "(not set)";
330
- yield* Console.log(`Default owner: ${defaultOwner}\n`);
372
+ yield* Effect.log(`Default owner: ${defaultOwner}\n`);
331
373
  for (const [groupName, group] of Object.entries(parsedConfig.groups)){
332
374
  const owner = group.owner ?? parsedConfig.owner ?? "(not set)";
333
- yield* Console.log(`[${groupName}] (owner: ${owner})`);
334
- for (const repo of group.repos)yield* Console.log(` - ${owner}/${repo}`);
335
- if (group.settings?.length) yield* Console.log(` settings: ${group.settings.join(", ")}`);
336
- if (group.environments?.length) yield* Console.log(` environments: ${group.environments.join(", ")}`);
375
+ yield* Effect.log(`[${groupName}] (owner: ${owner})`);
376
+ for (const repo of group.repos)yield* Effect.log(` - ${owner}/${repo}`);
377
+ if (group.settings?.length) yield* Effect.log(` settings: ${group.settings.join(", ")}`);
378
+ if (group.environments?.length) yield* Effect.log(` environments: ${group.environments.join(", ")}`);
337
379
  if (group.secrets) {
338
380
  const parts = [];
339
381
  if (group.secrets.actions?.length) parts.push(`actions:[${group.secrets.actions.join(",")}]`);
@@ -342,7 +384,7 @@ const listCommand = Command.make("list", {
342
384
  if (group.secrets.environments) {
343
385
  for (const [envName, envGroups] of Object.entries(group.secrets.environments))if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
344
386
  }
345
- if (parts.length) yield* Console.log(` secrets: ${parts.join(", ")}`);
387
+ if (parts.length) yield* Effect.log(` secrets: ${parts.join(", ")}`);
346
388
  }
347
389
  if (group.variables) {
348
390
  const parts = [];
@@ -350,13 +392,15 @@ const listCommand = Command.make("list", {
350
392
  if (group.variables.environments) {
351
393
  for (const [envName, envGroups] of Object.entries(group.variables.environments))if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
352
394
  }
353
- if (parts.length) yield* Console.log(` variables: ${parts.join(", ")}`);
395
+ if (parts.length) yield* Effect.log(` variables: ${parts.join(", ")}`);
354
396
  }
355
- if (group.rulesets?.length) yield* Console.log(` rulesets: ${group.rulesets.join(", ")}`);
356
- if (group.credentials) yield* Console.log(` credentials: ${group.credentials}`);
357
- yield* Console.log("");
397
+ if (group.rulesets?.length) yield* Effect.log(` rulesets: ${group.rulesets.join(", ")}`);
398
+ if (group.security?.length) yield* Effect.log(` security: ${group.security.join(", ")}`);
399
+ if (group.code_scanning?.length) yield* Effect.log(` code_scanning: ${group.code_scanning.join(", ")}`);
400
+ if (group.credentials) yield* Effect.log(` credentials: ${group.credentials}`);
401
+ yield* Effect.log("");
358
402
  }
359
- })).pipe(Command.withDescription("Show config summary"));
403
+ }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Show config summary"));
360
404
  const sync_configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
361
405
  const groupOption = Options.text("group").pipe(Options.withDescription("Sync only a specific repo group"), Options.optional);
362
406
  const repoOption = Options.text("repo").pipe(Options.withDescription("Sync only a specific repo"), Options.optional);
@@ -377,7 +421,10 @@ const syncCommand = Command.make("sync", {
377
421
  logLevel: logLevelOption
378
422
  }, ({ config, group, repo, dryRun, noCleanup, logLevel: logLevelFlag })=>Effect.gen(function*() {
379
423
  const configFile = yield* ReposetsConfigFile;
380
- const { config: parsedConfig, configDir } = yield* loadConfigWithDir(configFile, config);
424
+ const sources = yield* configFile.discover;
425
+ if (0 === sources.length) return void (yield* Effect.logError("No config file found."));
426
+ const parsedConfig = sources[0].value;
427
+ const configDir = dirname(sources[0].path);
381
428
  const credentialsFile = yield* ReposetsCredentialsFile;
382
429
  const credentials = yield* credentialsFile.loadOrDefault({
383
430
  profiles: {}
@@ -385,7 +432,7 @@ const syncCommand = Command.make("sync", {
385
432
  const profileNames = Object.keys(credentials.profiles);
386
433
  const defaultProfile = 1 === profileNames.length ? profileNames[0] : void 0;
387
434
  const token = defaultProfile ? credentials.profiles[defaultProfile]?.github_token : void 0;
388
- if (!token) return void (yield* Console.error("No GitHub token found. Run 'reposets credentials create' first."));
435
+ if (!token) return void (yield* Effect.logError("No GitHub token found. Run 'reposets credentials create' first."));
389
436
  const logLevel = "Some" === logLevelFlag._tag ? logLevelFlag.value : parsedConfig.log_level;
390
437
  const githubLayer = GitHubClientLive(token);
391
438
  const opLayer = OnePasswordClientLive;
@@ -397,7 +444,7 @@ const syncCommand = Command.make("sync", {
397
444
  const engineLayer = Layer.provideMerge(SyncEngineLive, Layer.merge(Layer.merge(githubLayer, resolverLayer), loggerLayer));
398
445
  const groupFilter = "Some" === group._tag ? group.value : void 0;
399
446
  const repoFilter = "Some" === repo._tag ? repo.value : void 0;
400
- if (dryRun && "silent" !== logLevel) yield* Console.log("DRY RUN \u2014 no changes will be made\n");
447
+ if (dryRun && "silent" !== logLevel) yield* Effect.log("DRY RUN \u2014 no changes will be made\n");
401
448
  yield* Effect.provide(Effect.gen(function*() {
402
449
  const engine = yield* SyncEngine;
403
450
  yield* engine.syncAll(parsedConfig, credentials, {
@@ -408,82 +455,51 @@ const syncCommand = Command.make("sync", {
408
455
  configDir
409
456
  });
410
457
  }), engineLayer);
411
- })).pipe(Command.withDescription("Sync repos with GitHub"));
458
+ }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Sync repos with GitHub"));
412
459
  const validate_configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
413
460
  const validateCommand = Command.make("validate", {
414
461
  config: validate_configOption
415
462
  }, ({ config })=>Effect.gen(function*() {
416
463
  const configFile = yield* ReposetsConfigFile;
417
464
  let hasErrors = false;
418
- const configResult = yield* Effect.either(loadConfigWithDir(configFile, config));
419
- if ("Left" === configResult._tag) return void (yield* Console.error(`Config validation failed: ${configResult.left.message}`));
420
- yield* Console.log("Config schema: valid");
421
- const { config: parsedConfig, configDir } = configResult.right;
422
- for (const [groupName, group] of Object.entries(parsedConfig.groups)){
423
- for (const ref of group.settings ?? [])if (!parsedConfig.settings?.[ref]) {
424
- yield* Console.error(`Group '${groupName}': references unknown settings group '${ref}'`);
425
- hasErrors = true;
426
- }
427
- const allSecretRefs = [
428
- ...group.secrets?.actions ?? [],
429
- ...group.secrets?.dependabot ?? [],
430
- ...group.secrets?.codespaces ?? []
431
- ];
432
- for (const ref of allSecretRefs)if (!parsedConfig.secrets?.[ref]) {
433
- yield* Console.error(`Group '${groupName}': references unknown secrets group '${ref}'`);
434
- hasErrors = true;
435
- }
436
- for (const ref of group.variables?.actions ?? [])if (!parsedConfig.variables?.[ref]) {
437
- yield* Console.error(`Group '${groupName}': references unknown variables group '${ref}'`);
438
- hasErrors = true;
439
- }
440
- for (const ref of group.rulesets ?? [])if (!parsedConfig.rulesets?.[ref]) {
441
- yield* Console.error(`Group '${groupName}': references unknown ruleset '${ref}'`);
442
- hasErrors = true;
443
- }
444
- for (const ref of group.environments ?? [])if (!parsedConfig.environments?.[ref]) {
445
- yield* Console.error(`Group '${groupName}': references unknown environment '${ref}'`);
446
- hasErrors = true;
447
- }
448
- if (group.secrets?.environments) {
449
- for (const [envName, groupRefs] of Object.entries(group.secrets.environments))for (const ref of groupRefs)if (!parsedConfig.secrets?.[ref]) {
450
- yield* Console.error(`Group '${groupName}': secrets.environments.${envName} references unknown secrets group '${ref}'`);
451
- hasErrors = true;
452
- }
453
- }
454
- if (group.variables?.environments) {
455
- for (const [envName, groupRefs] of Object.entries(group.variables.environments))for (const ref of groupRefs)if (!parsedConfig.variables?.[ref]) {
456
- yield* Console.error(`Group '${groupName}': variables.environments.${envName} references unknown variables group '${ref}'`);
457
- hasErrors = true;
458
- }
459
- }
460
- }
465
+ const configResult = yield* Effect.either(configFile.discover);
466
+ if ("Left" === configResult._tag) return void (yield* Effect.logError(`Config validation failed: ${configResult.left.message}`));
467
+ const sources = configResult.right;
468
+ if (0 === sources.length) return void (yield* Effect.logError("No config file found."));
469
+ yield* Effect.log("Config schema: valid");
470
+ const parsedConfig = sources[0].value;
471
+ const configDir = dirname(sources[0].path);
461
472
  for (const [groupName, group] of Object.entries(parsedConfig.secrets))if ("file" in group) for (const [entryName, filePath] of Object.entries(group.file)){
462
473
  const fullPath = join(configDir, filePath);
463
474
  if (!existsSync(fullPath)) {
464
- yield* Console.error(`secrets.${groupName}.file.${entryName}: file not found: ${fullPath}`);
475
+ yield* Effect.logError(`secrets.${groupName}.file.${entryName}: file not found: ${fullPath}`);
465
476
  hasErrors = true;
466
477
  }
467
478
  }
468
479
  for (const [groupName, group] of Object.entries(parsedConfig.variables))if ("file" in group) for (const [entryName, filePath] of Object.entries(group.file)){
469
480
  const fullPath = join(configDir, filePath);
470
481
  if (!existsSync(fullPath)) {
471
- yield* Console.error(`variables.${groupName}.file.${entryName}: file not found: ${fullPath}`);
482
+ yield* Effect.logError(`variables.${groupName}.file.${entryName}: file not found: ${fullPath}`);
472
483
  hasErrors = true;
473
484
  }
474
485
  }
475
486
  const credentialsFile = yield* ReposetsCredentialsFile;
476
487
  const credsResult = yield* Effect.either(credentialsFile.load);
477
- if ("Left" === credsResult._tag) yield* Console.log("Credentials file: not found (optional)");
488
+ if ("Left" === credsResult._tag) yield* Effect.log("Credentials file: not found (optional)");
478
489
  else {
479
- yield* Console.log("Credentials schema: valid");
490
+ yield* Effect.log("Credentials schema: valid");
480
491
  for (const [groupName, group] of Object.entries(parsedConfig.groups))if (group.credentials && !credsResult.right.profiles[group.credentials]) {
481
- yield* Console.error(`Group '${groupName}': references unknown credentials profile '${group.credentials}'`);
492
+ yield* Effect.logError(`Group '${groupName}': references unknown credentials profile '${group.credentials}'`);
482
493
  hasErrors = true;
483
494
  }
484
495
  }
485
- if (!hasErrors) yield* Console.log("\nAll checks passed.");
486
- })).pipe(Command.withDescription("Validate config without API calls"));
496
+ if (!hasErrors) yield* Effect.log("\nAll checks passed.");
497
+ }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Validate config without API calls"));
498
+ const CliLogger = Logger.replace(Logger.defaultLogger, Logger.make(({ logLevel, message })=>{
499
+ const text = "string" == typeof message ? message : String(message);
500
+ if (LogLevel.greaterThanEqual(logLevel, LogLevel.Error)) globalThis.console.error(text);
501
+ else globalThis.console.log(text);
502
+ }));
487
503
  const rootCommand = Command.make("reposets").pipe(Command.withSubcommands([
488
504
  syncCommand,
489
505
  listCommand,
@@ -496,5 +512,5 @@ const cli = Command.run(rootCommand, {
496
512
  name: "reposets",
497
513
  version: "0.0.0"
498
514
  });
499
- const program = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(ConfigFilesLive), Effect.provide(NodeContext.layer));
515
+ const program = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(NodeContext.layer), Effect.provide(CliLogger));
500
516
  NodeRuntime.runMain(program);