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.
@@ -1,8 +1,9 @@
1
1
  import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
2
- import { Effect, FileSystem, Path } from "effect";
3
- import { Command, Flag } from "effect/unstable/cli";
2
+ import { Invocation } from "../../services/Invocation.js";
3
+ import { Console, Effect, FileSystem, Path } from "effect";
4
+ import { CliExit } from "@effected/cli";
5
+ import { Command, Flag } from "effect/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,7 +1,8 @@
1
1
  import { profileOwner } from "../../schemas/credentials.js";
2
2
  import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
3
- import { Effect } from "effect";
4
- import { Command } from "effect/unstable/cli";
3
+ import { Console, Effect } from "effect";
4
+ import { CliExit } from "@effected/cli";
5
+ import { Command } from "effect/cli";
5
6
 
6
7
  //#region src/cli/commands/list.ts
7
8
  /** `secrets` and `variables` render the same way; only the scope names differ. */
@@ -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/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/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,8 +2,9 @@ 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";
6
- import { Command } from "effect/unstable/cli";
5
+ import { Console, Effect } from "effect";
6
+ import { CliExit } from "@effected/cli";
7
+ import { Command } from "effect/cli";
7
8
 
8
9
  //#region src/cli/commands/validate.ts
9
10
  /**
@@ -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/cli/flags.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { makeConfigFilesLive } from "../services/ConfigFiles.js";
2
2
  import { Effect, Layer, Option } from "effect";
3
- import { Flag, GlobalFlag } from "effect/unstable/cli";
3
+ import { Flag, GlobalFlag } from "effect/cli";
4
4
 
5
5
  //#region src/cli/flags.ts
6
6
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reposets",
3
- "version": "1.0.21",
3
+ "version": "2.0.1",
4
4
  "private": false,
5
5
  "description": "CLI tool to sync GitHub repo settings, secrets and rulesets across personal repositories",
6
6
  "keywords": [
@@ -48,15 +48,16 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@1password/sdk": "^0.5.0",
51
- "@effect/platform-node": "4.0.0-rc.117",
52
- "@effected/app": "^0.18.0",
53
- "@effected/config-file": "^0.12.0",
54
- "@effected/github": "^0.13.0",
55
- "@effected/store": "^0.10.0",
56
- "@effected/toml": "^0.9.0",
57
- "@effected/xdg": "^0.7.0",
51
+ "@effect/platform-node": "4.0.0-rc.118",
52
+ "@effected/app": "^0.19.0",
53
+ "@effected/cli": "^0.10.0",
54
+ "@effected/config-file": "^0.13.0",
55
+ "@effected/github": "^0.14.0",
56
+ "@effected/store": "^0.11.0",
57
+ "@effected/toml": "^0.10.0",
58
+ "@effected/xdg": "^0.8.0",
58
59
  "blakejs": "^1.2.1",
59
- "effect": "4.0.0-rc.117"
60
+ "effect": "4.0.0-rc.118"
60
61
  },
61
62
  "engines": {
62
63
  "node": ">=24.11.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 };