@savvy-web/cli 2.0.1 → 2.1.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.
- package/cli/index.js +1 -1
- package/commands/changeset/index.js +4 -0
- package/commands/commit/commitlint-invocation.js +43 -0
- package/commands/commit/hooks/post-commit-verify.js +3 -32
- package/commands/commit/index.js +2 -1
- package/commands/commit/lint.js +85 -0
- package/commands/lint/fmt.js +2 -7
- package/commands/repos/index.js +4 -0
- package/index.d.ts +14 -5
- package/package.json +8 -8
package/cli/index.js
CHANGED
|
@@ -68,7 +68,7 @@ const rootCommand = Command.make("savvy").pipe(Command.withSubcommands([
|
|
|
68
68
|
* CLI application: reads argv from the Stdio service provided by NodeServices.
|
|
69
69
|
* (v4's `Command.run` takes only `version` — the name comes from the root command.)
|
|
70
70
|
*/
|
|
71
|
-
const cli = Command.run(rootCommand, { version: "2.
|
|
71
|
+
const cli = Command.run(rootCommand, { version: "2.1.1" });
|
|
72
72
|
/**
|
|
73
73
|
* Shared workspace services from `@effected/workspaces`, wired as a
|
|
74
74
|
* self-contained unit and built ONCE (layers memoize by reference).
|
|
@@ -32,6 +32,10 @@ const _changesetCommand = Command.make("changeset").pipe(Command.withSubcommands
|
|
|
32
32
|
* reference effect's non-exported `Inspectable` module (TS4023). The
|
|
33
33
|
* annotation preserves the exact Error/Requirements channels, so the root
|
|
34
34
|
* layer graph stays compiler-validated.
|
|
35
|
+
*
|
|
36
|
+
* The requirements channel names the subcommands' services rather than `never`:
|
|
37
|
+
* `Command.withSubcommands` propagates each subcommand's requirements up into
|
|
38
|
+
* the group's `R`, which the root assembly discharges via `AppLive`.
|
|
35
39
|
*/
|
|
36
40
|
const changesetCommand = _changesetCommand;
|
|
37
41
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/commands/commit/commitlint-invocation.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build the `commitlint` invocation for the detected package manager.
|
|
4
|
+
*
|
|
5
|
+
* @param pm - The detected package manager.
|
|
6
|
+
* @param configPath - Explicit `--config` path, or `null` to let commitlint resolve it.
|
|
7
|
+
* @param tail - The mode args, e.g. `["--last"]` or `["--edit", file]`.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
function buildCommitlintInvocation(pm, configPath, tail) {
|
|
12
|
+
const base = configPath ? [
|
|
13
|
+
"commitlint",
|
|
14
|
+
"--config",
|
|
15
|
+
configPath,
|
|
16
|
+
...tail
|
|
17
|
+
] : ["commitlint", ...tail];
|
|
18
|
+
switch (pm) {
|
|
19
|
+
case "pnpm": return {
|
|
20
|
+
command: "pnpm",
|
|
21
|
+
args: ["exec", ...base]
|
|
22
|
+
};
|
|
23
|
+
case "yarn": return {
|
|
24
|
+
command: "yarn",
|
|
25
|
+
args: ["exec", ...base]
|
|
26
|
+
};
|
|
27
|
+
case "bun": return {
|
|
28
|
+
command: "bunx",
|
|
29
|
+
args: base
|
|
30
|
+
};
|
|
31
|
+
case "npm": return {
|
|
32
|
+
command: "npx",
|
|
33
|
+
args: [
|
|
34
|
+
"--no",
|
|
35
|
+
"--",
|
|
36
|
+
...base
|
|
37
|
+
]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
//#endregion
|
|
43
|
+
export { buildCommitlintInvocation };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildCommitlintInvocation } from "../commitlint-invocation.js";
|
|
1
2
|
import { Git } from "@effected/git";
|
|
2
3
|
import { Commitlint } from "@savvy-web/silk-effects";
|
|
3
4
|
import { Effect } from "effect";
|
|
@@ -28,43 +29,13 @@ function buildPostCommitAdvice(i) {
|
|
|
28
29
|
if (i.branchTicketId !== null && !i.bodyHasClosing) lines.push(`Branch implies ticket #${i.branchTicketId} but the commit body has no Closes/Fixes/Resolves trailer for it. If this commit closes #${i.branchTicketId}, amend with: git commit --amend --no-edit --trailer "Closes: #${i.branchTicketId}"`);
|
|
29
30
|
return lines.length === 0 ? null : lines.join("\n\n");
|
|
30
31
|
}
|
|
31
|
-
function buildCommitlintInvocation(pm, configPath) {
|
|
32
|
-
const tail = configPath ? [
|
|
33
|
-
"commitlint",
|
|
34
|
-
"--config",
|
|
35
|
-
configPath,
|
|
36
|
-
"--last"
|
|
37
|
-
] : ["commitlint", "--last"];
|
|
38
|
-
switch (pm) {
|
|
39
|
-
case "pnpm": return {
|
|
40
|
-
command: "pnpm",
|
|
41
|
-
args: ["exec", ...tail]
|
|
42
|
-
};
|
|
43
|
-
case "yarn": return {
|
|
44
|
-
command: "yarn",
|
|
45
|
-
args: ["exec", ...tail]
|
|
46
|
-
};
|
|
47
|
-
case "bun": return {
|
|
48
|
-
command: "bunx",
|
|
49
|
-
args: tail
|
|
50
|
-
};
|
|
51
|
-
case "npm": return {
|
|
52
|
-
command: "npx",
|
|
53
|
-
args: [
|
|
54
|
-
"--no",
|
|
55
|
-
"--",
|
|
56
|
-
...tail
|
|
57
|
-
]
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
32
|
/** The repo root via `@effected/git`, degrading to `process.cwd()` — the prior execFile contract. */
|
|
62
33
|
const getRepoRoot = Effect.gen(function* () {
|
|
63
34
|
return yield* (yield* Git).repoRoot(process.cwd()).pipe(Effect.catch(() => Effect.succeed(process.cwd())));
|
|
64
35
|
});
|
|
65
36
|
function runCommitlintLast(root) {
|
|
66
37
|
return (async () => {
|
|
67
|
-
const { command, args } = buildCommitlintInvocation(await Commitlint.detectPackageManager(root), await Commitlint.readCommitlintConfigPath(root));
|
|
38
|
+
const { command, args } = buildCommitlintInvocation(await Commitlint.detectPackageManager(root), await Commitlint.readCommitlintConfigPath(root), ["--last"]);
|
|
68
39
|
try {
|
|
69
40
|
await execFileP(command, args, { cwd: root });
|
|
70
41
|
return false;
|
|
@@ -97,4 +68,4 @@ const postCommitVerifyCommand = Command.make("post-commit-verify", {}, () => Eff
|
|
|
97
68
|
}).pipe(Effect.provide(Commitlint.HookSilencer))).pipe(Command.withDescription("Verify the most recent commit (commitlint replay + signature + closes trailer)"));
|
|
98
69
|
|
|
99
70
|
//#endregion
|
|
100
|
-
export {
|
|
71
|
+
export { buildPostCommitAdvice, postCommitVerifyCommand };
|
package/commands/commit/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { runCommitInit } from "./init.js";
|
|
2
2
|
import { runCommitCheck } from "./check.js";
|
|
3
3
|
import { hookCommand } from "./hook.js";
|
|
4
|
+
import { lintCommand } from "./lint.js";
|
|
4
5
|
import { Command } from "effect/unstable/cli";
|
|
5
6
|
|
|
6
7
|
//#region src/commands/commit/index.ts
|
|
7
8
|
/* v8 ignore start -- CLI registration; each command tested via exported handler */
|
|
8
|
-
const _commitCommand = Command.make("commit").pipe(Command.withSubcommands([hookCommand]), Command.withDescription("Commit standards: config, checks, and Claude hook handlers"));
|
|
9
|
+
const _commitCommand = Command.make("commit").pipe(Command.withSubcommands([hookCommand, lintCommand]), Command.withDescription("Commit standards: config, checks, and Claude hook handlers"));
|
|
9
10
|
/**
|
|
10
11
|
* The `savvy commit` command group for use in Task B7 root assembly.
|
|
11
12
|
*
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { buildCommitlintInvocation } from "./commitlint-invocation.js";
|
|
2
|
+
import { Git } from "@effected/git";
|
|
3
|
+
import { Commitlint } from "@savvy-web/silk-effects";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { Argument, CliError, Command } from "effect/unstable/cli";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
//#region src/commands/commit/lint.ts
|
|
10
|
+
/**
|
|
11
|
+
* `savvy commit lint <file>` — validate a CANDIDATE commit-message file
|
|
12
|
+
* against the real Silk commitlint preset BEFORE committing.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* Answers "would this message pass?" by running the same rule engine the
|
|
16
|
+
* husky `commit-msg` hook enforces (`commitlint --edit <file>`), rather than
|
|
17
|
+
* the advisory heuristics behind `hook pre-commit-message`. Exits non-zero
|
|
18
|
+
* when commitlint rejects the message and 0 when it passes; commitlint's own
|
|
19
|
+
* stdout/stderr is surfaced verbatim so the user sees the actual violations.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
const execFileP = promisify(execFile);
|
|
24
|
+
/** The repo root via `@effected/git`, degrading to `process.cwd()`. */
|
|
25
|
+
const getRepoRoot = Effect.gen(function* () {
|
|
26
|
+
return yield* (yield* Git).repoRoot(process.cwd()).pipe(Effect.catch(() => Effect.succeed(process.cwd())));
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Run `commitlint --edit <file>` and capture its output.
|
|
30
|
+
*
|
|
31
|
+
* A zero exit resolves to `passed: true`; a non-zero exit (commitlint's way
|
|
32
|
+
* of signalling violations) resolves to `passed: false` with the captured
|
|
33
|
+
* diagnostics — the promise never rejects.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
function runCommitlintEdit(root, file) {
|
|
38
|
+
return (async () => {
|
|
39
|
+
const { command, args } = buildCommitlintInvocation(await Commitlint.detectPackageManager(root), await Commitlint.readCommitlintConfigPath(root), ["--edit", file]);
|
|
40
|
+
try {
|
|
41
|
+
const { stdout, stderr } = await execFileP(command, args, { cwd: root });
|
|
42
|
+
return {
|
|
43
|
+
passed: true,
|
|
44
|
+
stdout,
|
|
45
|
+
stderr
|
|
46
|
+
};
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const e = error;
|
|
49
|
+
return {
|
|
50
|
+
passed: false,
|
|
51
|
+
stdout: e.stdout ?? "",
|
|
52
|
+
stderr: e.stderr ?? e.message ?? String(error)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Validate a candidate commit-message file against the Silk commitlint preset.
|
|
59
|
+
*
|
|
60
|
+
* Surfaces commitlint's own output, then fails the effect (non-zero exit) when
|
|
61
|
+
* the message is rejected so callers — the `commit-create` skill script, CI,
|
|
62
|
+
* or a human — get an honest pass/fail signal before `git commit` runs.
|
|
63
|
+
*
|
|
64
|
+
* @param file - Path to the candidate commit-message file.
|
|
65
|
+
* @returns An Effect that runs commitlint and fails on rejection.
|
|
66
|
+
*
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
function runCommitLint(file) {
|
|
70
|
+
return Effect.gen(function* () {
|
|
71
|
+
const root = yield* getRepoRoot;
|
|
72
|
+
const { passed, stdout, stderr } = yield* Effect.promise(() => runCommitlintEdit(root, file));
|
|
73
|
+
if (stdout.length > 0) yield* Effect.sync(() => process.stdout.write(stdout));
|
|
74
|
+
if (stderr.length > 0) yield* Effect.sync(() => process.stderr.write(stderr));
|
|
75
|
+
if (!passed) return yield* Effect.fail(new CliError.UserError({ cause: `Commit message at ${file} failed commitlint validation` }));
|
|
76
|
+
yield* Effect.log("Commit message passes the Silk commitlint preset.");
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/* v8 ignore next */
|
|
80
|
+
const fileArg = Argument.file("file", { mustExist: true });
|
|
81
|
+
/* v8 ignore next 3 -- CLI registration; handler tested via runCommitLint */
|
|
82
|
+
const lintCommand = Command.make("lint", { file: fileArg }, ({ file }) => runCommitLint(file)).pipe(Command.withDescription("Validate a candidate commit-message file against the Silk commitlint preset"));
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
85
|
+
export { lintCommand, runCommitLint };
|
package/commands/lint/fmt.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Lint } from "@savvy-web/silk-effects";
|
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { Argument, Command } from "effect/unstable/cli";
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { Yaml
|
|
5
|
+
import { Yaml } from "@effected/yaml";
|
|
6
6
|
|
|
7
7
|
//#region src/commands/lint/fmt.ts
|
|
8
8
|
/**
|
|
@@ -15,11 +15,6 @@ import { Yaml, YamlStringifyOptions } from "@effected/yaml";
|
|
|
15
15
|
*
|
|
16
16
|
* @internal
|
|
17
17
|
*/
|
|
18
|
-
/** Default YAML stringify options matching PnpmWorkspace handler (plain scalars = the v3 singleQuote:false posture). */
|
|
19
|
-
const YAML_STRINGIFY_OPTIONS = YamlStringifyOptions.make({
|
|
20
|
-
indent: 2,
|
|
21
|
-
lineWidth: 0
|
|
22
|
-
});
|
|
23
18
|
/** Repeated file path arguments. */
|
|
24
19
|
const filesArg = Argument.file("files", { mustExist: true }).pipe(Argument.variadic());
|
|
25
20
|
/** Sort package.json files with sort-package-json. */
|
|
@@ -37,7 +32,7 @@ const pnpmWorkspaceCommand = Command.make("pnpm-workspace", {}, () => Effect.gen
|
|
|
37
32
|
const content = readFileSync(filepath, "utf-8");
|
|
38
33
|
const parsed = yield* Yaml.parse(content);
|
|
39
34
|
const sorted = Lint.PnpmWorkspace.sortContent(parsed);
|
|
40
|
-
writeFileSync(filepath, yield*
|
|
35
|
+
writeFileSync(filepath, yield* Effect.promise(() => Lint.PnpmWorkspace.formatContent(sorted, filepath)), "utf-8");
|
|
41
36
|
}));
|
|
42
37
|
/** Format YAML files with Prettier. */
|
|
43
38
|
const yamlCommand = Command.make("yaml", { files: filesArg }, ({ files }) => Effect.gen(function* () {
|
package/commands/repos/index.js
CHANGED
|
@@ -24,6 +24,10 @@ const _reposCommand = Command.make("repos").pipe(Command.withSubcommands([
|
|
|
24
24
|
* reference effect's non-exported `Inspectable` module (TS4023). The
|
|
25
25
|
* annotation preserves the exact Error/Requirements channels, so the root
|
|
26
26
|
* layer graph stays compiler-validated.
|
|
27
|
+
*
|
|
28
|
+
* The requirements channel names `Repos.ReposManager` rather than `never`:
|
|
29
|
+
* `Command.withSubcommands` propagates each subcommand's requirements up into
|
|
30
|
+
* the group's `R`, which the root assembly discharges via `AppLive`.
|
|
27
31
|
*/
|
|
28
32
|
const reposCommand = _reposCommand;
|
|
29
33
|
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BiomeSchemaSync, Changesets, ConfigDiscovery, ManagedSection, Repos, SectionParseError, SectionWriteError, ToolDiscovery, VersioningStrategy } from "@savvy-web/silk-effects";
|
|
2
|
-
import { Cause, Effect, FileSystem } from "effect";
|
|
2
|
+
import { Cause, Effect, FileSystem, Path } from "effect";
|
|
3
3
|
import { Command } from "effect/unstable/cli";
|
|
4
|
+
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
4
5
|
import { Git } from "@effected/git";
|
|
5
6
|
import { WorkspaceDiscovery, WorkspaceRoot } from "@effected/workspaces";
|
|
6
7
|
import { PlatformError } from "effect/PlatformError";
|
|
@@ -102,8 +103,12 @@ declare function runChangesetInit(opts: {
|
|
|
102
103
|
* reference effect's non-exported `Inspectable` module (TS4023). The
|
|
103
104
|
* annotation preserves the exact Error/Requirements channels, so the root
|
|
104
105
|
* layer graph stays compiler-validated.
|
|
106
|
+
*
|
|
107
|
+
* The requirements channel names the subcommands' services rather than `never`:
|
|
108
|
+
* `Command.withSubcommands` propagates each subcommand's requirements up into
|
|
109
|
+
* the group's `R`, which the root assembly discharges via `AppLive`.
|
|
105
110
|
*/
|
|
106
|
-
declare const changesetCommand: Command.Command<"changeset", Record<string, never>, Record<string, never>, Changesets.ConfigurationError | Error | Changesets.ReleasePlanError | Cause.UnknownError | Changesets.DepsRegenPlanError,
|
|
111
|
+
declare const changesetCommand: Command.Command<"changeset", Record<string, never>, Record<string, never>, Changesets.ConfigurationError | Error | Changesets.ReleasePlanError | Cause.UnknownError | Changesets.DepsRegenPlanError, ChildProcessSpawner.ChildProcessSpawner | Changesets.ConfigInspector | FileSystem.FileSystem | Path.Path | Changesets.ReleasePlanner>;
|
|
107
112
|
//#endregion
|
|
108
113
|
//#region src/commands/check.d.ts
|
|
109
114
|
/**
|
|
@@ -175,7 +180,7 @@ declare function runCommitInit(opts: {
|
|
|
175
180
|
* errors from Effect's internal types. Task B7 should import and use this directly
|
|
176
181
|
* as `Command.withSubcommands([commitCommand])` — the cast is for declaration emit only.
|
|
177
182
|
*/
|
|
178
|
-
declare const commitCommand: Command.Command<"commit", {}, {},
|
|
183
|
+
declare const commitCommand: Command.Command<"commit", {}, {}, import("effect/unstable/cli/CliError").UserError, import("effect/unstable/process/ChildProcessSpawner").ChildProcessSpawner | import("@effected/git").Git>;
|
|
179
184
|
//#endregion
|
|
180
185
|
//#region src/commands/init.d.ts
|
|
181
186
|
/**
|
|
@@ -251,7 +256,7 @@ declare function runLintInit(opts: {
|
|
|
251
256
|
* errors from Effect's internal types. Task B7 should import and use this directly
|
|
252
257
|
* as `Command.withSubcommands([lintCommand])` — the cast is for declaration emit only.
|
|
253
258
|
*/
|
|
254
|
-
declare const lintCommand: Command.Command<"lint", {}, {}, import("@effected/yaml").YamlParseError
|
|
259
|
+
declare const lintCommand: Command.Command<"lint", {}, {}, import("@effected/yaml").YamlParseError, never>;
|
|
255
260
|
//#endregion
|
|
256
261
|
//#region src/commands/repos/commands/status.d.ts
|
|
257
262
|
/**
|
|
@@ -280,8 +285,12 @@ declare const runReposSync: (cwd: string) => Effect.Effect<void, never, Repos.Re
|
|
|
280
285
|
* reference effect's non-exported `Inspectable` module (TS4023). The
|
|
281
286
|
* annotation preserves the exact Error/Requirements channels, so the root
|
|
282
287
|
* layer graph stays compiler-validated.
|
|
288
|
+
*
|
|
289
|
+
* The requirements channel names `Repos.ReposManager` rather than `never`:
|
|
290
|
+
* `Command.withSubcommands` propagates each subcommand's requirements up into
|
|
291
|
+
* the group's `R`, which the root assembly discharges via `AppLive`.
|
|
283
292
|
*/
|
|
284
|
-
declare const reposCommand: Command.Command<"repos", Record<string, never>, Record<string, never>, Repos.GitSubmoduleError,
|
|
293
|
+
declare const reposCommand: Command.Command<"repos", Record<string, never>, Record<string, never>, Repos.GitSubmoduleError, Repos.ReposManager>;
|
|
285
294
|
//#endregion
|
|
286
295
|
export { changesetCommand, checkCommand, commitCommand, initCommand, lintCommand, reposCommand, runChangesetCheck, runChangesetInit, runCheck, runCli, runCommitCheck, runCommitInit, runInit, runLintCheck, runLintInit, runReposStatus, runReposSync };
|
|
287
296
|
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@savvy-web/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The savvy CLI — unified commit, changeset, and lint commands for the Silk Suite",
|
|
6
6
|
"homepage": "https://github.com/savvy-web/systems/tree/main/packages/cli",
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
"savvy": "bin/savvy.js"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@effect/platform-node": "4.0.0-beta.
|
|
35
|
-
"@effected/git": "^0.4.
|
|
36
|
-
"@effected/jsonc": "^0.
|
|
37
|
-
"@effected/workspaces": "^0.
|
|
38
|
-
"@effected/yaml": "^0.
|
|
39
|
-
"@savvy-web/silk-effects": "4.0
|
|
40
|
-
"effect": "4.0.0-beta.
|
|
34
|
+
"@effect/platform-node": "4.0.0-beta.99",
|
|
35
|
+
"@effected/git": "^0.4.1",
|
|
36
|
+
"@effected/jsonc": "^0.4.0",
|
|
37
|
+
"@effected/workspaces": "^0.4.1",
|
|
38
|
+
"@effected/yaml": "^0.4.0",
|
|
39
|
+
"@savvy-web/silk-effects": "4.1.0",
|
|
40
|
+
"effect": "4.0.0-beta.99"
|
|
41
41
|
}
|
|
42
42
|
}
|