@savvy-web/cli 0.3.1 → 0.4.1

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 (39) hide show
  1. package/bin/savvy.d.ts +1 -0
  2. package/bin/savvy.js +17 -1
  3. package/cli/index.js +123 -0
  4. package/commands/changeset/commands/analyze-branch.js +108 -0
  5. package/commands/changeset/commands/check.js +71 -0
  6. package/commands/changeset/commands/classify.js +69 -0
  7. package/commands/changeset/commands/config-show.js +100 -0
  8. package/commands/changeset/commands/config-validate.js +63 -0
  9. package/commands/changeset/commands/deps-detect.js +103 -0
  10. package/commands/changeset/commands/deps-regen.js +277 -0
  11. package/commands/changeset/commands/init.js +634 -0
  12. package/commands/changeset/commands/lint.js +62 -0
  13. package/commands/changeset/commands/release-surface.js +96 -0
  14. package/commands/changeset/commands/transform.js +88 -0
  15. package/commands/changeset/commands/validate-file.js +52 -0
  16. package/commands/changeset/commands/version.js +178 -0
  17. package/commands/changeset/index.js +42 -0
  18. package/commands/changeset/utils/config-gate.js +59 -0
  19. package/commands/check.js +74 -0
  20. package/commands/clean.js +186 -0
  21. package/commands/commit/check.js +170 -0
  22. package/commands/commit/constants.js +10 -0
  23. package/commands/commit/hook.js +22 -0
  24. package/commands/commit/hooks/post-commit-verify.js +121 -0
  25. package/commands/commit/hooks/pre-commit-message.js +64 -0
  26. package/commands/commit/hooks/session-start.js +69 -0
  27. package/commands/commit/hooks/user-prompt-submit.js +42 -0
  28. package/commands/commit/index.js +20 -0
  29. package/commands/commit/init.js +127 -0
  30. package/commands/init.js +88 -0
  31. package/commands/lint/check.js +306 -0
  32. package/commands/lint/fmt.js +64 -0
  33. package/commands/lint/index.js +20 -0
  34. package/commands/lint/init.js +221 -0
  35. package/index.d.ts +237 -244
  36. package/index.js +14 -1
  37. package/package.json +39 -51
  38. package/841.js +0 -2394
  39. package/tsdoc-metadata.json +0 -11
package/bin/savvy.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { };
package/bin/savvy.js CHANGED
@@ -1,3 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from "../841.js";
2
+ import { runCli } from "../cli/index.js";
3
+
4
+ //#region src/bin/cli.ts
5
+ /**
6
+ * Binary entry point for the `savvy` CLI.
7
+ *
8
+ * This file is the `bin` target in `package.json`. It delegates immediately
9
+ * to {@link runCli} which assembles the `\@effect/cli` command tree and
10
+ * executes it via `\@effect/platform-node`.
11
+ *
12
+ * @internal
13
+ */
14
+ /* v8 ignore start -- CLI bootstrap; commands tested individually */
3
15
  runCli();
16
+ /* v8 ignore stop */
17
+
18
+ //#endregion
19
+ export { };
package/cli/index.js ADDED
@@ -0,0 +1,123 @@
1
+ import { changesetCommand } from "../commands/changeset/index.js";
2
+ import { checkCommand } from "../commands/check.js";
3
+ import { cleanCommand } from "../commands/clean.js";
4
+ import { commitCommand } from "../commands/commit/index.js";
5
+ import { initCommand } from "../commands/init.js";
6
+ import { lintCommand } from "../commands/lint/index.js";
7
+ import { Command } from "@effect/cli";
8
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
9
+ import { BiomeSchemaSyncLive, ChangesetConfigReaderLive, Changesets, ConfigDiscoveryLive, ManagedSectionLive, SilkPublishabilityDetectorLive, ToolDiscoveryLive, VersioningStrategyLive } from "@savvy-web/silk-effects";
10
+ import { Effect, Layer } from "effect";
11
+ import { PackageManagerDetectorLive, WorkspaceDiscoveryLive, WorkspaceRootLive } from "workspaces-effect";
12
+
13
+ //#region src/cli/index.ts
14
+ /**
15
+ * Root `savvy` CLI entry point using `@effect/cli`.
16
+ *
17
+ * @remarks
18
+ * Assembles the five Phase-B command pieces — the `init` and `check` top-level
19
+ * orchestrators plus the `changeset`, `commit`, and `lint` groups — under a
20
+ * single `savvy` root command, then provides the merged runtime Layer stack
21
+ * that satisfies every command's service requirements.
22
+ *
23
+ * The layer stack is the union of the three standalone CLIs' stacks
24
+ * (`@savvy-web/changesets`, `@savvy-web/commitlint`, `@savvy-web/lint-staged`),
25
+ * with each service's transitive dependencies wired:
26
+ *
27
+ * - `NodeContext.layer` — `FileSystem`, `Path`, and `CommandExecutor`, consumed
28
+ * by every config reader, workspace service, and tool-discovery layer.
29
+ * - Workspace services — `WorkspaceRootLive`, `PackageManagerDetectorLive`, and
30
+ * `WorkspaceDiscoveryLive` (provided `WorkspaceRootLive`), the minimal hand-wired
31
+ * trio shared by the three source CLIs.
32
+ * - Flat silk-effects services — `ChangesetConfigReaderLive`,
33
+ * `SilkPublishabilityDetectorLive`, `ManagedSectionLive`, `BiomeSchemaSyncLive`,
34
+ * `ConfigDiscoveryLive`, `ToolDiscoveryLive`, and `VersioningStrategyLive`
35
+ * (provided `ChangesetConfigReaderLive`).
36
+ * - Changesets-namespace services — `Changesets.ConfigInspectorLive` (provided
37
+ * `ChangesetConfigReaderLive`), `Changesets.WorkspaceSnapshotReaderLive`, and
38
+ * `Changesets.BranchAnalyzerLive`, which shares the single `ConfigInspectorLive`
39
+ * instance built once via `provideMerge`.
40
+ *
41
+ * The CLI version is injected at build time via `__PACKAGE_VERSION__`.
42
+ *
43
+ * @internal
44
+ */
45
+ /* v8 ignore start -- CLI registration; each command tested via exported handler */
46
+ /**
47
+ * Root `savvy` command nesting the two orchestrators and three command groups.
48
+ */
49
+ const rootCommand = Command.make("savvy").pipe(Command.withSubcommands([
50
+ initCommand,
51
+ checkCommand,
52
+ cleanCommand,
53
+ commitCommand,
54
+ changesetCommand,
55
+ lintCommand
56
+ ]));
57
+ const cli = Command.run(rootCommand, {
58
+ name: "savvy",
59
+ version: process.env.__PACKAGE_VERSION__ ?? "0.0.0"
60
+ });
61
+ /**
62
+ * Shared base layer: workspace services, the changeset config reader, and the
63
+ * leaf silk-effects services that depend only on the platform. Built once and
64
+ * `provideMerge`d so the upper services draw from it AND it stays exposed in the
65
+ * final context for the handlers that yield these tags directly.
66
+ *
67
+ * @remarks
68
+ * The workspace services (`WorkspaceRoot`, `WorkspaceDiscovery`,
69
+ * `PackageManagerDetector`) are wired as a self-contained unit:
70
+ * `WorkspaceDiscoveryLive` is provided `WorkspaceRootLive`, and the bare
71
+ * `WorkspaceRootLive` / `PackageManagerDetectorLive` are exposed for the
72
+ * handlers that yield those tags directly. This mirrors the three source CLIs'
73
+ * minimal workspace wiring rather than pulling in the heavier `WorkspacesLive`
74
+ * (which also forks `DependencyGraph` / `PublishabilityDetector` background work).
75
+ */
76
+ const WorkspaceLive = Layer.mergeAll(WorkspaceRootLive, PackageManagerDetectorLive, WorkspaceDiscoveryLive.pipe(Layer.provide(WorkspaceRootLive)));
77
+ /**
78
+ * Base layer membership: silk-effects leaf services (`ManagedSection`,
79
+ * `BiomeSchemaSync`, `ConfigDiscovery`, `SilkPublishabilityDetector`,
80
+ * `WorkspaceSnapshotReader`) that depend only on the platform, plus the
81
+ * changeset base layers (`WorkspaceLive`, `ChangesetConfigReader`) that
82
+ * `AppLive`'s upper services build upon.
83
+ */
84
+ const BaseLive = Layer.mergeAll(WorkspaceLive, ChangesetConfigReaderLive, ManagedSectionLive, BiomeSchemaSyncLive, ConfigDiscoveryLive, SilkPublishabilityDetectorLive, Changesets.WorkspaceSnapshotReaderLive);
85
+ /**
86
+ * Merged runtime Layer stack — the union of the three source CLIs' stacks with
87
+ * every inter-layer dependency satisfied.
88
+ *
89
+ * @remarks
90
+ * The upper services depend on members of `BaseLive`:
91
+ * `ToolDiscoveryLive` needs `WorkspaceRoot`, `PackageManagerDetector`, and
92
+ * `CommandExecutor`; `VersioningStrategyLive` needs `ChangesetConfigReader`;
93
+ * `Changesets.ConfigInspectorLive` needs `ChangesetConfigReader` and
94
+ * `WorkspaceDiscovery`; `Changesets.BranchAnalyzerLive` needs `ConfigInspector`.
95
+ *
96
+ * `ConfigInspectorLive` is built once via {@link Layer.provideMerge}: the merge
97
+ * feeds that single `ConfigInspector` instance into `BranchAnalyzerLive` AND
98
+ * re-exposes it for the `classify` / `config` handlers that yield it directly,
99
+ * so it is never constructed twice per run.
100
+ *
101
+ * `provideMerge(BaseLive)` feeds the remaining deps and re-exposes the base
102
+ * services for handlers that yield them directly. `provideMerge(NodeContext.layer)`
103
+ * supplies `FileSystem`, `Path`, and `CommandExecutor` to everything underneath.
104
+ */
105
+ const InspectorAndAnalyzerLive = Changesets.BranchAnalyzerLive.pipe(Layer.provideMerge(Changesets.ConfigInspectorLive));
106
+ const AppLive = Layer.mergeAll(ToolDiscoveryLive, VersioningStrategyLive, InspectorAndAnalyzerLive).pipe(Layer.provideMerge(BaseLive), Layer.provideMerge(NodeContext.layer));
107
+ /**
108
+ * Bootstrap and run the `savvy` CLI application.
109
+ *
110
+ * @remarks
111
+ * Builds an Effect from the parsed `process.argv`, provides the merged layer
112
+ * stack, and hands execution to `NodeRuntime.runMain`.
113
+ *
114
+ * @internal
115
+ */
116
+ function runCli() {
117
+ const main = Effect.suspend(() => cli(process.argv)).pipe(Effect.provide(AppLive));
118
+ NodeRuntime.runMain(main);
119
+ }
120
+ /* v8 ignore stop */
121
+
122
+ //#endregion
123
+ export { runCli };
@@ -0,0 +1,108 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Effect, Option } from "effect";
4
+ import { resolve } from "node:path";
5
+
6
+ //#region src/commands/changeset/commands/analyze-branch.ts
7
+ /**
8
+ * `analyze-branch` command -- diff the current branch and classify every
9
+ * changed file.
10
+ *
11
+ * @remarks
12
+ * Wraps {@link BranchAnalyzer.analyzeBranch}. Returns the merge-base SHA,
13
+ * the per-file classification, the deduped package list affected by the
14
+ * branch, and the list of paths that didn't map to any release surface
15
+ * (candidates for an AskUserQuestion in agent workflows).
16
+ *
17
+ * @example
18
+ * ```bash
19
+ * savvy changeset analyze-branch
20
+ * savvy changeset analyze-branch --base main --json
21
+ * savvy changeset analyze-branch --cwd ./project --base develop
22
+ * ```
23
+ *
24
+ * @internal
25
+ */
26
+ const { BranchAnalyzer } = Changesets;
27
+ /* v8 ignore start -- CLI option definitions */
28
+ const cwdOption = Options.directory("cwd").pipe(Options.withDescription("Project root (defaults to the current working directory)"), Options.withDefault("."));
29
+ const baseOption = Options.text("base").pipe(Options.withDescription("Override the base branch (defaults to config baseBranch or origin/HEAD)"), Options.optional);
30
+ const jsonOption = Options.boolean("json").pipe(Options.withDescription("Emit JSON instead of human-readable output"), Options.withDefault(false));
31
+ /* v8 ignore stop */
32
+ /**
33
+ * Render a {@link BranchAnalysis} as human-readable text.
34
+ *
35
+ * @internal
36
+ */
37
+ function renderHuman(analysis) {
38
+ const lines = [];
39
+ lines.push(`Base branch: ${analysis.baseBranch}`);
40
+ lines.push(`Merge base SHA: ${analysis.mergeBaseSha}`);
41
+ lines.push("");
42
+ if (analysis.packagesAffected.length > 0) {
43
+ lines.push(`Packages affected (${analysis.packagesAffected.length}):`);
44
+ for (const p of analysis.packagesAffected) lines.push(` ${p}`);
45
+ } else lines.push("Packages affected: (none)");
46
+ lines.push("");
47
+ if (analysis.files.length === 0) lines.push("Changes: (no files changed)");
48
+ else {
49
+ lines.push(`Changes (${analysis.files.length}):`);
50
+ const statusGlyph = {
51
+ added: "A",
52
+ modified: "M",
53
+ deleted: "D",
54
+ renamed: "R",
55
+ copied: "C",
56
+ typechange: "T",
57
+ unmerged: "U",
58
+ unknown: "?"
59
+ };
60
+ for (const f of analysis.files) {
61
+ const glyph = statusGlyph[f.status] ?? "?";
62
+ const owner = f.package ?? "<unmapped>";
63
+ const reason = f.reason === "workspace" ? "workspace" : f.reason !== null ? `${f.reason.kind}: ${f.reason.glob}` : "—";
64
+ lines.push(` ${glyph} ${f.path}\t${owner}\t${reason}`);
65
+ }
66
+ }
67
+ if (analysis.unmappedFiles.length > 0) {
68
+ lines.push("");
69
+ lines.push(`Unmapped (${analysis.unmappedFiles.length}):`);
70
+ for (const p of analysis.unmappedFiles) lines.push(` ${p}`);
71
+ }
72
+ return lines.join("\n");
73
+ }
74
+ /**
75
+ * Resolve cwd + base branch, invoke `BranchAnalyzer.analyzeBranch`, and
76
+ * render the result. Sets `process.exitCode = 1` on `ConfigurationError`
77
+ * or `GitError`.
78
+ *
79
+ * @internal
80
+ */
81
+ function runAnalyzeBranch(cwd, base, json) {
82
+ return Effect.gen(function* () {
83
+ const analyzer = yield* BranchAnalyzer;
84
+ const resolvedCwd = resolve(cwd);
85
+ const baseBranch = Option.getOrUndefined(base);
86
+ const analysis = yield* analyzer.analyzeBranch(resolvedCwd, baseBranch ? { baseBranch } : void 0).pipe(Effect.catchTags({
87
+ ConfigurationError: (err) => {
88
+ process.exitCode = 1;
89
+ return Effect.fail(err);
90
+ },
91
+ GitError: (err) => {
92
+ process.exitCode = 1;
93
+ return Effect.fail(err);
94
+ }
95
+ }));
96
+ const output = json ? JSON.stringify(analysis, null, 2) : renderHuman(analysis);
97
+ yield* Effect.log(output);
98
+ });
99
+ }
100
+ /* v8 ignore next 6 -- CLI registration */
101
+ const analyzeBranchCommand = Command.make("analyze-branch", {
102
+ cwd: cwdOption,
103
+ base: baseOption,
104
+ json: jsonOption
105
+ }, ({ cwd, base, json }) => runAnalyzeBranch(cwd, base, json)).pipe(Command.withDescription("Diff the current branch and classify every changed file by owning package"));
106
+
107
+ //#endregion
108
+ export { analyzeBranchCommand };
@@ -0,0 +1,71 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { resolve } from "node:path";
5
+
6
+ //#region src/commands/changeset/commands/check.ts
7
+ /**
8
+ * Check command -- full validation pipeline with human-readable output.
9
+ *
10
+ * Runs lint on all changeset files in a directory and reports a grouped
11
+ * summary with pass/fail counts. Unlike the `lint` command, output is
12
+ * formatted for human consumption rather than machine parsing.
13
+ *
14
+ * @remarks
15
+ * The command resolves the directory argument, delegates to
16
+ * {@link ChangesetLinter.validate}, groups the resulting
17
+ * {@link LintMessage} objects by file path, and logs each file's errors
18
+ * indented under the file name. Sets `process.exitCode = 1` when errors
19
+ * are found.
20
+ *
21
+ * @example
22
+ * ```bash
23
+ * savvy changeset check .changeset
24
+ * ```
25
+ *
26
+ * @internal
27
+ */
28
+ const { ChangesetLinter } = Changesets;
29
+ /* v8 ignore next */
30
+ const dirArg = Args.directory({ name: "dir" }).pipe(Args.withDefault(".changeset"));
31
+ /**
32
+ * Run the check validation pipeline on all changeset files in `dir`.
33
+ *
34
+ * Groups lint messages by file and logs a human-readable summary. Sets
35
+ * `process.exitCode = 1` when one or more errors are found.
36
+ *
37
+ * @param dir - Path to the changeset directory (resolved relative to cwd)
38
+ * @returns An Effect that performs validation and logs results
39
+ *
40
+ * @internal
41
+ */
42
+ function runChangesetCheck(dir) {
43
+ return Effect.gen(function* () {
44
+ const resolved = resolve(dir);
45
+ const messages = yield* Effect.try({
46
+ try: () => ChangesetLinter.validate(resolved),
47
+ catch: (e) => new Error(String(e))
48
+ });
49
+ const byFile = /* @__PURE__ */ new Map();
50
+ for (const msg of messages) {
51
+ const existing = byFile.get(msg.file);
52
+ if (existing) existing.push(msg);
53
+ else byFile.set(msg.file, [msg]);
54
+ }
55
+ 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
+ }
59
+ const errorCount = messages.length;
60
+ const filesWithErrors = byFile.size;
61
+ 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
+ });
66
+ }
67
+ /* v8 ignore next 3 -- CLI registration; handler tested via runChangesetCheck */
68
+ const checkCommand = Command.make("check", { dir: dirArg }, ({ dir }) => runChangesetCheck(dir)).pipe(Command.withDescription("Full changeset validation with summary"));
69
+
70
+ //#endregion
71
+ export { runChangesetCheck };
@@ -0,0 +1,69 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { resolve } from "node:path";
5
+
6
+ //#region src/commands/changeset/commands/classify.ts
7
+ /**
8
+ * `classify` command -- map one or more paths to their owning package.
9
+ *
10
+ * @remarks
11
+ * Wraps {@link ConfigInspector.classify}. Each path resolves to a package
12
+ * via (in order): workspace match → `additionalScopes` glob → `versionFiles`
13
+ * glob → `null` (unmapped).
14
+ *
15
+ * @example
16
+ * ```bash
17
+ * savvy changeset classify packages/foo/src/index.ts plugin/SKILL.md
18
+ * savvy changeset classify --cwd ./monorepo plugin/x.md --json
19
+ * ```
20
+ *
21
+ * @internal
22
+ */
23
+ const { ConfigInspector } = Changesets;
24
+ /* v8 ignore start -- CLI option definitions */
25
+ const pathsArg = Args.text({ name: "path" }).pipe(Args.repeated);
26
+ const cwdOption = Options.directory("cwd").pipe(Options.withDescription("Project root (defaults to the current working directory)"), Options.withDefault("."));
27
+ const jsonOption = Options.boolean("json").pipe(Options.withDescription("Emit JSON instead of human-readable output"), Options.withDefault(false));
28
+ /* v8 ignore stop */
29
+ /**
30
+ * Format a single {@link Classification} as a human-readable line.
31
+ *
32
+ * @internal
33
+ */
34
+ function renderClassificationLine(c) {
35
+ if (c.package === null) return `${c.path}\t<unmapped>`;
36
+ if (c.reason === "workspace") return `${c.path}\t${c.package}\tworkspace`;
37
+ if (c.reason !== null) return `${c.path}\t${c.package}\t${c.reason.kind}: ${c.reason.glob}`;
38
+ return `${c.path}\t${c.package}`;
39
+ }
40
+ /**
41
+ * Resolve the cwd, invoke `ConfigInspector.classify`, and render the result.
42
+ * Sets `process.exitCode = 1` on `ConfigurationError`.
43
+ *
44
+ * @internal
45
+ */
46
+ function runClassify(cwd, paths, json) {
47
+ return Effect.gen(function* () {
48
+ const inspector = yield* ConfigInspector;
49
+ const resolvedCwd = resolve(cwd);
50
+ const classifications = yield* inspector.classify(resolvedCwd, paths).pipe(Effect.catchTag("ConfigurationError", (err) => {
51
+ process.exitCode = 1;
52
+ return Effect.fail(err);
53
+ }));
54
+ if (json) {
55
+ yield* Effect.log(JSON.stringify(classifications, null, 2));
56
+ return;
57
+ }
58
+ for (const c of classifications) yield* Effect.log(renderClassificationLine(c));
59
+ });
60
+ }
61
+ /* v8 ignore next 6 -- CLI registration */
62
+ const classifyCommand = Command.make("classify", {
63
+ paths: pathsArg,
64
+ cwd: cwdOption,
65
+ json: jsonOption
66
+ }, ({ paths, cwd, json }) => runClassify(cwd, paths, json)).pipe(Command.withDescription("Map paths to their owning package per .changeset/config.json"));
67
+
68
+ //#endregion
69
+ export { classifyCommand };
@@ -0,0 +1,100 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { resolve } from "node:path";
5
+
6
+ //#region src/commands/changeset/commands/config-show.ts
7
+ /**
8
+ * `config show` command -- emit the resolved `.changeset/config.json`.
9
+ *
10
+ * @remarks
11
+ * Wraps {@link ConfigInspector.inspect} and renders either human-readable
12
+ * output (default) or JSON (`--json` / `--format=json`). The same data shape
13
+ * the agent consumes is what the user sees, so debugging an unexpected
14
+ * classification result becomes a single `savvy changeset config show --json`
15
+ * invocation.
16
+ *
17
+ * @example
18
+ * ```bash
19
+ * savvy changeset config show
20
+ * savvy changeset config show --json
21
+ * savvy changeset config show ./path/to/project --json
22
+ * ```
23
+ *
24
+ * @internal
25
+ */
26
+ const { ConfigInspector } = Changesets;
27
+ /* v8 ignore start -- CLI option definitions */
28
+ const dirArg = Args.directory({ name: "dir" }).pipe(Args.withDefault("."));
29
+ const jsonOption = Options.boolean("json").pipe(Options.withDescription("Emit JSON instead of human-readable output"), Options.withDefault(false));
30
+ /* v8 ignore stop */
31
+ /**
32
+ * Render an {@link InspectedConfig} as human-readable text.
33
+ *
34
+ * @internal
35
+ */
36
+ function renderHuman(config) {
37
+ const lines = [];
38
+ lines.push(`Config: ${config.configPath}`);
39
+ lines.push(`Project: ${config.projectDir}`);
40
+ lines.push(`Changelog: ${config.changelog ?? "(none)"}`);
41
+ lines.push(`Base: ${config.baseBranch}`);
42
+ lines.push(`Access: ${config.access}`);
43
+ if (config.ignore.length > 0) lines.push(`Ignore: ${config.ignore.join(", ")}`);
44
+ if (config.legacyVersionFilesUsed) {
45
+ lines.push("");
46
+ lines.push("⚠ This config still uses the deprecated top-level `versionFiles[]`.");
47
+ lines.push(" Migrate to `packages[<name>].versionFiles` — required for 1.0.0.");
48
+ }
49
+ lines.push("");
50
+ if (config.packages.length === 0) {
51
+ lines.push("Packages: (none declared)");
52
+ return lines.join("\n");
53
+ }
54
+ lines.push(`Packages (${config.packages.length}):`);
55
+ for (const pkg of config.packages) {
56
+ lines.push("");
57
+ lines.push(` ${pkg.name} v${pkg.version}`);
58
+ lines.push(` workspace: ${pkg.workspaceDir}`);
59
+ if (pkg.additionalScopes.length > 0) {
60
+ lines.push(` additionalScopes (${pkg.additionalScopes.length}):`);
61
+ for (const g of pkg.additionalScopes) lines.push(` - ${g}`);
62
+ lines.push(` additionalScopeFiles (${pkg.additionalScopeFiles.length}):`);
63
+ for (const f of pkg.additionalScopeFiles) lines.push(` ${f}`);
64
+ }
65
+ if (pkg.versionFiles.length > 0) {
66
+ lines.push(` versionFiles (${pkg.versionFiles.length}):`);
67
+ for (const vf of pkg.versionFiles) {
68
+ lines.push(` ${vf.glob} → ${vf.paths.join(", ")} (${vf.matchedFiles.length} file${vf.matchedFiles.length === 1 ? "" : "s"} matched)`);
69
+ for (const f of vf.matchedFiles) lines.push(` ${f}`);
70
+ }
71
+ }
72
+ }
73
+ return lines.join("\n");
74
+ }
75
+ /**
76
+ * Resolve the project dir, invoke `ConfigInspector.inspect`, and render the
77
+ * result. Sets `process.exitCode = 1` on `ConfigurationError`.
78
+ *
79
+ * @internal
80
+ */
81
+ function runConfigShow(dir, json) {
82
+ return Effect.gen(function* () {
83
+ const inspector = yield* ConfigInspector;
84
+ const resolved = resolve(dir);
85
+ const config = yield* inspector.inspect(resolved).pipe(Effect.catchTag("ConfigurationError", (err) => {
86
+ process.exitCode = 1;
87
+ return Effect.fail(err);
88
+ }));
89
+ const output = json ? JSON.stringify(config, null, 2) : renderHuman(config);
90
+ yield* Effect.log(output);
91
+ });
92
+ }
93
+ /* v8 ignore next 6 -- CLI registration */
94
+ const configShowCommand = Command.make("show", {
95
+ dir: dirArg,
96
+ json: jsonOption
97
+ }, ({ dir, json }) => runConfigShow(dir, json)).pipe(Command.withDescription("Print the resolved .changeset/config.json"));
98
+
99
+ //#endregion
100
+ export { configShowCommand };
@@ -0,0 +1,63 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { resolve } from "node:path";
5
+
6
+ //#region src/commands/changeset/commands/config-validate.ts
7
+ /**
8
+ * `config validate` command -- validate-only mode for `.changeset/config.json`.
9
+ *
10
+ * @remarks
11
+ * Invokes {@link ConfigInspector.inspect} solely for its side effect of
12
+ * surfacing {@link ConfigurationError}. On success prints a short OK
13
+ * summary and exits 0. On error prints the structured failure (field +
14
+ * reason) and exits non-zero.
15
+ *
16
+ * Used by CI gates and by the `version` / `transform` commands' refusal
17
+ * posture in Phase 5.
18
+ *
19
+ * @example
20
+ * ```bash
21
+ * savvy changeset config validate
22
+ * savvy changeset config validate ./path/to/project
23
+ * ```
24
+ *
25
+ * @internal
26
+ */
27
+ const { ConfigInspector } = Changesets;
28
+ /* v8 ignore next */
29
+ const dirArg = Args.directory({ name: "dir" }).pipe(Args.withDefault("."));
30
+ /**
31
+ * Run validation. Logs a one-line OK on success; logs the error and sets
32
+ * `process.exitCode = 1` on failure.
33
+ *
34
+ * @internal
35
+ */
36
+ function runConfigValidate(dir) {
37
+ return Effect.gen(function* () {
38
+ const inspector = yield* ConfigInspector;
39
+ const resolved = resolve(dir);
40
+ const result = yield* inspector.inspect(resolved).pipe(Effect.map((config) => ({
41
+ ok: true,
42
+ config
43
+ })), Effect.catchTag("ConfigurationError", (err) => Effect.succeed({
44
+ ok: false,
45
+ field: err.field,
46
+ reason: err.reason
47
+ })));
48
+ if (result.ok) {
49
+ const { config } = result;
50
+ const pkgCount = config.packages.length;
51
+ const note = config.legacyVersionFilesUsed ? " (warning: legacy versionFiles in use)" : "";
52
+ yield* Effect.log(`OK ${config.configPath} — ${pkgCount} package${pkgCount === 1 ? "" : "s"} declared${note}`);
53
+ return;
54
+ }
55
+ yield* Effect.log(`FAIL ${result.field}: ${result.reason}`);
56
+ process.exitCode = 1;
57
+ });
58
+ }
59
+ /* v8 ignore next 5 -- CLI registration */
60
+ const configValidateCommand = Command.make("validate", { dir: dirArg }, ({ dir }) => runConfigValidate(dir)).pipe(Command.withDescription("Validate .changeset/config.json without rendering it"));
61
+
62
+ //#endregion
63
+ export { configValidateCommand };
@@ -0,0 +1,103 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Effect, Option } from "effect";
4
+ import { WorkspaceDiscovery } from "workspaces-effect";
5
+ import { resolve } from "node:path";
6
+
7
+ //#region src/commands/changeset/commands/deps-detect.ts
8
+ /**
9
+ * `deps detect` command — read-only dependency-diff inspection.
10
+ *
11
+ * @remarks
12
+ * Computes the per-workspace-package dependency changes between two git
13
+ * refs and renders them either as structured JSON (one row per change)
14
+ * or as ready-to-paste CSH005 markdown. No file writes.
15
+ *
16
+ * Defaults:
17
+ * - `--from` → `git merge-base <baseBranch> HEAD`
18
+ * - `--to` → working tree (i.e., `HEAD` plus staged + unstaged + untracked,
19
+ * matching `analyze-branch`'s coverage). Passed as the special value
20
+ * `WORKTREE` to {@link WorkspaceSnapshotReader} — implementations resolve
21
+ * this against the live working tree rather than `git show`.
22
+ *
23
+ * @example
24
+ * ```bash
25
+ * savvy changeset deps detect
26
+ * savvy changeset deps detect --from HEAD~5 --to HEAD --json
27
+ * savvy changeset deps detect --package @scope/foo --markdown
28
+ * ```
29
+ *
30
+ * @internal
31
+ */
32
+ const { ConfigInspector, WorkspaceSnapshotReader, computeWorkspaceDependencyDiffs, gitMergeBase, listPublishablePackageNames, serializeDependencyTableToMarkdown, snapshotFromWorktree } = Changesets;
33
+ /* v8 ignore start -- CLI option definitions */
34
+ const fromOption = Options.text("from").pipe(Options.withDescription("Older ref to diff from (defaults to merge-base with base branch)"), Options.optional);
35
+ const toOption = Options.text("to").pipe(Options.withDescription("Newer ref to diff to (defaults to working tree)"), Options.optional);
36
+ const cwdOption = Options.directory("cwd").pipe(Options.withDescription("Project root (defaults to the current working directory)"), Options.withDefault("."));
37
+ const packageOption = Options.text("package").pipe(Options.withDescription("Restrict output to a single workspace package"), Options.optional);
38
+ const jsonOption = Options.boolean("json").pipe(Options.withDescription("Emit JSON (default)"), Options.withDefault(false));
39
+ const markdownOption = Options.boolean("markdown").pipe(Options.withDescription("Emit one CSH005 markdown block per workspace package"), Options.withDefault(false));
40
+ /* v8 ignore stop */
41
+ /**
42
+ * Render a per-workspace diff as markdown — one frontmatter+section block
43
+ * per affected workspace package, suitable for pasting into individual
44
+ * `.changeset/*.md` files.
45
+ *
46
+ * @internal
47
+ */
48
+ function renderMarkdownBlocks(diffs) {
49
+ const blocks = [];
50
+ for (const diff of diffs) {
51
+ const frontmatter = `---\n"${diff.package}": patch\n---`;
52
+ const table = serializeDependencyTableToMarkdown([...diff.rows]);
53
+ blocks.push(`${frontmatter}\n\n## Dependencies\n\n${table}\n`);
54
+ }
55
+ return blocks.join("\n");
56
+ }
57
+ /**
58
+ * Handler exported for direct invocation in tests.
59
+ *
60
+ * @internal
61
+ */
62
+ function runDepsDetect(cwd, from, to, pkg, json, markdown) {
63
+ return Effect.gen(function* () {
64
+ const reader = yield* WorkspaceSnapshotReader;
65
+ const resolvedCwd = resolve(cwd);
66
+ let fromRef = Option.getOrUndefined(from);
67
+ if (!fromRef) fromRef = yield* gitMergeBase(resolvedCwd, (yield* (yield* ConfigInspector).inspect(resolvedCwd).pipe(Effect.catchTag("ConfigurationError", () => Effect.succeed({ baseBranch: "main" })))).baseBranch).pipe(Effect.catchTag("GitError", (err) => {
68
+ process.exitCode = 1;
69
+ return Effect.fail(err);
70
+ }));
71
+ const toRef = Option.getOrUndefined(to);
72
+ let diffs = computeWorkspaceDependencyDiffs(yield* reader.snapshotAt(resolvedCwd, fromRef).pipe(Effect.catchTag("GitError", (err) => {
73
+ process.exitCode = 1;
74
+ return Effect.fail(err);
75
+ })), toRef ? yield* reader.snapshotAt(resolvedCwd, toRef).pipe(Effect.catchTag("GitError", (err) => {
76
+ process.exitCode = 1;
77
+ return Effect.fail(err);
78
+ })) : snapshotFromWorktree(resolvedCwd));
79
+ const targetPkg = Option.getOrUndefined(pkg);
80
+ if (targetPkg) diffs = diffs.filter((d) => d.package === targetPkg);
81
+ else {
82
+ const publishable = yield* listPublishablePackageNames(yield* (yield* WorkspaceDiscovery).listPackages(resolvedCwd).pipe(Effect.catchAll(() => Effect.succeed([]))));
83
+ diffs = diffs.filter((d) => publishable.has(d.package));
84
+ }
85
+ if (markdown && !json) {
86
+ yield* Effect.log(renderMarkdownBlocks(diffs));
87
+ return;
88
+ }
89
+ yield* Effect.log(JSON.stringify(diffs, null, 2));
90
+ });
91
+ }
92
+ /* v8 ignore next 7 */
93
+ const depsDetectCommand = Command.make("detect", {
94
+ from: fromOption,
95
+ to: toOption,
96
+ cwd: cwdOption,
97
+ package: packageOption,
98
+ json: jsonOption,
99
+ markdown: markdownOption
100
+ }, ({ from, to, cwd, package: pkg, json, markdown }) => runDepsDetect(cwd, from, to, pkg, json, markdown)).pipe(Command.withDescription("Compute the dependency diff between two refs"));
101
+
102
+ //#endregion
103
+ export { depsDetectCommand };