javi-forge 1.7.1 → 1.9.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.
@@ -0,0 +1,30 @@
1
+ #!/bin/bash
2
+ # Commit-msg: block AI attribution in commit messages
3
+ set -e
4
+ COMMIT_MSG_FILE="$1"
5
+ COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
6
+
7
+ AI_PATTERNS=(
8
+ "co-authored-by:.*claude" "co-authored-by:.*anthropic"
9
+ "co-authored-by:.*gpt" "co-authored-by:.*openai"
10
+ "co-authored-by:.*copilot" "co-authored-by:.*gemini"
11
+ "co-authored-by:.*\\bai\\b"
12
+ "made by claude" "made by gpt" "made by ai"
13
+ "generated by claude" "generated by gpt" "generated by ai"
14
+ "written by claude" "written by ai"
15
+ "claude code" "claude opus" "claude sonnet" "claude haiku"
16
+ "gpt-4" "gpt-3" "chatgpt"
17
+ "@anthropic.com" "@openai.com"
18
+ )
19
+
20
+ for pattern in "${AI_PATTERNS[@]}"; do
21
+ if echo "$COMMIT_MSG" | grep -iqE "$pattern"; then
22
+ echo ""
23
+ echo "COMMIT BLOCKED: AI Attribution Detected"
24
+ echo " Pattern: $pattern"
25
+ echo " Remove AI attribution. You are the sole author."
26
+ echo ""
27
+ exit 1
28
+ fi
29
+ done
30
+ exit 0
@@ -0,0 +1,32 @@
1
+ {
2
+ "pre-commit": {
3
+ "version": 1,
4
+ "sha256": "811f34ce57517e129554bc9c09801a66c0207332cd3e7f2950db43a40580e914",
5
+ "historical": [
6
+ {
7
+ "sha256": "811f34ce57517e129554bc9c09801a66c0207332cd3e7f2950db43a40580e914",
8
+ "firstCommit": "5587b3b9b9ed3c86809c0a3d89a850dd2bce7e1a"
9
+ }
10
+ ]
11
+ },
12
+ "pre-push": {
13
+ "version": 1,
14
+ "sha256": "7de58640aeef33085a49f31f1d9d0c8bacde0069d6d3265ae41aa8d3cd14d7a5",
15
+ "historical": [
16
+ {
17
+ "sha256": "7de58640aeef33085a49f31f1d9d0c8bacde0069d6d3265ae41aa8d3cd14d7a5",
18
+ "firstCommit": "5587b3b9b9ed3c86809c0a3d89a850dd2bce7e1a"
19
+ }
20
+ ]
21
+ },
22
+ "commit-msg": {
23
+ "version": 1,
24
+ "sha256": "1c23a60cd4ba7f6bc666da400b5d2971c4294782c8d9ce41543e7815de11a1d6",
25
+ "historical": [
26
+ {
27
+ "sha256": "1c23a60cd4ba7f6bc666da400b5d2971c4294782c8d9ce41543e7815de11a1d6",
28
+ "firstCommit": "5587b3b9b9ed3c86809c0a3d89a850dd2bce7e1a"
29
+ }
30
+ ]
31
+ }
32
+ }
@@ -0,0 +1,15 @@
1
+ #!/bin/bash
2
+ # Pre-commit: quick CI check via javi-forge
3
+ # To skip: git commit --no-verify
4
+ set -e
5
+ echo "PRE-COMMIT: Running quick check..."
6
+ if command -v javi-forge &>/dev/null; then
7
+ javi-forge ci --quick --no-docker --no-security --no-ci-ghagga
8
+ else
9
+ npx javi-forge ci --quick --no-docker --no-security --no-ci-ghagga
10
+ fi || {
11
+ echo ""
12
+ echo "Quick check FAILED — fix the issues above before committing."
13
+ echo "To skip: git commit --no-verify"
14
+ exit 1
15
+ }
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+ # Pre-push: full CI simulation via javi-forge
3
+ # To skip: git push --no-verify
4
+ set -e
5
+ if ! docker info &>/dev/null; then
6
+ echo "PRE-PUSH: Docker is not running."
7
+ echo " Start Docker or use: git push --no-verify"
8
+ exit 1
9
+ fi
10
+ echo "PRE-PUSH: Running CI simulation..."
11
+ if command -v javi-forge &>/dev/null; then
12
+ javi-forge ci
13
+ else
14
+ npx javi-forge ci
15
+ fi || {
16
+ echo ""
17
+ echo "CI FAILED — push aborted. Fix the issues above."
18
+ echo "To skip: git push --no-verify"
19
+ exit 1
20
+ }
@@ -15,11 +15,22 @@ export async function handleCi(cli, ctx) {
15
15
  // Sub-command: javi-forge ci init → install git hooks
16
16
  if (cli.input[1] === "init") {
17
17
  const { installCIHooks } = await import("../../commands/ci.js");
18
- const { installed, errors } = await installCIHooks(process.cwd());
18
+ const { installed, upgraded, backups, errors, states } = await installCIHooks(process.cwd(), {
19
+ force: cli.flags.force === true,
20
+ });
21
+ for (const backup of backups) {
22
+ console.log(`⚠ Backed up the previous hook → ${backup}`);
23
+ }
19
24
  if (installed.length > 0) {
20
25
  console.log(`✓ Installed git hooks: ${installed.join(", ")}`);
21
26
  console.log(" Hooks call javi-forge ci (with npx fallback)");
22
27
  }
28
+ // Upgrades are reported DISTINCTLY from fresh installs: replacing an
29
+ // older javi-forge hook is not the same event as writing a new one.
30
+ for (const hook of upgraded) {
31
+ const was = states.find((entry) => entry.name === hook)?.state;
32
+ console.log(`↑ Upgraded ${hook}${was === undefined ? "" : ` (was ${was})`}`);
33
+ }
23
34
  for (const err of errors) {
24
35
  console.error(`✗ ${err}`);
25
36
  }
@@ -8,7 +8,7 @@
8
8
  * Help banner shown by meow when `--help` is passed or invalid args are supplied.
9
9
  * Multi-line template literal — preserve exact formatting (whitespace is significant).
10
10
  */
11
- export declare const HELP_TEXT = "\n Usage\n $ javi-forge [command] [options]\n\n Commands\n init Bootstrap a new project (default)\n ci Run CI simulation (lint + compile + test + security + ghagga)\n tdd init Install TDD-enforcing pre-commit hook (auto-detects stack)\n tdd pipeline Install TDD pipeline pre-push hook (--mode strict|warn)\n analyze Run repoforge skills analysis\n doctor Show health report\n workflow show Render a workflow graph as ASCII (--template <name> or file path)\n workflow validate Validate project state against a workflow graph\n workflow list List available workflows and built-in templates\n plugin add Install a plugin from GitHub (org/repo)\n plugin remove Remove an installed plugin\n plugin list List installed plugins\n plugin search Search the plugin registry\n plugin validate Validate a local plugin directory\n plugin sync Auto-detect and wire installed plugins\n plugin export Export plugin to Agent Skills spec format (skills.json)\n plugin export --codex: Export plugin to Codex-compatible TOML subagent files\n plugin export-skills Generate aggregated skills.json from all installed plugins\n plugin export-skills global Generate global skills.json from all globally installed plugins\n plugin import Import an Agent Skills spec package as a javi-forge plugin\n skills doctor Show skills health report (add --deep for conflict detection)\n skills budget Show token cost of loaded skills (add -b N for custom budget)\n skills score Score a skill on quality dimensions (completeness, clarity, testability, token-efficiency)\n skills benchmark Benchmark a skill with structural quality checks\n skills auto Auto-detect project stack and suggest/install matching AI skills\n skills auto-install Alias for skills auto\n skill publish Package a skill directory for marketplace distribution (generates plugin.json)\n security baseline Create security baseline from current audit findings\n security check Check for regressions against baseline (exits non-zero if found)\n security update Re-snapshot baseline (acknowledge current vulns)\n security allowlist Add all current findings to the allowlist (suppress in future checks)\n llms-txt Generate AI-friendly llms.txt for current project\n\n Options\n --dry-run Preview changes without writing files\n --stack Project stack (node, python, go, rust, java-gradle, java-maven, elixir)\n --ci CI provider (github, gitlab, woodpecker)\n --memory Memory module (engram, obsidian-brain, memory-simple, none)\n --project-name Project name (skips name prompt)\n --ghagga Enable GHAGGA review system\n --mock Enable mock-first mode (no real API keys needed)\n --local-ai Include local AI dev stack (Ollama + Docker Compose)\n --batch Non-interactive mode (auto-proceed, no keyboard input)\n --deep Enable deep analysis (conflict + duplicate detection)\n --budget, -b Token budget limit for skills (default: 8000)\n --skills-dir Custom skills directory path\n --author Author name for skill publish\n --repo Repository URL for skill publish\n --version Show version\n --help Show this help\n\n CI options (javi-forge ci)\n --quick Lint + compile only (fast, for pre-commit)\n --shell Open interactive shell in CI container\n --detect Show detected stack and exit\n --config PATH Load ordered CI runners from a versioned config file\n (default discovery: .javi-forge/ci.yaml)\n --stack STACK Force a single explicit stack (single-stack repos only \u2014\n insufficient for hybrid repos; use --config instead)\n --no-docker Run commands natively (no Docker)\n --no-ci-ghagga Skip GHAGGA review\n --no-security Skip Semgrep security scan\n --timeout N Per-step timeout in seconds (default: 600)\n\n CI hooks (javi-forge ci init)\n Install git hooks that call javi-forge ci.\n No files copied \u2014 hooks reference the global CLI.\n\n Examples\n $ javi-forge\n $ javi-forge init --dry-run\n $ javi-forge init --stack node --ci github\n $ javi-forge ci\n $ javi-forge ci init\n $ javi-forge tdd init\n $ javi-forge ci --quick\n $ javi-forge ci --no-ci-ghagga --no-security\n $ javi-forge ci --no-docker\n $ javi-forge ci --shell\n $ javi-forge ci --config .javi-forge/ci.yaml\n $ javi-forge analyze\n $ javi-forge doctor\n $ javi-forge plugin add mapbox/agent-skills\n $ javi-forge plugin list\n";
11
+ export declare const HELP_TEXT = "\n Usage\n $ javi-forge [command] [options]\n\n Commands\n init Bootstrap a new project (default)\n ci Run CI simulation (lint + compile + test + security + ghagga)\n tdd init Install TDD-enforcing pre-commit hook (auto-detects stack)\n tdd pipeline Install TDD pipeline pre-push hook (--mode strict|warn)\n analyze Run repoforge skills analysis\n doctor Show health report\n workflow show Render a workflow graph as ASCII (--template <name> or file path)\n workflow validate Validate project state against a workflow graph\n workflow list List available workflows and built-in templates\n plugin add Install a plugin from GitHub (org/repo)\n plugin remove Remove an installed plugin\n plugin list List installed plugins\n plugin search Search the plugin registry\n plugin validate Validate a local plugin directory\n plugin sync Auto-detect and wire installed plugins\n plugin export Export plugin to Agent Skills spec format (skills.json)\n plugin export --codex: Export plugin to Codex-compatible TOML subagent files\n plugin export-skills Generate aggregated skills.json from all installed plugins\n plugin export-skills global Generate global skills.json from all globally installed plugins\n plugin import Import an Agent Skills spec package as a javi-forge plugin\n skills doctor Show skills health report (add --deep for conflict detection)\n skills budget Show token cost of loaded skills (add -b N for custom budget)\n skills score Score a skill on quality dimensions (completeness, clarity, testability, token-efficiency)\n skills benchmark Benchmark a skill with structural quality checks\n skills auto Auto-detect project stack and suggest/install matching AI skills\n skills auto-install Alias for skills auto\n skill publish Package a skill directory for marketplace distribution (generates plugin.json)\n security baseline Create security baseline from current audit findings\n security check Check for regressions against baseline (exits non-zero if found)\n security update Re-snapshot baseline (acknowledge current vulns)\n security allowlist Add all current findings to the allowlist (suppress in future checks)\n llms-txt Generate AI-friendly llms.txt for current project\n\n Options\n --dry-run Preview changes without writing files\n --stack Project stack (node, python, go, rust, java-gradle, java-maven, elixir)\n --ci CI provider (github, gitlab, woodpecker)\n --memory Memory module (engram, obsidian-brain, memory-simple, none)\n --project-name Project name (skips name prompt)\n --ghagga Enable GHAGGA review system\n --mock Enable mock-first mode (no real API keys needed)\n --local-ai Include local AI dev stack (Ollama + Docker Compose)\n --batch Non-interactive mode (auto-proceed, no keyboard input)\n --deep Enable deep analysis (conflict + duplicate detection)\n --budget, -b Token budget limit for skills (default: 8000)\n --skills-dir Custom skills directory path\n --author Author name for skill publish\n --repo Repository URL for skill publish\n --version Show version\n --help Show this help\n\n CI options (javi-forge ci)\n --quick Lint + compile only (fast, for pre-commit)\n --shell Open interactive shell in CI container\n --detect Show detected stack and exit\n --config PATH Load ordered CI runners from a versioned config file\n (default discovery: .javi-forge/ci.yaml)\n --stack STACK Force a single explicit stack (single-stack repos only \u2014\n insufficient for hybrid repos; use --config instead)\n --no-docker Run commands natively (no Docker)\n --no-ci-ghagga Skip GHAGGA review\n --no-security Skip Semgrep security scan\n --timeout N Per-step timeout in seconds (default: 600)\n\n CI hooks (javi-forge ci init)\n Install git hooks that call javi-forge ci.\n No files copied \u2014 hooks reference the global CLI.\n Existing hooks javi-forge did not write are refused, never clobbered.\n --force Overwrite a foreign or locally modified hook. The previous\n content is copied to a .bak sibling first; if that backup\n cannot be written, the hook is left untouched. Symlinked\n hook paths are refused even with --force.\n\n Examples\n $ javi-forge\n $ javi-forge init --dry-run\n $ javi-forge init --stack node --ci github\n $ javi-forge ci\n $ javi-forge ci init\n $ javi-forge ci init --force\n $ javi-forge tdd init\n $ javi-forge ci --quick\n $ javi-forge ci --no-ci-ghagga --no-security\n $ javi-forge ci --no-docker\n $ javi-forge ci --shell\n $ javi-forge ci --config .javi-forge/ci.yaml\n $ javi-forge analyze\n $ javi-forge doctor\n $ javi-forge plugin add mapbox/agent-skills\n $ javi-forge plugin list\n";
12
12
  export declare const FLAGS_SCHEMA: {
13
13
  readonly dryRun: {
14
14
  readonly type: "boolean";
@@ -78,6 +78,10 @@ export declare const FLAGS_SCHEMA: {
78
78
  readonly type: "number";
79
79
  readonly default: 600;
80
80
  };
81
+ readonly force: {
82
+ readonly type: "boolean";
83
+ readonly default: false;
84
+ };
81
85
  readonly minSeverity: {
82
86
  readonly type: "string";
83
87
  readonly default: "low";
package/dist/cli/help.js CHANGED
@@ -80,6 +80,11 @@ export const HELP_TEXT = `
80
80
  CI hooks (javi-forge ci init)
81
81
  Install git hooks that call javi-forge ci.
82
82
  No files copied — hooks reference the global CLI.
83
+ Existing hooks javi-forge did not write are refused, never clobbered.
84
+ --force Overwrite a foreign or locally modified hook. The previous
85
+ content is copied to a .bak sibling first; if that backup
86
+ cannot be written, the hook is left untouched. Symlinked
87
+ hook paths are refused even with --force.
83
88
 
84
89
  Examples
85
90
  $ javi-forge
@@ -87,6 +92,7 @@ export const HELP_TEXT = `
87
92
  $ javi-forge init --stack node --ci github
88
93
  $ javi-forge ci
89
94
  $ javi-forge ci init
95
+ $ javi-forge ci init --force
90
96
  $ javi-forge tdd init
91
97
  $ javi-forge ci --quick
92
98
  $ javi-forge ci --no-ci-ghagga --no-security
@@ -117,6 +123,8 @@ export const FLAGS_SCHEMA = {
117
123
  ciGhagga: { type: "boolean", default: true },
118
124
  security: { type: "boolean", default: true },
119
125
  timeout: { type: "number", default: 600 },
126
+ // ci init: overwrite a foreign / locally modified hook (backs it up first)
127
+ force: { type: "boolean", default: false },
120
128
  // Security check flags
121
129
  minSeverity: { type: "string", default: "low" },
122
130
  staleDays: { type: "number", default: 30 },
@@ -77,8 +77,59 @@ export interface ResolveRunnerOptions {
77
77
  */
78
78
  export declare function resolveCIRunners(projectDir: string, options?: ResolveRunnerOptions): Promise<ResolvedRunners>;
79
79
  export declare function runCI(options: CIOptions, onStep: CIStepCallback): Promise<void>;
80
- export declare function installCIHooks(projectDir: string): Promise<{
80
+ /**
81
+ * Classification of an existing `.git/hooks/<name>` before anything is written
82
+ * (design D6). Every state has exactly one write policy, so no hook is ever
83
+ * overwritten without a decision.
84
+ */
85
+ export declare const HOOK_STATE: {
86
+ readonly ABSENT: "absent";
87
+ readonly MANAGED_CURRENT: "managed-current";
88
+ readonly MANAGED_OUTDATED: "managed-outdated";
89
+ readonly MANAGED_EDITED: "managed-edited";
90
+ readonly LEGACY_V0: "legacy-v0";
91
+ readonly FOREIGN: "foreign";
92
+ readonly SYMLINK: "symlink";
93
+ /** Exists but is not a regular file (directory, fifo, device) — never forceable. */
94
+ readonly NOT_A_FILE: "not-a-file";
95
+ };
96
+ export type HookState = (typeof HOOK_STATE)[keyof typeof HOOK_STATE];
97
+ export interface HookHistoricalEntry {
98
+ sha256: string;
99
+ firstCommit: string;
100
+ }
101
+ export interface HookManifestEntry {
102
+ version: number;
103
+ sha256: string;
104
+ historical: HookHistoricalEntry[];
105
+ }
106
+ export interface HookStateReport {
107
+ name: string;
108
+ state: HookState;
109
+ }
110
+ export interface InstallHooksOptions {
111
+ /** Overwrite `foreign` / `managed-edited` hooks, backing them up first. */
112
+ force?: boolean;
113
+ }
114
+ export interface InstallHooksResult {
81
115
  installed: string[];
116
+ /** Hooks that were `managed-outdated` or `legacy-v0` and got REPLACED. */
117
+ upgraded: string[];
118
+ backups: string[];
82
119
  errors: string[];
83
- }>;
120
+ states: HookStateReport[];
121
+ }
122
+ /**
123
+ * Pure classification of hook CONTENT (D6 steps 1-5).
124
+ *
125
+ * The hash input is the body BELOW the marker block — the shebang stays in the
126
+ * body, the two marker lines are removed — so a version bump alone does not
127
+ * invalidate installed hooks. Lines are split on `\n` WITHOUT stripping a
128
+ * trailing `\r`: a CRLF-converted file fails the `$`-anchored marker regex,
129
+ * takes the unmarked path and classifies `foreign`, which is correct — it no
130
+ * longer matches anything released. The hash CLAIMED by the marker is never
131
+ * trusted; classification always recomputes against the shipped manifest.
132
+ */
133
+ export declare function classifyHookContent(content: string, hookName: string, entry: HookManifestEntry): HookState;
134
+ export declare function installCIHooks(projectDir: string, options?: InstallHooksOptions): Promise<InstallHooksResult>;
84
135
  //# sourceMappingURL=ci.d.ts.map
@@ -1,6 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { constants } from "node:fs";
2
4
  import path from "node:path";
3
5
  import fs from "fs-extra";
6
+ import { HOOK_ASSETS_DIR } from "../constants.js";
4
7
  import { CI_STACKS, findCIConfig, loadCIConfig, } from "../lib/ci-config.js";
5
8
  import { refreshContextDir } from "../lib/context.js";
6
9
  import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
@@ -680,121 +683,343 @@ async function runGhagga(projectDir) {
680
683
  // =============================================================================
681
684
  // CI Hooks Installation
682
685
  // =============================================================================
683
- const PRE_COMMIT_HOOK = `#!/bin/bash
684
- # Pre-commit: quick CI check via javi-forge
685
- # To skip: git commit --no-verify
686
- set -e
687
- echo "PRE-COMMIT: Running quick check..."
688
- if command -v javi-forge &>/dev/null; then
689
- javi-forge ci --quick --no-docker --no-security --no-ci-ghagga
690
- else
691
- npx javi-forge ci --quick --no-docker --no-security --no-ci-ghagga
692
- fi || {
693
- echo ""
694
- echo "Quick check FAILED — fix the issues above before committing."
695
- echo "To skip: git commit --no-verify"
696
- exit 1
697
- }
698
- `;
699
- const PRE_PUSH_HOOK = `#!/bin/bash
700
- # Pre-push: full CI simulation via javi-forge
701
- # To skip: git push --no-verify
702
- set -e
703
- if ! docker info &>/dev/null; then
704
- echo "PRE-PUSH: Docker is not running."
705
- echo " Start Docker or use: git push --no-verify"
706
- exit 1
707
- fi
708
- echo "PRE-PUSH: Running CI simulation..."
709
- if command -v javi-forge &>/dev/null; then
710
- javi-forge ci
711
- else
712
- npx javi-forge ci
713
- fi || {
714
- echo ""
715
- echo "CI FAILED — push aborted. Fix the issues above."
716
- echo "To skip: git push --no-verify"
717
- exit 1
718
- }
719
- `;
720
- const COMMIT_MSG_HOOK = `#!/bin/bash
721
- # Commit-msg: block AI attribution in commit messages
722
- set -e
723
- COMMIT_MSG_FILE="$1"
724
- COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
725
-
726
- AI_PATTERNS=(
727
- "co-authored-by:.*claude" "co-authored-by:.*anthropic"
728
- "co-authored-by:.*gpt" "co-authored-by:.*openai"
729
- "co-authored-by:.*copilot" "co-authored-by:.*gemini"
730
- "co-authored-by:.*\\\\bai\\\\b"
731
- "made by claude" "made by gpt" "made by ai"
732
- "generated by claude" "generated by gpt" "generated by ai"
733
- "written by claude" "written by ai"
734
- "claude code" "claude opus" "claude sonnet" "claude haiku"
735
- "gpt-4" "gpt-3" "chatgpt"
736
- "@anthropic.com" "@openai.com"
737
- )
738
-
739
- for pattern in "\${AI_PATTERNS[@]}"; do
740
- if echo "$COMMIT_MSG" | grep -iqE "$pattern"; then
741
- echo ""
742
- echo "COMMIT BLOCKED: AI Attribution Detected"
743
- echo " Pattern: $pattern"
744
- echo " Remove AI attribution. You are the sole author."
745
- echo ""
746
- exit 1
747
- fi
748
- done
749
- exit 0
750
- `;
751
- export async function installCIHooks(projectDir) {
686
+ /**
687
+ * Classification of an existing `.git/hooks/<name>` before anything is written
688
+ * (design D6). Every state has exactly one write policy, so no hook is ever
689
+ * overwritten without a decision.
690
+ */
691
+ export const HOOK_STATE = {
692
+ ABSENT: "absent",
693
+ MANAGED_CURRENT: "managed-current",
694
+ MANAGED_OUTDATED: "managed-outdated",
695
+ MANAGED_EDITED: "managed-edited",
696
+ LEGACY_V0: "legacy-v0",
697
+ FOREIGN: "foreign",
698
+ SYMLINK: "symlink",
699
+ /** Exists but is not a regular file (directory, fifo, device) — never forceable. */
700
+ NOT_A_FILE: "not-a-file",
701
+ };
702
+ const HOOK_NAMES = ["pre-commit", "pre-push", "commit-msg"];
703
+ /** Bound on the `.bak.{epochMs}-{n}` ladder before a forced install gives up. */
704
+ const BACKUP_RETRY_BUDGET = 8;
705
+ const HOOK_MARKER_NAME_RE = /^# javi-forge-hook: (?<name>[a-z-]+) v(?<version>\d+)$/;
706
+ const HOOK_MARKER_HASH_RE = /^# javi-forge-hash: sha256:[0-9a-f]{64}$/;
707
+ function sha256Utf8(value) {
708
+ return createHash("sha256").update(Buffer.from(value, "utf8")).digest("hex");
709
+ }
710
+ function errorCode(error) {
711
+ return error && typeof error === "object" && "code" in error
712
+ ? String(error.code)
713
+ : "";
714
+ }
715
+ function isReleasedBody(entry, hash) {
716
+ return entry.historical.some((historical) => historical.sha256 === hash);
717
+ }
718
+ /**
719
+ * Pure classification of hook CONTENT (D6 steps 1-5).
720
+ *
721
+ * The hash input is the body BELOW the marker block — the shebang stays in the
722
+ * body, the two marker lines are removed — so a version bump alone does not
723
+ * invalidate installed hooks. Lines are split on `\n` WITHOUT stripping a
724
+ * trailing `\r`: a CRLF-converted file fails the `$`-anchored marker regex,
725
+ * takes the unmarked path and classifies `foreign`, which is correct — it no
726
+ * longer matches anything released. The hash CLAIMED by the marker is never
727
+ * trusted; classification always recomputes against the shipped manifest.
728
+ */
729
+ export function classifyHookContent(content, hookName, entry) {
730
+ const lines = content.split("\n");
731
+ const marker = HOOK_MARKER_NAME_RE.exec(lines[1] ?? "");
732
+ if (lines[0]?.startsWith("#!") === true &&
733
+ marker?.groups !== undefined &&
734
+ HOOK_MARKER_HASH_RE.test(lines[2] ?? "")) {
735
+ // The marker name is BOUND to the slot: someone else's marker never
736
+ // grants us permission to overwrite this file.
737
+ if (marker.groups.name !== hookName) {
738
+ return HOOK_STATE.FOREIGN;
739
+ }
740
+ const body = [lines[0], ...lines.slice(3)].join("\n");
741
+ const computed = sha256Utf8(body);
742
+ if (computed === entry.sha256) {
743
+ return Number(marker.groups.version) === entry.version
744
+ ? HOOK_STATE.MANAGED_CURRENT
745
+ : HOOK_STATE.MANAGED_OUTDATED;
746
+ }
747
+ return isReleasedBody(entry, computed)
748
+ ? HOOK_STATE.MANAGED_OUTDATED
749
+ : HOOK_STATE.MANAGED_EDITED;
750
+ }
751
+ // Unmarked: the whole file is the candidate body. Bytes identical to the
752
+ // CURRENT asset still classify legacy-v0 — the marker, not the content, is
753
+ // what makes a hook managed.
754
+ return isReleasedBody(entry, sha256Utf8(content))
755
+ ? HOOK_STATE.LEGACY_V0
756
+ : HOOK_STATE.FOREIGN;
757
+ }
758
+ async function lstatOrNull(target) {
759
+ try {
760
+ return await fs.lstat(target);
761
+ }
762
+ catch (statErr) {
763
+ if (errorCode(statErr) === "ENOENT") {
764
+ return null;
765
+ }
766
+ throw statErr;
767
+ }
768
+ }
769
+ /** D6 step 0 — `lstat` first; only a regular file is ever read. */
770
+ async function classifyHookPath(hookPath, hookName, entry) {
771
+ const stat = await lstatOrNull(hookPath);
772
+ if (stat === null) {
773
+ return HOOK_STATE.ABSENT;
774
+ }
775
+ if (stat.isSymbolicLink()) {
776
+ return HOOK_STATE.SYMLINK;
777
+ }
778
+ if (!stat.isFile()) {
779
+ return HOOK_STATE.NOT_A_FILE;
780
+ }
781
+ return classifyHookContent(await fs.readFile(hookPath, "utf8"), hookName, entry);
782
+ }
783
+ /**
784
+ * Splice the marker block in after the shebang. The body is written unmodified,
785
+ * so the round-trip is byte-exact and a re-install classifies `managed-current`
786
+ * with zero writes.
787
+ */
788
+ function renderHook(body, hookName, entry) {
789
+ const [shebang, ...rest] = body.split("\n");
790
+ return [
791
+ shebang,
792
+ `# javi-forge-hook: ${hookName} v${entry.version}`,
793
+ `# javi-forge-hash: sha256:${sha256Utf8(body)}`,
794
+ ...rest,
795
+ ].join("\n");
796
+ }
797
+ /**
798
+ * What `--force` would ACTUALLY do with the backup, for the refusal message.
799
+ *
800
+ * Naming `.bak` unconditionally would be a lie whenever `.bak` is already taken
801
+ * by an earlier backup — and promising a backup at all would be a lie whenever
802
+ * the `.bak` path is a symlink or a directory, because `backupHook` refuses
803
+ * those EVEN WITH `--force`. The probe is `lstat`, never `pathExists`: the
804
+ * latter follows symlinks, so it reports `true` for a symlink to an existing
805
+ * file and `false` for a dangling one — both readings promise a backup that
806
+ * will never happen.
807
+ */
808
+ async function describeBackupPlan(hookPath) {
809
+ const plain = `${hookPath}.bak`;
810
+ const existing = await lstatOrNull(plain);
811
+ if (existing !== null && (existing.isSymbolicLink() || !existing.isFile())) {
812
+ return `note that --force will REFUSE this hook until ${plain} is removed: it exists and is not a regular file, and javi-forge never writes a backup through it`;
813
+ }
814
+ const target = existing === null ? plain : `${plain}.<timestamp>`;
815
+ return `the current file is saved as ${target}`;
816
+ }
817
+ async function refusalMessage(hookPath, state) {
818
+ if (state === HOOK_STATE.SYMLINK) {
819
+ return `refusing to write through a symlink at ${hookPath}`;
820
+ }
821
+ if (state === HOOK_STATE.NOT_A_FILE) {
822
+ return `${hookPath} exists but is not a regular file. Refusing to overwrite; --force does not apply.`;
823
+ }
824
+ const plan = await describeBackupPlan(hookPath);
825
+ const reason = state === HOOK_STATE.MANAGED_EDITED
826
+ ? "carries a javi-forge marker but its contents were modified locally"
827
+ : "exists and is not a javi-forge hook (no marker, and its contents match no released javi-forge template)";
828
+ return `${hookPath} ${reason}. Refusing to overwrite. Inspect it, then re-run 'javi-forge ci init --force' (${plan}), or delete it.`;
829
+ }
830
+ /**
831
+ * Candidate backup targets, in order: `.bak`, `.bak.{epochMs}`, then
832
+ * `.bak.{epochMs}-{n}`. Bounded — a forced install never loops forever.
833
+ */
834
+ function* backupCandidates(hookPath) {
835
+ yield `${hookPath}.bak`;
836
+ const stamp = Date.now();
837
+ yield `${hookPath}.bak.${stamp}`;
838
+ for (let n = 1; n <= BACKUP_RETRY_BUDGET; n += 1) {
839
+ yield `${hookPath}.bak.${stamp}-${n}`;
840
+ }
841
+ }
842
+ /**
843
+ * Copy the hook to a `.bak` sibling BEFORE a forced overwrite (D4).
844
+ *
845
+ * The backup path is a write target and gets the same protection as the hook
846
+ * path: every candidate is `lstat`ed and a symlink or non-regular file is
847
+ * refused EVEN WITH `--force`, otherwise a planted
848
+ * `pre-commit.bak -> ~/.ssh/authorized_keys` would turn `--force` into an
849
+ * arbitrary-write primitive. Creation goes through `COPYFILE_EXCL`, so
850
+ * "does it exist?" and "create it" are one atomic step: a backup can never
851
+ * clobber an earlier backup, same-millisecond collisions are impossible, and a
852
+ * symlink planted between the `lstat` and the copy loses the race. The copy is
853
+ * of the ORIGINAL BYTES — never a utf8 round-trip, which would corrupt a
854
+ * non-UTF8 hook — and the original mode is restored so a restored backup is
855
+ * still executable.
856
+ *
857
+ * Throwing here ABORTS the hook: the caller never reaches its write.
858
+ */
859
+ async function backupHook(hookPath) {
860
+ const original = await fs.stat(hookPath);
861
+ for (const candidate of backupCandidates(hookPath)) {
862
+ const existing = await lstatOrNull(candidate);
863
+ if (existing !== null) {
864
+ if (existing.isSymbolicLink() || !existing.isFile()) {
865
+ throw new Error(`refusing to write the backup ${candidate}: it exists and is not a regular file. The hook was left unchanged.`);
866
+ }
867
+ // An earlier backup — keep it, try the next name.
868
+ continue;
869
+ }
870
+ try {
871
+ await fs.copyFile(hookPath, candidate, constants.COPYFILE_EXCL);
872
+ }
873
+ catch (copyErr) {
874
+ if (errorCode(copyErr) === "EEXIST") {
875
+ continue;
876
+ }
877
+ throw new Error(`could not write the backup ${candidate} (${errorCode(copyErr) || "unknown error"}): ${copyErr instanceof Error ? copyErr.message : String(copyErr)}. The hook was left unchanged.`);
878
+ }
879
+ await fs.chmod(candidate, original.mode);
880
+ return candidate;
881
+ }
882
+ throw new Error(`could not back up ${hookPath}: every candidate backup path is taken. The hook was left unchanged.`);
883
+ }
884
+ const MANIFEST_PATH = path.join(HOOK_ASSETS_DIR, "manifest.json");
885
+ /** The mode every installed hook ends up with — git ignores a non-executable hook. */
886
+ const HOOK_MODE = 0o755;
887
+ const REINSTALL_REMEDY = "reinstall javi-forge (npm i -g javi-forge) or repack it from source";
888
+ async function loadHookManifest() {
889
+ return (await fs.readJson(MANIFEST_PATH));
890
+ }
891
+ /**
892
+ * A broken INSTALL of javi-forge (missing, unreadable or truncated manifest) is
893
+ * an operator-actionable condition, not a stack trace: it surfaces as a named
894
+ * `errors[]` entry naming the path, the reason and the remedy. Without this the
895
+ * rejection escapes `installCIHooks` entirely and takes `handleCi` down with an
896
+ * unhandled promise rejection.
897
+ */
898
+ function assertHookManifestEntry(entry, hookName) {
899
+ const problem = entry === undefined || entry === null
900
+ ? `has no "${hookName}" entry`
901
+ : typeof entry.sha256 !== "string" ||
902
+ typeof entry.version !== "number" ||
903
+ !Array.isArray(entry.historical)
904
+ ? `has a malformed "${hookName}" entry (expected version:number, sha256:string, historical:array)`
905
+ : "";
906
+ if (problem !== "") {
907
+ throw new Error(`${MANIFEST_PATH} ${problem}. The javi-forge install is incomplete; ${REINSTALL_REMEDY}.`);
908
+ }
909
+ }
910
+ /**
911
+ * Bring an already-current hook back to mode 0755 WITHOUT writing bytes.
912
+ *
913
+ * The chmod is skipped when the mode already matches, so the common path does
914
+ * no syscall beyond the `lstat`; when it does run, `chmod` changes `ctime` only
915
+ * — `mtime` and the contents are untouched, so the zero-write idempotence
916
+ * contract survives. The path was classified as a regular file moments earlier;
917
+ * the residual TOCTOU window is the one documented for the write path itself.
918
+ */
919
+ async function repairHookMode(hookPath) {
920
+ const stat = await lstatOrNull(hookPath);
921
+ if (stat !== null && (stat.mode & 0o777) !== HOOK_MODE) {
922
+ await fs.chmod(hookPath, HOOK_MODE);
923
+ }
924
+ }
925
+ /**
926
+ * The hook body as SHIPPED. There is no inline copy of these templates any
927
+ * more: `assets/hooks/<name>` is the single source, hashed by the manifest and
928
+ * asserted present in the published tarball by the packaging check.
929
+ */
930
+ async function readHookBody(hookName) {
931
+ return await fs.readFile(path.join(HOOK_ASSETS_DIR, hookName), "utf8");
932
+ }
933
+ export async function installCIHooks(projectDir, options = {}) {
934
+ const force = options.force === true;
935
+ const empty = { installed: [], upgraded: [], backups: [], states: [] };
752
936
  const gitDir = path.join(projectDir, ".git");
753
937
  if (!(await fs.pathExists(gitDir))) {
754
938
  return {
755
- installed: [],
939
+ ...empty,
756
940
  errors: ["Not a git repository. Run git init first."],
757
941
  };
758
942
  }
759
943
  const hooksDir = path.join(gitDir, "hooks");
760
- await fs.ensureDir(hooksDir);
761
- const hooks = [
762
- { name: "pre-commit", content: PRE_COMMIT_HOOK },
763
- { name: "pre-push", content: PRE_PUSH_HOOK },
764
- { name: "commit-msg", content: COMMIT_MSG_HOOK },
765
- ];
944
+ let manifest;
945
+ try {
946
+ await fs.ensureDir(hooksDir);
947
+ }
948
+ catch (e) {
949
+ return {
950
+ ...empty,
951
+ errors: [
952
+ `could not create ${hooksDir} (${errorCode(e) || "unknown error"}): ${e instanceof Error ? e.message : String(e)}. No hook was installed.`,
953
+ ],
954
+ };
955
+ }
956
+ try {
957
+ manifest = await loadHookManifest();
958
+ }
959
+ catch (e) {
960
+ return {
961
+ ...empty,
962
+ errors: [
963
+ `could not read ${MANIFEST_PATH} (${errorCode(e) || "unknown error"}): ${e instanceof Error ? e.message : String(e)}. The javi-forge install is incomplete; ${REINSTALL_REMEDY}.`,
964
+ ],
965
+ };
966
+ }
766
967
  const installed = [];
968
+ const upgraded = [];
969
+ const backups = [];
767
970
  const errors = [];
768
- for (const hook of hooks) {
769
- const hookPath = path.join(hooksDir, hook.name);
971
+ const states = [];
972
+ for (const name of HOOK_NAMES) {
973
+ const hookPath = path.join(hooksDir, name);
974
+ const entry = manifest[name];
770
975
  try {
771
- // Refuse to follow symlinks. .git/hooks/<name> as a symlink to a
772
- // sensitive file (e.g., ~/.ssh/authorized_keys) would otherwise be
773
- // clobbered by writeFile. lstat reports the link, not the target.
774
- let isSymlink = false;
775
- try {
776
- const stat = await fs.lstat(hookPath);
777
- isSymlink = stat.isSymbolicLink();
976
+ assertHookManifestEntry(entry, name);
977
+ const state = await classifyHookPath(hookPath, name, entry);
978
+ states.push({ name, state });
979
+ if (state === HOOK_STATE.MANAGED_CURRENT) {
980
+ // Content is already correct, so nothing is REWRITTEN — but a hook
981
+ // stripped of its exec bit is dead weight (git skips it silently),
982
+ // so the mode is repaired in place. `chmod` leaves both the bytes
983
+ // and the mtime untouched, so idempotence still holds.
984
+ await repairHookMode(hookPath);
985
+ continue;
986
+ }
987
+ // A symlink or a non-regular path is refused ALWAYS — `--force` is
988
+ // consent to lose YOUR file, not permission to write through a link.
989
+ if (state === HOOK_STATE.SYMLINK || state === HOOK_STATE.NOT_A_FILE) {
990
+ throw new Error(await refusalMessage(hookPath, state));
778
991
  }
779
- catch (statErr) {
780
- const code = statErr && typeof statErr === "object" && "code" in statErr
781
- ? statErr.code
782
- : "";
783
- if (code !== "ENOENT") {
784
- throw statErr;
992
+ if (state === HOOK_STATE.MANAGED_EDITED || state === HOOK_STATE.FOREIGN) {
993
+ if (!force) {
994
+ throw new Error(await refusalMessage(hookPath, state));
785
995
  }
786
- // ENOENT is expected if the hook file doesn't exist yet.
996
+ // Backup FIRST. If it throws, the write below is never reached.
997
+ backups.push(await backupHook(hookPath));
998
+ }
999
+ const body = await readHookBody(name);
1000
+ // The `mode` option applies ONLY when the file is CREATED: overwriting
1001
+ // an existing 0644 hook would leave it non-executable and git would
1002
+ // silently skip it. The chmod is therefore unconditional, on every
1003
+ // write path, which also makes the mode independent of the umask.
1004
+ await fs.writeFile(hookPath, renderHook(body, name, entry), {
1005
+ mode: HOOK_MODE,
1006
+ });
1007
+ await fs.chmod(hookPath, HOOK_MODE);
1008
+ // `upgraded` means "javi-forge content was replaced by newer
1009
+ // javi-forge content". A forced overwrite of someone else's file is a
1010
+ // fresh install, not an upgrade.
1011
+ if (state === HOOK_STATE.MANAGED_OUTDATED ||
1012
+ state === HOOK_STATE.LEGACY_V0) {
1013
+ upgraded.push(name);
787
1014
  }
788
- if (isSymlink) {
789
- throw new Error(`refusing to write through a symlink at ${hookPath}`);
1015
+ else {
1016
+ installed.push(name);
790
1017
  }
791
- await fs.writeFile(hookPath, hook.content, { mode: 0o755 });
792
- installed.push(hook.name);
793
1018
  }
794
1019
  catch (e) {
795
- errors.push(`${hook.name}: ${e instanceof Error ? e.message : String(e)}`);
1020
+ errors.push(`${name}: ${e instanceof Error ? e.message : String(e)}`);
796
1021
  }
797
1022
  }
798
- return { installed, errors };
1023
+ return { installed, upgraded, backups, errors, states };
799
1024
  }
800
1025
  //# sourceMappingURL=ci.js.map
@@ -9,6 +9,8 @@ export declare const MODULES_DIR: string;
9
9
  export declare const WORKFLOWS_DIR: string;
10
10
  /** CI-local directory */
11
11
  export declare const CI_LOCAL_DIR: string;
12
+ /** Git hook assets directory (installed by `javi-forge ci init`) */
13
+ export declare const HOOK_ASSETS_DIR: string;
12
14
  /** Security hooks template directory */
13
15
  export declare const SECURITY_HOOKS_DIR: string;
14
16
  /** Local AI stack template directory */
package/dist/constants.js CHANGED
@@ -11,6 +11,8 @@ export const MODULES_DIR = path.join(FORGE_ROOT, "modules");
11
11
  export const WORKFLOWS_DIR = path.join(FORGE_ROOT, "workflows");
12
12
  /** CI-local directory */
13
13
  export const CI_LOCAL_DIR = path.join(FORGE_ROOT, "ci-local");
14
+ /** Git hook assets directory (installed by `javi-forge ci init`) */
15
+ export const HOOK_ASSETS_DIR = path.join(FORGE_ROOT, "assets", "hooks");
14
16
  /** Security hooks template directory */
15
17
  export const SECURITY_HOOKS_DIR = path.join(TEMPLATES_DIR, "security-hooks");
16
18
  /** Local AI stack template directory */
package/package.json CHANGED
@@ -1,94 +1,95 @@
1
1
  {
2
- "name": "javi-forge",
3
- "version": "1.7.1",
4
- "description": "Project scaffolding and AI-ready CI bootstrap",
5
- "type": "module",
6
- "bin": {
7
- "javi-forge": "dist/index.js"
8
- },
9
- "scripts": {
10
- "build": "tsc",
11
- "dev": "tsx watch src/index.tsx",
12
- "start": "node dist/index.js",
13
- "typecheck": "tsc --noEmit",
14
- "typecheck:test": "tsc --project tsconfig.test.json --noEmit",
15
- "test": "vitest run",
16
- "test:watch": "vitest",
17
- "test:coverage": "vitest run --coverage",
18
- "test:mutation": "stryker run",
19
- "test:hooks": "bash ci-local/hooks/commit-msg.test.sh",
20
- "lint": "biome check src/",
21
- "lint:fix": "biome check --write src/",
22
- "format": "biome format --write src/",
23
- "validate": "pnpm typecheck && pnpm typecheck:test && pnpm lint && pnpm test",
24
- "package:check": "npm pack --dry-run --json --cache /tmp/javi-forge-npm-pack-cache > /tmp/javi-forge-pack.json && node scripts/verify-package-contents.mjs /tmp/javi-forge-pack.json"
25
- },
26
- "files": [
27
- "dist/**/*.js",
28
- "dist/**/*.d.ts",
29
- "!dist/__integration__/**",
30
- "!dist/e2e/**",
31
- "ci-local/",
32
- "modules/",
33
- "templates/",
34
- "workflows/",
35
- "lib/",
36
- "README.md",
37
- "LICENSE*",
38
- ".gitignore.template"
39
- ],
40
- "author": "JNZader",
41
- "license": "MIT",
42
- "repository": {
43
- "type": "git",
44
- "url": "git+https://github.com/JNZader/javi-forge.git"
45
- },
46
- "engines": {
47
- "node": ">=22"
48
- },
49
- "dependencies": {
50
- "chalk": "^5.6.2",
51
- "fs-extra": "^11.3.5",
52
- "glob": "^13.0.6",
53
- "ink": "^7.0.3",
54
- "ink-spinner": "^5.0.0",
55
- "meow": "^14.1.0",
56
- "react": "^19.2.6",
57
- "update-notifier": "^7.3.1",
58
- "yaml": "^2.9.0"
59
- },
60
- "devDependencies": {
61
- "@biomejs/biome": "^2.4.15",
62
- "@semantic-release/changelog": "^6.0.3",
63
- "@semantic-release/exec": "^7.1.0",
64
- "@semantic-release/git": "^10.0.1",
65
- "@semantic-release/github": "^12.0.8",
66
- "@stryker-mutator/core": "^9.6.1",
67
- "@stryker-mutator/typescript-checker": "^9.6.1",
68
- "@stryker-mutator/vitest-runner": "^9.6.1",
69
- "@types/fs-extra": "^11.0.4",
70
- "@types/node": "^25.8.0",
71
- "@types/react": "^19.2.14",
72
- "@types/update-notifier": "^6.0.8",
73
- "@vitest/coverage-v8": "^4.1.6",
74
- "conventional-changelog-conventionalcommits": "^9.3.1",
75
- "ink-testing-library": "^4.0.0",
76
- "semantic-release": "^25.0.3",
77
- "tsx": "^4.22.1",
78
- "typescript": "^6.0.3",
79
- "vite": "^8.0.13",
80
- "vitest": "^4.1.6"
81
- },
82
- "pnpm": {
83
- "overrides": {
84
- "handlebars": "^4.7.9",
85
- "picomatch": "^4.0.4",
86
- "brace-expansion": "^5.0.5",
87
- "lodash": "^4.18.0",
88
- "lodash-es": "^4.18.0",
89
- "fast-uri": "^3.1.2",
90
- "postcss": "^8.5.10",
91
- "vite": "^8.0.5"
92
- }
93
- }
2
+ "name": "javi-forge",
3
+ "version": "1.9.0",
4
+ "description": "Project scaffolding and AI-ready CI bootstrap",
5
+ "type": "module",
6
+ "bin": {
7
+ "javi-forge": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx watch src/index.tsx",
12
+ "start": "node dist/index.js",
13
+ "typecheck": "tsc --noEmit",
14
+ "typecheck:test": "tsc --project tsconfig.test.json --noEmit",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "test:coverage": "vitest run --coverage",
18
+ "test:mutation": "stryker run",
19
+ "test:hooks": "bash ci-local/hooks/commit-msg.test.sh",
20
+ "lint": "biome check src/",
21
+ "lint:fix": "biome check --write src/",
22
+ "format": "biome format --write src/",
23
+ "validate": "pnpm typecheck && pnpm typecheck:test && pnpm lint && pnpm test",
24
+ "package:check": "npm pack --dry-run --json --cache /tmp/javi-forge-npm-pack-cache > /tmp/javi-forge-pack.json && node scripts/verify-package-contents.mjs /tmp/javi-forge-pack.json"
25
+ },
26
+ "files": [
27
+ "dist/**/*.js",
28
+ "dist/**/*.d.ts",
29
+ "!dist/__integration__/**",
30
+ "!dist/e2e/**",
31
+ "assets/",
32
+ "ci-local/",
33
+ "modules/",
34
+ "templates/",
35
+ "workflows/",
36
+ "lib/",
37
+ "README.md",
38
+ "LICENSE*",
39
+ ".gitignore.template"
40
+ ],
41
+ "author": "JNZader",
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/JNZader/javi-forge.git"
46
+ },
47
+ "engines": {
48
+ "node": ">=22"
49
+ },
50
+ "dependencies": {
51
+ "chalk": "^5.6.2",
52
+ "fs-extra": "^11.3.5",
53
+ "glob": "^13.0.6",
54
+ "ink": "^7.0.3",
55
+ "ink-spinner": "^5.0.0",
56
+ "meow": "^14.1.0",
57
+ "react": "^19.2.6",
58
+ "update-notifier": "^7.3.1",
59
+ "yaml": "^2.9.0"
60
+ },
61
+ "devDependencies": {
62
+ "@biomejs/biome": "^2.4.15",
63
+ "@semantic-release/changelog": "^6.0.3",
64
+ "@semantic-release/exec": "^7.1.0",
65
+ "@semantic-release/git": "^10.0.1",
66
+ "@semantic-release/github": "^12.0.8",
67
+ "@stryker-mutator/core": "^9.6.1",
68
+ "@stryker-mutator/typescript-checker": "^9.6.1",
69
+ "@stryker-mutator/vitest-runner": "^9.6.1",
70
+ "@types/fs-extra": "^11.0.4",
71
+ "@types/node": "^25.8.0",
72
+ "@types/react": "^19.2.14",
73
+ "@types/update-notifier": "^6.0.8",
74
+ "@vitest/coverage-v8": "^4.1.6",
75
+ "conventional-changelog-conventionalcommits": "^9.3.1",
76
+ "ink-testing-library": "^4.0.0",
77
+ "semantic-release": "^25.0.3",
78
+ "tsx": "^4.22.1",
79
+ "typescript": "^6.0.3",
80
+ "vite": "^8.0.13",
81
+ "vitest": "^4.1.6"
82
+ },
83
+ "pnpm": {
84
+ "overrides": {
85
+ "handlebars": "^4.7.9",
86
+ "picomatch": "^4.0.4",
87
+ "brace-expansion": "^5.0.5",
88
+ "lodash": "^4.18.0",
89
+ "lodash-es": "^4.18.0",
90
+ "fast-uri": "^3.1.2",
91
+ "postcss": "^8.5.10",
92
+ "vite": "^8.0.5"
93
+ }
94
+ }
94
95
  }