reposets 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # reposets
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/)
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.
8
+
9
+ ## Why reposets
10
+
11
+ Managing repository settings by hand doesn't scale. When you have dozens of repos that should share the same branch protection rules, CI secrets, and merge settings, clicking through the GitHub UI for each one is slow and error-prone. reposets lets you define that configuration once and sync it everywhere.
12
+
13
+ ## Features
14
+
15
+ - **Git-committable config templates** — Your entire repo configuration lives in a TOML file that is safe to commit, review, and share. Sensitive values are never stored in the config itself.
16
+ - **Resolvable values** — Secrets and integer fields reference named labels that resolve at sync time from 1Password, local files, or inline values in a separate credentials file. One config template works across environments.
17
+ - **Multi-scope secret and variable management** — Assign the same secret group to Actions, Dependabot, Codespaces, and deployment environments with scoped targeting.
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
+ - **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.
21
+ - **Cleanup policies** — Automatically remove undeclared resources per scope with optional preserve lists, so your repos converge to the declared state.
22
+ - **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
+ ## Installation
25
+
26
+ ```sh
27
+ npm install -g reposets
28
+ ```
29
+
30
+ Alternative (no install):
31
+
32
+ ```sh
33
+ npx reposets <command>
34
+ ```
35
+
36
+ Requires Node.js >= 20.
37
+
38
+ ## Quick Start
39
+
40
+ 1. Run `reposets init` to scaffold config files.
41
+ 2. Add a credential profile:
42
+
43
+ ```sh
44
+ reposets credentials create --profile personal --github-token ghp_...
45
+ ```
46
+
47
+ 3. Edit `reposets.config.toml` with your repos and settings:
48
+
49
+ ```toml
50
+ owner = "your-username"
51
+
52
+ [settings.default]
53
+ has_wiki = false
54
+ delete_branch_on_merge = true
55
+
56
+ [groups.my-repos]
57
+ repos = ["repo-one", "repo-two"]
58
+ settings = ["default"]
59
+ ```
60
+
61
+ 4. Validate your config:
62
+
63
+ ```sh
64
+ reposets validate
65
+ ```
66
+
67
+ 5. Preview changes without applying them:
68
+
69
+ ```sh
70
+ reposets sync --dry-run
71
+ ```
72
+
73
+ 6. Apply the config:
74
+
75
+ ```sh
76
+ reposets sync
77
+ ```
78
+
79
+ ## Commands
80
+
81
+ | Command | Description |
82
+ | :--- | :--- |
83
+ | `reposets sync` | Apply config to repos (supports --dry-run, --group, --repo, --no-cleanup) |
84
+ | `reposets list` | Show config summary |
85
+ | `reposets validate` | Validate config without API calls |
86
+ | `reposets doctor` | Deep diagnostics with typo detection |
87
+ | `reposets init` | Scaffold config files (--project for local) |
88
+ | `reposets credentials` | Manage credential profiles (create, list, delete) |
89
+
90
+ All commands accept `--log-level silent|info|verbose|debug`.
91
+
92
+ ## Configuration
93
+
94
+ reposets uses two TOML files:
95
+
96
+ - `reposets.config.toml` — defines settings, secrets, variables, rulesets, environments, and groups
97
+ - `reposets.credentials.toml` — stores GitHub tokens and optional resolve sections for named values
98
+
99
+ Config lookup order (first match wins):
100
+
101
+ 1. `--config` flag (explicit path or directory)
102
+ 2. Walk up from current directory looking for `reposets.config.toml`
103
+ 3. XDG fallback: `~/.config/reposets/reposets.config.toml`
104
+
105
+ 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.
106
+
107
+ ## Token Permissions
108
+
109
+ reposets requires a fine-grained personal access token with:
110
+
111
+ - Repository > Administration (Read and write)
112
+ - Repository > Secrets (Read and write)
113
+ - Repository > Variables (Read and write)
114
+ - Repository > Environments (Read and write)
115
+ - Account > GPG keys (Read and write)
116
+
117
+ ## Documentation
118
+
119
+ Full reference guides are available in the [`docs/`](https://github.com/spencerbeggs/reposets/tree/main/docs) folder:
120
+
121
+ - [Commands Reference](https://github.com/spencerbeggs/reposets/blob/main/docs/commands.md) - all commands, flags, and usage examples
122
+ - [Configuration](https://github.com/spencerbeggs/reposets/blob/main/docs/configuration.md) - config file format, path resolution, and settings reference
123
+ - [Credentials](https://github.com/spencerbeggs/reposets/blob/main/docs/credentials.md) - credential profiles, resolve sections, and 1Password integration
124
+ - [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
+ - [Rulesets](https://github.com/spencerbeggs/reposets/blob/main/docs/rulesets.md) - branch and tag ruleset configuration
126
+ - [Environments](https://github.com/spencerbeggs/reposets/blob/main/docs/environments.md) - deployment environment setup
127
+ - [Cleanup](https://github.com/spencerbeggs/reposets/blob/main/docs/cleanup.md) - automatic cleanup of undeclared resources
128
+ - [Token Permissions](https://github.com/spencerbeggs/reposets/blob/main/docs/token-permissions.md) - GitHub PAT setup guide
129
+
130
+ ## License
131
+
132
+ [MIT](./LICENSE)
@@ -0,0 +1,500 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Options } from "@effect/cli";
3
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
4
+ import { Console, Effect, Layer } from "effect";
5
+ import { AppDirs } from "xdg-effect";
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { parse } from "smol-toml";
9
+ import { CredentialResolverLive, ReposetsCredentialsFile, SyncEngineLive, SyncLoggerLive, OnePasswordClientLive, GitHubClientLive, loadConfigWithDir, SyncEngine, ConfigFilesLive, 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* Console.error(`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* Console.error("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* Console.log(`Created profile '${profile}'.`);
44
+ })).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* Console.log("No credential profiles configured."));
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("");
54
+ }
55
+ })).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* Console.error(`Profile '${profile}' not found.`));
64
+ const { [profile]: _, ...remainingProfiles } = creds.profiles;
65
+ yield* credentialsFile.save({
66
+ profiles: remainingProfiles
67
+ });
68
+ yield* Console.log(`Deleted profile '${profile}'.`);
69
+ })).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
+ "groups"
85
+ ]);
86
+ const KNOWN_GROUP_KEYS = new Set([
87
+ "owner",
88
+ "repos",
89
+ "credentials",
90
+ "settings",
91
+ "secrets",
92
+ "variables",
93
+ "rulesets",
94
+ "environments",
95
+ "cleanup"
96
+ ]);
97
+ const KNOWN_CLEANUP_KEYS = new Set([
98
+ "secrets",
99
+ "variables",
100
+ "rulesets",
101
+ "environments"
102
+ ]);
103
+ const KNOWN_CLEANUP_SECRETS_KEYS = new Set([
104
+ "actions",
105
+ "dependabot",
106
+ "codespaces",
107
+ "environments"
108
+ ]);
109
+ const KNOWN_CLEANUP_VARIABLES_KEYS = new Set([
110
+ "actions",
111
+ "environments"
112
+ ]);
113
+ function findClosestMatch(key, known) {
114
+ let best;
115
+ let bestDist = 1 / 0;
116
+ for (const candidate of known){
117
+ const dist = levenshtein(key, candidate);
118
+ if (dist < bestDist && dist <= 3) {
119
+ bestDist = dist;
120
+ best = candidate;
121
+ }
122
+ }
123
+ return best;
124
+ }
125
+ function levenshtein(a, b) {
126
+ const matrix = [];
127
+ for(let i = 0; i <= a.length; i++)matrix[i] = [
128
+ i
129
+ ];
130
+ for(let j = 0; j <= b.length; j++)matrix[0][j] = j;
131
+ for(let i = 1; i <= a.length; i++)for(let j = 1; j <= b.length; j++){
132
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
133
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
134
+ }
135
+ return matrix[a.length][b.length];
136
+ }
137
+ const doctorCommand = Command.make("doctor", {
138
+ config: configOption
139
+ }, ({ config })=>Effect.gen(function*() {
140
+ 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
+ let raw;
146
+ try {
147
+ const configToml = readFileSync(configPath, "utf-8");
148
+ raw = parse(configToml);
149
+ } catch (err) {
150
+ yield* Console.error(`TOML parse error: ${err instanceof Error ? err.message : String(err)}`);
151
+ return;
152
+ }
153
+ let warnings = 0;
154
+ for (const key of Object.keys(raw))if (!KNOWN_CONFIG_KEYS.has(key)) {
155
+ const suggestion = findClosestMatch(key, KNOWN_CONFIG_KEYS);
156
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
157
+ yield* Console.log(`Warning: unknown top-level key '${key}'${hint}`);
158
+ warnings++;
159
+ }
160
+ const groups = raw.groups;
161
+ if (groups && "object" == typeof groups) {
162
+ for (const [groupName, group] of Object.entries(groups))if (group && "object" == typeof group) {
163
+ for (const key of Object.keys(group))if (!KNOWN_GROUP_KEYS.has(key)) {
164
+ const suggestion = findClosestMatch(key, KNOWN_GROUP_KEYS);
165
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
166
+ yield* Console.log(`Warning: unknown key '${key}' in groups.${groupName}${hint}`);
167
+ warnings++;
168
+ }
169
+ }
170
+ }
171
+ if (groups && "object" == typeof groups) for (const [groupName, group] of Object.entries(groups)){
172
+ if (!group || "object" != typeof group) continue;
173
+ const cleanup = group.cleanup;
174
+ if (!cleanup || "object" != typeof cleanup) continue;
175
+ const cleanupObj = cleanup;
176
+ const prefix = `groups.${groupName}.cleanup`;
177
+ for (const key of Object.keys(cleanupObj))if (!KNOWN_CLEANUP_KEYS.has(key)) {
178
+ const suggestion = findClosestMatch(key, KNOWN_CLEANUP_KEYS);
179
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
180
+ yield* Console.log(`Warning: unknown key '${key}' in ${prefix}${hint}`);
181
+ warnings++;
182
+ }
183
+ const secrets = cleanupObj.secrets;
184
+ if (secrets && "object" == typeof secrets) {
185
+ for (const key of Object.keys(secrets))if (!KNOWN_CLEANUP_SECRETS_KEYS.has(key)) {
186
+ const suggestion = findClosestMatch(key, KNOWN_CLEANUP_SECRETS_KEYS);
187
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
188
+ yield* Console.log(`Warning: unknown key '${key}' in ${prefix}.secrets${hint}`);
189
+ warnings++;
190
+ }
191
+ }
192
+ const variables = cleanupObj.variables;
193
+ if (variables && "object" == typeof variables) {
194
+ for (const key of Object.keys(variables))if (!KNOWN_CLEANUP_VARIABLES_KEYS.has(key)) {
195
+ const suggestion = findClosestMatch(key, KNOWN_CLEANUP_VARIABLES_KEYS);
196
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
197
+ yield* Console.log(`Warning: unknown key '${key}' in ${prefix}.variables${hint}`);
198
+ warnings++;
199
+ }
200
+ }
201
+ }
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"));
212
+ const projectOption = Options.boolean("project").pipe(Options.withDescription("Create config in current directory instead of XDG/home location"), Options.withDefault(false));
213
+ const CONFIG_TEMPLATE = `# reposets configuration
214
+ # See: https://github.com/spencerbeggs/reposets
215
+
216
+ # Default owner for all groups (can be overridden per group)
217
+ # owner = "your-github-username"
218
+
219
+ # --- Settings groups ---
220
+ # [settings.defaults]
221
+ # has_wiki = false
222
+ # has_issues = true
223
+ # delete_branch_on_merge = true
224
+
225
+ # --- Secret groups ---
226
+ # Secrets can be file, value, or resolved kind:
227
+ #
228
+ # [secrets.from-files.file]
229
+ # APP_KEY = "./private/app-key"
230
+ #
231
+ # [secrets.inline.value]
232
+ # STATIC_SECRET = "my-secret"
233
+ #
234
+ # [secrets.from-creds.resolved]
235
+ # NPM_TOKEN = "MY_NPM_TOKEN"
236
+
237
+ # --- Variable groups ---
238
+ # [variables.turbo.value]
239
+ # DO_NOT_TRACK = "1"
240
+ # TURBO_TELEMETRY_DISABLED = "1"
241
+ #
242
+ # [variables.bot.resolved]
243
+ # APP_BOT_NAME = "MY_BOT_NAME"
244
+
245
+ # --- Rulesets ---
246
+ # [rulesets.default-branch]
247
+ # name = "default-branch"
248
+ # enforcement = "active"
249
+ # target = "branch"
250
+ #
251
+ # [rulesets.default-branch.conditions.ref_name]
252
+ # include = ["~DEFAULT_BRANCH"]
253
+ # exclude = []
254
+ #
255
+ # [[rulesets.default-branch.rules]]
256
+ # type = "deletion"
257
+
258
+ # --- Cleanup defaults ---
259
+ # [cleanup]
260
+ # secrets = false
261
+ # variables = false
262
+ # rulesets = false
263
+
264
+ # --- Groups ---
265
+ # [groups.my-projects]
266
+ # repos = ["repo-one", "repo-two"]
267
+ # settings = ["defaults"]
268
+ # secrets = { actions = ["from-files", "from-creds"] }
269
+ # variables = { actions = ["turbo", "bot"] }
270
+ # rulesets = ["default-branch"]
271
+ `;
272
+ const CREDENTIALS_TEMPLATE = `# reposets credentials (keep this file private)
273
+ # See: https://github.com/spencerbeggs/reposets
274
+
275
+ # [profiles.personal]
276
+ # github_token = "ghp_your_token_here"
277
+ # op_service_account_token = "ops_your_token_here"
278
+ `;
279
+ const CREDENTIALS_FILE = "reposets.credentials.toml";
280
+ const CONFIG_FILE = "reposets.config.toml";
281
+ const initCommand = Command.make("init", {
282
+ project: projectOption
283
+ }, ({ project })=>Effect.gen(function*() {
284
+ const appDirs = yield* AppDirs;
285
+ const xdgConfigDir = yield* appDirs.config;
286
+ const targetDir = project ? process.cwd() : xdgConfigDir;
287
+ if (!existsSync(targetDir)) mkdirSync(targetDir, {
288
+ recursive: true
289
+ });
290
+ const configPath = join(targetDir, CONFIG_FILE);
291
+ const credsPath = join(targetDir, CREDENTIALS_FILE);
292
+ if (existsSync(configPath)) yield* Console.log(`Config already exists: ${configPath}`);
293
+ else {
294
+ writeFileSync(configPath, CONFIG_TEMPLATE);
295
+ yield* Console.log(`Created: ${configPath}`);
296
+ }
297
+ if (existsSync(credsPath)) yield* Console.log(`Credentials already exists: ${credsPath}`);
298
+ else {
299
+ writeFileSync(credsPath, CREDENTIALS_TEMPLATE);
300
+ yield* Console.log(`Created: ${credsPath}`);
301
+ }
302
+ if (project) {
303
+ const gitignorePath = join(targetDir, ".gitignore");
304
+ if (existsSync(gitignorePath)) {
305
+ const content = readFileSync(gitignorePath, "utf-8");
306
+ if (!content.includes(CREDENTIALS_FILE)) {
307
+ appendFileSync(gitignorePath, `\n${CREDENTIALS_FILE}\n`);
308
+ yield* Console.log(`Added ${CREDENTIALS_FILE} to .gitignore`);
309
+ }
310
+ } else {
311
+ writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
312
+ yield* Console.log(`Created .gitignore with ${CREDENTIALS_FILE}`);
313
+ }
314
+ } else {
315
+ const gitignorePath = join(targetDir, ".gitignore");
316
+ if (!existsSync(gitignorePath)) {
317
+ writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
318
+ yield* Console.log(`Created .gitignore in ${targetDir}`);
319
+ }
320
+ }
321
+ yield* Console.log("\nDone! Edit your config and credentials files to get started.");
322
+ })).pipe(Command.withDescription("Scaffold config files"));
323
+ const list_configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
324
+ const listCommand = Command.make("list", {
325
+ config: list_configOption
326
+ }, ({ config })=>Effect.gen(function*() {
327
+ const configFile = yield* ReposetsConfigFile;
328
+ const { config: parsedConfig } = yield* loadConfigWithDir(configFile, config);
329
+ const defaultOwner = parsedConfig.owner ?? "(not set)";
330
+ yield* Console.log(`Default owner: ${defaultOwner}\n`);
331
+ for (const [groupName, group] of Object.entries(parsedConfig.groups)){
332
+ 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(", ")}`);
337
+ if (group.secrets) {
338
+ const parts = [];
339
+ if (group.secrets.actions?.length) parts.push(`actions:[${group.secrets.actions.join(",")}]`);
340
+ if (group.secrets.dependabot?.length) parts.push(`dependabot:[${group.secrets.dependabot.join(",")}]`);
341
+ if (group.secrets.codespaces?.length) parts.push(`codespaces:[${group.secrets.codespaces.join(",")}]`);
342
+ if (group.secrets.environments) {
343
+ for (const [envName, envGroups] of Object.entries(group.secrets.environments))if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
344
+ }
345
+ if (parts.length) yield* Console.log(` secrets: ${parts.join(", ")}`);
346
+ }
347
+ if (group.variables) {
348
+ const parts = [];
349
+ if (group.variables.actions?.length) parts.push(`actions:[${group.variables.actions.join(",")}]`);
350
+ if (group.variables.environments) {
351
+ for (const [envName, envGroups] of Object.entries(group.variables.environments))if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
352
+ }
353
+ if (parts.length) yield* Console.log(` variables: ${parts.join(", ")}`);
354
+ }
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("");
358
+ }
359
+ })).pipe(Command.withDescription("Show config summary"));
360
+ const sync_configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
361
+ const groupOption = Options.text("group").pipe(Options.withDescription("Sync only a specific repo group"), Options.optional);
362
+ const repoOption = Options.text("repo").pipe(Options.withDescription("Sync only a specific repo"), Options.optional);
363
+ const dryRunOption = Options.boolean("dry-run").pipe(Options.withDescription("Preview changes without making them"), Options.withDefault(false));
364
+ const noCleanupOption = Options.boolean("no-cleanup").pipe(Options.withDescription("Skip cleanup of undeclared resources"), Options.withDefault(false));
365
+ const logLevelOption = Options.choice("log-level", [
366
+ "silent",
367
+ "info",
368
+ "verbose",
369
+ "debug"
370
+ ]).pipe(Options.withDescription("Set output verbosity (overrides log_level in config)"), Options.optional);
371
+ const syncCommand = Command.make("sync", {
372
+ config: sync_configOption,
373
+ group: groupOption,
374
+ repo: repoOption,
375
+ dryRun: dryRunOption,
376
+ noCleanup: noCleanupOption,
377
+ logLevel: logLevelOption
378
+ }, ({ config, group, repo, dryRun, noCleanup, logLevel: logLevelFlag })=>Effect.gen(function*() {
379
+ const configFile = yield* ReposetsConfigFile;
380
+ const { config: parsedConfig, configDir } = yield* loadConfigWithDir(configFile, config);
381
+ const credentialsFile = yield* ReposetsCredentialsFile;
382
+ const credentials = yield* credentialsFile.loadOrDefault({
383
+ profiles: {}
384
+ });
385
+ const profileNames = Object.keys(credentials.profiles);
386
+ const defaultProfile = 1 === profileNames.length ? profileNames[0] : void 0;
387
+ 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."));
389
+ const logLevel = "Some" === logLevelFlag._tag ? logLevelFlag.value : parsedConfig.log_level;
390
+ const githubLayer = GitHubClientLive(token);
391
+ const opLayer = OnePasswordClientLive;
392
+ const resolverLayer = Layer.provide(CredentialResolverLive, opLayer);
393
+ const loggerLayer = SyncLoggerLive({
394
+ dryRun,
395
+ logLevel
396
+ });
397
+ const engineLayer = Layer.provideMerge(SyncEngineLive, Layer.merge(Layer.merge(githubLayer, resolverLayer), loggerLayer));
398
+ const groupFilter = "Some" === group._tag ? group.value : void 0;
399
+ 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");
401
+ yield* Effect.provide(Effect.gen(function*() {
402
+ const engine = yield* SyncEngine;
403
+ yield* engine.syncAll(parsedConfig, credentials, {
404
+ dryRun,
405
+ noCleanup,
406
+ groupFilter,
407
+ repoFilter,
408
+ configDir
409
+ });
410
+ }), engineLayer);
411
+ })).pipe(Command.withDescription("Sync repos with GitHub"));
412
+ const validate_configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
413
+ const validateCommand = Command.make("validate", {
414
+ config: validate_configOption
415
+ }, ({ config })=>Effect.gen(function*() {
416
+ const configFile = yield* ReposetsConfigFile;
417
+ 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
+ }
461
+ for (const [groupName, group] of Object.entries(parsedConfig.secrets))if ("file" in group) for (const [entryName, filePath] of Object.entries(group.file)){
462
+ const fullPath = join(configDir, filePath);
463
+ if (!existsSync(fullPath)) {
464
+ yield* Console.error(`secrets.${groupName}.file.${entryName}: file not found: ${fullPath}`);
465
+ hasErrors = true;
466
+ }
467
+ }
468
+ for (const [groupName, group] of Object.entries(parsedConfig.variables))if ("file" in group) for (const [entryName, filePath] of Object.entries(group.file)){
469
+ const fullPath = join(configDir, filePath);
470
+ if (!existsSync(fullPath)) {
471
+ yield* Console.error(`variables.${groupName}.file.${entryName}: file not found: ${fullPath}`);
472
+ hasErrors = true;
473
+ }
474
+ }
475
+ const credentialsFile = yield* ReposetsCredentialsFile;
476
+ const credsResult = yield* Effect.either(credentialsFile.load);
477
+ if ("Left" === credsResult._tag) yield* Console.log("Credentials file: not found (optional)");
478
+ else {
479
+ yield* Console.log("Credentials schema: valid");
480
+ 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}'`);
482
+ hasErrors = true;
483
+ }
484
+ }
485
+ if (!hasErrors) yield* Console.log("\nAll checks passed.");
486
+ })).pipe(Command.withDescription("Validate config without API calls"));
487
+ const rootCommand = Command.make("reposets").pipe(Command.withSubcommands([
488
+ syncCommand,
489
+ listCommand,
490
+ validateCommand,
491
+ doctorCommand,
492
+ initCommand,
493
+ credentialsCommand
494
+ ]));
495
+ const cli = Command.run(rootCommand, {
496
+ name: "reposets",
497
+ version: "0.0.0"
498
+ });
499
+ const program = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(ConfigFilesLive), Effect.provide(NodeContext.layer));
500
+ NodeRuntime.runMain(program);