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.
- package/dist/cli/dispatch/tdd.d.ts +6 -3
- package/dist/cli/dispatch/tdd.js +62 -31
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +2 -2
- package/dist/commands/ci.js +14 -4
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +112 -0
- package/dist/commands/hooks/sections/deps.d.ts +28 -0
- package/dist/commands/hooks/sections/deps.js +126 -0
- package/dist/commands/hooks/sections/permissions.d.ts +33 -0
- package/dist/commands/hooks/sections/permissions.js +101 -0
- package/dist/commands/hooks/sections/secrets.d.ts +58 -0
- package/dist/commands/hooks/sections/secrets.js +182 -0
- package/dist/commands/hooks.d.ts +39 -3
- package/dist/commands/hooks.js +93 -6
- package/dist/commands/init/steps/security.d.ts +10 -20
- package/dist/commands/init/steps/security.js +58 -78
- package/dist/commands/init.js +1 -2
- package/dist/commands/tdd.d.ts +0 -14
- package/dist/commands/tdd.js +0 -88
- package/dist/constants.d.ts +6 -1
- package/dist/constants.js +12 -7
- package/dist/lib/ci-config.d.ts +10 -0
- package/dist/lib/ci-config.js +33 -0
- package/dist/types/index.d.ts +0 -7
- package/package.json +1 -1
- package/dist/commands/tdd-pipeline.d.ts +0 -17
- package/dist/commands/tdd-pipeline.js +0 -144
- package/templates/security-hooks/commit-msg-signing +0 -29
- package/templates/security-hooks/pre-commit-permissions +0 -74
- package/templates/security-hooks/pre-commit-secrets +0 -74
- package/templates/security-hooks/pre-push-branch-protection +0 -62
- package/templates/security-hooks/pre-push-deps +0 -83
- package/templates/security-hooks/pre-push-signing +0 -67
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 secret-scan section (hook-consolidation S4).
|
|
3
|
+
*
|
|
4
|
+
* Port of `templates/security-hooks/pre-commit-secrets` into TypeScript. The
|
|
5
|
+
* bash body built the staged-file list with `git diff --cached --name-only`
|
|
6
|
+
* and then piped it through `xargs git diff` (pre-commit-secrets:52) — a
|
|
7
|
+
* whitespace split that silently dropped any path containing a space and
|
|
8
|
+
* swallowed the git error with `|| true` (K-005). This port builds the staged
|
|
9
|
+
* list NUL-safe (`-z` → split on `"\0"`) and passes each filename as a SEPARATE
|
|
10
|
+
* argv element to `git diff` (no shell, no xargs), so a path like
|
|
11
|
+
* `app secrets.env` is scanned as ONE path and can never be split.
|
|
12
|
+
*
|
|
13
|
+
* A matched pattern in an ADDED diff line blocks the commit (ok:false).
|
|
14
|
+
*/
|
|
15
|
+
import type { HookSection } from "../../hooks.js";
|
|
16
|
+
/** ARG_MAX guard: cap the number of file paths per `git diff` argv batch. */
|
|
17
|
+
export declare const DEFAULT_CHUNK_SIZE = 512;
|
|
18
|
+
/**
|
|
19
|
+
* maxBuffer for the content `git diff` read. Node's default is 1 MiB; a staged
|
|
20
|
+
* diff larger than that rejects and (caught) blocks every large legitimate
|
|
21
|
+
* commit with a cryptic buffer error. Raise the ceiling to a generous but
|
|
22
|
+
* BOUNDED 64 MiB so ordinary large commits scan, while a genuinely pathological
|
|
23
|
+
* diff still fails closed instead of exhausting memory.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DIFF_MAX_BUFFER: number;
|
|
26
|
+
interface Finding {
|
|
27
|
+
file: string;
|
|
28
|
+
line: number;
|
|
29
|
+
pattern: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SecretsSectionDeps {
|
|
32
|
+
/** argv-only exec (no shell). Defaults to the real `git` via execFileAsync. */
|
|
33
|
+
execFile: (cmd: string, args: string[], opts?: {
|
|
34
|
+
cwd?: string;
|
|
35
|
+
maxBuffer?: number;
|
|
36
|
+
}) => Promise<{
|
|
37
|
+
stdout: string;
|
|
38
|
+
stderr: string;
|
|
39
|
+
}>;
|
|
40
|
+
log: (msg: string) => void;
|
|
41
|
+
/** File-count per `git diff` batch (ARG_MAX guard). Defaults to 512. */
|
|
42
|
+
chunkSize: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Walk a unified diff and report every ADDED line (`+`, never `+++`) that
|
|
46
|
+
* matches a secret pattern, tracking the new-file path and line number so a
|
|
47
|
+
* finding reads `path:line pattern`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function scanDiff(diff: string): Finding[];
|
|
50
|
+
/**
|
|
51
|
+
* The `secrets` section factory. Injectable seams default to the real
|
|
52
|
+
* `git`-via-execFileAsync implementation; tests pass mocks. A thrown git error
|
|
53
|
+
* becomes a blocking `{ ok: false }` (never an unhandled rejection), matching
|
|
54
|
+
* the ciSection/tddSection hardening.
|
|
55
|
+
*/
|
|
56
|
+
export declare function secretsSection(overrides?: Partial<SecretsSectionDeps>): HookSection;
|
|
57
|
+
export {};
|
|
58
|
+
//# sourceMappingURL=secrets.d.ts.map
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 secret-scan section (hook-consolidation S4).
|
|
3
|
+
*
|
|
4
|
+
* Port of `templates/security-hooks/pre-commit-secrets` into TypeScript. The
|
|
5
|
+
* bash body built the staged-file list with `git diff --cached --name-only`
|
|
6
|
+
* and then piped it through `xargs git diff` (pre-commit-secrets:52) — a
|
|
7
|
+
* whitespace split that silently dropped any path containing a space and
|
|
8
|
+
* swallowed the git error with `|| true` (K-005). This port builds the staged
|
|
9
|
+
* list NUL-safe (`-z` → split on `"\0"`) and passes each filename as a SEPARATE
|
|
10
|
+
* argv element to `git diff` (no shell, no xargs), so a path like
|
|
11
|
+
* `app secrets.env` is scanned as ONE path and can never be split.
|
|
12
|
+
*
|
|
13
|
+
* A matched pattern in an ADDED diff line blocks the commit (ok:false).
|
|
14
|
+
*/
|
|
15
|
+
import { execFileAsync } from "../../../lib/exec.js";
|
|
16
|
+
/**
|
|
17
|
+
* Ported from `templates/security-hooks/pre-commit-secrets` SECRET_PATTERNS.
|
|
18
|
+
* bash `(?i)` → the JS `/i` flag; bash `\x27` (a single quote) → a literal `'`.
|
|
19
|
+
* No `/g` flag is used, so `.test()` never carries `lastIndex` state between
|
|
20
|
+
* lines. Order is preserved from the bash array.
|
|
21
|
+
*/
|
|
22
|
+
const SECRET_PATTERNS = [
|
|
23
|
+
// AWS access key id
|
|
24
|
+
{ name: "aws-access-key", re: /AKIA[0-9A-Z]{16}/ },
|
|
25
|
+
// Generic API keys / tokens assigned to key-like vars
|
|
26
|
+
{
|
|
27
|
+
name: "generic-api-key",
|
|
28
|
+
re: /(api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token)\s*[:=]\s*["'][A-Za-z0-9+/=_-]{20,}["']/i,
|
|
29
|
+
},
|
|
30
|
+
// Private key headers
|
|
31
|
+
{
|
|
32
|
+
name: "private-key",
|
|
33
|
+
re: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
|
|
34
|
+
},
|
|
35
|
+
// GitHub tokens
|
|
36
|
+
{ name: "github-token", re: /gh[pousr]_[A-Za-z0-9_]{36,}/ },
|
|
37
|
+
// Generic password assignments
|
|
38
|
+
{
|
|
39
|
+
name: "password-assignment",
|
|
40
|
+
re: /(password|passwd|pwd)\s*[:=]\s*["'][^\s"']{8,}["']/i,
|
|
41
|
+
},
|
|
42
|
+
// Slack tokens
|
|
43
|
+
{ name: "slack-token", re: /xox[baprs]-[0-9a-zA-Z-]+/ },
|
|
44
|
+
// Stripe keys
|
|
45
|
+
{ name: "stripe-secret-key", re: /sk_live_[0-9a-zA-Z]{24,}/ },
|
|
46
|
+
{ name: "stripe-restricted-key", re: /rk_live_[0-9a-zA-Z]{24,}/ },
|
|
47
|
+
// SendGrid
|
|
48
|
+
{
|
|
49
|
+
name: "sendgrid-key",
|
|
50
|
+
re: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
/** ARG_MAX guard: cap the number of file paths per `git diff` argv batch. */
|
|
54
|
+
export const DEFAULT_CHUNK_SIZE = 512;
|
|
55
|
+
/**
|
|
56
|
+
* maxBuffer for the content `git diff` read. Node's default is 1 MiB; a staged
|
|
57
|
+
* diff larger than that rejects and (caught) blocks every large legitimate
|
|
58
|
+
* commit with a cryptic buffer error. Raise the ceiling to a generous but
|
|
59
|
+
* BOUNDED 64 MiB so ordinary large commits scan, while a genuinely pathological
|
|
60
|
+
* diff still fails closed instead of exhausting memory.
|
|
61
|
+
*/
|
|
62
|
+
export const DIFF_MAX_BUFFER = 64 * 1024 * 1024;
|
|
63
|
+
function defaultDeps() {
|
|
64
|
+
return {
|
|
65
|
+
execFile: async (cmd, args, opts) => {
|
|
66
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, opts);
|
|
67
|
+
return { stdout: String(stdout), stderr: String(stderr) };
|
|
68
|
+
},
|
|
69
|
+
log: (m) => console.log(m),
|
|
70
|
+
chunkSize: DEFAULT_CHUNK_SIZE,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** NUL-safe staged-file list (`-z` → split on "\0", drop the trailing empty). */
|
|
74
|
+
async function stagedFiles(deps, projectDir) {
|
|
75
|
+
const { stdout } = await deps.execFile("git", ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"], { cwd: projectDir });
|
|
76
|
+
return stdout.split("\0").filter((f) => f.length > 0);
|
|
77
|
+
}
|
|
78
|
+
function chunk(items, size) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (let i = 0; i < items.length; i += size)
|
|
81
|
+
out.push(items.slice(i, i + size));
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Walk a unified diff and report every ADDED line (`+`, never `+++`) that
|
|
86
|
+
* matches a secret pattern, tracking the new-file path and line number so a
|
|
87
|
+
* finding reads `path:line pattern`.
|
|
88
|
+
*/
|
|
89
|
+
export function scanDiff(diff) {
|
|
90
|
+
const findings = [];
|
|
91
|
+
let currentFile = "";
|
|
92
|
+
let newLine = 0;
|
|
93
|
+
for (const line of diff.split("\n")) {
|
|
94
|
+
if (line.startsWith("+++ ")) {
|
|
95
|
+
const p = line.slice(4).trim();
|
|
96
|
+
currentFile = p === "/dev/null" ? "" : p.replace(/^b\//, "");
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (line.startsWith("--- "))
|
|
100
|
+
continue;
|
|
101
|
+
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
102
|
+
if (hunk) {
|
|
103
|
+
newLine = Number.parseInt(hunk[1], 10);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (line.startsWith("diff --git") ||
|
|
107
|
+
line.startsWith("index ") ||
|
|
108
|
+
line.startsWith("old mode") ||
|
|
109
|
+
line.startsWith("new mode") ||
|
|
110
|
+
line.startsWith("similarity ") ||
|
|
111
|
+
line.startsWith("rename ") ||
|
|
112
|
+
line.startsWith("new file") ||
|
|
113
|
+
line.startsWith("deleted file")) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (line.startsWith("+")) {
|
|
117
|
+
const content = line.slice(1);
|
|
118
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
119
|
+
if (re.test(content)) {
|
|
120
|
+
findings.push({ file: currentFile, line: newLine, pattern: name });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
newLine++;
|
|
124
|
+
}
|
|
125
|
+
else if (line.startsWith("-")) {
|
|
126
|
+
// removed line — does not advance the new-file line counter
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// context (leading space) or blank line
|
|
130
|
+
newLine++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return findings;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The `secrets` section factory. Injectable seams default to the real
|
|
137
|
+
* `git`-via-execFileAsync implementation; tests pass mocks. A thrown git error
|
|
138
|
+
* becomes a blocking `{ ok: false }` (never an unhandled rejection), matching
|
|
139
|
+
* the ciSection/tddSection hardening.
|
|
140
|
+
*/
|
|
141
|
+
export function secretsSection(overrides = {}) {
|
|
142
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
143
|
+
return {
|
|
144
|
+
id: "secrets",
|
|
145
|
+
blocking: true,
|
|
146
|
+
async run({ projectDir }) {
|
|
147
|
+
try {
|
|
148
|
+
const files = await stagedFiles(deps, projectDir);
|
|
149
|
+
if (files.length === 0)
|
|
150
|
+
return { ok: true };
|
|
151
|
+
const findings = [];
|
|
152
|
+
for (const batch of chunk(files, deps.chunkSize)) {
|
|
153
|
+
// `--no-color` is MANDATORY: under `color.ui=always` (or
|
|
154
|
+
// `color.diff=always`) git emits ANSI escapes even to a pipe, so
|
|
155
|
+
// added lines render as `\x1b[32m+secret\x1b[m` and both the
|
|
156
|
+
// `+`/`+++ ` line checks in scanDiff fail — the scanner would then
|
|
157
|
+
// find zero secrets and fail OPEN. Forcing color off keeps the diff
|
|
158
|
+
// plain-text and the scan deterministic.
|
|
159
|
+
const { stdout } = await deps.execFile("git", ["diff", "--no-color", "--cached", "--", ...batch], { cwd: projectDir, maxBuffer: DIFF_MAX_BUFFER });
|
|
160
|
+
findings.push(...scanDiff(stdout));
|
|
161
|
+
}
|
|
162
|
+
if (findings.length === 0)
|
|
163
|
+
return { ok: true };
|
|
164
|
+
const summary = findings
|
|
165
|
+
.slice(0, 10)
|
|
166
|
+
.map((f) => `${f.file || "<staged>"}:${f.line} ${f.pattern}`)
|
|
167
|
+
.join("; ");
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
detail: `${findings.length} potential secret(s): ${summary}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=secrets.js.map
|
package/dist/commands/hooks.d.ts
CHANGED
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
* behavior); a config that FAILS to validate exits 1 — a broken config never
|
|
11
11
|
* silently skips a gate.
|
|
12
12
|
*
|
|
13
|
-
* S1a scope:
|
|
14
|
-
* sections have
|
|
15
|
-
* feature
|
|
13
|
+
* S1a/S3/S4 scope: the `ci`, `tdd`, `secrets`, `permissions` and `deps`
|
|
14
|
+
* sections all have factories now. `composeSections` still skips any enabled
|
|
15
|
+
* feature with no registered factory, so a future feature can land the same
|
|
16
|
+
* way.
|
|
16
17
|
*/
|
|
17
18
|
import { type CIHooksConfig } from "../lib/ci-config.js";
|
|
19
|
+
import type { Stack } from "../types/index.js";
|
|
18
20
|
import { runCI } from "./ci.js";
|
|
21
|
+
import { getTddTestCommand } from "./tdd.js";
|
|
19
22
|
/** A single composable unit of hook work. */
|
|
20
23
|
export interface HookSection {
|
|
21
24
|
/** stable id — "secrets" | "permissions" | "tdd" | "deps" | "ci" */
|
|
@@ -41,6 +44,39 @@ export type HookName = "pre-commit" | "pre-push";
|
|
|
41
44
|
* in later slices.
|
|
42
45
|
*/
|
|
43
46
|
export declare function composeSections(name: HookName, config: CIHooksConfig | null, registry: SectionRegistry): HookSection[];
|
|
47
|
+
/**
|
|
48
|
+
* Injectable seams for the `tdd` section. The section resolves the project's
|
|
49
|
+
* test command at HOOK-RUN time (never interpolated into a file), so the stack
|
|
50
|
+
* detector, the command resolver and the command runner are all overridable in
|
|
51
|
+
* tests. Every field defaults to the real implementation in `defaultRegistry`.
|
|
52
|
+
*/
|
|
53
|
+
export interface TddSectionDeps {
|
|
54
|
+
/** Only the stack + build tool are consumed — the wider CIStackInfo is fine. */
|
|
55
|
+
detectStack: (projectDir: string) => Promise<{
|
|
56
|
+
stackType: Stack;
|
|
57
|
+
buildTool: string;
|
|
58
|
+
}>;
|
|
59
|
+
resolveTestCmd: typeof getTddTestCommand;
|
|
60
|
+
runCommand: (cmd: string, projectDir: string) => Promise<{
|
|
61
|
+
ok: boolean;
|
|
62
|
+
detail?: string;
|
|
63
|
+
}>;
|
|
64
|
+
log: (msg: string) => void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The `tdd` section: at hook-run time it detects the stack, resolves the stack
|
|
68
|
+
* test command and runs it. A null command (no test runner configured) is NOT a
|
|
69
|
+
* failure — it prints an honest skip notice and returns ok:true (parity with the
|
|
70
|
+
* old warning-only generated hook). A missing test runner must never block a
|
|
71
|
+
* commit or push. When the command resolves it runs and its exit code decides.
|
|
72
|
+
*/
|
|
73
|
+
export declare function tddSection(deps: TddSectionDeps): HookSection;
|
|
74
|
+
/**
|
|
75
|
+
* The default section registry: the real `ci` and `tdd` factories. `tddDeps`
|
|
76
|
+
* overrides the tdd section's seams in tests (stack/command/runner); every
|
|
77
|
+
* omitted seam falls back to the real implementation.
|
|
78
|
+
*/
|
|
79
|
+
export declare function defaultRegistry(runCIImpl: typeof runCI, log: (msg: string) => void, tddDeps?: Partial<TddSectionDeps>): SectionRegistry;
|
|
44
80
|
/** Resolve the parsed `hooks:` config for a project (null → default [ci]). */
|
|
45
81
|
export declare function loadHooksConfig(projectDir: string): Promise<CIHooksConfig | null>;
|
|
46
82
|
/** Injectable seams for tests; every field defaults to the real implementation. */
|
package/dist/commands/hooks.js
CHANGED
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
* behavior); a config that FAILS to validate exits 1 — a broken config never
|
|
11
11
|
* silently skips a gate.
|
|
12
12
|
*
|
|
13
|
-
* S1a scope:
|
|
14
|
-
* sections have
|
|
15
|
-
* feature
|
|
13
|
+
* S1a/S3/S4 scope: the `ci`, `tdd`, `secrets`, `permissions` and `deps`
|
|
14
|
+
* sections all have factories now. `composeSections` still skips any enabled
|
|
15
|
+
* feature with no registered factory, so a future feature can land the same
|
|
16
|
+
* way.
|
|
16
17
|
*/
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
17
19
|
import { findCIConfig, loadCIConfig, } from "../lib/ci-config.js";
|
|
18
|
-
import { runCI } from "./ci.js";
|
|
20
|
+
import { detectCIStack, runCI } from "./ci.js";
|
|
21
|
+
import { depsSection } from "./hooks/sections/deps.js";
|
|
22
|
+
import { permissionsSection } from "./hooks/sections/permissions.js";
|
|
23
|
+
import { secretsSection } from "./hooks/sections/secrets.js";
|
|
24
|
+
import { getTddTestCommand } from "./tdd.js";
|
|
19
25
|
/** Fixed cheap→expensive order per hook (deterministic is a feature). */
|
|
20
26
|
const PRE_COMMIT_ORDER = ["secrets", "permissions", "tdd", "ci"];
|
|
21
27
|
const PRE_PUSH_ORDER = ["deps", "tdd", "ci"];
|
|
@@ -114,8 +120,89 @@ function reportStep(step, log) {
|
|
|
114
120
|
else if (step.status === "warning")
|
|
115
121
|
log(` ⚠ ${step.label}`);
|
|
116
122
|
}
|
|
117
|
-
|
|
118
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Run a resolved test command through `bash -c`, matching the rest of the
|
|
125
|
+
* codebase's project-command idiom (`runStep` in ci.ts). `getTddTestCommand`
|
|
126
|
+
* only ever returns a fixed, controlled set of commands (`npm test`,
|
|
127
|
+
* `<buildTool> run test`, `pytest`, `go test ./...`) — never user input — so
|
|
128
|
+
* `bash -c` introduces no injection vector, and it fixes native Windows where
|
|
129
|
+
* `npm`/`pnpm`/`yarn` are `.cmd` shims that a shell-less `spawn` cannot exec
|
|
130
|
+
* (ENOENT). A non-zero exit or a spawn error is a blocking failure; a passing
|
|
131
|
+
* command is ok:true.
|
|
132
|
+
*/
|
|
133
|
+
function runTestCommand(cmd, projectDir) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
const proc = spawn("bash", ["-c", cmd], {
|
|
136
|
+
cwd: projectDir,
|
|
137
|
+
stdio: "inherit",
|
|
138
|
+
env: { ...process.env, CI: "true" },
|
|
139
|
+
});
|
|
140
|
+
proc.on("close", (code) => resolve(code === 0
|
|
141
|
+
? { ok: true }
|
|
142
|
+
: { ok: false, detail: `tests failed (exit ${code ?? "unknown"})` }));
|
|
143
|
+
proc.on("error", (e) => resolve({
|
|
144
|
+
ok: false,
|
|
145
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
146
|
+
}));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The `tdd` section: at hook-run time it detects the stack, resolves the stack
|
|
151
|
+
* test command and runs it. A null command (no test runner configured) is NOT a
|
|
152
|
+
* failure — it prints an honest skip notice and returns ok:true (parity with the
|
|
153
|
+
* old warning-only generated hook). A missing test runner must never block a
|
|
154
|
+
* commit or push. When the command resolves it runs and its exit code decides.
|
|
155
|
+
*/
|
|
156
|
+
export function tddSection(deps) {
|
|
157
|
+
return {
|
|
158
|
+
id: "tdd",
|
|
159
|
+
blocking: true,
|
|
160
|
+
async run({ projectDir }) {
|
|
161
|
+
// A thrown detector / resolver / runner must NEVER propagate out of the
|
|
162
|
+
// section — an unhandled rejection would crash the hook (raw stack trace)
|
|
163
|
+
// and, for an advisory pre-push tdd:"warn", would BLOCK the push,
|
|
164
|
+
// violating the advisory-never-blocks invariant. Mirror ciSection: on a
|
|
165
|
+
// throw, report a blocking-mappable failure instead.
|
|
166
|
+
try {
|
|
167
|
+
const { stackType, buildTool } = await deps.detectStack(projectDir);
|
|
168
|
+
const testCmd = await deps.resolveTestCmd(stackType, buildTool, projectDir);
|
|
169
|
+
if (testCmd === null) {
|
|
170
|
+
deps.log(` ⓘ tdd: no test command detected for stack '${stackType}' — skipping (a missing test runner does not block).`);
|
|
171
|
+
return { ok: true };
|
|
172
|
+
}
|
|
173
|
+
deps.log(` ▶ tdd: ${testCmd}`);
|
|
174
|
+
return await deps.runCommand(testCmd, projectDir);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The default section registry: the real `ci` and `tdd` factories. `tddDeps`
|
|
187
|
+
* overrides the tdd section's seams in tests (stack/command/runner); every
|
|
188
|
+
* omitted seam falls back to the real implementation.
|
|
189
|
+
*/
|
|
190
|
+
export function defaultRegistry(runCIImpl, log, tddDeps = {}) {
|
|
191
|
+
return {
|
|
192
|
+
ci: () => ciSection(runCIImpl, log),
|
|
193
|
+
tdd: () => tddSection({
|
|
194
|
+
detectStack: tddDeps.detectStack ?? detectCIStack,
|
|
195
|
+
resolveTestCmd: tddDeps.resolveTestCmd ?? getTddTestCommand,
|
|
196
|
+
runCommand: tddDeps.runCommand ?? runTestCommand,
|
|
197
|
+
log: tddDeps.log ?? log,
|
|
198
|
+
}),
|
|
199
|
+
// S4 security sections. Each resolves its git/fs/exec seams at run time
|
|
200
|
+
// via the section's own defaults; unit tests inject mocks by importing
|
|
201
|
+
// the factory directly (see src/commands/hooks/sections/*.test.ts).
|
|
202
|
+
secrets: () => secretsSection({ log }),
|
|
203
|
+
permissions: () => permissionsSection({ log }),
|
|
204
|
+
deps: () => depsSection({ log }),
|
|
205
|
+
};
|
|
119
206
|
}
|
|
120
207
|
/** Resolve the parsed `hooks:` config for a project (null → default [ci]). */
|
|
121
208
|
export async function loadHooksConfig(projectDir) {
|
|
@@ -1,28 +1,18 @@
|
|
|
1
1
|
import type { StepFn } from "../types.js";
|
|
2
2
|
/**
|
|
3
|
-
* Step 14: Scaffold security hooks.
|
|
3
|
+
* Step 14: Scaffold security hooks (hook-consolidation S4 fold).
|
|
4
4
|
*
|
|
5
5
|
* - When options.securityHooks is false, reports "skipped".
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
6
|
+
* - Otherwise:
|
|
7
|
+
* 1. Copies the kiteguard-style runtime settings to `.claude/settings.json`
|
|
8
|
+
* when absent (KEPT — this is a real feature).
|
|
9
|
+
* 2. Merges the `hooks:` security sections for the selected reliability
|
|
10
|
+
* profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
|
|
11
|
+
* minimal `version: 2` config when absent). The dispatcher composes these
|
|
12
|
+
* sections at hook-run time (see src/commands/hooks.ts).
|
|
13
|
+
* - The old inert `ci-local/hooks/security/` git-hook copy is GONE (those hook
|
|
14
|
+
* bodies were ported to TypeScript sections in S4).
|
|
10
15
|
* - Errors are swallowed and reported as status:"error" — never thrown.
|
|
11
|
-
*
|
|
12
|
-
* Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
|
|
13
|
-
* Grouped with stepHookProfile for cohesion — both manage ci-local/hooks/.
|
|
14
16
|
*/
|
|
15
17
|
export declare const stepSecurityHooks: StepFn;
|
|
16
|
-
/**
|
|
17
|
-
* Step 14b: Write hook reliability profile.
|
|
18
|
-
*
|
|
19
|
-
* - When options.securityHooks is false, reports "skipped".
|
|
20
|
-
* - Otherwise writes ci-local/hooks/profile.json with the resolved profile
|
|
21
|
-
* (defaults to "standard" when hookProfile is undefined).
|
|
22
|
-
* - Errors are swallowed and reported as status:"error" — never thrown.
|
|
23
|
-
*
|
|
24
|
-
* Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
|
|
25
|
-
* Grouped with stepSecurityHooks for cohesion — both manage ci-local/hooks/.
|
|
26
|
-
*/
|
|
27
|
-
export declare const stepHookProfile: StepFn;
|
|
28
18
|
//# sourceMappingURL=security.d.ts.map
|
|
@@ -1,103 +1,83 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
3
|
import { SECURITY_HOOKS_DIR } from "../../../constants.js";
|
|
4
|
+
import { setHookFeature } from "../../../lib/ci-config.js";
|
|
4
5
|
import { ensureDirExists } from "../../../lib/common.js";
|
|
5
6
|
import { report } from "../report.js";
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
+
* Hook-feature preset per reliability profile (hook-consolidation S4).
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
|
|
17
|
-
* Grouped with stepHookProfile for cohesion — both manage ci-local/hooks/.
|
|
10
|
+
* The old `stepHookProfile` wrote a `ci-local/hooks/profile.json` that had ZERO
|
|
11
|
+
* runtime readers (design D7). The selector is repurposed: the chosen profile
|
|
12
|
+
* now drives WHICH `hooks:` security sections get merged into
|
|
13
|
+
* `.javi-forge/ci.yaml`, using the EXISTING `HookProfile` values (no "relaxed"):
|
|
14
|
+
* - strict → secrets + permissions + deps (every section)
|
|
15
|
+
* - standard → secrets + deps
|
|
16
|
+
* - minimal → CI gate only (no security sections)
|
|
18
17
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
report(onStep, stepId, "Scaffold security hooks", "running");
|
|
24
|
-
try {
|
|
25
|
-
if (securityHooks) {
|
|
26
|
-
if (await fs.pathExists(SECURITY_HOOKS_DIR)) {
|
|
27
|
-
if (!dryRun) {
|
|
28
|
-
// Copy 6-layer git security hooks into ci-local/hooks/security/
|
|
29
|
-
const secHooksDest = path.join(projectDir, "ci-local", "hooks", "security");
|
|
30
|
-
await ensureDirExists(secHooksDest);
|
|
31
|
-
const hookFiles = await fs.readdir(SECURITY_HOOKS_DIR);
|
|
32
|
-
const gitHooks = hookFiles.filter((f) => !f.endsWith(".json"));
|
|
33
|
-
for (const hook of gitHooks) {
|
|
34
|
-
const src = path.join(SECURITY_HOOKS_DIR, hook);
|
|
35
|
-
const dest = path.join(secHooksDest, hook);
|
|
36
|
-
await fs.copy(src, dest, { overwrite: false });
|
|
37
|
-
await fs.chmod(dest, 0o755);
|
|
38
|
-
}
|
|
39
|
-
// Copy runtime security settings (kiteguard-style) to .claude/
|
|
40
|
-
const settingsSrc = path.join(SECURITY_HOOKS_DIR, "claude-settings-security.json");
|
|
41
|
-
if (await fs.pathExists(settingsSrc)) {
|
|
42
|
-
const claudeDir = path.join(projectDir, ".claude");
|
|
43
|
-
await ensureDirExists(claudeDir);
|
|
44
|
-
const settingsDest = path.join(claudeDir, "settings.json");
|
|
45
|
-
if (!(await fs.pathExists(settingsDest))) {
|
|
46
|
-
await fs.copy(settingsSrc, settingsDest);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
report(onStep, stepId, "Scaffold security hooks", "done", dryRun
|
|
51
|
-
? "dry-run: would scaffold security hooks"
|
|
52
|
-
: "6 git layers + runtime hooks");
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
report(onStep, stepId, "Scaffold security hooks", "error", "security-hooks templates not found");
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
report(onStep, stepId, "Scaffold security hooks", "skipped", "not selected");
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
catch (e) {
|
|
63
|
-
report(onStep, stepId, "Scaffold security hooks", "error", String(e));
|
|
64
|
-
}
|
|
18
|
+
const PROFILE_PRESET = {
|
|
19
|
+
minimal: { preCommit: [], prePush: [] },
|
|
20
|
+
standard: { preCommit: ["secrets"], prePush: ["deps"] },
|
|
21
|
+
strict: { preCommit: ["secrets", "permissions"], prePush: ["deps"] },
|
|
65
22
|
};
|
|
66
23
|
/**
|
|
67
|
-
* Step
|
|
24
|
+
* Step 14: Scaffold security hooks (hook-consolidation S4 fold).
|
|
68
25
|
*
|
|
69
26
|
* - When options.securityHooks is false, reports "skipped".
|
|
70
|
-
* - Otherwise
|
|
71
|
-
*
|
|
27
|
+
* - Otherwise:
|
|
28
|
+
* 1. Copies the kiteguard-style runtime settings to `.claude/settings.json`
|
|
29
|
+
* when absent (KEPT — this is a real feature).
|
|
30
|
+
* 2. Merges the `hooks:` security sections for the selected reliability
|
|
31
|
+
* profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
|
|
32
|
+
* minimal `version: 2` config when absent). The dispatcher composes these
|
|
33
|
+
* sections at hook-run time (see src/commands/hooks.ts).
|
|
34
|
+
* - The old inert `ci-local/hooks/security/` git-hook copy is GONE (those hook
|
|
35
|
+
* bodies were ported to TypeScript sections in S4).
|
|
72
36
|
* - Errors are swallowed and reported as status:"error" — never thrown.
|
|
73
|
-
*
|
|
74
|
-
* Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
|
|
75
|
-
* Grouped with stepSecurityHooks for cohesion — both manage ci-local/hooks/.
|
|
76
37
|
*/
|
|
77
|
-
export const
|
|
38
|
+
export const stepSecurityHooks = async (ctx) => {
|
|
78
39
|
const { projectDir, dryRun, onStep, options } = ctx;
|
|
79
40
|
const { securityHooks, hookProfile } = options;
|
|
80
|
-
const stepId = "
|
|
81
|
-
report(onStep, stepId, "
|
|
41
|
+
const stepId = "security-hooks";
|
|
42
|
+
report(onStep, stepId, "Scaffold security hooks", "running");
|
|
82
43
|
try {
|
|
83
|
-
if (securityHooks) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
44
|
+
if (!securityHooks) {
|
|
45
|
+
report(onStep, stepId, "Scaffold security hooks", "skipped", "not selected");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const profile = hookProfile ?? "standard";
|
|
49
|
+
const preset = PROFILE_PRESET[profile];
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
report(onStep, stepId, "Scaffold security hooks", "done", `dry-run: would merge ${profile} hooks preset + copy .claude/settings.json`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// 1. Copy the kiteguard-style runtime security settings to .claude/.
|
|
55
|
+
const settingsSrc = path.join(SECURITY_HOOKS_DIR, "claude-settings-security.json");
|
|
56
|
+
if (await fs.pathExists(settingsSrc)) {
|
|
57
|
+
const claudeDir = path.join(projectDir, ".claude");
|
|
58
|
+
await ensureDirExists(claudeDir);
|
|
59
|
+
const settingsDest = path.join(claudeDir, "settings.json");
|
|
60
|
+
if (!(await fs.pathExists(settingsDest))) {
|
|
61
|
+
await fs.copy(settingsSrc, settingsDest);
|
|
90
62
|
}
|
|
91
|
-
report(onStep, stepId, "Write hook reliability profile", "done", dryRun
|
|
92
|
-
? `dry-run: would write profile.json (${hookProfile ?? "standard"})`
|
|
93
|
-
: `ci-local/hooks/profile.json (${hookProfile ?? "standard"})`);
|
|
94
63
|
}
|
|
95
|
-
|
|
96
|
-
|
|
64
|
+
// 2. Merge the profile's security sections into .javi-forge/ci.yaml.
|
|
65
|
+
for (const feature of preset.preCommit) {
|
|
66
|
+
await setHookFeature(projectDir, "pre-commit", feature, true);
|
|
67
|
+
}
|
|
68
|
+
for (const feature of preset.prePush) {
|
|
69
|
+
await setHookFeature(projectDir, "pre-push", feature, true);
|
|
97
70
|
}
|
|
71
|
+
const merged = [
|
|
72
|
+
...preset.preCommit.map((f) => `pre-commit.${f}`),
|
|
73
|
+
...preset.prePush.map((f) => `pre-push.${f}`),
|
|
74
|
+
];
|
|
75
|
+
report(onStep, stepId, "Scaffold security hooks", "done", merged.length > 0
|
|
76
|
+
? `${profile} preset: ${merged.join(", ")}`
|
|
77
|
+
: `${profile} preset: CI gate only (no security sections)`);
|
|
98
78
|
}
|
|
99
79
|
catch (e) {
|
|
100
|
-
report(onStep, stepId, "
|
|
80
|
+
report(onStep, stepId, "Scaffold security hooks", "error", String(e));
|
|
101
81
|
}
|
|
102
82
|
};
|
|
103
83
|
//# sourceMappingURL=security.js.map
|
package/dist/commands/init.js
CHANGED
|
@@ -14,7 +14,7 @@ import { stepManifest } from "./init/steps/manifest.js";
|
|
|
14
14
|
import { stepMemory } from "./init/steps/memory.js";
|
|
15
15
|
import { stepMock } from "./init/steps/mock.js";
|
|
16
16
|
import { stepSDD } from "./init/steps/sdd.js";
|
|
17
|
-
import {
|
|
17
|
+
import { stepSecurityHooks } from "./init/steps/security.js";
|
|
18
18
|
/**
|
|
19
19
|
* Main init orchestrator: bootstraps a project with CI, git hooks,
|
|
20
20
|
* memory module, AI config sync, SDD, ghagga, and friends.
|
|
@@ -43,7 +43,6 @@ export async function initProject(options, onStep) {
|
|
|
43
43
|
await stepClaudeMd(ctx);
|
|
44
44
|
await stepDockerDeploy(ctx);
|
|
45
45
|
await stepSecurityHooks(ctx);
|
|
46
|
-
await stepHookProfile(ctx);
|
|
47
46
|
await stepCodeGraph(ctx);
|
|
48
47
|
await stepLocalAi(ctx);
|
|
49
48
|
await stepAgentSkills(ctx);
|
package/dist/commands/tdd.d.ts
CHANGED
|
@@ -1,21 +1,7 @@
|
|
|
1
1
|
import type { Stack } from "../types/index.js";
|
|
2
|
-
export interface TddHookResult {
|
|
3
|
-
installed: string[];
|
|
4
|
-
errors: string[];
|
|
5
|
-
}
|
|
6
2
|
/**
|
|
7
3
|
* Resolve the correct test command for TDD hook based on stack and build tool.
|
|
8
4
|
* Returns null if no test command can be determined.
|
|
9
5
|
*/
|
|
10
6
|
export declare function getTddTestCommand(stack: Stack, buildTool: string, projectDir: string): Promise<string | null>;
|
|
11
|
-
/**
|
|
12
|
-
* Generate a TDD-enforcing pre-commit hook script.
|
|
13
|
-
* If testCmd is null, generates a warning-only hook.
|
|
14
|
-
*/
|
|
15
|
-
export declare function generateTddHook(testCmd: string | null, stack: Stack): string;
|
|
16
|
-
/**
|
|
17
|
-
* Install TDD pre-commit hook into .git/hooks/.
|
|
18
|
-
* Detects the project stack automatically and generates the appropriate hook.
|
|
19
|
-
*/
|
|
20
|
-
export declare function installTddHooks(projectDir: string): Promise<TddHookResult>;
|
|
21
7
|
//# sourceMappingURL=tdd.d.ts.map
|