@savvy-web/cli 3.2.8 → 3.3.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 (36) hide show
  1. package/cli/index.js +8 -5
  2. package/commands/changeset/commands/check.js +11 -7
  3. package/commands/changeset/commands/config-validate.js +6 -4
  4. package/commands/changeset/commands/deps-detect.js +4 -4
  5. package/commands/changeset/commands/deps-regen.js +11 -10
  6. package/commands/changeset/commands/init.js +13 -11
  7. package/commands/changeset/commands/lint.js +4 -3
  8. package/commands/changeset/commands/transform.js +7 -5
  9. package/commands/changeset/commands/validate-file.js +8 -6
  10. package/commands/changeset/commands/version.js +5 -4
  11. package/commands/changeset/utils/config-gate.js +3 -3
  12. package/commands/check.js +3 -2
  13. package/commands/clean.js +7 -8
  14. package/commands/commit/check.js +33 -35
  15. package/commands/commit/constants.js +5 -0
  16. package/commands/commit/init.js +8 -6
  17. package/commands/commit/lint.js +2 -1
  18. package/commands/lint/check.js +43 -47
  19. package/commands/lint/init.js +20 -22
  20. package/commands/repos/commands/add.js +8 -9
  21. package/commands/repos/commands/deregister.js +7 -7
  22. package/commands/repos/commands/note.js +7 -8
  23. package/commands/repos/commands/pin.js +11 -13
  24. package/commands/repos/commands/remove.js +13 -15
  25. package/commands/repos/commands/rename.js +10 -12
  26. package/commands/repos/commands/restore.js +12 -14
  27. package/commands/repos/commands/status.js +16 -8
  28. package/commands/repos/commands/sync.js +12 -13
  29. package/index.d.ts +16 -15
  30. package/internal/failure-line.js +39 -0
  31. package/internal/output.js +52 -0
  32. package/internal/version-line.js +19 -0
  33. package/main.d.ts +18 -2
  34. package/main.js +26 -16
  35. package/package.json +3 -1
  36. package/version.js +13 -0
package/cli/index.js CHANGED
@@ -122,12 +122,15 @@ const LocalExecLive = Workspaces.localExecLayer();
122
122
  const ToolDiscoveryGroupLive = ToolDiscovery.layer.pipe(Layer.provide(LocalExecLive), Layer.provide(WorkspaceLive));
123
123
  /**
124
124
  * The merged runtime Layer stack satisfying every command's service
125
- * requirements. Provided to `rootCommand`'s assembled `Command.run` Effect by
126
- * `main()` in `../main.js`; this module never calls `NodeRuntime.runMain`
127
- * itself.
125
+ * requirements, still open on the platform services. Provided to
126
+ * `rootCommand`'s assembled `Command.run` Effect by `main()` in `../main.js`,
127
+ * which hands {@link CliPlatform} to `CliRuntime.main` so the platform is
128
+ * provided once, at the edge; this module never runs anything itself.
128
129
  */
129
- const AppLive = Layer.mergeAll(ToolDiscoveryGroupLive, InspectorAndAnalyzerLive, ReposGroupLive).pipe(Layer.provideMerge(BaseLive), Layer.provideMerge(NodeServices.layer));
130
+ const AppLive = Layer.mergeAll(ToolDiscoveryGroupLive, InspectorAndAnalyzerLive, ReposGroupLive).pipe(Layer.provideMerge(BaseLive));
131
+ /** The platform `main()` passes to `CliRuntime.main`: Node's filesystem, path, stdio, terminal and spawner. */
132
+ const CliPlatform = NodeServices.layer;
130
133
  /* v8 ignore stop */
131
134
 
132
135
  //#endregion
133
- export { AppLive, rootCommand };
136
+ export { AppLive, CliPlatform, rootCommand };
@@ -1,5 +1,7 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command } from "effect/unstable/cli";
2
3
  import { resolve } from "node:path";
4
+ import { CliExit } from "@effected/cli";
3
5
  import { Changesets } from "@savvy-web/silk-effects";
4
6
  import { Effect } from "effect";
5
7
 
@@ -15,7 +17,7 @@ import { Effect } from "effect";
15
17
  * The command resolves the directory argument, delegates to
16
18
  * {@link ChangesetLinter.validate}, groups the resulting
17
19
  * {@link LintMessage} objects by file path, and logs each file's errors
18
- * indented under the file name. Sets `process.exitCode = 1` when errors
20
+ * indented under the file name. Sets exit code 1 through `CliExit.set` when errors
19
21
  * are found.
20
22
  *
21
23
  * @example
@@ -32,7 +34,7 @@ const dirArg = Argument.Directory("dir").pipe(Argument.withDefault(".changeset")
32
34
  * Run the check validation pipeline on all changeset files in `dir`.
33
35
  *
34
36
  * Groups lint messages by file and logs a human-readable summary. Sets
35
- * `process.exitCode = 1` when one or more errors are found.
37
+ * exit code 1 through `CliExit.set` when one or more errors are found.
36
38
  *
37
39
  * @param dir - Path to the changeset directory (resolved relative to cwd)
38
40
  * @returns An Effect that performs validation and logs results
@@ -53,15 +55,17 @@ function runChangesetCheck(dir) {
53
55
  else byFile.set(msg.file, [msg]);
54
56
  }
55
57
  for (const [file, fileMessages] of byFile) {
56
- yield* Effect.log(`\n${file}`);
57
- for (const msg of fileMessages) yield* Effect.log(` ${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
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}`);
58
61
  }
59
62
  const errorCount = messages.length;
60
63
  const filesWithErrors = byFile.size;
61
64
  if (errorCount > 0) {
62
- yield* Effect.log(`\n${filesWithErrors} file(s) with errors, ${errorCount} error(s) found`);
63
- process.exitCode = 1;
64
- } else yield* Effect.log("All changeset files passed validation.");
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");
65
69
  });
66
70
  }
67
71
  /* v8 ignore next 3 -- CLI registration; handler tested via runChangesetCheck */
@@ -1,5 +1,7 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command } from "effect/unstable/cli";
2
3
  import { resolve } from "node:path";
4
+ import { CliExit } from "@effected/cli";
3
5
  import { Changesets } from "@savvy-web/silk-effects";
4
6
  import { Effect } from "effect";
5
7
 
@@ -29,7 +31,7 @@ const { ConfigInspector } = Changesets;
29
31
  const dirArg = Argument.Directory("dir").pipe(Argument.withDefault("."));
30
32
  /**
31
33
  * Run validation. Logs a one-line OK on success; logs the error and sets
32
- * `process.exitCode = 1` on failure.
34
+ * exit code 1 through `CliExit.set` on failure.
33
35
  *
34
36
  * @internal
35
37
  */
@@ -49,11 +51,11 @@ function runConfigValidate(dir) {
49
51
  const { config } = result;
50
52
  const pkgCount = config.packages.length;
51
53
  const note = config.legacyVersionFilesUsed ? " (warning: legacy versionFiles in use)" : "";
52
- yield* Effect.log(`OK ${config.configPath} — ${pkgCount} package${pkgCount === 1 ? "" : "s"} declared${note}`);
54
+ yield* Output.ok(`${config.configPath} — ${pkgCount} package${pkgCount === 1 ? "" : "s"} declared${note}`);
53
55
  return;
54
56
  }
55
- yield* Effect.log(`FAIL ${result.field}: ${result.reason}`);
56
- process.exitCode = 1;
57
+ yield* Output.fail(`${result.field}: ${result.reason}`);
58
+ yield* CliExit.set(1);
57
59
  });
58
60
  }
59
61
  /* v8 ignore next 5 -- CLI registration */
@@ -1,5 +1,7 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Command, Flag } from "effect/unstable/cli";
2
3
  import { resolve } from "node:path";
4
+ import { CliExit } from "@effected/cli";
3
5
  import { Changesets } from "@savvy-web/silk-effects";
4
6
  import { Console, Effect, Option } from "effect";
5
7
 
@@ -65,11 +67,9 @@ function runDepsDetect(cwd, from, to, pkg, json, markdown) {
65
67
  ...Option.isSome(pkg) ? { package: pkg.value } : {},
66
68
  ...Option.isSome(from) ? { from: from.value } : {},
67
69
  ...Option.isSome(to) ? { to: to.value } : {}
68
- }).pipe(Effect.tapError(() => Effect.sync(() => {
69
- process.exitCode = 1;
70
- })))).toWrite.map((entry) => entry.diff);
70
+ }).pipe(Effect.tapError(() => CliExit.set(1)))).toWrite.map((entry) => entry.diff);
71
71
  if (markdown && !json) {
72
- yield* Effect.log(renderMarkdownBlocks(diffs));
72
+ yield* Output.line(renderMarkdownBlocks(diffs));
73
73
  return;
74
74
  }
75
75
  yield* Console.log(JSON.stringify(diffs, null, 2));
@@ -1,5 +1,7 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Command, Flag } from "effect/unstable/cli";
2
3
  import { resolve } from "node:path";
4
+ import { CliExit } from "@effected/cli";
3
5
  import { Changesets } from "@savvy-web/silk-effects";
4
6
  import { Console, Effect, Option } from "effect";
5
7
 
@@ -66,9 +68,7 @@ function runDepsRegen(cwd, base, pkg, dryRun, json) {
66
68
  cwd,
67
69
  ...Option.isSome(base) ? { base: base.value } : {},
68
70
  ...Option.isSome(pkg) ? { package: pkg.value } : {}
69
- }).pipe(Effect.tapError(() => Effect.sync(() => {
70
- process.exitCode = 1;
71
- })));
71
+ }).pipe(Effect.tapError(() => CliExit.set(1)));
72
72
  if (!dryRun) yield* service.execute(plan);
73
73
  if (json) yield* Console.log(JSON.stringify(plan, null, 2));
74
74
  else yield* renderHumanPlan(plan);
@@ -76,20 +76,21 @@ function runDepsRegen(cwd, base, pkg, dryRun, json) {
76
76
  }
77
77
  function renderHumanPlan(plan) {
78
78
  return Effect.gen(function* () {
79
- if (plan.toDelete.length === 0 && plan.toWrite.length === 0) yield* Effect.log("No dependency changes to regenerate.");
79
+ if (plan.toDelete.length === 0 && plan.toWrite.length === 0) yield* Output.ok("No dependency changes to regenerate");
80
80
  else {
81
81
  if (plan.toDelete.length > 0) {
82
- yield* Effect.log(`Deleted ${plan.toDelete.length} pure dependency changeset(s):`);
83
- for (const entry of plan.toDelete) yield* Effect.log(` - ${entry.file} (${entry.package})`);
82
+ yield* Output.ok(`Deleted ${plan.toDelete.length} pure dependency changeset(s):`);
83
+ for (const entry of plan.toDelete) yield* Output.detail(`${entry.file} (${entry.package})`);
84
84
  }
85
85
  if (plan.toWrite.length > 0) {
86
- yield* Effect.log(`Wrote ${plan.toWrite.length} fresh dependency changeset(s):`);
87
- for (const entry of plan.toWrite) yield* Effect.log(` + ${entry.file} (${entry.package} — ${entry.diff.rows.length} row${entry.diff.rows.length === 1 ? "" : "s"})`);
86
+ yield* Output.ok(`Wrote ${plan.toWrite.length} fresh dependency changeset(s):`);
87
+ for (const entry of plan.toWrite) yield* Output.detail(`+ ${entry.file} (${entry.package} — ${entry.diff.rows.length} row${entry.diff.rows.length === 1 ? "" : "s"})`);
88
88
  }
89
89
  }
90
90
  if (plan.skippedMixed.length > 0) {
91
- yield* Effect.log(`\nSkipped ${plan.skippedMixed.length} mixed changeset(s) (have Dependencies but also other content):`);
92
- for (const file of plan.skippedMixed) yield* Effect.log(` ~ ${file}`);
91
+ yield* Output.line("");
92
+ yield* Output.skip(`Skipped ${plan.skippedMixed.length} mixed changeset(s) (have Dependencies but also other content):`);
93
+ for (const file of plan.skippedMixed) yield* Output.detail(`~ ${file}`);
93
94
  }
94
95
  });
95
96
  }
@@ -1,4 +1,6 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { join } from "node:path";
3
+ import { CliExit } from "@effected/cli";
2
4
  import { Changesets } from "@savvy-web/silk-effects";
3
5
  import { Data, Effect, Option, Result, Schema } from "effect";
4
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -568,7 +570,7 @@ function runChangesetInit(opts) {
568
570
  return Effect.gen(function* () {
569
571
  const root = yield* resolveWorkspaceRoot(process.cwd());
570
572
  const repo = yield* detectGitHubRepo(root);
571
- if (!repo && !quiet) yield* Effect.log("Warning: could not detect GitHub repo from git remote, using placeholder");
573
+ if (!repo && !quiet) yield* Effect.logWarning("could not detect GitHub repo from git remote, using placeholder");
572
574
  const repoSlug = repo ?? "owner/repo";
573
575
  if (check) {
574
576
  const changesetDir = join(root, ".changeset");
@@ -579,39 +581,39 @@ function runChangesetInit(opts) {
579
581
  ...checkChangesetMarkdownlint(changesetDir)
580
582
  ];
581
583
  if (issues.length === 0) {
582
- yield* Effect.log("All @savvy-web/changesets config files are up to date.");
584
+ yield* Output.ok("All @savvy-web/changesets config files are up to date");
583
585
  return;
584
586
  }
585
- for (const issue of issues) yield* Effect.logWarning(`${issue.file}: ${issue.message}`);
586
- yield* Effect.logWarning("Run \"savvy init --force\" to fix.");
587
+ for (const issue of issues) yield* Output.warn(`${issue.file}: ${issue.message}`);
588
+ yield* Output.detail("Run \"savvy init --force\" to fix");
587
589
  return;
588
590
  }
589
591
  const changesetDir = yield* ensureChangesetDir(root);
590
- yield* Effect.log("Ensured .changeset/ directory");
592
+ yield* Output.ok("Ensured .changeset/ directory");
591
593
  const errors = [];
592
594
  const configResult = yield* handleConfig(changesetDir, repoSlug, force).pipe(Effect.result);
593
595
  if (Result.isSuccess(configResult)) {
594
- yield* Effect.log(configResult.success);
596
+ yield* Output.ok(configResult.success);
595
597
  if (!quiet) yield* warnIfLegacyVersionFiles(changesetDir);
596
598
  } else errors.push(configResult.failure);
597
599
  if (!skipMarkdownlint) {
598
600
  const baseResult = yield* handleBaseMarkdownlint(root).pipe(Effect.result);
599
- if (Result.isSuccess(baseResult)) yield* Effect.log(baseResult.success);
601
+ if (Result.isSuccess(baseResult)) yield* Output.ok(baseResult.success);
600
602
  else errors.push(baseResult.failure);
601
603
  }
602
604
  const mdlintResult = yield* handleChangesetMarkdownlint(changesetDir, root, force).pipe(Effect.result);
603
- if (Result.isSuccess(mdlintResult)) yield* Effect.log(mdlintResult.success);
605
+ if (Result.isSuccess(mdlintResult)) yield* Output.ok(mdlintResult.success);
604
606
  else errors.push(mdlintResult.failure);
605
607
  if (errors.length > 0) {
606
608
  for (const err of errors) yield* Effect.logError(err.message);
607
- if (!quiet) process.exitCode = 1;
609
+ if (!quiet) yield* CliExit.set(1);
608
610
  return;
609
611
  }
610
- yield* Effect.log("Init complete.");
612
+ yield* Output.ok("Init complete");
611
613
  }).pipe(Effect.catch((error) => Effect.gen(function* () {
612
614
  if (!quiet) {
613
615
  yield* Effect.logError(error instanceof InitError ? error.message : `Init failed: ${String(error)}`);
614
- process.exitCode = 1;
616
+ yield* CliExit.set(1);
615
617
  }
616
618
  })));
617
619
  }
@@ -1,5 +1,6 @@
1
1
  import { Argument, Command, Flag } from "effect/unstable/cli";
2
2
  import { resolve } from "node:path";
3
+ import { CliExit } from "@effected/cli";
3
4
  import { Changesets } from "@savvy-web/silk-effects";
4
5
  import { Console, Effect } from "effect";
5
6
 
@@ -15,7 +16,7 @@ import { Console, Effect } from "effect";
15
16
  * The command resolves the directory argument, delegates to
16
17
  * {@link ChangesetLinter.validate}, and logs each {@link LintMessage} as a
17
18
  * single colon-delimited line. When no errors are found and `--quiet` is not
18
- * set, a success message is printed. Sets `process.exitCode = 1` when errors
19
+ * set, a success message is printed. Sets exit code 1 through `CliExit.set` when errors
19
20
  * are found.
20
21
  *
21
22
  * @example
@@ -35,7 +36,7 @@ const quietOption = Flag.Boolean("quiet").pipe(Flag.withAlias("q"), Flag.withDes
35
36
  * Run machine-readable lint validation on all changeset files in `dir`.
36
37
  *
37
38
  * Outputs one line per error in `file:line:col rule message` format. Sets
38
- * `process.exitCode = 1` when one or more errors are found.
39
+ * exit code 1 through `CliExit.set` when one or more errors are found.
39
40
  *
40
41
  * @param dir - Path to the changeset directory (resolved relative to cwd)
41
42
  * @param quiet - When `true`, suppress the "No lint errors found" message
@@ -49,7 +50,7 @@ function runLint(dir, quiet) {
49
50
  const messages = yield* Effect.try(() => ChangesetLinter.validate(resolved));
50
51
  for (const msg of messages) yield* Console.log(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
51
52
  if (!quiet && messages.length === 0) yield* Console.log("No lint errors found.");
52
- if (messages.length > 0) process.exitCode = 1;
53
+ if (messages.length > 0) yield* CliExit.set(1);
53
54
  });
54
55
  }
55
56
  /* v8 ignore next 3 -- CLI registration; handler tested via runLint */
@@ -1,6 +1,8 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { requireValidConfig } from "../utils/config-gate.js";
2
3
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
4
  import { dirname, resolve } from "node:path";
5
+ import { CliExit } from "@effected/cli";
4
6
  import { Changesets } from "@savvy-web/silk-effects";
5
7
  import { Effect } from "effect";
6
8
  import { readFileSync, writeFileSync } from "node:fs";
@@ -63,18 +65,18 @@ function runTransform(file, dryRun, check) {
63
65
  const content = yield* Effect.try(() => readFileSync(resolved, "utf-8"));
64
66
  const result = ChangelogTransformer.transformContent(content);
65
67
  if (dryRun) {
66
- yield* Effect.log(result);
68
+ yield* Output.line(result);
67
69
  return;
68
70
  }
69
71
  if (check) {
70
72
  if (result !== content) {
71
- yield* Effect.log(`${resolved} would be modified by transform.`);
72
- process.exitCode = 1;
73
- } else yield* Effect.log(`${resolved} is already formatted.`);
73
+ yield* Output.warn(`${resolved} would be modified by transform`);
74
+ yield* CliExit.set(1);
75
+ } else yield* Output.ok(`${resolved} is already formatted`);
74
76
  return;
75
77
  }
76
78
  yield* Effect.try(() => writeFileSync(resolved, result, "utf-8"));
77
- yield* Effect.log(`Transformed ${resolved}`);
79
+ yield* Output.ok(`Transformed ${resolved}`);
78
80
  });
79
81
  }
80
82
  /* v8 ignore next 5 -- CLI registration; handler tested via runTransform */
@@ -1,4 +1,6 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command } from "effect/unstable/cli";
3
+ import { CliExit } from "@effected/cli";
2
4
  import { Changesets } from "@savvy-web/silk-effects";
3
5
  import { Effect } from "effect";
4
6
 
@@ -24,7 +26,7 @@ const fileArg = Argument.File("file");
24
26
  * Run lint validation on a single changeset file.
25
27
  *
26
28
  * Outputs one line per error in `file:line:col rule message` format.
27
- * Logs "Valid." when the file passes. Sets `process.exitCode = 1`
29
+ * Logs "Valid." when the file passes. Sets exit code 1 through `CliExit.set`
28
30
  * when errors are found or the file cannot be read.
29
31
  *
30
32
  * @param filePath - Path to the changeset `.md` file
@@ -35,14 +37,14 @@ const fileArg = Argument.File("file");
35
37
  function runValidateFile(filePath) {
36
38
  return Effect.gen(function* () {
37
39
  const result = yield* Effect.try(() => ChangesetLinter.validateFile(filePath)).pipe(Effect.catch((error) => Effect.gen(function* () {
38
- yield* Effect.log(`Error: ${error instanceof Error ? error.message : String(error)}`);
39
- process.exitCode = 1;
40
+ yield* Effect.logError(`Error: ${error instanceof Error ? error.message : String(error)}`);
41
+ yield* CliExit.set(1);
40
42
  return null;
41
43
  })));
42
44
  if (result === null) return;
43
- for (const msg of result) yield* Effect.log(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
44
- if (result.length > 0) process.exitCode = 1;
45
- else yield* Effect.log("Valid.");
45
+ for (const msg of result) yield* Output.line(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
46
+ if (result.length > 0) yield* CliExit.set(1);
47
+ else yield* Output.ok("Valid");
46
48
  });
47
49
  }
48
50
  /* v8 ignore next 3 -- CLI registration; handler tested via runValidateFile */
@@ -1,3 +1,4 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { requireValidConfig } from "../utils/config-gate.js";
2
3
  import { Command, Flag } from "effect/unstable/cli";
3
4
  import { Changesets } from "@savvy-web/silk-effects";
@@ -29,13 +30,13 @@ function runVersion(dryRun) {
29
30
  yield* requireValidConfig(cwd);
30
31
  const result = yield* (yield* Changesets.ReleasePlanner).apply(cwd, { dryRun });
31
32
  if (result.releases.length === 0) {
32
- yield* Effect.log("No pending changesets.");
33
+ yield* Output.ok("No pending changesets");
33
34
  return;
34
35
  }
35
36
  const verb = dryRun ? "Would release" : "Released";
36
- for (const r of result.releases) yield* Effect.log(`${verb} ${r.name}: ${r.oldVersion} -> ${r.newVersion} (${r.type})`);
37
- if (!dryRun) yield* Effect.log(`Touched ${result.touchedFiles.length} file(s)`);
38
- for (const u of result.versionFileUpdates) yield* Effect.log(`${dryRun ? "Would update" : "Updated"} ${u.filePath} -> ${u.version}`);
37
+ for (const r of result.releases) yield* Output.ok(`${verb} ${r.name}: ${r.oldVersion} -> ${r.newVersion} (${r.type})`);
38
+ if (!dryRun) yield* Output.detail(`Touched ${result.touchedFiles.length} file(s)`);
39
+ for (const u of result.versionFileUpdates) yield* Output.detail(`${dryRun ? "Would update" : "Updated"} ${u.filePath} -> ${u.version}`);
39
40
  });
40
41
  }
41
42
  /* v8 ignore next 4 -- CLI registration; handler tested via runVersion */
@@ -1,4 +1,5 @@
1
1
  import { join, resolve } from "node:path";
2
+ import { CliExit } from "@effected/cli";
2
3
  import { Changesets } from "@savvy-web/silk-effects";
3
4
  import { Effect } from "effect";
4
5
  import { existsSync } from "node:fs";
@@ -26,7 +27,7 @@ import { existsSync } from "node:fs";
26
27
  * projects that have not yet been bootstrapped.
27
28
  * - **Config present**: the gate invokes
28
29
  * {@link ConfigInspector.inspect}. On `ConfigurationError`, it sets
29
- * `process.exitCode = 1` and propagates the error so the caller's
30
+ * exit code 1 through `CliExit.set` and propagates the error so the caller's
30
31
  * `Effect.gen` short-circuits.
31
32
  *
32
33
  * @internal
@@ -50,8 +51,7 @@ function requireValidConfig(cwd) {
50
51
  const configPath = join(projectDir, ".changeset", "config.json");
51
52
  if (!existsSync(configPath)) return;
52
53
  yield* (yield* ConfigInspector).inspect(projectDir).pipe(Effect.catchTag("ConfigurationError", (err) => {
53
- process.exitCode = 1;
54
- return Effect.fail(err);
54
+ return CliExit.set(1).pipe(Effect.andThen(Effect.fail(err)));
55
55
  }));
56
56
  });
57
57
  }
package/commands/check.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Output } from "../internal/output.js";
1
2
  import { runChangesetCheck } from "./changeset/commands/check.js";
2
3
  import "./changeset/index.js";
3
4
  import { runCommitCheck } from "./commit/check.js";
@@ -44,8 +45,8 @@ function runCheck(steps) {
44
45
  return Effect.gen(function* () {
45
46
  const results = yield* Effect.all([
46
47
  Effect.result(steps.changeset),
47
- Effect.result(steps.commit),
48
- Effect.result(steps.lint)
48
+ Output.line("").pipe(Effect.andThen(Effect.result(steps.commit))),
49
+ Output.line("").pipe(Effect.andThen(Effect.result(steps.lint)))
49
50
  ], { concurrency: 1 });
50
51
  for (const result of results) if (Result.isFailure(result)) return yield* Effect.fail(result.failure);
51
52
  });
package/commands/clean.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Output } from "../internal/output.js";
1
2
  import { Command, Flag } from "effect/unstable/cli";
2
3
  import { join, sep } from "node:path";
3
4
  import { Data, Effect } from "effect";
@@ -97,10 +98,6 @@ function removeTargets(targets, dryRun) {
97
98
  };
98
99
  });
99
100
  }
100
- /** Unicode symbols for output. */
101
- const CHECK_MARK = "✓";
102
- const BULLET = "•";
103
- const WARN_MARK = "⚠";
104
101
  /** Split the comma-separated `--globs` value; fall back to defaults when empty. */
105
102
  function parseGlobs(raw) {
106
103
  const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -147,14 +144,16 @@ function runClean(opts) {
147
144
  }))));
148
145
  for (const { g, report } of reports) {
149
146
  if (g.targets.length === 0) continue;
150
- yield* Effect.log(`\n${g.pkg.relativePath === "." ? "<root>" : g.pkg.relativePath}`);
151
- for (const t of report.removed) yield* Effect.log(` ${BULLET} ${verb} [${t.kind}] ${t.path}`);
152
- for (const f of report.failed) yield* Effect.log(` ${WARN_MARK} failed [${f.target.kind}] ${f.target.path}: ${f.reason}`);
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}`);
153
151
  total += report.removed.length;
154
152
  failures.push(...report.failed);
155
153
  }
156
154
  }
157
- yield* Effect.log(`\n${CHECK_MARK} ${opts.dryRun ? "Would remove" : "Removed"} ${total} item(s).`);
155
+ yield* Output.line("");
156
+ yield* Output.ok(`${opts.dryRun ? "Would remove" : "Removed"} ${total} item(s)`);
158
157
  if (failures.length > 0) {
159
158
  for (const f of failures) yield* Effect.logError(`Failed to remove ${f.target.path}: ${f.reason}`);
160
159
  return yield* Effect.fail(new CleanError({
@@ -1,3 +1,4 @@
1
+ import { Output } from "../../internal/output.js";
1
2
  import { HUSKY_HOOK_PATH, POST_CHECKOUT_HOOK_PATH, POST_COMMIT_HOOK_PATH, POST_MERGE_HOOK_PATH } from "./constants.js";
2
3
  import { SECTION_DEF, savvyCommitBlock } from "./init.js";
3
4
  import { ChangesetConfigReader, Commitlint, SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, savvyBasePreamble, savvyHooksHygiene, savvyInstallBlock, savvyToolchainCheck } from "@savvy-web/silk-effects";
@@ -6,10 +7,6 @@ import { VersioningStrategy } from "@effected/workspaces";
6
7
  import { CheckOutcome, ManagedSection } from "@effected/templates";
7
8
 
8
9
  //#region src/commands/commit/check.ts
9
- /** Unicode cross symbol. */
10
- const CROSS_MARK = "✗";
11
- /** Unicode bullet symbol. */
12
- const BULLET = "•";
13
10
  /** Possible commitlint configuration file names, in priority order. */
14
11
  const CONFIG_FILES = [
15
12
  "commitlint.config.ts",
@@ -98,41 +95,41 @@ function runCommitCheck() {
98
95
  return Effect.gen(function* () {
99
96
  const fs = yield* FileSystem.FileSystem;
100
97
  const ms = yield* ManagedSection;
101
- yield* Effect.log("Checking commitlint configuration...\n");
98
+ yield* Output.heading("commitlint configuration");
102
99
  const foundConfig = yield* findConfigFile(fs);
103
- if (foundConfig) yield* Effect.log(`${"✓"} Config file: ${foundConfig}`);
104
- else yield* Effect.log(`${CROSS_MARK} No commitlint config file found`);
100
+ if (foundConfig) yield* Output.ok(`Config file: ${foundConfig}`);
101
+ else yield* Output.fail(`No commitlint config file found`);
105
102
  const hasHuskyHook = yield* fs.exists(HUSKY_HOOK_PATH);
106
- if (hasHuskyHook) yield* Effect.log(`${"✓"} Husky hook: ${HUSKY_HOOK_PATH}`);
107
- else yield* Effect.log(`${CROSS_MARK} No husky commit-msg hook found`);
103
+ if (hasHuskyHook) yield* Output.ok(`Husky hook: ${HUSKY_HOOK_PATH}`);
104
+ else yield* Output.fail(`No husky commit-msg hook found`);
108
105
  let sectionsHealthy = true;
109
106
  if (hasHuskyHook) {
110
107
  const baseStatus = yield* ms.check(HUSKY_HOOK_PATH, SavvyBaseSection.section(savvyBasePreamble()));
111
- if (CheckOutcome.$is("UpToDate")(baseStatus)) yield* Effect.log(`${"✓"} Base section: up-to-date`);
108
+ if (CheckOutcome.$is("UpToDate")(baseStatus)) yield* Output.ok(`Base section: up-to-date`);
112
109
  else if (CheckOutcome.$is("Drifted")(baseStatus)) {
113
110
  sectionsHealthy = false;
114
- yield* Effect.log(`${"⚠"} Base section: outdated (run 'savvy init' to update)`);
111
+ yield* Output.warn(`Base section: outdated (run 'savvy init' to update)`);
115
112
  } else {
116
113
  sectionsHealthy = false;
117
- yield* Effect.log(`${BULLET} Base section: not found (run 'savvy init' to add)`);
114
+ yield* Output.skip(`Base section: not found (run 'savvy init' to add)`);
118
115
  }
119
116
  const block = yield* ms.read(HUSKY_HOOK_PATH, SECTION_DEF);
120
117
  if (Option.isSome(block)) {
121
118
  const configPath = extractConfigPathFromManaged(block.value.content);
122
119
  if (configPath) {
123
120
  const status = yield* ms.check(HUSKY_HOOK_PATH, savvyCommitBlock(configPath));
124
- if (CheckOutcome.$is("UpToDate")(status)) yield* Effect.log(`${"✓"} Commit section: up-to-date`);
121
+ if (CheckOutcome.$is("UpToDate")(status)) yield* Output.ok(`Commit section: up-to-date`);
125
122
  else {
126
123
  sectionsHealthy = false;
127
- yield* Effect.log(`${"⚠"} Commit section: outdated (run 'savvy init' to update)`);
124
+ yield* Output.warn(`Commit section: outdated (run 'savvy init' to update)`);
128
125
  }
129
126
  } else {
130
127
  sectionsHealthy = false;
131
- yield* Effect.log(`${"⚠"} Commit section: outdated (run 'savvy init' to update)`);
128
+ yield* Output.warn(`Commit section: outdated (run 'savvy init' to update)`);
132
129
  }
133
130
  } else {
134
131
  sectionsHealthy = false;
135
- yield* Effect.log(`${BULLET} Commit section: not found (run 'savvy init' to add)`);
132
+ yield* Output.skip(`Commit section: not found (run 'savvy init' to add)`);
136
133
  }
137
134
  }
138
135
  for (const hookPath of [
@@ -142,51 +139,52 @@ function runCommitCheck() {
142
139
  ]) {
143
140
  if (!(yield* fs.exists(hookPath))) {
144
141
  sectionsHealthy = false;
145
- yield* Effect.log(`${BULLET} Hygiene hook: ${hookPath} not found (run 'savvy init' to add)`);
142
+ yield* Output.skip(`Hygiene hook: ${hookPath} not found (run 'savvy init' to add)`);
146
143
  continue;
147
144
  }
148
145
  const hygieneStatus = yield* ms.check(hookPath, SavvyHooksSection.section(savvyHooksHygiene()));
149
- if (CheckOutcome.$is("UpToDate")(hygieneStatus)) yield* Effect.log(`${"✓"} Hygiene hook: ${hookPath}`);
146
+ if (CheckOutcome.$is("UpToDate")(hygieneStatus)) yield* Output.ok(`Hygiene hook: ${hookPath}`);
150
147
  else if (CheckOutcome.$is("Drifted")(hygieneStatus)) {
151
148
  sectionsHealthy = false;
152
- yield* Effect.log(`${"⚠"} Hygiene hook: ${hookPath} outdated (run 'savvy init' to update)`);
149
+ yield* Output.warn(`Hygiene hook: ${hookPath} outdated (run 'savvy init' to update)`);
153
150
  } else {
154
151
  sectionsHealthy = false;
155
- yield* Effect.log(`${BULLET} Hygiene hook: ${hookPath} section not found (run 'savvy init' to add)`);
152
+ yield* Output.skip(`Hygiene hook: ${hookPath} section not found (run 'savvy init' to add)`);
156
153
  }
157
154
  if (hookPath === ".husky/post-commit") continue;
158
155
  const installHook = hookPath === ".husky/post-checkout" ? "post-checkout" : "post-merge";
159
156
  const installStatus = yield* ms.check(hookPath, savvyInstallBlock(installHook));
160
- if (CheckOutcome.$is("UpToDate")(installStatus)) yield* Effect.log(`${"✓"} Dependency install: ${hookPath}`);
157
+ if (CheckOutcome.$is("UpToDate")(installStatus)) yield* Output.ok(`Dependency install: ${hookPath}`);
161
158
  else if (CheckOutcome.$is("Drifted")(installStatus)) {
162
159
  sectionsHealthy = false;
163
- yield* Effect.log(`${"⚠"} Dependency install: ${hookPath} outdated (run 'savvy init' to update)`);
160
+ yield* Output.warn(`Dependency install: ${hookPath} outdated (run 'savvy init' to update)`);
164
161
  } else {
165
162
  sectionsHealthy = false;
166
- yield* Effect.log(`${BULLET} Dependency install: ${hookPath} section not found (run 'savvy init' to add)`);
163
+ yield* Output.skip(`Dependency install: ${hookPath} section not found (run 'savvy init' to add)`);
167
164
  }
168
165
  const toolchainStatus = yield* ms.check(hookPath, SavvyToolchainSection.section(savvyToolchainCheck()));
169
- if (CheckOutcome.$is("UpToDate")(toolchainStatus)) yield* Effect.log(`${"✓"} Toolchain check: ${hookPath}`);
166
+ if (CheckOutcome.$is("UpToDate")(toolchainStatus)) yield* Output.ok(`Toolchain check: ${hookPath}`);
170
167
  else if (CheckOutcome.$is("Drifted")(toolchainStatus)) {
171
168
  sectionsHealthy = false;
172
- yield* Effect.log(`${"⚠"} Toolchain check: ${hookPath} outdated (run 'savvy init' to update)`);
169
+ yield* Output.warn(`Toolchain check: ${hookPath} outdated (run 'savvy init' to update)`);
173
170
  } else {
174
171
  sectionsHealthy = false;
175
- yield* Effect.log(`${BULLET} Toolchain check: ${hookPath} section not found (run 'savvy init' to add)`);
172
+ yield* Output.skip(`Toolchain check: ${hookPath} section not found (run 'savvy init' to add)`);
176
173
  }
177
174
  }
178
- if (yield* fs.exists(DCO_FILE_PATH)) yield* Effect.log(`${"✓"} DCO file: ${DCO_FILE_PATH}`);
179
- else yield* Effect.log(`${BULLET} No DCO file (signoff not required)`);
180
- yield* Effect.log("\nDetected settings:");
181
- yield* Effect.log(` DCO required: ${Commitlint.detectDCO()}`);
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()}`);
182
180
  const releaseFormat = yield* detectReleaseFormat;
183
- yield* Effect.log(` Release format: ${releaseFormat}`);
181
+ yield* Output.detail(`Release format: ${releaseFormat}`);
184
182
  const scopes = yield* Effect.catch(Commitlint.detectScopes, () => Effect.succeed([]));
185
183
  const scopeDisplay = scopes.length > 0 ? scopes.join(", ") : "(none - not a monorepo or no packages found)";
186
- yield* Effect.log(` Detected scopes: ${scopeDisplay}`);
187
- yield* Effect.log("");
188
- if (!foundConfig || !hasHuskyHook || !sectionsHealthy) yield* Effect.log(`${CROSS_MARK} Commitlint needs configuration. Run: savvy init`);
189
- else yield* Effect.log(`${"✓"} Commitlint is configured correctly.`);
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.`);
190
188
  });
191
189
  }
192
190
 
@@ -1,4 +1,9 @@
1
1
  //#region src/commands/commit/constants.ts
2
+ /**
3
+ * Shared constants for CLI commands.
4
+ *
5
+ * @internal
6
+ */
2
7
  /** Husky commit-msg hook path (savvy-base + savvy-commit sections). */
3
8
  const HUSKY_HOOK_PATH = ".husky/commit-msg";
4
9
  /** Husky post-checkout hook path (savvy-hooks hygiene). */