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