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 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` and the `--log-level` filter Effect core provides. `--log-level error` is quiet on success and loud on failure; `--log-level none` is the CI form.
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 { formatSchemaIssue } from "../lib/schema-issues.js";
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
- * Report a failure through the CLI logger and exit non-zero.
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
- * `NodeRuntime.runMain` reports an unhandled failure with Effect's *default*
62
- * logger, which sits outside the layers the program was provided — so a failure
63
- * escaping to it prints `[23:05:56] ERROR (#2): …` **on stdout**, in the format
64
- * {@link CliLoggerLive} exists to replace, on the stream errors must not use.
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
- * Catching here keeps the failure inside the logger's scope. The exit code is
67
- * then ours to set, since the effect now succeeds.
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 reportAndExit = (error) => Effect.gen(function* () {
70
- yield* Effect.logError(String(error));
118
+ const render = (error) => {
119
+ const lines = [String(error)];
71
120
  if (isTagged(error, "ConfigValidationError")) {
72
- const lines = formatSchemaIssue(error.issue);
73
- for (const line of lines) yield* Effect.logError(` ${line}`);
74
- if (lines.some((line) => line.startsWith("unknown key"))) yield* Effect.logError(" Run 'reposets doctor' for suggested spellings.");
75
- }
76
- yield* Effect.sync(() => {
77
- process.exitCode = 1;
78
- });
79
- });
80
- /** Whether a caught value is a tagged error of a given tag. */
81
- const isTagged = (error, tag) => typeof error === "object" && error !== null && "_tag" in error && error._tag === tag;
82
- cli.pipe(Command.run({ version: "1.0.21" }), Effect.catch(reportAndExit), Effect.provide(MainLive), NodeRuntime.runMain);
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
- yield* Effect.logError("Provide exactly one of --op or --env, not both.");
77
- return;
78
- }
79
- if (op === void 0 && env === void 0) {
80
- yield* Effect.logError("Provide a reference: --op \"op://Vault/item/field\" or --env REPOSETS_GITHUB_TOKEN. A token value is never accepted.");
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* Effect.log(`Created profile '${profile}' (${username !== void 0 ? `username: ${username}` : `org: ${org}`}, github_token: ${describeSource(github_token)}) in ${path}.`);
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* Effect.log("No credential profiles configured.");
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* Effect.log(`[${name}]`);
150
- yield* Effect.log(` acts as: ${owner} (${ownerType === "User" ? "user" : "organization"})`);
151
- yield* Effect.log(` github_token: ${describeSource(profile.github_token)}`);
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* Effect.log(` resolve.${kind}.${label}: ${reference}`);
148
+ for (const [label, reference] of Object.entries(entries)) yield* Console.log(` resolve.${kind}.${label}: ${reference}`);
160
149
  }
161
- yield* Effect.log("");
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* Effect.log(`Deleted profile '${profile}' from ${path}.`);
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
  /**
@@ -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 { formatSchemaIssue } from "../../lib/schema-issues.js";
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 core's --log-level to filter by severity"
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* Effect.log(`Config: ${configPath}`);
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* Effect.log(`Warning: '${key}'${where} ${removedFrom}`);
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* Effect.log(`Warning: unknown key '${key}'${where}${hint}`);
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* Effect.log(`Warning: unrecognised setting '${field}' in settings.${groupName}${hint}`);
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* Effect.log("Schema validation: passed");
351
+ if (schemaValid) yield* Console.log("Schema validation: passed");
345
352
  else {
346
- yield* Effect.logError("Schema validation: FAILED — these values are rejected, not ignored");
347
- for (const line of formatSchemaIssue(validationIssue)) yield* Effect.logError(` ${line}`);
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* Effect.log("");
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* Effect.logError(`Credentials: FAILED to load — ${String(loaded.failure.message ?? loaded.failure)}`);
355
- yield* Effect.logError(" Unknown keys are rejected. `op_service_account_token` was removed — that token now comes from OP_SERVICE_ACCOUNT_TOKEN in the environment.");
356
- } else if (profileNames.length === 0) yield* Effect.log("Credentials: no profiles configured");
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* Effect.log(`Credentials: ${profileNames.length} profile(s)`);
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* Effect.log(` [${name}] github_token: ${token}${resolveSummary}`);
383
+ yield* Console.log(` [${name}] github_token: ${token}${resolveSummary}`);
377
384
  }
378
- const hasServiceAccount = env["OP_SERVICE_ACCOUNT_TOKEN"] !== void 0 && env["OP_SERVICE_ACCOUNT_TOKEN"] !== "";
379
- if (usesOnePassword) yield* Effect.log(hasServiceAccount ? ` ${OP_SERVICE_ACCOUNT_TOKEN}: set` : ` ${OP_SERVICE_ACCOUNT_TOKEN}: NOT SET — op:// references cannot be resolved`);
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* Effect.log("");
382
- yield* Effect.log(`Version: ${env.__PACKAGE_VERSION__ ?? "0.0.0 (dev build)"}`);
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* Effect.log(`Credentials file: ${credentialsLine}`);
387
- yield* Effect.log(`State database: ${path.join(appDirs.dirs.state, "store.db")}`);
388
- yield* Effect.log("");
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* Effect.logError(`Token [${name}]: could not resolve — ${String(resolved.failure)}`);
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* Effect.logError(`Token [${name}]: resolved but REJECTED by GitHub — ${String(identity.failure)}`);
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* line.ok ? Effect.log(line.text) : Effect.logError(line.text);
413
+ yield* Console.log(line.text);
407
414
  }
408
- yield* Effect.log("");
409
- yield* Effect.log("Required fine-grained token permissions:");
410
- for (const [scope, permission, why] of REQUIRED_PERMISSIONS) yield* Effect.log(` ${scope} > ${permission} — ${why}`);
411
- yield* Effect.log(" (Metadata: Read is mandatory and granted automatically)");
412
- yield* Effect.log(" These are NOT verified — GitHub does not expose a fine-grained token's own scopes.");
413
- yield* Effect.log("");
414
- yield* Effect.log(warnings === 0 ? "No unknown keys detected." : `${warnings} warning(s) found.`);
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`.
@@ -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. The `CliLogger` emits each line verbatim, so
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* Effect.log(input.repo === void 0 ? "No runs recorded yet. The journal fills in as you sync." : `No recorded run has touched ${input.repo}.`);
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* Effect.log(line);
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
- yield* Effect.logError(`'${prefix}' matches ${matches.length} runs. Use more of the id:`);
130
- for (const run of matches.slice(0, 5)) yield* Effect.logError(` ${run.id} ${formatWhen(run.startedAt)}`);
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* Effect.log(`run ${run.id}`);
139
- yield* Effect.log(` ${formatWhen(run.startedAt)} · ${run.dryRun === 1 ? "dry-run" : "applied"} · ${run.outcome ?? "unfinished"} · ${formatDuration(run)}`);
140
- yield* Effect.log("");
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* Effect.log(` error: ${run.error}`);
143
- yield* Effect.log("");
129
+ yield* Console.log(` error: ${run.error}`);
130
+ yield* Console.log("");
144
131
  }
145
132
  if (changes.length === 0) {
146
- yield* Effect.log(run.error !== null && run.error !== "" ? " No resources changed." : " No resources changed (nothing to do).");
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* Effect.log(` ${repo}`);
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* Effect.log(` ${action.padEnd(18)} ${what}${detail}`);
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* Effect.log(removed === 0 ? `Nothing to prune; ${keep} or fewer runs are recorded.` : `Pruned ${removed} run${removed === 1 ? "" : "s"}, keeping the newest ${keep}.`);
178
- yield* Effect.log("Applied state and the cache are untouched — drift detection still works.");
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* Effect.log(`Cleared ${removed} run${removed === 1 ? "" : "s"} from the journal.`);
183
- yield* Effect.log("Applied state and the cache are untouched — drift detection still works.");
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`.
@@ -1,8 +1,9 @@
1
1
  import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
2
- import { Effect, FileSystem, Path } from "effect";
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 ? cwd() : yield* appDirs.ensureConfig;
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* Effect.log(`Already exists: ${target}`);
137
+ yield* Console.log(`Already exists: ${target}`);
127
138
  return;
128
139
  }
129
- yield* (yield* fs.writeFileString(target, contents).pipe(Effect.option))._tag === "Some" ? Effect.log(`Created: ${target}`) : Effect.logError(`Could not write: ${target}`);
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
- if ((yield* fs.writeFileString(gitignorePath, `${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some") yield* Effect.log(`Created .gitignore with ${CREDENTIALS_FILENAME}`);
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
- if ((yield* fs.writeFileString(gitignorePath, `${existing}${separator}${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some") yield* Effect.log(`Added ${CREDENTIALS_FILENAME} to .gitignore`);
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:");
@@ -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* Effect.log(`[${groupName}] (${acts})`);
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* Effect.log(` - ${owner}/${repo}`);
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* Effect.log(` ${label}: ${names.join(", ")}`);
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* Effect.log(` secrets: ${secrets.join(", ")}`);
58
+ if (secrets.length > 0) yield* Console.log(` secrets: ${secrets.join(", ")}`);
57
59
  const variables = scopeParts(group.variables);
58
- if (variables.length > 0) yield* Effect.log(` variables: ${variables.join(", ")}`);
59
- yield* Effect.log("");
60
+ if (variables.length > 0) yield* Console.log(` variables: ${variables.join(", ")}`);
61
+ yield* Console.log("");
60
62
  }
61
63
  });
62
64
  /**
@@ -1,6 +1,8 @@
1
1
  import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
2
- import { Effect, FileSystem, Path } from "effect";
3
- import { Command, Flag, Prompt } from "effect/unstable/cli";
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 targets = yield* findTargets(fs, path, appDirs, process.cwd());
81
+ const { cwd } = yield* Invocation;
82
+ const targets = yield* findTargets(fs, path, appDirs, cwd);
80
83
  if (targets.length === 0) {
81
- yield* Effect.log("Nothing to remove — no reposets files found on this machine.");
84
+ yield* Console.log("Nothing to remove — no reposets files found on this machine.");
82
85
  return;
83
86
  }
84
- yield* Effect.log("This will delete:");
87
+ yield* Console.log("This will delete:");
85
88
  for (const target of targets) {
86
- yield* Effect.log(` ${target.path}`);
87
- yield* Effect.log(` ${target.what} — loses ${target.cost}`);
89
+ yield* Console.log(` ${target.path}`);
90
+ yield* Console.log(` ${target.what} — loses ${target.cost}`);
88
91
  }
89
- yield* Effect.log("");
90
- yield* Effect.log("Nothing on GitHub is touched. Everything reposets applied stays applied.");
92
+ yield* Console.log("");
93
+ yield* Console.log("Nothing on GitHub is touched. Everything reposets applied stays applied.");
91
94
  if (!force) {
92
- if (process.stdin.isTTY !== true) {
93
- yield* Effect.logError("");
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* Effect.log("Nothing was deleted.");
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* Effect.log(` removed ${target.path}`);
110
+ yield* Console.log(` removed ${target.path}`);
115
111
  }
116
- yield* Effect.log("");
117
- yield* Effect.log(removed === targets.length ? `Done. ${removed} file${removed === 1 ? "" : "s"} removed.` : `Removed ${removed} of ${targets.length}; the rest are listed above.`);
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.
@@ -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 { Command, Flag } from "effect/unstable/cli";
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 discovered = yield* configFile.discover.pipe(Effect.result);
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* Effect.sync(() => {
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* Effect.sync(() => {
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
- yield* Effect.logError(input.group === void 0 ? "No groups configured. Add a [groups.<name>] section to sync anything." : `No group named '${input.group}'. Configured: ${Object.keys(config.groups).join(", ") || "none"}`);
124
- yield* Effect.sync(() => {
125
- process.exitCode = 1;
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* Effect.log(`${report.repos} repo(s), ${report.changes} change(s), ${report.drifted} drifted, ${report.errors} error(s)`);
200
- if (report.errors > 0 || input.failOnDrift && report.drifted > 0) yield* Effect.sync(() => {
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.
@@ -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 validateCommand = Command.make("validate", {}, () => Effect.gen(function* () {
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* Effect.sync(() => {
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* Effect.sync(() => {
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* Effect.sync(() => {
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* Effect.log(`Valid: ${sources[0]?.path ?? "config"}`);
65
- yield* Effect.log(` groups: ${groups === 0 ? "none declared yet" : String(groups)}`);
66
- })).pipe(Command.withDescription("Validate reposets.config.toml against the schema"));
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": "1.0.21",
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
- /** Read an environment variable, failing in terms of its name. */
27
- const fromEnv = (label, variable) => Effect.suspend(() => {
28
- const value = env[variable];
29
- return value === void 0 ? Effect.fail(new ResolveError({
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
- })) : Effect.succeed(Redacted.make(value, { label }));
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
- /** Read the service account token, failing in terms of the reference that needed it. */
27
- const serviceAccountToken = (reference) => Effect.suspend(() => {
28
- const token = env[OP_SERVICE_ACCOUNT_TOKEN];
29
- return token === void 0 || token === "" ? Effect.fail(new OnePasswordError({
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
- })) : Effect.succeed(token);
33
- });
43
+ })),
44
+ onSome: Effect.succeed
45
+ })));
34
46
  /**
35
47
  * Resolves `op://` references through the 1Password SDK.
36
48
  *
@@ -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
- * Lines are emitted with `Effect.log`, never `Console.log`: the CLI entrypoint
45
- * installs a logger that routes Effect's output, and a direct console write
46
- * would bypass both it and the filtering above.
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) => Effect.log(line);
71
+ const emit = (line) => Console.log(line);
67
72
  /**
68
73
  * Failures, on the error channel.
69
74
  *
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.59.2"
8
+ "packageVersion": "7.59.1"
9
9
  }
10
10
  ]
11
11
  }
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 };
@@ -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 };