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/500.js +2545 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/reposets.js +500 -0
- package/index.d.ts +1680 -0
- package/index.js +2 -0
- package/package.json +75 -0
- package/tsdoc-metadata.json +11 -0
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
|
+
[](https://www.npmjs.com/package/reposets)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](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)
|
package/bin/reposets.js
ADDED
|
@@ -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);
|