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/500.js +760 -61
- package/README.md +11 -3
- package/bin/reposets.js +128 -112
- package/index.d.ts +194 -34
- package/index.js +1 -1
- package/package.json +3 -3
- package/tsdoc-metadata.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://www.typescriptlang.org/)
|
|
6
6
|
|
|
7
|
-
Declarative GitHub repository management. Define your repo settings, secrets, variables, rulesets,
|
|
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
|
-
- **
|
|
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 {
|
|
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 {
|
|
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*
|
|
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*
|
|
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*
|
|
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*
|
|
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*
|
|
51
|
-
if (profile.github_token) yield*
|
|
52
|
-
if (profile.op_service_account_token) yield*
|
|
53
|
-
yield*
|
|
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*
|
|
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*
|
|
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(
|
|
142
|
-
if ("Left" === discoverResult._tag) return void (yield*
|
|
143
|
-
const
|
|
144
|
-
|
|
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*
|
|
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*
|
|
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*
|
|
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*
|
|
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*
|
|
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*
|
|
202
|
+
yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}.variables${hint}`);
|
|
198
203
|
warnings++;
|
|
199
204
|
}
|
|
200
205
|
}
|
|
201
206
|
}
|
|
202
|
-
yield*
|
|
203
|
-
yield*
|
|
204
|
-
yield*
|
|
205
|
-
yield*
|
|
206
|
-
yield*
|
|
207
|
-
yield*
|
|
208
|
-
yield*
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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*
|
|
332
|
+
if (existsSync(configPath)) yield* Effect.log(`Config already exists: ${configPath}`);
|
|
293
333
|
else {
|
|
294
334
|
writeFileSync(configPath, CONFIG_TEMPLATE);
|
|
295
|
-
yield*
|
|
335
|
+
yield* Effect.log(`Created: ${configPath}`);
|
|
296
336
|
}
|
|
297
|
-
if (existsSync(credsPath)) yield*
|
|
337
|
+
if (existsSync(credsPath)) yield* Effect.log(`Credentials already exists: ${credsPath}`);
|
|
298
338
|
else {
|
|
299
339
|
writeFileSync(credsPath, CREDENTIALS_TEMPLATE);
|
|
300
|
-
yield*
|
|
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*
|
|
348
|
+
yield* Effect.log(`Added ${CREDENTIALS_FILE} to .gitignore`);
|
|
309
349
|
}
|
|
310
350
|
} else {
|
|
311
351
|
writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
|
|
312
|
-
yield*
|
|
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*
|
|
358
|
+
yield* Effect.log(`Created .gitignore in ${targetDir}`);
|
|
319
359
|
}
|
|
320
360
|
}
|
|
321
|
-
yield*
|
|
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
|
|
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*
|
|
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*
|
|
334
|
-
for (const repo of group.repos)yield*
|
|
335
|
-
if (group.settings?.length) yield*
|
|
336
|
-
if (group.environments?.length) yield*
|
|
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*
|
|
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*
|
|
395
|
+
if (parts.length) yield* Effect.log(` variables: ${parts.join(", ")}`);
|
|
354
396
|
}
|
|
355
|
-
if (group.rulesets?.length) yield*
|
|
356
|
-
if (group.
|
|
357
|
-
yield*
|
|
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
|
|
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*
|
|
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*
|
|
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(
|
|
419
|
-
if ("Left" === configResult._tag) return void (yield*
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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*
|
|
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*
|
|
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*
|
|
488
|
+
if ("Left" === credsResult._tag) yield* Effect.log("Credentials file: not found (optional)");
|
|
478
489
|
else {
|
|
479
|
-
yield*
|
|
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*
|
|
492
|
+
yield* Effect.logError(`Group '${groupName}': references unknown credentials profile '${group.credentials}'`);
|
|
482
493
|
hasErrors = true;
|
|
483
494
|
}
|
|
484
495
|
}
|
|
485
|
-
if (!hasErrors) yield*
|
|
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(
|
|
515
|
+
const program = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(NodeContext.layer), Effect.provide(CliLogger));
|
|
500
516
|
NodeRuntime.runMain(program);
|