reposets 2.0.4 → 3.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/README.md CHANGED
@@ -31,16 +31,16 @@ Scaffold the config files in the current directory:
31
31
 
32
32
  ```bash
33
33
  reposets init --project
34
- # Created: ./reposets.config.toml
35
- # Created: ./reposets.credentials.toml
36
- # Created .gitignore with reposets.credentials.toml
34
+ # ✓ Created: ./reposets.config.toml
35
+ # ✓ Created: ./reposets.credentials.toml
36
+ # ✓ Created .gitignore with reposets.credentials.toml
37
37
  ```
38
38
 
39
39
  Add a credential profile. It records *where* your token lives, never the token itself:
40
40
 
41
41
  ```bash
42
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.
43
+ # ✓ Created profile 'personal' (username: your-username, github_token: op op://Private/github/token) in ./reposets.credentials.toml.
44
44
  ```
45
45
 
46
46
  Use `--env REPOSETS_GITHUB_TOKEN` in place of `--op` to read the token from the environment instead of 1Password.
@@ -62,7 +62,7 @@ Check it, preview it, then apply it:
62
62
 
63
63
  ```bash
64
64
  reposets validate
65
- # Valid: ./reposets.config.toml
65
+ # ✓ Valid: ./reposets.config.toml
66
66
  # groups: 1
67
67
 
68
68
  reposets sync --dry-run
@@ -95,11 +95,13 @@ The owner is not in the config. It belongs to the credential profile, which decl
95
95
  | `reposets validate` | Validate the config with no API calls |
96
96
  | `reposets doctor` | Diagnose config, credentials and token, and print required permissions |
97
97
  | `reposets history` | Show past runs. Subcommands: `show`, `prune`, `clear` |
98
- | `reposets init` | Scaffold the config files. `--project` for the current directory |
98
+ | `reposets init` | Scaffold the config files. `--project` for the current directory, `--no-project` for the XDG config directory; asked on a terminal when omitted |
99
99
  | `reposets nuke` | Delete every local reposets file. Nothing on GitHub is touched |
100
100
  | `reposets credentials` | Manage credential profiles: `create`, `list`, `delete` |
101
101
 
102
- Every command accepts `--config`. Command output goes to stdout and diagnostics and errors to stderr, so `reposets sync > /dev/null` is quiet on success and loud on failure. Usage errors exit `64`; findings exit `1`.
102
+ Every command accepts `--config`. Command output goes to stdout and diagnostics and errors to stderr, so `reposets sync > /dev/null` is quiet on success and loud on failure. Usage errors exit `64`; findings exit `1`; a cancelled prompt exits `130`.
103
+
104
+ Output adapts to who is reading. On a terminal, a person gets colour, prompts for anything a command needs and was not given, and a live progress footer during `sync` and `drift`. A coding agent or CI job gets plain text and is never prompted — a missing answer is a usage error naming the flag that supplies it. Override the detection with `--human`, `--agent` or `--ci` (or `REPOSETS_AUDIENCE`).
103
105
 
104
106
  ## Configuration
105
107
 
@@ -146,7 +148,13 @@ The last five are only needed if you use the matching config sections. No accoun
146
148
 
147
149
  ## Editor support
148
150
 
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.
151
+ Both files have versioned JSON schemas, and `reposets init` stamps a `#:schema` line at the top of each file it creates so editors validate and autocomplete it:
152
+
153
+ ```toml
154
+ #:schema https://raw.githubusercontent.com/spencerbeggs/reposets/main/schemas/3.0/config.json
155
+ ```
156
+
157
+ The credentials file uses `https://raw.githubusercontent.com/spencerbeggs/reposets/main/schemas/3.0/credentials.json`. The schemas are also listed in [SchemaStore](https://www.schemastore.org/), and carry annotations for the Tombi and Taplo TOML language servers.
150
158
 
151
159
  ## License
152
160
 
package/bin/reposets.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { CredentialsFilesLive } from "../services/ConfigFiles.js";
3
3
  import { Invocation } from "../services/Invocation.js";
4
+ import { CACHE_DB_FILENAME, STATE_DB_FILENAME } from "../store/files.js";
4
5
  import { migrations } from "../store/migrations.js";
5
6
  import { SyncJournalLive } from "../store/SyncJournal.js";
6
7
  import { credentialsCommand } from "../cli/commands/credentials.js";
@@ -14,9 +15,9 @@ import { listCommand } from "../cli/commands/list.js";
14
15
  import { nukeCommand } from "../cli/commands/nuke.js";
15
16
  import { validateCommand } from "../cli/commands/validate.js";
16
17
  import { Effect, Layer } from "effect";
17
- import { App } from "@effected/app";
18
+ import { App, AppCache, AppStore } from "@effected/app";
18
19
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
19
- import { CliColor, CliRuntime, ConfigIssueRenderer } from "@effected/cli";
20
+ import { CliAudience, CliRuntime, ConfigIssueRenderer, Fmt } from "@effected/cli";
20
21
  import { Command } from "effect/cli";
21
22
 
22
23
  //#region src/cli/index.ts
@@ -31,22 +32,56 @@ import { Command } from "effect/cli";
31
32
  * `0.0.0 (dev build)` from every build until the version was threaded down
32
33
  * from here through {@link Invocation}.
33
34
  */
34
- const VERSION = "2.0.4";
35
+ const VERSION = "3.1.0";
35
36
  /**
36
- * The application control plane.
37
+ * The application's directories: XDG resolution and the `reposets` namespace
38
+ * under it.
37
39
  *
38
40
  * @remarks
39
- * Bound once, at module scope. `App.layer` opens both SQLite databases, so a
40
- * second call would open a second pair with split event streams.
41
+ * `App.layerDirs` is the directories half of `App.layer`, without the two
42
+ * databases `App.layer` always opens. Every command can resolve a path through
43
+ * this; resolving one creates nothing — only an `ensure*` member does, and only
44
+ * the command that writes calls it.
45
+ */
46
+ const DirsLive = App.layerDirs({ namespace: "reposets" });
47
+ /**
48
+ * The state database, `store.db` under the XDG state directory.
49
+ *
50
+ * @remarks
51
+ * Bound once, at module scope: `AppStore.layer` is a layer-returning function,
52
+ * and a second call would open a second connection onto the same file with a
53
+ * second migration ledger.
41
54
  *
42
- * Migrations run during layer construction, so a fresh checkout gets its schema
43
- * on the first command rather than on first write.
55
+ * It is attached only to the commands that read or write it
56
+ * ({@link DatabasesLive}, {@link HistoryLive}), not to the platform. When it sat in the
57
+ * platform, every command — `init`, `list`, `validate`, `nuke` — opened (and so
58
+ * created) `store.db` as a side effect, and `nuke` deleted the database while
59
+ * its own process held it open, leaving its `-wal` and `-shm` files behind.
60
+ * Migrations still run during layer construction, so they now run on the first
61
+ * command that uses the database rather than on the first command of any kind.
44
62
  */
45
- const AppLive = App.layer({
46
- namespace: "reposets",
47
- store: { migrations }
63
+ const StoreLive = AppStore.layer({
64
+ migrations,
65
+ filename: STATE_DB_FILENAME
48
66
  });
49
67
  /**
68
+ * Both databases, for the commands that run the sync engine.
69
+ *
70
+ * @remarks
71
+ * `sync` and `drift` read and write the applied state and memoize GitHub reads
72
+ * in `cache.db`. The engine builds its own journal over `Store`, so nothing
73
+ * else is needed here.
74
+ */
75
+ const DatabasesLive = Layer.mergeAll(StoreLive, AppCache.layer({ filename: CACHE_DB_FILENAME }));
76
+ /**
77
+ * The run journal over the state database, for `history` and its subcommands.
78
+ *
79
+ * @remarks
80
+ * `history` reads only the journal, so it opens `store.db` and never
81
+ * `cache.db`.
82
+ */
83
+ const HistoryLive = SyncJournalLive.pipe(Layer.provide(StoreLive));
84
+ /**
50
85
  * Everything this module reads from `process`, handed down as plain values.
51
86
  *
52
87
  * @remarks
@@ -68,29 +103,41 @@ const InvocationLive = Invocation.layer({
68
103
  * `Command.provide(ConfigLive)` sits here, once, rather than in each subcommand:
69
104
  * subcommand requirements bubble into the parent's `R` through
70
105
  * `withSubcommands`, so one provide covers all of them.
106
+ *
107
+ * The databases are the deliberate exception. They are provided per command,
108
+ * to `sync`, `drift` and `history` only, because providing them here would
109
+ * open both files for every command again — see {@link StoreLive}. A command
110
+ * that does not name `Store` or `Cache` in its requirements never opens one.
111
+ *
112
+ * `CliAudience.flags()` adds `--audience`, `--human`, `--agent` and `--ci` to
113
+ * every command. `CliAudience.run` resolves them before core parses, so the
114
+ * audience is known to every prompt, report and failure line the run writes.
71
115
  */
72
116
  const cli = Command.make("reposets", {}, () => Effect.void).pipe(Command.withDescription("Sync GitHub repository settings across repos from a TOML config"), Command.withSubcommands([
73
117
  validateCommand,
74
- syncCommand,
118
+ syncCommand.pipe(Command.provide(DatabasesLive)),
75
119
  listCommand,
76
120
  doctorCommand,
77
- driftCommand,
121
+ driftCommand.pipe(Command.provide(DatabasesLive)),
78
122
  initCommand,
79
123
  nukeCommand,
80
- historyCommand,
124
+ historyCommand.pipe(Command.provide(HistoryLive)),
81
125
  credentialsCommand
82
- ]), Command.provide(ConfigLive), Command.provide(CredentialsFilesLive), Command.provide(SyncJournalLive), Command.withGlobalFlags([ConfigFlag]));
126
+ ]), Command.provide(ConfigLive), Command.provide(CredentialsFilesLive), Command.withGlobalFlags([ConfigFlag]), Command.withSharedFlags(CliAudience.flags()));
83
127
  /**
84
- * The platform: Node's services, the application control plane over them, the
85
- * invocation facts, and one colour-aware help/error formatter.
128
+ * The platform: Node's services, the application's directories over them, and
129
+ * the invocation facts.
86
130
  *
87
131
  * @remarks
88
- * `provideMerge` rather than `provide` at each step, because commands require
89
- * the platform services (`FileSystem`, `Path`, `Stdio`) directly as well as
90
- * through `App`. `CliColor.formatterLayer()` needs `Stdio` to decide whether
91
- * colour is on, so it sits above `NodeServices.layer` rather than beside it.
132
+ * `provideMerge` rather than `provide`, because commands require the platform
133
+ * services (`FileSystem`, `Path`, `Stdio`) directly as well as through
134
+ * `AppDirs`, and the per-command database layers need `FileSystem`, `Path` and
135
+ * `AppDirs` from here. No database is opened at this level.
136
+ * There is no formatter here: under `env`, `CliRuntime.main` installs the
137
+ * colour-aware help formatter itself, closer to the program, and one set here
138
+ * would be shadowed.
92
139
  */
93
- const PlatformLive = Layer.mergeAll(AppLive, CliColor.formatterLayer(), InvocationLive).pipe(Layer.provideMerge(NodeServices.layer));
140
+ const PlatformLive = Layer.mergeAll(DirsLive, InvocationLive).pipe(Layer.provideMerge(NodeServices.layer));
94
141
  /** Whether a failure is a tagged error of a given tag. */
95
142
  const isTagged = (error, tag) => typeof error === "object" && error !== null && "_tag" in error && error._tag === tag;
96
143
  /**
@@ -98,34 +145,45 @@ const isTagged = (error, tag) => typeof error === "object" && error !== null &&
98
145
  *
99
146
  * @remarks
100
147
  * `CliRuntime.main` hands this every failure it does not already know how to
101
- * skip — a usage error `Command.runWith` printed itself never reaches it — and
102
- * logs each returned line through `CliLogger` before exiting `1`.
148
+ * skip — a usage error `Command.runWith` printed itself never reaches it, and a
149
+ * cancelled prompt is drawn by the kit — and logs each returned line before
150
+ * exiting `1`.
151
+ *
152
+ * A config that fails to decode arrives here as a `ConfigValidationError`.
153
+ * The kit's default report already draws its issue as a tree of rejected keys,
154
+ * so the failure describes itself rather than telling a CI log to go run
155
+ * another command; this adds only the `doctor` pointer, and only when
156
+ * something was misspelled (`ConfigIssueRenderer` names it an `unknown key`).
157
+ * A missing key has no near-match to suggest, and pointing a confused user at
158
+ * a command that cannot help them is worse than saying nothing.
103
159
  *
104
- * A config that fails to decode arrives here as a `ConfigValidationError`,
105
- * whose message names the file and stops there. The structured cause is on its
106
- * `issue`, which `ConfigIssueRenderer` turns into `unknown key at …` lines, so
107
- * the failure describes itself rather than telling a CI log to go run another
108
- * command. The `doctor` pointer stays only when something was misspelled — a
109
- * missing key has no near-match to suggest, and pointing a confused user at a
110
- * command that cannot help them is worse than saying nothing.
160
+ * Any other typed failure that carries a `cause` gets it printed under the
161
+ * kit's own status line. A TOML syntax error is a `ConfigCodecError` whose own
162
+ * message is the bare "toml parse failed" — the line and column live on the
163
+ * cause, and printing it is the difference between "your config is broken
164
+ * somewhere" and a position plus what the parser expected there. The cause
165
+ * reports a 0-based `line:column`: an unclosed `[groups` header on the first
166
+ * line reads "ExpectedTableHeaderClose at 0:7 expected ] to close the table
167
+ * header".
111
168
  *
112
- * Any other failure that carries a `cause` gets it printed on a second line. A
113
- * TOML syntax error is a `ConfigCodecError` whose own message is the bare
114
- * "toml parse failed" — the line and column live on the cause, and printing it
115
- * is the difference between "your config is broken somewhere" and "1:9,
116
- * expected ] to close the table header".
169
+ * Everything else, defects included, is the kit's default report. Lines built
170
+ * here from data are sanitised: the kit keeps a person's escapes, so it cannot
171
+ * strip an injected one from a `render`'s output.
117
172
  */
118
- const render = (error) => {
119
- const lines = [String(error)];
120
- if (isTagged(error, "ConfigValidationError")) {
121
- const issues = ConfigIssueRenderer.render(error);
122
- lines.push(...issues.map((line) => ` ${line}`));
123
- if (issues.some((line) => line.startsWith("unknown key"))) lines.push(" Run 'reposets doctor' for suggested spellings.");
124
- } else if (typeof error === "object" && error !== null && "cause" in error && error.cause !== void 0) lines.push(` ${String(error.cause)}`);
125
- return lines;
173
+ const render = (error, details) => {
174
+ if (details.isDefect) return details.defaultLines;
175
+ if (isTagged(error, "ConfigValidationError")) return ConfigIssueRenderer.render(error).some((line) => line.startsWith("unknown key")) ? [...details.defaultLines, "Run 'reposets doctor' for suggested spellings."] : details.defaultLines;
176
+ if (typeof error === "object" && error !== null && "cause" in error && error.cause !== void 0) return [...details.defaultLines, ` ${Fmt.sanitize(String(error.cause))}`];
177
+ return details.defaultLines;
126
178
  };
127
- NodeRuntime.runMain(CliRuntime.main(Command.run(cli, { version: VERSION }), {
179
+ NodeRuntime.runMain(CliRuntime.main(CliAudience.run(cli, { version: VERSION }), {
128
180
  platform: PlatformLive,
181
+ env: {
182
+ audienceEnvVar: "REPOSETS_AUDIENCE",
183
+ log: { envVar: "REPOSETS_LOG_LEVEL" },
184
+ spansEnvVar: "REPOSETS_SPANS",
185
+ stderrIsTerminal: Effect.sync(() => process.stderr.isTTY === true)
186
+ },
129
187
  render,
130
188
  helpOnUsageError: "stderr"
131
189
  }));
@@ -1,12 +1,19 @@
1
1
  import { profileOwner } from "../../schemas/credentials.js";
2
2
  import { ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
3
- import { Console, Effect, Option } from "effect";
3
+ import { Effect, Option } from "effect";
4
+ import { CliInteractive, CliMessage, Doc } from "@effected/cli";
4
5
  import { CliError, Command, Flag } from "effect/cli";
6
+ import { CliUi, Select, TextInput } from "@effected/cli/ui";
5
7
  import { AppDirs } from "@effected/xdg";
6
8
 
7
9
  //#region src/cli/commands/credentials.ts
8
10
  const EMPTY = { profiles: {} };
9
- const profileFlag = Flag.String("profile").pipe(Flag.withDescription("Credential profile name"));
11
+ /**
12
+ * Optional on both `create` and `delete`: an interactive run asks for a
13
+ * missing name, a non-interactive one is refused with a message naming the
14
+ * flag, exactly as a missing required flag would be (exit 64).
15
+ */
16
+ const profileFlag = Flag.String("profile").pipe(Flag.optional, Flag.withDescription("Credential profile name. Asked when omitted on a terminal"));
10
17
  const opFlag = Flag.String("op").pipe(Flag.optional, Flag.withDescription("1Password secret reference, e.g. \"op://Vault/item/field\""));
11
18
  const envFlag = Flag.String("env").pipe(Flag.optional, Flag.withDescription("Name of an environment variable holding the token, e.g. \"REPOSETS_GITHUB_TOKEN\""));
12
19
  const usernameFlag = Flag.String("username").pipe(Flag.optional, Flag.withDescription("The personal account this profile acts as. Mutually exclusive with --org"));
@@ -31,7 +38,63 @@ const SECRET_PREFIXES = [
31
38
  "sk-",
32
39
  "xoxb-"
33
40
  ];
34
- const looksLikeSecret = (value) => SECRET_PREFIXES.some((prefix) => value.startsWith(prefix)) || value.length > 60;
41
+ /**
42
+ * A token embedded anywhere in a value — `op://v/ghp_…` — rather than leading it.
43
+ *
44
+ * @remarks
45
+ * A prefix only counts at a word boundary and followed by a token-like body:
46
+ * a run of at least 20 letters and digits for the GitHub and 1Password
47
+ * prefixes (a `ghp_` token's body is 36), digits for a Slack bot token. Both
48
+ * conditions keep names out: `devops_team` has no boundary before `ops_`, and
49
+ * `op://Vault/ops_deploy_credentials/field` has no unbroken 20-character run
50
+ * after it. `sk-` is deliberately absent — too common inside ordinary words to
51
+ * mean anything away from the start of a value.
52
+ */
53
+ const EMBEDDED_SECRET = /(?:^|[^A-Za-z0-9])(?:(?:gh[pousr]_|github_pat_|ops_)[A-Za-z0-9]{20}|xoxb-[0-9]{6})/;
54
+ /**
55
+ * A GitHub classic token from before prefixes: exactly 40 hex digits.
56
+ *
57
+ * @remarks
58
+ * It carries no prefix and is under the length rule, and one that begins with
59
+ * a letter is also a valid environment-variable name, so without this it is
60
+ * accepted by `--env` and printed back in the confirmation line.
61
+ */
62
+ const LEGACY_GITHUB_TOKEN = /^[0-9a-f]{40}$/i;
63
+ /**
64
+ * Whether a value looks like a pasted token rather than a reference or a name.
65
+ *
66
+ * @remarks
67
+ * A known token prefix at the start, a token embedded anywhere
68
+ * ({@link EMBEDDED_SECRET}), a pre-prefix GitHub token
69
+ * ({@link LEGACY_GITHUB_TOKEN}), or anything longer than 60 characters. The
70
+ * length rule catches the tokens that carry no recognisable prefix, but it
71
+ * never applies to an `op://` value: that is a 1Password reference by
72
+ * construction, none of the token prefixes begins that way, and a real
73
+ * reference crosses 60 characters easily once a vault or item name has spaces
74
+ * in it (`op://Engineering Shared Vault/GitHub Production Deploy Token/credential`).
75
+ * A token pasted *into* a reference is still caught, by the embedded rule.
76
+ */
77
+ const looksLikeSecret = (value) => SECRET_PREFIXES.some((prefix) => value.startsWith(prefix)) || EMBEDDED_SECRET.test(value) || LEGACY_GITHUB_TOKEN.test(value) || !value.startsWith("op://") && value.length > 60;
78
+ /**
79
+ * The explanation for a flag value {@link looksLikeSecret} catches — in any
80
+ * flag, since a token pasted into `--profile` or `--username` would otherwise
81
+ * be stored as a TOML key and printed back in the confirmation. It never
82
+ * contains the value.
83
+ */
84
+ const LOOKS_LIKE_SECRET = "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.";
85
+ /**
86
+ * The prompt path's versions of {@link LOOKS_LIKE_SECRET}.
87
+ *
88
+ * @remarks
89
+ * Shorter, because a widget truncates its validation line to the terminal and
90
+ * the flag path's explanation loses its point at 80 columns; and phrased for
91
+ * someone typing into a field rather than passing a flag. Neither contains
92
+ * the value.
93
+ */
94
+ const SECRET_IN_REFERENCE = "That looks like a token, not a reference — enter where it lives.";
95
+ const SECRET_IN_NAME = "That looks like a token, not a name. It was not stored.";
96
+ /** What `--env` names, and what the prompt accepts for it: a shell variable identifier. */
97
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
35
98
  /**
36
99
  * Write credentials back to the file they were read from.
37
100
  *
@@ -63,6 +126,46 @@ const refuse = (message) => Effect.fail(new CliError.UserError({ cause: message
63
126
  /** How a profile's `github_token` is sourced, for display. Never a value. */
64
127
  const describeSource = (source) => "op" in source ? `op ${source.op}` : `env ${source.env}`;
65
128
  /**
129
+ * Show a screen, mapping "nobody is there to answer" to a refusal.
130
+ *
131
+ * @remarks
132
+ * The handlers decide interactivity up front with `CliInteractive` and refuse
133
+ * before any screen when a run cannot prompt, so the `NotInteractive` mapped
134
+ * here is defensive: should a screen ever be reached in such a run, it is the
135
+ * same usage error (exit 64) naming the flag that would have answered it.
136
+ * A cancel is the kit's `Cancelled` and is left alone, so `CliRuntime.main`
137
+ * renders it as one line and exits 130.
138
+ */
139
+ const ask = (screen, refusal, options) => CliUi.run(screen, options).pipe(Effect.catchTag("NotInteractive", () => refuse(refusal)));
140
+ const MISSING_REFERENCE = "Provide a reference: --op \"op://Vault/item/field\" or --env REPOSETS_GITHUB_TOKEN. A token value is never accepted.";
141
+ const MISSING_ACTS_AS = "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.";
142
+ const MISSING_PROFILE = "Provide --profile <name>: the name a group's 'credentials' field refers to.";
143
+ /**
144
+ * Validate a token reference typed at the prompt.
145
+ *
146
+ * @remarks
147
+ * The secret check runs first and on both kinds, so a pasted token is called
148
+ * a token rather than getting a complaint about its shape. Every message here
149
+ * fits a narrow terminal, is shown inside the screen, and none contains the
150
+ * typed value.
151
+ */
152
+ const validateReference = (kind) => (value) => {
153
+ if (value === "") return "A reference is required.";
154
+ if (looksLikeSecret(value)) return SECRET_IN_REFERENCE;
155
+ if (kind === "op" && !value.startsWith("op://")) return "A 1Password reference starts with \"op://\".";
156
+ if (kind === "env" && !ENV_NAME.test(value)) return "Letters, digits and underscores only, not starting with a digit.";
157
+ };
158
+ /**
159
+ * A name typed at a prompt: anything but blank, and never a token.
160
+ *
161
+ * @remarks
162
+ * A profile name becomes a TOML key and an owner name a stored value, and both
163
+ * are printed in the confirmation — so a token pasted into either field would
164
+ * be written to disk and echoed, the exact outcome the reference prompt
165
+ * guards against.
166
+ */
167
+ const validName = (what) => (value) => value.trim() === "" ? `${what} is required.` : looksLikeSecret(value) ? SECRET_IN_NAME : void 0;
168
+ /**
66
169
  * `reposets credentials create` — record a *reference* to a GitHub token.
67
170
  *
68
171
  * @remarks
@@ -73,36 +176,120 @@ const describeSource = (source) => "op" in source ? `op ${source.op}` : `env ${s
73
176
  * variable — both are addresses, safe to appear in shell history and in this
74
177
  * command's own output.
75
178
  *
179
+ * Whatever the flags leave out, an interactive run asks for, in order: the
180
+ * profile name, who it acts as (personal account or organization, then the
181
+ * name), and where the token is (1Password or an environment variable, then
182
+ * the reference). The flags given are validated **before** any question, so a
183
+ * person is never walked through a wizard only to be refused for a flag; two
184
+ * conflicting flags (`--op` with `--env`, `--username` with `--org`) are
185
+ * refused even on a terminal, since there is no question that resolves them.
186
+ * A non-interactive run is refused exactly as before, the message naming the
187
+ * flag to pass (exit 64).
188
+ *
189
+ * A token can be pasted into any text field, not only the reference: every
190
+ * value — each flag and each typed answer — is checked with `looksLikeSecret`
191
+ * and refused without being repeated, so nothing token-shaped becomes a
192
+ * profile key, an owner or a reference, or reaches the confirmation line. The
193
+ * text prompts' frames are cleared when they close, so a token typed and then
194
+ * abandoned is not left in the terminal's scrollback; the accepted answers are
195
+ * recorded in the confirmation line instead. The reference prompt — the one
196
+ * field a token is most likely to be pasted into — is masked by the same rule
197
+ * `validate` refuses on (`mask: looksLikeSecret`), so what is hidden is exactly
198
+ * what is refused. The kit latches the mask once the rule answers true, until
199
+ * the field is emptied, so editing a pasted token's prefix away never unmasks
200
+ * the rest; a paste is masked from its first frame, and a token typed by hand
201
+ * shows at most its public prefix (`ghp`) before the mask trips. A real
202
+ * `op://` reference or variable name stays readable while it is typed.
203
+ * `validate` sees the real text and refuses a token with a message that never
204
+ * quotes it. The reference itself is echoed in the confirmation line, which is
205
+ * where a typo in it shows. The name prompts are not masked: a name is read back as
206
+ * it is typed.
207
+ *
76
208
  * `op_service_account_token` is likewise not accepted: it comes from the
77
209
  * environment, so there is nothing to write here.
78
210
  *
79
211
  * @public
80
212
  */
81
213
  const createHandler = (input) => Effect.gen(function* () {
82
- const { profile, op, env, username, org } = input;
214
+ const { op, env, username, org } = input;
83
215
  yield* (yield* AppDirs).ensureConfig;
84
216
  const credentialsFile = yield* ReposetsCredentialsFile;
85
217
  if (op !== void 0 && env !== void 0) return yield* refuse("Provide exactly one of --op or --env, not both.");
86
- if (op === void 0 && env === void 0) return yield* refuse("Provide a reference: --op \"op://Vault/item/field\" or --env REPOSETS_GITHUB_TOKEN. A token value is never accepted.");
218
+ const interactive = yield* CliInteractive;
219
+ if (op === void 0 && env === void 0 && !interactive) return yield* refuse(MISSING_REFERENCE);
87
220
  if (op !== void 0 && !op.startsWith("op://")) return yield* refuse("--op must be a 1Password reference starting with \"op://\". Value not echoed.");
88
- if (looksLikeSecret(op ?? env ?? "")) return yield* refuse("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.");
221
+ if ([
222
+ op,
223
+ env,
224
+ input.profile,
225
+ username,
226
+ org
227
+ ].some((value) => value !== void 0 && looksLikeSecret(value))) return yield* refuse(LOOKS_LIKE_SECRET);
89
228
  if (username !== void 0 && org !== void 0) return yield* refuse("Provide exactly one of --username or --org, not both.");
90
- if (username === void 0 && org === void 0) return yield* refuse("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.");
229
+ if (username === void 0 && org === void 0 && !interactive) return yield* refuse(MISSING_ACTS_AS);
230
+ if (input.profile === void 0 && !interactive) return yield* refuse(MISSING_PROFILE);
91
231
  const existing = yield* credentialsFile.loadOrDefault(EMPTY);
92
- if (existing.profiles[profile] !== void 0) return yield* refuse(`Profile '${profile}' already exists. Delete it first.`);
93
- const github_token = op !== void 0 ? { op } : { env };
94
- const actsAs = username !== void 0 ? {
95
- username,
96
- github_token
97
- } : {
98
- org,
232
+ const taken = (name) => existing.profiles[name] === void 0 ? void 0 : `Profile '${name}' already exists. Delete it first.`;
233
+ if (input.profile !== void 0) {
234
+ const problem = taken(input.profile);
235
+ if (problem !== void 0) return yield* refuse(problem);
236
+ }
237
+ const profile = input.profile ?? (yield* ask(TextInput.screen({
238
+ message: "Profile name",
239
+ placeholder: "personal",
240
+ validate: (value) => validName("A profile name")(value) ?? taken(value)
241
+ }), MISSING_PROFILE, { clear: true }));
242
+ const actsAs = username !== void 0 ? { username } : org !== void 0 ? { org } : yield* Effect.gen(function* () {
243
+ const kind = yield* ask(Select.screen({
244
+ message: "Who does this profile act as?",
245
+ choices: [{
246
+ label: "A personal account",
247
+ value: "username",
248
+ detail: "repositories you own"
249
+ }, {
250
+ label: "An organization",
251
+ value: "org",
252
+ detail: "repositories an organization owns"
253
+ }]
254
+ }), MISSING_ACTS_AS);
255
+ const what = kind === "username" ? "GitHub username" : "Organization name";
256
+ const name = yield* ask(TextInput.screen({
257
+ message: what,
258
+ validate: validName(`A ${what.toLowerCase()}`)
259
+ }), MISSING_ACTS_AS, { clear: true });
260
+ return kind === "username" ? { username: name } : { org: name };
261
+ });
262
+ const github_token = op !== void 0 ? { op } : env !== void 0 ? { env } : yield* Effect.gen(function* () {
263
+ const kind = yield* ask(Select.screen({
264
+ message: "Where is the GitHub token?",
265
+ choices: [{
266
+ label: "1Password",
267
+ value: "op",
268
+ detail: "an op:// secret reference"
269
+ }, {
270
+ label: "An environment variable",
271
+ value: "env",
272
+ detail: "e.g. in CI"
273
+ }]
274
+ }), MISSING_REFERENCE);
275
+ const reference = yield* ask(TextInput.screen({
276
+ message: kind === "op" ? "1Password reference" : "Environment variable name",
277
+ placeholder: kind === "op" ? "op://Vault/item/field" : "REPOSETS_GITHUB_TOKEN",
278
+ validate: validateReference(kind),
279
+ mask: looksLikeSecret
280
+ }), MISSING_REFERENCE, { clear: true });
281
+ return kind === "op" ? { op: reference } : { env: reference };
282
+ });
283
+ const record = {
284
+ ...actsAs,
99
285
  github_token
100
286
  };
101
287
  const path = yield* saveWhereRead(credentialsFile, { profiles: {
102
288
  ...existing.profiles,
103
- [profile]: actsAs
289
+ [profile]: record
104
290
  } });
105
- yield* Console.log(`Created profile '${profile}' (${username !== void 0 ? `username: ${username}` : `org: ${org}`}, github_token: ${describeSource(github_token)}) in ${path}.`);
291
+ const { owner, ownerType } = profileOwner(record);
292
+ yield* CliMessage.success(`Created profile '${profile}' (${ownerType === "User" ? "username" : "org"}: ${owner}, github_token: ${describeSource(github_token)}) in ${path}.`);
106
293
  });
107
294
  const createCommand = Command.make("create", {
108
295
  profile: profileFlag,
@@ -111,12 +298,17 @@ const createCommand = Command.make("create", {
111
298
  username: usernameFlag,
112
299
  org: orgFlag
113
300
  }, ({ profile, op, env, username, org }) => createHandler({
114
- profile,
301
+ profile: Option.getOrUndefined(profile),
115
302
  op: Option.getOrUndefined(op),
116
303
  env: Option.getOrUndefined(env),
117
304
  username: Option.getOrUndefined(username),
118
305
  org: Option.getOrUndefined(org)
119
306
  })).pipe(Command.withDescription("Add a credential profile holding a reference to a GitHub token"));
307
+ /** Who a profile acts as, for display. */
308
+ const describeOwner = (profile) => {
309
+ const { owner, ownerType } = profileOwner(profile);
310
+ return `${owner} (${ownerType === "User" ? "user" : "organization"})`;
311
+ };
120
312
  /**
121
313
  * `reposets credentials list` — the profiles and what they point at.
122
314
  *
@@ -125,47 +317,71 @@ const createCommand = Command.make("create", {
125
317
  * discloses which vault items and environment variables reposets reads, never
126
318
  * their contents — which is exactly what someone running this needs to see.
127
319
  *
320
+ * Printed as one document, a section per profile, so the audience decides the
321
+ * rendering: plain for an agent or a pipe, styled on a terminal.
322
+ *
128
323
  * @public
129
324
  */
130
325
  const listHandler = Effect.gen(function* () {
131
326
  const credentials = yield* (yield* ReposetsCredentialsFile).loadOrDefault(EMPTY);
132
- if (Object.keys(credentials.profiles).length === 0) {
133
- yield* Console.log("No credential profiles configured.");
327
+ const entries = Object.entries(credentials.profiles);
328
+ if (entries.length === 0) {
329
+ yield* CliMessage.info("No credential profiles configured.");
134
330
  return;
135
331
  }
136
- for (const [name, profile] of Object.entries(credentials.profiles)) {
137
- const { owner, ownerType } = profileOwner(profile);
138
- yield* Console.log(`[${name}]`);
139
- yield* Console.log(` acts as: ${owner} (${ownerType === "User" ? "user" : "organization"})`);
140
- yield* Console.log(` github_token: ${describeSource(profile.github_token)}`);
332
+ yield* Doc.print(entries.map(([name, profile]) => {
333
+ const lines = [`acts as: ${describeOwner(profile)}`, `github_token: ${describeSource(profile.github_token)}`];
141
334
  for (const kind of [
142
335
  "op",
143
336
  "env",
144
337
  "file"
145
338
  ]) {
146
- const entries = profile.resolve?.[kind];
147
- if (entries === void 0) continue;
148
- for (const [label, reference] of Object.entries(entries)) yield* Console.log(` resolve.${kind}.${label}: ${reference}`);
339
+ const resolved = profile.resolve?.[kind];
340
+ if (resolved === void 0) continue;
341
+ for (const [label, reference] of Object.entries(resolved)) lines.push(`resolve.${kind}.${label}: ${reference}`);
149
342
  }
150
- yield* Console.log("");
151
- }
343
+ return Doc.section(`[${name}]`, [Doc.lines(lines)]);
344
+ }));
152
345
  });
153
346
  const listCommand = Command.make("list", {}, () => listHandler).pipe(Command.withDescription("List credential profiles and the references they hold"));
154
347
  /**
155
348
  * `reposets credentials delete` — remove a profile.
156
349
  *
350
+ * @remarks
351
+ * Without `--profile`, an interactive run picks from the existing profiles —
352
+ * each shown with who it acts as and its token reference, never a value — and
353
+ * a non-interactive run is refused naming `--profile` (exit 64). With no
354
+ * profiles at all there is nothing to pick, which is said and succeeds.
355
+ *
356
+ * @param profile - the profile to remove, or `undefined` when the flag was omitted
357
+ *
157
358
  * @public
158
359
  */
159
360
  const deleteHandler = (profile) => Effect.gen(function* () {
160
361
  yield* (yield* AppDirs).ensureConfig;
362
+ if (profile === void 0 && !(yield* CliInteractive)) return yield* refuse("Provide --profile <name>: the profile to delete.");
363
+ if (profile !== void 0 && looksLikeSecret(profile)) return yield* refuse(LOOKS_LIKE_SECRET);
161
364
  const credentialsFile = yield* ReposetsCredentialsFile;
162
365
  const credentials = yield* credentialsFile.loadOrDefault(EMPTY);
163
- if (credentials.profiles[profile] === void 0) return yield* refuse(`Profile '${profile}' not found.`);
164
- const { [profile]: _removed, ...remaining } = credentials.profiles;
366
+ const entries = Object.entries(credentials.profiles);
367
+ if (profile === void 0 && entries.length === 0) {
368
+ yield* CliMessage.info("No credential profiles configured — nothing to delete.");
369
+ return;
370
+ }
371
+ const name = profile ?? (yield* ask(Select.screen({
372
+ message: "Delete which profile?",
373
+ choices: entries.map(([key, value]) => ({
374
+ label: key,
375
+ value: key,
376
+ detail: `${describeOwner(value)} · github_token: ${describeSource(value.github_token)}`
377
+ }))
378
+ }), "Provide --profile <name>: the profile to delete."));
379
+ if (credentials.profiles[name] === void 0) return yield* refuse(`Profile '${name}' not found.`);
380
+ const { [name]: _removed, ...remaining } = credentials.profiles;
165
381
  const path = yield* saveWhereRead(credentialsFile, { profiles: remaining });
166
- yield* Console.log(`Deleted profile '${profile}' from ${path}.`);
382
+ yield* CliMessage.success(`Deleted profile '${name}' from ${path}.`);
167
383
  });
168
- const deleteCommand = Command.make("delete", { profile: profileFlag }, ({ profile }) => deleteHandler(profile)).pipe(Command.withDescription("Remove a credential profile"));
384
+ const deleteCommand = Command.make("delete", { profile: profileFlag }, ({ profile }) => deleteHandler(Option.getOrUndefined(profile))).pipe(Command.withDescription("Remove a credential profile"));
169
385
  /**
170
386
  * `reposets credentials` — manage `reposets.credentials.toml`.
171
387
  *