@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/cli/index.js CHANGED
@@ -5,7 +5,8 @@ import { initCommand } from "../commands/init.js";
5
5
  import { lintCommand } from "../commands/lint/index.js";
6
6
  import { reposCommand } from "../commands/repos/index.js";
7
7
  import { cleanCommand } from "../commands/clean.js";
8
- import { Command } from "effect/cli";
8
+ import { CliConfig, Command, GlobalFlag } from "effect/cli";
9
+ import { CliAudience } from "@effected/cli";
9
10
  import { BiomeSchemaSync, ChangesetConfigReader, Changesets, ConfigDiscovery, Repos, SilkPublishability } from "@savvy-web/silk-effects";
10
11
  import { Layer } from "effect";
11
12
  import { Git } from "@effected/git";
@@ -63,7 +64,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
63
64
  /**
64
65
  * Root `savvy` command nesting the two orchestrators and three command groups.
65
66
  */
66
- const rootCommand = Command.make("savvy").pipe(Command.withSubcommands([
67
+ const rootCommand = Command.make("savvy").pipe(Command.withSharedFlags(CliAudience.flags()), Command.withSubcommands([
67
68
  initCommand,
68
69
  checkCommand,
69
70
  cleanCommand,
@@ -128,9 +129,24 @@ const ToolDiscoveryGroupLive = ToolDiscovery.layer.pipe(Layer.provide(LocalExecL
128
129
  * provided once, at the edge; this module never runs anything itself.
129
130
  */
130
131
  const AppLive = Layer.mergeAll(ToolDiscoveryGroupLive, InspectorAndAnalyzerLive, ReposGroupLive).pipe(Layer.provideMerge(BaseLive));
132
+ /**
133
+ * Core's built-in global flags for the whole program: `--help`, `--version`,
134
+ * `--completions` and `--log-level`. Core's generic `--wizard` is trimmed —
135
+ * savvy asks its own questions through `@effected/cli/ui` screens. Provided
136
+ * around the program by `main()`, inside `CliRuntime.main`'s environment, so
137
+ * help, parsing and `CliAudience`'s interactive `--wizard` restore all read
138
+ * it (the kit only restores a wizard its own gate removed, never one a
139
+ * consumer's `builtIns` left out).
140
+ */
141
+ const CliConfigLive = CliConfig.layer({ builtIns: [
142
+ GlobalFlag.Help,
143
+ GlobalFlag.Version,
144
+ GlobalFlag.Completions,
145
+ GlobalFlag.LogLevel
146
+ ] });
131
147
  /** The platform `main()` passes to `CliRuntime.main`: Node's filesystem, path, stdio, terminal and spawner. */
132
148
  const CliPlatform = NodeServices.layer;
133
149
  /* v8 ignore stop */
134
150
 
135
151
  //#endregion
136
- export { AppLive, CliPlatform, rootCommand };
152
+ export { AppLive, CliConfigLive, CliPlatform, rootCommand };
@@ -1,7 +1,7 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
2
  import { Argument, Command } from "effect/cli";
3
- import { resolve } from "node:path";
4
- import { CliExit } from "@effected/cli";
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
+ import { CliExit, Doc } from "@effected/cli";
5
5
  import { Changesets } from "@savvy-web/silk-effects";
6
6
  import { Effect } from "effect";
7
7
 
@@ -16,8 +16,10 @@ import { Effect } from "effect";
16
16
  * @remarks
17
17
  * The command resolves the directory argument, delegates to
18
18
  * {@link ChangesetLinter.validate}, groups the resulting
19
- * {@link LintMessage} objects by file path, and logs each file's errors
20
- * indented under the file name. Sets exit code 1 through `CliExit.set` when errors
19
+ * {@link LintMessage} objects by file path, and prints one section per file
20
+ * (titled by the file, its findings as a position/rule/message table) plus a
21
+ * GitHub Actions error annotation per finding, which only the Actions log
22
+ * renders. Sets exit code 1 through `CliExit.set` when errors
21
23
  * are found.
22
24
  *
23
25
  * @example
@@ -28,20 +30,44 @@ import { Effect } from "effect";
28
30
  * @internal
29
31
  */
30
32
  const { ChangesetLinter } = Changesets;
33
+ /** The title `savvy check` gives this section. */
34
+ const SECTION_TITLE = "changesets";
35
+ /** The columns of a file's findings table. */
36
+ const FINDING_COLUMNS = [
37
+ { header: "position" },
38
+ { header: "rule" },
39
+ { header: "message" }
40
+ ];
41
+ /**
42
+ * A finding's file as the report and the runner show it: relative to the
43
+ * working directory when it lies under it (what a GitHub Actions annotation
44
+ * resolves against the workspace), absolute otherwise.
45
+ */
46
+ const displayPath = (cwd, file) => {
47
+ const rel = relative(cwd, file);
48
+ return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? file : rel;
49
+ };
31
50
  /* v8 ignore next */
32
51
  const dirArg = Argument.Directory("dir").pipe(Argument.withDefault(".changeset"));
33
52
  /**
34
- * Run the check validation pipeline on all changeset files in `dir`.
53
+ * The changeset check as a {@link CheckSection}: its blocks and verdict,
54
+ * unprinted, for `savvy check` to fold into its one document.
35
55
  *
36
- * Groups lint messages by file and logs a human-readable summary. Sets
37
- * exit code 1 through `CliExit.set` when one or more errors are found.
56
+ * @remarks
57
+ * One titled section per file with errors (its findings as a
58
+ * position/rule/message table), each followed by one GitHub Actions error
59
+ * annotation per finding, then the summary line. The annotations sit at the
60
+ * top level of `blocks`, not inside the file sections: the kit draws an
61
+ * annotation only at the top level, as a top-level section's child, or as a
62
+ * direct child of a group's body, so this placement survives the caller
63
+ * wrapping `blocks` in a collapsible.
38
64
  *
39
65
  * @param dir - Path to the changeset directory (resolved relative to cwd)
40
- * @returns An Effect that performs validation and logs results
66
+ * @returns The section; `verdict` is `failure` when any file has errors
41
67
  *
42
68
  * @internal
43
69
  */
44
- function runChangesetCheck(dir) {
70
+ function changesetCheckSection(dir) {
45
71
  return Effect.gen(function* () {
46
72
  const resolved = resolve(dir);
47
73
  const messages = yield* Effect.try({
@@ -54,22 +80,57 @@ function runChangesetCheck(dir) {
54
80
  if (existing) existing.push(msg);
55
81
  else byFile.set(msg.file, [msg]);
56
82
  }
83
+ if (messages.length === 0) return {
84
+ title: SECTION_TITLE,
85
+ blocks: [Report.ok("All changeset files passed validation")],
86
+ verdict: "success",
87
+ fixedByInit: false
88
+ };
89
+ const cwd = resolve();
90
+ const blocks = [];
57
91
  for (const [file, fileMessages] of byFile) {
58
- yield* Output.line("");
59
- yield* Output.heading(file);
60
- for (const msg of fileMessages) yield* Output.detail(`${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
92
+ const display = displayPath(cwd, file);
93
+ blocks.push(Doc.section(Doc.file(display), [Doc.table(FINDING_COLUMNS, fileMessages.map((msg) => [
94
+ `${msg.line}:${msg.column}`,
95
+ msg.rule,
96
+ msg.message
97
+ ]))]), ...fileMessages.map((msg) => Doc.annotation({
98
+ level: "error",
99
+ file: display,
100
+ line: msg.line,
101
+ col: msg.column,
102
+ title: msg.rule
103
+ }, msg.message)));
61
104
  }
62
- const errorCount = messages.length;
63
- const filesWithErrors = byFile.size;
64
- if (errorCount > 0) {
65
- yield* Output.line("");
66
- yield* Output.fail(`${filesWithErrors} file(s) with errors, ${errorCount} error(s) found`);
67
- yield* CliExit.set(1);
68
- } else yield* Output.ok("All changeset files passed validation");
105
+ blocks.push(Report.fail(`${byFile.size} file(s) with errors, ${messages.length} error(s) found`));
106
+ return {
107
+ title: SECTION_TITLE,
108
+ blocks,
109
+ verdict: "failure",
110
+ fixedByInit: false
111
+ };
112
+ });
113
+ }
114
+ /**
115
+ * Run the check validation pipeline on all changeset files in `dir` and
116
+ * print {@link changesetCheckSection}'s blocks (no title heading: the
117
+ * standalone `savvy changeset check` output). Sets exit code 1 through
118
+ * `CliExit.set` when one or more errors are found.
119
+ *
120
+ * @param dir - Path to the changeset directory (resolved relative to cwd)
121
+ * @returns An Effect that performs validation and prints the report
122
+ *
123
+ * @internal
124
+ */
125
+ function runChangesetCheck(dir) {
126
+ return Effect.gen(function* () {
127
+ const section = yield* changesetCheckSection(dir);
128
+ yield* Report.print(section.blocks);
129
+ if (section.verdict === "failure") yield* CliExit.set(1);
69
130
  });
70
131
  }
71
132
  /* v8 ignore next 3 -- CLI registration; handler tested via runChangesetCheck */
72
133
  const checkCommand = Command.make("check", { dir: dirArg }, ({ dir }) => runChangesetCheck(dir)).pipe(Command.withDescription("Full changeset validation with summary"));
73
134
 
74
135
  //#endregion
75
- export { checkCommand, runChangesetCheck };
136
+ export { changesetCheckSection, checkCommand, runChangesetCheck };
@@ -1,6 +1,6 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
2
  import { Argument, Command } from "effect/cli";
3
- import { resolve } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import { CliExit } from "@effected/cli";
5
5
  import { Changesets } from "@savvy-web/silk-effects";
6
6
  import { Effect } from "effect";
@@ -30,8 +30,11 @@ const { ConfigInspector } = Changesets;
30
30
  /* v8 ignore next */
31
31
  const dirArg = Argument.Directory("dir").pipe(Argument.withDefault("."));
32
32
  /**
33
- * Run validation. Logs a one-line OK on success; logs the error and sets
34
- * exit code 1 through `CliExit.set` on failure.
33
+ * Run validation and print the outcome as one stdout line: `✓ <config> —
34
+ * N packages declared` on success, `✗ <config> — field: reason` plus exit
35
+ * code 1 through `CliExit.set` on a finding. Both outcomes stay on stdout as
36
+ * `Report` lines (not `CliMessage`, whose failure line goes to stderr), so a
37
+ * gate reading the result reads one stream.
35
38
  *
36
39
  * @internal
37
40
  */
@@ -51,10 +54,10 @@ function runConfigValidate(dir) {
51
54
  const { config } = result;
52
55
  const pkgCount = config.packages.length;
53
56
  const note = config.legacyVersionFilesUsed ? " (warning: legacy versionFiles in use)" : "";
54
- yield* Output.ok(`${config.configPath} — ${pkgCount} package${pkgCount === 1 ? "" : "s"} declared${note}`);
57
+ yield* Report.print([Report.ok(`${config.configPath} — ${pkgCount} package${pkgCount === 1 ? "" : "s"} declared${note}`)]);
55
58
  return;
56
59
  }
57
- yield* Output.fail(`${result.field}: ${result.reason}`);
60
+ yield* Report.print([Report.fail(`${join(resolved, ".changeset", "config.json")} — ${result.field}: ${result.reason}`)]);
58
61
  yield* CliExit.set(1);
59
62
  });
60
63
  }
@@ -1,4 +1,4 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
2
  import { Command, Flag } from "effect/cli";
3
3
  import { resolve } from "node:path";
4
4
  import { CliExit } from "@effected/cli";
@@ -69,7 +69,7 @@ function runDepsDetect(cwd, from, to, pkg, json, markdown) {
69
69
  ...Option.isSome(to) ? { to: to.value } : {}
70
70
  }).pipe(Effect.tapError(() => CliExit.set(1)))).toWrite.map((entry) => entry.diff);
71
71
  if (markdown && !json) {
72
- yield* Output.line(renderMarkdownBlocks(diffs));
72
+ yield* Report.print([Report.verbatim(renderMarkdownBlocks(diffs))]);
73
73
  return;
74
74
  }
75
75
  yield* Console.log(JSON.stringify(diffs, null, 2));
@@ -1,7 +1,7 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
2
  import { Command, Flag } from "effect/cli";
3
3
  import { resolve } from "node:path";
4
- import { CliExit } from "@effected/cli";
4
+ import { CliExit, Doc } from "@effected/cli";
5
5
  import { Changesets } from "@savvy-web/silk-effects";
6
6
  import { Console, Effect, Option } from "effect";
7
7
 
@@ -41,9 +41,12 @@ import { Console, Effect, Option } from "effect";
41
41
  *
42
42
  * **Dry-run reporting.** With `--dry-run` nothing is written or deleted, so
43
43
  * the human output is phrased as a plan (`Would delete N pure dependency
44
- * changeset(s):` / `Would write N dependency changeset(s):`) rather than the
45
- * real run's `✓ Deleted …` / `✓ Wrote …`. A real run reports what
46
- * `execute()` returned, not what the plan listed: deletes are tolerant, so a
44
+ * changeset(s):` / `Would write N dependency changeset(s):`, each over a
45
+ * file / package (/ rows) table) rather than the
46
+ * real run's `✓ Wrote …` / `✓ Deleted …`. A real run prints each write and
47
+ * delete as `execute()` reports it landing (its `onStep`), so a run that
48
+ * fails partway still shows what already reached disk; it reports what
49
+ * `execute()` did, not what the plan listed: deletes are tolerant, so a
47
50
  * planned delete that found nothing or could not remove the file is reported
48
51
  * as not removed. `--json`
49
52
  * emits the plan's fields plus an explicit `dryRun` boolean in both modes,
@@ -79,57 +82,100 @@ function runDepsRegen(cwd, base, pkg, dryRun, json) {
79
82
  ...Option.isSome(base) ? { base: base.value } : {},
80
83
  ...Option.isSome(pkg) ? { package: pkg.value } : {}
81
84
  }).pipe(Effect.tapError(() => CliExit.set(1)));
82
- const result = dryRun ? void 0 : yield* service.execute(plan);
83
- if (json) yield* Console.log(JSON.stringify({
84
- ...plan,
85
- dryRun,
86
- ...result ? { result } : {}
87
- }, null, 2));
88
- else yield* renderHumanPlan(plan, result);
85
+ if (json) {
86
+ const result = dryRun ? void 0 : yield* service.execute(plan);
87
+ yield* Console.log(JSON.stringify({
88
+ ...plan,
89
+ dryRun,
90
+ ...result ? { result } : {}
91
+ }, null, 2));
92
+ } else if (dryRun) yield* renderDryRunPlan(plan);
93
+ else yield* runAndReport(plan, service);
89
94
  });
90
95
  }
96
+ /** The columns of a table of changesets to delete (or not removed). */
97
+ const DELETE_COLUMNS = [{ header: "file" }, { header: "package" }];
98
+ /** The columns of a table of changesets to write. */
99
+ const WRITE_COLUMNS = [
100
+ { header: "file" },
101
+ { header: "package" },
102
+ {
103
+ header: "rows",
104
+ align: "right"
105
+ }
106
+ ];
107
+ const row = (entry) => `${entry.file} (${entry.package})`;
108
+ const writeRow = (entry) => `${entry.file} (${entry.package} — ${entry.diff.rows.length} row${entry.diff.rows.length === 1 ? "" : "s"})`;
91
109
  /**
92
- * Render the plan for a person. A dry run (no `result`) changed nothing, so its
93
- * headings are plan-phrased and carry no `✓`. A real run reports what
94
- * `execute` actually did: deletes are tolerant, so a planned delete absent from
95
- * `result.deleted` was already gone or could not be removed, and is reported as
96
- * not removed, never as deleted.
110
+ * Apply the plan for a person, printing each write and delete as `execute`
111
+ * reports it landing, so a failure partway still leaves what reached disk on
112
+ * stdout. The closing summary covers what no step reported: a planned delete
113
+ * absent from `result.deleted` was already gone or could not be removed, and
114
+ * is reported as not removed, never as deleted. Only paths `execute` reports
115
+ * are ever named as written or deleted.
97
116
  */
98
- function renderHumanPlan(plan, result) {
117
+ function runAndReport(plan, service) {
99
118
  return Effect.gen(function* () {
100
- const dryRun = result === void 0;
101
- if (plan.toDelete.length === 0 && plan.toWrite.length === 0) yield* Output.ok("No dependency changes to regenerate");
102
- else {
103
- if (dryRun && plan.toDelete.length > 0) {
104
- yield* Output.heading(`Would delete ${plan.toDelete.length} pure dependency changeset(s):`);
105
- for (const entry of plan.toDelete) yield* Output.detail(`${entry.file} (${entry.package})`);
119
+ const writes = new Map(plan.toWrite.map((entry) => [entry.file, entry]));
120
+ const deletes = new Map(plan.toDelete.map((entry) => [entry.file, entry]));
121
+ const reported = /* @__PURE__ */ new Set();
122
+ const stepBlock = (step) => {
123
+ if (step._tag === "Written") {
124
+ const entry = writes.get(step.file);
125
+ return Report.ok(`Wrote ${entry ? writeRow(entry) : step.file}`);
106
126
  }
107
- if (!dryRun) {
108
- const deleted = new Set(result.deleted);
109
- const removed = plan.toDelete.filter((entry) => deleted.has(entry.file));
110
- const notRemoved = plan.toDelete.filter((entry) => !deleted.has(entry.file));
111
- if (removed.length > 0) {
112
- yield* Output.ok(`Deleted ${removed.length} pure dependency changeset(s):`);
113
- for (const entry of removed) yield* Output.detail(`${entry.file} (${entry.package})`);
114
- }
115
- if (notRemoved.length > 0) {
116
- yield* Output.skip(`${notRemoved.length} planned deletion(s) not removed (already gone or undeletable):`);
117
- for (const entry of notRemoved) yield* Output.detail(`${entry.file} (${entry.package})`);
118
- }
119
- }
120
- const writes = dryRun ? plan.toWrite : plan.toWrite.filter((entry) => result.written.includes(entry.file));
121
- if (writes.length > 0) {
122
- yield* dryRun ? Output.heading(`Would write ${writes.length} dependency changeset(s):`) : Output.ok(`Wrote ${writes.length} fresh dependency changeset(s):`);
123
- for (const entry of writes) yield* Output.detail(`+ ${entry.file} (${entry.package} — ${entry.diff.rows.length} row${entry.diff.rows.length === 1 ? "" : "s"})`);
124
- }
125
- }
126
- if (plan.skippedMixed.length > 0) {
127
- yield* Output.line("");
128
- yield* Output.skip(`Skipped ${plan.skippedMixed.length} mixed changeset(s) (have Dependencies but also other content):`);
129
- for (const file of plan.skippedMixed) yield* Output.detail(`~ ${file}`);
130
- }
127
+ const entry = deletes.get(step.file);
128
+ return Report.ok(`Deleted ${entry ? row(entry) : step.file}`);
129
+ };
130
+ const printStep = (step) => Effect.suspend(() => {
131
+ reported.add(`${step._tag}:${step.file}`);
132
+ return Report.print([stepBlock(step)]);
133
+ });
134
+ const result = yield* service.execute(plan, { onStep: printStep });
135
+ yield* renderRunSummary(plan, result, [...result.written.filter((file) => !reported.has(`Written:${file}`)).map((file) => stepBlock({
136
+ _tag: "Written",
137
+ file
138
+ })), ...result.deleted.filter((file) => !reported.has(`Deleted:${file}`)).map((file) => stepBlock({
139
+ _tag: "Deleted",
140
+ file
141
+ }))]);
131
142
  });
132
143
  }
144
+ /** The closing document of a real run: whatever no step printed, then the totals. */
145
+ function renderRunSummary(plan, result, unreported) {
146
+ const blocks = [...unreported];
147
+ if (plan.toDelete.length === 0 && plan.toWrite.length === 0) blocks.push(Report.ok("No dependency changes to regenerate"));
148
+ else {
149
+ const deleted = new Set(result.deleted);
150
+ const notRemoved = plan.toDelete.filter((entry) => !deleted.has(entry.file));
151
+ if (notRemoved.length > 0) blocks.push(Report.skip(`${notRemoved.length} planned deletion(s) not removed (already gone or undeletable):`), Doc.table(DELETE_COLUMNS, notRemoved.map((entry) => [entry.file, entry.package])));
152
+ if (result.written.length > 0 || result.deleted.length > 0) blocks.push(Report.ok(`Wrote ${result.written.length} fresh and deleted ${result.deleted.length} pure dependency changeset(s)`));
153
+ }
154
+ return Report.print([...blocks, ...mixedBlocks(plan)]);
155
+ }
156
+ /**
157
+ * Render a dry run for a person. Nothing changed, so its headings are
158
+ * plan-phrased and carry no `✓`.
159
+ */
160
+ function renderDryRunPlan(plan) {
161
+ const blocks = [];
162
+ if (plan.toDelete.length === 0 && plan.toWrite.length === 0) blocks.push(Report.ok("No dependency changes to regenerate"));
163
+ else {
164
+ if (plan.toDelete.length > 0) blocks.push(Doc.section(`Would delete ${plan.toDelete.length} pure dependency changeset(s):`, [Doc.table(DELETE_COLUMNS, plan.toDelete.map((entry) => [entry.file, entry.package]))]));
165
+ if (plan.toWrite.length > 0) blocks.push(Doc.section(`Would write ${plan.toWrite.length} dependency changeset(s):`, [Doc.table(WRITE_COLUMNS, plan.toWrite.map((entry) => [
166
+ entry.file,
167
+ entry.package,
168
+ String(entry.diff.rows.length)
169
+ ]))]));
170
+ }
171
+ return Report.print([...blocks, ...mixedBlocks(plan)]);
172
+ }
173
+ /** The skipped-mixed note both a dry and a real run close with. */
174
+ function mixedBlocks(plan) {
175
+ const blocks = [];
176
+ if (plan.skippedMixed.length > 0) blocks.push(Report.line(""), Report.skip(`Skipped ${plan.skippedMixed.length} mixed changeset(s) (have Dependencies but also other content):`, ...plan.skippedMixed.map((file) => `~ ${file}`)));
177
+ return blocks;
178
+ }
133
179
  /* v8 ignore next 12 */
134
180
  const depsRegenCommand = Command.make("regen", {
135
181
  cwd: cwdOption,
@@ -1,8 +1,10 @@
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 { join } from "node:path";
3
- import { CliExit } from "@effected/cli";
4
+ import { CliDoc, CliExit, Doc } from "@effected/cli";
4
5
  import { Changesets } from "@savvy-web/silk-effects";
5
6
  import { Data, Effect, Option, Result, Schema } from "effect";
7
+ import { TerminalEnv } from "@effected/env";
6
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
9
  import { Git } from "@effected/git";
8
10
  import { Jsonc, JsoncEdit, JsoncModifier } from "@effected/jsonc";
@@ -555,17 +557,18 @@ function checkChangesetMarkdownlint(changesetDir) {
555
557
  }
556
558
  }
557
559
  /**
558
- * Run the full init pipeline.
560
+ * Run the full init pipeline, failing when a step fails.
559
561
  *
560
- * Exported so Task B5's unified `savvy init` orchestrator can invoke the
561
- * changeset init step directly without going through the CLI command layer.
562
+ * Steps 3–5 each run even when an earlier one fails; the run then fails with
563
+ * one {@link CommandError} listing every failed step. `quiet` swallows that
564
+ * failure (and any other) instead.
562
565
  *
563
566
  * @param opts - The same options the CLI command receives
564
567
  * @returns An Effect that performs initialization
565
568
  *
566
569
  * @internal
567
570
  */
568
- function runChangesetInit(opts) {
571
+ function runChangesetInitOrFail(opts) {
569
572
  const { force, quiet, skipMarkdownlint, check } = opts;
570
573
  return Effect.gen(function* () {
571
574
  const root = yield* resolveWorkspaceRoot(process.cwd());
@@ -581,42 +584,72 @@ function runChangesetInit(opts) {
581
584
  ...checkChangesetMarkdownlint(changesetDir)
582
585
  ];
583
586
  if (issues.length === 0) {
584
- yield* Output.ok("All @savvy-web/changesets config files are up to date");
587
+ yield* Report.print([Report.ok("All @savvy-web/changesets config files are up to date")]);
585
588
  return;
586
589
  }
587
- for (const issue of issues) yield* Output.warn(`${issue.file}: ${issue.message}`);
588
- yield* Output.detail("Run \"savvy init --force\" to fix");
590
+ yield* Report.print([...issues.map((issue) => Report.warn(`${issue.file}: ${issue.message}`)), Report.detail("Run \"savvy init --force\" to fix")]);
589
591
  return;
590
592
  }
591
593
  const changesetDir = yield* ensureChangesetDir(root);
592
- yield* Output.ok("Ensured .changeset/ directory");
594
+ yield* Report.print([Report.ok("Ensured .changeset/ directory")]);
593
595
  const errors = [];
594
596
  const configResult = yield* handleConfig(changesetDir, repoSlug, force).pipe(Effect.result);
595
597
  if (Result.isSuccess(configResult)) {
596
- yield* Output.ok(configResult.success);
598
+ yield* Report.print([Report.ok(configResult.success)]);
597
599
  if (!quiet) yield* warnIfLegacyVersionFiles(changesetDir);
598
600
  } else errors.push(configResult.failure);
599
601
  if (!skipMarkdownlint) {
600
602
  const baseResult = yield* handleBaseMarkdownlint(root).pipe(Effect.result);
601
- if (Result.isSuccess(baseResult)) yield* Output.ok(baseResult.success);
603
+ if (Result.isSuccess(baseResult)) yield* Report.print([Report.ok(baseResult.success)]);
602
604
  else errors.push(baseResult.failure);
603
605
  }
604
606
  const mdlintResult = yield* handleChangesetMarkdownlint(changesetDir, root, force).pipe(Effect.result);
605
- if (Result.isSuccess(mdlintResult)) yield* Output.ok(mdlintResult.success);
607
+ if (Result.isSuccess(mdlintResult)) yield* Report.print([Report.ok(mdlintResult.success)]);
606
608
  else errors.push(mdlintResult.failure);
607
609
  if (errors.length > 0) {
608
- for (const err of errors) yield* Effect.logError(err.message);
609
- if (!quiet) yield* CliExit.set(1);
610
- return;
611
- }
612
- yield* Output.ok("Init complete");
613
- }).pipe(Effect.catch((error) => Effect.gen(function* () {
614
- if (!quiet) {
615
- yield* Effect.logError(error instanceof InitError ? error.message : `Init failed: ${String(error)}`);
616
- yield* CliExit.set(1);
610
+ if (quiet) return;
611
+ return yield* Effect.fail(new CommandError({
612
+ message: `changeset init could not finish ${errors.length} step(s)`,
613
+ detail: errors.map((err) => err.message),
614
+ hint: "Fix the files named above, then re-run \"savvy init\"."
615
+ }));
617
616
  }
617
+ yield* Report.print([Report.ok("Init complete")]);
618
+ }).pipe(Effect.catch((error) => quiet ? Effect.void : Effect.fail(error instanceof CommandError ? error : CommandError.from(error, {
619
+ message: "changeset init failed",
620
+ hint: "Fix the problem above, then re-run \"savvy init\"."
621
+ }))));
622
+ }
623
+ /**
624
+ * Run the changeset init as one step of the orchestrated `savvy init`: a
625
+ * failure is reported, not propagated, so the commit and lint steps still
626
+ * run.
627
+ *
628
+ * @remarks
629
+ * Runs {@link runChangesetInitOrFail}. Its {@link CommandError} is drawn on
630
+ * stderr exactly as the runtime draws a failure (its `CliDoc` document,
631
+ * rendered for the audience; never wrapped off a terminal), exit code 1 is
632
+ * set through `CliExit.set`, and the step succeeds. A caller that wants the
633
+ * failure to end the run calls {@link runChangesetInitOrFail} instead.
634
+ *
635
+ * Exported so the unified `savvy init` orchestrator can invoke the changeset
636
+ * init step directly without going through the CLI command layer.
637
+ *
638
+ * @param opts - The same options the CLI command receives
639
+ * @returns An Effect that never fails
640
+ *
641
+ * @internal
642
+ */
643
+ function runChangesetInit(opts) {
644
+ return runChangesetInitOrFail(opts).pipe(Effect.catchTag("CommandError", (error) => Effect.gen(function* () {
645
+ const terminal = yield* TerminalEnv;
646
+ yield* Doc.print(error[CliDoc](), {
647
+ stream: "stderr",
648
+ ...terminal.stderr.isTerminal ? {} : { width: Number.POSITIVE_INFINITY }
649
+ });
650
+ yield* CliExit.set(1);
618
651
  })));
619
652
  }
620
653
 
621
654
  //#endregion
622
- export { InitError, InitErrorBase, checkBaseMarkdownlint, checkChangesetDir, checkChangesetMarkdownlint, checkConfig, detectGitHubRepo, detectLegacyVersionFiles, ensureChangesetDir, findMarkdownlintConfig, handleBaseMarkdownlint, handleChangesetMarkdownlint, handleConfig, legacyVersionFilesWarning, resolveWorkspaceRoot, runChangesetInit, warnIfLegacyVersionFiles };
655
+ export { InitError, InitErrorBase, checkBaseMarkdownlint, checkChangesetDir, checkChangesetMarkdownlint, checkConfig, detectGitHubRepo, detectLegacyVersionFiles, ensureChangesetDir, findMarkdownlintConfig, handleBaseMarkdownlint, handleChangesetMarkdownlint, handleConfig, legacyVersionFilesWarning, resolveWorkspaceRoot, runChangesetInit, runChangesetInitOrFail, warnIfLegacyVersionFiles };