reposets 0.4.1 → 0.4.3

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
@@ -1,8 +1,9 @@
1
1
  # reposets
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/reposets)](https://www.npmjs.com/package/reposets)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![TypeScript](https://img.shields.io/badge/TypeScript-6.0-blue.svg)](https://www.typescriptlang.org/)
3
+ [![npm](https://img.shields.io/npm/v/reposets?label=npm&color=cb3837)](https://www.npmjs.com/package/reposets)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
6
7
 
7
8
  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
9
 
@@ -22,26 +23,26 @@ Managing repository settings by hand doesn't scale. When you have dozens of repo
22
23
  - **Cleanup policies** — Automatically remove undeclared resources per scope with optional preserve lists, so your repos converge to the declared state.
23
24
  - **Dry-run and validation** — Preview changes before applying, validate config locally without touching the GitHub API, and catch typos with built-in diagnostics.
24
25
 
25
- ## Installation
26
+ ## Install
26
27
 
27
- ```sh
28
+ ```bash
28
29
  npm install -g reposets
29
30
  ```
30
31
 
31
32
  Alternative (no install):
32
33
 
33
- ```sh
34
+ ```bash
34
35
  npx reposets <command>
35
36
  ```
36
37
 
37
- Requires Node.js >= 20.
38
+ Requires the Node.js version shown in the badge above.
38
39
 
39
- ## Quick Start
40
+ ## Quick start
40
41
 
41
42
  1. Run `reposets init` to scaffold config files.
42
43
  2. Add a credential profile:
43
44
 
44
- ```sh
45
+ ```bash
45
46
  reposets credentials create --profile personal --github-token ghp_...
46
47
  ```
47
48
 
@@ -61,19 +62,19 @@ Requires Node.js >= 20.
61
62
 
62
63
  4. Validate your config:
63
64
 
64
- ```sh
65
+ ```bash
65
66
  reposets validate
66
67
  ```
67
68
 
68
69
  5. Preview changes without applying them:
69
70
 
70
- ```sh
71
+ ```bash
71
72
  reposets sync --dry-run
72
73
  ```
73
74
 
74
75
  6. Apply the config:
75
76
 
76
- ```sh
77
+ ```bash
77
78
  reposets sync
78
79
  ```
79
80
 
@@ -105,7 +106,7 @@ Config lookup order (first match wins):
105
106
 
106
107
  See the [docs/](https://github.com/spencerbeggs/reposets/tree/main/docs) folder for full reference on configuration, credentials, secrets, rulesets, environments, cleanup, and token setup.
107
108
 
108
- ## Token Permissions
109
+ ## Token permissions
109
110
 
110
111
  reposets requires a fine-grained personal access token with:
111
112
 
@@ -137,4 +138,4 @@ Full reference guides are available in the [`docs/`](https://github.com/spencerb
137
138
 
138
139
  ## License
139
140
 
140
- [MIT](./LICENSE)
141
+ [MIT](LICENSE)
package/bin/reposets.js CHANGED
@@ -1,516 +1,34 @@
1
1
  #!/usr/bin/env node
2
- import { Command, Options } from "@effect/cli";
2
+ import { credentialsCommand } from "../cli/commands/credentials.js";
3
+ import { doctorCommand } from "../cli/commands/doctor.js";
4
+ import { initCommand } from "../cli/commands/init.js";
5
+ import { listCommand } from "../cli/commands/list.js";
6
+ import { syncCommand } from "../cli/commands/sync.js";
7
+ import { validateCommand } from "../cli/commands/validate.js";
8
+ import { Effect, LogLevel, Logger } from "effect";
9
+ import { Command } from "@effect/cli";
3
10
  import { NodeContext, NodeRuntime } from "@effect/platform-node";
4
- import { Effect, Layer, LogLevel, Logger, Option } from "effect";
5
- import { AppDirs } from "xdg-effect";
6
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { parse } from "smol-toml";
8
- import { dirname, join } from "node:path";
9
- import { CredentialResolverLive, SyncEngineLive, ReposetsCredentialsFile, SyncLoggerLive, OnePasswordClientLive, GitHubClientLive, SyncEngine, makeConfigFilesLive, ReposetsConfigFile } from "../500.js";
10
- const EMPTY_CREDENTIALS = {
11
- profiles: {}
12
- };
13
- const profileOption = Options.text("profile").pipe(Options.withDescription("Credential profile name"));
14
- const githubTokenOption = Options.text("github-token").pipe(Options.withDescription("GitHub personal access token"), Options.optional);
15
- const opTokenOption = Options.text("op-token").pipe(Options.withDescription("1Password service account token"), Options.optional);
16
- function redactToken(token) {
17
- if (token.length <= 8) return "****";
18
- return `${token.slice(0, 4)}...${token.slice(-4)}`;
19
- }
20
- const createCommand = Command.make("create", {
21
- profile: profileOption,
22
- githubToken: githubTokenOption,
23
- opToken: opTokenOption
24
- }, ({ profile, githubToken, opToken })=>Effect.gen(function*() {
25
- const appDirs = yield* AppDirs;
26
- yield* appDirs.ensureConfig;
27
- const credentialsFile = yield* ReposetsCredentialsFile;
28
- const creds = yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS);
29
- if (creds.profiles[profile]) return void (yield* Effect.logError(`Profile '${profile}' already exists. Delete it first.`));
30
- const newProfile = {};
31
- if ("Some" === githubToken._tag) newProfile.github_token = githubToken.value;
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* Effect.logError("Provide at least --github-token or --op-token."));
34
- yield* credentialsFile.update((current)=>({
35
- profiles: {
36
- ...current.profiles,
37
- [profile]: {
38
- github_token: newProfile.github_token ?? "",
39
- ...newProfile
40
- }
41
- }
42
- }), EMPTY_CREDENTIALS);
43
- yield* Effect.log(`Created profile '${profile}'.`);
44
- }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Add a credential profile"));
45
- const listCredsCommand = Command.make("list", {}, ()=>Effect.gen(function*() {
46
- const credentialsFile = yield* ReposetsCredentialsFile;
47
- const creds = yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS);
48
- if (0 === Object.keys(creds.profiles).length) return void (yield* Effect.log("No credential profiles configured."));
49
- for (const [name, profile] of Object.entries(creds.profiles)){
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
- }
55
- }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("List profiles (tokens redacted)"));
56
- const deleteCommand = Command.make("delete", {
57
- profile: profileOption
58
- }, ({ profile })=>Effect.gen(function*() {
59
- const appDirs = yield* AppDirs;
60
- yield* appDirs.ensureConfig;
61
- const credentialsFile = yield* ReposetsCredentialsFile;
62
- const creds = yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS);
63
- if (!creds.profiles[profile]) return void (yield* Effect.logError(`Profile '${profile}' not found.`));
64
- const { [profile]: _, ...remainingProfiles } = creds.profiles;
65
- yield* credentialsFile.save({
66
- profiles: remainingProfiles
67
- });
68
- yield* Effect.log(`Deleted profile '${profile}'.`);
69
- }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Remove a profile"));
70
- const credentialsCommand = Command.make("credentials").pipe(Command.withDescription("Manage credential profiles"), Command.withSubcommands([
71
- createCommand,
72
- listCredsCommand,
73
- deleteCommand
74
- ]));
75
- const configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
76
- const KNOWN_CONFIG_KEYS = new Set([
77
- "owner",
78
- "log_level",
79
- "settings",
80
- "secrets",
81
- "variables",
82
- "rulesets",
83
- "environments",
84
- "security",
85
- "code_scanning",
86
- "groups"
87
- ]);
88
- const KNOWN_GROUP_KEYS = new Set([
89
- "owner",
90
- "repos",
91
- "credentials",
92
- "settings",
93
- "secrets",
94
- "variables",
95
- "rulesets",
96
- "environments",
97
- "security",
98
- "code_scanning",
99
- "cleanup"
100
- ]);
101
- const KNOWN_CLEANUP_KEYS = new Set([
102
- "secrets",
103
- "variables",
104
- "rulesets",
105
- "environments"
106
- ]);
107
- const KNOWN_CLEANUP_SECRETS_KEYS = new Set([
108
- "actions",
109
- "dependabot",
110
- "codespaces",
111
- "environments"
112
- ]);
113
- const KNOWN_CLEANUP_VARIABLES_KEYS = new Set([
114
- "actions",
115
- "environments"
116
- ]);
117
- function findClosestMatch(key, known) {
118
- let best;
119
- let bestDist = 1 / 0;
120
- for (const candidate of known){
121
- const dist = levenshtein(key, candidate);
122
- if (dist < bestDist && dist <= 3) {
123
- bestDist = dist;
124
- best = candidate;
125
- }
126
- }
127
- return best;
128
- }
129
- function levenshtein(a, b) {
130
- const matrix = [];
131
- for(let i = 0; i <= a.length; i++)matrix[i] = [
132
- i
133
- ];
134
- for(let j = 0; j <= b.length; j++)matrix[0][j] = j;
135
- for(let i = 1; i <= a.length; i++)for(let j = 1; j <= b.length; j++){
136
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
137
- matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
138
- }
139
- return matrix[a.length][b.length];
140
- }
141
- const doctorCommand = Command.make("doctor", {
142
- config: configOption
143
- }, ({ config })=>Effect.gen(function*() {
144
- const configFile = yield* ReposetsConfigFile;
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;
150
- let raw;
151
- try {
152
- const configToml = readFileSync(configPath, "utf-8");
153
- raw = parse(configToml);
154
- } catch (err) {
155
- yield* Effect.logError(`TOML parse error: ${err instanceof Error ? err.message : String(err)}`);
156
- return;
157
- }
158
- let warnings = 0;
159
- for (const key of Object.keys(raw))if (!KNOWN_CONFIG_KEYS.has(key)) {
160
- const suggestion = findClosestMatch(key, KNOWN_CONFIG_KEYS);
161
- const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
162
- yield* Effect.log(`Warning: unknown top-level key '${key}'${hint}`);
163
- warnings++;
164
- }
165
- const groups = raw.groups;
166
- if (groups && "object" == typeof groups) {
167
- for (const [groupName, group] of Object.entries(groups))if (group && "object" == typeof group) {
168
- for (const key of Object.keys(group))if (!KNOWN_GROUP_KEYS.has(key)) {
169
- const suggestion = findClosestMatch(key, KNOWN_GROUP_KEYS);
170
- const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
171
- yield* Effect.log(`Warning: unknown key '${key}' in groups.${groupName}${hint}`);
172
- warnings++;
173
- }
174
- }
175
- }
176
- if (groups && "object" == typeof groups) for (const [groupName, group] of Object.entries(groups)){
177
- if (!group || "object" != typeof group) continue;
178
- const cleanup = group.cleanup;
179
- if (!cleanup || "object" != typeof cleanup) continue;
180
- const cleanupObj = cleanup;
181
- const prefix = `groups.${groupName}.cleanup`;
182
- for (const key of Object.keys(cleanupObj))if (!KNOWN_CLEANUP_KEYS.has(key)) {
183
- const suggestion = findClosestMatch(key, KNOWN_CLEANUP_KEYS);
184
- const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
185
- yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}${hint}`);
186
- warnings++;
187
- }
188
- const secrets = cleanupObj.secrets;
189
- if (secrets && "object" == typeof secrets) {
190
- for (const key of Object.keys(secrets))if (!KNOWN_CLEANUP_SECRETS_KEYS.has(key)) {
191
- const suggestion = findClosestMatch(key, KNOWN_CLEANUP_SECRETS_KEYS);
192
- const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
193
- yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}.secrets${hint}`);
194
- warnings++;
195
- }
196
- }
197
- const variables = cleanupObj.variables;
198
- if (variables && "object" == typeof variables) {
199
- for (const key of Object.keys(variables))if (!KNOWN_CLEANUP_VARIABLES_KEYS.has(key)) {
200
- const suggestion = findClosestMatch(key, KNOWN_CLEANUP_VARIABLES_KEYS);
201
- const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
202
- yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}.variables${hint}`);
203
- warnings++;
204
- }
205
- }
206
- }
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"));
221
- const projectOption = Options.boolean("project").pipe(Options.withDescription("Create config in current directory instead of XDG/home location"), Options.withDefault(false));
222
- const CONFIG_TEMPLATE = `# reposets configuration
223
- # See: https://github.com/spencerbeggs/reposets
224
-
225
- # Default owner for all groups (can be overridden per group)
226
- # owner = "your-github-username"
227
-
228
- # --- Settings groups ---
229
- # [settings.defaults]
230
- # has_wiki = false
231
- # has_issues = true
232
- # delete_branch_on_merge = true
233
-
234
- # --- Secret groups ---
235
- # Secrets can be file, value, or resolved kind:
236
- #
237
- # [secrets.from-files.file]
238
- # APP_KEY = "./private/app-key"
239
- #
240
- # [secrets.inline.value]
241
- # STATIC_SECRET = "my-secret"
242
- #
243
- # [secrets.from-creds.resolved]
244
- # NPM_TOKEN = "MY_NPM_TOKEN"
245
-
246
- # --- Variable groups ---
247
- # [variables.turbo.value]
248
- # DO_NOT_TRACK = "1"
249
- # TURBO_TELEMETRY_DISABLED = "1"
250
- #
251
- # [variables.bot.resolved]
252
- # APP_BOT_NAME = "MY_BOT_NAME"
253
-
254
- # --- Rulesets ---
255
- # [rulesets.default-branch]
256
- # name = "default-branch"
257
- # enforcement = "active"
258
- # target = "branch"
259
- #
260
- # [rulesets.default-branch.conditions.ref_name]
261
- # include = ["~DEFAULT_BRANCH"]
262
- # exclude = []
263
- #
264
- # [[rulesets.default-branch.rules]]
265
- # type = "deletion"
266
11
 
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
-
296
- # --- Cleanup defaults ---
297
- # [cleanup]
298
- # secrets = false
299
- # variables = false
300
- # rulesets = false
301
-
302
- # --- Groups ---
303
- # [groups.my-projects]
304
- # repos = ["repo-one", "repo-two"]
305
- # settings = ["defaults"]
306
- # secrets = { actions = ["from-files", "from-creds"] }
307
- # variables = { actions = ["turbo", "bot"] }
308
- # rulesets = ["default-branch"]
309
- # security = ["oss-defaults"]
310
- # code_scanning = ["oss-defaults"]
311
- `;
312
- const CREDENTIALS_TEMPLATE = `# reposets credentials (keep this file private)
313
- # See: https://github.com/spencerbeggs/reposets
314
-
315
- # [profiles.personal]
316
- # github_token = "ghp_your_token_here"
317
- # op_service_account_token = "ops_your_token_here"
318
- `;
319
- const CREDENTIALS_FILE = "reposets.credentials.toml";
320
- const CONFIG_FILE = "reposets.config.toml";
321
- const initCommand = Command.make("init", {
322
- project: projectOption
323
- }, ({ project })=>Effect.gen(function*() {
324
- const appDirs = yield* AppDirs;
325
- const xdgConfigDir = yield* appDirs.config;
326
- const targetDir = project ? process.cwd() : xdgConfigDir;
327
- if (!existsSync(targetDir)) mkdirSync(targetDir, {
328
- recursive: true
329
- });
330
- const configPath = join(targetDir, CONFIG_FILE);
331
- const credsPath = join(targetDir, CREDENTIALS_FILE);
332
- if (existsSync(configPath)) yield* Effect.log(`Config already exists: ${configPath}`);
333
- else {
334
- writeFileSync(configPath, CONFIG_TEMPLATE);
335
- yield* Effect.log(`Created: ${configPath}`);
336
- }
337
- if (existsSync(credsPath)) yield* Effect.log(`Credentials already exists: ${credsPath}`);
338
- else {
339
- writeFileSync(credsPath, CREDENTIALS_TEMPLATE);
340
- yield* Effect.log(`Created: ${credsPath}`);
341
- }
342
- if (project) {
343
- const gitignorePath = join(targetDir, ".gitignore");
344
- if (existsSync(gitignorePath)) {
345
- const content = readFileSync(gitignorePath, "utf-8");
346
- if (!content.includes(CREDENTIALS_FILE)) {
347
- appendFileSync(gitignorePath, `\n${CREDENTIALS_FILE}\n`);
348
- yield* Effect.log(`Added ${CREDENTIALS_FILE} to .gitignore`);
349
- }
350
- } else {
351
- writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
352
- yield* Effect.log(`Created .gitignore with ${CREDENTIALS_FILE}`);
353
- }
354
- } else {
355
- const gitignorePath = join(targetDir, ".gitignore");
356
- if (!existsSync(gitignorePath)) {
357
- writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
358
- yield* Effect.log(`Created .gitignore in ${targetDir}`);
359
- }
360
- }
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"));
363
- const list_configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
364
- const listCommand = Command.make("list", {
365
- config: list_configOption
366
- }, ({ config })=>Effect.gen(function*() {
367
- const configFile = yield* ReposetsConfigFile;
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;
371
- const defaultOwner = parsedConfig.owner ?? "(not set)";
372
- yield* Effect.log(`Default owner: ${defaultOwner}\n`);
373
- for (const [groupName, group] of Object.entries(parsedConfig.groups)){
374
- const owner = group.owner ?? parsedConfig.owner ?? "(not set)";
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(", ")}`);
379
- if (group.secrets) {
380
- const parts = [];
381
- if (group.secrets.actions?.length) parts.push(`actions:[${group.secrets.actions.join(",")}]`);
382
- if (group.secrets.dependabot?.length) parts.push(`dependabot:[${group.secrets.dependabot.join(",")}]`);
383
- if (group.secrets.codespaces?.length) parts.push(`codespaces:[${group.secrets.codespaces.join(",")}]`);
384
- if (group.secrets.environments) {
385
- for (const [envName, envGroups] of Object.entries(group.secrets.environments))if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
386
- }
387
- if (parts.length) yield* Effect.log(` secrets: ${parts.join(", ")}`);
388
- }
389
- if (group.variables) {
390
- const parts = [];
391
- if (group.variables.actions?.length) parts.push(`actions:[${group.variables.actions.join(",")}]`);
392
- if (group.variables.environments) {
393
- for (const [envName, envGroups] of Object.entries(group.variables.environments))if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
394
- }
395
- if (parts.length) yield* Effect.log(` variables: ${parts.join(", ")}`);
396
- }
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("");
402
- }
403
- }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Show config summary"));
404
- const sync_configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
405
- const groupOption = Options.text("group").pipe(Options.withDescription("Sync only a specific repo group"), Options.optional);
406
- const repoOption = Options.text("repo").pipe(Options.withDescription("Sync only a specific repo"), Options.optional);
407
- const dryRunOption = Options.boolean("dry-run").pipe(Options.withDescription("Preview changes without making them"), Options.withDefault(false));
408
- const noCleanupOption = Options.boolean("no-cleanup").pipe(Options.withDescription("Skip cleanup of undeclared resources"), Options.withDefault(false));
409
- const logLevelOption = Options.choice("log-level", [
410
- "silent",
411
- "info",
412
- "verbose",
413
- "debug"
414
- ]).pipe(Options.withDescription("Set output verbosity (overrides log_level in config)"), Options.optional);
415
- const syncCommand = Command.make("sync", {
416
- config: sync_configOption,
417
- group: groupOption,
418
- repo: repoOption,
419
- dryRun: dryRunOption,
420
- noCleanup: noCleanupOption,
421
- logLevel: logLevelOption
422
- }, ({ config, group, repo, dryRun, noCleanup, logLevel: logLevelFlag })=>Effect.gen(function*() {
423
- const configFile = yield* ReposetsConfigFile;
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);
428
- const credentialsFile = yield* ReposetsCredentialsFile;
429
- const credentials = yield* credentialsFile.loadOrDefault({
430
- profiles: {}
431
- });
432
- const profileNames = Object.keys(credentials.profiles);
433
- const defaultProfile = 1 === profileNames.length ? profileNames[0] : void 0;
434
- const token = defaultProfile ? credentials.profiles[defaultProfile]?.github_token : void 0;
435
- if (!token) return void (yield* Effect.logError("No GitHub token found. Run 'reposets credentials create' first."));
436
- const logLevel = "Some" === logLevelFlag._tag ? logLevelFlag.value : parsedConfig.log_level;
437
- const githubLayer = GitHubClientLive(token);
438
- const opLayer = OnePasswordClientLive;
439
- const resolverLayer = Layer.provide(CredentialResolverLive, opLayer);
440
- const loggerLayer = SyncLoggerLive({
441
- dryRun,
442
- logLevel
443
- });
444
- const engineLayer = Layer.provideMerge(SyncEngineLive, Layer.merge(Layer.merge(githubLayer, resolverLayer), loggerLayer));
445
- const groupFilter = "Some" === group._tag ? group.value : void 0;
446
- const repoFilter = "Some" === repo._tag ? repo.value : void 0;
447
- if (dryRun && "silent" !== logLevel) yield* Effect.log("DRY RUN \u2014 no changes will be made\n");
448
- yield* Effect.provide(Effect.gen(function*() {
449
- const engine = yield* SyncEngine;
450
- yield* engine.syncAll(parsedConfig, credentials, {
451
- dryRun,
452
- noCleanup,
453
- groupFilter,
454
- repoFilter,
455
- configDir
456
- });
457
- }), engineLayer);
458
- }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Sync repos with GitHub"));
459
- const validate_configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
460
- const validateCommand = Command.make("validate", {
461
- config: validate_configOption
462
- }, ({ config })=>Effect.gen(function*() {
463
- const configFile = yield* ReposetsConfigFile;
464
- let hasErrors = false;
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);
472
- for (const [groupName, group] of Object.entries(parsedConfig.secrets))if ("file" in group) for (const [entryName, filePath] of Object.entries(group.file)){
473
- const fullPath = join(configDir, filePath);
474
- if (!existsSync(fullPath)) {
475
- yield* Effect.logError(`secrets.${groupName}.file.${entryName}: file not found: ${fullPath}`);
476
- hasErrors = true;
477
- }
478
- }
479
- for (const [groupName, group] of Object.entries(parsedConfig.variables))if ("file" in group) for (const [entryName, filePath] of Object.entries(group.file)){
480
- const fullPath = join(configDir, filePath);
481
- if (!existsSync(fullPath)) {
482
- yield* Effect.logError(`variables.${groupName}.file.${entryName}: file not found: ${fullPath}`);
483
- hasErrors = true;
484
- }
485
- }
486
- const credentialsFile = yield* ReposetsCredentialsFile;
487
- const credsResult = yield* Effect.either(credentialsFile.load);
488
- if ("Left" === credsResult._tag) yield* Effect.log("Credentials file: not found (optional)");
489
- else {
490
- yield* Effect.log("Credentials schema: valid");
491
- for (const [groupName, group] of Object.entries(parsedConfig.groups))if (group.credentials && !credsResult.right.profiles[group.credentials]) {
492
- yield* Effect.logError(`Group '${groupName}': references unknown credentials profile '${group.credentials}'`);
493
- hasErrors = true;
494
- }
495
- }
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);
12
+ //#region src/cli/index.ts
13
+ const CliLogger = Logger.replace(Logger.defaultLogger, Logger.make(({ logLevel, message }) => {
14
+ const text = typeof message === "string" ? message : String(message);
15
+ if (LogLevel.greaterThanEqual(logLevel, LogLevel.Error)) globalThis.console.error(text);
16
+ else globalThis.console.log(text);
502
17
  }));
503
18
  const rootCommand = Command.make("reposets").pipe(Command.withSubcommands([
504
- syncCommand,
505
- listCommand,
506
- validateCommand,
507
- doctorCommand,
508
- initCommand,
509
- credentialsCommand
19
+ syncCommand,
20
+ listCommand,
21
+ validateCommand,
22
+ doctorCommand,
23
+ initCommand,
24
+ credentialsCommand
510
25
  ]));
511
26
  const cli = Command.run(rootCommand, {
512
- name: "reposets",
513
- version: "0.0.0"
27
+ name: "reposets",
28
+ version: "0.0.0"
514
29
  });
515
- const program = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(NodeContext.layer), Effect.provide(CliLogger));
30
+ const program = Effect.suspend(() => cli(process.argv)).pipe(Effect.provide(NodeContext.layer), Effect.provide(CliLogger));
516
31
  NodeRuntime.runMain(program);
32
+
33
+ //#endregion
34
+ export { };