reposets 1.0.21 → 2.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 +1 -1
- package/bin/reposets.js +72 -23
- package/cli/commands/credentials.js +28 -42
- package/cli/commands/doctor.js +44 -37
- package/cli/commands/history.js +21 -34
- package/cli/commands/init.js +19 -9
- package/cli/commands/list.js +11 -9
- package/cli/commands/nuke.js +19 -22
- package/cli/commands/sync.js +13 -41
- package/cli/commands/validate.js +21 -18
- package/package.json +2 -1
- package/services/CredentialResolver.js +20 -8
- package/services/Invocation.js +38 -0
- package/services/OnePasswordClient.js +20 -8
- package/services/SyncLogger.js +10 -5
- package/tsdoc-metadata.json +1 -1
- package/cli/logger.js +0 -48
- package/lib/schema-issues.js +0 -50
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ The owner is not in the config. It belongs to the credential profile, which decl
|
|
|
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
|
|
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`.
|
|
103
103
|
|
|
104
104
|
## Configuration
|
|
105
105
|
|
package/bin/reposets.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { CredentialsFilesLive } from "../services/ConfigFiles.js";
|
|
3
|
-
import {
|
|
3
|
+
import { Invocation } from "../services/Invocation.js";
|
|
4
4
|
import { migrations } from "../store/migrations.js";
|
|
5
5
|
import { SyncJournalLive } from "../store/SyncJournal.js";
|
|
6
6
|
import { credentialsCommand } from "../cli/commands/credentials.js";
|
|
@@ -13,14 +13,26 @@ import { initCommand } from "../cli/commands/init.js";
|
|
|
13
13
|
import { listCommand } from "../cli/commands/list.js";
|
|
14
14
|
import { nukeCommand } from "../cli/commands/nuke.js";
|
|
15
15
|
import { validateCommand } from "../cli/commands/validate.js";
|
|
16
|
-
import { CliLoggerLive } from "../cli/logger.js";
|
|
17
16
|
import { Effect, Layer } from "effect";
|
|
18
17
|
import { App } from "@effected/app";
|
|
19
18
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
19
|
+
import { CliColor, CliRuntime, ConfigIssueRenderer } from "@effected/cli";
|
|
20
20
|
import { Command } from "effect/unstable/cli";
|
|
21
21
|
|
|
22
22
|
//#region src/cli/index.ts
|
|
23
23
|
/**
|
|
24
|
+
* The build's version.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* The bundler substitutes `process.env.__PACKAGE_VERSION__` with a literal at
|
|
28
|
+
* build time, so this is not a runtime environment read. Only the exact
|
|
29
|
+
* `process.env.__PACKAGE_VERSION__` spelling is substituted — a destructured
|
|
30
|
+
* `env.__PACKAGE_VERSION__` is not, which is why `doctor` printed
|
|
31
|
+
* `0.0.0 (dev build)` from every build until the version was threaded down
|
|
32
|
+
* from here through {@link Invocation}.
|
|
33
|
+
*/
|
|
34
|
+
const VERSION = "2.0.0";
|
|
35
|
+
/**
|
|
24
36
|
* The application control plane.
|
|
25
37
|
*
|
|
26
38
|
* @remarks
|
|
@@ -35,6 +47,21 @@ const AppLive = App.layer({
|
|
|
35
47
|
store: { migrations }
|
|
36
48
|
});
|
|
37
49
|
/**
|
|
50
|
+
* Everything this module reads from `process`, handed down as plain values.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* This file is the only one under `src/` allowed to read `process`. Commands and
|
|
54
|
+
* services require {@link Invocation} instead, so a test can hand them a
|
|
55
|
+
* different working directory. Two things are deliberately not here: the
|
|
56
|
+
* environment, which every reader takes through Effect's `Config` (the default
|
|
57
|
+
* `ConfigProvider` reads the process environment, so nothing here has to), and
|
|
58
|
+
* whether stdin is a terminal, which `nuke` asks core's `Stdio.stdinIsTerminal`.
|
|
59
|
+
*/
|
|
60
|
+
const InvocationLive = Invocation.layer({
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
version: VERSION
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
38
65
|
* The root command.
|
|
39
66
|
*
|
|
40
67
|
* @remarks
|
|
@@ -53,33 +80,55 @@ const cli = Command.make("reposets", {}, () => Effect.void).pipe(Command.withDes
|
|
|
53
80
|
historyCommand,
|
|
54
81
|
credentialsCommand
|
|
55
82
|
]), Command.provide(ConfigLive), Command.provide(CredentialsFilesLive), Command.provide(SyncJournalLive), Command.withGlobalFlags([ConfigFlag]));
|
|
56
|
-
const MainLive = Layer.mergeAll(Layer.provideMerge(AppLive, NodeServices.layer), CliLoggerLive);
|
|
57
83
|
/**
|
|
58
|
-
*
|
|
84
|
+
* The platform: Node's services, the application control plane over them, the
|
|
85
|
+
* invocation facts, and one colour-aware help/error formatter.
|
|
59
86
|
*
|
|
60
87
|
* @remarks
|
|
61
|
-
* `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
92
|
+
*/
|
|
93
|
+
const PlatformLive = Layer.mergeAll(AppLive, CliColor.formatterLayer(), InvocationLive).pipe(Layer.provideMerge(NodeServices.layer));
|
|
94
|
+
/** Whether a failure is a tagged error of a given tag. */
|
|
95
|
+
const isTagged = (error, tag) => typeof error === "object" && error !== null && "_tag" in error && error._tag === tag;
|
|
96
|
+
/**
|
|
97
|
+
* Render a failure that escaped a command, one line at a time, to stderr.
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* `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`.
|
|
103
|
+
*
|
|
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.
|
|
65
111
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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".
|
|
68
117
|
*/
|
|
69
|
-
const
|
|
70
|
-
|
|
118
|
+
const render = (error) => {
|
|
119
|
+
const lines = [String(error)];
|
|
71
120
|
if (isTagged(error, "ConfigValidationError")) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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;
|
|
126
|
+
};
|
|
127
|
+
NodeRuntime.runMain(CliRuntime.main(Command.run(cli, { version: VERSION }), {
|
|
128
|
+
platform: PlatformLive,
|
|
129
|
+
render,
|
|
130
|
+
helpOnUsageError: "stderr"
|
|
131
|
+
}));
|
|
83
132
|
|
|
84
133
|
//#endregion
|
|
85
134
|
export { };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { profileOwner } from "../../schemas/credentials.js";
|
|
2
2
|
import { ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
3
|
-
import { Effect, Option } from "effect";
|
|
4
|
-
import { Command, Flag } from "effect/unstable/cli";
|
|
3
|
+
import { Console, Effect, Option } from "effect";
|
|
4
|
+
import { CliError, Command, Flag } from "effect/unstable/cli";
|
|
5
5
|
import { AppDirs } from "@effected/xdg";
|
|
6
6
|
|
|
7
7
|
//#region src/cli/commands/credentials.ts
|
|
@@ -50,6 +50,16 @@ const saveWhereRead = (file, value) => Effect.gen(function* () {
|
|
|
50
50
|
yield* file.write(value, existing.path);
|
|
51
51
|
return existing.path;
|
|
52
52
|
});
|
|
53
|
+
/**
|
|
54
|
+
* Refuse an invocation the user got wrong.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* Every refusal in this module is a usage error — the flags asked for
|
|
58
|
+
* something invalid — so it fails with `CliError.UserError` and exits 64.
|
|
59
|
+
* `Command.runWith` prints the message once, on stderr. The messages never
|
|
60
|
+
* echo a supplied value, for the reason given in {@link createHandler}.
|
|
61
|
+
*/
|
|
62
|
+
const refuse = (message) => Effect.fail(new CliError.UserError({ cause: message }));
|
|
53
63
|
/** How a profile's `github_token` is sourced, for display. Never a value. */
|
|
54
64
|
const describeSource = (source) => "op" in source ? `op ${source.op}` : `env ${source.env}`;
|
|
55
65
|
/**
|
|
@@ -72,35 +82,14 @@ const createHandler = (input) => Effect.gen(function* () {
|
|
|
72
82
|
const { profile, op, env, username, org } = input;
|
|
73
83
|
yield* (yield* AppDirs).ensureConfig;
|
|
74
84
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
75
|
-
if (op !== void 0 && env !== void 0)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (op !== void 0 && !op.startsWith("op://")) {
|
|
84
|
-
yield* Effect.logError("--op must be a 1Password reference starting with \"op://\". Value not echoed.");
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (looksLikeSecret(op ?? env ?? "")) {
|
|
88
|
-
yield* Effect.logError("That looks like a credential value, not a reference. This command stores references only — pass --op \"op://Vault/item/field\" or --env VAR_NAME. Value not echoed.");
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (username !== void 0 && org !== void 0) {
|
|
92
|
-
yield* Effect.logError("Provide exactly one of --username or --org, not both.");
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (username === void 0 && org === void 0) {
|
|
96
|
-
yield* Effect.logError("Provide --username <you> for a personal account or --org <name> for an organization. It is what the profile acts as, and it decides which settings are even valid.");
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
85
|
+
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.");
|
|
87
|
+
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.");
|
|
89
|
+
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.");
|
|
99
91
|
const existing = yield* credentialsFile.loadOrDefault(EMPTY);
|
|
100
|
-
if (existing.profiles[profile] !== void 0) {
|
|
101
|
-
yield* Effect.logError(`Profile '${profile}' already exists. Delete it first.`);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
92
|
+
if (existing.profiles[profile] !== void 0) return yield* refuse(`Profile '${profile}' already exists. Delete it first.`);
|
|
104
93
|
const github_token = op !== void 0 ? { op } : { env };
|
|
105
94
|
const actsAs = username !== void 0 ? {
|
|
106
95
|
username,
|
|
@@ -113,7 +102,7 @@ const createHandler = (input) => Effect.gen(function* () {
|
|
|
113
102
|
...existing.profiles,
|
|
114
103
|
[profile]: actsAs
|
|
115
104
|
} });
|
|
116
|
-
yield*
|
|
105
|
+
yield* Console.log(`Created profile '${profile}' (${username !== void 0 ? `username: ${username}` : `org: ${org}`}, github_token: ${describeSource(github_token)}) in ${path}.`);
|
|
117
106
|
});
|
|
118
107
|
const createCommand = Command.make("create", {
|
|
119
108
|
profile: profileFlag,
|
|
@@ -141,14 +130,14 @@ const createCommand = Command.make("create", {
|
|
|
141
130
|
const listHandler = Effect.gen(function* () {
|
|
142
131
|
const credentials = yield* (yield* ReposetsCredentialsFile).loadOrDefault(EMPTY);
|
|
143
132
|
if (Object.keys(credentials.profiles).length === 0) {
|
|
144
|
-
yield*
|
|
133
|
+
yield* Console.log("No credential profiles configured.");
|
|
145
134
|
return;
|
|
146
135
|
}
|
|
147
136
|
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
148
137
|
const { owner, ownerType } = profileOwner(profile);
|
|
149
|
-
yield*
|
|
150
|
-
yield*
|
|
151
|
-
yield*
|
|
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)}`);
|
|
152
141
|
for (const kind of [
|
|
153
142
|
"op",
|
|
154
143
|
"env",
|
|
@@ -156,9 +145,9 @@ const listHandler = Effect.gen(function* () {
|
|
|
156
145
|
]) {
|
|
157
146
|
const entries = profile.resolve?.[kind];
|
|
158
147
|
if (entries === void 0) continue;
|
|
159
|
-
for (const [label, reference] of Object.entries(entries)) yield*
|
|
148
|
+
for (const [label, reference] of Object.entries(entries)) yield* Console.log(` resolve.${kind}.${label}: ${reference}`);
|
|
160
149
|
}
|
|
161
|
-
yield*
|
|
150
|
+
yield* Console.log("");
|
|
162
151
|
}
|
|
163
152
|
});
|
|
164
153
|
const listCommand = Command.make("list", {}, () => listHandler).pipe(Command.withDescription("List credential profiles and the references they hold"));
|
|
@@ -171,13 +160,10 @@ const deleteHandler = (profile) => Effect.gen(function* () {
|
|
|
171
160
|
yield* (yield* AppDirs).ensureConfig;
|
|
172
161
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
173
162
|
const credentials = yield* credentialsFile.loadOrDefault(EMPTY);
|
|
174
|
-
if (credentials.profiles[profile] === void 0) {
|
|
175
|
-
yield* Effect.logError(`Profile '${profile}' not found.`);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
163
|
+
if (credentials.profiles[profile] === void 0) return yield* refuse(`Profile '${profile}' not found.`);
|
|
178
164
|
const { [profile]: _removed, ...remaining } = credentials.profiles;
|
|
179
165
|
const path = yield* saveWhereRead(credentialsFile, { profiles: remaining });
|
|
180
|
-
yield*
|
|
166
|
+
yield* Console.log(`Deleted profile '${profile}' from ${path}.`);
|
|
181
167
|
});
|
|
182
168
|
const deleteCommand = Command.make("delete", { profile: profileFlag }, ({ profile }) => deleteHandler(profile)).pipe(Command.withDescription("Remove a credential profile"));
|
|
183
169
|
/**
|
package/cli/commands/doctor.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { SettingsGroupSchema } from "../../schemas/config.js";
|
|
2
2
|
import { profileOwner } from "../../schemas/credentials.js";
|
|
3
3
|
import { CONFIG_FILENAME, CREDENTIALS_FILENAME, ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
4
|
-
import {
|
|
4
|
+
import { Invocation } from "../../services/Invocation.js";
|
|
5
5
|
import { OP_SERVICE_ACCOUNT_TOKEN, OnePasswordClientLive } from "../../services/OnePasswordClient.js";
|
|
6
6
|
import { CredentialResolver, CredentialResolverLive } from "../../services/CredentialResolver.js";
|
|
7
7
|
import { ConfigFlag } from "../flags.js";
|
|
8
|
-
import { Effect, FileSystem, Layer, Option, Path, Result } from "effect";
|
|
8
|
+
import { Config, Console, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
|
|
9
|
+
import { SchemaIssueRenderer } from "@effected/cli";
|
|
9
10
|
import { Command } from "effect/unstable/cli";
|
|
10
11
|
import { AppDirs } from "@effected/xdg";
|
|
11
|
-
import { cwd, env } from "node:process";
|
|
12
12
|
import { GitHubClient } from "@effected/github";
|
|
13
13
|
import { Toml } from "@effected/toml";
|
|
14
14
|
|
|
@@ -51,7 +51,7 @@ const KNOWN_GROUP_KEYS = /* @__PURE__ */ new Set([
|
|
|
51
51
|
const REMOVED_KEYS = {
|
|
52
52
|
owner: "removed in 1.0 — the owner now belongs to the credential profile, which declares `username` or `org`",
|
|
53
53
|
"owner (in groups)": "removed in 1.0 — a group's owner comes from the profile named by its `credentials`, so a group cannot override it",
|
|
54
|
-
log_level: "removed in 1.0 — output is one level; use --debug for diagnostics, or
|
|
54
|
+
log_level: "removed in 1.0 — output is one level; use --debug for diagnostics, or redirect stdout to quieten a run"
|
|
55
55
|
};
|
|
56
56
|
/**
|
|
57
57
|
* The settings fields the schema names, derived rather than listed.
|
|
@@ -202,10 +202,10 @@ const findClosestMatch = (key, known) => {
|
|
|
202
202
|
* `appDirs.dirs.config` rather than `ensureConfig`: the latter *creates* the
|
|
203
203
|
* directory, and a read-only diagnostic must not.
|
|
204
204
|
*/
|
|
205
|
-
const locateConfig = (fs, path, appDirs, configFlag) => Effect.gen(function* () {
|
|
205
|
+
const locateConfig = (fs, path, appDirs, configFlag, cwd) => Effect.gen(function* () {
|
|
206
206
|
const candidates = [];
|
|
207
207
|
if (configFlag !== void 0) candidates.push(configFlag, path.join(configFlag, CONFIG_FILENAME));
|
|
208
|
-
let dir = cwd
|
|
208
|
+
let dir = cwd;
|
|
209
209
|
for (;;) {
|
|
210
210
|
candidates.push(path.join(dir, CONFIG_FILENAME));
|
|
211
211
|
const parent = path.dirname(dir);
|
|
@@ -233,6 +233,12 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
|
|
|
233
233
|
* *names* — and whether the 1Password service account variable is present. It
|
|
234
234
|
* never resolves a reference, so there is no value in scope to leak.
|
|
235
235
|
*
|
|
236
|
+
* The report — failing sections included — is the command's output and goes to
|
|
237
|
+
* stdout in one piece, so `reposets doctor > report.txt` captures all of it.
|
|
238
|
+
* Only the three early stops (no config, unreadable, unparseable) are
|
|
239
|
+
* diagnostics on stderr. `doctor` always exits 0: it describes, it does not
|
|
240
|
+
* gate — `validate` is the gate.
|
|
241
|
+
*
|
|
236
242
|
* @public
|
|
237
243
|
*/
|
|
238
244
|
/**
|
|
@@ -280,16 +286,17 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
280
286
|
const fs = yield* FileSystem.FileSystem;
|
|
281
287
|
const path = yield* Path.Path;
|
|
282
288
|
const appDirs = yield* AppDirs;
|
|
289
|
+
const invocation = yield* Invocation;
|
|
283
290
|
const discovered = yield* configFile.discover.pipe(Effect.result);
|
|
284
291
|
const sources = discovered._tag === "Success" ? discovered.success : [];
|
|
285
292
|
const schemaValid = discovered._tag === "Success" && sources.length > 0;
|
|
286
293
|
const validationIssue = discovered._tag === "Failure" ? discovered.failure.issue : void 0;
|
|
287
|
-
const configPath = sources[0]?.path ?? (yield* locateConfig(fs, path, appDirs, configFlag));
|
|
294
|
+
const configPath = sources[0]?.path ?? (yield* locateConfig(fs, path, appDirs, configFlag, invocation.cwd));
|
|
288
295
|
if (configPath === void 0) {
|
|
289
296
|
yield* Effect.logError("No config found. Run 'reposets init' to create one.");
|
|
290
297
|
return;
|
|
291
298
|
}
|
|
292
|
-
yield*
|
|
299
|
+
yield* Console.log(`Config: ${configPath}`);
|
|
293
300
|
const text = yield* fs.readFileString(configPath).pipe(Effect.option);
|
|
294
301
|
if (text._tag === "None") {
|
|
295
302
|
yield* Effect.logError(`Could not read ${configPath}`);
|
|
@@ -308,13 +315,13 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
308
315
|
if (known.has(key)) continue;
|
|
309
316
|
const removedFrom = REMOVED_KEYS[where === "" ? key : `${key} (in groups)`] ?? REMOVED_KEYS[key];
|
|
310
317
|
if (removedFrom !== void 0) {
|
|
311
|
-
yield*
|
|
318
|
+
yield* Console.log(`Warning: '${key}'${where} ${removedFrom}`);
|
|
312
319
|
warnings += 1;
|
|
313
320
|
continue;
|
|
314
321
|
}
|
|
315
322
|
const suggestion = findClosestMatch(key, known);
|
|
316
323
|
const hint = suggestion === void 0 ? "" : ` — did you mean '${suggestion}'?`;
|
|
317
|
-
yield*
|
|
324
|
+
yield* Console.log(`Warning: unknown key '${key}'${where}${hint}`);
|
|
318
325
|
warnings += 1;
|
|
319
326
|
}
|
|
320
327
|
});
|
|
@@ -326,7 +333,7 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
326
333
|
if (KNOWN_SETTINGS_FIELDS.has(field)) continue;
|
|
327
334
|
const suggestion = findClosestMatch(field, KNOWN_SETTINGS_FIELDS);
|
|
328
335
|
const hint = suggestion === void 0 ? " — it will be sent to GitHub as-is, which ignores fields it does not recognise" : ` — did you mean '${suggestion}'?`;
|
|
329
|
-
yield*
|
|
336
|
+
yield* Console.log(`Warning: unrecognised setting '${field}' in settings.${groupName}${hint}`);
|
|
330
337
|
warnings += 1;
|
|
331
338
|
}
|
|
332
339
|
}
|
|
@@ -341,21 +348,21 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
341
348
|
yield* checkKeys(cleanup.secrets, KNOWN_CLEANUP_SECRETS_KEYS, ` in ${prefix}.secrets`);
|
|
342
349
|
yield* checkKeys(cleanup.variables, KNOWN_CLEANUP_VARIABLES_KEYS, ` in ${prefix}.variables`);
|
|
343
350
|
}
|
|
344
|
-
if (schemaValid) yield*
|
|
351
|
+
if (schemaValid) yield* Console.log("Schema validation: passed");
|
|
345
352
|
else {
|
|
346
|
-
yield*
|
|
347
|
-
for (const line of
|
|
353
|
+
yield* Console.log("Schema validation: FAILED — these values are rejected, not ignored");
|
|
354
|
+
for (const line of SchemaIssueRenderer.render(validationIssue)) yield* Console.log(` ${line}`);
|
|
348
355
|
}
|
|
349
|
-
yield*
|
|
356
|
+
yield* Console.log("");
|
|
350
357
|
const loaded = yield* credentialsFile.loadOrDefault({ profiles: {} }).pipe(Effect.result);
|
|
351
358
|
const credentials = loaded._tag === "Success" ? loaded.success : { profiles: {} };
|
|
352
359
|
const profileNames = Object.keys(credentials.profiles);
|
|
353
360
|
if (loaded._tag === "Failure") {
|
|
354
|
-
yield*
|
|
355
|
-
yield*
|
|
356
|
-
} else if (profileNames.length === 0) yield*
|
|
361
|
+
yield* Console.log(`Credentials: FAILED to load — ${String(loaded.failure.message ?? loaded.failure)}`);
|
|
362
|
+
yield* Console.log(" Unknown keys are rejected. `op_service_account_token` was removed — that token now comes from OP_SERVICE_ACCOUNT_TOKEN in the environment.");
|
|
363
|
+
} else if (profileNames.length === 0) yield* Console.log("Credentials: no profiles configured");
|
|
357
364
|
else {
|
|
358
|
-
yield*
|
|
365
|
+
yield* Console.log(`Credentials: ${profileNames.length} profile(s)`);
|
|
359
366
|
let usesOnePassword = false;
|
|
360
367
|
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
361
368
|
const token = "op" in profile.github_token ? `op ${profile.github_token.op}` : `env ${profile.github_token.env}`;
|
|
@@ -373,45 +380,45 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
373
380
|
}
|
|
374
381
|
}
|
|
375
382
|
const resolveSummary = sections.length === 0 ? "" : `, resolve ${sections.join(" ")}`;
|
|
376
|
-
yield*
|
|
383
|
+
yield* Console.log(` [${name}] github_token: ${token}${resolveSummary}`);
|
|
377
384
|
}
|
|
378
|
-
const hasServiceAccount =
|
|
379
|
-
if (usesOnePassword) yield*
|
|
385
|
+
const hasServiceAccount = Option.isSome(yield* Config.option(Config.String(OP_SERVICE_ACCOUNT_TOKEN)).pipe(Effect.orElseSucceed(() => Option.none())));
|
|
386
|
+
if (usesOnePassword) yield* Console.log(hasServiceAccount ? ` ${OP_SERVICE_ACCOUNT_TOKEN}: set` : ` ${OP_SERVICE_ACCOUNT_TOKEN}: NOT SET — op:// references cannot be resolved`);
|
|
380
387
|
}
|
|
381
|
-
yield*
|
|
382
|
-
yield*
|
|
388
|
+
yield* Console.log("");
|
|
389
|
+
yield* Console.log(`Version: ${invocation.version}`);
|
|
383
390
|
const credentialDiscovery = yield* credentialsFile.discover.pipe(Effect.result);
|
|
384
391
|
const credentialSources = credentialDiscovery._tag === "Success" ? credentialDiscovery.success : [];
|
|
385
392
|
const credentialsLine = credentialDiscovery._tag === "Failure" ? "(failed to load — the error above names the file)" : credentialSources.length === 0 ? `${path.join(appDirs.dirs.config, CREDENTIALS_FILENAME)} (not created yet)` : credentialSources[0]?.path ?? "";
|
|
386
|
-
yield*
|
|
387
|
-
yield*
|
|
388
|
-
yield*
|
|
393
|
+
yield* Console.log(`Credentials file: ${credentialsLine}`);
|
|
394
|
+
yield* Console.log(`State database: ${path.join(appDirs.dirs.state, "store.db")}`);
|
|
395
|
+
yield* Console.log("");
|
|
389
396
|
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
390
397
|
const resolverLayer = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
|
|
391
398
|
const resolved = yield* Effect.gen(function* () {
|
|
392
399
|
return yield* (yield* CredentialResolver).resolveGitHubToken(profile);
|
|
393
400
|
}).pipe(Effect.provide(resolverLayer), Effect.result);
|
|
394
401
|
if (resolved._tag === "Failure") {
|
|
395
|
-
yield*
|
|
402
|
+
yield* Console.log(`Token [${name}]: could not resolve — ${String(resolved.failure)}`);
|
|
396
403
|
continue;
|
|
397
404
|
}
|
|
398
405
|
const identity = yield* Effect.gen(function* () {
|
|
399
406
|
return yield* (yield* GitHubClient).request("GET /user", {});
|
|
400
407
|
}).pipe(Effect.provide(GitHubClient.layerFromToken({ token: resolved.success })), Effect.result);
|
|
401
408
|
if (identity._tag !== "Success") {
|
|
402
|
-
yield*
|
|
409
|
+
yield* Console.log(`Token [${name}]: resolved but REJECTED by GitHub — ${String(identity.failure)}`);
|
|
403
410
|
continue;
|
|
404
411
|
}
|
|
405
412
|
const line = describeIdentity(name, profile, identity.success.login);
|
|
406
|
-
yield*
|
|
413
|
+
yield* Console.log(line.text);
|
|
407
414
|
}
|
|
408
|
-
yield*
|
|
409
|
-
yield*
|
|
410
|
-
for (const [scope, permission, why] of REQUIRED_PERMISSIONS) yield*
|
|
411
|
-
yield*
|
|
412
|
-
yield*
|
|
413
|
-
yield*
|
|
414
|
-
yield*
|
|
415
|
+
yield* Console.log("");
|
|
416
|
+
yield* Console.log("Required fine-grained token permissions:");
|
|
417
|
+
for (const [scope, permission, why] of REQUIRED_PERMISSIONS) yield* Console.log(` ${scope} > ${permission} — ${why}`);
|
|
418
|
+
yield* Console.log(" (Metadata: Read is mandatory and granted automatically)");
|
|
419
|
+
yield* Console.log(" These are NOT verified — GitHub does not expose a fine-grained token's own scopes.");
|
|
420
|
+
yield* Console.log("");
|
|
421
|
+
yield* Console.log(warnings === 0 ? "No unknown keys detected." : `${warnings} warning(s) found.`);
|
|
415
422
|
});
|
|
416
423
|
/**
|
|
417
424
|
* `reposets doctor`.
|
package/cli/commands/history.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SyncJournal } from "../../store/SyncJournal.js";
|
|
2
|
-
import { Effect } from "effect";
|
|
3
|
-
import { Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { CliError, Command, Flag } from "effect/unstable/cli";
|
|
4
4
|
|
|
5
5
|
//#region src/cli/commands/history.ts
|
|
6
6
|
const limitFlag = Flag.Int("limit").pipe(Flag.withDefault(20), Flag.withDescription("How many runs to show, newest first"));
|
|
@@ -42,7 +42,7 @@ const pad = (value, width) => value.padEnd(width);
|
|
|
42
42
|
*
|
|
43
43
|
* @remarks
|
|
44
44
|
* Computed rather than fixed because group names and repository names are
|
|
45
|
-
* user-supplied and unbounded.
|
|
45
|
+
* user-supplied and unbounded. Each line is written verbatim to stdout, so
|
|
46
46
|
* alignment computed here survives to the terminal.
|
|
47
47
|
*/
|
|
48
48
|
const renderRows = (summaries) => {
|
|
@@ -87,14 +87,11 @@ const historyHandler = (input) => Effect.gen(function* () {
|
|
|
87
87
|
...input.repo === void 0 ? {} : { repo: input.repo }
|
|
88
88
|
});
|
|
89
89
|
if (summaries.length === 0) {
|
|
90
|
-
yield*
|
|
90
|
+
yield* Console.log(input.repo === void 0 ? "No runs recorded yet. The journal fills in as you sync." : `No recorded run has touched ${input.repo}.`);
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
|
-
for (const line of renderRows(summaries)) yield*
|
|
94
|
-
if (summaries.length === input.limit) {
|
|
95
|
-
yield* Effect.log("");
|
|
96
|
-
yield* Effect.log(`Showing the most recent ${input.limit}. Pass --limit for more.`);
|
|
97
|
-
}
|
|
93
|
+
for (const line of renderRows(summaries)) yield* Console.log(line);
|
|
94
|
+
if (summaries.length === input.limit) yield* Effect.log(`Showing the most recent ${input.limit}. Pass --limit for more.`);
|
|
98
95
|
});
|
|
99
96
|
/**
|
|
100
97
|
* The present-tense form of a stored action, for a dry run.
|
|
@@ -118,32 +115,22 @@ const wouldForm = (action) => action === "created" ? "create" : action === "upda
|
|
|
118
115
|
const showHandler = (prefix) => Effect.gen(function* () {
|
|
119
116
|
const journal = yield* SyncJournal;
|
|
120
117
|
const matches = (yield* journal.history({ limit: 1e3 }).pipe(Effect.orElseSucceed(() => []))).filter((run) => run.id.startsWith(prefix));
|
|
121
|
-
if (matches.length === 0) {
|
|
122
|
-
yield* Effect.logError(`No run matches '${prefix}'.`);
|
|
123
|
-
yield* Effect.sync(() => {
|
|
124
|
-
process.exitCode = 1;
|
|
125
|
-
});
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
118
|
+
if (matches.length === 0) return yield* Effect.fail(new CliError.UserError({ cause: `No run matches '${prefix}'.` }));
|
|
128
119
|
if (matches.length > 1) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
yield* Effect.sync(() => {
|
|
132
|
-
process.exitCode = 1;
|
|
133
|
-
});
|
|
134
|
-
return;
|
|
120
|
+
const candidates = matches.slice(0, 5).map((run) => ` ${run.id} ${formatWhen(run.startedAt)}`);
|
|
121
|
+
return yield* Effect.fail(new CliError.UserError({ cause: [`'${prefix}' matches ${matches.length} runs. Use more of the id:`, ...candidates].join("\n") }));
|
|
135
122
|
}
|
|
136
123
|
const run = matches[0];
|
|
137
124
|
const changes = yield* journal.changesFor(run.id).pipe(Effect.orElseSucceed(() => []));
|
|
138
|
-
yield*
|
|
139
|
-
yield*
|
|
140
|
-
yield*
|
|
125
|
+
yield* Console.log(`run ${run.id}`);
|
|
126
|
+
yield* Console.log(` ${formatWhen(run.startedAt)} · ${run.dryRun === 1 ? "dry-run" : "applied"} · ${run.outcome ?? "unfinished"} · ${formatDuration(run)}`);
|
|
127
|
+
yield* Console.log("");
|
|
141
128
|
if (run.error !== null && run.error !== "") {
|
|
142
|
-
yield*
|
|
143
|
-
yield*
|
|
129
|
+
yield* Console.log(` error: ${run.error}`);
|
|
130
|
+
yield* Console.log("");
|
|
144
131
|
}
|
|
145
132
|
if (changes.length === 0) {
|
|
146
|
-
yield*
|
|
133
|
+
yield* Console.log(run.error !== null && run.error !== "" ? " No resources changed." : " No resources changed (nothing to do).");
|
|
147
134
|
return;
|
|
148
135
|
}
|
|
149
136
|
const byRepo = /* @__PURE__ */ new Map();
|
|
@@ -153,12 +140,12 @@ const showHandler = (prefix) => Effect.gen(function* () {
|
|
|
153
140
|
else existing.push(change);
|
|
154
141
|
}
|
|
155
142
|
for (const [repo, records] of byRepo) {
|
|
156
|
-
yield*
|
|
143
|
+
yield* Console.log(` ${repo}`);
|
|
157
144
|
for (const record of records) {
|
|
158
145
|
const detail = record.detail === void 0 || record.detail === null ? "" : ` — ${record.detail}`;
|
|
159
146
|
const what = record.kind === record.name ? record.kind : `${record.kind} ${record.name}`;
|
|
160
147
|
const action = run.dryRun === 1 ? `would ${wouldForm(record.action)}` : record.action;
|
|
161
|
-
yield*
|
|
148
|
+
yield* Console.log(` ${action.padEnd(18)} ${what}${detail}`);
|
|
162
149
|
}
|
|
163
150
|
}
|
|
164
151
|
});
|
|
@@ -174,13 +161,13 @@ const showHandler = (prefix) => Effect.gen(function* () {
|
|
|
174
161
|
*/
|
|
175
162
|
const pruneHandler = (keep) => Effect.gen(function* () {
|
|
176
163
|
const removed = yield* (yield* SyncJournal).prune(keep).pipe(Effect.orElseSucceed(() => 0));
|
|
177
|
-
yield*
|
|
178
|
-
yield*
|
|
164
|
+
yield* Console.log(removed === 0 ? `Nothing to prune; ${keep} or fewer runs are recorded.` : `Pruned ${removed} run${removed === 1 ? "" : "s"}, keeping the newest ${keep}.`);
|
|
165
|
+
yield* Console.log("Applied state and the cache are untouched — drift detection still works.");
|
|
179
166
|
});
|
|
180
167
|
const clearHandler = () => Effect.gen(function* () {
|
|
181
168
|
const removed = yield* (yield* SyncJournal).clear().pipe(Effect.orElseSucceed(() => 0));
|
|
182
|
-
yield*
|
|
183
|
-
yield*
|
|
169
|
+
yield* Console.log(`Cleared ${removed} run${removed === 1 ? "" : "s"} from the journal.`);
|
|
170
|
+
yield* Console.log("Applied state and the cache are untouched — drift detection still works.");
|
|
184
171
|
});
|
|
185
172
|
/**
|
|
186
173
|
* `reposets history`.
|
package/cli/commands/init.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Invocation } from "../../services/Invocation.js";
|
|
3
|
+
import { Console, Effect, FileSystem, Path } from "effect";
|
|
4
|
+
import { CliExit } from "@effected/cli";
|
|
3
5
|
import { Command, Flag } from "effect/unstable/cli";
|
|
4
6
|
import { AppDirs } from "@effected/xdg";
|
|
5
|
-
import { cwd } from "node:process";
|
|
6
7
|
|
|
7
8
|
//#region src/cli/commands/init.ts
|
|
8
9
|
const projectFlag = Flag.Boolean("project").pipe(Flag.withDefault(false), Flag.withDescription("Scaffold into the current directory instead of the XDG config directory"));
|
|
@@ -111,32 +112,41 @@ const CREDENTIALS_TEMPLATE = `# reposets credentials
|
|
|
111
112
|
* references and so is not catastrophic to commit, but it still names a
|
|
112
113
|
* person's vault layout, and the habit is worth keeping.
|
|
113
114
|
*
|
|
115
|
+
* What was created or found goes to stdout; the next-steps guidance to stderr.
|
|
116
|
+
* A file that could not be written is reported on stderr and exits 1.
|
|
117
|
+
*
|
|
114
118
|
* @public
|
|
115
119
|
*/
|
|
116
120
|
const initHandler = (project) => Effect.gen(function* () {
|
|
117
121
|
const fs = yield* FileSystem.FileSystem;
|
|
118
122
|
const path = yield* Path.Path;
|
|
119
123
|
const appDirs = yield* AppDirs;
|
|
120
|
-
const targetDir = project ?
|
|
124
|
+
const targetDir = project ? (yield* Invocation).cwd : yield* appDirs.ensureConfig;
|
|
121
125
|
yield* fs.makeDirectory(targetDir, { recursive: true });
|
|
126
|
+
/**
|
|
127
|
+
* A write that fails is a real failure of this command — the user asked
|
|
128
|
+
* for a file and did not get one — so it is reported on stderr and the
|
|
129
|
+
* run exits 1 through `CliExit`. The remaining files are still attempted:
|
|
130
|
+
* one unwritable path should not hide what else did or did not happen.
|
|
131
|
+
*/
|
|
132
|
+
const failed = (target) => Effect.logError(`Could not write: ${target}`).pipe(Effect.andThen(CliExit.set(1)));
|
|
122
133
|
/** Write a file unless it is already there; report either way. */
|
|
123
134
|
const scaffold = (name, contents) => Effect.gen(function* () {
|
|
124
135
|
const target = path.join(targetDir, name);
|
|
125
136
|
if (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false))) {
|
|
126
|
-
yield*
|
|
137
|
+
yield* Console.log(`Already exists: ${target}`);
|
|
127
138
|
return;
|
|
128
139
|
}
|
|
129
|
-
yield* (yield* fs.writeFileString(target, contents).pipe(Effect.option))._tag === "Some" ?
|
|
140
|
+
yield* (yield* fs.writeFileString(target, contents).pipe(Effect.option))._tag === "Some" ? Console.log(`Created: ${target}`) : failed(target);
|
|
130
141
|
});
|
|
131
142
|
yield* scaffold(CONFIG_FILENAME, CONFIG_TEMPLATE);
|
|
132
143
|
yield* scaffold(CREDENTIALS_FILENAME, CREDENTIALS_TEMPLATE);
|
|
133
144
|
const gitignorePath = path.join(targetDir, ".gitignore");
|
|
134
145
|
const existing = yield* fs.readFileString(gitignorePath).pipe(Effect.orElseSucceed(() => void 0));
|
|
135
|
-
if (existing === void 0) {
|
|
136
|
-
|
|
137
|
-
} else if (!existing.includes("reposets.credentials.toml")) {
|
|
146
|
+
if (existing === void 0) yield* (yield* fs.writeFileString(gitignorePath, `${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some" ? Console.log(`Created .gitignore with ${CREDENTIALS_FILENAME}`) : failed(gitignorePath);
|
|
147
|
+
else if (!existing.includes("reposets.credentials.toml")) {
|
|
138
148
|
const separator = existing.endsWith("\n") ? "" : "\n";
|
|
139
|
-
|
|
149
|
+
yield* (yield* fs.writeFileString(gitignorePath, `${existing}${separator}${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some" ? Console.log(`Added ${CREDENTIALS_FILENAME} to .gitignore`) : failed(gitignorePath);
|
|
140
150
|
}
|
|
141
151
|
yield* Effect.log("");
|
|
142
152
|
yield* Effect.log("Done. Edit the config, then add a credential reference with:");
|
package/cli/commands/list.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { profileOwner } from "../../schemas/credentials.js";
|
|
2
2
|
import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
3
|
-
import { Effect } from "effect";
|
|
3
|
+
import { Console, Effect } from "effect";
|
|
4
|
+
import { CliExit } from "@effected/cli";
|
|
4
5
|
import { Command } from "effect/unstable/cli";
|
|
5
6
|
|
|
6
7
|
//#region src/cli/commands/list.ts
|
|
@@ -26,7 +27,8 @@ const scopeParts = (scopes) => {
|
|
|
26
27
|
* A reading command: it decodes the config and prints its structure, touching
|
|
27
28
|
* neither GitHub nor the store. Empty collections are omitted rather than
|
|
28
29
|
* printed as `(none)` — a group that assigns nothing should read as a short
|
|
29
|
-
* entry, not a wall of blanks.
|
|
30
|
+
* entry, not a wall of blanks. The summary is the command's output, so it is
|
|
31
|
+
* written to stdout.
|
|
30
32
|
*
|
|
31
33
|
* @public
|
|
32
34
|
*/
|
|
@@ -35,28 +37,28 @@ const listHandler = Effect.gen(function* () {
|
|
|
35
37
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
36
38
|
if ((yield* configFile.discover).length === 0) {
|
|
37
39
|
yield* Effect.logError("No config file found. Run 'reposets init' to create one.");
|
|
38
|
-
return;
|
|
40
|
+
return yield* CliExit.set(1);
|
|
39
41
|
}
|
|
40
42
|
const config = yield* configFile.load;
|
|
41
43
|
const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
|
|
42
44
|
for (const [groupName, group] of Object.entries(config.groups)) {
|
|
43
45
|
const profile = credentials.profiles[group.credentials];
|
|
44
46
|
const acts = profile === void 0 ? `credentials: ${group.credentials} — NOT FOUND` : `owner: ${profileOwner(profile).owner}, credentials: ${group.credentials}`;
|
|
45
|
-
yield*
|
|
47
|
+
yield* Console.log(`[${groupName}] (${acts})`);
|
|
46
48
|
const owner = profile === void 0 ? "(unknown)" : profileOwner(profile).owner;
|
|
47
|
-
for (const repo of group.repos) yield*
|
|
49
|
+
for (const repo of group.repos) yield* Console.log(` - ${owner}/${repo}`);
|
|
48
50
|
for (const [label, names] of [
|
|
49
51
|
["settings", group.settings],
|
|
50
52
|
["environments", group.environments],
|
|
51
53
|
["rulesets", group.rulesets],
|
|
52
54
|
["security", group.security],
|
|
53
55
|
["code_scanning", group.code_scanning]
|
|
54
|
-
]) if (names !== void 0 && names.length > 0) yield*
|
|
56
|
+
]) if (names !== void 0 && names.length > 0) yield* Console.log(` ${label}: ${names.join(", ")}`);
|
|
55
57
|
const secrets = scopeParts(group.secrets);
|
|
56
|
-
if (secrets.length > 0) yield*
|
|
58
|
+
if (secrets.length > 0) yield* Console.log(` secrets: ${secrets.join(", ")}`);
|
|
57
59
|
const variables = scopeParts(group.variables);
|
|
58
|
-
if (variables.length > 0) yield*
|
|
59
|
-
yield*
|
|
60
|
+
if (variables.length > 0) yield* Console.log(` variables: ${variables.join(", ")}`);
|
|
61
|
+
yield* Console.log("");
|
|
60
62
|
}
|
|
61
63
|
});
|
|
62
64
|
/**
|
package/cli/commands/nuke.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { Invocation } from "../../services/Invocation.js";
|
|
3
|
+
import { Console, Effect, FileSystem, Path, Stdio } from "effect";
|
|
4
|
+
import { CliExit } from "@effected/cli";
|
|
5
|
+
import { CliError, Command, Flag, Prompt } from "effect/unstable/cli";
|
|
4
6
|
import { AppDirs } from "@effected/xdg";
|
|
5
7
|
|
|
6
8
|
//#region src/cli/commands/nuke.ts
|
|
@@ -76,30 +78,24 @@ const nukeHandler = (force) => Effect.gen(function* () {
|
|
|
76
78
|
const fs = yield* FileSystem.FileSystem;
|
|
77
79
|
const path = yield* Path.Path;
|
|
78
80
|
const appDirs = yield* AppDirs;
|
|
79
|
-
const
|
|
81
|
+
const { cwd } = yield* Invocation;
|
|
82
|
+
const targets = yield* findTargets(fs, path, appDirs, cwd);
|
|
80
83
|
if (targets.length === 0) {
|
|
81
|
-
yield*
|
|
84
|
+
yield* Console.log("Nothing to remove — no reposets files found on this machine.");
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
|
-
yield*
|
|
87
|
+
yield* Console.log("This will delete:");
|
|
85
88
|
for (const target of targets) {
|
|
86
|
-
yield*
|
|
87
|
-
yield*
|
|
89
|
+
yield* Console.log(` ${target.path}`);
|
|
90
|
+
yield* Console.log(` ${target.what} — loses ${target.cost}`);
|
|
88
91
|
}
|
|
89
|
-
yield*
|
|
90
|
-
yield*
|
|
92
|
+
yield* Console.log("");
|
|
93
|
+
yield* Console.log("Nothing on GitHub is touched. Everything reposets applied stays applied.");
|
|
91
94
|
if (!force) {
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
yield* Effect.logError("Refusing: not an interactive terminal, and --force was not given.");
|
|
95
|
-
yield* Effect.sync(() => {
|
|
96
|
-
process.exitCode = 1;
|
|
97
|
-
});
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
yield* Effect.log("");
|
|
95
|
+
if (!(yield* (yield* Stdio.Stdio).stdinIsTerminal)) return yield* Effect.fail(new CliError.UserError({ cause: "Refusing: not an interactive terminal, and --force was not given." }));
|
|
96
|
+
yield* Console.log("");
|
|
101
97
|
if (!(yield* Prompt.Confirm({ message: `Delete ${targets.length} file${targets.length === 1 ? "" : "s"}?` }).pipe(Effect.orElseSucceed(() => false)))) {
|
|
102
|
-
yield*
|
|
98
|
+
yield* Console.log("Nothing was deleted.");
|
|
103
99
|
return;
|
|
104
100
|
}
|
|
105
101
|
}
|
|
@@ -111,10 +107,11 @@ const nukeHandler = (force) => Effect.gen(function* () {
|
|
|
111
107
|
continue;
|
|
112
108
|
}
|
|
113
109
|
removed += 1;
|
|
114
|
-
yield*
|
|
110
|
+
yield* Console.log(` removed ${target.path}`);
|
|
115
111
|
}
|
|
116
|
-
yield*
|
|
117
|
-
yield*
|
|
112
|
+
yield* Console.log("");
|
|
113
|
+
yield* Console.log(removed === targets.length ? `Done. ${removed} file${removed === 1 ? "" : "s"} removed.` : `Removed ${removed} of ${targets.length}; the rest are listed above.`);
|
|
114
|
+
if (removed < targets.length) yield* CliExit.set(1);
|
|
118
115
|
});
|
|
119
116
|
/**
|
|
120
117
|
* The `nuke` command.
|
package/cli/commands/sync.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
2
|
-
import { formatSchemaIssue } from "../../lib/schema-issues.js";
|
|
3
2
|
import { SyncJournal, SyncJournalLive } from "../../store/SyncJournal.js";
|
|
4
3
|
import { OnePasswordClientLive } from "../../services/OnePasswordClient.js";
|
|
5
4
|
import { CredentialResolver, CredentialResolverLive } from "../../services/CredentialResolver.js";
|
|
@@ -10,8 +9,9 @@ import { RepoCacheLive } from "../../store/RepoCache.js";
|
|
|
10
9
|
import { PHASE_NAMES } from "../../sync/phase.js";
|
|
11
10
|
import { allPhases } from "../../sync/phases/index.js";
|
|
12
11
|
import { SyncEngine, SyncEngineLive } from "../../sync/SyncEngine.js";
|
|
13
|
-
import { Effect, Layer, Option } from "effect";
|
|
14
|
-
import {
|
|
12
|
+
import { Console, Effect, Layer, Option } from "effect";
|
|
13
|
+
import { CliExit } from "@effected/cli";
|
|
14
|
+
import { CliError, Command, Flag } from "effect/unstable/cli";
|
|
15
15
|
import { CodeScanning, DeploymentEnvironment, GitHubClient, GitHubRepository, RepositorySecret, RepositorySecurity, RepositoryVariable, Ruleset, WorkflowDispatch } from "@effected/github";
|
|
16
16
|
|
|
17
17
|
//#region src/cli/commands/sync.ts
|
|
@@ -71,39 +71,18 @@ const partitionByProfile = (groups, only) => {
|
|
|
71
71
|
* @public
|
|
72
72
|
*/
|
|
73
73
|
const syncHandler = (input) => Effect.gen(function* () {
|
|
74
|
+
const badPhases = [...unknownPhases(input.only), ...unknownPhases(input.skip)];
|
|
75
|
+
if (badPhases.length > 0) return yield* Effect.fail(new CliError.UserError({ cause: `Unknown phase name(s): ${badPhases.join(", ")}. Valid phases: ${PHASE_NAMES.join(", ")}. Nothing was synced.` }));
|
|
74
76
|
const configFile = yield* ReposetsConfigFile;
|
|
75
77
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
76
|
-
const
|
|
77
|
-
if (discovered._tag === "Failure") {
|
|
78
|
-
yield* Effect.logError(String(discovered.failure));
|
|
79
|
-
const failure = discovered.failure;
|
|
80
|
-
for (const line of formatSchemaIssue(failure.issue)) yield* Effect.logError(` ${line}`);
|
|
81
|
-
if (failure.cause !== void 0) yield* Effect.logError(` ${String(failure.cause)}`);
|
|
82
|
-
yield* Effect.sync(() => {
|
|
83
|
-
process.exitCode = 1;
|
|
84
|
-
});
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
const source = discovered.success[0];
|
|
78
|
+
const source = (yield* configFile.discover)[0];
|
|
88
79
|
if (source === void 0) {
|
|
89
80
|
yield* Effect.logError("No config found. Run 'reposets init' to create one.");
|
|
90
|
-
yield*
|
|
91
|
-
process.exitCode = 1;
|
|
92
|
-
});
|
|
93
|
-
return;
|
|
81
|
+
return yield* CliExit.set(1);
|
|
94
82
|
}
|
|
95
83
|
const config = source.value;
|
|
96
84
|
const configDir = source.path.slice(0, source.path.lastIndexOf("/"));
|
|
97
85
|
const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
|
|
98
|
-
const badPhases = [...unknownPhases(input.only), ...unknownPhases(input.skip)];
|
|
99
|
-
if (badPhases.length > 0) {
|
|
100
|
-
yield* Effect.logError(`Unknown phase name(s): ${badPhases.join(", ")}. Valid phases: ${PHASE_NAMES.join(", ")}`);
|
|
101
|
-
yield* Effect.logError("Nothing was synced.");
|
|
102
|
-
yield* Effect.sync(() => {
|
|
103
|
-
process.exitCode = 1;
|
|
104
|
-
});
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
86
|
const dangling = danglingReferences(config);
|
|
108
87
|
if (dangling.length > 0) {
|
|
109
88
|
yield* Effect.logError("Config references sections that do not exist:");
|
|
@@ -113,18 +92,13 @@ const syncHandler = (input) => Effect.gen(function* () {
|
|
|
113
92
|
}
|
|
114
93
|
yield* Effect.logError("");
|
|
115
94
|
yield* Effect.logError("Nothing was synced. Fix the references or run 'reposets validate' for the full list.");
|
|
116
|
-
yield*
|
|
117
|
-
process.exitCode = 1;
|
|
118
|
-
});
|
|
119
|
-
return;
|
|
95
|
+
return yield* CliExit.set(1);
|
|
120
96
|
}
|
|
121
97
|
const partitions = partitionByProfile(config.groups, input.group);
|
|
122
98
|
if (partitions.size === 0) {
|
|
123
|
-
|
|
124
|
-
yield* Effect.
|
|
125
|
-
|
|
126
|
-
});
|
|
127
|
-
return;
|
|
99
|
+
if (input.group !== void 0) return yield* Effect.fail(new CliError.UserError({ cause: `No group named '${input.group}'. Configured: ${Object.keys(config.groups).join(", ") || "none"}` }));
|
|
100
|
+
yield* Effect.logError("No groups configured. Add a [groups.<name>] section to sync anything.");
|
|
101
|
+
return yield* CliExit.set(1);
|
|
128
102
|
}
|
|
129
103
|
const resolverLayer = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
|
|
130
104
|
const loggerLayer = SyncLoggerLive({
|
|
@@ -196,10 +170,8 @@ const syncHandler = (input) => Effect.gen(function* () {
|
|
|
196
170
|
errors
|
|
197
171
|
};
|
|
198
172
|
}).pipe(Effect.provide(sharedLayer));
|
|
199
|
-
yield*
|
|
200
|
-
if (report.errors > 0 || input.failOnDrift && report.drifted > 0) yield*
|
|
201
|
-
process.exitCode = 1;
|
|
202
|
-
});
|
|
173
|
+
yield* Console.log(`${report.repos} repo(s), ${report.changes} change(s), ${report.drifted} drifted, ${report.errors} error(s)`);
|
|
174
|
+
if (report.errors > 0 || input.failOnDrift && report.drifted > 0) yield* CliExit.set(1);
|
|
203
175
|
});
|
|
204
176
|
/**
|
|
205
177
|
* The `sync` command.
|
package/cli/commands/validate.js
CHANGED
|
@@ -2,7 +2,8 @@ import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/Conf
|
|
|
2
2
|
import { danglingReferences } from "../../lib/config-refs.js";
|
|
3
3
|
import { undefinedCredentialLabels } from "../../lib/credential-labels.js";
|
|
4
4
|
import { orgOnlyViolations } from "../../lib/org-only.js";
|
|
5
|
-
import { Effect } from "effect";
|
|
5
|
+
import { Console, Effect } from "effect";
|
|
6
|
+
import { CliExit } from "@effected/cli";
|
|
6
7
|
import { Command } from "effect/unstable/cli";
|
|
7
8
|
|
|
8
9
|
//#region src/cli/commands/validate.ts
|
|
@@ -18,9 +19,14 @@ import { Command } from "effect/unstable/cli";
|
|
|
18
19
|
* The handler simply *requires* `ReposetsConfigFile`; how it gets built from
|
|
19
20
|
* `--config` is the root command's business, not this command's.
|
|
20
21
|
*
|
|
22
|
+
* The success result goes to stdout. A reference or credential problem is a
|
|
23
|
+
* finding, not a failure: the details go to stderr and the command succeeds
|
|
24
|
+
* with `CliExit.set(1)`. A file that does not decode fails `discover`, and the
|
|
25
|
+
* entrypoint's renderer prints the issue lines and exits `1`.
|
|
26
|
+
*
|
|
21
27
|
* @public
|
|
22
28
|
*/
|
|
23
|
-
const
|
|
29
|
+
const validateHandler = Effect.gen(function* () {
|
|
24
30
|
const configFile = yield* ReposetsConfigFile;
|
|
25
31
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
26
32
|
const sources = yield* configFile.discover;
|
|
@@ -33,10 +39,7 @@ const validateCommand = Command.make("validate", {}, () => Effect.gen(function*
|
|
|
33
39
|
const defined = ref.defined.length === 0 ? "none defined" : `defined: ${ref.defined.join(", ")}`;
|
|
34
40
|
yield* Effect.logError(` ${ref.where}: '${ref.name}' does not exist — ${defined}`);
|
|
35
41
|
}
|
|
36
|
-
yield*
|
|
37
|
-
process.exitCode = 1;
|
|
38
|
-
});
|
|
39
|
-
return;
|
|
42
|
+
return yield* CliExit.set(1);
|
|
40
43
|
}
|
|
41
44
|
const labels = undefinedCredentialLabels(value, credentials);
|
|
42
45
|
const violations = orgOnlyViolations(value, credentials);
|
|
@@ -45,25 +48,25 @@ const validateCommand = Command.make("validate", {}, () => Effect.gen(function*
|
|
|
45
48
|
for (const label of labels) yield* Effect.logError(` [${label.group}] ${label.where}: credential label '${label.label}' is not declared in profile '${label.profile}'`);
|
|
46
49
|
yield* Effect.logError("");
|
|
47
50
|
yield* Effect.logError("Add it to that profile's [resolve] section in reposets.credentials.toml, or correct the name.");
|
|
48
|
-
yield*
|
|
49
|
-
process.exitCode = 1;
|
|
50
|
-
});
|
|
51
|
-
return;
|
|
51
|
+
return yield* CliExit.set(1);
|
|
52
52
|
}
|
|
53
53
|
if (violations.length > 0) {
|
|
54
54
|
yield* Effect.logError(`Invalid: ${sources[0]?.path ?? "config"}`);
|
|
55
55
|
for (const violation of violations) yield* Effect.logError(` [${violation.group}] ${violation.where}: ${violation.detail}, but profile '${violation.profile}' is a personal account`);
|
|
56
56
|
yield* Effect.logError("");
|
|
57
57
|
yield* Effect.logError("Either move these repositories to a profile declaring `org`, or drop the settings.");
|
|
58
|
-
yield*
|
|
59
|
-
process.exitCode = 1;
|
|
60
|
-
});
|
|
61
|
-
return;
|
|
58
|
+
return yield* CliExit.set(1);
|
|
62
59
|
}
|
|
63
60
|
const groups = Object.keys(value.groups).length;
|
|
64
|
-
yield*
|
|
65
|
-
yield*
|
|
66
|
-
})
|
|
61
|
+
yield* Console.log(`Valid: ${sources[0]?.path ?? "config"}`);
|
|
62
|
+
yield* Console.log(` groups: ${groups === 0 ? "none declared yet" : String(groups)}`);
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* The `validate` command.
|
|
66
|
+
*
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
const validateCommand = Command.make("validate", {}, () => validateHandler).pipe(Command.withDescription("Validate reposets.config.toml against the schema"));
|
|
67
70
|
|
|
68
71
|
//#endregion
|
|
69
|
-
export { validateCommand };
|
|
72
|
+
export { validateCommand, validateHandler };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reposets",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "CLI tool to sync GitHub repo settings, secrets and rulesets across personal repositories",
|
|
6
6
|
"keywords": [
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@1password/sdk": "^0.5.0",
|
|
51
51
|
"@effect/platform-node": "4.0.0-rc.117",
|
|
52
52
|
"@effected/app": "^0.18.0",
|
|
53
|
+
"@effected/cli": "^0.9.0",
|
|
53
54
|
"@effected/config-file": "^0.12.0",
|
|
54
55
|
"@effected/github": "^0.13.0",
|
|
55
56
|
"@effected/store": "^0.10.0",
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { OnePasswordClient } from "./OnePasswordClient.js";
|
|
2
|
-
import { Context, Data, Effect, Layer, Redacted } from "effect";
|
|
3
|
-
import { env } from "node:process";
|
|
2
|
+
import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
|
|
4
3
|
import { readFileSync } from "node:fs";
|
|
5
4
|
import { isAbsolute, resolve } from "node:path";
|
|
6
5
|
|
|
@@ -23,15 +22,28 @@ var ResolveError = class extends Data.TaggedError("ResolveError") {
|
|
|
23
22
|
return `Failed to resolve '${this.label}' from ${this.source}: ${this.reason}`;
|
|
24
23
|
}
|
|
25
24
|
};
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Read an environment variable, failing in terms of its name.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Read through `Config`, so the value comes from the ambient `ConfigProvider` —
|
|
30
|
+
* the process environment in the shipped bin, an explicit record in a test —
|
|
31
|
+
* and never from `process` directly. The provider treats an empty variable as
|
|
32
|
+
* absent, so `FOO=""` fails as "not set" rather than resolving to an empty
|
|
33
|
+
* credential that would only fail later, against GitHub, with a worse message.
|
|
34
|
+
*/
|
|
35
|
+
const fromEnv = (label, variable) => Config.option(Config.String(variable)).pipe(Effect.mapError((error) => new ResolveError({
|
|
36
|
+
label,
|
|
37
|
+
source: "env",
|
|
38
|
+
reason: `could not read ${variable}: ${error.message}`
|
|
39
|
+
})), Effect.flatMap(Option.match({
|
|
40
|
+
onNone: () => Effect.fail(new ResolveError({
|
|
30
41
|
label,
|
|
31
42
|
source: "env",
|
|
32
43
|
reason: `environment variable ${variable} is not set`
|
|
33
|
-
}))
|
|
34
|
-
})
|
|
44
|
+
})),
|
|
45
|
+
onSome: (value) => Effect.succeed(Redacted.make(value, { label }))
|
|
46
|
+
})));
|
|
35
47
|
/** Read a file, failing in terms of its path. */
|
|
36
48
|
const fromFile = (label, filePath, basePath) => Effect.try({
|
|
37
49
|
try: () => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Context, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/services/Invocation.ts
|
|
4
|
+
/**
|
|
5
|
+
* The facts about this process a command needs, read once at the entrypoint.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Only the CLI entrypoint (`src/cli/index.ts`) reads `process`. Everything
|
|
9
|
+
* below it receives the working directory and the build's version through this
|
|
10
|
+
* service instead, so a handler can be driven by a test that hands it a
|
|
11
|
+
* different directory without `process.chdir`.
|
|
12
|
+
*
|
|
13
|
+
* The environment is deliberately NOT here. Environment variables are read
|
|
14
|
+
* through Effect's `Config`, which resolves them from the ambient
|
|
15
|
+
* `ConfigProvider` — the process environment by default, an explicit record
|
|
16
|
+
* under `ConfigProvider.layer` in a test. That is core's own seam for the
|
|
17
|
+
* environment, and `@effected/xdg` already reads `HOME` and `XDG_*` through it;
|
|
18
|
+
* a second, hand-rolled one here would let the two disagree.
|
|
19
|
+
*
|
|
20
|
+
* The whole shape is one immutable value with no behaviour, so `Layer.succeed`
|
|
21
|
+
* is the complete double — there is nothing for a partial mock to stub. The
|
|
22
|
+
* moment a method appears here it stops being a value and becomes a service.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
var Invocation = class extends Context.Service()("reposets/Invocation") {
|
|
27
|
+
/**
|
|
28
|
+
* A fixed invocation, for tests and for the entrypoint alike.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* Each call mints a fresh layer; bind the result to a `const` rather than
|
|
32
|
+
* calling this at more than one provide site.
|
|
33
|
+
*/
|
|
34
|
+
static layer = (value) => Layer.succeed(this, value);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
export { Invocation };
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { Context, Data, Effect, Layer, Redacted } from "effect";
|
|
2
|
-
import { env } from "node:process";
|
|
1
|
+
import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
|
|
3
2
|
|
|
4
3
|
//#region src/services/OnePasswordClient.ts
|
|
5
4
|
/**
|
|
@@ -23,14 +22,27 @@ var OnePasswordError = class extends Data.TaggedError("OnePasswordError") {
|
|
|
23
22
|
return `Failed to resolve ${this.reference}: ${this.reason}`;
|
|
24
23
|
}
|
|
25
24
|
};
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Read the service account token, failing in terms of the reference that needed it.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Read through `Config`, so it comes from the ambient `ConfigProvider` — the
|
|
30
|
+
* process environment in the shipped bin, an explicit record in a test — and
|
|
31
|
+
* never from `process` directly. The provider treats an empty variable as
|
|
32
|
+
* absent, which is the answer wanted here too: an empty token authenticates
|
|
33
|
+
* nothing. A provider that fails outright is reported the same way, naming the
|
|
34
|
+
* variable, rather than escaping as a `ConfigError` this service never declared.
|
|
35
|
+
*/
|
|
36
|
+
const serviceAccountToken = (reference) => Config.option(Config.String(OP_SERVICE_ACCOUNT_TOKEN)).pipe(Effect.mapError((error) => new OnePasswordError({
|
|
37
|
+
reference,
|
|
38
|
+
reason: `could not read ${OP_SERVICE_ACCOUNT_TOKEN}: ${error.message}`
|
|
39
|
+
})), Effect.flatMap(Option.match({
|
|
40
|
+
onNone: () => Effect.fail(new OnePasswordError({
|
|
30
41
|
reference,
|
|
31
42
|
reason: `${OP_SERVICE_ACCOUNT_TOKEN} is not set in the environment`
|
|
32
|
-
}))
|
|
33
|
-
|
|
43
|
+
})),
|
|
44
|
+
onSome: Effect.succeed
|
|
45
|
+
})));
|
|
34
46
|
/**
|
|
35
47
|
* Resolves `op://` references through the 1Password SDK.
|
|
36
48
|
*
|
package/services/SyncLogger.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Context, Effect, Layer, Ref } from "effect";
|
|
1
|
+
import { Console, Context, Effect, Layer, Ref } from "effect";
|
|
2
2
|
|
|
3
3
|
//#region src/services/SyncLogger.ts
|
|
4
4
|
/** Plural forms the naive `+ "s"` gets wrong. */
|
|
@@ -41,9 +41,14 @@ function pluralize(resource, count) {
|
|
|
41
41
|
* out-of-band edit happens to match the config. The tool has no work to do; the
|
|
42
42
|
* human still changed something, and staying quiet would hide it.
|
|
43
43
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* **The report is the product; failures are diagnostics.** What a run did —
|
|
45
|
+
* group and repository headers, every operation, every drift line, the closing
|
|
46
|
+
* "Sync complete!" — is the output of `sync` and `drift`, so it is written with
|
|
47
|
+
* `Console.log` to stdout, where `reposets drift > report.txt` captures it.
|
|
48
|
+
* Failures are emitted with `Effect.logError`, which the CLI logger
|
|
49
|
+
* (`CliLogger.layer()`) routes to stderr, so they stay on the terminal when
|
|
50
|
+
* stdout is redirected. A consequence worth knowing: `--log-level` filters
|
|
51
|
+
* diagnostics only — it no longer silences the report.
|
|
47
52
|
*
|
|
48
53
|
* @public
|
|
49
54
|
*/
|
|
@@ -63,7 +68,7 @@ function SyncLoggerLive(config) {
|
|
|
63
68
|
return Layer.effect(SyncLogger, Effect.gen(function* () {
|
|
64
69
|
const errors = yield* Ref.make([]);
|
|
65
70
|
const currentRepo = yield* Ref.make("");
|
|
66
|
-
const emit = (line) =>
|
|
71
|
+
const emit = (line) => Console.log(line);
|
|
67
72
|
/**
|
|
68
73
|
* Failures, on the error channel.
|
|
69
74
|
*
|
package/tsdoc-metadata.json
CHANGED
package/cli/logger.js
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { Console, LogLevel, Logger } from "effect";
|
|
2
|
-
|
|
3
|
-
//#region src/cli/logger.ts
|
|
4
|
-
/**
|
|
5
|
-
* Renders one log record as a plain CLI line.
|
|
6
|
-
*
|
|
7
|
-
* @remarks
|
|
8
|
-
* Effect's default logger emits `[23:04:05.891] INFO (#2): message`, which is
|
|
9
|
-
* the right shape for a service and the wrong one for a command-line tool: the
|
|
10
|
-
* timestamp, level and fiber id are noise in front of output a human is reading,
|
|
11
|
-
* and they make a formatted block — `doctor`'s permission table, for instance —
|
|
12
|
-
* unreadable.
|
|
13
|
-
*
|
|
14
|
-
* A message that is an array is joined with a space, matching how `Effect.log`
|
|
15
|
-
* accepts variadic parts.
|
|
16
|
-
*/
|
|
17
|
-
const render = (message) => Array.isArray(message) ? message.map(String).join(" ") : String(message);
|
|
18
|
-
/**
|
|
19
|
-
* The CLI's output logger: plain lines, errors on stderr.
|
|
20
|
-
*
|
|
21
|
-
* @remarks
|
|
22
|
-
* Routing by level is the contract `SyncLogger` is written against — it emits
|
|
23
|
-
* failures with `Effect.logError` precisely so that `reposets sync > log.txt`
|
|
24
|
-
* still shows them on the terminal while the log captures progress. Without a
|
|
25
|
-
* logger that honors the distinction, that choice does nothing.
|
|
26
|
-
*
|
|
27
|
-
* `Logger.layer` replaces the default logger rather than merging with it, so
|
|
28
|
-
* nothing is emitted twice.
|
|
29
|
-
*
|
|
30
|
-
* A `Logger`'s callback is **synchronous**, so it cannot yield an `Effect` and
|
|
31
|
-
* therefore cannot reach `Stdio`'s sinks. The way out is not to write to
|
|
32
|
-
* `process.stdout` — it is the one core's own loggers take: read the `Console`
|
|
33
|
-
* off the fiber. `Console.Console` is a `Context.Reference`, so it carries a
|
|
34
|
-
* default and never appears in `R`, and a test swaps the reference instead of
|
|
35
|
-
* stubbing a global.
|
|
36
|
-
*
|
|
37
|
-
* `console.log`/`console.error` supply their own newline, which is why nothing
|
|
38
|
-
* here appends one.
|
|
39
|
-
*
|
|
40
|
-
* @public
|
|
41
|
-
*/
|
|
42
|
-
const CliLoggerLive = Logger.layer([Logger.make(({ fiber, logLevel, message }) => {
|
|
43
|
-
const console = fiber.getRef(Console.Console);
|
|
44
|
-
(LogLevel.isGreaterThanOrEqualTo(logLevel, "Error") ? console.error : console.log)(render(message));
|
|
45
|
-
})]);
|
|
46
|
-
|
|
47
|
-
//#endregion
|
|
48
|
-
export { CliLoggerLive };
|
package/lib/schema-issues.js
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { SchemaIssue } from "effect";
|
|
2
|
-
|
|
3
|
-
//#region src/lib/schema-issues.ts
|
|
4
|
-
/**
|
|
5
|
-
* Core's structured formatter: one entry per leaf, each with its property path.
|
|
6
|
-
*
|
|
7
|
-
* @remarks
|
|
8
|
-
* Built once. It is a pure function of the issue tree, and `Formatter` carries
|
|
9
|
-
* no state.
|
|
10
|
-
*/
|
|
11
|
-
const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => issue._tag === "UnexpectedKey" ? "unknown key" : SchemaIssue.defaultLeafHook(issue) });
|
|
12
|
-
/**
|
|
13
|
-
* Render a `ConfigValidationError`'s `issue` as one line per rejected value.
|
|
14
|
-
*
|
|
15
|
-
* @remarks
|
|
16
|
-
* **No walker of our own.** Core already ships one:
|
|
17
|
-
* `SchemaIssue.makeFormatterStandardSchemaV1()` flattens the tree to
|
|
18
|
-
* `{ message, path }` entries, which is exactly the shape this needs.
|
|
19
|
-
* `defaultLeafHook` handles every leaf variant — `UnexpectedKey`,
|
|
20
|
-
* `InvalidType`, `MissingKey`, `Forbidden`, `OneOf` — so a wrong *type* renders
|
|
21
|
-
* as sensibly as an unknown key, and a variant added upstream is covered
|
|
22
|
-
* without a change here. Hand-rolling this against the excess-property shape
|
|
23
|
-
* alone would have got every other cause wrong.
|
|
24
|
-
*
|
|
25
|
-
* `issue` is declared `Schema.Defect`, so it arrives as `unknown`; the
|
|
26
|
-
* `isIssue` guard is what makes reading it safe. Anything that is not an issue
|
|
27
|
-
* tree yields no lines rather than a crash — a rendering helper on an error
|
|
28
|
-
* path must never become the reason a command dies.
|
|
29
|
-
*
|
|
30
|
-
* Nodes are never stringified. Each carries the entire AST inline, annotations
|
|
31
|
-
* included, so `String(node)` would dump the schema rather than describe the
|
|
32
|
-
* failure. Only `message` and `path` are read.
|
|
33
|
-
*
|
|
34
|
-
* @param issue - the `issue` field of a `ConfigValidationError`, or any value
|
|
35
|
-
* @returns one line per rejected value, deepest path last; empty if `issue` is
|
|
36
|
-
* not an issue tree
|
|
37
|
-
*
|
|
38
|
-
* @public
|
|
39
|
-
*/
|
|
40
|
-
const formatSchemaIssue = (issue) => {
|
|
41
|
-
if (!SchemaIssue.isIssue(issue)) return [];
|
|
42
|
-
const lines = formatter(issue).issues.map((entry) => {
|
|
43
|
-
const path = (entry.path ?? []).map(String).join(".");
|
|
44
|
-
return path === "" ? entry.message : `${entry.message} at ${path}`;
|
|
45
|
-
});
|
|
46
|
-
return [...new Set(lines)];
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
//#endregion
|
|
50
|
-
export { formatSchemaIssue };
|