@savvy-web/cli 3.5.0 → 3.6.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.
Files changed (43) hide show
  1. package/cli/index.js +19 -3
  2. package/commands/changeset/commands/check.js +82 -21
  3. package/commands/changeset/commands/config-validate.js +9 -6
  4. package/commands/changeset/commands/deps-detect.js +2 -2
  5. package/commands/changeset/commands/deps-regen.js +94 -48
  6. package/commands/changeset/commands/init.js +56 -23
  7. package/commands/changeset/commands/transform.js +82 -8
  8. package/commands/changeset/commands/validate-file.js +12 -11
  9. package/commands/changeset/commands/version.js +48 -17
  10. package/commands/changeset/index.js +3 -3
  11. package/commands/changeset/utils/config-gate.js +12 -10
  12. package/commands/check-section.js +34 -0
  13. package/commands/check.js +48 -39
  14. package/commands/clean/progress.js +71 -0
  15. package/commands/clean/view.js +26 -0
  16. package/commands/clean.js +113 -50
  17. package/commands/commit/check.js +80 -77
  18. package/commands/commit/init.js +21 -10
  19. package/commands/commit/lint.js +2 -2
  20. package/commands/init.js +122 -34
  21. package/commands/lint/check.js +126 -171
  22. package/commands/lint/init.js +52 -33
  23. package/commands/lint/text.js +28 -13
  24. package/commands/repos/commands/add.js +14 -17
  25. package/commands/repos/commands/deregister.js +37 -18
  26. package/commands/repos/commands/note.js +78 -26
  27. package/commands/repos/commands/pin.js +39 -24
  28. package/commands/repos/commands/remove.js +30 -56
  29. package/commands/repos/commands/rename.js +32 -23
  30. package/commands/repos/commands/restore.js +42 -56
  31. package/commands/repos/commands/status.js +43 -47
  32. package/commands/repos/commands/sync.js +11 -40
  33. package/commands/repos/index.js +7 -10
  34. package/commands/repos/shared.js +129 -0
  35. package/index.d.ts +162 -61
  36. package/internal/command-error.js +60 -0
  37. package/internal/confirm.js +52 -0
  38. package/internal/failure-line.js +26 -12
  39. package/internal/report.js +101 -0
  40. package/main.js +23 -16
  41. package/package.json +17 -12
  42. package/version.js +1 -1
  43. package/internal/output.js +0 -52
package/commands/clean.js CHANGED
@@ -1,9 +1,12 @@
1
- import { Output } from "../internal/output.js";
1
+ import { Report } from "../internal/report.js";
2
+ import { CommandError } from "../internal/command-error.js";
2
3
  import { Command, Flag } from "effect/cli";
3
4
  import { join, sep } from "node:path";
4
- import { Data, Effect } from "effect";
5
+ import { CliInteractive } from "@effected/cli";
6
+ import { Console, Effect, PubSub } from "effect";
5
7
  import { WorkspaceDiscovery } from "@effected/workspaces";
6
8
  import { glob, realpath, rm } from "node:fs/promises";
9
+ import { CliUi } from "@effected/cli/ui";
7
10
 
8
11
  //#region src/commands/clean.ts
9
12
  /**
@@ -25,8 +28,6 @@ const DEFAULT_GLOBS = [
25
28
  ];
26
29
  /** Directory names a recursive (`**`) glob must never descend into. */
27
30
  const NO_DESCEND = /* @__PURE__ */ new Set(["node_modules", ".git"]);
28
- /** Unexpected failure while planning or removing artifacts. */
29
- var CleanError = class extends Data.TaggedError("CleanError") {};
30
31
  /**
31
32
  * Glob `patterns` at the top of `pkgPath`, classify dir vs file, and enforce
32
33
  * that every match stays within `pkgPath` (rejecting symlink/`..` escapes and
@@ -58,9 +59,9 @@ function collectTargets(pkgPath, patterns) {
58
59
  }
59
60
  return [...seen.values()];
60
61
  },
61
- catch: (e) => new CleanError({
62
- step: `glob ${pkgPath}`,
63
- reason: e instanceof Error ? e.message : String(e)
62
+ catch: (e) => CommandError.from(e, {
63
+ message: `could not list the artifacts in ${pkgPath}`,
64
+ hint: "Check that the workspace directory exists and is readable, or narrow --globs."
64
65
  })
65
66
  });
66
67
  }
@@ -103,16 +104,61 @@ function parseGlobs(raw) {
103
104
  const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
104
105
  return parts.length > 0 ? parts : DEFAULT_GLOBS;
105
106
  }
107
+ /** A workspace's display name: its relative path, `<root>` for the root. */
108
+ const groupName = (relativePath) => relativePath === "." ? "<root>" : relativePath;
109
+ /**
110
+ * One workspace's document: its name, then each removed target and each
111
+ * failure. Only targets that actually succeeded are listed as removed (in a
112
+ * dry run `removed` holds every target); a failure gets its own marker.
113
+ */
114
+ const groupBlocks = (name, report, dryRun) => {
115
+ const verb = dryRun ? "would remove" : "removed";
116
+ return [
117
+ Report.line(""),
118
+ Report.heading(name),
119
+ ...report.removed.map((t) => Report.detail(`${verb} [${t.kind}] ${t.path}`)),
120
+ ...report.failed.map((f) => Report.warn(`failed [${f.target.kind}] ${f.target.path}: ${f.reason}`))
121
+ ];
122
+ };
123
+ /**
124
+ * Remove each workspace's targets in order (leaves first, the root last),
125
+ * calling `onStart` before and `onDone` after each workspace that has any.
126
+ */
127
+ const removeGroups = (groups, dryRun, hooks) => Effect.gen(function* () {
128
+ let total = 0;
129
+ const failures = [];
130
+ for (const group of groups) {
131
+ if (group.targets.length === 0) continue;
132
+ yield* hooks.onStart(group);
133
+ const report = yield* removeTargets(group.targets, dryRun);
134
+ yield* hooks.onDone(group, report);
135
+ total += report.removed.length;
136
+ failures.push(...report.failed);
137
+ }
138
+ return {
139
+ total,
140
+ failures
141
+ };
142
+ });
106
143
  /**
107
144
  * Discover packages, plan targets per workspace (leaves first, root last),
108
145
  * dedup across overlapping roots, remove (or preview), and report.
146
+ *
147
+ * @remarks
148
+ * A person at a terminal (`CliInteractive`) watches a live progress view
149
+ * whose committed frame is the summary — the counts and a table of anything
150
+ * that could not be removed — with each workspace's document printed above
151
+ * it as the workspace finishes. Every other run (an agent, CI, a pipe)
152
+ * prints each workspace's document as it finishes and a one-line total, and
153
+ * never loads Ink or React. Either way, targets that could not be removed
154
+ * fail the run with a `CommandError` listing them.
109
155
  */
110
156
  function runClean(opts) {
111
157
  const patterns = parseGlobs(opts.globs);
112
158
  return Effect.gen(function* () {
113
- const packages = yield* (yield* WorkspaceDiscovery).listPackages().pipe(Effect.mapError((e) => new CleanError({
114
- step: "discover workspaces",
115
- reason: e.message
159
+ const packages = yield* (yield* WorkspaceDiscovery).listPackages().pipe(Effect.mapError((e) => CommandError.from(e, {
160
+ message: "could not discover the workspace packages",
161
+ hint: "Run savvy clean from inside the workspace (a directory with pnpm-workspace.yaml or a workspaces field)."
116
162
  })));
117
163
  const leaves = packages.filter((p) => !(p.relativePath === "."));
118
164
  const roots = packages.filter((p) => p.relativePath === ".");
@@ -122,47 +168,64 @@ function runClean(opts) {
122
168
  targets
123
169
  }))));
124
170
  const seen = /* @__PURE__ */ new Set();
125
- const groups = planned.map(({ pkg, targets }) => {
126
- return {
127
- pkg,
128
- targets: targets.filter((t) => {
129
- if (seen.has(t.path)) return false;
130
- seen.add(t.path);
131
- return true;
132
- })
133
- };
134
- });
135
- const leafGroups = groups.filter((g) => !(g.pkg.relativePath === "."));
136
- const rootGroups = groups.filter((g) => g.pkg.relativePath === ".");
137
- const verb = opts.dryRun ? "would remove" : "removed";
138
- let total = 0;
139
- const failures = [];
140
- for (const phase of [leafGroups, rootGroups]) {
141
- const reports = yield* Effect.forEach(phase, (g) => removeTargets(g.targets, opts.dryRun).pipe(Effect.map((report) => ({
142
- g,
143
- report
144
- }))));
145
- for (const { g, report } of reports) {
146
- if (g.targets.length === 0) continue;
147
- yield* Output.line("");
148
- yield* Output.heading(g.pkg.relativePath === "." ? "<root>" : g.pkg.relativePath);
149
- for (const t of report.removed) yield* Output.detail(`${verb} [${t.kind}] ${t.path}`);
150
- for (const f of report.failed) yield* Output.warn(`failed [${f.target.kind}] ${f.target.path}: ${f.reason}`);
151
- total += report.removed.length;
152
- failures.push(...report.failed);
153
- }
154
- }
155
- yield* Output.line("");
156
- yield* Output.ok(`${opts.dryRun ? "Would remove" : "Removed"} ${total} item(s)`);
157
- if (failures.length > 0) {
158
- for (const f of failures) yield* Effect.logError(`Failed to remove ${f.target.path}: ${f.reason}`);
159
- return yield* Effect.fail(new CleanError({
160
- step: "remove",
161
- reason: `${failures.length} target(s) could not be removed`
162
- }));
163
- }
171
+ const groups = planned.map(({ pkg, targets }) => ({
172
+ name: groupName(pkg.relativePath),
173
+ targets: targets.filter((t) => {
174
+ if (seen.has(t.path)) return false;
175
+ seen.add(t.path);
176
+ return true;
177
+ })
178
+ }));
179
+ const printGroup = (group, report) => Report.print(groupBlocks(group.name, report, opts.dryRun));
180
+ const outcome = (yield* CliInteractive) ? yield* cleanLive(groups, opts.dryRun, printGroup) : yield* removeGroups(groups, opts.dryRun, {
181
+ onStart: () => Effect.void,
182
+ onDone: printGroup
183
+ }).pipe(Effect.tap(({ total }) => Report.print([Report.line(""), Report.ok(`${opts.dryRun ? "Would remove" : "Removed"} ${total} item(s)`)])));
184
+ if (outcome.failures.length > 0) return yield* Effect.fail(new CommandError({
185
+ message: `${outcome.failures.length} target(s) could not be removed`,
186
+ detail: outcome.failures.map((f) => `${f.target.path}: ${f.reason}`),
187
+ hint: "Check the permissions on those paths and that no process holds them open, then re-run savvy clean."
188
+ }));
164
189
  });
165
190
  }
191
+ /**
192
+ * The removal pass under a live view, for a person at a terminal.
193
+ *
194
+ * @remarks
195
+ * The view's module (the JSX) is imported here, on the one path that draws,
196
+ * so no other run loads React. Every line the pass writes goes through the
197
+ * view's `logConsole`, landing above the frame instead of tearing it.
198
+ */
199
+ const cleanLive = (groups, dryRun, printGroup) => Effect.scoped(Effect.gen(function* () {
200
+ const { cleanView } = yield* Effect.promise(() => import("./clean/view.js"));
201
+ const pubsub = yield* PubSub.unbounded();
202
+ const events = yield* PubSub.subscribe(pubsub);
203
+ const view = yield* CliUi.live({
204
+ ...cleanView,
205
+ events
206
+ });
207
+ const workspaces = groups.filter((g) => g.targets.length > 0).length;
208
+ yield* PubSub.publish(pubsub, {
209
+ _tag: "Started",
210
+ workspaces,
211
+ dryRun
212
+ });
213
+ const outcome = yield* removeGroups(groups, dryRun, {
214
+ onStart: (group) => PubSub.publish(pubsub, {
215
+ _tag: "WorkspaceStarted",
216
+ name: group.name
217
+ }),
218
+ onDone: (group, report) => Effect.andThen(printGroup(group, report), PubSub.publish(pubsub, {
219
+ _tag: "WorkspaceDone",
220
+ name: group.name,
221
+ removed: report.removed,
222
+ failed: report.failed
223
+ }))
224
+ }).pipe(Effect.provideService(Console.Console, view.logConsole));
225
+ yield* PubSub.publish(pubsub, { _tag: "Ended" });
226
+ yield* view.close;
227
+ return outcome;
228
+ }));
166
229
  /* v8 ignore start -- CLI option/registration; orchestration tested via runClean */
167
230
  const globsOption = Flag.String("globs").pipe(Flag.withAlias("g"), Flag.withDescription(`Comma-separated glob patterns to remove from each workspace root (default: ${DEFAULT_GLOBS.join(",")})`), Flag.withDefault(DEFAULT_GLOBS.join(",")));
168
231
  const dryRunOption = Flag.Boolean("dry-run").pipe(Flag.withAlias("n"), Flag.withDescription("Report what would be removed without deleting anything"), Flag.withDefault(false));
@@ -182,4 +245,4 @@ const _cleanCommand = Command.make("clean", {
182
245
  const cleanCommand = _cleanCommand;
183
246
 
184
247
  //#endregion
185
- export { CleanError, cleanCommand, collectTargets, parseGlobs, removeTargets, runClean };
248
+ export { cleanCommand, collectTargets, parseGlobs, removeTargets, runClean };
@@ -1,6 +1,8 @@
1
- import { Output } from "../../internal/output.js";
1
+ import { Report } from "../../internal/report.js";
2
+ import { hookTable } from "../check-section.js";
2
3
  import { HUSKY_HOOK_PATH, POST_CHECKOUT_HOOK_PATH, POST_COMMIT_HOOK_PATH, POST_MERGE_HOOK_PATH } from "./constants.js";
3
4
  import { SECTION_DEF, savvyCommitBlock } from "./init.js";
5
+ import { CliExit } from "@effected/cli";
4
6
  import { ChangesetConfigReader, Commitlint, SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, savvyBasePreamble, savvyHooksHygiene, savvyInstallBlock, savvyToolchainCheck } from "@savvy-web/silk-effects";
5
7
  import { Effect, FileSystem, Option } from "effect";
6
8
  import { VersioningStrategy } from "@effected/workspaces";
@@ -82,55 +84,47 @@ const detectReleaseFormat = Effect.gen(function* () {
82
84
  return STRATEGY_TO_FORMAT[strategy.type] ?? "semver";
83
85
  });
84
86
  /**
85
- * Run the check validation pipeline.
87
+ * Check the commitlint setup and return it as a {@link CheckSection}.
86
88
  *
87
- * Exported so Task B6's unified `savvy check` orchestrator can invoke the
88
- * commitlint check step directly without going through the CLI command layer.
89
- *
90
- * @returns An Effect that performs validation and logs results
89
+ * @remarks
90
+ * Severity rule: anything `savvy init` would write or rewrite — a missing
91
+ * config file or `commit-msg` hook, a hook file or managed section that is
92
+ * absent or drifted — is a finding (`✗`, verdict `failure`). A missing DCO
93
+ * file is advisory (`↷`): signoff is simply not required.
91
94
  *
92
95
  * @internal
93
96
  */
94
- function runCommitCheck() {
97
+ function commitCheckSection() {
95
98
  return Effect.gen(function* () {
96
99
  const fs = yield* FileSystem.FileSystem;
97
100
  const ms = yield* ManagedSection;
98
- yield* Output.heading("commitlint configuration");
101
+ const blocks = [];
102
+ const rows = [];
103
+ const stateOf = (outcome) => CheckOutcome.$is("UpToDate")(outcome) ? "up-to-date" : CheckOutcome.$is("Drifted")(outcome) ? "outdated" : "missing";
99
104
  const foundConfig = yield* findConfigFile(fs);
100
- if (foundConfig) yield* Output.ok(`Config file: ${foundConfig}`);
101
- else yield* Output.fail(`No commitlint config file found`);
105
+ blocks.push(foundConfig ? Report.ok(`Config file: ${foundConfig}`) : Report.fail("No commitlint config file found"));
102
106
  const hasHuskyHook = yield* fs.exists(HUSKY_HOOK_PATH);
103
- if (hasHuskyHook) yield* Output.ok(`Husky hook: ${HUSKY_HOOK_PATH}`);
104
- else yield* Output.fail(`No husky commit-msg hook found`);
105
- let sectionsHealthy = true;
107
+ blocks.push(hasHuskyHook ? Report.ok(`Husky hook: ${HUSKY_HOOK_PATH}`) : Report.fail("No husky commit-msg hook found"));
106
108
  if (hasHuskyHook) {
107
- const baseStatus = yield* ms.check(HUSKY_HOOK_PATH, SavvyBaseSection.section(savvyBasePreamble()));
108
- if (CheckOutcome.$is("UpToDate")(baseStatus)) yield* Output.ok(`Base section: up-to-date`);
109
- else if (CheckOutcome.$is("Drifted")(baseStatus)) {
110
- sectionsHealthy = false;
111
- yield* Output.warn(`Base section: outdated (run 'savvy init' to update)`);
112
- } else {
113
- sectionsHealthy = false;
114
- yield* Output.skip(`Base section: not found (run 'savvy init' to add)`);
115
- }
109
+ rows.push([
110
+ HUSKY_HOOK_PATH,
111
+ "savvy-base",
112
+ stateOf(yield* ms.check(HUSKY_HOOK_PATH, SavvyBaseSection.section(savvyBasePreamble())))
113
+ ]);
116
114
  const block = yield* ms.read(HUSKY_HOOK_PATH, SECTION_DEF);
117
115
  if (Option.isSome(block)) {
118
116
  const configPath = extractConfigPathFromManaged(block.value.content);
119
- if (configPath) {
120
- const status = yield* ms.check(HUSKY_HOOK_PATH, savvyCommitBlock(configPath));
121
- if (CheckOutcome.$is("UpToDate")(status)) yield* Output.ok(`Commit section: up-to-date`);
122
- else {
123
- sectionsHealthy = false;
124
- yield* Output.warn(`Commit section: outdated (run 'savvy init' to update)`);
125
- }
126
- } else {
127
- sectionsHealthy = false;
128
- yield* Output.warn(`Commit section: outdated (run 'savvy init' to update)`);
129
- }
130
- } else {
131
- sectionsHealthy = false;
132
- yield* Output.skip(`Commit section: not found (run 'savvy init' to add)`);
133
- }
117
+ const state = configPath ? CheckOutcome.$is("UpToDate")(yield* ms.check(".husky/commit-msg", savvyCommitBlock(configPath))) ? "up-to-date" : "outdated" : "outdated";
118
+ rows.push([
119
+ HUSKY_HOOK_PATH,
120
+ "savvy-commit",
121
+ state
122
+ ]);
123
+ } else rows.push([
124
+ HUSKY_HOOK_PATH,
125
+ "savvy-commit",
126
+ "missing"
127
+ ]);
134
128
  }
135
129
  for (const hookPath of [
136
130
  POST_CHECKOUT_HOOK_PATH,
@@ -138,55 +132,64 @@ function runCommitCheck() {
138
132
  POST_COMMIT_HOOK_PATH
139
133
  ]) {
140
134
  if (!(yield* fs.exists(hookPath))) {
141
- sectionsHealthy = false;
142
- yield* Output.skip(`Hygiene hook: ${hookPath} not found (run 'savvy init' to add)`);
135
+ rows.push([
136
+ hookPath,
137
+ "(hook file)",
138
+ "missing"
139
+ ]);
143
140
  continue;
144
141
  }
145
- const hygieneStatus = yield* ms.check(hookPath, SavvyHooksSection.section(savvyHooksHygiene()));
146
- if (CheckOutcome.$is("UpToDate")(hygieneStatus)) yield* Output.ok(`Hygiene hook: ${hookPath}`);
147
- else if (CheckOutcome.$is("Drifted")(hygieneStatus)) {
148
- sectionsHealthy = false;
149
- yield* Output.warn(`Hygiene hook: ${hookPath} outdated (run 'savvy init' to update)`);
150
- } else {
151
- sectionsHealthy = false;
152
- yield* Output.skip(`Hygiene hook: ${hookPath} section not found (run 'savvy init' to add)`);
153
- }
142
+ rows.push([
143
+ hookPath,
144
+ "savvy-hooks",
145
+ stateOf(yield* ms.check(hookPath, SavvyHooksSection.section(savvyHooksHygiene())))
146
+ ]);
154
147
  if (hookPath === ".husky/post-commit") continue;
155
148
  const installHook = hookPath === ".husky/post-checkout" ? "post-checkout" : "post-merge";
156
- const installStatus = yield* ms.check(hookPath, savvyInstallBlock(installHook));
157
- if (CheckOutcome.$is("UpToDate")(installStatus)) yield* Output.ok(`Dependency install: ${hookPath}`);
158
- else if (CheckOutcome.$is("Drifted")(installStatus)) {
159
- sectionsHealthy = false;
160
- yield* Output.warn(`Dependency install: ${hookPath} outdated (run 'savvy init' to update)`);
161
- } else {
162
- sectionsHealthy = false;
163
- yield* Output.skip(`Dependency install: ${hookPath} section not found (run 'savvy init' to add)`);
164
- }
165
- const toolchainStatus = yield* ms.check(hookPath, SavvyToolchainSection.section(savvyToolchainCheck()));
166
- if (CheckOutcome.$is("UpToDate")(toolchainStatus)) yield* Output.ok(`Toolchain check: ${hookPath}`);
167
- else if (CheckOutcome.$is("Drifted")(toolchainStatus)) {
168
- sectionsHealthy = false;
169
- yield* Output.warn(`Toolchain check: ${hookPath} outdated (run 'savvy init' to update)`);
170
- } else {
171
- sectionsHealthy = false;
172
- yield* Output.skip(`Toolchain check: ${hookPath} section not found (run 'savvy init' to add)`);
173
- }
149
+ rows.push([
150
+ hookPath,
151
+ "savvy-install",
152
+ stateOf(yield* ms.check(hookPath, savvyInstallBlock(installHook)))
153
+ ]);
154
+ rows.push([
155
+ hookPath,
156
+ "savvy-toolchain",
157
+ stateOf(yield* ms.check(hookPath, SavvyToolchainSection.section(savvyToolchainCheck())))
158
+ ]);
174
159
  }
175
- if (yield* fs.exists(DCO_FILE_PATH)) yield* Output.ok(`DCO file: ${DCO_FILE_PATH}`);
176
- else yield* Output.skip(`No DCO file (signoff not required)`);
177
- yield* Output.line("");
178
- yield* Output.heading("Detected settings");
179
- yield* Output.detail(`DCO required: ${Commitlint.detectDCO()}`);
160
+ if (rows.length > 0) blocks.push(hookTable(rows));
161
+ const hasDCOFile = yield* fs.exists(DCO_FILE_PATH);
162
+ blocks.push(hasDCOFile ? Report.ok(`DCO file: ${DCO_FILE_PATH}`) : Report.skip("No DCO file (signoff not required)"));
180
163
  const releaseFormat = yield* detectReleaseFormat;
181
- yield* Output.detail(`Release format: ${releaseFormat}`);
182
164
  const scopes = yield* Effect.catch(Commitlint.detectScopes, () => Effect.succeed([]));
183
165
  const scopeDisplay = scopes.length > 0 ? scopes.join(", ") : "(none - not a monorepo or no packages found)";
184
- yield* Output.detail(`Detected scopes: ${scopeDisplay}`);
185
- yield* Output.line("");
186
- if (!foundConfig || !hasHuskyHook || !sectionsHealthy) yield* Output.fail(`Commitlint needs configuration. Run: savvy init`);
187
- else yield* Output.ok(`Commitlint is configured correctly.`);
166
+ blocks.push(Report.line("Detected settings"), Report.detail(`DCO required: ${Commitlint.detectDCO()}`), Report.detail(`Release format: ${releaseFormat}`), Report.detail(`Detected scopes: ${scopeDisplay}`));
167
+ const sectionsHealthy = rows.every(([, , state]) => state === "up-to-date");
168
+ const hasIssues = !foundConfig || !hasHuskyHook || !sectionsHealthy;
169
+ blocks.push(hasIssues ? Report.fail("Commitlint needs configuration") : Report.ok("Commitlint is configured correctly"));
170
+ return {
171
+ title: "commitlint",
172
+ blocks,
173
+ verdict: hasIssues ? "failure" : "success",
174
+ fixedByInit: hasIssues
175
+ };
176
+ });
177
+ }
178
+ /**
179
+ * Check the commitlint setup, print its report on stdout, and set exit code 1
180
+ * when it found a misconfiguration (see {@link commitCheckSection}).
181
+ *
182
+ * @returns An Effect that performs validation and prints its report
183
+ *
184
+ * @internal
185
+ */
186
+ function runCommitCheck() {
187
+ return Effect.gen(function* () {
188
+ const section = yield* commitCheckSection();
189
+ yield* Report.print([Report.heading(section.title), ...section.blocks]);
190
+ if (section.verdict === "failure") yield* CliExit.set(1);
188
191
  });
189
192
  }
190
193
 
191
194
  //#endregion
192
- export { runCommitCheck };
195
+ export { commitCheckSection, runCommitCheck };
@@ -1,4 +1,5 @@
1
- import { Output } from "../../internal/output.js";
1
+ import { Report } from "../../internal/report.js";
2
+ import { CommandError } from "../../internal/command-error.js";
2
3
  import { HUSKY_HOOK_PATH, POST_CHECKOUT_HOOK_PATH, POST_COMMIT_HOOK_PATH, POST_MERGE_HOOK_PATH } from "./constants.js";
3
4
  import { dirname } from "node:path";
4
5
  import { SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, savvyBasePreamble, savvyHooksHygiene, savvyInstallBlock, savvyToolSection, savvyToolchainCheck } from "@savvy-web/silk-effects";
@@ -70,7 +71,18 @@ export default CommitlintConfig.silk();
70
71
  function makeExecutable(path) {
71
72
  return Effect.tryPromise({
72
73
  try: () => chmod(path, EXECUTABLE_MODE),
73
- catch: (e) => new Error(String(e))
74
+ catch: (e) => CommandError.from(e, {
75
+ message: `could not make ${path} executable`,
76
+ hint: `Check that you own ${path}, then re-run the init.`
77
+ })
78
+ });
79
+ }
80
+ /** The failure for a `--config` path given as absolute. */
81
+ function absoluteConfigError(config) {
82
+ return new CommandError({
83
+ message: "Config path must be relative to repository root, not absolute",
84
+ detail: [`got ${config}`],
85
+ hint: "Pass the config path relative to the repository root, e.g. lib/configs/<name>.config.ts."
74
86
  });
75
87
  }
76
88
  /**
@@ -89,14 +101,14 @@ function runCommitInit(opts) {
89
101
  return Effect.gen(function* () {
90
102
  const fs = yield* FileSystem.FileSystem;
91
103
  const ms = yield* ManagedSection;
92
- if (config.startsWith("/")) yield* Effect.fail(/* @__PURE__ */ new Error("Config path must be relative to repository root, not absolute"));
93
- yield* Output.heading("commitlint");
104
+ if (config.startsWith("/")) return yield* Effect.fail(absoluteConfigError(config));
105
+ yield* Report.print([Report.heading("commitlint")]);
94
106
  yield* fs.makeDirectory(".husky", { recursive: true });
95
107
  if (force) yield* fs.writeFileString(HUSKY_HOOK_PATH, COMMIT_MSG_HEADER);
96
108
  else yield* ensureHookFile(HUSKY_HOOK_PATH, COMMIT_MSG_HEADER);
97
109
  const commitResults = yield* ms.syncAll(HUSKY_HOOK_PATH, [SavvyBaseSection.section(savvyBasePreamble()), savvyCommitBlock(config)]);
98
110
  yield* makeExecutable(HUSKY_HOOK_PATH);
99
- yield* Output.ok(`${force ? "Replaced" : "Synced"} ${HUSKY_HOOK_PATH} (${commitResults.map((r) => r._tag).join(", ")})`);
111
+ yield* Report.print([Report.ok(`${force ? "Replaced" : "Synced"} ${HUSKY_HOOK_PATH} (${commitResults.map((r) => r._tag).join(", ")})`)]);
100
112
  for (const [hookPath, installHook] of HYGIENE_HOOKS) {
101
113
  yield* ensureHookFile(hookPath, HYGIENE_HEADER);
102
114
  const sections = [SavvyHooksSection.section(savvyHooksHygiene())];
@@ -106,17 +118,16 @@ function runCommitInit(opts) {
106
118
  }
107
119
  yield* ms.syncAll(hookPath, sections);
108
120
  yield* makeExecutable(hookPath);
109
- yield* Output.ok(`Synced ${hookPath}`);
121
+ yield* Report.print([Report.ok(`Synced ${hookPath}`)]);
110
122
  }
111
- if ((yield* fs.exists(config)) && !force) yield* Output.warn(`${config} already exists (use --force to overwrite)`);
123
+ if ((yield* fs.exists(config)) && !force) yield* Report.print([Report.warn(`${config} already exists (use --force to overwrite)`)]);
112
124
  else {
113
125
  const configDir = dirname(config);
114
126
  if (configDir && configDir !== ".") yield* fs.makeDirectory(configDir, { recursive: true });
115
127
  yield* fs.writeFileString(config, CONFIG_CONTENT);
116
- yield* Output.ok(`Created ${config}`);
128
+ yield* Report.print([Report.ok(`Created ${config}`)]);
117
129
  }
118
- yield* Output.line("");
119
- yield* Output.ok("Install @commitlint/cli if it is not already installed");
130
+ yield* Report.print([Report.line(""), Report.ok("Install @commitlint/cli if it is not already installed")]);
120
131
  });
121
132
  }
122
133
 
@@ -1,4 +1,4 @@
1
- import { Output } from "../../internal/output.js";
1
+ import { Report } from "../../internal/report.js";
2
2
  import { buildCommitlintInvocation } from "./commitlint-invocation.js";
3
3
  import { Argument, CliError, Command } from "effect/cli";
4
4
  import { Commitlint } from "@savvy-web/silk-effects";
@@ -76,7 +76,7 @@ function runCommitLint(file) {
76
76
  if (stdout.length > 0) yield* Effect.sync(() => process.stdout.write(stdout));
77
77
  if (stderr.length > 0) yield* Effect.sync(() => process.stderr.write(stderr));
78
78
  if (!passed) return yield* Effect.fail(new CliError.UserError({ cause: `Commit message at ${file} failed commitlint validation` }));
79
- yield* Output.ok("Commit message passes the Silk commitlint preset");
79
+ yield* Report.print([Report.ok("Commit message passes the Silk commitlint preset")]);
80
80
  });
81
81
  }
82
82
  /* v8 ignore next */