reposets 2.0.3 → 3.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.
@@ -9,9 +9,11 @@ import { RepoCacheLive } from "../../store/RepoCache.js";
9
9
  import { PHASE_NAMES } from "../../sync/phase.js";
10
10
  import { allPhases } from "../../sync/phases/index.js";
11
11
  import { SyncEngine, SyncEngineLive } from "../../sync/SyncEngine.js";
12
- import { Console, Effect, Layer, Option } from "effect";
13
- import { CliExit } from "@effected/cli";
12
+ import { syncSummaryBlock } from "../views/sync-progress-model.js";
13
+ import { Console, Effect, Exit, Layer, Option, PubSub } from "effect";
14
+ import { CliExit, CliInteractive, CliMessage, Doc } from "@effected/cli";
14
15
  import { CliError, Command, Flag } from "effect/cli";
16
+ import { CliUi } from "@effected/cli/ui";
15
17
  import { CodeScanning, DeploymentEnvironment, GitHubClient, GitHubRepository, RepositorySecret, RepositorySecurity, RepositoryVariable, Ruleset, WorkflowDispatch } from "@effected/github";
16
18
 
17
19
  //#region src/cli/commands/sync.ts
@@ -61,12 +63,86 @@ const partitionByProfile = (groups, only) => {
61
63
  return partitions;
62
64
  };
63
65
  /**
66
+ * How many repositories a run will act on.
67
+ *
68
+ * @remarks
69
+ * The engine's own selection rule, applied up front: every group the
70
+ * partitions selected, each narrowed by a `--repo` filter. Computed before the
71
+ * engine runs because a progress view needs its denominator at the start — the
72
+ * engine only learns the count by walking the groups.
73
+ *
74
+ * Groups whose profile turns out to be missing or unresolvable are still
75
+ * counted. They were selected; the summary saying `0/2 repos` beside an error
76
+ * is the honest account of a run that skipped them.
77
+ *
78
+ * @public
79
+ */
80
+ const selectedRepoCount = (groups, partitions, repo) => {
81
+ let total = 0;
82
+ for (const names of partitions.values()) for (const name of names) {
83
+ const repos = groups[name]?.repos ?? [];
84
+ total += repo === void 0 ? repos.length : repos.filter((r) => r === repo).length;
85
+ }
86
+ return total;
87
+ };
88
+ /**
89
+ * Run `work` under a live sync progress view, and end the view on every exit.
90
+ *
91
+ * @remarks
92
+ * Order matters at every step. The subscription is taken before `work`
93
+ * publishes anything, and handed to the view itself rather than as a stream,
94
+ * so `RunStarted` is never missed. `work` runs with the view's `logConsole` as
95
+ * its `Console`, so every report line and every `Effect.logError` lands above
96
+ * the frame.
97
+ *
98
+ * The view is closed on success, failure, defect and interruption alike, and
99
+ * before the scope closes. `close` folds whatever is still queued and commits
100
+ * the final frame; the bare scope close a failure would otherwise fall through
101
+ * to stops the fold at once, dropping the tail and leaving a spinner frame on
102
+ * the terminal as if the run were still going. When `work` did not end the
103
+ * run itself — it failed before `finish` — a `RunEnded` with no totals is
104
+ * published first, so the committed frame is the summary of what got done
105
+ * rather than a frozen spinner. The failure then propagates unchanged.
106
+ *
107
+ * @param view - the view's options without `events`: the handler passes
108
+ * `syncProgressView`, and a test the same value.
109
+ * @param work - the run, given the `PubSub` to publish its events to.
110
+ *
111
+ * @public
112
+ */
113
+ const runUnderSyncView = (view, work) => Effect.scoped(Effect.gen(function* () {
114
+ const pubsub = yield* PubSub.unbounded();
115
+ const events = yield* PubSub.subscribe(pubsub);
116
+ const handle = yield* CliUi.live({
117
+ ...view,
118
+ events
119
+ });
120
+ return yield* work(pubsub).pipe(Effect.provideService(Console.Console, handle.logConsole), Effect.onExit((exit) => Exit.isSuccess(exit) ? handle.close : PubSub.publish(pubsub, { _tag: "RunEnded" }).pipe(Effect.andThen(handle.close))));
121
+ }));
122
+ /**
64
123
  * `reposets sync` — apply the config to every selected repository.
65
124
  *
66
125
  * @remarks
67
126
  * The layer graph is built here rather than at the entrypoint because it
68
127
  * depends on values only known after the config and credentials have loaded:
69
- * the GitHub token, and the logger's tier and dry-run flag.
128
+ * the GitHub token, and the logger's dry-run flag and event sink.
129
+ *
130
+ * **Two ways to show progress, decided by `CliInteractive`.** A person at a
131
+ * terminal gets a live footer (`CliUi.live`, hosted): a spinner, the current
132
+ * repository and the counts so far, redrawn in place while the per-line report
133
+ * scrolls above it. Every line — the report on stdout and every
134
+ * `Effect.logError` on stderr — reaches the terminal through the view's
135
+ * `logConsole`, provided as the fiber's `Console` around the whole run, so
136
+ * nothing tears the frame. The view's committed final frame is the summary.
137
+ *
138
+ * Anyone else — a pipe, an agent, CI — gets no view and no React load: the
139
+ * view module is imported dynamically on the drawing path only. The same
140
+ * summary block is printed statically with `Doc.print` instead.
141
+ *
142
+ * **Findings are failures, not diagnostics.** "No config found", a dangling
143
+ * reference and a config with no groups exit 1 through `CliExit`, and they are
144
+ * written with `CliMessage.failure` — on stderr, and unlike `Effect.logError`
145
+ * never silenced by `--log-level`, because a run that did nothing must say why.
70
146
  *
71
147
  * @public
72
148
  */
@@ -77,7 +153,7 @@ const syncHandler = (input) => Effect.gen(function* () {
77
153
  const credentialsFile = yield* ReposetsCredentialsFile;
78
154
  const source = (yield* configFile.discover)[0];
79
155
  if (source === void 0) {
80
- yield* Effect.logError("No config found. Run 'reposets init' to create one.");
156
+ yield* CliMessage.failure("No config found. Run 'reposets init' to create one.");
81
157
  return yield* CliExit.set(1);
82
158
  }
83
159
  const config = source.value;
@@ -85,92 +161,110 @@ const syncHandler = (input) => Effect.gen(function* () {
85
161
  const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
86
162
  const dangling = danglingReferences(config);
87
163
  if (dangling.length > 0) {
88
- yield* Effect.logError("Config references sections that do not exist:");
89
- for (const ref of dangling) {
90
- const defined = ref.defined.length === 0 ? "none defined" : `defined: ${ref.defined.join(", ")}`;
91
- yield* Effect.logError(` ${ref.where}: '${ref.name}' does not exist — ${defined}`);
92
- }
93
- yield* Effect.logError("");
94
- yield* Effect.logError("Nothing was synced. Fix the references or run 'reposets validate' for the full list.");
164
+ yield* CliMessage.failure("Config references sections that do not exist:");
165
+ yield* Doc.print([Doc.list(dangling.map((ref) => Doc.paragraph(`${ref.where}: '${ref.name}' does not exist — ${ref.defined.length === 0 ? "none defined" : `defined: ${ref.defined.join(", ")}`}`))), Doc.paragraph("Nothing was synced. Fix the references or run 'reposets validate' for the full list.")], { stream: "stderr" });
95
166
  return yield* CliExit.set(1);
96
167
  }
97
168
  const partitions = partitionByProfile(config.groups, input.group);
98
169
  if (partitions.size === 0) {
99
170
  if (input.group !== void 0) return yield* Effect.fail(new CliError.UserError({ cause: `No group named '${input.group}'. Configured: ${Object.keys(config.groups).join(", ") || "none"}` }));
100
- yield* Effect.logError("No groups configured. Add a [groups.<name>] section to sync anything.");
171
+ yield* CliMessage.failure("No groups configured. Add a [groups.<name>] section to sync anything.");
101
172
  return yield* CliExit.set(1);
102
173
  }
174
+ const total = selectedRepoCount(config.groups, partitions, input.repo);
103
175
  const resolverLayer = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
104
- const loggerLayer = SyncLoggerLive({
105
- dryRun: input.dryRun,
106
- debug: input.debug
107
- });
108
- const sharedLayer = Layer.mergeAll(SyncJournalLive, AppliedStateLive, RepoCacheLive, resolverLayer, loggerLayer);
109
- const report = yield* Effect.gen(function* () {
110
- const journal = yield* SyncJournal;
111
- const logger = yield* SyncLogger;
112
- const resolver = yield* CredentialResolver;
113
- const runId = yield* journal.startRun({
114
- group: input.group,
115
- dryRun: input.dryRun
116
- }).pipe(Effect.orElseSucceed(() => "unrecorded"));
117
- let repos = 0;
118
- let changes = 0;
119
- let drifted = 0;
120
- let errors = 0;
121
- let firstError;
122
- for (const [profileName, groupNames] of partitions) {
123
- const profile = credentials.profiles[profileName];
124
- if (profile === void 0) {
125
- const message = `credential profile '${profileName}' does not exist (has: ${Object.keys(credentials.profiles).join(", ") || "none — run 'reposets credentials create'"}); skipping ${groupNames.join(", ")}`;
126
- yield* logger.syncError(`profile ${profileName}`, message);
127
- errors += 1;
128
- firstError ??= `profile ${profileName}: ${message}`;
129
- continue;
130
- }
131
- const resolved = yield* resolver.resolveGitHubToken(profile).pipe(Effect.result);
132
- if (resolved._tag === "Failure") {
133
- const message = `could not resolve the GitHub token for profile '${profileName}' — ${resolved.failure.reason}`;
134
- yield* logger.syncError(`profile ${profileName}`, message);
135
- errors += 1;
136
- firstError ??= `profile ${profileName}: ${message}`;
137
- continue;
176
+ /**
177
+ * The run itself, publishing to `events` when a view is drawn.
178
+ *
179
+ * @remarks
180
+ * The shared layer is built once and provided around the WHOLE loop
181
+ * rather than per partition. Layers memoize per build, so providing these
182
+ * inside the loop would mint a logger per profile — each with its own
183
+ * error tally, so `finish()` would report one partition's errors and call
184
+ * it the run — plus a separate journal and cache connection per profile.
185
+ */
186
+ const runSync = (events) => {
187
+ const loggerLayer = SyncLoggerLive({
188
+ dryRun: input.dryRun,
189
+ debug: input.debug,
190
+ events
191
+ });
192
+ const sharedLayer = Layer.mergeAll(SyncJournalLive, AppliedStateLive, RepoCacheLive, resolverLayer, loggerLayer);
193
+ return Effect.gen(function* () {
194
+ const journal = yield* SyncJournal;
195
+ const logger = yield* SyncLogger;
196
+ const resolver = yield* CredentialResolver;
197
+ yield* logger.runStart(total);
198
+ const runId = yield* journal.startRun({
199
+ group: input.group,
200
+ dryRun: input.dryRun
201
+ }).pipe(Effect.orElseSucceed(() => "unrecorded"));
202
+ let repos = 0;
203
+ let changes = 0;
204
+ let drifted = 0;
205
+ let errors = 0;
206
+ let firstError;
207
+ for (const [profileName, groupNames] of partitions) {
208
+ const profile = credentials.profiles[profileName];
209
+ if (profile === void 0) {
210
+ const message = `credential profile '${profileName}' does not exist (has: ${Object.keys(credentials.profiles).join(", ") || "none — run 'reposets credentials create'"}); skipping ${groupNames.join(", ")}`;
211
+ yield* logger.syncError(`profile ${profileName}`, message);
212
+ errors += 1;
213
+ firstError ??= `profile ${profileName}: ${message}`;
214
+ continue;
215
+ }
216
+ const resolved = yield* resolver.resolveGitHubToken(profile).pipe(Effect.result);
217
+ if (resolved._tag === "Failure") {
218
+ const message = `could not resolve the GitHub token for profile '${profileName}' — ${resolved.failure.reason}`;
219
+ yield* logger.syncError(`profile ${profileName}`, message);
220
+ errors += 1;
221
+ firstError ??= `profile ${profileName}: ${message}`;
222
+ continue;
223
+ }
224
+ const token = resolved.success;
225
+ const services = Layer.mergeAll(GitHubRepository.layer, Ruleset.layer, RepositorySecurity.layer, CodeScanning.layer, DeploymentEnvironment.layer, RepositorySecret.layer, RepositoryVariable.layer, WorkflowDispatch.layer).pipe(Layer.provideMerge(GitHubClient.layerFromToken({ token })));
226
+ const engineLayer = SyncEngineLive(allPhases).pipe(Layer.provide(services));
227
+ const scoped = {
228
+ ...config,
229
+ groups: Object.fromEntries(groupNames.map((name) => [name, config.groups[name]]))
230
+ };
231
+ const partial = yield* Effect.gen(function* () {
232
+ return yield* (yield* SyncEngine).syncAll(scoped, credentials, {
233
+ runId,
234
+ dryRun: input.dryRun,
235
+ noCleanup: input.noCleanup,
236
+ configDir,
237
+ group: input.group,
238
+ repo: input.repo,
239
+ only: phaseSet(input.only),
240
+ skip: phaseSet(input.skip)
241
+ });
242
+ }).pipe(Effect.provide(engineLayer));
243
+ repos += partial.repos;
244
+ changes += partial.changes;
245
+ drifted += partial.drifted;
246
+ errors += partial.errors;
247
+ if (partial.errorSummary !== void 0) firstError ??= partial.errorSummary;
138
248
  }
139
- const token = resolved.success;
140
- const services = Layer.mergeAll(GitHubRepository.layer, Ruleset.layer, RepositorySecurity.layer, CodeScanning.layer, DeploymentEnvironment.layer, RepositorySecret.layer, RepositoryVariable.layer, WorkflowDispatch.layer).pipe(Layer.provideMerge(GitHubClient.layerFromToken({ token })));
141
- const engineLayer = SyncEngineLive(allPhases).pipe(Layer.provide(services));
142
- const scoped = {
143
- ...config,
144
- groups: Object.fromEntries(groupNames.map((name) => [name, config.groups[name]]))
249
+ const summary = {
250
+ repos,
251
+ changes,
252
+ drifted,
253
+ errors
145
254
  };
146
- const partial = yield* Effect.gen(function* () {
147
- return yield* (yield* SyncEngine).syncAll(scoped, credentials, {
148
- runId,
149
- dryRun: input.dryRun,
150
- noCleanup: input.noCleanup,
151
- configDir,
152
- group: input.group,
153
- repo: input.repo,
154
- only: phaseSet(input.only),
155
- skip: phaseSet(input.skip)
156
- });
157
- }).pipe(Effect.provide(engineLayer));
158
- repos += partial.repos;
159
- changes += partial.changes;
160
- drifted += partial.drifted;
161
- errors += partial.errors;
162
- if (partial.errorSummary !== void 0) firstError ??= partial.errorSummary;
163
- }
164
- yield* journal.finishRun(runId, errors === 0 ? "success" : "partial", firstError).pipe(Effect.ignore);
165
- yield* logger.finish();
166
- return {
167
- repos,
168
- changes,
169
- drifted,
170
- errors
171
- };
172
- }).pipe(Effect.provide(sharedLayer));
173
- yield* Console.log(`${report.repos} repo(s), ${report.changes} change(s), ${report.drifted} drifted, ${report.errors} error(s)`);
255
+ yield* journal.finishRun(runId, errors === 0 ? "success" : "partial", firstError).pipe(Effect.ignore);
256
+ yield* logger.finish(summary);
257
+ return summary;
258
+ }).pipe(Effect.provide(sharedLayer));
259
+ };
260
+ const runWithView = Effect.promise(() => import("../views/sync-progress.js")).pipe(Effect.flatMap(({ syncProgressView }) => runUnderSyncView(syncProgressView, runSync)));
261
+ const interactive = yield* CliInteractive;
262
+ const report = interactive ? yield* runWithView : yield* runSync(void 0);
263
+ if (!interactive) yield* Doc.print([syncSummaryBlock({
264
+ dryRun: input.dryRun,
265
+ total,
266
+ ...report
267
+ })]);
174
268
  if (report.errors > 0 || input.failOnDrift && report.drifted > 0) yield* CliExit.set(1);
175
269
  });
176
270
  /**
@@ -199,4 +293,4 @@ const syncCommand = Command.make("sync", {
199
293
  })).pipe(Command.withDescription("Apply the config to every repository in a group, or all groups"));
200
294
 
201
295
  //#endregion
202
- export { partitionByProfile, syncCommand, syncHandler };
296
+ export { partitionByProfile, runUnderSyncView, selectedRepoCount, syncCommand, syncHandler };
@@ -2,12 +2,33 @@ import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/Conf
2
2
  import { danglingReferences } from "../../lib/config-refs.js";
3
3
  import { undefinedCredentialLabels } from "../../lib/credential-labels.js";
4
4
  import { orgOnlyViolations } from "../../lib/org-only.js";
5
- import { Console, Effect } from "effect";
6
- import { CliExit } from "@effected/cli";
5
+ import { Effect } from "effect";
6
+ import { CliExit, CliMessage, Doc, Status } from "@effected/cli";
7
7
  import { Command } from "effect/cli";
8
8
 
9
9
  //#region src/cli/commands/validate.ts
10
10
  /**
11
+ * Findings grouped by the group that references them, as a document.
12
+ *
13
+ * @remarks
14
+ * Grouped because that is the unit a reader fixes: every finding sits in a
15
+ * `[groups.<name>]` table, and seeing all of one group's problems together is
16
+ * one trip into the file rather than several. Each finding keeps its full
17
+ * config path — the problem is reported where the reference is **used**, which
18
+ * is where the edit goes. The optional `hint` closes the report with the fix
19
+ * that applies to every finding in it.
20
+ */
21
+ const findingsDoc = (findings, hint) => {
22
+ const byGroup = /* @__PURE__ */ new Map();
23
+ for (const finding of findings) {
24
+ const existing = byGroup.get(finding.group);
25
+ if (existing === void 0) byGroup.set(finding.group, [finding.text]);
26
+ else existing.push(finding.text);
27
+ }
28
+ const sections = [...byGroup].map(([group, texts]) => Doc.section(`[${group}]`, [Doc.list(texts.map((text) => Doc.paragraph(Doc.status(Status.core, "failure"), " ", text)), { compact: true })]));
29
+ return [Doc.section(void 0, hint === void 0 ? sections : [...sections, Doc.paragraph(hint)])];
30
+ };
31
+ /**
11
32
  * `reposets validate` — discovers, decodes and reports the config file.
12
33
  *
13
34
  * @remarks
@@ -19,9 +40,11 @@ import { Command } from "effect/cli";
19
40
  * The handler simply *requires* `ReposetsConfigFile`; how it gets built from
20
41
  * `--config` is the root command's business, not this command's.
21
42
  *
22
- * The success result goes to stdout. A reference or credential problem is a
23
- * finding, not a failure: the details go to stderr and the command succeeds
24
- * with `CliExit.set(1)`. A file that does not decode fails `discover`, and the
43
+ * The success result goes to stdout as a `CliMessage.success` line. A
44
+ * reference or credential problem is a finding, not a failure: an `Invalid:`
45
+ * line (`CliMessage.failure`, which no log level silences) and the grouped
46
+ * findings document go to stderr, and the command succeeds with
47
+ * `CliExit.set(1)`. A file that does not decode fails `discover`, and the
25
48
  * entrypoint's renderer prints the issue lines and exits `1`.
26
49
  *
27
50
  * @public
@@ -29,37 +52,37 @@ import { Command } from "effect/cli";
29
52
  const validateHandler = Effect.gen(function* () {
30
53
  const configFile = yield* ReposetsConfigFile;
31
54
  const credentialsFile = yield* ReposetsCredentialsFile;
32
- const sources = yield* configFile.discover;
33
- const value = yield* configFile.load;
34
- const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
35
- const dangling = danglingReferences(value);
36
- if (dangling.length > 0) {
37
- yield* Effect.logError(`Invalid: ${sources[0]?.path ?? "config"}`);
38
- for (const ref of dangling) {
39
- const defined = ref.defined.length === 0 ? "none defined" : `defined: ${ref.defined.join(", ")}`;
40
- yield* Effect.logError(` ${ref.where}: '${ref.name}' does not exist — ${defined}`);
41
- }
55
+ const source = (yield* configFile.discover)[0];
56
+ if (source === void 0) {
57
+ yield* CliMessage.failure("No config found. Run 'reposets init' to create one.");
42
58
  return yield* CliExit.set(1);
43
59
  }
60
+ const value = source.value;
61
+ const where = source.path;
62
+ const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
63
+ const invalid = (doc) => Effect.gen(function* () {
64
+ yield* CliMessage.failure(`Invalid: ${where}`);
65
+ yield* Doc.print(doc, { stream: "stderr" });
66
+ return yield* CliExit.set(1);
67
+ });
68
+ const dangling = danglingReferences(value);
69
+ if (dangling.length > 0) return yield* invalid(findingsDoc(dangling.map((ref) => ({
70
+ group: ref.group,
71
+ text: `${ref.where}: '${ref.name}' does not exist — ${ref.defined.length === 0 ? "none defined" : `defined: ${ref.defined.join(", ")}`}`
72
+ }))));
44
73
  const labels = undefinedCredentialLabels(value, credentials);
45
74
  const violations = orgOnlyViolations(value, credentials);
46
- if (labels.length > 0) {
47
- yield* Effect.logError(`Invalid: ${sources[0]?.path ?? "config"}`);
48
- for (const label of labels) yield* Effect.logError(` [${label.group}] ${label.where}: credential label '${label.label}' is not declared in profile '${label.profile}'`);
49
- yield* Effect.logError("");
50
- yield* Effect.logError("Add it to that profile's [resolve] section in reposets.credentials.toml, or correct the name.");
51
- return yield* CliExit.set(1);
52
- }
53
- if (violations.length > 0) {
54
- yield* Effect.logError(`Invalid: ${sources[0]?.path ?? "config"}`);
55
- for (const violation of violations) yield* Effect.logError(` [${violation.group}] ${violation.where}: ${violation.detail}, but profile '${violation.profile}' is a personal account`);
56
- yield* Effect.logError("");
57
- yield* Effect.logError("Either move these repositories to a profile declaring `org`, or drop the settings.");
58
- return yield* CliExit.set(1);
59
- }
75
+ if (labels.length > 0) return yield* invalid(findingsDoc(labels.map((label) => ({
76
+ group: label.group,
77
+ text: `${label.where}: credential label '${label.label}' is not declared in profile '${label.profile}'`
78
+ })), "Add it to that profile's [resolve] section in reposets.credentials.toml, or correct the name."));
79
+ if (violations.length > 0) return yield* invalid(findingsDoc(violations.map((violation) => ({
80
+ group: violation.group,
81
+ text: `${violation.where}: ${violation.detail}, but profile '${violation.profile}' is a personal account`
82
+ })), "Either move these repositories to a profile declaring `org`, or drop the settings."));
60
83
  const groups = Object.keys(value.groups).length;
61
- yield* Console.log(`Valid: ${sources[0]?.path ?? "config"}`);
62
- yield* Console.log(` groups: ${groups === 0 ? "none declared yet" : String(groups)}`);
84
+ yield* CliMessage.success(`Valid: ${where}`);
85
+ yield* Doc.print([Doc.verbatim(`groups: ${groups === 0 ? "none declared yet" : String(groups)}`, { indent: 2 })]);
63
86
  });
64
87
  /**
65
88
  * The `validate` command.
@@ -0,0 +1,131 @@
1
+ import { Doc, Status } from "@effected/cli";
2
+
3
+ //#region src/cli/views/sync-progress-model.ts
4
+ /**
5
+ * The state before any event.
6
+ *
7
+ * @public
8
+ */
9
+ const initialSyncProgress = {
10
+ dryRun: false,
11
+ total: 0,
12
+ repos: 0,
13
+ changes: 0,
14
+ drifted: 0,
15
+ errors: 0,
16
+ current: void 0,
17
+ finished: false
18
+ };
19
+ /**
20
+ * Fold one event into the progress state.
21
+ *
22
+ * @remarks
23
+ * The live counts are the view's own approximation — one per operation line,
24
+ * one per drift line — because the engine counts journal changes, which a
25
+ * settings write can make several of. `RunEnded` carries the engine's totals
26
+ * and replaces them, so the committed final frame says exactly what the static
27
+ * summary of a non-interactive run says.
28
+ *
29
+ * `repos` counts repositories *finished*: a repository is done when the next
30
+ * one starts. A `RunEnded` with no totals is a run that did not finish (the
31
+ * handler publishes one when the work fails or is interrupted), so it keeps
32
+ * every live count and does not count the repository in flight as done.
33
+ *
34
+ * @public
35
+ */
36
+ const reduceSyncProgress = (state, event) => {
37
+ switch (event._tag) {
38
+ case "RunStarted": return {
39
+ ...initialSyncProgress,
40
+ total: event.total,
41
+ dryRun: event.dryRun
42
+ };
43
+ case "GroupStarted": return state;
44
+ case "RepoStarted": return {
45
+ ...state,
46
+ repos: state.current === void 0 ? state.repos : state.repos + 1,
47
+ current: event.slug
48
+ };
49
+ case "Operation": return event.verb === "skip" ? state : {
50
+ ...state,
51
+ changes: state.changes + event.count
52
+ };
53
+ case "Drift": return {
54
+ ...state,
55
+ drifted: state.drifted + 1
56
+ };
57
+ case "Error": return {
58
+ ...state,
59
+ errors: state.errors + 1
60
+ };
61
+ case "RunEnded": return {
62
+ ...state,
63
+ repos: event.repos ?? state.repos,
64
+ changes: event.changes ?? state.changes,
65
+ drifted: event.drifted ?? state.drifted,
66
+ errors: event.errors ?? state.errors,
67
+ current: void 0,
68
+ finished: true
69
+ };
70
+ }
71
+ };
72
+ /**
73
+ * A run begins at `RunStarted`.
74
+ *
75
+ * @public
76
+ */
77
+ const isSyncRunStart = (event) => event._tag === "RunStarted";
78
+ /**
79
+ * A run ends at `RunEnded`, whose frame is committed as the summary.
80
+ *
81
+ * @public
82
+ */
83
+ const isSyncRunEnd = (event) => event._tag === "RunEnded";
84
+ const noun = (n, one, many) => n === 1 ? one : many;
85
+ /**
86
+ * The run's counts as one document block: `Dry run: 2/3 repos, 4 changes, 1
87
+ * drifted, 0 errors`.
88
+ *
89
+ * @remarks
90
+ * The one summary both paths draw — the live view inside its frame through
91
+ * `DocView`, a non-interactive run as a static `Doc.print` — so the two read
92
+ * byte for byte alike. Every counter shows at zero: `0 errors` is the line a
93
+ * reader looks for, and a counter that vanishes at zero makes the line change
94
+ * shape between runs.
95
+ *
96
+ * @public
97
+ */
98
+ const syncSummaryBlock = (state) => Doc.counts({
99
+ layout: "inline",
100
+ label: state.dryRun ? "Dry run" : "Sync",
101
+ total: () => state.total,
102
+ counters: [
103
+ Doc.counter(Status.core, "success", {
104
+ key: "repos",
105
+ label: noun(state.total, "repo", "repos"),
106
+ n: state.repos,
107
+ showZero: true
108
+ }),
109
+ Doc.counter(Status.core, "info", {
110
+ key: "changes",
111
+ label: noun(state.changes, "change", "changes"),
112
+ n: state.changes,
113
+ showZero: true
114
+ }),
115
+ Doc.counter(Status.core, "warning", {
116
+ key: "drifted",
117
+ label: "drifted",
118
+ n: state.drifted,
119
+ showZero: true
120
+ }),
121
+ Doc.counter(Status.core, "failure", {
122
+ key: "errors",
123
+ label: noun(state.errors, "error", "errors"),
124
+ n: state.errors,
125
+ showZero: true
126
+ })
127
+ ]
128
+ });
129
+
130
+ //#endregion
131
+ export { initialSyncProgress, isSyncRunEnd, isSyncRunStart, reduceSyncProgress, syncSummaryBlock };
@@ -0,0 +1,68 @@
1
+ import { initialSyncProgress, isSyncRunEnd, isSyncRunStart, reduceSyncProgress, syncSummaryBlock } from "./sync-progress-model.js";
2
+ import { Fmt } from "@effected/cli";
3
+ import { DocView, Styled, useGlyphs } from "@effected/cli/ui";
4
+ import { Box, Text } from "ink";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+
7
+ //#region src/cli/views/sync-progress.tsx
8
+ /**
9
+ * The redrawing footer of an interactive `sync` or `drift`.
10
+ *
11
+ * @remarks
12
+ * While the run goes: a spinner, the repository being worked on, and the
13
+ * counts so far. Once `RunEnded` arrives the spinner line goes and only the
14
+ * counts remain — that frame is committed to the terminal, so it IS the run's
15
+ * closing summary, the same block a non-interactive run prints statically.
16
+ *
17
+ * A component rather than a bare function so the glyph hook runs inside the
18
+ * providers the live view mounts it under.
19
+ */
20
+ const SyncProgress = (props) => {
21
+ const glyphs = useGlyphs();
22
+ const { state, frame } = props;
23
+ const summary = /* @__PURE__ */ jsx(DocView, { doc: syncSummaryBlock(state) });
24
+ if (state.finished) return summary;
25
+ const spinner = glyphs.spinner[frame % glyphs.spinner.length] ?? "";
26
+ const doing = state.dryRun ? "checking" : "syncing";
27
+ return /* @__PURE__ */ jsxs(Box, {
28
+ flexDirection: "column",
29
+ children: [/* @__PURE__ */ jsxs(Text, { children: [
30
+ /* @__PURE__ */ jsx(Styled, {
31
+ token: "accent",
32
+ children: spinner
33
+ }),
34
+ " ",
35
+ state.current === void 0 ? "starting…" : `${doing} ${Fmt.sanitize(state.current)}`
36
+ ] }), summary]
37
+ });
38
+ };
39
+ /**
40
+ * The sync progress view: every `CliUi.live` option but `events`.
41
+ *
42
+ * @remarks
43
+ * Defined once and exported so the handler and its test drive the same value —
44
+ * a test that rebuilt the options would exercise a copy. `hosted` because a
45
+ * run that cannot draw (a pipe, an agent, CI) has its own closing output — the
46
+ * static summary `sync` prints — so the view must print nothing there; the
47
+ * handler does not mount it on such a run in the first place.
48
+ *
49
+ * This module is the only one that holds JSX, and `sync` imports it
50
+ * dynamically on the drawing path alone, so a non-interactive run never loads
51
+ * React or Ink.
52
+ *
53
+ * @public
54
+ */
55
+ const syncProgressView = {
56
+ initial: initialSyncProgress,
57
+ reduce: reduceSyncProgress,
58
+ render: (state, frame) => /* @__PURE__ */ jsx(SyncProgress, {
59
+ state,
60
+ frame
61
+ }),
62
+ isStart: isSyncRunStart,
63
+ isTerminal: isSyncRunEnd,
64
+ mode: "hosted"
65
+ };
66
+
67
+ //#endregion
68
+ export { syncProgressView };
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