reposets 3.1.8 → 4.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/bin/reposets.js CHANGED
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { CredentialsFilesLive } from "../services/ConfigFiles.js";
3
2
  import { Invocation } from "../services/Invocation.js";
4
- import { CACHE_DB_FILENAME, STATE_DB_FILENAME } from "../store/files.js";
5
- import { migrations } from "../store/migrations.js";
3
+ import { CredentialsFilesLive } from "../services/ConfigFiles.js";
4
+ import { CACHE_OPTIONS, STORE_OPTIONS } from "../store/files.js";
6
5
  import { SyncJournalLive } from "../store/SyncJournal.js";
7
6
  import { credentialsCommand } from "../cli/commands/credentials.js";
8
- import { ConfigFlag, ConfigLive } from "../cli/flags.js";
9
7
  import { doctorCommand } from "../cli/commands/doctor.js";
10
8
  import { syncCommand } from "../cli/commands/sync.js";
11
9
  import { driftCommand } from "../cli/commands/drift.js";
@@ -14,7 +12,8 @@ import { initCommand } from "../cli/commands/init.js";
14
12
  import { listCommand } from "../cli/commands/list.js";
15
13
  import { nukeCommand } from "../cli/commands/nuke.js";
16
14
  import { validateCommand } from "../cli/commands/validate.js";
17
- import { Effect, Layer } from "effect";
15
+ import { ConfigFlag, ConfigLive } from "../cli/flags.js";
16
+ import { Effect, Layer, Predicate } from "effect";
18
17
  import { App, AppCache, AppStore } from "@effected/app";
19
18
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
20
19
  import { CliAudience, CliRuntime, ConfigIssueRenderer, Fmt } from "@effected/cli";
@@ -32,7 +31,7 @@ import { Command } from "effect/cli";
32
31
  * `0.0.0 (dev build)` from every build until the version was threaded down
33
32
  * from here through {@link Invocation}.
34
33
  */
35
- const VERSION = "3.1.8";
34
+ const VERSION = "4.0.1";
36
35
  /**
37
36
  * The application's directories: XDG resolution and the `reposets` namespace
38
37
  * under it.
@@ -60,10 +59,7 @@ const DirsLive = App.layerDirs({ namespace: "reposets" });
60
59
  * Migrations still run during layer construction, so they now run on the first
61
60
  * command that uses the database rather than on the first command of any kind.
62
61
  */
63
- const StoreLive = AppStore.layer({
64
- migrations,
65
- filename: STATE_DB_FILENAME
66
- });
62
+ const StoreLive = AppStore.layer(STORE_OPTIONS);
67
63
  /**
68
64
  * Both databases, for the commands that run the sync engine.
69
65
  *
@@ -72,7 +68,7 @@ const StoreLive = AppStore.layer({
72
68
  * in `cache.db`. The engine builds its own journal over `Store`, so nothing
73
69
  * else is needed here.
74
70
  */
75
- const DatabasesLive = Layer.mergeAll(StoreLive, AppCache.layer({ filename: CACHE_DB_FILENAME }));
71
+ const DatabasesLive = Layer.mergeAll(StoreLive, AppCache.layer(CACHE_OPTIONS));
76
72
  /**
77
73
  * The run journal over the state database, for `history` and its subcommands.
78
74
  *
@@ -138,8 +134,6 @@ const cli = Command.make("reposets", {}, () => Effect.void).pipe(Command.withDes
138
134
  * would be shadowed.
139
135
  */
140
136
  const PlatformLive = Layer.mergeAll(DirsLive, InvocationLive).pipe(Layer.provideMerge(NodeServices.layer));
141
- /** Whether a failure is a tagged error of a given tag. */
142
- const isTagged = (error, tag) => typeof error === "object" && error !== null && "_tag" in error && error._tag === tag;
143
137
  /**
144
138
  * Render a failure that escaped a command, one line at a time, to stderr.
145
139
  *
@@ -161,19 +155,31 @@ const isTagged = (error, tag) => typeof error === "object" && error !== null &&
161
155
  * kit's own status line. A TOML syntax error is a `ConfigCodecError` whose own
162
156
  * message is the bare "toml parse failed" — the line and column live on the
163
157
  * cause, and printing it is the difference between "your config is broken
164
- * somewhere" and a position plus what the parser expected there. The cause
165
- * reports a 0-based `line:column`: an unclosed `[groups` header on the first
166
- * line reads "ExpectedTableHeaderClose at 0:7 expected ] to close the table
158
+ * somewhere" and a position plus what the parser expected there. The cause is
159
+ * read with `Fmt.causeMessage`, the whole `cause` chain's messages without
160
+ * the `TomlParseError:` prefix `String` would add, and falls back to
161
+ * `Fmt.describe` when nothing along the chain has a message. The parser
162
+ * reports a 1-based `line:column`: an unclosed `[groups` header on the first
163
+ * line reads "ExpectedTableHeaderClose at 1:8 expected ] to close the table
167
164
  * header".
168
165
  *
166
+ * When the failure names a file — `ConfigCodecError.path`, which
167
+ * `ConfigFile`'s read pipeline fills in and which its `message` deliberately
168
+ * leaves out — the cause line is prefixed with it, so a syntax error says
169
+ * which of the discovered files it is in.
170
+ *
169
171
  * Everything else, defects included, is the kit's default report. Lines built
170
172
  * here from data are sanitised: the kit keeps a person's escapes, so it cannot
171
173
  * strip an injected one from a `render`'s output.
172
174
  */
173
175
  const render = (error, details) => {
174
176
  if (details.isDefect) return details.defaultLines;
175
- if (isTagged(error, "ConfigValidationError")) return ConfigIssueRenderer.render(error).some((line) => line.startsWith("unknown key")) ? [...details.defaultLines, "Run 'reposets doctor' for suggested spellings."] : details.defaultLines;
176
- if (typeof error === "object" && error !== null && "cause" in error && error.cause !== void 0) return [...details.defaultLines, ` ${Fmt.sanitize(String(error.cause))}`];
177
+ if (Predicate.isTagged(error, "ConfigValidationError")) return ConfigIssueRenderer.render(error).some((line) => line.startsWith("unknown key")) ? [...details.defaultLines, "Run 'reposets doctor' for suggested spellings."] : details.defaultLines;
178
+ if (Predicate.hasProperty(error, "cause") && error.cause !== void 0) {
179
+ const cause = Fmt.causeMessage(error.cause) ?? Fmt.describe(error.cause);
180
+ const where = Predicate.hasProperty(error, "path") && Predicate.isString(error.path) ? `${error.path}: ` : "";
181
+ return [...details.defaultLines, ` ${Fmt.sanitize(`${where}${cause}`)}`];
182
+ }
177
183
  return details.defaultLines;
178
184
  };
179
185
  NodeRuntime.runMain(CliRuntime.main(CliAudience.run(cli, { version: VERSION }), {
@@ -181,8 +187,7 @@ NodeRuntime.runMain(CliRuntime.main(CliAudience.run(cli, { version: VERSION }),
181
187
  env: {
182
188
  audienceEnvVar: "REPOSETS_AUDIENCE",
183
189
  log: { envVar: "REPOSETS_LOG_LEVEL" },
184
- spansEnvVar: "REPOSETS_SPANS",
185
- stderrIsTerminal: Effect.sync(() => process.stderr.isTTY === true)
190
+ spansEnvVar: "REPOSETS_SPANS"
186
191
  },
187
192
  render,
188
193
  helpOnUsageError: "stderr"
@@ -96,24 +96,6 @@ const SECRET_IN_NAME = "That looks like a token, not a name. It was not stored."
96
96
  /** What `--env` names, and what the prompt accepts for it: a shell variable identifier. */
97
97
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
98
98
  /**
99
- * Write credentials back to the file they were read from.
100
- *
101
- * @remarks
102
- * `ConfigFileShape.save` resolves its own default path — the XDG one — rather
103
- * than the path `discover` found. With a project-local
104
- * `reposets.credentials.toml` present, that splits reads from writes: `create`
105
- * lands in XDG while `list` keeps reading the local file, so a profile is
106
- * created that the user cannot see and that sync will not use. Writing back to
107
- * the discovered source keeps one file authoritative, and falls back to `save`
108
- * only when there is genuinely nothing to update.
109
- */
110
- const saveWhereRead = (file, value) => Effect.gen(function* () {
111
- const existing = (yield* file.discover.pipe(Effect.orElseSucceed(() => [])))[0];
112
- if (existing === void 0) return yield* file.save(value);
113
- yield* file.write(value, existing.path);
114
- return existing.path;
115
- });
116
- /**
117
99
  * Refuse an invocation the user got wrong.
118
100
  *
119
101
  * @remarks
@@ -284,7 +266,7 @@ const createHandler = (input) => Effect.gen(function* () {
284
266
  ...actsAs,
285
267
  github_token
286
268
  };
287
- const path = yield* saveWhereRead(credentialsFile, { profiles: {
269
+ const path = yield* credentialsFile.save({ profiles: {
288
270
  ...existing.profiles,
289
271
  [profile]: record
290
272
  } });
@@ -378,7 +360,7 @@ const deleteHandler = (profile) => Effect.gen(function* () {
378
360
  }), "Provide --profile <name>: the profile to delete."));
379
361
  if (credentials.profiles[name] === void 0) return yield* refuse(`Profile '${name}' not found.`);
380
362
  const { [name]: _removed, ...remaining } = credentials.profiles;
381
- const path = yield* saveWhereRead(credentialsFile, { profiles: remaining });
363
+ const path = yield* credentialsFile.save({ profiles: remaining });
382
364
  yield* CliMessage.success(`Deleted profile '${name}' from ${path}.`);
383
365
  });
384
366
  const deleteCommand = Command.make("delete", { profile: profileFlag }, ({ profile }) => deleteHandler(Option.getOrUndefined(profile))).pipe(Command.withDescription("Remove a credential profile"));
@@ -1,13 +1,13 @@
1
1
  import { SettingsGroupSchema } from "../../schemas/config.js";
2
2
  import { profileOwner } from "../../schemas/credentials.js";
3
- import { CONFIG_FILENAME, CREDENTIALS_FILENAME, ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
4
3
  import { Invocation } from "../../services/Invocation.js";
5
- import { STATE_DB_FILENAME } from "../../store/files.js";
4
+ import { CREDENTIALS_FILENAME, ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
5
+ import { STORE_OPTIONS } from "../../store/files.js";
6
6
  import { OP_SERVICE_ACCOUNT_TOKEN, OnePasswordClientLive } from "../../services/OnePasswordClient.js";
7
7
  import { CredentialResolver, CredentialResolverLive } from "../../services/CredentialResolver.js";
8
- import { ConfigFlag } from "../flags.js";
9
- import { Config, Console, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
10
- import { CliMessage, Doc, SchemaIssueRenderer, Status } from "@effected/cli";
8
+ import { Config, Console, Effect, FileSystem, Layer, Option, Path, Predicate, Result } from "effect";
9
+ import { AppStore } from "@effected/app";
10
+ import { CliMessage, Doc, Fmt, SchemaIssueRenderer, Status } from "@effected/cli";
11
11
  import { Command } from "effect/cli";
12
12
  import { AppDirs } from "@effected/xdg";
13
13
  import { GitHubClient } from "@effected/github";
@@ -157,69 +157,37 @@ const REQUIRED_PERMISSIONS = [
157
157
  "resolve team slugs (org-owned repos only)"
158
158
  ]
159
159
  ];
160
- const levenshtein = (a, b) => {
161
- const matrix = [];
162
- for (let i = 0; i <= a.length; i++) matrix[i] = [i];
163
- for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
164
- for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
165
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
166
- const row = matrix[i];
167
- const prev = matrix[i - 1];
168
- row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
169
- }
170
- return matrix[a.length][b.length];
171
- };
172
- /** The nearest known key within edit distance 3, if any. */
173
- const findClosestMatch = (key, known) => {
174
- let best;
175
- let bestDist = Number.POSITIVE_INFINITY;
176
- for (const candidate of known) {
177
- const dist = levenshtein(key, candidate);
178
- if (dist < bestDist && dist <= 3) {
179
- bestDist = dist;
180
- best = candidate;
181
- }
182
- }
183
- return best;
184
- };
185
160
  /**
186
- * Find the config file without decoding it.
161
+ * The nearest known key within edit distance 3, if any.
187
162
  *
188
163
  * @remarks
189
- * The same two tiers the loader uses — walk up from the working directory, then
190
- * the XDG config directory — but testing only for existence.
191
- *
192
- * This was written on the belief that `discover` skips a source that fails to
193
- * decode. **That was wrong** — it propagates, and a validation failure now
194
- * reports its own path and its structured issue. The duplication survives for a
195
- * different and narrower reason, measured rather than assumed:
196
- * `ConfigCodecError` carries `codec` and `operation` and **no path**, so a TOML
197
- * *syntax* error cannot name the file it failed to parse. That is the most
198
- * ordinary config mistake there is, and this command exists to say which file
199
- * is wrong — so it finds the file itself and parses it for the line and column.
164
+ * Three edits, the threshold this check had before it moved onto the kit;
165
+ * `Fmt.suggest` defaults to two, `effect/cli`'s threshold. Ties keep the
166
+ * first known key, as before.
167
+ */
168
+ const findClosestMatch = (key, known) => Fmt.suggest(key, known, { maxDistance: 3 })[0];
169
+ /**
170
+ * The credential resolver `doctor`'s live token check runs through.
200
171
  *
201
- * Delete this when the codec error carries a path.
172
+ * @remarks
173
+ * Bound once at module scope and provided once around the whole check, not
174
+ * rebuilt per profile: a layer built inside the loop was a fresh reference on
175
+ * every iteration, so it built the resolver and its 1Password client once per
176
+ * profile.
177
+ */
178
+ const ResolverLive = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
179
+ /**
180
+ * The file a failed discovery was reading.
202
181
  *
203
- * `appDirs.dirs.config` rather than `ensureConfig`: the latter *creates* the
204
- * directory, and a read-only diagnostic must not.
182
+ * @remarks
183
+ * Every failure `discover` can raise names its file: `ConfigFileReadError`
184
+ * and `ConfigCodecError` carry `path` (the codec's is filled in by
185
+ * `ConfigFile`'s read pipeline), and `ConfigValidationError` an
186
+ * `Option` of it. That is what lets a TOML *syntax* error — the most ordinary
187
+ * config mistake there is — name the file it failed to parse, so this command
188
+ * no longer re-walks the resolver chain to find the file itself.
205
189
  */
206
- const locateConfig = (fs, path, appDirs, configFlag, cwd) => Effect.gen(function* () {
207
- const candidates = [];
208
- if (configFlag !== void 0) candidates.push(configFlag, path.join(configFlag, CONFIG_FILENAME));
209
- let dir = cwd;
210
- for (;;) {
211
- candidates.push(path.join(dir, CONFIG_FILENAME));
212
- const parent = path.dirname(dir);
213
- if (parent === dir) break;
214
- dir = parent;
215
- }
216
- candidates.push(path.join(appDirs.dirs.config, CONFIG_FILENAME));
217
- for (const candidate of candidates) {
218
- const info = yield* fs.stat(candidate).pipe(Effect.option);
219
- if (info._tag === "Some" && info.value.type === "File") return candidate;
220
- }
221
- });
222
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
190
+ const failedPath = (failure) => failure._tag === "ConfigValidationError" ? Option.getOrUndefined(failure.path) : failure.path;
223
191
  /**
224
192
  * `reposets doctor` — config diagnostics, typo detection, credential posture.
225
193
  *
@@ -286,7 +254,7 @@ const describeIdentity = (name, profile, login) => {
286
254
  text: `Token [${name}]: resolved, but authenticates as ${login} while the profile declares username = "${owner}"`
287
255
  };
288
256
  };
289
- const doctorHandler = (configFlag) => Effect.gen(function* () {
257
+ const doctorHandler = Effect.gen(function* () {
290
258
  const configFile = yield* ReposetsConfigFile;
291
259
  const credentialsFile = yield* ReposetsCredentialsFile;
292
260
  const fs = yield* FileSystem.FileSystem;
@@ -297,7 +265,7 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
297
265
  const sources = discovered._tag === "Success" ? discovered.success : [];
298
266
  const schemaValid = discovered._tag === "Success" && sources.length > 0;
299
267
  const validationIssue = discovered._tag === "Failure" ? discovered.failure.issue : void 0;
300
- const configPath = sources[0]?.path ?? (yield* locateConfig(fs, path, appDirs, configFlag, invocation.cwd));
268
+ const configPath = discovered._tag === "Success" ? sources[0]?.path : failedPath(discovered.failure);
301
269
  if (configPath === void 0) {
302
270
  yield* CliMessage.failure("No config found. Run 'reposets init' to create one.");
303
271
  return;
@@ -309,7 +277,7 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
309
277
  ["Version: ", invocation.version],
310
278
  ["Config: ", Doc.file(configPath)],
311
279
  ["Credentials file: ", ...Array.isArray(credentialsLine) ? credentialsLine : [credentialsLine]],
312
- ["State database: ", Doc.file(path.join(appDirs.dirs.state, STATE_DB_FILENAME))]
280
+ ["State database: ", Doc.file(yield* AppStore.location(STORE_OPTIONS))]
313
281
  ])]);
314
282
  const text = yield* fs.readFileString(configPath).pipe(Effect.option);
315
283
  if (text._tag === "None") {
@@ -320,13 +288,13 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
320
288
  const parsed = Toml.parseResult(text.value);
321
289
  if (Result.isFailure(parsed)) {
322
290
  yield* Doc.print([files]);
323
- yield* CliMessage.failure(`TOML parse error: ${String(parsed.failure)}`);
291
+ yield* CliMessage.failure(`TOML parse error: ${Fmt.sanitize(Fmt.describe(parsed.failure))}`);
324
292
  return;
325
293
  }
326
- const raw = isRecord(parsed.success) ? parsed.success : {};
294
+ const raw = Predicate.isObject(parsed.success) ? parsed.success : {};
327
295
  const warnings = [];
328
296
  const checkKeys = (target, known, where) => {
329
- if (!isRecord(target)) return;
297
+ if (!Predicate.isObject(target)) return;
330
298
  for (const key of Object.keys(target)) {
331
299
  if (known.has(key)) continue;
332
300
  const removedFrom = REMOVED_KEYS[where === "" ? key : `${key} (in groups)`] ?? REMOVED_KEYS[key];
@@ -340,9 +308,9 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
340
308
  }
341
309
  };
342
310
  checkKeys(raw, KNOWN_CONFIG_KEYS, "");
343
- const settingsSection = isRecord(raw.settings) ? raw.settings : {};
311
+ const settingsSection = Predicate.isObject(raw.settings) ? raw.settings : {};
344
312
  for (const [groupName, body] of Object.entries(settingsSection)) {
345
- if (!isRecord(body)) continue;
313
+ if (!Predicate.isObject(body)) continue;
346
314
  for (const field of Object.keys(body)) {
347
315
  if (KNOWN_SETTINGS_FIELDS.has(field)) continue;
348
316
  const suggestion = findClosestMatch(field, KNOWN_SETTINGS_FIELDS);
@@ -351,11 +319,11 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
351
319
  }
352
320
  }
353
321
  const groups = raw.groups;
354
- if (isRecord(groups)) for (const [groupName, group] of Object.entries(groups)) {
322
+ if (Predicate.isObject(groups)) for (const [groupName, group] of Object.entries(groups)) {
355
323
  checkKeys(group, KNOWN_GROUP_KEYS, ` in groups.${groupName}`);
356
- if (!isRecord(group)) continue;
324
+ if (!Predicate.isObject(group)) continue;
357
325
  const cleanup = group.cleanup;
358
- if (!isRecord(cleanup)) continue;
326
+ if (!Predicate.isObject(cleanup)) continue;
359
327
  const prefix = `groups.${groupName}.cleanup`;
360
328
  checkKeys(cleanup, KNOWN_CLEANUP_KEYS, ` in ${prefix}`);
361
329
  checkKeys(cleanup.secrets, KNOWN_CLEANUP_SECRETS_KEYS, ` in ${prefix}.secrets`);
@@ -363,15 +331,15 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
363
331
  }
364
332
  const issueLines = schemaValid ? [] : SchemaIssueRenderer.render(validationIssue);
365
333
  const schema = Doc.section("Schema", [schemaValid ? Doc.paragraph(Doc.status(Status.core, "success"), " Schema validation: passed") : Doc.paragraph(Doc.status(Status.core, "failure"), " Schema validation: FAILED — these values are rejected, not ignored"), ...issueLines.length === 0 ? [] : [Doc.verbatim(issueLines.join("\n"), { indent: 2 })]]);
366
- const keys = Doc.section("Config keys", [...warnings.length === 0 ? [] : [Doc.list(warnings.map((warning) => Doc.paragraph(Doc.status(Status.core, "warning"), " ", warning)), { compact: true })], warnings.length === 0 ? Doc.paragraph(Doc.status(Status.core, "success"), " No unknown keys detected.") : Doc.paragraph(`${warnings.length} warning(s) found.`)]);
334
+ const keys = Doc.section("Config keys", [...warnings.length === 0 ? [] : [Doc.list(warnings.map((warning) => Doc.paragraph(Doc.status(Status.core, "warning"), " ", warning)), { compact: true })], warnings.length === 0 ? Doc.paragraph(Doc.status(Status.core, "success"), " No unknown keys detected.") : Doc.paragraph(`${Fmt.plural(warnings.length, "warning")} found.`)]);
367
335
  const loaded = yield* credentialsFile.loadOrDefault({ profiles: {} }).pipe(Effect.result);
368
336
  const credentials = loaded._tag === "Success" ? loaded.success : { profiles: {} };
369
337
  const profileNames = Object.keys(credentials.profiles);
370
338
  const credentialBlocks = [];
371
- if (loaded._tag === "Failure") credentialBlocks.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Credentials: FAILED to load — ${String(loaded.failure.message ?? loaded.failure)}`), Doc.paragraph("Unknown keys are rejected. `op_service_account_token` was removed — that token now comes from OP_SERVICE_ACCOUNT_TOKEN in the environment."));
339
+ if (loaded._tag === "Failure") credentialBlocks.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Credentials: FAILED to load — ${Fmt.sanitize(Fmt.describe(loaded.failure))}`), Doc.paragraph("Unknown keys are rejected. `op_service_account_token` was removed — that token now comes from OP_SERVICE_ACCOUNT_TOKEN in the environment."));
372
340
  else if (profileNames.length === 0) credentialBlocks.push(Doc.paragraph("Credentials: no profiles configured"));
373
341
  else {
374
- credentialBlocks.push(Doc.paragraph(`Credentials: ${profileNames.length} profile(s)`));
342
+ credentialBlocks.push(Doc.paragraph(`Credentials: ${Fmt.plural(profileNames.length, "profile")}`));
375
343
  let usesOnePassword = false;
376
344
  const profileLines = [];
377
345
  for (const [name, profile] of Object.entries(credentials.profiles)) {
@@ -402,26 +370,27 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
402
370
  keys,
403
371
  Doc.section("Credentials", credentialBlocks)
404
372
  ])]);
405
- const tokenLines = [];
406
- for (const [name, profile] of Object.entries(credentials.profiles)) {
407
- const resolverLayer = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
408
- const resolved = yield* Effect.gen(function* () {
409
- return yield* (yield* CredentialResolver).resolveGitHubToken(profile);
410
- }).pipe(Effect.provide(resolverLayer), Effect.result);
411
- if (resolved._tag === "Failure") {
412
- tokenLines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: could not resolve — ${String(resolved.failure)}`));
413
- continue;
414
- }
415
- const identity = yield* Effect.gen(function* () {
416
- return yield* (yield* GitHubClient).request("GET /user", {});
417
- }).pipe(Effect.provide(GitHubClient.layerFromToken({ token: resolved.success })), Effect.result);
418
- if (identity._tag !== "Success") {
419
- tokenLines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: resolved but REJECTED by GitHub — ${String(identity.failure)}`));
420
- continue;
373
+ const tokenLines = yield* Effect.gen(function* () {
374
+ const resolver = yield* CredentialResolver;
375
+ const lines = [];
376
+ for (const [name, profile] of Object.entries(credentials.profiles)) {
377
+ const resolved = yield* resolver.resolveGitHubToken(profile).pipe(Effect.result);
378
+ if (resolved._tag === "Failure") {
379
+ lines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: could not resolve — ${Fmt.sanitize(Fmt.describe(resolved.failure))}`));
380
+ continue;
381
+ }
382
+ const identity = yield* Effect.gen(function* () {
383
+ return yield* (yield* GitHubClient).request("GET /user", {});
384
+ }).pipe(Effect.provide(GitHubClient.layerFromToken({ token: resolved.success })), Effect.result);
385
+ if (identity._tag !== "Success") {
386
+ lines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: resolved but REJECTED by GitHub — ${Fmt.sanitize(Fmt.describe(identity.failure))}`));
387
+ continue;
388
+ }
389
+ const line = describeIdentity(name, profile, identity.success.login);
390
+ lines.push(Doc.paragraph(Doc.status(Status.core, line.ok ? "success" : "failure"), " ", line.text));
421
391
  }
422
- const line = describeIdentity(name, profile, identity.success.login);
423
- tokenLines.push(Doc.paragraph(Doc.status(Status.core, line.ok ? "success" : "failure"), " ", line.text));
424
- }
392
+ return lines;
393
+ }).pipe(Effect.provide(ResolverLive));
425
394
  const permissions = Doc.section("Required fine-grained token permissions", [
426
395
  Doc.list(REQUIRED_PERMISSIONS.map(([scope, permission, why]) => Doc.paragraph(`${scope} > ${permission} — ${why}`)), { compact: true }),
427
396
  Doc.paragraph(Doc.text("(Metadata: Read is mandatory and granted automatically)", "muted")),
@@ -435,9 +404,6 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
435
404
  *
436
405
  * @public
437
406
  */
438
- const doctorCommand = Command.make("doctor", {}, () => Effect.gen(function* () {
439
- const flag = yield* ConfigFlag;
440
- yield* doctorHandler(Option.getOrUndefined(flag));
441
- })).pipe(Command.withDescription("Diagnose the config: unknown keys, credential posture, required token permissions"));
407
+ const doctorCommand = Command.make("doctor", {}, () => doctorHandler).pipe(Command.withDescription("Diagnose the config: unknown keys, credential posture, required token permissions"));
442
408
 
443
409
  export { describeIdentity, doctorCommand, doctorHandler };
@@ -21,9 +21,7 @@ const formatDuration = (summary) => {
21
21
  if (summary.finishedAt === null) return "—";
22
22
  const millis = Date.parse(summary.finishedAt) - Date.parse(summary.startedAt);
23
23
  if (Number.isNaN(millis) || millis < 0) return "—";
24
- if (millis < 1e3) return `${millis}ms`;
25
- const seconds = millis / 1e3;
26
- return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m${String(Math.round(seconds % 60)).padStart(2, "0")}s`;
24
+ return Fmt.duration(millis);
27
25
  };
28
26
  /**
29
27
  * What became of a run.
@@ -212,7 +210,7 @@ const pickRun = (runs, abbreviate) => Effect.gen(function* () {
212
210
  const outcome = formatOutcome(run);
213
211
  return {
214
212
  label: `${abbreviate(run.id)} · ${formatWhen(run.startedAt)} · ${outcome} · ${Fmt.sanitize(run.group ?? "(all)")}`,
215
- detail: `${formatMode(run)} · ${run.changes} change${run.changes === 1 ? "" : "s"} · ${formatDuration(run)}`,
213
+ detail: `${formatMode(run)} · ${Fmt.plural(run.changes, "change")} · ${formatDuration(run)}`,
216
214
  value: run
217
215
  };
218
216
  })
@@ -286,7 +284,7 @@ const showHandler = (prefix) => Effect.gen(function* () {
286
284
  const what = record.kind === record.name ? record.kind : `${record.kind} ${record.name}`;
287
285
  const action = dryRun ? `would ${wouldForm(record.action)}` : record.action;
288
286
  return [
289
- Doc.text(action.padEnd(18), actionToken(record.action, dryRun)),
287
+ Doc.text(Fmt.pad(action, 18), actionToken(record.action, dryRun)),
290
288
  " ",
291
289
  what,
292
290
  detail
@@ -312,7 +310,7 @@ const UNTOUCHED = "Applied state and the cache are untouched — drift detection
312
310
  */
313
311
  const pruneHandler = (keep) => Effect.gen(function* () {
314
312
  const removed = yield* (yield* SyncJournal).prune(keep).pipe(Effect.orElseSucceed(() => 0));
315
- yield* removed === 0 ? CliMessage.info(`Nothing to prune; ${keep} or fewer runs are recorded.`) : CliMessage.success(`Pruned ${removed} run${removed === 1 ? "" : "s"}, keeping the newest ${keep}.`);
313
+ yield* removed === 0 ? CliMessage.info(`Nothing to prune; ${keep} or fewer runs are recorded.`) : CliMessage.success(`Pruned ${Fmt.plural(removed, "run")}, keeping the newest ${keep}.`);
316
314
  yield* CliMessage.info(UNTOUCHED);
317
315
  });
318
316
  /**
@@ -320,7 +318,7 @@ const pruneHandler = (keep) => Effect.gen(function* () {
320
318
  */
321
319
  const clearHandler = () => Effect.gen(function* () {
322
320
  const removed = yield* (yield* SyncJournal).clear().pipe(Effect.orElseSucceed(() => 0));
323
- yield* CliMessage.success(`Cleared ${removed} run${removed === 1 ? "" : "s"} from the journal.`);
321
+ yield* CliMessage.success(`Cleared ${Fmt.plural(removed, "run")} from the journal.`);
324
322
  yield* CliMessage.info(UNTOUCHED);
325
323
  });
326
324
  /**
@@ -1,5 +1,5 @@
1
- import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
2
1
  import { Invocation } from "../../services/Invocation.js";
2
+ import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
3
3
  import { configSchemaHost, credentialsSchemaHost, schemaDirective } from "../../schemas/hosted.js";
4
4
  import { Effect, FileSystem, Option, Path } from "effect";
5
5
  import { CliExit, CliMessage } from "@effected/cli";
@@ -1,11 +1,13 @@
1
- import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
2
1
  import { Invocation } from "../../services/Invocation.js";
3
- import { CACHE_DB_FILENAME, STATE_DB_FILENAME } from "../../store/files.js";
2
+ import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
3
+ import { CACHE_OPTIONS, STORE_OPTIONS } from "../../store/files.js";
4
4
  import { Effect, FileSystem, Path } from "effect";
5
- import { CliExit, CliInteractive, CliMessage, Doc } from "@effected/cli";
5
+ import { AppCache, AppStore } from "@effected/app";
6
+ import { CliExit, CliInteractive, CliMessage, Doc, Fmt } from "@effected/cli";
6
7
  import { CliError, Command, Flag } from "effect/cli";
7
8
  import { CliUi, Confirm, MultiSelect } from "@effected/cli/ui";
8
9
  import { AppDirs, Xdg } from "@effected/xdg";
10
+ import { Walker } from "@effected/walker";
9
11
 
10
12
  // src/cli/commands/nuke.ts
11
13
  const forceFlag = Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Delete without asking. Intended for scripts; there is no undo"));
@@ -81,23 +83,18 @@ const isInitGitignore = (contents) => {
81
83
  */
82
84
  const findTargets = (fs, path, appDirs, from) => Effect.gen(function* () {
83
85
  const files = [];
84
- let dir = from;
85
- for (;;) {
86
- files.push({
87
- path: path.join(dir, CONFIG_FILENAME),
88
- what: "project config",
89
- cost: "loses your groups and settings",
90
- location: "project"
91
- }, {
92
- path: path.join(dir, CREDENTIALS_FILENAME),
93
- what: "project credentials",
94
- cost: "loses token references, not tokens",
95
- location: "project"
96
- });
97
- const parent = path.dirname(dir);
98
- if (parent === dir) break;
99
- dir = parent;
100
- }
86
+ const chain = yield* Walker.ascend(from);
87
+ for (const dir of chain) files.push({
88
+ path: path.join(dir, CONFIG_FILENAME),
89
+ what: "project config",
90
+ cost: "loses your groups and settings",
91
+ location: "project"
92
+ }, {
93
+ path: path.join(dir, CREDENTIALS_FILENAME),
94
+ what: "project credentials",
95
+ cost: "loses token references, not tokens",
96
+ location: "project"
97
+ });
101
98
  files.push({
102
99
  path: path.join(appDirs.dirs.config, CONFIG_FILENAME),
103
100
  what: "user config",
@@ -124,12 +121,12 @@ const findTargets = (fs, path, appDirs, from) => Effect.gen(function* () {
124
121
  location: "user"
125
122
  });
126
123
  const databases = [{
127
- path: path.join(appDirs.dirs.state, STATE_DB_FILENAME),
124
+ path: yield* AppStore.location(STORE_OPTIONS),
128
125
  what: "state database",
129
126
  cost: "loses run history AND the drift baselines — drift detection restarts from nothing",
130
127
  location: "state"
131
128
  }, {
132
- path: path.join(appDirs.dirs.cache, CACHE_DB_FILENAME),
129
+ path: yield* AppCache.location(CACHE_OPTIONS),
133
130
  what: "cache database",
134
131
  cost: "loses cached GitHub reads — rebuilt on the next sync",
135
132
  location: "cache"
@@ -168,13 +165,12 @@ const removeEmptyDirs = (fs, appDirs) => Effect.gen(function* () {
168
165
  if (entries._tag === "None" || entries.value.length > 0) continue;
169
166
  const outcome = yield* fs.remove(dir, { recursive: true }).pipe(Effect.result);
170
167
  if (outcome._tag === "Failure") {
171
- yield* Effect.logWarning(`could not remove empty directory ${dir} — ${String(outcome.failure)}`);
168
+ yield* Effect.logWarning(`could not remove empty directory ${dir} — ${Fmt.describe(outcome.failure)}`);
172
169
  continue;
173
170
  }
174
171
  yield* CliMessage.success(`removed empty directory ${dir}`);
175
172
  }
176
173
  });
177
- const plural = (n) => `${n} file${n === 1 ? "" : "s"}`;
178
174
  /**
179
175
  * How many files a set of targets removes.
180
176
  *
@@ -269,7 +265,7 @@ const choose = (targets, short) => Effect.gen(function* () {
269
265
  sections: pickerSections(targets, short)
270
266
  }));
271
267
  if (chosen.length === 0) return [];
272
- const { confirmed } = yield* CliUi.run(Confirm.screen({ message: `Delete ${plural(fileCount(chosen))}?` }));
268
+ const { confirmed } = yield* CliUi.run(Confirm.screen({ message: `Delete ${Fmt.plural(fileCount(chosen), "file")}?` }));
273
269
  return confirmed ? chosen : [];
274
270
  }).pipe(Effect.catchTag("NotInteractive", () => Effect.fail(refusal())));
275
271
  /**
@@ -336,16 +332,16 @@ const nukeHandler = (force) => Effect.gen(function* () {
336
332
  for (const target of targets) for (const file of target.paths) {
337
333
  const outcome = yield* fs.remove(file).pipe(Effect.result);
338
334
  if (outcome._tag === "Failure") {
339
- yield* Effect.logError(`could not remove ${file} — ${String(outcome.failure)}`);
335
+ yield* Effect.logError(`could not remove ${file} — ${Fmt.describe(outcome.failure)}`);
340
336
  continue;
341
337
  }
342
338
  yield* CliMessage.success(`removed ${file}`);
343
339
  removed += 1;
344
340
  }
345
341
  yield* removeEmptyDirs(fs, appDirs);
346
- if (removed === total) yield* CliMessage.success(`Done. ${plural(removed)} removed.`);
342
+ if (removed === total) yield* CliMessage.success(`Done. ${Fmt.plural(removed, "file")} removed.`);
347
343
  else {
348
- yield* CliMessage.failure(`Removed ${removed} of ${plural(total)}; the failures are listed above.`);
344
+ yield* CliMessage.failure(`Removed ${removed} of ${Fmt.plural(total, "file")}; the failures are listed above.`);
349
345
  yield* CliExit.set(1);
350
346
  }
351
347
  });
@@ -18,22 +18,6 @@ import { CliUi } from "@effected/cli/ui";
18
18
  import { CodeScanning, DeploymentEnvironment, GitHubClient, GitHubRepository, RepositorySecret, RepositorySecurity, RepositoryVariable, Ruleset, WorkflowDispatch } from "@effected/github";
19
19
 
20
20
  // src/cli/commands/sync.ts
21
- const isPhaseName = (value) => PHASE_NAMES.includes(value);
22
- const phaseSet = (values) => new Set(values.filter(isPhaseName));
23
- /**
24
- * Phase names the user typed that do not exist.
25
- *
26
- * @remarks
27
- * Unrecognised names used to be filtered out silently, and an empty `only` set
28
- * reads downstream as "no filter" — so `--only nonsense` ran **all eight
29
- * phases**. The flag reached for to limit the blast radius of a destructive
30
- * command did the opposite of what it says, and reported success.
31
- *
32
- * Any unknown name is refused, including alongside valid ones: a typo in
33
- * `--only settings,secrts` means the user asked for two phases and would
34
- * otherwise get one, silently. That is a narrower wrong answer, not a safer one.
35
- */
36
- const unknownPhases = (values) => values.filter((v) => !isPhaseName(v));
37
21
  /**
38
22
  * Group names by the credential profile they authenticate as.
39
23
  *
@@ -148,8 +132,6 @@ const runUnderSyncView = (view, work) => Effect.scoped(Effect.gen(function* () {
148
132
  * @public
149
133
  */
150
134
  const syncHandler = (input) => Effect.gen(function* () {
151
- const badPhases = [...unknownPhases(input.only), ...unknownPhases(input.skip)];
152
- 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.` }));
153
135
  const configFile = yield* ReposetsConfigFile;
154
136
  const credentialsFile = yield* ReposetsCredentialsFile;
155
137
  const source = (yield* configFile.discover)[0];
@@ -237,8 +219,8 @@ const syncHandler = (input) => Effect.gen(function* () {
237
219
  configDir,
238
220
  group: input.group,
239
221
  repo: input.repo,
240
- only: phaseSet(input.only),
241
- skip: phaseSet(input.skip)
222
+ only: new Set(input.only),
223
+ skip: new Set(input.skip)
242
224
  });
243
225
  }).pipe(Effect.provide(engineLayer));
244
226
  repos += partial.repos;
@@ -278,8 +260,8 @@ const syncCommand = Command.make("sync", {
278
260
  failOnDrift: Flag.Boolean("fail-on-drift").pipe(Flag.withDefault(false), Flag.withDescription("Exit non-zero when a resource was changed outside reposets")),
279
261
  group: Flag.String("group").pipe(Flag.optional, Flag.withDescription("Sync only this group")),
280
262
  repo: Flag.String("repo").pipe(Flag.optional, Flag.withDescription("Sync only this repository")),
281
- only: Flag.String("only").pipe(Flag.atLeast(0), Flag.withDescription("Run only these phases")),
282
- skip: Flag.String("skip").pipe(Flag.atLeast(0), Flag.withDescription("Skip these phases")),
263
+ only: Flag.Literals("only", PHASE_NAMES).pipe(Flag.atLeast(0), Flag.withDescription("Run only these phases")),
264
+ skip: Flag.Literals("skip", PHASE_NAMES).pipe(Flag.atLeast(0), Flag.withDescription("Skip these phases")),
283
265
  debug: Flag.Boolean("debug").pipe(Flag.withDefault(false), Flag.withDescription("Annotate output with value sources and the fingerprints behind a drift report"))
284
266
  }, (input) => syncHandler({
285
267
  dryRun: input.dryRun,
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FileSystem, Layer, Path, Schema } from "effect";
1
+ import { Context, FileSystem, Layer, Path, Schema } from "effect";
2
2
  import { AppDirs, Xdg } from "@effected/xdg";
3
3
  /**
4
4
  * The whole `reposets.config.toml` document.
@@ -323,6 +323,44 @@ export declare const ConfigSchema: Schema.Struct<{
323
323
  * @public
324
324
  */
325
325
  type Config = typeof ConfigSchema.Type;
326
+ declare const Invocation_base: Context.ServiceClass<Invocation, "reposets/Invocation", {
327
+ /** The directory the command was run from. */
328
+ readonly cwd: string;
329
+ /** The build's version, substituted by the bundler at build time. */
330
+ readonly version: string;
331
+ }>;
332
+ /**
333
+ * The facts about this process a command needs, read once at the entrypoint.
334
+ *
335
+ * @remarks
336
+ * Only the CLI entrypoint (`src/cli/index.ts`) reads `process`. Everything
337
+ * below it receives the working directory and the build's version through this
338
+ * service instead, so a handler can be driven by a test that hands it a
339
+ * different directory without `process.chdir`.
340
+ *
341
+ * The environment is deliberately NOT here. Environment variables are read
342
+ * through Effect's `Config`, which resolves them from the ambient
343
+ * `ConfigProvider` — the process environment by default, an explicit record
344
+ * under `ConfigProvider.layer` in a test. That is core's own seam for the
345
+ * environment, and `@effected/xdg` already reads `HOME` and `XDG_*` through it;
346
+ * a second, hand-rolled one here would let the two disagree.
347
+ *
348
+ * The whole shape is one immutable value with no behaviour, so `Layer.succeed`
349
+ * is the complete double — there is nothing for a partial mock to stub. The
350
+ * moment a method appears here it stops being a value and becomes a service.
351
+ *
352
+ * @public
353
+ */
354
+ export declare class Invocation extends Invocation_base {
355
+ /**
356
+ * A fixed invocation, for tests and for the entrypoint alike.
357
+ *
358
+ * @remarks
359
+ * Each call mints a fresh layer; bind the result to a `const` rather than
360
+ * calling this at more than one provide site.
361
+ */
362
+ static readonly layer: (value: Context.Service.Shape<typeof Invocation>) => Layer.Layer<Invocation>;
363
+ }
326
364
  /**
327
365
  * The config file's fixed name, in every tier of the resolver chain.
328
366
  *
@@ -747,6 +785,6 @@ export declare class ConfigFlagMissingConfig extends ConfigFlagMissingConfig_bas
747
785
  *
748
786
  * @public
749
787
  */
750
- export declare const makeConfigFilesLive: (configFlag: string | undefined) => Layer.Layer<ReposetsConfigFile, ConfigFlagNotFound | ConfigFlagMissingConfig, FileSystem.FileSystem | Path.Path | AppDirs | Xdg>;
788
+ export declare const makeConfigFilesLive: (configFlag: string | undefined) => Layer.Layer<ReposetsConfigFile, ConfigFlagNotFound | ConfigFlagMissingConfig, FileSystem.FileSystem | Path.Path | AppDirs | Xdg | Invocation>;
751
789
  export type { Config };
752
790
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ConfigSchema } from "./schemas/config.js";
2
+ import { Invocation } from "./services/Invocation.js";
2
3
  import { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ReposetsConfigFile, makeConfigFilesLive } from "./services/ConfigFiles.js";
3
4
 
4
- export { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ConfigSchema, ReposetsConfigFile, makeConfigFilesLive };
5
+ export { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ConfigSchema, Invocation, ReposetsConfigFile, makeConfigFilesLive };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reposets",
3
- "version": "3.1.8",
3
+ "version": "4.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,20 +48,22 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@1password/sdk": "^0.5.0",
51
- "@effect/platform-node": "^4.0.6",
52
- "@effected/app": "^0.27.1",
53
- "@effected/cli": "^0.22.2",
54
- "@effected/config-file": "^0.20.1",
55
- "@effected/env": "^0.5.0",
56
- "@effected/github": "^0.25.0",
57
- "@effected/glob": "^0.14.0",
58
- "@effected/schemastore": "^0.26.2",
59
- "@effected/store": "^0.19.0",
60
- "@effected/toml": "^0.16.0",
61
- "@effected/walker": "^0.20.1",
62
- "@effected/xdg": "^0.15.1",
51
+ "@effect/platform-node": "^4.0.7",
52
+ "@effected/app": "^0.29.0",
53
+ "@effected/cli": "^0.24.0",
54
+ "@effected/config-file": "^0.22.0",
55
+ "@effected/env": "^0.7.0",
56
+ "@effected/github": "^0.26.0",
57
+ "@effected/glob": "^0.16.0",
58
+ "@effected/jsonc": "^0.21.0",
59
+ "@effected/schemastore": "^0.27.0",
60
+ "@effected/store": "^0.21.0",
61
+ "@effected/toml": "^0.17.0",
62
+ "@effected/walker": "^0.22.0",
63
+ "@effected/xdg": "^0.17.0",
64
+ "@effected/yaml": "^0.26.0",
63
65
  "blakejs": "^1.2.1",
64
- "effect": "^4.0.6",
66
+ "effect": "^4.0.7",
65
67
  "ink": "^8.0.0",
66
68
  "react": "^19.3.1"
67
69
  },
@@ -1,5 +1,6 @@
1
1
  import { ConfigSchema } from "../schemas/config.js";
2
2
  import { CredentialsSchema } from "../schemas/credentials.js";
3
+ import { Invocation } from "./Invocation.js";
3
4
  import { Data, Effect, FileSystem, Layer, Path } from "effect";
4
5
  import { AppConfig } from "@effected/app";
5
6
  import { ConfigFile, ConfigResolver } from "@effected/config-file";
@@ -73,15 +74,30 @@ const STRICT_KEYS = {
73
74
  * `AppConfig.layer` appends its own XDG tier after the resolvers given here, so
74
75
  * the upward walk wins and the XDG fallback comes free.
75
76
  *
77
+ * The walk starts at {@link Invocation}'s `cwd`, never the process's own:
78
+ * `upwardWalk` defaults to `process.cwd()` when given no `cwd`, which made
79
+ * config discovery the one reader of `process` below the entrypoint.
80
+ *
81
+ * **Writes go back to the file that was found.** `AppConfig` pins
82
+ * `saveTarget: "default"`, the XDG path, which would split reads from writes
83
+ * when a project-local `reposets.credentials.toml` exists: `credentials create`
84
+ * would land in XDG while `list` and `sync` keep reading the local file.
85
+ * `"source"` writes to the highest-priority file the chain found, and to XDG
86
+ * only when there is none.
87
+ *
76
88
  * @public
77
89
  */
78
- const CredentialsFilesLive = AppConfig.layer(ReposetsCredentialsFile, {
90
+ const CredentialsFilesLive = Layer.unwrap(Effect.map(Invocation, ({ cwd }) => AppConfig.layer(ReposetsCredentialsFile, {
79
91
  filename: CREDENTIALS_FILENAME,
80
92
  schema: CredentialsSchema,
81
93
  codec: TomlCodec,
82
- resolvers: [ConfigResolver.upwardWalk({ filename: CREDENTIALS_FILENAME })],
83
- parseOptions: STRICT_KEYS
84
- });
94
+ resolvers: [ConfigResolver.upwardWalk({
95
+ filename: CREDENTIALS_FILENAME,
96
+ cwd
97
+ })],
98
+ parseOptions: STRICT_KEYS,
99
+ saveTarget: "source"
100
+ })));
85
101
  /**
86
102
  * Raised when `--config` names a path that does not exist.
87
103
  *
@@ -144,14 +160,23 @@ var ConfigFlagMissingConfig = class extends Data.TaggedError("ConfigFlagMissingC
144
160
  * A directory is checked for the config file by existence only, not decoded:
145
161
  * a file that is present but invalid must still resolve, so `doctor` can
146
162
  * diagnose it and a command can report its decode error against the right path.
163
+ *
164
+ * Everything relative is taken against {@link Invocation}'s `cwd`: the upward
165
+ * walk starts there, and a relative `--config` is resolved there before it is
166
+ * checked, so a test that hands a command another directory moves both.
147
167
  */
148
- const resolversFor = (configFlag) => Effect.gen(function* () {
149
- if (configFlag === void 0) return [ConfigResolver.upwardWalk({ filename: CONFIG_FILENAME })];
168
+ const resolversFor = (flag) => Effect.gen(function* () {
169
+ const { cwd } = yield* Invocation;
170
+ if (flag === void 0) return [ConfigResolver.upwardWalk({
171
+ filename: CONFIG_FILENAME,
172
+ cwd
173
+ })];
150
174
  const fs = yield* FileSystem.FileSystem;
175
+ const path = yield* Path.Path;
176
+ const configFlag = path.resolve(cwd, flag);
151
177
  const info = yield* fs.stat(configFlag).pipe(Effect.option);
152
178
  if (info._tag === "None") return yield* new ConfigFlagNotFound({ path: configFlag });
153
179
  if (info.value.type !== "Directory") return [ConfigResolver.explicitPath(configFlag)];
154
- const path = yield* Path.Path;
155
180
  if (!(yield* fs.exists(path.join(configFlag, "reposets.config.toml")).pipe(Effect.orElseSucceed(() => false)))) return yield* new ConfigFlagMissingConfig({
156
181
  dir: configFlag,
157
182
  filename: CONFIG_FILENAME
@@ -1,7 +1,5 @@
1
1
  import { OnePasswordClient } from "./OnePasswordClient.js";
2
- import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
3
- import { readFileSync } from "node:fs";
4
- import { isAbsolute, resolve } from "node:path";
2
+ import { Config, Context, Data, Effect, FileSystem, Layer, Option, Path, Redacted } from "effect";
5
3
 
6
4
  // src/services/CredentialResolver.ts
7
5
  /**
@@ -44,18 +42,21 @@ const fromEnv = (label, variable) => Config.option(Config.String(variable)).pipe
44
42
  })),
45
43
  onSome: (value) => Effect.succeed(Redacted.make(value, { label }))
46
44
  })));
47
- /** Read a file, failing in terms of its path. */
48
- const fromFile = (label, filePath, basePath) => Effect.try({
49
- try: () => {
50
- const fullPath = isAbsolute(filePath) ? filePath : resolve(basePath, filePath);
51
- return Redacted.make(readFileSync(fullPath, "utf-8").trim(), { label });
52
- },
53
- catch: (error) => new ResolveError({
54
- label,
55
- source: "file",
56
- reason: error instanceof Error ? error.message : String(error)
57
- })
58
- });
45
+ /**
46
+ * Read a file, failing in terms of its path.
47
+ *
48
+ * @remarks
49
+ * Through core's `FileSystem`, so a test reads from a seeded in-memory volume
50
+ * rather than the disk. A relative path resolves against `basePath`. The
51
+ * failure reason is the `PlatformError` message — `NotFound:
52
+ * FileSystem.readFile (/abs/path)` — which names the path and never the
53
+ * contents.
54
+ */
55
+ const fromFile = (fs, path, label, filePath, basePath) => fs.readFileString(path.isAbsolute(filePath) ? filePath : path.resolve(basePath, filePath)).pipe(Effect.map((text) => Redacted.make(text.trim(), { label })), Effect.mapError((error) => new ResolveError({
56
+ label,
57
+ source: "file",
58
+ reason: error.message
59
+ })));
59
60
  /**
60
61
  * Turns the references in a credential profile into usable values.
61
62
  *
@@ -84,6 +85,8 @@ const fromFile = (label, filePath, basePath) => Effect.try({
84
85
  */
85
86
  var CredentialResolver = class extends Context.Service()("reposets/CredentialResolver", { make: Effect.gen(function* () {
86
87
  const onePassword = yield* OnePasswordClient;
88
+ const fs = yield* FileSystem.FileSystem;
89
+ const path = yield* Path.Path;
87
90
  /** One `op://` reference, with 1Password's failure restated in this service's terms. */
88
91
  const fromOnePassword = (label, reference) => onePassword.resolve(reference).pipe(Effect.mapError((error) => new ResolveError({
89
92
  label,
@@ -99,14 +102,15 @@ var CredentialResolver = class extends Context.Service()("reposets/CredentialRes
99
102
  if (section === void 0) return resolved;
100
103
  for (const [label, reference] of Object.entries(section.op ?? {})) resolved.set(label, yield* fromOnePassword(label, reference));
101
104
  for (const [label, variable] of Object.entries(section.env ?? {})) resolved.set(label, yield* fromEnv(label, variable));
102
- for (const [label, filePath] of Object.entries(section.file ?? {})) resolved.set(label, yield* fromFile(label, filePath, basePath));
105
+ for (const [label, filePath] of Object.entries(section.file ?? {})) resolved.set(label, yield* fromFile(fs, path, label, filePath, basePath));
103
106
  for (const [label, value] of Object.entries(section.value ?? {})) resolved.set(label, Redacted.make(typeof value === "string" ? value : JSON.stringify(value), { label }));
104
107
  return resolved;
105
108
  })
106
109
  };
107
110
  }) }) {};
108
111
  /**
109
- * Live resolver, over the ambient {@link OnePasswordClient}.
112
+ * Live resolver, over the ambient {@link OnePasswordClient} and the platform's
113
+ * `FileSystem` and `Path`.
110
114
  *
111
115
  * @public
112
116
  */
@@ -1,4 +1,5 @@
1
1
  import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
2
+ import { Fmt } from "@effected/cli";
2
3
 
3
4
  // src/services/OnePasswordClient.ts
4
5
  /**
@@ -76,7 +77,7 @@ var OnePasswordClient = class OnePasswordClient extends Context.Service()("repos
76
77
  },
77
78
  catch: (error) => new OnePasswordError({
78
79
  reference,
79
- reason: error instanceof Error ? error.message : String(error)
80
+ reason: Fmt.describe(error)
80
81
  })
81
82
  });
82
83
  return Redacted.make(value, { label: reference });
@@ -129,7 +129,7 @@ function SyncLoggerLive(config) {
129
129
  * 8; the dry-run `would <present>` forms pad to 14. The result is
130
130
  * concatenated directly, with no separating space.
131
131
  */
132
- const formatVerb = (pastTense, presentTense) => dryRun ? `would ${presentTense}`.padEnd(14) : pastTense.padEnd(8);
132
+ const formatVerb = (pastTense, presentTense) => dryRun ? Fmt.pad(`would ${presentTense}`, 14) : Fmt.pad(pastTense, 8);
133
133
  /** A change made reads as done; the same change on a dry run, as information. */
134
134
  const changeStatus = dryRun ? "info" : "success";
135
135
  return {
@@ -139,7 +139,7 @@ function SyncLoggerLive(config) {
139
139
  dryRun
140
140
  }),
141
141
  groupStart: (name, selected, declared) => {
142
- const scope = selected === declared ? `${selected} ${selected === 1 ? "repo" : "repos"}` : `${selected} of ${declared} ${declared === 1 ? "repo" : "repos"}`;
142
+ const scope = selected === declared ? Fmt.plural(selected, "repo") : `${selected} of ${Fmt.plural(declared, "repo")}`;
143
143
  return emit(`group: ${name} (${scope})`).pipe(Effect.andThen(publish({
144
144
  _tag: "GroupStarted",
145
145
  name,
@@ -187,7 +187,7 @@ function SyncLoggerLive(config) {
187
187
  driftDetected: (resource, name, drift) => {
188
188
  const consequence = !drift.needsApply ? "already matches config, nothing written" : dryRun ? "would overwrite" : "overwritten";
189
189
  const fingerprints = debug ? ` <- applied ${drift.applied} live ${drift.live}` : "";
190
- return action("warning", `${"drift".padEnd(8)}${resource} ${name} changed outside reposets — ${consequence}${fingerprints}`).pipe(Effect.andThen(publish({
190
+ return action("warning", `${Fmt.pad("drift", 8)}${resource} ${name} changed outside reposets — ${consequence}${fingerprints}`).pipe(Effect.andThen(publish({
191
191
  _tag: "Drift",
192
192
  resource,
193
193
  name,
@@ -213,7 +213,7 @@ function SyncLoggerLive(config) {
213
213
  const errs = yield* Ref.get(errors);
214
214
  if (errs.length === 0) yield* Console.log(`${mark("success")} Sync complete!`);
215
215
  else {
216
- yield* emitFailure(`Sync complete with ${errs.length} ${errs.length === 1 ? "error" : "errors"}:`);
216
+ yield* emitFailure(`Sync complete with ${Fmt.plural(errs.length, "error")}:`);
217
217
  for (const err of errs) yield* emitError(Fmt.sanitize(err.repo === "" ? ` ${err.context} — ${err.message}` : ` ${err.repo}: ${err.context} — ${err.message}`));
218
218
  }
219
219
  yield* publish({
package/store/files.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { migrations } from "./migrations.js";
2
+
1
3
  // src/store/files.ts
2
4
  /**
3
5
  * The state database's file name, under the XDG state directory.
@@ -20,5 +22,29 @@ const STATE_DB_FILENAME = "store.db";
20
22
  * @public
21
23
  */
22
24
  const CACHE_DB_FILENAME = "cache.db";
25
+ /**
26
+ * The state database's options: what `AppStore.layer` opens and what
27
+ * `AppStore.location` reports.
28
+ *
29
+ * @remarks
30
+ * One value for both, so the path `doctor` prints and `nuke` deletes is
31
+ * derived by the same code from the same options as the path the layer opens
32
+ * — `AppStore.location` is that derivation, not a copy of it. The directory
33
+ * is left to its default, `state`.
34
+ *
35
+ * @public
36
+ */
37
+ const STORE_OPTIONS = {
38
+ migrations,
39
+ filename: STATE_DB_FILENAME
40
+ };
41
+ /**
42
+ * The cache database's options, for `AppCache.layer` and `AppCache.location`,
43
+ * for the same reason as {@link STORE_OPTIONS}. The directory is left to its
44
+ * default, `cache`.
45
+ *
46
+ * @public
47
+ */
48
+ const CACHE_OPTIONS = { filename: CACHE_DB_FILENAME };
23
49
 
24
- export { CACHE_DB_FILENAME, STATE_DB_FILENAME };
50
+ export { CACHE_DB_FILENAME, CACHE_OPTIONS, STATE_DB_FILENAME, STORE_OPTIONS };
package/sync/phase.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { Fmt } from "@effected/cli";
2
3
 
3
4
  // src/sync/phase.ts
4
5
  /** A phase that touched nothing and failed at nothing. */
@@ -5,6 +5,7 @@ import { read } from "./resource.js";
5
5
  import { fingerprint } from "../../lib/fingerprint.js";
6
6
  import { decide, isDrift, needsApply } from "../decide.js";
7
7
  import { Effect, Option } from "effect";
8
+ import { Fmt } from "@effected/cli";
8
9
  import { CodeScanning, WorkflowDispatch } from "@effected/github";
9
10
 
10
11
  // src/sync/phases/code-scanning.ts
@@ -151,7 +152,7 @@ const codeScanningPhase = Effect.gen(function* () {
151
152
  errors
152
153
  };
153
154
  if (!ctx.dryRun) {
154
- const failure = yield* codeScanning.configure(desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(error.message ?? String(error)))));
155
+ const failure = yield* codeScanning.configure(desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(Fmt.describe(error)))));
155
156
  if (Option.isSome(failure)) return {
156
157
  changes: [],
157
158
  errors: [...errors, {
@@ -3,6 +3,7 @@ import { AppliedState, lookup } from "../../store/AppliedState.js";
3
3
  import { fingerprint } from "../../lib/fingerprint.js";
4
4
  import { decide, isDrift, needsApply } from "../decide.js";
5
5
  import { Effect, Option } from "effect";
6
+ import { Fmt } from "@effected/cli";
6
7
  import { DeploymentEnvironment } from "@effected/github";
7
8
 
8
9
  // src/sync/phases/environments.ts
@@ -68,7 +69,7 @@ const environmentsPhase = Effect.gen(function* () {
68
69
  if (isDrift(decision)) yield* logger.driftDetected(KIND, name, decision);
69
70
  if (!needsApply(decision)) continue;
70
71
  if (!ctx.dryRun) {
71
- const failure = yield* environments.upsert(name, desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(error.message ?? String(error)))));
72
+ const failure = yield* environments.upsert(name, desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(Fmt.describe(error)))));
72
73
  if (Option.isSome(failure)) {
73
74
  errors.push({
74
75
  context: `environment ${name}`,
@@ -1,6 +1,5 @@
1
1
  import { Effect, Redacted } from "effect";
2
- import { readFileSync } from "node:fs";
3
- import { isAbsolute, resolve } from "node:path";
2
+ import { Fmt } from "@effected/cli";
4
3
 
5
4
  // src/sync/phases/resource.ts
6
5
  /**
@@ -25,7 +24,7 @@ import { isAbsolute, resolve } from "node:path";
25
24
  * this runs on the secret-loading path, and the error is the most likely thing
26
25
  * to reach a log.
27
26
  */
28
- const resolveOne = (group, label, ctx) => {
27
+ const resolveOne = (io, group, label, ctx) => Effect.gen(function* () {
29
28
  const entries = /* @__PURE__ */ new Map();
30
29
  const errors = [];
31
30
  if ("value" in group) {
@@ -53,21 +52,19 @@ const resolveOne = (group, label, ctx) => {
53
52
  };
54
53
  }
55
54
  for (const [name, filePath] of Object.entries(group.file)) {
56
- const fullPath = isAbsolute(filePath) ? filePath : resolve(ctx.configDir, filePath);
57
- try {
58
- entries.set(name, Redacted.make(readFileSync(fullPath, "utf-8").trim(), { label: name }));
59
- } catch (error) {
60
- errors.push({
61
- context: `${label}.${name}`,
62
- message: error instanceof Error ? error.message : String(error)
63
- });
64
- }
55
+ const fullPath = io.path.isAbsolute(filePath) ? filePath : io.path.resolve(ctx.configDir, filePath);
56
+ const text = yield* io.fs.readFileString(fullPath).pipe(Effect.result);
57
+ if (text._tag === "Success") entries.set(name, Redacted.make(text.success.trim(), { label: name }));
58
+ else errors.push({
59
+ context: `${label}.${name}`,
60
+ message: text.failure.message
61
+ });
65
62
  }
66
63
  return {
67
64
  entries,
68
65
  errors
69
66
  };
70
- };
67
+ });
71
68
  /**
72
69
  * Resolve every group a scope references, later groups winning.
73
70
  *
@@ -79,13 +76,13 @@ const resolveOne = (group, label, ctx) => {
79
76
  *
80
77
  * @public
81
78
  */
82
- const resolveGroups = (refs, definitions, ctx) => {
79
+ const resolveGroups = (io, refs, definitions, ctx) => Effect.gen(function* () {
83
80
  const entries = /* @__PURE__ */ new Map();
84
81
  const errors = [];
85
82
  for (const ref of refs) {
86
83
  const group = definitions[ref];
87
84
  if (group === void 0) continue;
88
- const outcome = resolveOne(group, ref, ctx);
85
+ const outcome = yield* resolveOne(io, group, ref, ctx);
89
86
  for (const [name, value] of outcome.entries) entries.set(name, value);
90
87
  errors.push(...outcome.errors);
91
88
  }
@@ -93,7 +90,7 @@ const resolveGroups = (refs, definitions, ctx) => {
93
90
  entries,
94
91
  errors
95
92
  };
96
- };
93
+ });
97
94
  /**
98
95
  * The names a set of groups declares, without resolving any value.
99
96
  *
@@ -133,7 +130,7 @@ const declaredNames = (refs, definitions) => {
133
130
  *
134
131
  * @public
135
132
  */
136
- const read = (effect) => effect.pipe(Effect.map((value) => ({ value })), Effect.catch((error) => Effect.succeed({ failed: error.message ?? String(error) })));
133
+ const read = (effect) => effect.pipe(Effect.map((value) => ({ value })), Effect.catch((error) => Effect.succeed({ failed: Fmt.describe(error) })));
137
134
  /**
138
135
  * Wrap an operation so a failure becomes a reportable error rather than a
139
136
  * failed run.
@@ -142,7 +139,7 @@ const read = (effect) => effect.pipe(Effect.map((value) => ({ value })), Effect.
142
139
  */
143
140
  const capture = (context, effect) => effect.pipe(Effect.as(void 0), Effect.catch((error) => Effect.succeed({
144
141
  context,
145
- message: error.message ?? String(error)
142
+ message: Fmt.describe(error)
146
143
  })));
147
144
 
148
145
  export { capture, declaredNames, read, resolveGroups };
@@ -3,7 +3,7 @@ import { AppliedState, lookup } from "../../store/AppliedState.js";
3
3
  import { capture, resolveGroups } from "./resource.js";
4
4
  import { fingerprint } from "../../lib/fingerprint.js";
5
5
  import { decide, isDrift, needsApply } from "../decide.js";
6
- import { Effect, Option, Redacted } from "effect";
6
+ import { Effect, FileSystem, Option, Path, Redacted } from "effect";
7
7
  import { RepositorySecret } from "@effected/github";
8
8
 
9
9
  // src/sync/phases/secrets.ts
@@ -53,6 +53,10 @@ const secretsPhase = Effect.gen(function* () {
53
53
  const secrets = yield* RepositorySecret;
54
54
  const applied = yield* AppliedState;
55
55
  const logger = yield* SyncLogger;
56
+ const io = {
57
+ fs: yield* FileSystem.FileSystem,
58
+ path: yield* Path.Path
59
+ };
56
60
  const run = (ctx) => Effect.gen(function* () {
57
61
  const scopes = ctx.config.groups[ctx.group]?.secrets;
58
62
  if (scopes === void 0) return {
@@ -109,12 +113,12 @@ const secretsPhase = Effect.gen(function* () {
109
113
  }
110
114
  });
111
115
  for (const scope of SCOPES) {
112
- const resolved = resolveGroups(scopes[scope] ?? [], ctx.config.secrets, ctx);
116
+ const resolved = yield* resolveGroups(io, scopes[scope] ?? [], ctx.config.secrets, ctx);
113
117
  errors.push(...resolved.errors);
114
118
  yield* syncStore(resolved.entries, scope, void 0);
115
119
  }
116
120
  for (const [environment, refs] of Object.entries(scopes.environments ?? {})) {
117
- const resolved = resolveGroups(refs, ctx.config.secrets, ctx);
121
+ const resolved = yield* resolveGroups(io, refs, ctx.config.secrets, ctx);
118
122
  errors.push(...resolved.errors);
119
123
  yield* syncStore(resolved.entries, "actions", environment);
120
124
  }
@@ -3,6 +3,7 @@ import { AppliedState, lookup } from "../../store/AppliedState.js";
3
3
  import { fingerprint } from "../../lib/fingerprint.js";
4
4
  import { decide, isDrift, needsApply } from "../decide.js";
5
5
  import { Effect, Option } from "effect";
6
+ import { Fmt } from "@effected/cli";
6
7
  import { RepositorySecurity } from "@effected/github";
7
8
 
8
9
  // src/sync/phases/security.ts
@@ -62,7 +63,7 @@ const securityPhase = Effect.gen(function* () {
62
63
  return (toggle === "vulnerability_alerts" ? security.vulnerabilityAlerts() : toggle === "automated_security_fixes" ? security.automatedSecurityFixes() : security.privateVulnerabilityReporting()).pipe(Effect.map(Option.some), Effect.orElseSucceed(() => Option.none()));
63
64
  };
64
65
  const write = (toggle, value) => {
65
- return (toggle === "vulnerability_alerts" ? security.setVulnerabilityAlerts(value) : toggle === "automated_security_fixes" ? security.setAutomatedSecurityFixes(value) : security.setPrivateVulnerabilityReporting(value)).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(error.message ?? String(error)))));
66
+ return (toggle === "vulnerability_alerts" ? security.setVulnerabilityAlerts(value) : toggle === "automated_security_fixes" ? security.setAutomatedSecurityFixes(value) : security.setPrivateVulnerabilityReporting(value)).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(Fmt.describe(error)))));
66
67
  };
67
68
  const run = (ctx) => Effect.gen(function* () {
68
69
  const merged = mergeSecurity(ctx);
@@ -237,7 +237,7 @@ const settingsPhase = Effect.gen(function* () {
237
237
  };
238
238
  let sent;
239
239
  if (!ctx.dryRun) {
240
- const outcome = yield* settingsClient.applySettings(desired).pipe(Effect.map((applied) => ({ applied })), Effect.catch((error) => Effect.succeed({ failed: error.message ?? String(error) })));
240
+ const outcome = yield* read(settingsClient.applySettings(desired, { unknownKeys: "send" }));
241
241
  if ("failed" in outcome) return {
242
242
  changes: [],
243
243
  errors: [...errors, {
@@ -245,7 +245,8 @@ const settingsPhase = Effect.gen(function* () {
245
245
  message: outcome.failed
246
246
  }]
247
247
  };
248
- sent = [...outcome.applied.rest, ...outcome.applied.graphql];
248
+ for (const key of outcome.value.unverified) yield* logger.syncOperation("apply", "setting", key, "(unconfirmed: GitHub does not describe this field)");
249
+ sent = [...outcome.value.rest, ...outcome.value.graphql];
249
250
  yield* applied.record({
250
251
  repo: ctx.slug,
251
252
  kind: KIND,
@@ -3,7 +3,7 @@ import { AppliedState, lookup } from "../../store/AppliedState.js";
3
3
  import { capture, resolveGroups } from "./resource.js";
4
4
  import { fingerprint } from "../../lib/fingerprint.js";
5
5
  import { decide, isDrift, needsApply } from "../decide.js";
6
- import { Effect, Option, Redacted } from "effect";
6
+ import { Effect, FileSystem, Option, Path, Redacted } from "effect";
7
7
  import { RepositoryVariable } from "@effected/github";
8
8
 
9
9
  // src/sync/phases/variables.ts
@@ -46,6 +46,10 @@ const variablesPhase = Effect.gen(function* () {
46
46
  const variables = yield* RepositoryVariable;
47
47
  const applied = yield* AppliedState;
48
48
  const logger = yield* SyncLogger;
49
+ const io = {
50
+ fs: yield* FileSystem.FileSystem,
51
+ path: yield* Path.Path
52
+ };
49
53
  const run = (ctx) => Effect.gen(function* () {
50
54
  const scopes = ctx.config.groups[ctx.group]?.variables;
51
55
  if (scopes === void 0) return {
@@ -54,12 +58,12 @@ const variablesPhase = Effect.gen(function* () {
54
58
  };
55
59
  const changes = [];
56
60
  const errors = [];
57
- const actions = resolveGroups(scopes.actions ?? [], ctx.config.variables, ctx);
61
+ const actions = yield* resolveGroups(io, scopes.actions ?? [], ctx.config.variables, ctx);
58
62
  errors.push(...actions.errors);
59
63
  /** Every environment's resolved entries, by environment name. */
60
64
  const byEnvironment = /* @__PURE__ */ new Map();
61
65
  for (const [environment, refs] of Object.entries(scopes.environments ?? {})) {
62
- const resolved = resolveGroups(refs, ctx.config.variables, ctx);
66
+ const resolved = yield* resolveGroups(io, refs, ctx.config.variables, ctx);
63
67
  errors.push(...resolved.errors);
64
68
  byEnvironment.set(environment, resolved.entries);
65
69
  }
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.59.4"
8
+ "packageVersion": "7.60.0"
9
9
  }
10
10
  ]
11
11
  }