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.
- package/assets/hooks/commit-msg +30 -0
- package/assets/hooks/manifest.json +32 -0
- package/assets/hooks/pre-commit +15 -0
- package/assets/hooks/pre-push +20 -0
- package/dist/cli/dispatch/ci.js +12 -1
- package/dist/cli/help.d.ts +5 -1
- package/dist/cli/help.js +8 -0
- package/dist/commands/ci.d.ts +53 -2
- package/dist/commands/ci.js +383 -107
- package/package.json +93 -92
|
@@ -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
|
+
}
|
package/dist/cli/dispatch/ci.js
CHANGED
|
@@ -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
|
}
|
package/dist/cli/help.d.ts
CHANGED
|
@@ -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 },
|
package/dist/commands/ci.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/commands/ci.js
CHANGED
|
@@ -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
|
-
/**
|
|
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"
|
|
299
|
+
if (resolved.source === "auto") {
|
|
288
300
|
return `Stack: ${first.stack} (${first.buildTool})`;
|
|
289
301
|
}
|
|
290
|
-
if (resolved.source === "stack-override"
|
|
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
|
|
320
|
-
buildTool: primary
|
|
321
|
-
javaVersion: primary
|
|
322
|
-
lintCmd: primary
|
|
323
|
-
compileCmd: primary
|
|
324
|
-
testCmd: primary
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
)
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
769
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
789
|
-
|
|
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(`${
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
}
|