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.
- package/README.md +16 -8
- package/bin/reposets.js +103 -45
- package/cli/commands/credentials.js +214 -34
- package/cli/commands/doctor.js +64 -54
- package/cli/commands/drift.js +4 -0
- package/cli/commands/history.js +222 -68
- package/cli/commands/init.js +65 -18
- package/cli/commands/list.js +23 -10
- package/cli/commands/nuke.js +279 -43
- package/cli/commands/sync.js +175 -81
- package/cli/commands/validate.js +54 -31
- package/cli/views/sync-progress-model.js +131 -0
- package/cli/views/sync-progress.js +68 -0
- package/index.d.ts +35 -1
- package/index.js +2 -2
- package/package.json +16 -10
- package/schemas/hosted.js +79 -0
- package/services/ConfigFiles.js +53 -13
- package/services/SyncLogger.js +109 -23
- package/store/SyncJournal.js +1 -0
- package/store/files.js +25 -0
package/cli/commands/sync.js
CHANGED
|
@@ -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 {
|
|
13
|
-
import {
|
|
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
|
|
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*
|
|
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*
|
|
89
|
-
|
|
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*
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 };
|
package/cli/commands/validate.js
CHANGED
|
@@ -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 {
|
|
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
|
|
23
|
-
* finding, not a failure:
|
|
24
|
-
*
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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*
|
|
62
|
-
yield*
|
|
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
|