javi-forge 1.8.0 → 1.9.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.
@@ -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,10 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import fsp from "node:fs/promises";
2
5
  import path from "node:path";
3
6
  import fs from "fs-extra";
7
+ import { HOOK_ASSETS_DIR } from "../constants.js";
4
8
  import { CI_STACKS, findCIConfig, loadCIConfig, } from "../lib/ci-config.js";
5
9
  import { refreshContextDir } from "../lib/context.js";
6
10
  import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
@@ -281,13 +285,21 @@ async function isGhaggaAvailable() {
281
285
  function report(onStep, id, label, status, detail) {
282
286
  onStep({ id, label, status, detail });
283
287
  }
284
- /** Detect-step label: legacy format for auto, explicit otherwise. */
288
+ /**
289
+ * Detect-step label: legacy format for auto, explicit otherwise.
290
+ *
291
+ * INVARIANT (holds for every `ResolvedRunners` value): `runners` is never
292
+ * empty. The config path rejects an empty list before resolving
293
+ * (`src/lib/ci-config.ts` — "runners is required and must be a non-empty
294
+ * list"), and the `auto` and `stack-override` paths each yield exactly one
295
+ * runner. `runners[0]` therefore needs no fallback.
296
+ */
285
297
  function describeRunners(resolved) {
286
298
  const first = resolved.runners[0];
287
- if (resolved.source === "auto" && first) {
299
+ if (resolved.source === "auto") {
288
300
  return `Stack: ${first.stack} (${first.buildTool})`;
289
301
  }
290
- if (resolved.source === "stack-override" && first) {
302
+ if (resolved.source === "stack-override") {
291
303
  return `Stack: ${first.stack} (${first.buildTool}, --stack override)`;
292
304
  }
293
305
  const summary = resolved.runners
@@ -314,14 +326,16 @@ export async function runCI(options, onStep) {
314
326
  }
315
327
  // Legacy single-runner view for the zero-config auto path. Keeping this
316
328
  // shape guarantees single-stack repositories behave exactly as before.
329
+ // `runners[0]` is always present — see the invariant on `describeRunners`.
330
+ // The command lists CAN be empty, so those keep their `?? null`.
317
331
  const primary = resolved.runners[0];
318
332
  const stackInfo = {
319
- stackType: primary?.stack ?? "node",
320
- buildTool: primary?.buildTool ?? "npm",
321
- javaVersion: primary?.javaVersion ?? "21",
322
- lintCmd: primary?.lintCmds[0] ?? null,
323
- compileCmd: primary?.compileCmds[0] ?? null,
324
- testCmd: primary?.testCmds[0] ?? null,
333
+ stackType: primary.stack,
334
+ buildTool: primary.buildTool,
335
+ javaVersion: primary.javaVersion,
336
+ lintCmd: primary.lintCmds[0] ?? null,
337
+ compileCmd: primary.compileCmds[0] ?? null,
338
+ testCmd: primary.testCmds[0] ?? null,
325
339
  };
326
340
  // ── Detect mode ─────────────────────────────────────────────────────────────
327
341
  if (mode === "detect")
@@ -680,121 +694,383 @@ async function runGhagga(projectDir) {
680
694
  // =============================================================================
681
695
  // CI Hooks Installation
682
696
  // =============================================================================
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) {
697
+ /**
698
+ * Classification of an existing `.git/hooks/<name>` before anything is written
699
+ * (design D6). Every state has exactly one write policy, so no hook is ever
700
+ * overwritten without a decision.
701
+ */
702
+ export const HOOK_STATE = {
703
+ ABSENT: "absent",
704
+ MANAGED_CURRENT: "managed-current",
705
+ MANAGED_OUTDATED: "managed-outdated",
706
+ MANAGED_EDITED: "managed-edited",
707
+ LEGACY_V0: "legacy-v0",
708
+ FOREIGN: "foreign",
709
+ SYMLINK: "symlink",
710
+ /** Exists but is not a regular file (directory, fifo, device) — never forceable. */
711
+ NOT_A_FILE: "not-a-file",
712
+ };
713
+ const HOOK_NAMES = ["pre-commit", "pre-push", "commit-msg"];
714
+ /** Bound on the `.bak.{epochMs}-{n}` ladder before a forced install gives up. */
715
+ const BACKUP_RETRY_BUDGET = 8;
716
+ const HOOK_MARKER_NAME_RE = /^# javi-forge-hook: (?<name>[a-z-]+) v(?<version>\d+)$/;
717
+ const HOOK_MARKER_HASH_RE = /^# javi-forge-hash: sha256:[0-9a-f]{64}$/;
718
+ function sha256Utf8(value) {
719
+ return createHash("sha256").update(Buffer.from(value, "utf8")).digest("hex");
720
+ }
721
+ function errorCode(error) {
722
+ return error && typeof error === "object" && "code" in error
723
+ ? String(error.code)
724
+ : "";
725
+ }
726
+ function isReleasedBody(entry, hash) {
727
+ return entry.historical.some((historical) => historical.sha256 === hash);
728
+ }
729
+ /**
730
+ * Pure classification of hook CONTENT (D6 steps 1-5).
731
+ *
732
+ * The hash input is the body BELOW the marker block — the shebang stays in the
733
+ * body, the two marker lines are removed — so a version bump alone does not
734
+ * invalidate installed hooks. Lines are split on `\n` WITHOUT stripping a
735
+ * trailing `\r`: a CRLF-converted file fails the `$`-anchored marker regex,
736
+ * takes the unmarked path and classifies `foreign`, which is correct — it no
737
+ * longer matches anything released. The hash CLAIMED by the marker is never
738
+ * trusted; classification always recomputes against the shipped manifest.
739
+ */
740
+ export function classifyHookContent(content, hookName, entry) {
741
+ const lines = content.split("\n");
742
+ const marker = HOOK_MARKER_NAME_RE.exec(lines[1] ?? "");
743
+ if (lines[0]?.startsWith("#!") === true &&
744
+ marker?.groups !== undefined &&
745
+ HOOK_MARKER_HASH_RE.test(lines[2] ?? "")) {
746
+ // The marker name is BOUND to the slot: someone else's marker never
747
+ // grants us permission to overwrite this file.
748
+ if (marker.groups.name !== hookName) {
749
+ return HOOK_STATE.FOREIGN;
750
+ }
751
+ const body = [lines[0], ...lines.slice(3)].join("\n");
752
+ const computed = sha256Utf8(body);
753
+ if (computed === entry.sha256) {
754
+ return Number(marker.groups.version) === entry.version
755
+ ? HOOK_STATE.MANAGED_CURRENT
756
+ : HOOK_STATE.MANAGED_OUTDATED;
757
+ }
758
+ return isReleasedBody(entry, computed)
759
+ ? HOOK_STATE.MANAGED_OUTDATED
760
+ : HOOK_STATE.MANAGED_EDITED;
761
+ }
762
+ // Unmarked: the whole file is the candidate body. Bytes identical to the
763
+ // CURRENT asset still classify legacy-v0 — the marker, not the content, is
764
+ // what makes a hook managed.
765
+ return isReleasedBody(entry, sha256Utf8(content))
766
+ ? HOOK_STATE.LEGACY_V0
767
+ : HOOK_STATE.FOREIGN;
768
+ }
769
+ async function lstatOrNull(target) {
770
+ try {
771
+ return await fs.lstat(target);
772
+ }
773
+ catch (statErr) {
774
+ if (errorCode(statErr) === "ENOENT") {
775
+ return null;
776
+ }
777
+ throw statErr;
778
+ }
779
+ }
780
+ /** D6 step 0 — `lstat` first; only a regular file is ever read. */
781
+ async function classifyHookPath(hookPath, hookName, entry) {
782
+ const stat = await lstatOrNull(hookPath);
783
+ if (stat === null) {
784
+ return HOOK_STATE.ABSENT;
785
+ }
786
+ if (stat.isSymbolicLink()) {
787
+ return HOOK_STATE.SYMLINK;
788
+ }
789
+ if (!stat.isFile()) {
790
+ return HOOK_STATE.NOT_A_FILE;
791
+ }
792
+ return classifyHookContent(await fs.readFile(hookPath, "utf8"), hookName, entry);
793
+ }
794
+ /**
795
+ * Splice the marker block in after the shebang. The body is written unmodified,
796
+ * so the round-trip is byte-exact and a re-install classifies `managed-current`
797
+ * with zero writes.
798
+ */
799
+ function renderHook(body, hookName, entry) {
800
+ const [shebang, ...rest] = body.split("\n");
801
+ return [
802
+ shebang,
803
+ `# javi-forge-hook: ${hookName} v${entry.version}`,
804
+ `# javi-forge-hash: sha256:${sha256Utf8(body)}`,
805
+ ...rest,
806
+ ].join("\n");
807
+ }
808
+ /**
809
+ * What `--force` would ACTUALLY do with the backup, for the refusal message.
810
+ *
811
+ * Naming `.bak` unconditionally would be a lie whenever `.bak` is already taken
812
+ * by an earlier backup — and promising a backup at all would be a lie whenever
813
+ * the `.bak` path is a symlink or a directory, because `backupHook` refuses
814
+ * those EVEN WITH `--force`. The probe is `lstat`, never `pathExists`: the
815
+ * latter follows symlinks, so it reports `true` for a symlink to an existing
816
+ * file and `false` for a dangling one — both readings promise a backup that
817
+ * will never happen.
818
+ */
819
+ async function describeBackupPlan(hookPath) {
820
+ const plain = `${hookPath}.bak`;
821
+ const existing = await lstatOrNull(plain);
822
+ if (existing !== null && (existing.isSymbolicLink() || !existing.isFile())) {
823
+ 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`;
824
+ }
825
+ const target = existing === null ? plain : `${plain}.<timestamp>`;
826
+ return `the current file is saved as ${target}`;
827
+ }
828
+ async function refusalMessage(hookPath, state) {
829
+ if (state === HOOK_STATE.SYMLINK) {
830
+ return `refusing to write through a symlink at ${hookPath}`;
831
+ }
832
+ if (state === HOOK_STATE.NOT_A_FILE) {
833
+ return `${hookPath} exists but is not a regular file. Refusing to overwrite; --force does not apply.`;
834
+ }
835
+ const plan = await describeBackupPlan(hookPath);
836
+ const reason = state === HOOK_STATE.MANAGED_EDITED
837
+ ? "carries a javi-forge marker but its contents were modified locally"
838
+ : "exists and is not a javi-forge hook (no marker, and its contents match no released javi-forge template)";
839
+ return `${hookPath} ${reason}. Refusing to overwrite. Inspect it, then re-run 'javi-forge ci init --force' (${plan}), or delete it.`;
840
+ }
841
+ /**
842
+ * Candidate backup targets, in order: `.bak`, `.bak.{epochMs}`, then
843
+ * `.bak.{epochMs}-{n}`. Bounded — a forced install never loops forever.
844
+ */
845
+ function* backupCandidates(hookPath) {
846
+ yield `${hookPath}.bak`;
847
+ const stamp = Date.now();
848
+ yield `${hookPath}.bak.${stamp}`;
849
+ for (let n = 1; n <= BACKUP_RETRY_BUDGET; n += 1) {
850
+ yield `${hookPath}.bak.${stamp}-${n}`;
851
+ }
852
+ }
853
+ /**
854
+ * Copy the hook to a `.bak` sibling BEFORE a forced overwrite (D4).
855
+ *
856
+ * The backup path is a write target and gets the same protection as the hook
857
+ * path: every candidate is `lstat`ed and a symlink or non-regular file is
858
+ * refused EVEN WITH `--force`, otherwise a planted
859
+ * `pre-commit.bak -> ~/.ssh/authorized_keys` would turn `--force` into an
860
+ * arbitrary-write primitive. Creation goes through `COPYFILE_EXCL`, so
861
+ * "does it exist?" and "create it" are one atomic step: a backup can never
862
+ * clobber an earlier backup, same-millisecond collisions are impossible, and a
863
+ * symlink planted between the `lstat` and the copy loses the race. The copy is
864
+ * of the ORIGINAL BYTES — never a utf8 round-trip, which would corrupt a
865
+ * non-UTF8 hook — and the original mode is restored so a restored backup is
866
+ * still executable.
867
+ *
868
+ * Throwing here ABORTS the hook: the caller never reaches its write.
869
+ */
870
+ async function backupHook(hookPath) {
871
+ const original = await fs.stat(hookPath);
872
+ for (const candidate of backupCandidates(hookPath)) {
873
+ const existing = await lstatOrNull(candidate);
874
+ if (existing !== null) {
875
+ if (existing.isSymbolicLink() || !existing.isFile()) {
876
+ throw new Error(`refusing to write the backup ${candidate}: it exists and is not a regular file. The hook was left unchanged.`);
877
+ }
878
+ // An earlier backup — keep it, try the next name.
879
+ continue;
880
+ }
881
+ try {
882
+ await fs.copyFile(hookPath, candidate, constants.COPYFILE_EXCL);
883
+ }
884
+ catch (copyErr) {
885
+ if (errorCode(copyErr) === "EEXIST") {
886
+ continue;
887
+ }
888
+ 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.`);
889
+ }
890
+ // The mode restore addresses the FD of the file THIS call just created,
891
+ // not the path: a symlink planted at `candidate` after the
892
+ // `COPYFILE_EXCL` copy cannot capture the mode change (SEC-1).
893
+ const handle = await fsp.open(candidate, constants.O_RDONLY | O_NOFOLLOW);
894
+ try {
895
+ await handle.chmod(original.mode);
896
+ }
897
+ finally {
898
+ await handle.close();
899
+ }
900
+ return candidate;
901
+ }
902
+ throw new Error(`could not back up ${hookPath}: every candidate backup path is taken. The hook was left unchanged.`);
903
+ }
904
+ const MANIFEST_PATH = path.join(HOOK_ASSETS_DIR, "manifest.json");
905
+ /** The mode every installed hook ends up with — git ignores a non-executable hook. */
906
+ const HOOK_MODE = 0o755;
907
+ const REINSTALL_REMEDY = "reinstall javi-forge (npm i -g javi-forge) or repack it from source";
908
+ async function loadHookManifest() {
909
+ return (await fs.readJson(MANIFEST_PATH));
910
+ }
911
+ /**
912
+ * A broken INSTALL of javi-forge (missing, unreadable or truncated manifest) is
913
+ * an operator-actionable condition, not a stack trace: it surfaces as a named
914
+ * `errors[]` entry naming the path, the reason and the remedy. Without this the
915
+ * rejection escapes `installCIHooks` entirely and takes `handleCi` down with an
916
+ * unhandled promise rejection.
917
+ */
918
+ function assertHookManifestEntry(entry, hookName) {
919
+ const problem = entry === undefined || entry === null
920
+ ? `has no "${hookName}" entry`
921
+ : typeof entry.sha256 !== "string" ||
922
+ typeof entry.version !== "number" ||
923
+ !Array.isArray(entry.historical) ||
924
+ !entry.historical.every((h) => typeof h?.sha256 === "string")
925
+ ? `has a malformed "${hookName}" entry (expected version:number, sha256:string, historical:array of {sha256:string})`
926
+ : "";
927
+ if (problem !== "") {
928
+ throw new Error(`${MANIFEST_PATH} ${problem}. The javi-forge install is incomplete; ${REINSTALL_REMEDY}.`);
929
+ }
930
+ }
931
+ /**
932
+ * `O_NOFOLLOW` on the platforms that have it, a no-op flag elsewhere. Windows
933
+ * has no such flag AND no hook-symlink threat worth the crash of an
934
+ * `undefined` in a bitmask.
935
+ */
936
+ const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
937
+ /**
938
+ * Write the hook through a FILE DESCRIPTOR, never through the path (SEC-1).
939
+ *
940
+ * `classifyHookPath` established moments earlier that `hookPath` is a regular
941
+ * file, but a local attacker with write access to `.git/hooks` could swap a
942
+ * symlink in during that window and turn the write into an arbitrary-write
943
+ * primitive. `O_NOFOLLOW` closes it: if the path IS a symlink when the write
944
+ * finally happens, `open` fails with `ELOOP` and the hook surfaces as a named
945
+ * per-hook error instead of clobbering the link target. Everything after the
946
+ * open then addresses the FD — `fchmod`, not a second path lookup — so the
947
+ * bytes and the mode provably land on the same inode.
948
+ *
949
+ * The mode argument applies ONLY when the file is CREATED: overwriting an
950
+ * existing 0644 hook would leave it non-executable and git would silently skip
951
+ * it. The `fchmod` is therefore unconditional, on every write path, which also
952
+ * makes the final mode independent of the umask.
953
+ *
954
+ * NOT covered (deferred by decision in SEC-1): a hardlink to a victim file
955
+ * survives `O_NOFOLLOW`. On modern Linux `fs.protected_hardlinks=1` blocks the
956
+ * cross-owner case; an `nlink > 1` refusal is parked in the backlog.
957
+ */
958
+ async function writeHookFile(hookPath, content) {
959
+ const handle = await fsp.open(hookPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | O_NOFOLLOW, HOOK_MODE);
960
+ try {
961
+ await handle.writeFile(content, "utf8");
962
+ await handle.chmod(HOOK_MODE);
963
+ }
964
+ finally {
965
+ await handle.close();
966
+ }
967
+ }
968
+ /**
969
+ * Bring an already-current hook back to mode 0755 WITHOUT writing bytes.
970
+ *
971
+ * The chmod is skipped when the mode already matches, so the common path does
972
+ * no syscall beyond the `lstat`; when it does run, `chmod` changes `ctime` only
973
+ * — `mtime` and the contents are untouched, so the zero-write idempotence
974
+ * contract survives. The path was classified as a regular file moments earlier;
975
+ * the residual TOCTOU window is the one documented for the write path itself.
976
+ */
977
+ async function repairHookMode(hookPath) {
978
+ const stat = await lstatOrNull(hookPath);
979
+ if (stat !== null && (stat.mode & 0o777) !== HOOK_MODE) {
980
+ await fs.chmod(hookPath, HOOK_MODE);
981
+ }
982
+ }
983
+ /**
984
+ * The hook body as SHIPPED. There is no inline copy of these templates any
985
+ * more: `assets/hooks/<name>` is the single source, hashed by the manifest and
986
+ * asserted present in the published tarball by the packaging check.
987
+ */
988
+ async function readHookBody(hookName) {
989
+ return await fs.readFile(path.join(HOOK_ASSETS_DIR, hookName), "utf8");
990
+ }
991
+ export async function installCIHooks(projectDir, options = {}) {
992
+ const force = options.force === true;
993
+ const empty = { installed: [], upgraded: [], backups: [], states: [] };
752
994
  const gitDir = path.join(projectDir, ".git");
753
995
  if (!(await fs.pathExists(gitDir))) {
754
996
  return {
755
- installed: [],
997
+ ...empty,
756
998
  errors: ["Not a git repository. Run git init first."],
757
999
  };
758
1000
  }
759
1001
  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
- ];
1002
+ let manifest;
1003
+ try {
1004
+ await fs.ensureDir(hooksDir);
1005
+ }
1006
+ catch (e) {
1007
+ return {
1008
+ ...empty,
1009
+ errors: [
1010
+ `could not create ${hooksDir} (${errorCode(e) || "unknown error"}): ${e instanceof Error ? e.message : String(e)}. No hook was installed.`,
1011
+ ],
1012
+ };
1013
+ }
1014
+ try {
1015
+ manifest = await loadHookManifest();
1016
+ }
1017
+ catch (e) {
1018
+ return {
1019
+ ...empty,
1020
+ errors: [
1021
+ `could not read ${MANIFEST_PATH} (${errorCode(e) || "unknown error"}): ${e instanceof Error ? e.message : String(e)}. The javi-forge install is incomplete; ${REINSTALL_REMEDY}.`,
1022
+ ],
1023
+ };
1024
+ }
766
1025
  const installed = [];
1026
+ const upgraded = [];
1027
+ const backups = [];
767
1028
  const errors = [];
768
- for (const hook of hooks) {
769
- const hookPath = path.join(hooksDir, hook.name);
1029
+ const states = [];
1030
+ for (const name of HOOK_NAMES) {
1031
+ const hookPath = path.join(hooksDir, name);
1032
+ const entry = manifest[name];
770
1033
  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();
1034
+ assertHookManifestEntry(entry, name);
1035
+ const state = await classifyHookPath(hookPath, name, entry);
1036
+ states.push({ name, state });
1037
+ if (state === HOOK_STATE.MANAGED_CURRENT) {
1038
+ // Content is already correct, so nothing is REWRITTEN — but a hook
1039
+ // stripped of its exec bit is dead weight (git skips it silently),
1040
+ // so the mode is repaired in place. `chmod` leaves both the bytes
1041
+ // and the mtime untouched, so idempotence still holds.
1042
+ await repairHookMode(hookPath);
1043
+ continue;
1044
+ }
1045
+ // A symlink or a non-regular path is refused ALWAYS — `--force` is
1046
+ // consent to lose YOUR file, not permission to write through a link.
1047
+ if (state === HOOK_STATE.SYMLINK || state === HOOK_STATE.NOT_A_FILE) {
1048
+ throw new Error(await refusalMessage(hookPath, state));
778
1049
  }
779
- catch (statErr) {
780
- const code = statErr && typeof statErr === "object" && "code" in statErr
781
- ? statErr.code
782
- : "";
783
- if (code !== "ENOENT") {
784
- throw statErr;
1050
+ if (state === HOOK_STATE.MANAGED_EDITED || state === HOOK_STATE.FOREIGN) {
1051
+ if (!force) {
1052
+ throw new Error(await refusalMessage(hookPath, state));
785
1053
  }
786
- // ENOENT is expected if the hook file doesn't exist yet.
1054
+ // Backup FIRST. If it throws, the write below is never reached.
1055
+ backups.push(await backupHook(hookPath));
1056
+ }
1057
+ const body = await readHookBody(name);
1058
+ await writeHookFile(hookPath, renderHook(body, name, entry));
1059
+ // `upgraded` means "javi-forge content was replaced by newer
1060
+ // javi-forge content". A forced overwrite of someone else's file is a
1061
+ // fresh install, not an upgrade.
1062
+ if (state === HOOK_STATE.MANAGED_OUTDATED ||
1063
+ state === HOOK_STATE.LEGACY_V0) {
1064
+ upgraded.push(name);
787
1065
  }
788
- if (isSymlink) {
789
- throw new Error(`refusing to write through a symlink at ${hookPath}`);
1066
+ else {
1067
+ installed.push(name);
790
1068
  }
791
- await fs.writeFile(hookPath, hook.content, { mode: 0o755 });
792
- installed.push(hook.name);
793
1069
  }
794
1070
  catch (e) {
795
- errors.push(`${hook.name}: ${e instanceof Error ? e.message : String(e)}`);
1071
+ errors.push(`${name}: ${e instanceof Error ? e.message : String(e)}`);
796
1072
  }
797
1073
  }
798
- return { installed, errors };
1074
+ return { installed, upgraded, backups, errors, states };
799
1075
  }
800
1076
  //# sourceMappingURL=ci.js.map
package/package.json CHANGED
@@ -1,94 +1,95 @@
1
1
  {
2
- "name": "javi-forge",
3
- "version": "1.8.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
- "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.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
+ "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
  }