reposets 0.4.1 → 0.4.2
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 +15 -14
- package/bin/reposets.d.ts +1 -0
- package/bin/reposets.js +25 -507
- package/cli/commands/credentials.js +74 -0
- package/cli/commands/doctor.js +154 -0
- package/cli/commands/init.js +147 -0
- package/cli/commands/list.js +49 -0
- package/cli/commands/sync.js +70 -0
- package/cli/commands/validate.js +53 -0
- package/errors.js +12 -0
- package/index.d.ts +1750 -1840
- package/index.js +19 -2
- package/lib/crypto.js +27 -0
- package/package.json +61 -73
- package/schemas/common.js +130 -0
- package/schemas/config.js +469 -0
- package/schemas/credentials.js +78 -0
- package/schemas/environment.js +70 -0
- package/schemas/ruleset.js +545 -0
- package/services/ConfigFiles.js +119 -0
- package/services/CredentialResolver.js +40 -0
- package/services/GitHubClient.js +875 -0
- package/services/OnePasswordClient.js +30 -0
- package/services/SyncEngine.js +580 -0
- package/services/SyncLogger.js +106 -0
- package/tsdoc-metadata.json +11 -11
- package/500.js +0 -3354
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# reposets
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/reposets)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](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
|
-
##
|
|
26
|
+
## Install
|
|
26
27
|
|
|
27
|
-
```
|
|
28
|
+
```bash
|
|
28
29
|
npm install -g reposets
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
Alternative (no install):
|
|
32
33
|
|
|
33
|
-
```
|
|
34
|
+
```bash
|
|
34
35
|
npx reposets <command>
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
Requires Node.js
|
|
38
|
+
Requires the Node.js version shown in the badge above.
|
|
38
39
|
|
|
39
|
-
## Quick
|
|
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
|
-
```
|
|
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
|
-
```
|
|
65
|
+
```bash
|
|
65
66
|
reposets validate
|
|
66
67
|
```
|
|
67
68
|
|
|
68
69
|
5. Preview changes without applying them:
|
|
69
70
|
|
|
70
|
-
```
|
|
71
|
+
```bash
|
|
71
72
|
reposets sync --dry-run
|
|
72
73
|
```
|
|
73
74
|
|
|
74
75
|
6. Apply the config:
|
|
75
76
|
|
|
76
|
-
```
|
|
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
|
|
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](
|
|
141
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/bin/reposets.js
CHANGED
|
@@ -1,516 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
19
|
+
syncCommand,
|
|
20
|
+
listCommand,
|
|
21
|
+
validateCommand,
|
|
22
|
+
doctorCommand,
|
|
23
|
+
initCommand,
|
|
24
|
+
credentialsCommand
|
|
510
25
|
]));
|
|
511
26
|
const cli = Command.run(rootCommand, {
|
|
512
|
-
|
|
513
|
-
|
|
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 { };
|