reposets 2.0.4 → 3.1.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/index.d.ts CHANGED
@@ -707,6 +707,40 @@ export declare class ConfigFlagNotFound extends ConfigFlagNotFound_base<{
707
707
  */
708
708
  get message(): string;
709
709
  }
710
+ declare const ConfigFlagMissingConfig_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
711
+ readonly _tag: "ConfigFlagMissingConfig";
712
+ } & Readonly<A>;
713
+ /**
714
+ * Raised when `--config` names a directory that holds no `reposets.config.toml`.
715
+ *
716
+ * @remarks
717
+ * The sibling of {@link ConfigFlagNotFound}, for the case it cannot see: the
718
+ * path exists, so the existence check passes, but the directory resolver then
719
+ * finds nothing and — being a probe with `never` in its error channel — falls
720
+ * through to `AppConfig`'s XDG tier. `reposets doctor --config ./empty-dir`
721
+ * would report on the user's XDG config, a different file from the one asked
722
+ * about, with nothing to say so. An explicit request fails loudly here for the
723
+ * same reason a missing path does.
724
+ *
725
+ * A separate tag rather than a widened `ConfigFlagNotFound`, because the two
726
+ * call for different fixes — a mistyped path, versus a right directory with
727
+ * the file missing or misnamed — and the message names both the directory and
728
+ * the filename it looked for. Both are paths the user typed or a fixed name;
729
+ * nothing secret reaches the message.
730
+ *
731
+ * @public
732
+ */
733
+ export declare class ConfigFlagMissingConfig extends ConfigFlagMissingConfig_base<{
734
+ readonly dir: string;
735
+ readonly filename: string;
736
+ }> {
737
+ /**
738
+ * @remarks
739
+ * As on {@link ConfigFlagNotFound}: without it the error renders as a bare
740
+ * tag and the directory never reaches the log line.
741
+ */
742
+ get message(): string;
743
+ }
710
744
  /**
711
745
  * Builds the config-file layer for one invocation's `--config` flag.
712
746
  *
@@ -716,7 +750,7 @@ export declare class ConfigFlagNotFound extends ConfigFlagNotFound_base<{
716
750
  *
717
751
  * @public
718
752
  */
719
- export declare const makeConfigFilesLive: (configFlag: string | undefined) => Layer.Layer<ReposetsConfigFile, ConfigFlagNotFound, FileSystem.FileSystem | Path.Path | AppDirs | Xdg>;
753
+ export declare const makeConfigFilesLive: (configFlag: string | undefined) => Layer.Layer<ReposetsConfigFile, ConfigFlagNotFound | ConfigFlagMissingConfig, FileSystem.FileSystem | Path.Path | AppDirs | Xdg>;
720
754
  //#endregion
721
755
  export type { Config };
722
756
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { ConfigSchema } from "./schemas/config.js";
2
- import { CONFIG_FILENAME, ConfigFlagNotFound, ReposetsConfigFile, makeConfigFilesLive } from "./services/ConfigFiles.js";
2
+ import { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ReposetsConfigFile, makeConfigFilesLive } from "./services/ConfigFiles.js";
3
3
 
4
- export { CONFIG_FILENAME, ConfigFlagNotFound, ConfigSchema, ReposetsConfigFile, makeConfigFilesLive };
4
+ export { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ConfigSchema, ReposetsConfigFile, makeConfigFilesLive };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reposets",
3
- "version": "2.0.4",
3
+ "version": "3.1.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,15 +49,21 @@
49
49
  "dependencies": {
50
50
  "@1password/sdk": "^0.5.0",
51
51
  "@effect/platform-node": "^4.0.0",
52
- "@effected/app": "^0.20.0",
53
- "@effected/cli": "^0.11.0",
54
- "@effected/config-file": "^0.14.0",
55
- "@effected/github": "^0.15.0",
56
- "@effected/store": "^0.12.0",
52
+ "@effected/app": "^0.21.1",
53
+ "@effected/cli": "^0.12.0",
54
+ "@effected/config-file": "^0.14.2",
55
+ "@effected/env": "^0.1.0",
56
+ "@effected/github": "^0.15.1",
57
+ "@effected/glob": "^0.10.0",
58
+ "@effected/schemastore": "^0.21.0",
59
+ "@effected/store": "^0.13.0",
57
60
  "@effected/toml": "^0.11.0",
61
+ "@effected/walker": "^0.15.0",
58
62
  "@effected/xdg": "^0.9.0",
59
63
  "blakejs": "^1.2.1",
60
- "effect": "^4.0.0"
64
+ "effect": "^4.0.0",
65
+ "ink": "^7.1.1",
66
+ "react": "^19.3.0"
61
67
  },
62
68
  "engines": {
63
69
  "node": ">=24.11.0"
@@ -0,0 +1,79 @@
1
+ import { HostedSchema } from "@effected/schemastore";
2
+
3
+ //#region src/schemas/hosted.ts
4
+ /**
5
+ * The version label both published JSON Schema documents carry.
6
+ *
7
+ * @remarks
8
+ * `3.0` tracks the reposets 3.0.0 release that introduced versioned schemas.
9
+ * The label names the TOML contract, not the package version: a later
10
+ * reposets release that leaves both file shapes alone keeps `3.0`. A contract
11
+ * change (a new required key, a removed or retyped key, a new `default`) is
12
+ * answered by appending a new label to `versions` and leaving the `3.0` file
13
+ * frozen on disk — `init` stamps the `3.0` URL into every config it scaffolds,
14
+ * so editing that document in place would move a URL people pin.
15
+ *
16
+ * @public
17
+ */
18
+ const SCHEMA_VERSION = "3.0";
19
+ /**
20
+ * Where the `reposets.config.toml` JSON Schema is hosted and which version is
21
+ * current.
22
+ *
23
+ * @remarks
24
+ * The one identity both sides derive from: `lib/configs/schemastore.config.ts`
25
+ * builds the document at this `$id` (keyed by `name`), and `reposets init`
26
+ * stamps `#:schema <$id>` into the config it scaffolds. Neither spells the URL
27
+ * by hand, so the file the build writes and the URL a fresh config points at
28
+ * cannot disagree. Served raw from `main` under the repository root's
29
+ * `schemas/` directory — independent of the package layout — as
30
+ * `schemas/<version>/config.json` (`appendVersion: false`, the version
31
+ * directory alone names the version).
32
+ *
33
+ * @public
34
+ */
35
+ const configSchemaHost = HostedSchema.github({
36
+ repo: "spencerbeggs/reposets",
37
+ path: "schemas",
38
+ name: "config",
39
+ versions: ["3.0"],
40
+ appendVersion: false
41
+ });
42
+ /**
43
+ * Where the `reposets.credentials.toml` JSON Schema is hosted and which
44
+ * version is current.
45
+ *
46
+ * @remarks
47
+ * The credentials-file twin of {@link configSchemaHost}: same repository,
48
+ * directory, layout and version label, so the two documents always move
49
+ * together.
50
+ *
51
+ * @public
52
+ */
53
+ const credentialsSchemaHost = HostedSchema.github({
54
+ repo: "spencerbeggs/reposets",
55
+ path: "schemas",
56
+ name: "credentials",
57
+ versions: ["3.0"],
58
+ appendVersion: false
59
+ });
60
+ /**
61
+ * The `#:schema` directive `init` writes as the first line of a scaffolded
62
+ * TOML file, followed by the blank line Tombi requires before the document.
63
+ *
64
+ * @remarks
65
+ * `#:schema <url>` is the spelling both Taplo and Tombi read, and to every
66
+ * TOML parser it is an ordinary comment — so the config and credentials
67
+ * decoders never see it. The directive binds the file to one schema version
68
+ * without depending on SchemaStore's catalog, which matches by file name and
69
+ * may lag a release.
70
+ *
71
+ * @param host - the hosted schema the file is written against
72
+ * @returns the directive line and its trailing blank line
73
+ *
74
+ * @public
75
+ */
76
+ const schemaDirective = (host) => `#:schema ${host.$id}\n\n`;
77
+
78
+ //#endregion
79
+ export { configSchemaHost, credentialsSchemaHost, schemaDirective };
@@ -1,6 +1,6 @@
1
1
  import { ConfigSchema } from "../schemas/config.js";
2
2
  import { CredentialsSchema } from "../schemas/credentials.js";
3
- import { Data, Effect, FileSystem, Layer } from "effect";
3
+ import { Data, Effect, FileSystem, Layer, Path } from "effect";
4
4
  import { AppConfig } from "@effected/app";
5
5
  import { ConfigFile, ConfigResolver, TomlCodec } from "@effected/config-file";
6
6
 
@@ -48,14 +48,13 @@ var ReposetsCredentialsFile = class extends ConfigFile.Service()("reposets/Crede
48
48
  * are covered by a `StructWithRest` rest schema, so they are not excess;
49
49
  * verified here and pinned by a test upstream.
50
50
  *
51
- * **Applied to credentials only, for now.** The config file keeps lenient
52
- * decoding because `doctor` diagnoses unknown keys with nearest-match
53
- * suggestions — strictly better output than a decode failure — and it locates
54
- * the file through `discover`, which decodes. Making the load strict means
55
- * `discover` fails and `doctor` reports "no config found" for a file that is
56
- * present and one character wrong, losing the diagnosis exactly when it is
57
- * wanted. Turning it on for config needs `doctor` to locate the file without
58
- * decoding first.
51
+ * **Applied to both files.** Strict config decoding was once held back
52
+ * because `doctor` found the config through `discover`, which decodes: a
53
+ * strict load would have made a file that is present and one key wrong look
54
+ * absent, losing the diagnosis exactly when it is wanted. `doctor` now keeps
55
+ * `discover`'s failure as the structured issue it reports, and locates the
56
+ * file on its own (`locateConfig`) without decoding, so its nearest-match
57
+ * suggestions survive a strict load.
59
58
  */
60
59
  const STRICT_KEYS = {
61
60
  onExcessProperty: "error",
@@ -105,20 +104,61 @@ var ConfigFlagNotFound = class extends Data.TaggedError("ConfigFlagNotFound") {
105
104
  }
106
105
  };
107
106
  /**
107
+ * Raised when `--config` names a directory that holds no `reposets.config.toml`.
108
+ *
109
+ * @remarks
110
+ * The sibling of {@link ConfigFlagNotFound}, for the case it cannot see: the
111
+ * path exists, so the existence check passes, but the directory resolver then
112
+ * finds nothing and — being a probe with `never` in its error channel — falls
113
+ * through to `AppConfig`'s XDG tier. `reposets doctor --config ./empty-dir`
114
+ * would report on the user's XDG config, a different file from the one asked
115
+ * about, with nothing to say so. An explicit request fails loudly here for the
116
+ * same reason a missing path does.
117
+ *
118
+ * A separate tag rather than a widened `ConfigFlagNotFound`, because the two
119
+ * call for different fixes — a mistyped path, versus a right directory with
120
+ * the file missing or misnamed — and the message names both the directory and
121
+ * the filename it looked for. Both are paths the user typed or a fixed name;
122
+ * nothing secret reaches the message.
123
+ *
124
+ * @public
125
+ */
126
+ var ConfigFlagMissingConfig = class extends Data.TaggedError("ConfigFlagMissingConfig") {
127
+ /**
128
+ * @remarks
129
+ * As on {@link ConfigFlagNotFound}: without it the error renders as a bare
130
+ * tag and the directory never reaches the log line.
131
+ */
132
+ get message() {
133
+ return `--config directory has no ${this.filename}: ${this.dir}`;
134
+ }
135
+ };
136
+ /**
108
137
  * Builds the resolver tiers that must win over `AppConfig`'s own XDG chain.
109
138
  *
110
139
  * @remarks
111
140
  * `AppConfig.layer` prepends these, in order, ahead of `XdgConfig.resolver` and
112
141
  * the native-directory probe, so only the higher-priority tiers appear here.
142
+ *
143
+ * A directory is checked for the config file by existence only, not decoded:
144
+ * a file that is present but invalid must still resolve, so `doctor` can
145
+ * diagnose it and a command can report its decode error against the right path.
113
146
  */
114
147
  const resolversFor = (configFlag) => Effect.gen(function* () {
115
148
  if (configFlag === void 0) return [ConfigResolver.upwardWalk({ filename: CONFIG_FILENAME })];
116
- const info = yield* (yield* FileSystem.FileSystem).stat(configFlag).pipe(Effect.option);
149
+ const fs = yield* FileSystem.FileSystem;
150
+ const info = yield* fs.stat(configFlag).pipe(Effect.option);
117
151
  if (info._tag === "None") return yield* new ConfigFlagNotFound({ path: configFlag });
118
- return info.value.type === "Directory" ? [ConfigResolver.staticDir({
152
+ if (info.value.type !== "Directory") return [ConfigResolver.explicitPath(configFlag)];
153
+ const path = yield* Path.Path;
154
+ if (!(yield* fs.exists(path.join(configFlag, "reposets.config.toml")).pipe(Effect.orElseSucceed(() => false)))) return yield* new ConfigFlagMissingConfig({
155
+ dir: configFlag,
156
+ filename: CONFIG_FILENAME
157
+ });
158
+ return [ConfigResolver.staticDir({
119
159
  dir: configFlag,
120
160
  filename: CONFIG_FILENAME
121
- })] : [ConfigResolver.explicitPath(configFlag)];
161
+ })];
122
162
  });
123
163
  /**
124
164
  * Builds the config-file layer for one invocation's `--config` flag.
@@ -138,4 +178,4 @@ const makeConfigFilesLive = (configFlag) => Layer.unwrap(Effect.map(resolversFor
138
178
  })));
139
179
 
140
180
  //#endregion
141
- export { CONFIG_FILENAME, CREDENTIALS_FILENAME, ConfigFlagNotFound, CredentialsFilesLive, ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive };
181
+ export { CONFIG_FILENAME, CREDENTIALS_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, CredentialsFilesLive, ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive };
@@ -1,4 +1,6 @@
1
- import { Console, Context, Effect, Layer, Ref } from "effect";
1
+ import { Console, Context, Effect, Layer, PubSub, Ref } from "effect";
2
+ import { CliLog, CliTheme, Fmt, Status } from "@effected/cli";
3
+ import { Audience } from "@effected/env";
2
4
 
3
5
  //#region src/services/SyncLogger.ts
4
6
  /** Plural forms the naive `+ "s"` gets wrong. */
@@ -41,14 +43,31 @@ function pluralize(resource, count) {
41
43
  * out-of-band edit happens to match the config. The tool has no work to do; the
42
44
  * human still changed something, and staying quiet would hide it.
43
45
  *
46
+ * **Every action line leads with a status glyph**, from the kit's core
47
+ * vocabulary: a change made is `success`, a dry run's `would …` is `info`, a
48
+ * deletion and a drift are `warning`, a failure is `failure`, a skip is `skip`.
49
+ * The glyph is painted for a person; an agent gets it unpainted and the text
50
+ * plain, through `CliTheme.forAudience` — the rule `CliMessage` paints by. The
51
+ * failure lines with a glyph — the per-repository `error` line, at the action
52
+ * lines' indent, and the closing `Sync complete with N errors:` header — go
53
+ * through `CliLog.status`, which paints its glyph the same way on the log
54
+ * channel (the logger strips every escape a program logs itself). Headers
55
+ * (`group:`, `repo:`) take none — they are structure, not outcomes. The glyph
56
+ * sits after the indent and before the padded verb, so the verbs still line
57
+ * up with each other.
58
+ *
44
59
  * **The report is the product; failures are diagnostics.** What a run did —
45
60
  * group and repository headers, every operation, every drift line, the closing
46
61
  * "Sync complete!" — is the output of `sync` and `drift`, so it is written with
47
62
  * `Console.log` to stdout, where `reposets drift > report.txt` captures it.
48
- * Failures are emitted with `Effect.logError`, which the CLI logger
49
- * (`CliLogger.layer()`) routes to stderr, so they stay on the terminal when
50
- * stdout is redirected. A consequence worth knowing: `--log-level` filters
51
- * diagnostics only — it no longer silences the report.
63
+ * Failures are emitted with `Effect.logError`, which the CLI logger routes to
64
+ * stderr, so they stay on the terminal when stdout is redirected. A consequence
65
+ * worth knowing: `--log-level` filters diagnostics only — it does not silence
66
+ * the report.
67
+ *
68
+ * Both go through the fiber's `Console`, which is the seam a live progress view
69
+ * uses: providing its `logConsole` around the run puts every one of these lines
70
+ * above the redrawing frame without this service knowing a view exists.
52
71
  *
53
72
  * @public
54
73
  */
@@ -57,18 +76,42 @@ var SyncLogger = class extends Context.Service()("reposets/SyncLogger") {};
57
76
  * Build a live logger for one run.
58
77
  *
59
78
  * @remarks
60
- * A factory rather than a bare layer because both settings are per-invocation:
61
- * they come from the `--dry-run` and `--debug` flags, which are only known once
62
- * the command has parsed.
79
+ * A factory rather than a bare layer because its settings are per-invocation:
80
+ * they come from the `--dry-run` and `--debug` flags, and the event sink from
81
+ * whether this run draws a view — all known only once the command has parsed.
82
+ *
83
+ * Requires `CliTheme` and `Audience`, read once when the layer builds, to paint
84
+ * the status glyphs; under the bin both come from the environment
85
+ * `CliRuntime.main` builds.
63
86
  *
64
87
  * @public
65
88
  */
66
89
  function SyncLoggerLive(config) {
67
- const { dryRun, debug } = config;
90
+ const { dryRun, debug, events } = config;
68
91
  return Layer.effect(SyncLogger, Effect.gen(function* () {
92
+ const theme = yield* CliTheme;
93
+ const audience = yield* Audience;
69
94
  const errors = yield* Ref.make([]);
70
95
  const currentRepo = yield* Ref.make("");
71
- const emit = (line) => Console.log(line);
96
+ const publish = (event) => events === void 0 ? Effect.void : PubSub.publish(events, event);
97
+ /** stdout's theme as this run's audience sees it: unpainted for an agent. */
98
+ const stdoutTheme = CliTheme.forAudience(theme.forStream("stdout"), audience.kind);
99
+ /** A status glyph for a report line on stdout. */
100
+ const mark = (status) => stdoutTheme.status(Status.core, status);
101
+ /**
102
+ * A failure status line, glyph painted for a person, `indent` spaces in.
103
+ *
104
+ * @remarks
105
+ * `CliLog.status` logs at `Error` (the status's rank), so the line goes
106
+ * where `Effect.logError` goes — stderr — and its text is sanitised. The
107
+ * theme and audience this layer was built with are provided, so the
108
+ * glyph follows the same audience the stdout lines do. In the ASCII set
109
+ * the glyph is the word `[FAIL]`.
110
+ */
111
+ const emitFailure = (text, indent = 0) => CliLog.status(Status.core, "failure", text, { indent }).pipe(Effect.provideService(CliTheme, theme), Effect.provideService(Audience, audience));
112
+ const emit = (line) => Console.log(Fmt.sanitize(line));
113
+ /** An action line: indent, glyph, then the padded verb and its content. */
114
+ const action = (status, body) => Console.log(` ${mark(status)} ${Fmt.sanitize(body)}`);
72
115
  /**
73
116
  * Failures, on the error channel.
74
117
  *
@@ -87,34 +130,69 @@ function SyncLoggerLive(config) {
87
130
  * concatenated directly, with no separating space.
88
131
  */
89
132
  const formatVerb = (pastTense, presentTense) => dryRun ? `would ${presentTense}`.padEnd(14) : pastTense.padEnd(8);
133
+ /** A change made reads as done; the same change on a dry run, as information. */
134
+ const changeStatus = dryRun ? "info" : "success";
90
135
  return {
136
+ runStart: (total) => publish({
137
+ _tag: "RunStarted",
138
+ total,
139
+ dryRun
140
+ }),
91
141
  groupStart: (name, selected, declared) => {
92
142
  const scope = selected === declared ? `${selected} ${selected === 1 ? "repo" : "repos"}` : `${selected} of ${declared} ${declared === 1 ? "repo" : "repos"}`;
93
- return emit(`group: ${name} (${scope})`);
143
+ return emit(`group: ${name} (${scope})`).pipe(Effect.andThen(publish({
144
+ _tag: "GroupStarted",
145
+ name,
146
+ selected,
147
+ declared
148
+ })));
94
149
  },
95
150
  repoStart: (owner, repo) => {
96
151
  const repoSlug = `${owner}/${repo}`;
97
- return Ref.set(currentRepo, repoSlug).pipe(Effect.andThen(emit(` repo: ${repoSlug}`)));
152
+ return Ref.set(currentRepo, repoSlug).pipe(Effect.andThen(emit(` repo: ${repoSlug}`)), Effect.andThen(publish({
153
+ _tag: "RepoStarted",
154
+ slug: repoSlug
155
+ })));
98
156
  },
99
157
  settingsApplied: (fields) => {
100
158
  const named = [...fields].sort();
101
159
  const detail = named.length === 0 ? "" : named.length <= 6 ? ` (${named.join(", ")})` : ` (${named.length} fields: ${named.slice(0, 5).join(", ")}, …)`;
102
- return emit(` ${formatVerb("applied", "apply")}settings${detail}`);
160
+ return action(changeStatus, `${formatVerb("applied", "apply")}settings${detail}`).pipe(Effect.andThen(publish({
161
+ _tag: "Operation",
162
+ verb: "apply",
163
+ resource: "settings",
164
+ count: 1
165
+ })));
103
166
  },
104
167
  cleanupSummary: (resource, count, names) => {
105
168
  const suffix = names.length > 0 ? ` (${names.join(", ")})` : "";
106
- return emit(` ${formatVerb("deleted", "delete")}${count} ${pluralize(resource, count)}${suffix}`);
169
+ return action("warning", `${formatVerb("deleted", "delete")}${count} ${pluralize(resource, count)}${suffix}`).pipe(Effect.andThen(publish({
170
+ _tag: "Operation",
171
+ verb: "delete",
172
+ resource,
173
+ count
174
+ })));
107
175
  },
108
176
  syncOperation: (verb, resource, name, detail, source) => {
109
177
  const nameStr = name ? ` ${name}` : "";
110
178
  const suffix = detail ? ` ${detail}` : "";
111
179
  const sourceSuffix = source && debug ? ` <- ${source}` : "";
112
- return emit(` ${formatVerb(verb, verb)}${resource}${nameStr}${suffix}${sourceSuffix}`);
180
+ return action(verb === "skip" ? "skip" : verb === "delete" ? "warning" : changeStatus, `${formatVerb(verb, verb)}${resource}${nameStr}${suffix}${sourceSuffix}`).pipe(Effect.andThen(publish({
181
+ _tag: "Operation",
182
+ verb,
183
+ resource,
184
+ count: 1
185
+ })));
113
186
  },
114
187
  driftDetected: (resource, name, drift) => {
115
188
  const consequence = !drift.needsApply ? "already matches config, nothing written" : dryRun ? "would overwrite" : "overwritten";
116
189
  const fingerprints = debug ? ` <- applied ${drift.applied} live ${drift.live}` : "";
117
- return emit(` ${"drift".padEnd(8)}${resource} ${name} changed outside reposets — ${consequence}${fingerprints}`);
190
+ return action("warning", `${"drift".padEnd(8)}${resource} ${name} changed outside reposets — ${consequence}${fingerprints}`).pipe(Effect.andThen(publish({
191
+ _tag: "Drift",
192
+ resource,
193
+ name,
194
+ needsApply: drift.needsApply
195
+ })));
118
196
  },
119
197
  syncError: (context, message) => Effect.gen(function* () {
120
198
  const repo = yield* Ref.get(currentRepo);
@@ -123,16 +201,25 @@ function SyncLoggerLive(config) {
123
201
  context,
124
202
  message
125
203
  }]);
126
- yield* emitError(` error ${context}: ${message}`);
204
+ yield* emitFailure(`error ${context}: ${message}`, 4);
205
+ yield* publish({
206
+ _tag: "Error",
207
+ repo,
208
+ context,
209
+ message
210
+ });
127
211
  }),
128
- finish: () => Effect.gen(function* () {
212
+ finish: (summary) => Effect.gen(function* () {
129
213
  const errs = yield* Ref.get(errors);
130
- if (errs.length === 0) {
131
- yield* emit("Sync complete!");
132
- return;
214
+ if (errs.length === 0) yield* Console.log(`${mark("success")} Sync complete!`);
215
+ else {
216
+ yield* emitFailure(`Sync complete with ${errs.length} ${errs.length === 1 ? "error" : "errors"}:`);
217
+ for (const err of errs) yield* emitError(Fmt.sanitize(err.repo === "" ? ` ${err.context} — ${err.message}` : ` ${err.repo}: ${err.context} — ${err.message}`));
133
218
  }
134
- yield* emitError(`Sync complete with ${errs.length} ${errs.length === 1 ? "error" : "errors"}:`);
135
- for (const err of errs) yield* emitError(err.repo === "" ? ` ${err.context} — ${err.message}` : ` ${err.repo}: ${err.context} — ${err.message}`);
219
+ yield* publish({
220
+ _tag: "RunEnded",
221
+ ...summary
222
+ });
136
223
  })
137
224
  };
138
225
  }));
@@ -59,6 +59,7 @@ var SyncJournal = class extends Context.Service()("reposets/SyncJournal", { make
59
59
  ORDER BY r.rowid DESC
60
60
  LIMIT ${options.limit}
61
61
  `,
62
+ runIds: () => sql`SELECT id FROM sync_run`.pipe(Effect.map((rows) => rows.map((row) => row.id))),
62
63
  changesFor: (runId) => sql`
63
64
  SELECT repo, kind, name, action, detail
64
65
  FROM sync_change
package/store/files.js ADDED
@@ -0,0 +1,25 @@
1
+ //#region src/store/files.ts
2
+ /**
3
+ * The state database's file name, under the XDG state directory.
4
+ *
5
+ * @remarks
6
+ * Passed explicitly to `AppStore.layer` rather than left to its default, so
7
+ * the name the database is opened under and the name `nuke` and `doctor` look
8
+ * for are one constant and cannot drift apart.
9
+ *
10
+ * @public
11
+ */
12
+ const STATE_DB_FILENAME = "store.db";
13
+ /**
14
+ * The cache database's file name, under the XDG cache directory.
15
+ *
16
+ * @remarks
17
+ * Passed explicitly to `AppCache.layer` for the same reason as
18
+ * {@link STATE_DB_FILENAME}.
19
+ *
20
+ * @public
21
+ */
22
+ const CACHE_DB_FILENAME = "cache.db";
23
+
24
+ //#endregion
25
+ export { CACHE_DB_FILENAME, STATE_DB_FILENAME };