reposets 3.1.8 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0";
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"
@@ -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";
@@ -183,43 +183,27 @@ const findClosestMatch = (key, known) => {
183
183
  return best;
184
184
  };
185
185
  /**
186
- * Find the config file without decoding it.
186
+ * The credential resolver `doctor`'s live token check runs through.
187
187
  *
188
188
  * @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.
200
- *
201
- * Delete this when the codec error carries a path.
189
+ * Bound once at module scope and provided once around the whole check, not
190
+ * rebuilt per profile: a layer built inside the loop was a fresh reference on
191
+ * every iteration, so it built the resolver and its 1Password client once per
192
+ * profile.
193
+ */
194
+ const ResolverLive = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
195
+ /**
196
+ * The file a failed discovery was reading.
202
197
  *
203
- * `appDirs.dirs.config` rather than `ensureConfig`: the latter *creates* the
204
- * directory, and a read-only diagnostic must not.
198
+ * @remarks
199
+ * Every failure `discover` can raise names its file: `ConfigFileReadError`
200
+ * and `ConfigCodecError` carry `path` (the codec's is filled in by
201
+ * `ConfigFile`'s read pipeline), and `ConfigValidationError` an
202
+ * `Option` of it. That is what lets a TOML *syntax* error — the most ordinary
203
+ * config mistake there is — name the file it failed to parse, so this command
204
+ * no longer re-walks the resolver chain to find the file itself.
205
205
  */
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);
206
+ const failedPath = (failure) => failure._tag === "ConfigValidationError" ? Option.getOrUndefined(failure.path) : failure.path;
223
207
  /**
224
208
  * `reposets doctor` — config diagnostics, typo detection, credential posture.
225
209
  *
@@ -286,7 +270,7 @@ const describeIdentity = (name, profile, login) => {
286
270
  text: `Token [${name}]: resolved, but authenticates as ${login} while the profile declares username = "${owner}"`
287
271
  };
288
272
  };
289
- const doctorHandler = (configFlag) => Effect.gen(function* () {
273
+ const doctorHandler = Effect.gen(function* () {
290
274
  const configFile = yield* ReposetsConfigFile;
291
275
  const credentialsFile = yield* ReposetsCredentialsFile;
292
276
  const fs = yield* FileSystem.FileSystem;
@@ -297,7 +281,7 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
297
281
  const sources = discovered._tag === "Success" ? discovered.success : [];
298
282
  const schemaValid = discovered._tag === "Success" && sources.length > 0;
299
283
  const validationIssue = discovered._tag === "Failure" ? discovered.failure.issue : void 0;
300
- const configPath = sources[0]?.path ?? (yield* locateConfig(fs, path, appDirs, configFlag, invocation.cwd));
284
+ const configPath = discovered._tag === "Success" ? sources[0]?.path : failedPath(discovered.failure);
301
285
  if (configPath === void 0) {
302
286
  yield* CliMessage.failure("No config found. Run 'reposets init' to create one.");
303
287
  return;
@@ -309,7 +293,7 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
309
293
  ["Version: ", invocation.version],
310
294
  ["Config: ", Doc.file(configPath)],
311
295
  ["Credentials file: ", ...Array.isArray(credentialsLine) ? credentialsLine : [credentialsLine]],
312
- ["State database: ", Doc.file(path.join(appDirs.dirs.state, STATE_DB_FILENAME))]
296
+ ["State database: ", Doc.file(yield* AppStore.location(STORE_OPTIONS))]
313
297
  ])]);
314
298
  const text = yield* fs.readFileString(configPath).pipe(Effect.option);
315
299
  if (text._tag === "None") {
@@ -320,13 +304,13 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
320
304
  const parsed = Toml.parseResult(text.value);
321
305
  if (Result.isFailure(parsed)) {
322
306
  yield* Doc.print([files]);
323
- yield* CliMessage.failure(`TOML parse error: ${String(parsed.failure)}`);
307
+ yield* CliMessage.failure(`TOML parse error: ${Fmt.sanitize(Fmt.describe(parsed.failure))}`);
324
308
  return;
325
309
  }
326
- const raw = isRecord(parsed.success) ? parsed.success : {};
310
+ const raw = Predicate.isObject(parsed.success) ? parsed.success : {};
327
311
  const warnings = [];
328
312
  const checkKeys = (target, known, where) => {
329
- if (!isRecord(target)) return;
313
+ if (!Predicate.isObject(target)) return;
330
314
  for (const key of Object.keys(target)) {
331
315
  if (known.has(key)) continue;
332
316
  const removedFrom = REMOVED_KEYS[where === "" ? key : `${key} (in groups)`] ?? REMOVED_KEYS[key];
@@ -340,9 +324,9 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
340
324
  }
341
325
  };
342
326
  checkKeys(raw, KNOWN_CONFIG_KEYS, "");
343
- const settingsSection = isRecord(raw.settings) ? raw.settings : {};
327
+ const settingsSection = Predicate.isObject(raw.settings) ? raw.settings : {};
344
328
  for (const [groupName, body] of Object.entries(settingsSection)) {
345
- if (!isRecord(body)) continue;
329
+ if (!Predicate.isObject(body)) continue;
346
330
  for (const field of Object.keys(body)) {
347
331
  if (KNOWN_SETTINGS_FIELDS.has(field)) continue;
348
332
  const suggestion = findClosestMatch(field, KNOWN_SETTINGS_FIELDS);
@@ -351,11 +335,11 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
351
335
  }
352
336
  }
353
337
  const groups = raw.groups;
354
- if (isRecord(groups)) for (const [groupName, group] of Object.entries(groups)) {
338
+ if (Predicate.isObject(groups)) for (const [groupName, group] of Object.entries(groups)) {
355
339
  checkKeys(group, KNOWN_GROUP_KEYS, ` in groups.${groupName}`);
356
- if (!isRecord(group)) continue;
340
+ if (!Predicate.isObject(group)) continue;
357
341
  const cleanup = group.cleanup;
358
- if (!isRecord(cleanup)) continue;
342
+ if (!Predicate.isObject(cleanup)) continue;
359
343
  const prefix = `groups.${groupName}.cleanup`;
360
344
  checkKeys(cleanup, KNOWN_CLEANUP_KEYS, ` in ${prefix}`);
361
345
  checkKeys(cleanup.secrets, KNOWN_CLEANUP_SECRETS_KEYS, ` in ${prefix}.secrets`);
@@ -363,15 +347,15 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
363
347
  }
364
348
  const issueLines = schemaValid ? [] : SchemaIssueRenderer.render(validationIssue);
365
349
  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.`)]);
350
+ 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
351
  const loaded = yield* credentialsFile.loadOrDefault({ profiles: {} }).pipe(Effect.result);
368
352
  const credentials = loaded._tag === "Success" ? loaded.success : { profiles: {} };
369
353
  const profileNames = Object.keys(credentials.profiles);
370
354
  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."));
355
+ 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
356
  else if (profileNames.length === 0) credentialBlocks.push(Doc.paragraph("Credentials: no profiles configured"));
373
357
  else {
374
- credentialBlocks.push(Doc.paragraph(`Credentials: ${profileNames.length} profile(s)`));
358
+ credentialBlocks.push(Doc.paragraph(`Credentials: ${Fmt.plural(profileNames.length, "profile")}`));
375
359
  let usesOnePassword = false;
376
360
  const profileLines = [];
377
361
  for (const [name, profile] of Object.entries(credentials.profiles)) {
@@ -402,26 +386,27 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
402
386
  keys,
403
387
  Doc.section("Credentials", credentialBlocks)
404
388
  ])]);
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;
389
+ const tokenLines = yield* Effect.gen(function* () {
390
+ const resolver = yield* CredentialResolver;
391
+ const lines = [];
392
+ for (const [name, profile] of Object.entries(credentials.profiles)) {
393
+ const resolved = yield* resolver.resolveGitHubToken(profile).pipe(Effect.result);
394
+ if (resolved._tag === "Failure") {
395
+ lines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: could not resolve — ${Fmt.sanitize(Fmt.describe(resolved.failure))}`));
396
+ continue;
397
+ }
398
+ const identity = yield* Effect.gen(function* () {
399
+ return yield* (yield* GitHubClient).request("GET /user", {});
400
+ }).pipe(Effect.provide(GitHubClient.layerFromToken({ token: resolved.success })), Effect.result);
401
+ if (identity._tag !== "Success") {
402
+ lines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: resolved but REJECTED by GitHub — ${Fmt.sanitize(Fmt.describe(identity.failure))}`));
403
+ continue;
404
+ }
405
+ const line = describeIdentity(name, profile, identity.success.login);
406
+ lines.push(Doc.paragraph(Doc.status(Status.core, line.ok ? "success" : "failure"), " ", line.text));
421
407
  }
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
- }
408
+ return lines;
409
+ }).pipe(Effect.provide(ResolverLive));
425
410
  const permissions = Doc.section("Required fine-grained token permissions", [
426
411
  Doc.list(REQUIRED_PERMISSIONS.map(([scope, permission, why]) => Doc.paragraph(`${scope} > ${permission} — ${why}`)), { compact: true }),
427
412
  Doc.paragraph(Doc.text("(Metadata: Read is mandatory and granted automatically)", "muted")),
@@ -435,9 +420,6 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
435
420
  *
436
421
  * @public
437
422
  */
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"));
423
+ const doctorCommand = Command.make("doctor", {}, () => doctorHandler).pipe(Command.withDescription("Diagnose the config: unknown keys, credential posture, required token permissions"));
442
424
 
443
425
  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.0",
4
4
  "private": false,
5
5
  "description": "CLI tool to sync GitHub repo settings, secrets and rulesets across personal repositories",
6
6
  "keywords": [
@@ -49,17 +49,17 @@
49
49
  "dependencies": {
50
50
  "@1password/sdk": "^0.5.0",
51
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",
52
+ "@effected/app": "^0.28.0",
53
+ "@effected/cli": "^0.23.0",
54
+ "@effected/config-file": "^0.21.0",
55
+ "@effected/env": "^0.6.0",
56
+ "@effected/github": "^0.25.1",
57
+ "@effected/glob": "^0.15.0",
58
+ "@effected/schemastore": "^0.26.3",
59
+ "@effected/store": "^0.20.0",
60
60
  "@effected/toml": "^0.16.0",
61
- "@effected/walker": "^0.20.1",
62
- "@effected/xdg": "^0.15.1",
61
+ "@effected/walker": "^0.21.0",
62
+ "@effected/xdg": "^0.16.0",
63
63
  "blakejs": "^1.2.1",
64
64
  "effect": "^4.0.6",
65
65
  "ink": "^8.0.0",
@@ -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,22 @@ 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
+ *
76
81
  * @public
77
82
  */
78
- const CredentialsFilesLive = AppConfig.layer(ReposetsCredentialsFile, {
83
+ const CredentialsFilesLive = Layer.unwrap(Effect.map(Invocation, ({ cwd }) => AppConfig.layer(ReposetsCredentialsFile, {
79
84
  filename: CREDENTIALS_FILENAME,
80
85
  schema: CredentialsSchema,
81
86
  codec: TomlCodec,
82
- resolvers: [ConfigResolver.upwardWalk({ filename: CREDENTIALS_FILENAME })],
87
+ resolvers: [ConfigResolver.upwardWalk({
88
+ filename: CREDENTIALS_FILENAME,
89
+ cwd
90
+ })],
83
91
  parseOptions: STRICT_KEYS
84
- });
92
+ })));
85
93
  /**
86
94
  * Raised when `--config` names a path that does not exist.
87
95
  *
@@ -144,14 +152,23 @@ var ConfigFlagMissingConfig = class extends Data.TaggedError("ConfigFlagMissingC
144
152
  * A directory is checked for the config file by existence only, not decoded:
145
153
  * a file that is present but invalid must still resolve, so `doctor` can
146
154
  * diagnose it and a command can report its decode error against the right path.
155
+ *
156
+ * Everything relative is taken against {@link Invocation}'s `cwd`: the upward
157
+ * walk starts there, and a relative `--config` is resolved there before it is
158
+ * checked, so a test that hands a command another directory moves both.
147
159
  */
148
- const resolversFor = (configFlag) => Effect.gen(function* () {
149
- if (configFlag === void 0) return [ConfigResolver.upwardWalk({ filename: CONFIG_FILENAME })];
160
+ const resolversFor = (flag) => Effect.gen(function* () {
161
+ const { cwd } = yield* Invocation;
162
+ if (flag === void 0) return [ConfigResolver.upwardWalk({
163
+ filename: CONFIG_FILENAME,
164
+ cwd
165
+ })];
150
166
  const fs = yield* FileSystem.FileSystem;
167
+ const path = yield* Path.Path;
168
+ const configFlag = path.resolve(cwd, flag);
151
169
  const info = yield* fs.stat(configFlag).pipe(Effect.option);
152
170
  if (info._tag === "None") return yield* new ConfigFlagNotFound({ path: configFlag });
153
171
  if (info.value.type !== "Directory") return [ConfigResolver.explicitPath(configFlag)];
154
- const path = yield* Path.Path;
155
172
  if (!(yield* fs.exists(path.join(configFlag, "reposets.config.toml")).pipe(Effect.orElseSucceed(() => false)))) return yield* new ConfigFlagMissingConfig({
156
173
  dir: configFlag,
157
174
  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));
241
241
  if ("failed" in outcome) return {
242
242
  changes: [],
243
243
  errors: [...errors, {
@@ -245,7 +245,7 @@ const settingsPhase = Effect.gen(function* () {
245
245
  message: outcome.failed
246
246
  }]
247
247
  };
248
- sent = [...outcome.applied.rest, ...outcome.applied.graphql];
248
+ sent = [...outcome.value.rest, ...outcome.value.graphql];
249
249
  yield* applied.record({
250
250
  repo: ctx.slug,
251
251
  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
  }