@savvy-web/cli 0.3.1 → 0.4.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 (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
@@ -0,0 +1,96 @@
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/release-surface.ts
7
+ /**
8
+ * `release-surface` command -- list every path owned by a named package.
9
+ *
10
+ * @remarks
11
+ * For a given package name, emits the workspace directory, every
12
+ * `additionalScopes` glob and its materialized files, and every
13
+ * `versionFiles` entry and its targets. Useful for debugging "why is this
14
+ * path attributed to this package?" or "what's actually in this package's
15
+ * release surface right now?"
16
+ *
17
+ * @example
18
+ * ```bash
19
+ * savvy changeset release-surface @savvy-web/changesets
20
+ * savvy changeset release-surface @scope/foo --json
21
+ * ```
22
+ *
23
+ * @internal
24
+ */
25
+ const { ConfigInspector, ConfigurationError } = Changesets;
26
+ /* v8 ignore start -- CLI option definitions */
27
+ const packageArg = Args.text({ name: "package" });
28
+ const cwdOption = Options.directory("cwd").pipe(Options.withDescription("Project root (defaults to the current working directory)"), Options.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 a single package's resolved scope as human-readable text.
33
+ *
34
+ * @internal
35
+ */
36
+ function renderHuman(pkg) {
37
+ const lines = [];
38
+ lines.push(`Package: ${pkg.name} v${pkg.version}`);
39
+ lines.push(`Workspace: ${pkg.workspaceDir}`);
40
+ if (pkg.additionalScopes.length === 0 && pkg.versionFiles.length === 0) {
41
+ lines.push("");
42
+ lines.push("(no additionalScopes or versionFiles — workspace dir is the entire release surface)");
43
+ return lines.join("\n");
44
+ }
45
+ if (pkg.additionalScopes.length > 0) {
46
+ lines.push("");
47
+ lines.push(`additionalScopes (${pkg.additionalScopes.length} glob${pkg.additionalScopes.length === 1 ? "" : "s"}):`);
48
+ for (const g of pkg.additionalScopes) lines.push(` - ${g}`);
49
+ lines.push(`Resolved files (${pkg.additionalScopeFiles.length}):`);
50
+ for (const f of pkg.additionalScopeFiles) lines.push(` ${f}`);
51
+ }
52
+ if (pkg.versionFiles.length > 0) {
53
+ lines.push("");
54
+ lines.push(`versionFiles (${pkg.versionFiles.length}):`);
55
+ for (const vf of pkg.versionFiles) {
56
+ lines.push(` ${vf.glob} → ${vf.paths.join(", ")}`);
57
+ for (const f of vf.matchedFiles) lines.push(` ${f}`);
58
+ }
59
+ }
60
+ return lines.join("\n");
61
+ }
62
+ /**
63
+ * Resolve cwd, invoke `ConfigInspector.inspect`, find the named package's
64
+ * scope, and render it. Sets `process.exitCode = 1` on any error.
65
+ *
66
+ * @internal
67
+ */
68
+ function runReleaseSurface(cwd, pkgName, json) {
69
+ return Effect.gen(function* () {
70
+ const inspector = yield* ConfigInspector;
71
+ const resolvedCwd = resolve(cwd);
72
+ const config = yield* inspector.inspect(resolvedCwd).pipe(Effect.catchTag("ConfigurationError", (err) => {
73
+ process.exitCode = 1;
74
+ return Effect.fail(err);
75
+ }));
76
+ const scope = config.packages.find((p) => p.name === pkgName);
77
+ if (!scope) {
78
+ process.exitCode = 1;
79
+ return yield* Effect.fail(new ConfigurationError({
80
+ field: `packages["${pkgName}"]`,
81
+ reason: `Package "${pkgName}" is not declared in .changeset/config.json#packages. Declared packages: ${config.packages.map((p) => p.name).join(", ") || "(none)"}.`
82
+ }));
83
+ }
84
+ const output = json ? JSON.stringify(scope, null, 2) : renderHuman(scope);
85
+ yield* Effect.log(output);
86
+ });
87
+ }
88
+ /* v8 ignore next 6 -- CLI registration */
89
+ const releaseSurfaceCommand = Command.make("release-surface", {
90
+ package: packageArg,
91
+ cwd: cwdOption,
92
+ json: jsonOption
93
+ }, ({ package: pkgName, cwd, json }) => runReleaseSurface(cwd, pkgName, json)).pipe(Command.withDescription("Print every path owned by a package — workspace dir, additionalScopes, versionFiles"));
94
+
95
+ //#endregion
96
+ export { releaseSurfaceCommand };
@@ -0,0 +1,88 @@
1
+ import { requireValidConfig } from "../utils/config-gate.js";
2
+ import { Args, Command, Options } from "@effect/cli";
3
+ import { Changesets } from "@savvy-web/silk-effects";
4
+ import { Effect } from "effect";
5
+ import { dirname, resolve } from "node:path";
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+
8
+ //#region src/commands/changeset/commands/transform.ts
9
+ /**
10
+ * Transform command -- post-process CHANGELOG.md.
11
+ *
12
+ * Runs all remark transform plugins (section reordering, deduplication,
13
+ * contributor footnotes, issue link references, and format normalization)
14
+ * against a changelog file.
15
+ *
16
+ * @remarks
17
+ * The command supports three modes:
18
+ * - **Default** -- read the file, transform, and write back in place.
19
+ * - **`--dry-run` / `-n`** -- print the transformed output to stdout
20
+ * without writing.
21
+ * - **`--check` / `-c`** -- compare the transformed output against the
22
+ * original and exit with code 1 if they differ (useful in CI).
23
+ *
24
+ * Before any of those modes run, the command requires a valid
25
+ * `.changeset/config.json` (when one exists). A broken config indicates
26
+ * something structurally wrong with the project — refusing here surfaces
27
+ * that to the user rather than producing output the version step couldn't
28
+ * later corroborate.
29
+ *
30
+ * @example
31
+ * ```bash
32
+ * savvy changeset transform CHANGELOG.md
33
+ * savvy changeset transform --dry-run CHANGELOG.md
34
+ * savvy changeset transform --check CHANGELOG.md
35
+ * ```
36
+ *
37
+ * @internal
38
+ */
39
+ const { ChangelogTransformer } = Changesets;
40
+ /* v8 ignore start -- CLI option definitions; handler tested via runTransform */
41
+ const fileArg = Args.file({ name: "file" }).pipe(Args.withDefault("CHANGELOG.md"));
42
+ const dryRunOption = Options.boolean("dry-run").pipe(Options.withAlias("n"), Options.withDescription("Print transformed output instead of writing"), Options.withDefault(false));
43
+ const checkOption = Options.boolean("check").pipe(Options.withAlias("c"), Options.withDescription("Exit 1 if file would change (for CI)"), Options.withDefault(false));
44
+ /* v8 ignore stop */
45
+ /**
46
+ * Run the remark transform pipeline on a single changelog file.
47
+ *
48
+ * Reads the file at `file`, applies all remark transform plugins via
49
+ * {@link ChangelogTransformer.transformContent}, and either writes the result
50
+ * back, prints it to stdout (`dryRun`), or checks for differences (`check`).
51
+ *
52
+ * @param file - Path to the CHANGELOG.md file (resolved relative to cwd)
53
+ * @param dryRun - When `true`, print transformed output instead of writing
54
+ * @param check - When `true`, exit with code 1 if the file would change
55
+ * @returns An Effect that performs the transformation
56
+ *
57
+ * @internal
58
+ */
59
+ function runTransform(file, dryRun, check) {
60
+ return Effect.gen(function* () {
61
+ const resolved = resolve(file);
62
+ yield* requireValidConfig(dirname(resolved));
63
+ const content = yield* Effect.try(() => readFileSync(resolved, "utf-8"));
64
+ const result = ChangelogTransformer.transformContent(content);
65
+ if (dryRun) {
66
+ yield* Effect.log(result);
67
+ return;
68
+ }
69
+ if (check) {
70
+ 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.`);
74
+ return;
75
+ }
76
+ yield* Effect.try(() => writeFileSync(resolved, result, "utf-8"));
77
+ yield* Effect.log(`Transformed ${resolved}`);
78
+ });
79
+ }
80
+ /* v8 ignore next 5 -- CLI registration; handler tested via runTransform */
81
+ const transformCommand = Command.make("transform", {
82
+ file: fileArg,
83
+ dryRun: dryRunOption,
84
+ check: checkOption
85
+ }, ({ file, dryRun, check }) => runTransform(file, dryRun, check)).pipe(Command.withDescription("Post-process CHANGELOG.md"));
86
+
87
+ //#endregion
88
+ export { transformCommand };
@@ -0,0 +1,52 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+
5
+ //#region src/commands/changeset/commands/validate-file.ts
6
+ /**
7
+ * Validate-file command — validate a single changeset file.
8
+ *
9
+ * Reads one `.md` file and runs the full lint pipeline against it,
10
+ * outputting machine-readable diagnostics. Designed for use in hooks
11
+ * and editor integrations where only one file needs checking.
12
+ *
13
+ * @example
14
+ * ```bash
15
+ * savvy changeset validate-file .changeset/cool-lions-sing.md
16
+ * ```
17
+ *
18
+ * @internal
19
+ */
20
+ const { ChangesetLinter } = Changesets;
21
+ /* v8 ignore next */
22
+ const fileArg = Args.file({ name: "file" });
23
+ /**
24
+ * Run lint validation on a single changeset file.
25
+ *
26
+ * Outputs one line per error in `file:line:col rule message` format.
27
+ * Logs "Valid." when the file passes. Sets `process.exitCode = 1`
28
+ * when errors are found or the file cannot be read.
29
+ *
30
+ * @param filePath - Path to the changeset `.md` file
31
+ * @returns An Effect that performs validation and logs results
32
+ *
33
+ * @internal
34
+ */
35
+ function runValidateFile(filePath) {
36
+ return Effect.gen(function* () {
37
+ const result = yield* Effect.try(() => ChangesetLinter.validateFile(filePath)).pipe(Effect.catchAll((error) => Effect.gen(function* () {
38
+ yield* Effect.log(`Error: ${error instanceof Error ? error.message : String(error)}`);
39
+ process.exitCode = 1;
40
+ return null;
41
+ })));
42
+ 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.");
46
+ });
47
+ }
48
+ /* v8 ignore next 3 -- CLI registration; handler tested via runValidateFile */
49
+ const validateFileCommand = Command.make("validate-file", { file: fileArg }, ({ file }) => runValidateFile(file)).pipe(Command.withDescription("Validate a single changeset file"));
50
+
51
+ //#endregion
52
+ export { validateFileCommand };
@@ -0,0 +1,178 @@
1
+ import { requireValidConfig } from "../utils/config-gate.js";
2
+ import { Command, Options } from "@effect/cli";
3
+ import { Changesets } from "@savvy-web/silk-effects";
4
+ import { Effect } from "effect";
5
+ import { PackageManagerDetector, WorkspaceDiscovery } from "workspaces-effect";
6
+ import { join, resolve } from "node:path";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { execSync } from "node:child_process";
9
+
10
+ //#region src/commands/changeset/commands/version.ts
11
+ /**
12
+ * Version command -- orchestrate `changeset version` and changelog transforms.
13
+ *
14
+ * Detects the package manager, validates the config, runs `changeset version`,
15
+ * discovers all workspace CHANGELOG.md files, transforms each one with the
16
+ * remark pipeline, and updates any configured version files.
17
+ *
18
+ * @remarks
19
+ * The command performs six steps:
20
+ * 1. Detect the package manager (`pnpm`, `npm`, `yarn`, `bun`) via
21
+ * `PackageManagerDetector` from `workspaces-effect`.
22
+ * 2. **Require a valid `.changeset/config.json` via {@link ConfigInspector}**.
23
+ * If the config has overlap conflicts, unknown package keys, dual-shape,
24
+ * or schema errors, refuse to run and exit non-zero — a broken config
25
+ * means we cannot determine the right versions to write.
26
+ * 3. Run `changeset version` (skipped with `--dry-run`).
27
+ * 4. Discover all CHANGELOG.md files across workspace packages via
28
+ * `WorkspaceDiscovery` from `workspaces-effect`.
29
+ * 5. Transform each discovered changelog with
30
+ * {@link ChangelogTransformer.transformFile}.
31
+ * 6. Update version files using the **resolved** {@link InspectedConfig}
32
+ * via {@link VersionFiles.processResolvedVersionFiles}.
33
+ *
34
+ * @example
35
+ * ```bash
36
+ * savvy changeset version
37
+ * savvy changeset version --dry-run
38
+ * ```
39
+ *
40
+ * @internal
41
+ */
42
+ const { ChangelogTransformer, ConfigInspector, VersionFileError, VersionFiles } = Changesets;
43
+ /**
44
+ * Map package manager to the correct `changeset version` shell command.
45
+ *
46
+ * @internal
47
+ */
48
+ function getChangesetVersionCommand(pm) {
49
+ switch (pm) {
50
+ case "pnpm": return "pnpm exec changeset version";
51
+ case "yarn": return "yarn exec changeset version";
52
+ case "bun": return "bun x changeset version";
53
+ default: return "npx changeset version";
54
+ }
55
+ }
56
+ /* v8 ignore start -- CLI option definitions; handler tested via runVersion */
57
+ const dryRunOption = Options.boolean("dry-run").pipe(Options.withAlias("n"), Options.withDescription("Skip changeset version, only transform existing CHANGELOGs"), Options.withDefault(false));
58
+ /* v8 ignore stop */
59
+ /**
60
+ * Run the full version orchestration pipeline.
61
+ *
62
+ * Detects the package manager, validates the config, optionally runs
63
+ * `changeset version`, discovers and transforms all workspace changelogs,
64
+ * and updates version files using the resolved per-package scopes.
65
+ *
66
+ * @param dryRun - When `true`, skip `changeset version` and only transform
67
+ * existing CHANGELOG files
68
+ * @returns An Effect that performs the versioning pipeline
69
+ *
70
+ * @internal
71
+ */
72
+ function runVersion(dryRun) {
73
+ return Effect.gen(function* () {
74
+ const cwd = process.cwd();
75
+ const pm = (yield* (yield* PackageManagerDetector).detect(cwd).pipe(Effect.catchAll(() => Effect.succeed({
76
+ type: "npm",
77
+ version: void 0
78
+ })))).type;
79
+ yield* Effect.log(`Detected package manager: ${pm}`);
80
+ yield* requireValidConfig(cwd);
81
+ if (!dryRun) {
82
+ const cmd = getChangesetVersionCommand(pm);
83
+ yield* Effect.log(`Running: ${cmd}`);
84
+ yield* Effect.try({
85
+ try: () => execSync(cmd, {
86
+ cwd,
87
+ stdio: "inherit"
88
+ }),
89
+ catch: (error) => /* @__PURE__ */ new Error(`changeset version failed: ${error instanceof Error ? error.message : String(error)}`)
90
+ });
91
+ } else yield* Effect.log("Dry run: skipping changeset version");
92
+ const packages = yield* (yield* WorkspaceDiscovery).listPackages().pipe(Effect.catchAll(() => Effect.succeed([])));
93
+ const changelogs = [];
94
+ const seen = /* @__PURE__ */ new Set();
95
+ const resolvedCwd = resolve(cwd);
96
+ for (const pkg of packages) {
97
+ const changelogPath = join(pkg.path, "CHANGELOG.md");
98
+ if (existsSync(changelogPath) && !seen.has(pkg.path)) {
99
+ seen.add(pkg.path);
100
+ changelogs.push({
101
+ name: pkg.name,
102
+ path: pkg.path,
103
+ changelogPath
104
+ });
105
+ }
106
+ }
107
+ if (!seen.has(resolvedCwd)) {
108
+ const rootChangelog = join(resolvedCwd, "CHANGELOG.md");
109
+ if (existsSync(rootChangelog)) {
110
+ let rootName = "root";
111
+ try {
112
+ const pkg = JSON.parse(readFileSync(join(resolvedCwd, "package.json"), "utf-8"));
113
+ if (pkg.name) rootName = pkg.name;
114
+ } catch {}
115
+ changelogs.push({
116
+ name: rootName,
117
+ path: resolvedCwd,
118
+ changelogPath: rootChangelog
119
+ });
120
+ }
121
+ }
122
+ if (changelogs.length === 0) yield* Effect.log("No CHANGELOG.md files found.");
123
+ else {
124
+ yield* Effect.log(`Found ${changelogs.length} CHANGELOG.md file(s)`);
125
+ for (const entry of changelogs) {
126
+ yield* Effect.try({
127
+ try: () => ChangelogTransformer.transformFile(entry.changelogPath),
128
+ catch: (error) => /* @__PURE__ */ new Error(`Failed to transform ${entry.changelogPath}: ${error instanceof Error ? error.message : String(error)}`)
129
+ });
130
+ yield* Effect.log(`Transformed ${entry.name} → ${entry.changelogPath}`);
131
+ }
132
+ }
133
+ if (!existsSync(join(resolvedCwd, ".changeset", "config.json"))) return;
134
+ const scopesWithVersionFiles = (yield* (yield* ConfigInspector).inspect(resolvedCwd)).packages.filter((p) => p.versionFiles.length > 0).map((p) => {
135
+ const fresh = readPackageVersionFromDisk(p.workspaceDir);
136
+ return fresh && fresh !== p.version ? {
137
+ ...p,
138
+ version: fresh
139
+ } : p;
140
+ });
141
+ if (scopesWithVersionFiles.length === 0) return;
142
+ yield* Effect.log(`Found ${scopesWithVersionFiles.length} package${scopesWithVersionFiles.length === 1 ? "" : "s"} with versionFiles`);
143
+ const updates = yield* Effect.try({
144
+ try: () => VersionFiles.processResolvedVersionFiles(scopesWithVersionFiles, dryRun),
145
+ catch: (error) => {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ return new VersionFileError({
148
+ filePath: message.match(/Failed to update (.+?):/)?.[1] ?? cwd,
149
+ reason: message
150
+ });
151
+ }
152
+ });
153
+ for (const update of updates) {
154
+ const action = dryRun ? "Would update" : "Updated";
155
+ yield* Effect.log(`${action} ${update.filePath} → ${update.version}`);
156
+ }
157
+ });
158
+ }
159
+ /**
160
+ * Read the `version` field from a workspace package's `package.json` on
161
+ * disk. Used to bypass cached state in `ConfigInspector` and
162
+ * `WorkspaceDiscovery` after `changeset version` has rewritten each
163
+ * workspace's manifest.
164
+ *
165
+ * @internal
166
+ */
167
+ function readPackageVersionFromDisk(workspaceDir) {
168
+ try {
169
+ return JSON.parse(readFileSync(join(workspaceDir, "package.json"), "utf-8")).version ?? null;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+ /* v8 ignore next 4 -- CLI registration; handler tested via runVersion */
175
+ const versionCommand = Command.make("version", { dryRun: dryRunOption }, ({ dryRun }) => runVersion(dryRun)).pipe(Command.withDescription("Run changeset version and transform all CHANGELOGs"));
176
+
177
+ //#endregion
178
+ export { versionCommand };
@@ -0,0 +1,42 @@
1
+ import { analyzeBranchCommand } from "./commands/analyze-branch.js";
2
+ import { classifyCommand } from "./commands/classify.js";
3
+ import { configShowCommand } from "./commands/config-show.js";
4
+ import { configValidateCommand } from "./commands/config-validate.js";
5
+ import { depsDetectCommand } from "./commands/deps-detect.js";
6
+ import { depsRegenCommand } from "./commands/deps-regen.js";
7
+ import { lintCommand } from "./commands/lint.js";
8
+ import { releaseSurfaceCommand } from "./commands/release-surface.js";
9
+ import { transformCommand } from "./commands/transform.js";
10
+ import { validateFileCommand } from "./commands/validate-file.js";
11
+ import { versionCommand } from "./commands/version.js";
12
+ import { runChangesetCheck } from "./commands/check.js";
13
+ import { runChangesetInit } from "./commands/init.js";
14
+ import { Command } from "@effect/cli";
15
+
16
+ //#region src/commands/changeset/index.ts
17
+ /* v8 ignore start -- CLI registration; each command tested via exported handler */
18
+ const configGroup = Command.make("config").pipe(Command.withSubcommands([configShowCommand, configValidateCommand]), Command.withDescription("Inspect or validate .changeset/config.json"));
19
+ const depsGroup = Command.make("deps").pipe(Command.withSubcommands([depsDetectCommand, depsRegenCommand]), Command.withDescription("Generate or regenerate dependency changesets"));
20
+ const _changesetCommand = Command.make("changeset").pipe(Command.withSubcommands([
21
+ lintCommand,
22
+ transformCommand,
23
+ validateFileCommand,
24
+ versionCommand,
25
+ classifyCommand,
26
+ analyzeBranchCommand,
27
+ releaseSurfaceCommand,
28
+ configGroup,
29
+ depsGroup
30
+ ]), Command.withDescription("Section-aware changeset tooling"));
31
+ /**
32
+ * The `savvy changeset` command group for use in Task B7 root assembly.
33
+ *
34
+ * @remarks
35
+ * Typed as `unknown` at the export boundary to avoid TypeScript declaration-emit
36
+ * errors from Effect's internal types. Task B7 should import and use this via
37
+ * `Command.withSubcommands([changesetCommand as never])` or re-infer the type.
38
+ */
39
+ const changesetCommand = _changesetCommand;
40
+
41
+ //#endregion
42
+ export { changesetCommand };
@@ -0,0 +1,59 @@
1
+ import { Changesets } from "@savvy-web/silk-effects";
2
+ import { Effect } from "effect";
3
+ import { join, resolve } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+
6
+ //#region src/commands/changeset/utils/config-gate.ts
7
+ /**
8
+ * Shared "refuse on bad config" gate for CLI commands that must not run
9
+ * with an invalid `.changeset/config.json`.
10
+ *
11
+ * @remarks
12
+ * Used by `version` and `transform` in 0.9.0. Both commands are entry
13
+ * points to release-shaped work; running them against a config that
14
+ * fails the new validation contract (overlap, unknown package keys,
15
+ * dual-shape, schema errors) would produce broken or inconsistent
16
+ * output.
17
+ *
18
+ * `lint` deliberately does **not** call this gate — it operates on
19
+ * changeset markdown files and must keep working while the user is
20
+ * iterating on a config fix.
21
+ *
22
+ * Behavior:
23
+ * - **No config present** (`.changeset/config.json` absent): the gate
24
+ * resolves to `void` and the caller proceeds. This preserves the
25
+ * pre-0.9.0 behavior where `transform` and `version` worked on
26
+ * projects that have not yet been bootstrapped.
27
+ * - **Config present**: the gate invokes
28
+ * {@link ConfigInspector.inspect}. On `ConfigurationError`, it sets
29
+ * `process.exitCode = 1` and propagates the error so the caller's
30
+ * `Effect.gen` short-circuits.
31
+ *
32
+ * @internal
33
+ */
34
+ const { ConfigInspector } = Changesets;
35
+ /**
36
+ * Require a valid `.changeset/config.json` (when one exists) before
37
+ * proceeding. Resolves to `void` on a clean config OR on a project that
38
+ * doesn't have a config at all; fails with {@link ConfigurationError} on
39
+ * an invalid config.
40
+ *
41
+ * @param cwd - Project root (will be resolved against the process cwd)
42
+ * @returns Effect that succeeds on valid/absent config, fails with
43
+ * {@link ConfigurationError} otherwise
44
+ *
45
+ * @internal
46
+ */
47
+ function requireValidConfig(cwd) {
48
+ return Effect.gen(function* () {
49
+ const projectDir = resolve(cwd);
50
+ if (!existsSync(join(projectDir, ".changeset", "config.json"))) return;
51
+ yield* (yield* ConfigInspector).inspect(projectDir).pipe(Effect.catchTag("ConfigurationError", (err) => {
52
+ process.exitCode = 1;
53
+ return Effect.fail(err);
54
+ }));
55
+ });
56
+ }
57
+
58
+ //#endregion
59
+ export { requireValidConfig };
@@ -0,0 +1,74 @@
1
+ import { runChangesetCheck } from "./changeset/commands/check.js";
2
+ import "./changeset/index.js";
3
+ import { runCommitCheck } from "./commit/check.js";
4
+ import { runLintCheck } from "./lint/check.js";
5
+ import { Command, Options } from "@effect/cli";
6
+ import { Effect } from "effect";
7
+
8
+ //#region src/commands/check.ts
9
+ /**
10
+ * Unified `savvy check` orchestrator.
11
+ *
12
+ * @remarks
13
+ * Runs all three tool-specific check handlers — changeset, commit, lint —
14
+ * sequentially so each tool's console output is cleanly grouped rather than
15
+ * interleaved. Unlike the `init` orchestrator, check MUST NOT short-circuit:
16
+ * the user wants to see every failing check in a single pass. Uses
17
+ * `Effect.all` with `{ concurrency: 1, mode: "validate" }` to run all three
18
+ * regardless of individual failures and then surface the full set of errors.
19
+ *
20
+ * Runtime layer provision (ManagedSection, FileSystem, WorkspaceRoot,
21
+ * ToolDiscovery, etc.) is deferred to Task B7 (root `runCli`). The Effect
22
+ * returned by `checkCommand`'s handler therefore carries the full union of the
23
+ * three handlers' requirements in its R channel.
24
+ *
25
+ * @internal
26
+ */
27
+ /* v8 ignore start -- CLI option definitions; orchestration logic tested via runCheck */
28
+ const changesetDirOption = Options.text("changeset-dir").pipe(Options.withDescription("Path to the changeset directory"), Options.withDefault(".changeset"));
29
+ const quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Only output warnings from lint check"), Options.withDefault(false));
30
+ /* v8 ignore stop */
31
+ /**
32
+ * Run all three check step Effects without short-circuiting.
33
+ *
34
+ * All three checks always run, sequentially (concurrency 1) so per-tool
35
+ * output stays grouped. If any fail, the combined Effect fails with all
36
+ * accumulated errors. Uses `Effect.all` with `{ concurrency: 1, mode: "validate" }`
37
+ * to collect every failure rather than stopping at the first.
38
+ *
39
+ * @param steps - The three step Effects to run. Injected for testability.
40
+ * @returns An Effect that resolves to `void` on success, or fails with the
41
+ * union of all failing steps' errors.
42
+ */
43
+ function runCheck(steps) {
44
+ return Effect.all([
45
+ steps.changeset,
46
+ steps.commit,
47
+ steps.lint
48
+ ], {
49
+ concurrency: 1,
50
+ mode: "validate"
51
+ }).pipe(Effect.asVoid);
52
+ }
53
+ /* v8 ignore start -- CLI registration; orchestration logic tested via runCheck */
54
+ const _checkCommand = Command.make("check", {
55
+ changesetDir: changesetDirOption,
56
+ quiet: quietOption
57
+ }, (opts) => runCheck({
58
+ changeset: runChangesetCheck(opts.changesetDir),
59
+ commit: runCommitCheck(),
60
+ lint: runLintCheck({ quiet: opts.quiet })
61
+ })).pipe(Command.withDescription("Validate all Silk Suite tool configurations in one pass"));
62
+ /* v8 ignore stop */
63
+ /**
64
+ * The `savvy check` command for use in the Task B7 root assembly.
65
+ *
66
+ * @remarks
67
+ * Typed with `any` at the export boundary to avoid TypeScript declaration-emit
68
+ * errors from Effect's internal types. Task B7 should use this via
69
+ * `Command.withSubcommands([checkCommand as never])` or re-infer the type.
70
+ */
71
+ const checkCommand = _checkCommand;
72
+
73
+ //#endregion
74
+ export { checkCommand, runCheck };