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.
Files changed (54) hide show
  1. package/README.md +87 -75
  2. package/bin/reposets.js +68 -17
  3. package/cli/commands/credentials.js +170 -49
  4. package/cli/commands/doctor.js +364 -91
  5. package/cli/commands/drift.js +48 -0
  6. package/cli/commands/history.js +203 -0
  7. package/cli/commands/init.js +110 -104
  8. package/cli/commands/list.js +60 -39
  9. package/cli/commands/nuke.js +127 -0
  10. package/cli/commands/sync.js +219 -59
  11. package/cli/commands/validate.js +57 -41
  12. package/cli/flags.js +36 -0
  13. package/cli/logger.js +48 -0
  14. package/index.d.ts +471 -1499
  15. package/index.js +3 -18
  16. package/lib/config-refs.js +76 -0
  17. package/lib/credential-labels.js +0 -0
  18. package/lib/fingerprint.js +52 -0
  19. package/lib/org-only.js +61 -0
  20. package/lib/schema-issues.js +50 -0
  21. package/package.json +11 -10
  22. package/schemas/annotations.js +81 -0
  23. package/schemas/common.js +83 -48
  24. package/schemas/config.js +214 -212
  25. package/schemas/credentials.js +190 -54
  26. package/schemas/environment.js +27 -21
  27. package/schemas/ruleset.js +235 -139
  28. package/services/ConfigFiles.js +126 -104
  29. package/services/CredentialResolver.js +97 -33
  30. package/services/OnePasswordClient.js +88 -16
  31. package/services/SyncLogger.js +107 -76
  32. package/store/AppliedState.js +0 -0
  33. package/store/RepoCache.js +86 -0
  34. package/store/SyncJournal.js +92 -0
  35. package/store/migrations.js +86 -0
  36. package/sync/SyncEngine.js +156 -0
  37. package/sync/decide.js +56 -0
  38. package/sync/phase.js +55 -0
  39. package/sync/phases/cleanup.js +220 -0
  40. package/sync/phases/code-scanning.js +187 -0
  41. package/sync/phases/environments.js +106 -0
  42. package/sync/phases/index.js +39 -0
  43. package/sync/phases/resource.js +149 -0
  44. package/sync/phases/rulesets.js +186 -0
  45. package/sync/phases/secrets.js +138 -0
  46. package/sync/phases/security.js +129 -0
  47. package/sync/phases/settings.js +274 -0
  48. package/sync/phases/variables.js +132 -0
  49. package/tsdoc-metadata.json +1 -1
  50. package/bin/reposets.d.ts +0 -1
  51. package/errors.js +0 -12
  52. package/lib/crypto.js +0 -27
  53. package/services/GitHubClient.js +0 -875
  54. package/services/SyncEngine.js +0 -580
package/README.md CHANGED
@@ -3,25 +3,13 @@
3
3
  [![npm](https://img.shields.io/npm/v/reposets?label=npm&color=cb3837)](https://www.npmjs.com/package/reposets)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
- [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
6
+ [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
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
+ 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 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.
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
- Alternative (no install):
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
- 1. Run `reposets init` to scaffold config files.
43
- 2. Add a credential profile:
30
+ Scaffold the config files in the current directory:
44
31
 
45
- ```bash
46
- reposets credentials create --profile personal --github-token ghp_...
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
- 3. Edit `reposets.config.toml` with your repos and settings:
39
+ Add a credential profile. It records *where* your token lives, never the token itself:
50
40
 
51
- ```toml
52
- owner = "your-username"
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
- [settings.default]
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
- [groups.my-repos]
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
- 4. Validate your config:
50
+ ```toml
51
+ [settings.default]
52
+ has_wiki = false
53
+ delete_branch_on_merge = true
64
54
 
65
- ```bash
66
- reposets validate
67
- ```
55
+ [groups.my-repos]
56
+ repos = ["repo-one", "repo-two"]
57
+ credentials = "personal"
58
+ settings = ["default"]
59
+ ```
68
60
 
69
- 5. Preview changes without applying them:
61
+ Check it, preview it, then apply it:
70
62
 
71
- ```bash
72
- reposets sync --dry-run
73
- ```
63
+ ```bash
64
+ reposets validate
65
+ # Valid: ./reposets.config.toml
66
+ # groups: 1
74
67
 
75
- 6. Apply the config:
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
- ```bash
78
- reposets sync
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 to repos (supports --dry-run, --group, --repo, --no-cleanup) |
86
- | `reposets list` | Show config summary |
87
- | `reposets validate` | Validate config without API calls |
88
- | `reposets doctor` | Deep diagnostics with typo detection |
89
- | `reposets init` | Scaffold config files (--project for local) |
90
- | `reposets credentials` | Manage credential profiles (create, list, delete) |
91
-
92
- All commands accept `--log-level silent|info|verbose|debug`.
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 uses two TOML files:
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
- Config lookup order (first match wins):
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
- 1. `--config` flag (explicit path or directory)
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
- 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.
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 requires a fine-grained personal access token with:
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) — for `delegated_bypass_reviewers` team slugs on org-owned repos
121
- - Account > GPG keys (Read and write)
130
+ - Organization > Members (Read) — resolve team slugs on org-owned repositories
122
131
 
123
- The four security-related scopes are only required if you use the corresponding config sections.
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
- Full reference guides are available in the [`docs/`](https://github.com/spencerbeggs/reposets/tree/main/docs) folder:
128
-
129
- - [Commands Reference](https://github.com/spencerbeggs/reposets/blob/main/docs/commands.md) - all commands, flags, and usage examples
130
- - [Configuration](https://github.com/spencerbeggs/reposets/blob/main/docs/configuration.md) - config file format, path resolution, and settings reference
131
- - [Credentials](https://github.com/spencerbeggs/reposets/blob/main/docs/credentials.md) - credential profiles, resolve sections, and 1Password integration
132
- - [Secrets and Variables](https://github.com/spencerbeggs/reposets/blob/main/docs/secrets-and-variables.md) - resource groups, three kinds (file/value/resolved), and scoping
133
- - [Rulesets](https://github.com/spencerbeggs/reposets/blob/main/docs/rulesets.md) - branch and tag ruleset configuration
134
- - [Environments](https://github.com/spencerbeggs/reposets/blob/main/docs/environments.md) - deployment environment setup
135
- - [Advanced Security](https://github.com/spencerbeggs/reposets/blob/main/docs/configuration.md#security-and-analysis-nested-block) - secret scanning, vulnerability alerts, automated security fixes, private vulnerability reporting, and CodeQL default setup
136
- - [Cleanup](https://github.com/spencerbeggs/reposets/blob/main/docs/cleanup.md) - automatic cleanup of undeclared resources
137
- - [Token Permissions](https://github.com/spencerbeggs/reposets/blob/main/docs/token-permissions.md) - GitHub PAT setup guide
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 { syncCommand } from "../cli/commands/sync.js";
14
+ import { nukeCommand } from "../cli/commands/nuke.js";
7
15
  import { validateCommand } from "../cli/commands/validate.js";
8
- import { Effect, LogLevel, Logger } from "effect";
9
- import { Command } from "@effect/cli";
10
- import { NodeContext, NodeRuntime } from "@effect/platform-node";
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
- 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);
17
- }));
18
- const rootCommand = Command.make("reposets").pipe(Command.withSubcommands([
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 cli = Command.run(rootCommand, {
27
- name: "reposets",
28
- version: "0.0.0"
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
- const program = Effect.suspend(() => cli(process.argv)).pipe(Effect.provide(NodeContext.layer), Effect.provide(CliLogger));
31
- NodeRuntime.runMain(program);
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 { ReposetsCredentialsFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
2
- import { AppDirs } from "xdg-effect";
1
+ import { profileOwner } from "../../schemas/credentials.js";
2
+ import { ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
3
3
  import { Effect, Option } from "effect";
4
- import { Command, Options } from "@effect/cli";
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 EMPTY_CREDENTIALS = { profiles: {} };
8
- const profileOption = Options.text("profile").pipe(Options.withDescription("Credential profile name"));
9
- const githubTokenOption = Options.text("github-token").pipe(Options.withDescription("GitHub personal access token"), Options.optional);
10
- const opTokenOption = Options.text("op-token").pipe(Options.withDescription("1Password service account token"), Options.optional);
11
- function redactToken(token) {
12
- if (token.length <= 8) return "****";
13
- return `${token.slice(0, 4)}...${token.slice(-4)}`;
14
- }
15
- const createCommand = Command.make("create", {
16
- profile: profileOption,
17
- githubToken: githubTokenOption,
18
- opToken: opTokenOption
19
- }, ({ profile, githubToken, opToken }) => Effect.gen(function* () {
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 ((yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS)).profiles[profile]) {
23
- yield* Effect.logError(`Profile '${profile}' already exists. Delete it first.`);
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
- const newProfile = {};
27
- if (githubToken._tag === "Some") newProfile.github_token = githubToken.value;
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
- yield* credentialsFile.update((current) => ({ profiles: {
34
- ...current.profiles,
35
- [profile]: {
36
- github_token: newProfile.github_token ?? "",
37
- ...newProfile
38
- }
39
- } }), EMPTY_CREDENTIALS);
40
- yield* Effect.log(`Created profile '${profile}'.`);
41
- }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Add a credential profile"));
42
- const listCredsCommand = Command.make("list", {}, () => Effect.gen(function* () {
43
- const creds = yield* (yield* ReposetsCredentialsFile).loadOrDefault(EMPTY_CREDENTIALS);
44
- if (Object.keys(creds.profiles).length === 0) {
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(creds.profiles)) {
147
+ for (const [name, profile] of Object.entries(credentials.profiles)) {
148
+ const { owner, ownerType } = profileOwner(profile);
49
149
  yield* Effect.log(`[${name}]`);
50
- if (profile.github_token) yield* Effect.log(` github_token: ${redactToken(profile.github_token)}`);
51
- if (profile.op_service_account_token) yield* Effect.log(` op_service_account_token: ${redactToken(profile.op_service_account_token)}`);
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
- }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("List profiles (tokens redacted)"));
55
- const deleteCommand = Command.make("delete", { profile: profileOption }, ({ profile }) => Effect.gen(function* () {
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 creds = yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS);
59
- if (!creds.profiles[profile]) {
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]: _, ...remainingProfiles } = creds.profiles;
64
- yield* credentialsFile.save({ profiles: remainingProfiles });
65
- yield* Effect.log(`Deleted profile '${profile}'.`);
66
- }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Remove a profile"));
67
- const credentialsCommand = Command.make("credentials").pipe(Command.withDescription("Manage credential profiles"), Command.withSubcommands([
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
- listCredsCommand,
190
+ listCommand,
70
191
  deleteCommand
71
192
  ]));
72
193
 
73
194
  //#endregion
74
- export { credentialsCommand };
195
+ export { createHandler, credentialsCommand, deleteHandler, listHandler };