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.
@@ -9,9 +9,12 @@ 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 { syncProgressView } from "../views/sync-progress-view.js";
14
+ import { Console, Effect, Exit, Layer, Option, PubSub } from "effect";
15
+ import { CliExit, CliInteractive, CliMessage, Doc } from "@effected/cli";
14
16
  import { CliError, Command, Flag } from "effect/cli";
17
+ import { CliUi } from "@effected/cli/ui";
15
18
  import { CodeScanning, DeploymentEnvironment, GitHubClient, GitHubRepository, RepositorySecret, RepositorySecurity, RepositoryVariable, Ruleset, WorkflowDispatch } from "@effected/github";
16
19
 
17
20
  //#region src/cli/commands/sync.ts
@@ -61,12 +64,86 @@ const partitionByProfile = (groups, only) => {
61
64
  return partitions;
62
65
  };
63
66
  /**
67
+ * How many repositories a run will act on.
68
+ *
69
+ * @remarks
70
+ * The engine's own selection rule, applied up front: every group the
71
+ * partitions selected, each narrowed by a `--repo` filter. Computed before the
72
+ * engine runs because a progress view needs its denominator at the start — the
73
+ * engine only learns the count by walking the groups.
74
+ *
75
+ * Groups whose profile turns out to be missing or unresolvable are still
76
+ * counted. They were selected; the summary saying `0/2 repos` beside an error
77
+ * is the honest account of a run that skipped them.
78
+ *
79
+ * @public
80
+ */
81
+ const selectedRepoCount = (groups, partitions, repo) => {
82
+ let total = 0;
83
+ for (const names of partitions.values()) for (const name of names) {
84
+ const repos = groups[name]?.repos ?? [];
85
+ total += repo === void 0 ? repos.length : repos.filter((r) => r === repo).length;
86
+ }
87
+ return total;
88
+ };
89
+ /**
90
+ * Run `work` under a live sync progress view, and end the view on every exit.
91
+ *
92
+ * @remarks
93
+ * Order matters at every step. The subscription is taken before `work`
94
+ * publishes anything, and handed to the view itself rather than as a stream,
95
+ * so `RunStarted` is never missed. `work` runs with the view's `logConsole` as
96
+ * its `Console`, so every report line and every `Effect.logError` lands above
97
+ * the frame.
98
+ *
99
+ * The view is closed on success, failure, defect and interruption alike, and
100
+ * before the scope closes. `close` folds whatever is still queued and commits
101
+ * the final frame; the bare scope close a failure would otherwise fall through
102
+ * to stops the fold at once, dropping the tail and leaving a spinner frame on
103
+ * the terminal as if the run were still going. When `work` did not end the
104
+ * run itself — it failed before `finish` — a `RunEnded` with no totals is
105
+ * published first, so the committed frame is the summary of what got done
106
+ * rather than a frozen spinner. The failure then propagates unchanged.
107
+ *
108
+ * @param view - the view's options without `events`: the handler passes
109
+ * `syncProgressView`, and a test the same value.
110
+ * @param work - the run, given the `PubSub` to publish its events to.
111
+ *
112
+ * @public
113
+ */
114
+ const runUnderSyncView = (view, work) => Effect.scoped(Effect.gen(function* () {
115
+ const pubsub = yield* PubSub.unbounded();
116
+ const events = yield* PubSub.subscribe(pubsub);
117
+ const handle = yield* CliUi.live({
118
+ ...view,
119
+ events
120
+ });
121
+ 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))));
122
+ }));
123
+ /**
64
124
  * `reposets sync` — apply the config to every selected repository.
65
125
  *
66
126
  * @remarks
67
127
  * The layer graph is built here rather than at the entrypoint because it
68
128
  * 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.
129
+ * the GitHub token, and the logger's dry-run flag and event sink.
130
+ *
131
+ * **Two ways to show progress, decided by `CliInteractive`.** A person at a
132
+ * terminal gets a live footer (`CliUi.live`, hosted): a spinner, the current
133
+ * repository and the counts so far, redrawn in place while the per-line report
134
+ * scrolls above it. Every line — the report on stdout and every
135
+ * `Effect.logError` on stderr — reaches the terminal through the view's
136
+ * `logConsole`, provided as the fiber's `Console` around the whole run, so
137
+ * nothing tears the frame. The view's committed final frame is the summary.
138
+ *
139
+ * Anyone else — a pipe, an agent, CI — gets no view and no React load: the
140
+ * view's `render` is a `CliUi.lazyView`, and this path never mounts it. The same
141
+ * summary block is printed statically with `Doc.print` instead.
142
+ *
143
+ * **Findings are failures, not diagnostics.** "No config found", a dangling
144
+ * reference and a config with no groups exit 1 through `CliExit`, and they are
145
+ * written with `CliMessage.failure` — on stderr, and unlike `Effect.logError`
146
+ * never silenced by `--log-level`, because a run that did nothing must say why.
70
147
  *
71
148
  * @public
72
149
  */
@@ -77,7 +154,7 @@ const syncHandler = (input) => Effect.gen(function* () {
77
154
  const credentialsFile = yield* ReposetsCredentialsFile;
78
155
  const source = (yield* configFile.discover)[0];
79
156
  if (source === void 0) {
80
- yield* Effect.logError("No config found. Run 'reposets init' to create one.");
157
+ yield* CliMessage.failure("No config found. Run 'reposets init' to create one.");
81
158
  return yield* CliExit.set(1);
82
159
  }
83
160
  const config = source.value;
@@ -85,92 +162,109 @@ const syncHandler = (input) => Effect.gen(function* () {
85
162
  const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
86
163
  const dangling = danglingReferences(config);
87
164
  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.");
165
+ yield* CliMessage.failure("Config references sections that do not exist:");
166
+ 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
167
  return yield* CliExit.set(1);
96
168
  }
97
169
  const partitions = partitionByProfile(config.groups, input.group);
98
170
  if (partitions.size === 0) {
99
171
  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.");
172
+ yield* CliMessage.failure("No groups configured. Add a [groups.<name>] section to sync anything.");
101
173
  return yield* CliExit.set(1);
102
174
  }
175
+ const total = selectedRepoCount(config.groups, partitions, input.repo);
103
176
  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;
177
+ /**
178
+ * The run itself, publishing to `events` when a view is drawn.
179
+ *
180
+ * @remarks
181
+ * The shared layer is built once and provided around the WHOLE loop
182
+ * rather than per partition. Layers memoize per build, so providing these
183
+ * inside the loop would mint a logger per profile — each with its own
184
+ * error tally, so `finish()` would report one partition's errors and call
185
+ * it the run — plus a separate journal and cache connection per profile.
186
+ */
187
+ const runSync = (events) => {
188
+ const loggerLayer = SyncLoggerLive({
189
+ dryRun: input.dryRun,
190
+ debug: input.debug,
191
+ events
192
+ });
193
+ const sharedLayer = Layer.mergeAll(SyncJournalLive, AppliedStateLive, RepoCacheLive, resolverLayer, loggerLayer);
194
+ return Effect.gen(function* () {
195
+ const journal = yield* SyncJournal;
196
+ const logger = yield* SyncLogger;
197
+ const resolver = yield* CredentialResolver;
198
+ yield* logger.runStart(total);
199
+ const runId = yield* journal.startRun({
200
+ group: input.group,
201
+ dryRun: input.dryRun
202
+ }).pipe(Effect.orElseSucceed(() => "unrecorded"));
203
+ let repos = 0;
204
+ let changes = 0;
205
+ let drifted = 0;
206
+ let errors = 0;
207
+ let firstError;
208
+ for (const [profileName, groupNames] of partitions) {
209
+ const profile = credentials.profiles[profileName];
210
+ if (profile === void 0) {
211
+ const message = `credential profile '${profileName}' does not exist (has: ${Object.keys(credentials.profiles).join(", ") || "none — run 'reposets credentials create'"}); skipping ${groupNames.join(", ")}`;
212
+ yield* logger.syncError(`profile ${profileName}`, message);
213
+ errors += 1;
214
+ firstError ??= `profile ${profileName}: ${message}`;
215
+ continue;
216
+ }
217
+ const resolved = yield* resolver.resolveGitHubToken(profile).pipe(Effect.result);
218
+ if (resolved._tag === "Failure") {
219
+ const message = `could not resolve the GitHub token for profile '${profileName}' — ${resolved.failure.reason}`;
220
+ yield* logger.syncError(`profile ${profileName}`, message);
221
+ errors += 1;
222
+ firstError ??= `profile ${profileName}: ${message}`;
223
+ continue;
224
+ }
225
+ const token = resolved.success;
226
+ 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 })));
227
+ const engineLayer = SyncEngineLive(allPhases).pipe(Layer.provide(services));
228
+ const scoped = {
229
+ ...config,
230
+ groups: Object.fromEntries(groupNames.map((name) => [name, config.groups[name]]))
231
+ };
232
+ const partial = yield* Effect.gen(function* () {
233
+ return yield* (yield* SyncEngine).syncAll(scoped, credentials, {
234
+ runId,
235
+ dryRun: input.dryRun,
236
+ noCleanup: input.noCleanup,
237
+ configDir,
238
+ group: input.group,
239
+ repo: input.repo,
240
+ only: phaseSet(input.only),
241
+ skip: phaseSet(input.skip)
242
+ });
243
+ }).pipe(Effect.provide(engineLayer));
244
+ repos += partial.repos;
245
+ changes += partial.changes;
246
+ drifted += partial.drifted;
247
+ errors += partial.errors;
248
+ if (partial.errorSummary !== void 0) firstError ??= partial.errorSummary;
138
249
  }
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]]))
250
+ const summary = {
251
+ repos,
252
+ changes,
253
+ drifted,
254
+ errors
145
255
  };
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)`);
256
+ yield* journal.finishRun(runId, errors === 0 ? "success" : "partial", firstError).pipe(Effect.ignore);
257
+ yield* logger.finish(summary);
258
+ return summary;
259
+ }).pipe(Effect.provide(sharedLayer));
260
+ };
261
+ const interactive = yield* CliInteractive;
262
+ const report = interactive ? yield* runUnderSyncView(syncProgressView, runSync) : 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,143 @@
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
+ /**
85
+ * The run's counts as one document block: `Dry run: 2/3 repos, 4 changes, 1
86
+ * drifted, 0 errors`.
87
+ *
88
+ * @remarks
89
+ * The one summary both paths draw — the live view inside its frame through
90
+ * `DocView`, a non-interactive run as a static `Doc.print` — so the two read
91
+ * byte for byte alike. Every counter shows at zero: `0 errors` is the line a
92
+ * reader looks for, and a counter that vanishes at zero makes the line change
93
+ * shape between runs.
94
+ *
95
+ * Plurals are the kit's `{ one, other }` labels. A counter shown as a share
96
+ * of the headline's total — `repos` — agrees with that total (`0/1 repo`,
97
+ * `1/3 repos`); every other counter agrees with its own count.
98
+ *
99
+ * @public
100
+ */
101
+ const syncSummaryBlock = (state) => Doc.counts({
102
+ layout: "inline",
103
+ label: state.dryRun ? "Dry run" : "Sync",
104
+ total: () => state.total,
105
+ counters: [
106
+ Doc.counter(Status.core, "success", {
107
+ key: "repos",
108
+ label: {
109
+ one: "repo",
110
+ other: "repos"
111
+ },
112
+ n: state.repos,
113
+ showZero: true
114
+ }),
115
+ Doc.counter(Status.core, "info", {
116
+ key: "changes",
117
+ label: {
118
+ one: "change",
119
+ other: "changes"
120
+ },
121
+ n: state.changes,
122
+ showZero: true
123
+ }),
124
+ Doc.counter(Status.core, "warning", {
125
+ key: "drifted",
126
+ label: "drifted",
127
+ n: state.drifted,
128
+ showZero: true
129
+ }),
130
+ Doc.counter(Status.core, "failure", {
131
+ key: "errors",
132
+ label: {
133
+ one: "error",
134
+ other: "errors"
135
+ },
136
+ n: state.errors,
137
+ showZero: true
138
+ })
139
+ ]
140
+ });
141
+
142
+ //#endregion
143
+ export { initialSyncProgress, isSyncRunEnd, isSyncRunStart, reduceSyncProgress, syncSummaryBlock };
@@ -0,0 +1,32 @@
1
+ import { initialSyncProgress, isSyncRunEnd, isSyncRunStart, reduceSyncProgress } from "./sync-progress-model.js";
2
+ import { CliUi } from "@effected/cli/ui";
3
+
4
+ //#region src/cli/views/sync-progress-view.ts
5
+ /**
6
+ * The sync progress view: every `CliUi.live` option but `events`.
7
+ *
8
+ * @remarks
9
+ * Defined once and exported so the handler and its tests drive the same value —
10
+ * a test that rebuilt the options would exercise a copy. `hosted` because a
11
+ * run that cannot draw (a pipe, an agent, CI) has its own closing output — the
12
+ * static summary `sync` prints — so the view must print nothing there; the
13
+ * handler does not mount it on such a run in the first place.
14
+ *
15
+ * `render` is `CliUi.lazyView`: the JSX module is imported only when a run
16
+ * first draws, so importing this module — and `sync` — loads neither React
17
+ * nor Ink. It lives apart from the model because the JSX module imports the
18
+ * model, and a lazy import back into it is still an import cycle.
19
+ *
20
+ * @public
21
+ */
22
+ const syncProgressView = {
23
+ initial: initialSyncProgress,
24
+ reduce: reduceSyncProgress,
25
+ render: CliUi.lazyView(() => import("./sync-progress.js").then((module) => module.syncProgressRender)),
26
+ isStart: isSyncRunStart,
27
+ isTerminal: isSyncRunEnd,
28
+ mode: "hosted"
29
+ };
30
+
31
+ //#endregion
32
+ export { syncProgressView };
@@ -0,0 +1,55 @@
1
+ import { 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's `render`.
41
+ *
42
+ * @remarks
43
+ * This module is the only one that holds JSX. It is loaded through
44
+ * `CliUi.lazyView` by `syncProgressView` in `sync-progress-view.ts`, so only
45
+ * a run that draws it ever loads React or Ink.
46
+ *
47
+ * @public
48
+ */
49
+ const syncProgressRender = (state, frame) => /* @__PURE__ */ jsx(SyncProgress, {
50
+ state,
51
+ frame
52
+ });
53
+
54
+ //#endregion
55
+ export { syncProgressRender };