javi-forge 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/cli/dispatch/tdd.d.ts +6 -3
  2. package/dist/cli/dispatch/tdd.js +62 -31
  3. package/dist/cli/help.d.ts +1 -1
  4. package/dist/cli/help.js +2 -2
  5. package/dist/commands/ci.js +14 -4
  6. package/dist/commands/doctor.d.ts +8 -0
  7. package/dist/commands/doctor.js +112 -0
  8. package/dist/commands/hooks/sections/deps.d.ts +28 -0
  9. package/dist/commands/hooks/sections/deps.js +126 -0
  10. package/dist/commands/hooks/sections/permissions.d.ts +33 -0
  11. package/dist/commands/hooks/sections/permissions.js +101 -0
  12. package/dist/commands/hooks/sections/secrets.d.ts +58 -0
  13. package/dist/commands/hooks/sections/secrets.js +182 -0
  14. package/dist/commands/hooks.d.ts +39 -3
  15. package/dist/commands/hooks.js +93 -6
  16. package/dist/commands/init/steps/security.d.ts +10 -20
  17. package/dist/commands/init/steps/security.js +58 -78
  18. package/dist/commands/init.js +1 -2
  19. package/dist/commands/tdd.d.ts +0 -14
  20. package/dist/commands/tdd.js +0 -88
  21. package/dist/constants.d.ts +6 -1
  22. package/dist/constants.js +12 -7
  23. package/dist/lib/ci-config.d.ts +10 -0
  24. package/dist/lib/ci-config.js +33 -0
  25. package/dist/types/index.d.ts +0 -7
  26. package/package.json +1 -1
  27. package/dist/commands/tdd-pipeline.d.ts +0 -17
  28. package/dist/commands/tdd-pipeline.js +0 -144
  29. package/templates/security-hooks/commit-msg-signing +0 -29
  30. package/templates/security-hooks/pre-commit-permissions +0 -74
  31. package/templates/security-hooks/pre-commit-secrets +0 -74
  32. package/templates/security-hooks/pre-push-branch-protection +0 -62
  33. package/templates/security-hooks/pre-push-deps +0 -83
  34. package/templates/security-hooks/pre-push-signing +0 -67
@@ -2,9 +2,12 @@
2
2
  * `javi-forge tdd <init|pipeline>` handler.
3
3
  *
4
4
  * Console-only (no Ink, no React, no CIContextProvider).
5
- * Lazy-loads ./commands/tdd.js or ./commands/tdd-pipeline.js INSIDE the function
6
- * to preserve cold-start performance heavy command modules MUST NOT be
7
- * eager-imported at the top of this file.
5
+ *
6
+ * hook-consolidation S3: the handlers no longer WRITE `.git/hooks` themselves.
7
+ * They flip the `hooks.*.tdd` flag in `.javi-forge/ci.yaml` and delegate the
8
+ * hook install to the hardened `installCIHooks` — the single writer of
9
+ * `.git/hooks`. Heavy modules are lazy-imported INSIDE the function to preserve
10
+ * cold-start performance.
8
11
  */
9
12
  import type { CLI } from "./types.js";
10
13
  export declare function handleTdd(cli: CLI): Promise<void>;
@@ -2,43 +2,74 @@
2
2
  * `javi-forge tdd <init|pipeline>` handler.
3
3
  *
4
4
  * Console-only (no Ink, no React, no CIContextProvider).
5
- * Lazy-loads ./commands/tdd.js or ./commands/tdd-pipeline.js INSIDE the function
6
- * to preserve cold-start performance heavy command modules MUST NOT be
7
- * eager-imported at the top of this file.
5
+ *
6
+ * hook-consolidation S3: the handlers no longer WRITE `.git/hooks` themselves.
7
+ * They flip the `hooks.*.tdd` flag in `.javi-forge/ci.yaml` and delegate the
8
+ * hook install to the hardened `installCIHooks` — the single writer of
9
+ * `.git/hooks`. Heavy modules are lazy-imported INSIDE the function to preserve
10
+ * cold-start performance.
8
11
  */
12
+ /** Surface an installCIHooks result to the console (notes → backups → installed
13
+ * → upgraded → errors), matching the `ci init` output contract. */
14
+ function reportInstall(result) {
15
+ for (const note of result.notes) {
16
+ console.log(`ℹ ${note}`);
17
+ }
18
+ for (const backup of result.backups) {
19
+ console.log(`⚠ Backed up the previous hook → ${backup}`);
20
+ }
21
+ if (result.installed.length > 0) {
22
+ console.log(`✓ Installed git hooks: ${result.installed.join(", ")}`);
23
+ }
24
+ for (const hook of result.upgraded) {
25
+ const was = result.states.find((entry) => entry.name === hook)?.state;
26
+ console.log(`↑ Upgraded ${hook}${was === undefined ? "" : ` (was ${was})`}`);
27
+ }
28
+ for (const err of result.errors) {
29
+ console.error(`✗ ${err}`);
30
+ }
31
+ }
9
32
  export async function handleTdd(cli) {
10
- if (cli.input[1] === "init") {
11
- const { installTddHooks } = await import("../../commands/tdd.js");
12
- const { installed, errors } = await installTddHooks(process.cwd());
13
- if (installed.length > 0) {
14
- console.log(`\u2713 Installed TDD hooks: ${installed.join(", ")}`);
15
- console.log(" Pre-commit hook enforces tests must pass before commit");
16
- }
17
- for (const err of errors) {
18
- console.error(`\u2717 ${err}`);
19
- }
20
- process.exit(errors.length > 0 ? 1 : 0);
21
- }
22
- else if (cli.input[1] === "pipeline") {
23
- const { installTddPipelineHook } = await import("../../commands/tdd-pipeline.js");
33
+ const sub = cli.input[1];
34
+ const force = cli.flags.force === true;
35
+ if (sub === "init") {
36
+ const { setHookFeature } = await import("../../lib/ci-config.js");
37
+ const { installCIHooks } = await import("../../commands/ci.js");
38
+ // Install FIRST: installCIHooks writes generic static shims and does not
39
+ // depend on the tdd flag (the flag is read at hook-RUN time). Only once the
40
+ // managed hook is actually in place do we flip the config — otherwise a
41
+ // refused install (foreign hook / husky core.hooksPath) would leave ci.yaml
42
+ // claiming tdd:true while no managed hook exists.
43
+ const result = await installCIHooks(process.cwd(), { force });
44
+ reportInstall(result);
45
+ if (result.errors.length > 0)
46
+ process.exit(1);
47
+ const configPath = await setHookFeature(process.cwd(), "pre-commit", "tdd", true);
48
+ console.log(`✓ Enabled TDD in ${configPath} (hooks.pre-commit.tdd: true)`);
49
+ console.log(" The pre-commit hook now runs the stack test command via the dispatcher.");
50
+ process.exit(0);
51
+ }
52
+ else if (sub === "pipeline") {
24
53
  const mode = cli.flags.mode === "warn" ? "warn" : "strict";
25
- const result = await installTddPipelineHook(process.cwd(), mode);
26
- if (result.installed.length > 0) {
27
- console.log(`\u2713 Installed TDD pipeline hook: ${result.installed.join(", ")} [${result.mode}]`);
28
- console.log(` Pre-push hook enforces TDD pipeline (${result.mode} mode)`);
29
- }
30
- for (const skip of result.skipped) {
31
- console.log(`\u26A0 ${skip}`);
32
- }
33
- for (const err of result.errors) {
34
- console.error(`\u2717 ${err}`);
35
- }
36
- process.exit(result.errors.length > 0 ? 1 : 0);
54
+ const { setHookFeature } = await import("../../lib/ci-config.js");
55
+ const { installCIHooks } = await import("../../commands/ci.js");
56
+ // Install FIRST (see `init` above): never leave the config ahead of a
57
+ // refused install.
58
+ const result = await installCIHooks(process.cwd(), { force });
59
+ reportInstall(result);
60
+ if (result.errors.length > 0)
61
+ process.exit(1);
62
+ const configPath = await setHookFeature(process.cwd(), "pre-push", "tdd", mode);
63
+ console.log(`✓ Enabled TDD pipeline in ${configPath} (hooks.pre-push.tdd: ${mode})`);
64
+ console.log(mode === "warn"
65
+ ? " The pre-push hook runs tests as an ADVISORY section (never blocks)."
66
+ : " The pre-push hook BLOCKS the push when tests fail.");
67
+ process.exit(0);
37
68
  }
38
69
  else {
39
70
  console.error("Usage: javi-forge tdd <command>");
40
- console.error(" init Install TDD-enforcing pre-commit hook");
41
- console.error(" pipeline Install TDD pipeline pre-push hook (--mode strict|warn)");
71
+ console.error(" init Enable the TDD pre-commit section and install the managed hooks");
72
+ console.error(" pipeline Enable the TDD pre-push section (--mode strict|warn)");
42
73
  process.exit(1);
43
74
  }
44
75
  }
@@ -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 ci validate Validate .javi-forge/ci.yaml without running anything\n ci init Install git hooks that call javi-forge ci\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 hooks run Run a git hook's composed sections (pre-commit | pre-push)\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 ci validate\n $ javi-forge ci --help\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 ci validate Validate .javi-forge/ci.yaml without running anything\n ci init Install git hooks that call javi-forge ci\n tdd init Enable the TDD pre-commit section + install managed hooks\n tdd pipeline Enable the TDD pre-push section (--mode strict|warn)\n hooks run Run a git hook's composed sections (pre-commit | pre-push)\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 ci validate\n $ javi-forge ci --help\n $ javi-forge analyze\n $ javi-forge doctor\n $ javi-forge plugin add mapbox/agent-skills\n $ javi-forge plugin list\n";
12
12
  /**
13
13
  * Per-command help for `ci`, shown by `javi-forge ci --help` (or when `ci` is
14
14
  * given an unknown subcommand). Kept consistent with the global HELP_TEXT
package/dist/cli/help.js CHANGED
@@ -17,8 +17,8 @@ export const HELP_TEXT = `
17
17
  ci Run CI simulation (lint + compile + test + security + ghagga)
18
18
  ci validate Validate .javi-forge/ci.yaml without running anything
19
19
  ci init Install git hooks that call javi-forge ci
20
- tdd init Install TDD-enforcing pre-commit hook (auto-detects stack)
21
- tdd pipeline Install TDD pipeline pre-push hook (--mode strict|warn)
20
+ tdd init Enable the TDD pre-commit section + install managed hooks
21
+ tdd pipeline Enable the TDD pre-push section (--mode strict|warn)
22
22
  hooks run Run a git hook's composed sections (pre-commit | pre-push)
23
23
  analyze Run repoforge skills analysis
24
24
  doctor Show health report
@@ -269,11 +269,21 @@ export async function resolveCIRunners(projectDir, options = {}) {
269
269
  const configPath = config ?? (await findCIConfig(projectDir));
270
270
  if (configPath) {
271
271
  const ciConfig = await loadCIConfig(configPath);
272
- const runners = [];
273
- for (const runnerConfig of ciConfig.runners) {
274
- runners.push(await resolveConfiguredRunner(projectDir, runnerConfig));
272
+ const hasRunners = ciConfig.runners.length > 0;
273
+ const hasGates = (ciConfig.gates ?? []).length > 0;
274
+ // A config that declares runners OR gates is a genuine "user configured CI"
275
+ // signal: resolve it as the `config` source (a gates-only v2 config keeps
276
+ // its zero-runner, gates-run contract — see describeRunners). A hooks-only
277
+ // (or otherwise empty) v2 config declares NEITHER runners NOR gates, so it
278
+ // must NOT suppress zero-config auto-detection: fall through so `javi-forge
279
+ // ci` still runs a real lint/compile/test runner instead of a no-op.
280
+ if (hasRunners || hasGates) {
281
+ const runners = [];
282
+ for (const runnerConfig of ciConfig.runners) {
283
+ runners.push(await resolveConfiguredRunner(projectDir, runnerConfig));
284
+ }
285
+ return freezeRunners("config", runners, ciConfig.gates ?? []);
275
286
  }
276
- return freezeRunners("config", runners, ciConfig.gates ?? []);
277
287
  }
278
288
  // Zero-config default: single auto-detected runner (unchanged behavior).
279
289
  const info = await detectCIStack(projectDir);
@@ -1,7 +1,15 @@
1
1
  import type { DoctorResult } from "../types/index.js";
2
2
  export type CheckStatus = "ok" | "fail" | "skip";
3
+ interface RemoteInfo {
4
+ host: "github" | "gitlab" | "other";
5
+ owner: string;
6
+ repo: string;
7
+ }
8
+ /** Parse an origin remote URL (ssh or https) into host/owner/repo. */
9
+ export declare function parseRemote(url: string): RemoteInfo | null;
3
10
  /**
4
11
  * Run comprehensive health checks for the project and framework.
5
12
  */
6
13
  export declare function runDoctor(projectDir?: string): Promise<DoctorResult>;
14
+ export {};
7
15
  //# sourceMappingURL=doctor.d.ts.map
@@ -34,6 +34,110 @@ async function countDir(dir) {
34
34
  const entries = await fs.readdir(dir);
35
35
  return entries.filter((e) => !e.startsWith(".")).length;
36
36
  }
37
+ /** Read a single git config value, "" when unset or on any error. */
38
+ async function gitConfigValue(cwd, key) {
39
+ try {
40
+ const { stdout } = await execFileAsync("git", ["config", "--get", key], {
41
+ cwd,
42
+ });
43
+ return stdout.trim();
44
+ }
45
+ catch {
46
+ return "";
47
+ }
48
+ }
49
+ /** Parse an origin remote URL (ssh or https) into host/owner/repo. */
50
+ export function parseRemote(url) {
51
+ const trimmed = url.trim();
52
+ if (!trimmed)
53
+ return null;
54
+ // git@host:owner/repo(.git) | ssh://git@host/owner/repo | https://host/owner/repo(.git)
55
+ const m = trimmed.match(/(?:git@|https?:\/\/|ssh:\/\/(?:git@)?)([^/:]+)[/:]([^/]+)\/(.+?)(?:\.git)?$/);
56
+ if (!m)
57
+ return null;
58
+ const [, hostRaw, owner, repo] = m;
59
+ const host = hostRaw.includes("github")
60
+ ? "github"
61
+ : hostRaw.includes("gitlab")
62
+ ? "gitlab"
63
+ : "other";
64
+ return { host, owner, repo };
65
+ }
66
+ /**
67
+ * L4+L6 advisory (merged — signing is one check): report `ok` when commit
68
+ * signing is fully configured (`commit.gpgsign=true` AND `user.signingkey`
69
+ * set), otherwise `skip` with the enable snippet. Doctor is read-only — this is
70
+ * a recommendation, never a gate (a hook enforcing it was trivially bypassed
71
+ * with `--no-verify`).
72
+ */
73
+ async function commitSigningCheck(cwd) {
74
+ const gpgSign = await gitConfigValue(cwd, "commit.gpgsign");
75
+ const signingKey = await gitConfigValue(cwd, "user.signingkey");
76
+ if (gpgSign === "true" && signingKey) {
77
+ return {
78
+ label: "Commit signing",
79
+ status: "ok",
80
+ detail: "signing enabled (commit.gpgsign=true)",
81
+ };
82
+ }
83
+ return {
84
+ label: "Commit signing",
85
+ status: "skip",
86
+ detail: "not configured — enable with: git config commit.gpgsign true && git config user.signingkey <KEY>",
87
+ };
88
+ }
89
+ /**
90
+ * L5 advisory: server-side branch protection is the real control (the old local
91
+ * push-blocking hook was CI-exempt and `--no-verify`-bypassable). When `gh` is
92
+ * on PATH and origin is GitHub, probe the branch protection API; a missing
93
+ * probe or protection is a non-alarming `skip` (advisory, not a failure).
94
+ * GitLab or no `gh` → `skip` with a note to verify in the forge UI.
95
+ */
96
+ async function branchProtectionCheck(cwd) {
97
+ const remote = parseRemote(await gitConfigValue(cwd, "remote.origin.url"));
98
+ const label = "Branch protection";
99
+ if (!remote || remote.host === "other") {
100
+ return {
101
+ label,
102
+ status: "skip",
103
+ detail: "no GitHub/GitLab origin — verify branch protection in the forge UI",
104
+ };
105
+ }
106
+ if (remote.host === "gitlab" || !(await which("gh"))) {
107
+ return {
108
+ label,
109
+ status: "skip",
110
+ detail: "verify branch protection in the forge UI",
111
+ };
112
+ }
113
+ // gh + GitHub: resolve the default branch, then probe protection.
114
+ let branch = "main";
115
+ try {
116
+ const { stdout } = await execFileAsync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd });
117
+ branch = stdout.trim().replace(/^origin\//, "") || "main";
118
+ }
119
+ catch {
120
+ /* fall back to main */
121
+ }
122
+ try {
123
+ await execFileAsync("gh", [
124
+ "api",
125
+ `repos/${remote.owner}/${remote.repo}/branches/${branch}/protection`,
126
+ ]);
127
+ return {
128
+ label,
129
+ status: "ok",
130
+ detail: `server-side protection enabled on ${branch}`,
131
+ };
132
+ }
133
+ catch {
134
+ return {
135
+ label,
136
+ status: "skip",
137
+ detail: `no server-side branch protection detected on ${branch}`,
138
+ };
139
+ }
140
+ }
37
141
  /**
38
142
  * Run comprehensive health checks for the project and framework.
39
143
  */
@@ -76,6 +180,14 @@ export async function runDoctor(projectDir) {
76
180
  }
77
181
  }
78
182
  sections.push({ title: "System Tools", checks: toolChecks });
183
+ // ── 1b. Security (advisories, read-only) ────────────────────────────────────
184
+ // L4/L6 (commit signing) and L5 (branch protection) are advisories, NOT hooks
185
+ // (hook-consolidation D9): doctor reports, nothing blocks.
186
+ const securityChecks = [
187
+ await commitSigningCheck(cwd),
188
+ await branchProtectionCheck(cwd),
189
+ ];
190
+ sections.push({ title: "Security", checks: securityChecks });
79
191
  // ── 2. Framework Structure ─────────────────────────────────────────────────
80
192
  const structureChecks = [];
81
193
  const expectedDirs = [
@@ -0,0 +1,28 @@
1
+ /**
2
+ * L2 dependency-audit section (hook-consolidation S4).
3
+ *
4
+ * Port of `templates/security-hooks/pre-push-deps`. Same manifest→tool ladder,
5
+ * run via `execFileAsync` (argv, no shell). A tool that is not installed is an
6
+ * advisory skip (ok:true) exactly like the bash "not installed. Skipping"
7
+ * branch — a missing auditor must never block a push. A genuine audit finding
8
+ * (the auditor ran and exited non-zero) is a blocking failure (ok:false).
9
+ */
10
+ import type { HookSection } from "../../hooks.js";
11
+ export interface DepsSectionDeps {
12
+ execFile: (cmd: string, args: string[], opts?: {
13
+ cwd?: string;
14
+ }) => Promise<{
15
+ stdout: string;
16
+ stderr: string;
17
+ }>;
18
+ which: (bin: string) => Promise<boolean>;
19
+ fileExists: (p: string) => Promise<boolean>;
20
+ log: (msg: string) => void;
21
+ }
22
+ /**
23
+ * The `deps` section factory. Detects the project's manifest and runs the
24
+ * matching auditor. No manifest → advisory skip. A thrown error is caught and
25
+ * mapped to a blocking failure (never an unhandled rejection).
26
+ */
27
+ export declare function depsSection(overrides?: Partial<DepsSectionDeps>): HookSection;
28
+ //# sourceMappingURL=deps.d.ts.map
@@ -0,0 +1,126 @@
1
+ /**
2
+ * L2 dependency-audit section (hook-consolidation S4).
3
+ *
4
+ * Port of `templates/security-hooks/pre-push-deps`. Same manifest→tool ladder,
5
+ * run via `execFileAsync` (argv, no shell). A tool that is not installed is an
6
+ * advisory skip (ok:true) exactly like the bash "not installed. Skipping"
7
+ * branch — a missing auditor must never block a push. A genuine audit finding
8
+ * (the auditor ran and exited non-zero) is a blocking failure (ok:false).
9
+ */
10
+ import path from "node:path";
11
+ import fs from "fs-extra";
12
+ import { execFileAsync } from "../../../lib/exec.js";
13
+ async function whichReal(bin) {
14
+ try {
15
+ await execFileAsync("which", [bin]);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function defaultDeps() {
23
+ return {
24
+ execFile: async (cmd, args, opts) => {
25
+ const { stdout, stderr } = await execFileAsync(cmd, args, opts);
26
+ return { stdout: String(stdout), stderr: String(stderr) };
27
+ },
28
+ which: whichReal,
29
+ fileExists: (p) => fs.pathExists(p),
30
+ log: (m) => console.log(m),
31
+ };
32
+ }
33
+ /** ENOENT ⇒ the auditor binary is absent (advisory skip, never blocking). */
34
+ function isMissingBinary(e) {
35
+ return e?.code === "ENOENT";
36
+ }
37
+ /**
38
+ * Run an auditor. A clean exit is ok:true; a non-zero exit (the tool ran and
39
+ * found something) is a blocking ok:false; a missing binary (ENOENT) is an
40
+ * advisory skip ok:true (parity with the bash "not installed" branch).
41
+ */
42
+ async function runAudit(deps, projectDir, cmd, args) {
43
+ deps.log(` ▶ ${cmd} ${args.join(" ")}`);
44
+ try {
45
+ await deps.execFile(cmd, args, { cwd: projectDir });
46
+ return { ok: true };
47
+ }
48
+ catch (e) {
49
+ if (isMissingBinary(e)) {
50
+ deps.log(` ⓘ ${cmd} not installed. Skipping (does not block).`);
51
+ return { ok: true };
52
+ }
53
+ return {
54
+ ok: false,
55
+ detail: `high/critical vulnerabilities found (${cmd} ${args.join(" ")})`,
56
+ };
57
+ }
58
+ }
59
+ /**
60
+ * The `deps` section factory. Detects the project's manifest and runs the
61
+ * matching auditor. No manifest → advisory skip. A thrown error is caught and
62
+ * mapped to a blocking failure (never an unhandled rejection).
63
+ */
64
+ export function depsSection(overrides = {}) {
65
+ const deps = { ...defaultDeps(), ...overrides };
66
+ const at = (projectDir, name) => path.join(projectDir, name);
67
+ return {
68
+ id: "deps",
69
+ blocking: true,
70
+ async run({ projectDir }) {
71
+ try {
72
+ if (await deps.fileExists(at(projectDir, "package.json"))) {
73
+ if (await deps.fileExists(at(projectDir, "pnpm-lock.yaml"))) {
74
+ return runAudit(deps, projectDir, "pnpm", [
75
+ "audit",
76
+ "--audit-level=high",
77
+ ]);
78
+ }
79
+ if (await deps.fileExists(at(projectDir, "yarn.lock"))) {
80
+ return runAudit(deps, projectDir, "yarn", [
81
+ "npm",
82
+ "audit",
83
+ "--severity",
84
+ "high",
85
+ ]);
86
+ }
87
+ return runAudit(deps, projectDir, "npm", [
88
+ "audit",
89
+ "--audit-level=high",
90
+ ]);
91
+ }
92
+ if ((await deps.fileExists(at(projectDir, "requirements.txt"))) ||
93
+ (await deps.fileExists(at(projectDir, "pyproject.toml")))) {
94
+ if (!(await deps.which("pip-audit"))) {
95
+ deps.log(" ⓘ pip-audit not installed. Skipping (does not block).");
96
+ return { ok: true };
97
+ }
98
+ return runAudit(deps, projectDir, "pip-audit", []);
99
+ }
100
+ if (await deps.fileExists(at(projectDir, "Cargo.toml"))) {
101
+ if (!(await deps.which("cargo-audit"))) {
102
+ deps.log(" ⓘ cargo-audit not installed. Skipping (does not block).");
103
+ return { ok: true };
104
+ }
105
+ return runAudit(deps, projectDir, "cargo-audit", []);
106
+ }
107
+ if (await deps.fileExists(at(projectDir, "go.mod"))) {
108
+ if (!(await deps.which("govulncheck"))) {
109
+ deps.log(" ⓘ govulncheck not installed. Skipping (does not block).");
110
+ return { ok: true };
111
+ }
112
+ return runAudit(deps, projectDir, "govulncheck", ["./..."]);
113
+ }
114
+ deps.log(" ⓘ no supported dependency manifest found. Skipping.");
115
+ return { ok: true };
116
+ }
117
+ catch (e) {
118
+ return {
119
+ ok: false,
120
+ detail: e instanceof Error ? e.message : String(e),
121
+ };
122
+ }
123
+ },
124
+ };
125
+ }
126
+ //# sourceMappingURL=deps.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * L3 permission-boundary section (hook-consolidation S4).
3
+ *
4
+ * Port of `templates/security-hooks/pre-commit-permissions`. Builds the staged
5
+ * list NUL-safe (`-z` → split on "\0") and does the mode checks with `fs.stat`
6
+ * in TS instead of shelling out to `stat`/`grep`. Two issue classes block the
7
+ * commit: a world-writable file (`mode & 0o002`) and an executable
8
+ * (`mode & 0o111`) that is NOT an allowed script/hook/shebang file.
9
+ */
10
+ import type { HookSection } from "../../hooks.js";
11
+ export interface PermissionsSectionDeps {
12
+ execFile: (cmd: string, args: string[], opts?: {
13
+ cwd?: string;
14
+ }) => Promise<{
15
+ stdout: string;
16
+ stderr: string;
17
+ }>;
18
+ /** stat a path; returns null when the file does not exist / is not a regular file. */
19
+ statFile: (p: string) => Promise<{
20
+ mode: number;
21
+ isFile: boolean;
22
+ } | null>;
23
+ /** first line of a file (for the shebang allowance); "" on any read error. */
24
+ firstLine: (p: string) => Promise<string>;
25
+ log: (msg: string) => void;
26
+ }
27
+ /**
28
+ * The `permissions` section factory. Injectable seams default to the real
29
+ * git/fs implementation. A thrown error is caught and mapped to a blocking
30
+ * failure (never an unhandled rejection).
31
+ */
32
+ export declare function permissionsSection(overrides?: Partial<PermissionsSectionDeps>): HookSection;
33
+ //# sourceMappingURL=permissions.d.ts.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * L3 permission-boundary section (hook-consolidation S4).
3
+ *
4
+ * Port of `templates/security-hooks/pre-commit-permissions`. Builds the staged
5
+ * list NUL-safe (`-z` → split on "\0") and does the mode checks with `fs.stat`
6
+ * in TS instead of shelling out to `stat`/`grep`. Two issue classes block the
7
+ * commit: a world-writable file (`mode & 0o002`) and an executable
8
+ * (`mode & 0o111`) that is NOT an allowed script/hook/shebang file.
9
+ */
10
+ import path from "node:path";
11
+ import fs from "fs-extra";
12
+ import { execFileAsync } from "../../../lib/exec.js";
13
+ /** Extensions that legitimately carry the executable bit. */
14
+ const SCRIPT_EXT = /\.(sh|bash|zsh|py|rb|pl)$/;
15
+ async function statFileReal(p) {
16
+ try {
17
+ const st = await fs.stat(p);
18
+ return { mode: st.mode, isFile: st.isFile() };
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ async function firstLineReal(p) {
25
+ try {
26
+ const content = await fs.readFile(p, "utf-8");
27
+ return content.split("\n", 1)[0] ?? "";
28
+ }
29
+ catch {
30
+ return "";
31
+ }
32
+ }
33
+ function defaultDeps() {
34
+ return {
35
+ execFile: async (cmd, args, opts) => {
36
+ const { stdout, stderr } = await execFileAsync(cmd, args, opts);
37
+ return { stdout: String(stdout), stderr: String(stderr) };
38
+ },
39
+ statFile: statFileReal,
40
+ firstLine: firstLineReal,
41
+ log: (m) => console.log(m),
42
+ };
43
+ }
44
+ async function stagedFiles(deps, projectDir) {
45
+ const { stdout } = await deps.execFile("git", ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"], { cwd: projectDir });
46
+ return stdout.split("\0").filter((f) => f.length > 0);
47
+ }
48
+ /**
49
+ * The `permissions` section factory. Injectable seams default to the real
50
+ * git/fs implementation. A thrown error is caught and mapped to a blocking
51
+ * failure (never an unhandled rejection).
52
+ */
53
+ export function permissionsSection(overrides = {}) {
54
+ const deps = { ...defaultDeps(), ...overrides };
55
+ return {
56
+ id: "permissions",
57
+ blocking: true,
58
+ async run({ projectDir }) {
59
+ try {
60
+ const files = await stagedFiles(deps, projectDir);
61
+ if (files.length === 0)
62
+ return { ok: true };
63
+ const issues = [];
64
+ for (const file of files) {
65
+ const full = path.join(projectDir, file);
66
+ const st = await deps.statFile(full);
67
+ if (!st?.isFile)
68
+ continue;
69
+ if ((st.mode & 0o002) !== 0) {
70
+ issues.push(`world-writable: ${file}`);
71
+ }
72
+ if ((st.mode & 0o111) !== 0) {
73
+ const allowedByExt = SCRIPT_EXT.test(file);
74
+ const allowedByHooksDir = file.includes("/hooks/");
75
+ const allowedByShebang = allowedByExt || allowedByHooksDir
76
+ ? true
77
+ : (await deps.firstLine(full)).startsWith("#!");
78
+ if (!allowedByExt && !allowedByHooksDir && !allowedByShebang) {
79
+ issues.push(`unexpected executable: ${file}`);
80
+ }
81
+ }
82
+ }
83
+ if (issues.length === 0)
84
+ return { ok: true };
85
+ return {
86
+ ok: false,
87
+ detail: `${issues.length} permission issue(s): ${issues
88
+ .slice(0, 10)
89
+ .join("; ")}`,
90
+ };
91
+ }
92
+ catch (e) {
93
+ return {
94
+ ok: false,
95
+ detail: e instanceof Error ? e.message : String(e),
96
+ };
97
+ }
98
+ },
99
+ };
100
+ }
101
+ //# sourceMappingURL=permissions.js.map