reposets 1.0.21 → 2.0.1

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";
20
- import { Command } from "effect/unstable/cli";
19
+ import { CliColor, CliRuntime, ConfigIssueRenderer } from "@effected/cli";
20
+ import { Command } from "effect/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.1";
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/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";
9
- import { Command } from "effect/unstable/cli";
8
+ import { Config, Console, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
9
+ import { SchemaIssueRenderer } from "@effected/cli";
10
+ import { Command } from "effect/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 { syncHandler } from "./sync.js";
2
2
  import { Option } from "effect";
3
- import { Command, Flag } from "effect/unstable/cli";
3
+ import { Command, Flag } from "effect/cli";
4
4
 
5
5
  //#region src/cli/commands/drift.ts
6
6
  /**
@@ -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/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`.