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 +16 -8
- package/bin/reposets.js +103 -45
- package/cli/commands/credentials.js +214 -34
- package/cli/commands/doctor.js +64 -54
- package/cli/commands/drift.js +4 -0
- package/cli/commands/history.js +222 -68
- package/cli/commands/init.js +65 -18
- package/cli/commands/list.js +23 -10
- package/cli/commands/nuke.js +279 -43
- package/cli/commands/sync.js +175 -81
- package/cli/commands/validate.js +54 -31
- package/cli/views/sync-progress-model.js +131 -0
- package/cli/views/sync-progress.js +68 -0
- package/index.d.ts +35 -1
- package/index.js +2 -2
- package/package.json +8 -2
- package/schemas/hosted.js +79 -0
- package/services/ConfigFiles.js +53 -13
- package/services/SyncLogger.js +109 -23
- package/store/SyncJournal.js +1 -0
- package/store/files.js +25 -0
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
|
-
|
|
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 {
|
|
18
|
+
import { AppCache, AppStore } from "@effected/app";
|
|
18
19
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
19
|
-
import {
|
|
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 = "
|
|
36
|
+
const VERSION = "3.0.0";
|
|
35
37
|
/**
|
|
36
|
-
* The application
|
|
38
|
+
* The application's directories: XDG resolution and the `reposets` namespace
|
|
39
|
+
* under it.
|
|
37
40
|
*
|
|
38
41
|
* @remarks
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
127
|
+
]), Command.provide(ConfigLive), Command.provide(CredentialsFilesLive), Command.withGlobalFlags([ConfigFlag]), Command.withSharedFlags(CliAudience.flags()));
|
|
83
128
|
/**
|
|
84
|
-
* The platform: Node's services, the application
|
|
85
|
-
* invocation facts
|
|
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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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(
|
|
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
|
|
102
|
-
* logs each returned line
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
-
|
|
120
|
-
if (isTagged(error, "ConfigValidationError"))
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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]:
|
|
253
|
+
[profile]: record
|
|
104
254
|
} });
|
|
105
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
const
|
|
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
|
|
147
|
-
if (
|
|
148
|
-
for (const [label, reference] of Object.entries(
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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*
|
|
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
|
*
|