@savvy-web/cli 2.0.1 → 2.1.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/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.0.1" });
71
+ const cli = Command.run(rootCommand, { version: "2.1.0" });
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).
@@ -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 { buildCommitlintInvocation, buildPostCommitAdvice, postCommitVerifyCommand };
71
+ export { buildPostCommitAdvice, postCommitVerifyCommand };
@@ -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/index.d.ts CHANGED
@@ -175,7 +175,7 @@ declare function runCommitInit(opts: {
175
175
  * errors from Effect's internal types. Task B7 should import and use this directly
176
176
  * as `Command.withSubcommands([commitCommand])` — the cast is for declaration emit only.
177
177
  */
178
- declare const commitCommand: Command.Command<"commit", {}, {}, never, never>;
178
+ declare const commitCommand: Command.Command<"commit", {}, {}, import("effect/unstable/cli/CliError").UserError, never>;
179
179
  //#endregion
180
180
  //#region src/commands/init.d.ts
181
181
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/cli",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
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",
@@ -33,9 +33,9 @@
33
33
  "dependencies": {
34
34
  "@effect/platform-node": "4.0.0-beta.98",
35
35
  "@effected/git": "^0.4.0",
36
- "@effected/jsonc": "^0.2.0",
37
- "@effected/workspaces": "^0.3.1",
38
- "@effected/yaml": "^0.3.0",
36
+ "@effected/jsonc": "^0.3.0",
37
+ "@effected/workspaces": "^0.4.0",
38
+ "@effected/yaml": "^0.3.1",
39
39
  "@savvy-web/silk-effects": "4.0.1",
40
40
  "effect": "4.0.0-beta.98"
41
41
  }