reposets 0.4.2 → 1.0.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/README.md +87 -75
- package/bin/reposets.js +68 -17
- package/cli/commands/credentials.js +170 -49
- package/cli/commands/doctor.js +364 -91
- package/cli/commands/drift.js +48 -0
- package/cli/commands/history.js +203 -0
- package/cli/commands/init.js +110 -104
- package/cli/commands/list.js +60 -39
- package/cli/commands/nuke.js +127 -0
- package/cli/commands/sync.js +219 -59
- package/cli/commands/validate.js +57 -41
- package/cli/flags.js +36 -0
- package/cli/logger.js +48 -0
- package/index.d.ts +471 -1499
- package/index.js +3 -18
- package/lib/config-refs.js +76 -0
- package/lib/credential-labels.js +0 -0
- package/lib/fingerprint.js +52 -0
- package/lib/org-only.js +61 -0
- package/lib/schema-issues.js +50 -0
- package/package.json +11 -10
- package/schemas/annotations.js +81 -0
- package/schemas/common.js +83 -48
- package/schemas/config.js +214 -212
- package/schemas/credentials.js +190 -54
- package/schemas/environment.js +27 -21
- package/schemas/ruleset.js +235 -139
- package/services/ConfigFiles.js +126 -104
- package/services/CredentialResolver.js +97 -33
- package/services/OnePasswordClient.js +88 -16
- package/services/SyncLogger.js +107 -76
- package/store/AppliedState.js +0 -0
- package/store/RepoCache.js +86 -0
- package/store/SyncJournal.js +92 -0
- package/store/migrations.js +86 -0
- package/sync/SyncEngine.js +156 -0
- package/sync/decide.js +56 -0
- package/sync/phase.js +55 -0
- package/sync/phases/cleanup.js +220 -0
- package/sync/phases/code-scanning.js +187 -0
- package/sync/phases/environments.js +106 -0
- package/sync/phases/index.js +39 -0
- package/sync/phases/resource.js +149 -0
- package/sync/phases/rulesets.js +186 -0
- package/sync/phases/secrets.js +138 -0
- package/sync/phases/security.js +129 -0
- package/sync/phases/settings.js +274 -0
- package/sync/phases/variables.js +132 -0
- package/tsdoc-metadata.json +1 -1
- package/bin/reposets.d.ts +0 -1
- package/errors.js +0 -12
- package/lib/crypto.js +0 -27
- package/services/GitHubClient.js +0 -875
- package/services/SyncEngine.js +0 -580
package/README.md
CHANGED
|
@@ -3,25 +3,13 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/reposets)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://nodejs.org/)
|
|
6
|
-
[](https://www.typescriptlang.org/)
|
|
7
7
|
|
|
8
|
-
Declarative GitHub repository management. Define
|
|
8
|
+
Declarative GitHub repository management. Define repository settings, secrets, variables, rulesets, deployment environments, security toggles and CodeQL default setup in a TOML file, then apply them across every repository you own with one command.
|
|
9
9
|
|
|
10
10
|
## Why reposets
|
|
11
11
|
|
|
12
|
-
Managing repository settings by hand
|
|
13
|
-
|
|
14
|
-
## Features
|
|
15
|
-
|
|
16
|
-
- **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.
|
|
17
|
-
- **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.
|
|
18
|
-
- **Multi-scope secret and variable management** — Assign the same secret group to Actions, Dependabot, Codespaces, and deployment environments with scoped targeting.
|
|
19
|
-
- **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.
|
|
20
|
-
- **Deployment environment management** — Configure wait timers, reviewers, and branch policies for deployment environments alongside your other settings.
|
|
21
|
-
- **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.
|
|
22
|
-
- **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.
|
|
23
|
-
- **Cleanup policies** — Automatically remove undeclared resources per scope with optional preserve lists, so your repos converge to the declared state.
|
|
24
|
-
- **Dry-run and validation** — Preview changes before applying, validate config locally without touching the GitHub API, and catch typos with built-in diagnostics.
|
|
12
|
+
Managing repository settings by hand does not scale. Once a dozen repositories should share the same branch protection, CI secrets and merge settings, clicking through the GitHub UI for each one is slow and easy to get subtly wrong. reposets lets you declare that configuration once and converge every repository onto it, then tells you when someone changes a repository outside the tool.
|
|
25
13
|
|
|
26
14
|
## Install
|
|
27
15
|
|
|
@@ -29,7 +17,7 @@ Managing repository settings by hand doesn't scale. When you have dozens of repo
|
|
|
29
17
|
npm install -g reposets
|
|
30
18
|
```
|
|
31
19
|
|
|
32
|
-
|
|
20
|
+
Or run it without installing:
|
|
33
21
|
|
|
34
22
|
```bash
|
|
35
23
|
npx reposets <command>
|
|
@@ -39,102 +27,126 @@ Requires the Node.js version shown in the badge above.
|
|
|
39
27
|
|
|
40
28
|
## Quick start
|
|
41
29
|
|
|
42
|
-
|
|
43
|
-
2. Add a credential profile:
|
|
30
|
+
Scaffold the config files in the current directory:
|
|
44
31
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
```bash
|
|
33
|
+
reposets init --project
|
|
34
|
+
# Created: ./reposets.config.toml
|
|
35
|
+
# Created: ./reposets.credentials.toml
|
|
36
|
+
# Created .gitignore with reposets.credentials.toml
|
|
37
|
+
```
|
|
48
38
|
|
|
49
|
-
|
|
39
|
+
Add a credential profile. It records *where* your token lives, never the token itself:
|
|
50
40
|
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
```bash
|
|
42
|
+
reposets credentials create --profile personal --username your-username --op "op://Private/github/token"
|
|
43
|
+
# Created profile 'personal' (username: your-username, github_token: op op://Private/github/token) in ./reposets.credentials.toml.
|
|
44
|
+
```
|
|
53
45
|
|
|
54
|
-
|
|
55
|
-
has_wiki = false
|
|
56
|
-
delete_branch_on_merge = true
|
|
46
|
+
Use `--env REPOSETS_GITHUB_TOKEN` in place of `--op` to read the token from the environment instead of 1Password.
|
|
57
47
|
|
|
58
|
-
|
|
59
|
-
repos = ["repo-one", "repo-two"]
|
|
60
|
-
settings = ["default"]
|
|
61
|
-
```
|
|
48
|
+
Declare what should apply to which repositories in `reposets.config.toml`:
|
|
62
49
|
|
|
63
|
-
|
|
50
|
+
```toml
|
|
51
|
+
[settings.default]
|
|
52
|
+
has_wiki = false
|
|
53
|
+
delete_branch_on_merge = true
|
|
64
54
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
55
|
+
[groups.my-repos]
|
|
56
|
+
repos = ["repo-one", "repo-two"]
|
|
57
|
+
credentials = "personal"
|
|
58
|
+
settings = ["default"]
|
|
59
|
+
```
|
|
68
60
|
|
|
69
|
-
|
|
61
|
+
Check it, preview it, then apply it:
|
|
70
62
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
63
|
+
```bash
|
|
64
|
+
reposets validate
|
|
65
|
+
# Valid: ./reposets.config.toml
|
|
66
|
+
# groups: 1
|
|
74
67
|
|
|
75
|
-
|
|
68
|
+
reposets sync --dry-run
|
|
69
|
+
reposets sync
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The owner is not in the config. It belongs to the credential profile, which declares `username` or `org`, and every group names the profile it is synced as.
|
|
73
|
+
|
|
74
|
+
## Features
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
- **Config you can commit.** The whole declaration lives in one TOML file that is safe to review and share. No secret value is ever stored in it.
|
|
77
|
+
- **References, not secrets.** Tokens and sensitive values are addresses resolved at sync time from 1Password, environment variables or files. The credentials file discloses nothing if it leaks.
|
|
78
|
+
- **Multi-account in one run.** Each group names the credential profile it authenticates as, so a single config can span your personal account and an organization.
|
|
79
|
+
- **Drift detection.** `reposets drift` reports resources changed outside the tool and exits non-zero, without writing anything, so it gates CI on its own.
|
|
80
|
+
- **Multi-scope secrets and variables.** Assign the same group to Actions, Dependabot, Codespaces and deployment environments.
|
|
81
|
+
- **Ruleset shorthand.** Branch and tag rulesets written as compact tables instead of raw API payloads, with resolved references for App and integration IDs.
|
|
82
|
+
- **Deployment environments.** Wait timers, reviewers and branch policies alongside everything else, synced before the secrets scoped to them.
|
|
83
|
+
- **Security and CodeQL.** Secret scanning, push protection, vulnerability alerts, automated fixes, private reporting and CodeQL default setup. License- and ownership-aware: unlicensed GHAS fields warn instead of failing, and organization-only fields are rejected offline for personal accounts.
|
|
84
|
+
- **Cleanup policies.** Remove undeclared resources per scope, with preserve lists, so repositories converge on the declared state.
|
|
85
|
+
- **Offline validation.** `reposets validate` checks schema, reference integrity, organization-only constructs and undeclared credential labels without a token or a network call.
|
|
86
|
+
- **Run history.** Every run is journalled. `reposets history` shows what happened and `history show` lists every resource one run touched.
|
|
80
87
|
|
|
81
88
|
## Commands
|
|
82
89
|
|
|
83
90
|
| Command | Description |
|
|
84
91
|
| :--- | :--- |
|
|
85
|
-
| `reposets sync` | Apply config
|
|
86
|
-
| `reposets
|
|
87
|
-
| `reposets
|
|
88
|
-
| `reposets
|
|
89
|
-
| `reposets
|
|
90
|
-
| `reposets
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
| `reposets sync` | Apply the config. Supports `--dry-run`, `--group`, `--repo`, `--only`, `--skip`, `--no-cleanup`, `--fail-on-drift`, `--debug` |
|
|
93
|
+
| `reposets drift` | Report resources changed outside reposets and exit non-zero. Changes nothing |
|
|
94
|
+
| `reposets list` | Show a config summary by group |
|
|
95
|
+
| `reposets validate` | Validate the config with no API calls |
|
|
96
|
+
| `reposets doctor` | Diagnose config, credentials and token, and print required permissions |
|
|
97
|
+
| `reposets history` | Show past runs. Subcommands: `show`, `prune`, `clear` |
|
|
98
|
+
| `reposets init` | Scaffold the config files. `--project` for the current directory |
|
|
99
|
+
| `reposets nuke` | Delete every local reposets file. Nothing on GitHub is touched |
|
|
100
|
+
| `reposets credentials` | Manage credential profiles: `create`, `list`, `delete` |
|
|
101
|
+
|
|
102
|
+
Every command accepts `--config` and the `--log-level` filter Effect core provides. `--log-level error` is quiet on success and loud on failure; `--log-level none` is the CI form.
|
|
93
103
|
|
|
94
104
|
## Configuration
|
|
95
105
|
|
|
96
|
-
reposets
|
|
97
|
-
|
|
98
|
-
- `reposets.config.toml` — defines settings, secrets, variables, rulesets, environments, security, code_scanning, and groups
|
|
99
|
-
- `reposets.credentials.toml` — stores GitHub tokens and optional resolve sections for named values
|
|
106
|
+
reposets reads two TOML files:
|
|
100
107
|
|
|
101
|
-
|
|
108
|
+
- `reposets.config.toml` — settings, security, code scanning, environments, secrets, variables, rulesets and the groups tying them to repositories
|
|
109
|
+
- `reposets.credentials.toml` — named profiles declaring the account each acts as and where its token lives
|
|
102
110
|
|
|
103
|
-
|
|
104
|
-
2. Walk up from current directory looking for `reposets.config.toml`
|
|
105
|
-
3. XDG fallback: `~/.config/reposets/reposets.config.toml`
|
|
111
|
+
Lookup order for the config, first match wins:
|
|
106
112
|
|
|
107
|
-
|
|
113
|
+
1. The `--config` flag, a file path or a directory
|
|
114
|
+
2. Walking up from the current directory
|
|
115
|
+
3. `~/.config/reposets/reposets.config.toml`, respecting `$XDG_CONFIG_HOME`
|
|
108
116
|
|
|
109
117
|
## Token permissions
|
|
110
118
|
|
|
111
|
-
reposets
|
|
119
|
+
reposets needs a fine-grained personal access token with:
|
|
112
120
|
|
|
113
121
|
- Repository > Administration (Read and write)
|
|
114
122
|
- Repository > Secrets (Read and write)
|
|
115
123
|
- Repository > Variables (Read and write)
|
|
116
124
|
- Repository > Environments (Read and write)
|
|
125
|
+
- Repository > Dependabot secrets (Read and write)
|
|
126
|
+
- Repository > Actions (**Read**) — counts workflow files for the `actions` CodeQL language
|
|
117
127
|
- Repository > Code scanning alerts (Read and write) — for `[code_scanning.*]`
|
|
118
128
|
- Repository > Dependabot alerts (Read and write) — for `[security.*]`
|
|
119
129
|
- Repository > Secret scanning alerts (Read and write) — for `security_and_analysis`
|
|
120
|
-
- Organization > Members (Read) —
|
|
121
|
-
- Account > GPG keys (Read and write)
|
|
130
|
+
- Organization > Members (Read) — resolve team slugs on org-owned repositories
|
|
122
131
|
|
|
123
|
-
The
|
|
132
|
+
The last five are only needed if you use the matching config sections. No account-level permission is required. `reposets doctor` prints this list and checks that your token resolves and authenticates.
|
|
124
133
|
|
|
125
134
|
## Documentation
|
|
126
135
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
- [
|
|
130
|
-
- [
|
|
131
|
-
- [
|
|
132
|
-
- [
|
|
133
|
-
- [
|
|
134
|
-
- [
|
|
135
|
-
- [
|
|
136
|
-
- [
|
|
137
|
-
|
|
136
|
+
- [Migrating to 1.0](https://github.com/spencerbeggs/reposets/blob/main/docs/01-migrating-to-1.0.md) — before-and-after TOML for every breaking change
|
|
137
|
+
- [Commands](https://github.com/spencerbeggs/reposets/blob/main/docs/02-commands.md) — every command and flag, with output
|
|
138
|
+
- [Configuration](https://github.com/spencerbeggs/reposets/blob/main/docs/03-configuration.md) — file format, path resolution and the settings reference
|
|
139
|
+
- [Credentials](https://github.com/spencerbeggs/reposets/blob/main/docs/04-credentials.md) — profiles, owners, token references and resolve sections
|
|
140
|
+
- [Secrets and variables](https://github.com/spencerbeggs/reposets/blob/main/docs/05-secrets-and-variables.md) — the three group kinds and how scoping works
|
|
141
|
+
- [Rulesets](https://github.com/spencerbeggs/reposets/blob/main/docs/06-rulesets.md) — branch and tag rulesets, every field and the shorthand forms
|
|
142
|
+
- [Environments](https://github.com/spencerbeggs/reposets/blob/main/docs/07-environments.md) — deployment environments, reviewers and branch policies
|
|
143
|
+
- [Advanced security](https://github.com/spencerbeggs/reposets/blob/main/docs/03-configuration.md#security-and-analysis-block) — secret scanning, vulnerability alerts and CodeQL default setup
|
|
144
|
+
- [Cleanup](https://github.com/spencerbeggs/reposets/blob/main/docs/08-cleanup.md) — removing resources the config no longer declares
|
|
145
|
+
- [Token permissions](https://github.com/spencerbeggs/reposets/blob/main/docs/09-token-permissions.md) — which scopes are needed, and why
|
|
146
|
+
|
|
147
|
+
## Editor support
|
|
148
|
+
|
|
149
|
+
JSON schemas for both config files are published to [SchemaStore](https://www.schemastore.org/), so editors validate and autocomplete `reposets.config.toml` and `reposets.credentials.toml` with no setup. The schemas carry annotations for the Tombi and Taplo TOML language servers.
|
|
138
150
|
|
|
139
151
|
## License
|
|
140
152
|
|
package/bin/reposets.js
CHANGED
|
@@ -1,34 +1,85 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { CredentialsFilesLive } from "../services/ConfigFiles.js";
|
|
3
|
+
import { formatSchemaIssue } from "../lib/schema-issues.js";
|
|
4
|
+
import { migrations } from "../store/migrations.js";
|
|
5
|
+
import { SyncJournalLive } from "../store/SyncJournal.js";
|
|
2
6
|
import { credentialsCommand } from "../cli/commands/credentials.js";
|
|
7
|
+
import { ConfigFlag, ConfigLive } from "../cli/flags.js";
|
|
3
8
|
import { doctorCommand } from "../cli/commands/doctor.js";
|
|
9
|
+
import { syncCommand } from "../cli/commands/sync.js";
|
|
10
|
+
import { driftCommand } from "../cli/commands/drift.js";
|
|
11
|
+
import { historyCommand } from "../cli/commands/history.js";
|
|
4
12
|
import { initCommand } from "../cli/commands/init.js";
|
|
5
13
|
import { listCommand } from "../cli/commands/list.js";
|
|
6
|
-
import {
|
|
14
|
+
import { nukeCommand } from "../cli/commands/nuke.js";
|
|
7
15
|
import { validateCommand } from "../cli/commands/validate.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
16
|
+
import { CliLoggerLive } from "../cli/logger.js";
|
|
17
|
+
import { Effect, Layer } from "effect";
|
|
18
|
+
import { App } from "@effected/app";
|
|
19
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
20
|
+
import { Command } from "effect/unstable/cli";
|
|
11
21
|
|
|
12
22
|
//#region src/cli/index.ts
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The application control plane.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* Bound once, at module scope. `App.layer` opens both SQLite databases, so a
|
|
28
|
+
* second call would open a second pair with split event streams.
|
|
29
|
+
*
|
|
30
|
+
* Migrations run during layer construction, so a fresh checkout gets its schema
|
|
31
|
+
* on the first command rather than on first write.
|
|
32
|
+
*/
|
|
33
|
+
const AppLive = App.layer({
|
|
34
|
+
namespace: "reposets",
|
|
35
|
+
store: { migrations }
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* The root command.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* `Command.provide(ConfigLive)` sits here, once, rather than in each subcommand:
|
|
42
|
+
* subcommand requirements bubble into the parent's `R` through
|
|
43
|
+
* `withSubcommands`, so one provide covers all of them.
|
|
44
|
+
*/
|
|
45
|
+
const cli = Command.make("reposets", {}, () => Effect.void).pipe(Command.withDescription("Sync GitHub repository settings across repos from a TOML config"), Command.withSubcommands([
|
|
46
|
+
validateCommand,
|
|
19
47
|
syncCommand,
|
|
20
48
|
listCommand,
|
|
21
|
-
validateCommand,
|
|
22
49
|
doctorCommand,
|
|
50
|
+
driftCommand,
|
|
23
51
|
initCommand,
|
|
52
|
+
nukeCommand,
|
|
53
|
+
historyCommand,
|
|
24
54
|
credentialsCommand
|
|
25
|
-
]));
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
55
|
+
]), Command.provide(ConfigLive), Command.provide(CredentialsFilesLive), Command.provide(SyncJournalLive), Command.withGlobalFlags([ConfigFlag]));
|
|
56
|
+
const MainLive = Layer.mergeAll(Layer.provideMerge(AppLive, NodeServices.layer), CliLoggerLive);
|
|
57
|
+
/**
|
|
58
|
+
* Report a failure through the CLI logger and exit non-zero.
|
|
59
|
+
*
|
|
60
|
+
* @remarks
|
|
61
|
+
* `NodeRuntime.runMain` reports an unhandled failure with Effect's *default*
|
|
62
|
+
* logger, which sits outside the layers the program was provided — so a failure
|
|
63
|
+
* escaping to it prints `[23:05:56] ERROR (#2): …` **on stdout**, in the format
|
|
64
|
+
* {@link CliLoggerLive} exists to replace, on the stream errors must not use.
|
|
65
|
+
*
|
|
66
|
+
* Catching here keeps the failure inside the logger's scope. The exit code is
|
|
67
|
+
* then ours to set, since the effect now succeeds.
|
|
68
|
+
*/
|
|
69
|
+
const reportAndExit = (error) => Effect.gen(function* () {
|
|
70
|
+
yield* Effect.logError(String(error));
|
|
71
|
+
if (isTagged(error, "ConfigValidationError")) {
|
|
72
|
+
const lines = formatSchemaIssue(error.issue);
|
|
73
|
+
for (const line of lines) yield* Effect.logError(` ${line}`);
|
|
74
|
+
if (lines.some((line) => line.startsWith("unknown key"))) yield* Effect.logError(" Run 'reposets doctor' for suggested spellings.");
|
|
75
|
+
}
|
|
76
|
+
yield* Effect.sync(() => {
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
});
|
|
29
79
|
});
|
|
30
|
-
|
|
31
|
-
|
|
80
|
+
/** Whether a caught value is a tagged error of a given tag. */
|
|
81
|
+
const isTagged = (error, tag) => typeof error === "object" && error !== null && "_tag" in error && error._tag === tag;
|
|
82
|
+
cli.pipe(Command.run({ version: "1.0.0" }), Effect.catch(reportAndExit), Effect.provide(MainLive), NodeRuntime.runMain);
|
|
32
83
|
|
|
33
84
|
//#endregion
|
|
34
85
|
export { };
|
|
@@ -1,74 +1,195 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { profileOwner } from "../../schemas/credentials.js";
|
|
2
|
+
import { ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
3
3
|
import { Effect, Option } from "effect";
|
|
4
|
-
import { Command,
|
|
4
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
5
|
+
import { AppDirs } from "@effected/xdg";
|
|
5
6
|
|
|
6
7
|
//#region src/cli/commands/credentials.ts
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
8
|
+
const EMPTY = { profiles: {} };
|
|
9
|
+
const profileFlag = Flag.string("profile").pipe(Flag.withDescription("Credential profile name"));
|
|
10
|
+
const opFlag = Flag.string("op").pipe(Flag.withDescription("1Password secret reference, e.g. \"op://Vault/item/field\""), Flag.optional);
|
|
11
|
+
const envFlag = Flag.string("env").pipe(Flag.withDescription("Name of an environment variable holding the token, e.g. \"REPOSETS_GITHUB_TOKEN\""), Flag.optional);
|
|
12
|
+
const usernameFlag = Flag.string("username").pipe(Flag.withDescription("The personal account this profile acts as. Mutually exclusive with --org"), Flag.optional);
|
|
13
|
+
const orgFlag = Flag.string("org").pipe(Flag.withDescription("The organization this profile acts within. Mutually exclusive with --username"), Flag.optional);
|
|
14
|
+
/**
|
|
15
|
+
* Recognisable credential prefixes, so an obvious paste-in-the-wrong-place is
|
|
16
|
+
* caught rather than stored.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* A heuristic, not a guarantee — `--env` legitimately takes a bare identifier
|
|
20
|
+
* and a token can look like one. It costs nothing and catches the mistake this
|
|
21
|
+
* command's entire premise is built to prevent.
|
|
22
|
+
*/
|
|
23
|
+
const SECRET_PREFIXES = [
|
|
24
|
+
"ghp_",
|
|
25
|
+
"gho_",
|
|
26
|
+
"ghu_",
|
|
27
|
+
"ghs_",
|
|
28
|
+
"ghr_",
|
|
29
|
+
"github_pat_",
|
|
30
|
+
"ops_",
|
|
31
|
+
"sk-",
|
|
32
|
+
"xoxb-"
|
|
33
|
+
];
|
|
34
|
+
const looksLikeSecret = (value) => SECRET_PREFIXES.some((prefix) => value.startsWith(prefix)) || value.length > 60;
|
|
35
|
+
/**
|
|
36
|
+
* Write credentials back to the file they were read from.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* `ConfigFileShape.save` resolves its own default path — the XDG one — rather
|
|
40
|
+
* than the path `discover` found. With a project-local
|
|
41
|
+
* `reposets.credentials.toml` present, that splits reads from writes: `create`
|
|
42
|
+
* lands in XDG while `list` keeps reading the local file, so a profile is
|
|
43
|
+
* created that the user cannot see and that sync will not use. Writing back to
|
|
44
|
+
* the discovered source keeps one file authoritative, and falls back to `save`
|
|
45
|
+
* only when there is genuinely nothing to update.
|
|
46
|
+
*/
|
|
47
|
+
const saveWhereRead = (file, value) => Effect.gen(function* () {
|
|
48
|
+
const existing = (yield* file.discover.pipe(Effect.orElseSucceed(() => [])))[0];
|
|
49
|
+
if (existing === void 0) return yield* file.save(value);
|
|
50
|
+
yield* file.write(value, existing.path);
|
|
51
|
+
return existing.path;
|
|
52
|
+
});
|
|
53
|
+
/** How a profile's `github_token` is sourced, for display. Never a value. */
|
|
54
|
+
const describeSource = (source) => "op" in source ? `op ${source.op}` : `env ${source.env}`;
|
|
55
|
+
/**
|
|
56
|
+
* `reposets credentials create` — record a *reference* to a GitHub token.
|
|
57
|
+
*
|
|
58
|
+
* @remarks
|
|
59
|
+
* **This command never accepts a token.** v3 took `--github-token` and
|
|
60
|
+
* `--op-token` and wrote both into the file; the schema now stores a reference
|
|
61
|
+
* and nothing else, so there is no field to put a secret in and no prompt that
|
|
62
|
+
* would read one. `--op` names a 1Password item, `--env` names an environment
|
|
63
|
+
* variable — both are addresses, safe to appear in shell history and in this
|
|
64
|
+
* command's own output.
|
|
65
|
+
*
|
|
66
|
+
* `op_service_account_token` is likewise not accepted: it comes from the
|
|
67
|
+
* environment, so there is nothing to write here.
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
const createHandler = (input) => Effect.gen(function* () {
|
|
72
|
+
const { profile, op, env, username, org } = input;
|
|
20
73
|
yield* (yield* AppDirs).ensureConfig;
|
|
21
74
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
22
|
-
if (
|
|
23
|
-
yield* Effect.logError(
|
|
75
|
+
if (op !== void 0 && env !== void 0) {
|
|
76
|
+
yield* Effect.logError("Provide exactly one of --op or --env, not both.");
|
|
24
77
|
return;
|
|
25
78
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (opToken._tag === "Some") newProfile.op_service_account_token = opToken.value;
|
|
29
|
-
if (!newProfile.github_token && !newProfile.op_service_account_token) {
|
|
30
|
-
yield* Effect.logError("Provide at least --github-token or --op-token.");
|
|
79
|
+
if (op === void 0 && env === void 0) {
|
|
80
|
+
yield* Effect.logError("Provide a reference: --op \"op://Vault/item/field\" or --env REPOSETS_GITHUB_TOKEN. A token value is never accepted.");
|
|
31
81
|
return;
|
|
32
82
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
83
|
+
if (op !== void 0 && !op.startsWith("op://")) {
|
|
84
|
+
yield* Effect.logError("--op must be a 1Password reference starting with \"op://\". Value not echoed.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (looksLikeSecret(op ?? env ?? "")) {
|
|
88
|
+
yield* Effect.logError("That looks like a credential value, not a reference. This command stores references only — pass --op \"op://Vault/item/field\" or --env VAR_NAME. Value not echoed.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (username !== void 0 && org !== void 0) {
|
|
92
|
+
yield* Effect.logError("Provide exactly one of --username or --org, not both.");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (username === void 0 && org === void 0) {
|
|
96
|
+
yield* Effect.logError("Provide --username <you> for a personal account or --org <name> for an organization. It is what the profile acts as, and it decides which settings are even valid.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const existing = yield* credentialsFile.loadOrDefault(EMPTY);
|
|
100
|
+
if (existing.profiles[profile] !== void 0) {
|
|
101
|
+
yield* Effect.logError(`Profile '${profile}' already exists. Delete it first.`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const github_token = op !== void 0 ? { op } : { env };
|
|
105
|
+
const actsAs = username !== void 0 ? {
|
|
106
|
+
username,
|
|
107
|
+
github_token
|
|
108
|
+
} : {
|
|
109
|
+
org,
|
|
110
|
+
github_token
|
|
111
|
+
};
|
|
112
|
+
const path = yield* saveWhereRead(credentialsFile, { profiles: {
|
|
113
|
+
...existing.profiles,
|
|
114
|
+
[profile]: actsAs
|
|
115
|
+
} });
|
|
116
|
+
yield* Effect.log(`Created profile '${profile}' (${username !== void 0 ? `username: ${username}` : `org: ${org}`}, github_token: ${describeSource(github_token)}) in ${path}.`);
|
|
117
|
+
});
|
|
118
|
+
const createCommand = Command.make("create", {
|
|
119
|
+
profile: profileFlag,
|
|
120
|
+
op: opFlag,
|
|
121
|
+
env: envFlag,
|
|
122
|
+
username: usernameFlag,
|
|
123
|
+
org: orgFlag
|
|
124
|
+
}, ({ profile, op, env, username, org }) => createHandler({
|
|
125
|
+
profile,
|
|
126
|
+
op: Option.getOrUndefined(op),
|
|
127
|
+
env: Option.getOrUndefined(env),
|
|
128
|
+
username: Option.getOrUndefined(username),
|
|
129
|
+
org: Option.getOrUndefined(org)
|
|
130
|
+
})).pipe(Command.withDescription("Add a credential profile holding a reference to a GitHub token"));
|
|
131
|
+
/**
|
|
132
|
+
* `reposets credentials list` — the profiles and what they point at.
|
|
133
|
+
*
|
|
134
|
+
* @remarks
|
|
135
|
+
* There is nothing to redact. The file holds references, so printing it in full
|
|
136
|
+
* discloses which vault items and environment variables reposets reads, never
|
|
137
|
+
* their contents — which is exactly what someone running this needs to see.
|
|
138
|
+
*
|
|
139
|
+
* @public
|
|
140
|
+
*/
|
|
141
|
+
const listHandler = Effect.gen(function* () {
|
|
142
|
+
const credentials = yield* (yield* ReposetsCredentialsFile).loadOrDefault(EMPTY);
|
|
143
|
+
if (Object.keys(credentials.profiles).length === 0) {
|
|
45
144
|
yield* Effect.log("No credential profiles configured.");
|
|
46
145
|
return;
|
|
47
146
|
}
|
|
48
|
-
for (const [name, profile] of Object.entries(
|
|
147
|
+
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
148
|
+
const { owner, ownerType } = profileOwner(profile);
|
|
49
149
|
yield* Effect.log(`[${name}]`);
|
|
50
|
-
|
|
51
|
-
|
|
150
|
+
yield* Effect.log(` acts as: ${owner} (${ownerType === "User" ? "user" : "organization"})`);
|
|
151
|
+
yield* Effect.log(` github_token: ${describeSource(profile.github_token)}`);
|
|
152
|
+
for (const kind of [
|
|
153
|
+
"op",
|
|
154
|
+
"env",
|
|
155
|
+
"file"
|
|
156
|
+
]) {
|
|
157
|
+
const entries = profile.resolve?.[kind];
|
|
158
|
+
if (entries === void 0) continue;
|
|
159
|
+
for (const [label, reference] of Object.entries(entries)) yield* Effect.log(` resolve.${kind}.${label}: ${reference}`);
|
|
160
|
+
}
|
|
52
161
|
yield* Effect.log("");
|
|
53
162
|
}
|
|
54
|
-
})
|
|
55
|
-
const
|
|
163
|
+
});
|
|
164
|
+
const listCommand = Command.make("list", {}, () => listHandler).pipe(Command.withDescription("List credential profiles and the references they hold"));
|
|
165
|
+
/**
|
|
166
|
+
* `reposets credentials delete` — remove a profile.
|
|
167
|
+
*
|
|
168
|
+
* @public
|
|
169
|
+
*/
|
|
170
|
+
const deleteHandler = (profile) => Effect.gen(function* () {
|
|
56
171
|
yield* (yield* AppDirs).ensureConfig;
|
|
57
172
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
173
|
+
const credentials = yield* credentialsFile.loadOrDefault(EMPTY);
|
|
174
|
+
if (credentials.profiles[profile] === void 0) {
|
|
60
175
|
yield* Effect.logError(`Profile '${profile}' not found.`);
|
|
61
176
|
return;
|
|
62
177
|
}
|
|
63
|
-
const { [profile]:
|
|
64
|
-
yield* credentialsFile
|
|
65
|
-
yield* Effect.log(`Deleted profile '${profile}'.`);
|
|
66
|
-
})
|
|
67
|
-
const
|
|
178
|
+
const { [profile]: _removed, ...remaining } = credentials.profiles;
|
|
179
|
+
const path = yield* saveWhereRead(credentialsFile, { profiles: remaining });
|
|
180
|
+
yield* Effect.log(`Deleted profile '${profile}' from ${path}.`);
|
|
181
|
+
});
|
|
182
|
+
const deleteCommand = Command.make("delete", { profile: profileFlag }, ({ profile }) => deleteHandler(profile)).pipe(Command.withDescription("Remove a credential profile"));
|
|
183
|
+
/**
|
|
184
|
+
* `reposets credentials` — manage `reposets.credentials.toml`.
|
|
185
|
+
*
|
|
186
|
+
* @public
|
|
187
|
+
*/
|
|
188
|
+
const credentialsCommand = Command.make("credentials", {}, () => Effect.void).pipe(Command.withDescription("Manage credential profiles (references only — never token values)"), Command.withSubcommands([
|
|
68
189
|
createCommand,
|
|
69
|
-
|
|
190
|
+
listCommand,
|
|
70
191
|
deleteCommand
|
|
71
192
|
]));
|
|
72
193
|
|
|
73
194
|
//#endregion
|
|
74
|
-
export { credentialsCommand };
|
|
195
|
+
export { createHandler, credentialsCommand, deleteHandler, listHandler };
|