@savvy-web/cli 0.3.0 → 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.
- package/bin/savvy.d.ts +1 -0
- package/bin/savvy.js +17 -1
- package/cli/index.js +123 -0
- package/commands/changeset/commands/analyze-branch.js +108 -0
- package/commands/changeset/commands/check.js +71 -0
- package/commands/changeset/commands/classify.js +69 -0
- package/commands/changeset/commands/config-show.js +100 -0
- package/commands/changeset/commands/config-validate.js +63 -0
- package/commands/changeset/commands/deps-detect.js +103 -0
- package/commands/changeset/commands/deps-regen.js +277 -0
- package/commands/changeset/commands/init.js +634 -0
- package/commands/changeset/commands/lint.js +62 -0
- package/commands/changeset/commands/release-surface.js +96 -0
- package/commands/changeset/commands/transform.js +88 -0
- package/commands/changeset/commands/validate-file.js +52 -0
- package/commands/changeset/commands/version.js +178 -0
- package/commands/changeset/index.js +42 -0
- package/commands/changeset/utils/config-gate.js +59 -0
- package/commands/check.js +74 -0
- package/commands/clean.js +186 -0
- package/commands/commit/check.js +170 -0
- package/commands/commit/constants.js +10 -0
- package/commands/commit/hook.js +22 -0
- package/commands/commit/hooks/post-commit-verify.js +121 -0
- package/commands/commit/hooks/pre-commit-message.js +64 -0
- package/commands/commit/hooks/session-start.js +69 -0
- package/commands/commit/hooks/user-prompt-submit.js +42 -0
- package/commands/commit/index.js +20 -0
- package/commands/commit/init.js +127 -0
- package/commands/init.js +88 -0
- package/commands/lint/check.js +306 -0
- package/commands/lint/fmt.js +64 -0
- package/commands/lint/index.js +20 -0
- package/commands/lint/init.js +221 -0
- package/index.d.ts +237 -244
- package/index.js +14 -1
- package/package.json +39 -51
- package/841.js +0 -2394
- 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 "../
|
|
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 };
|