signoff-cli 0.0.0-stage → 4.0.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 (57) hide show
  1. package/.pre-commit-hooks.yaml +27 -0
  2. package/CHANGELOG.md +336 -0
  3. package/LICENSE +21 -0
  4. package/README.md +540 -3
  5. package/action.yml +24 -0
  6. package/bin/signoff.js +218 -0
  7. package/docs/diagrams/README.md +41 -0
  8. package/docs/diagrams/signoff-1-capability-en.png +0 -0
  9. package/docs/diagrams/signoff-1-capability.png +0 -0
  10. package/docs/diagrams/signoff-2-market-en.png +0 -0
  11. package/docs/diagrams/signoff-2-market.png +0 -0
  12. package/docs/diagrams/signoff-3-user-en.png +0 -0
  13. package/docs/diagrams/signoff-3-user.png +0 -0
  14. package/docs/diagrams/signoff-4-flow-en.png +0 -0
  15. package/docs/diagrams/signoff-4-flow.png +0 -0
  16. package/docs/diagrams/signoff-5-competitors-en.png +0 -0
  17. package/docs/diagrams/signoff-5-competitors.png +0 -0
  18. package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
  19. package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
  20. package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
  21. package/docs/diagrams/src/signoff-2-market.mmd +72 -0
  22. package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
  23. package/docs/diagrams/src/signoff-3-user.mmd +92 -0
  24. package/docs/gates.en.md +105 -0
  25. package/docs/gates.md +88 -0
  26. package/docs/integrations.md +58 -0
  27. package/docs/promotion.md +71 -0
  28. package/docs/signoff.schema.json +108 -0
  29. package/docs/trust.md +72 -0
  30. package/gates/guard.py +474 -0
  31. package/gates/p1_verify.py +111 -0
  32. package/gates/p2_coverage.py +127 -0
  33. package/gates/p3_queue.py +186 -0
  34. package/gates/p4_ledger.py +249 -0
  35. package/gates/p5_slop.py +109 -0
  36. package/lib/audit.js +213 -0
  37. package/lib/bootstrap.js +361 -0
  38. package/lib/checkpoint.js +184 -0
  39. package/lib/ci.js +58 -0
  40. package/lib/core.js +120 -0
  41. package/lib/cov.js +253 -0
  42. package/lib/detect.js +169 -0
  43. package/lib/doctor.js +304 -0
  44. package/lib/editor.js +97 -0
  45. package/lib/explain.js +73 -0
  46. package/lib/guard.js +1229 -0
  47. package/lib/p1.js +469 -0
  48. package/lib/p2.js +323 -0
  49. package/lib/p3.js +633 -0
  50. package/lib/p4.js +733 -0
  51. package/lib/p5.js +438 -0
  52. package/lib/preset.js +66 -0
  53. package/lib/secrets.js +336 -0
  54. package/lib/sign.js +196 -0
  55. package/package.json +55 -6
  56. package/test/selftest.js +1226 -0
  57. package/test/selftest.py +190 -0
package/lib/audit.js ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // signoff audit — did the commits that landed actually carry a Signoff trailer?
4
+ //
5
+ // The pre-commit hook records what it saw in `.signoff/passed` (staged tree + mode)
6
+ // and the commit-msg hook turns that record into a `Signoff-Pass:` / `Signoff-Skip:`
7
+ // line on the message. `git commit --no-verify` skips both hooks, so that commit
8
+ // carries no trailer at all — this command is how that stops being invisible after
9
+ // the fact: a trailer only counts when its tree prefix matches the commit's tree.
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const core = require("./core");
14
+
15
+ const DEFAULT_RANGE = "HEAD~1..HEAD";
16
+ const TRAILER = /^Signoff-(Pass|Skip): ([0-9a-f]{12})$/;
17
+ const SIG_LINE = /^Signoff-(Key|Sig): (.+)$/;
18
+ const USAGE = `usage: signoff audit [-h] [--range A..B] [--since-install] [--json] [--strict] [--verify] [--require-signed]
19
+
20
+ Did the commits that landed carry a Signoff trailer?
21
+
22
+ options:
23
+ -h, --help show this help message and exit
24
+ --range A..B commit range to audit (default: ${DEFAULT_RANGE})
25
+ --since-install audit from the commit recorded by signoff init
26
+ --json machine-readable result
27
+ --strict also fail on Signoff-Skip commits
28
+ --verify check each commit's Signoff-Sig against its Signoff-Key
29
+ (slow: one ssh-keygen per commit)
30
+ --require-signed fail when a commit carries no signature at all
31
+ `;
32
+
33
+ function warn(msg) {
34
+ process.stderr.write(`signoff audit: ${msg}\n`);
35
+ }
36
+
37
+ // `.signoff/installed.json` is written by `signoff init`; older repos do not have it.
38
+ function installPoint() {
39
+ const top = core.git(["rev-parse", "--show-toplevel"]);
40
+ if (top.code !== 0) return null;
41
+ const p = path.join(top.out.trim(), ".signoff", "installed.json");
42
+ try {
43
+ const d = JSON.parse(fs.readFileSync(p, "utf8"));
44
+ return d && typeof d.sha === "string" ? d : null;
45
+ } catch (e) {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function isAncestor(sha, of) {
51
+ return core.git(["merge-base", "--is-ancestor", sha, of]).code === 0;
52
+ }
53
+
54
+ function run(argv) {
55
+ const args = core.parseArgs(argv || [], { verify: false, requireSigned: false });
56
+ if (args.help || args._.indexOf("-h") !== -1) {
57
+ console.log(USAGE);
58
+ return 0;
59
+ }
60
+
61
+ const install = installPoint();
62
+ const sinceInstall = !!args.sinceInstall;
63
+ let range = DEFAULT_RANGE;
64
+ if (sinceInstall) {
65
+ if (!install || !install.sha) {
66
+ if (install && !install.sha) {
67
+ warn("install point has no commit yet (the first bootstrap ran mid-commit)");
68
+ warn("run `signoff init` once more after committing, or audit --range A..B");
69
+ } else {
70
+ warn("no install point recorded (run `signoff init`, or pass --range A..B)");
71
+ }
72
+ return 2;
73
+ }
74
+ // The install point is a commit id. If history was rewritten (rebase, force-push)
75
+ // or this is a different clone, that id is not an ancestor of HEAD and the range
76
+ // silently covers the wrong commits - a wrong audit is worse than a refused one.
77
+ const known = core.git(["cat-file", "-e", `${install.sha}^{commit}`]);
78
+ const anc = known.code === 0 ? core.git(["merge-base", "--is-ancestor", install.sha, "HEAD"]) : { code: 1 };
79
+ if (known.code !== 0 || anc.code !== 0) {
80
+ warn(`install point ${String(install.sha).slice(0, 12)} is not an ancestor of HEAD`);
81
+ warn("history was rewritten or this is another clone; run: signoff init --reset-install");
82
+ warn("(or audit an explicit window with --range A..B)");
83
+ return 2;
84
+ }
85
+ range = `${install.sha}..HEAD`;
86
+ }
87
+ if (args.range !== undefined) {
88
+ if (args.range === true) {
89
+ warn("--range needs a value, e.g. --range HEAD~1..HEAD");
90
+ return 2;
91
+ }
92
+ range = String(args.range);
93
+ }
94
+ const strict = !!args.strict;
95
+ const asJson = !!args.json;
96
+
97
+ if (core.git(["rev-parse", "--git-dir"]).code !== 0) {
98
+ warn("not a git repository (run inside a repo)");
99
+ return 2;
100
+ }
101
+ const listed = core.git(["rev-list", range]);
102
+ if (listed.code !== 0) {
103
+ warn(`cannot parse range '${range}' (try --range HEAD~1..HEAD)`);
104
+ return 2;
105
+ }
106
+
107
+ const shas = listed.out
108
+ .split(/\r?\n/)
109
+ .map((s) => s.trim())
110
+ .filter(Boolean);
111
+ const counts = { pass: 0, skip: 0, missing: 0 };
112
+ const sigCounts = {};
113
+ const verify = !!args.verify;
114
+ const sigcheck = require("./sign");
115
+ const allowed = verify ? sigcheck.allowedKeys(core.git(["rev-parse", "--show-toplevel"]).out.trim() || process.cwd()) : [];
116
+ const commits = [];
117
+ let merges = 0;
118
+
119
+ for (const sha of shas) {
120
+ // A merge has no single staged tree, so it cannot be accountable this way.
121
+ const parents = core.git(["log", "-1", "--format=%P", sha])
122
+ .out.split(/\s+/)
123
+ .filter(Boolean);
124
+ if (parents.length > 1) {
125
+ merges++;
126
+ continue;
127
+ }
128
+ const tree = core.git(["rev-parse", `${sha}^{tree}`]).out.trim().slice(0, 12);
129
+ const body = core.git(["log", "-1", "--format=%B", sha]).out;
130
+ let pass = null;
131
+ let skip = null;
132
+ for (const line of body.split(/\r?\n/)) {
133
+ const m = TRAILER.exec(line.trim());
134
+ if (!m) continue;
135
+ if (m[1] === "Pass") pass = m[2];
136
+ else skip = m[2];
137
+ }
138
+ let state = "missing";
139
+ if (pass !== null && pass === tree) state = "pass";
140
+ else if (skip !== null && skip === tree) state = "skip";
141
+ counts[state]++;
142
+ const entry = { sha, state };
143
+ if (verify) {
144
+ let key = null;
145
+ let sig = null;
146
+ for (const line of body.split(/\r?\n/)) {
147
+ const m = SIG_LINE.exec(line.trim());
148
+ if (!m) continue;
149
+ if (m[1] === "Key") key = m[2].trim();
150
+ else sig = m[2].trim();
151
+ }
152
+ if (state !== "pass") {
153
+ entry.sig = "unsigned";
154
+ } else if (!key || !sig) {
155
+ entry.sig = "unsigned";
156
+ } else {
157
+ const full = core.git(["rev-parse", `${sha}^{tree}`]).out.trim();
158
+ const v = sigcheck.verify(full, key, sig, allowed);
159
+ entry.sig = v.state;
160
+ if (v.reason) entry.why = v.reason;
161
+ }
162
+ sigCounts[entry.sig] = (sigCounts[entry.sig] || 0) + 1;
163
+ }
164
+ commits.push(entry);
165
+ }
166
+
167
+ // A missing commit that predates the install point cannot carry a trailer: the guard
168
+ // did not exist yet. Say so instead of leaving the number unexplained.
169
+ let preInstall = 0;
170
+ if (counts.missing > 0 && install && install.sha && !sinceInstall) {
171
+ for (const c of commits) {
172
+ if (c.state !== "missing") continue;
173
+ if (isAncestor(c.sha, install.sha)) preInstall++;
174
+ }
175
+ }
176
+
177
+ let ok = counts.missing === 0 && !(strict && counts.skip > 0);
178
+ if (verify) {
179
+ if ((sigCounts["bad-signature"] || 0) > 0) ok = false;
180
+ if (args.requireSigned && (sigCounts.unsigned || 0) > 0) ok = false;
181
+ }
182
+
183
+ if (asJson) {
184
+ const payload = { ok, range, strict, counts, skipped_merges: merges, commits };
185
+ if (verify) payload.signatures = sigCounts;
186
+ if (install && install.sha) payload.install = { sha: install.sha, pre_install_missing: preInstall };
187
+ core.json(payload);
188
+ } else {
189
+ for (const c of commits) console.log(`${c.sha.slice(0, 7)} ${c.state}`);
190
+ console.log(
191
+ `audit: ${shas.length} commits, pass=${counts.pass} skip=${counts.skip} ` +
192
+ `missing=${counts.missing} skipped_merges=${merges}`
193
+ );
194
+ if (strict && counts.skip > 0) console.log("audit: strict, so Signoff-Skip commits are failures");
195
+ if (verify) {
196
+ const total = Object.values(sigCounts).reduce((n, v) => n + v, 0);
197
+ const parts = Object.entries(sigCounts).map(([k, v]) => `${k}=${v}`).join(" ");
198
+ console.log(`audit: signatures ${parts || "none"} (of ${total} audited)`);
199
+ if (!(sigCounts.verified || 0) && total) {
200
+ console.log("audit: no key matched .signoff/allowed_signers (self-signed is not an identity)");
201
+ }
202
+ }
203
+ if (preInstall > 0) {
204
+ console.log(
205
+ `audit: ${preInstall} of ${counts.missing} missing commit(s) predate the install point ` +
206
+ `(${install.sha.slice(0, 12)}); use --since-install`
207
+ );
208
+ }
209
+ }
210
+ return ok ? 0 : 1;
211
+ }
212
+
213
+ module.exports = { run, USAGE, DEFAULT_RANGE };
@@ -0,0 +1,361 @@
1
+ "use strict";
2
+ // Zero-touch bootstrap: after one `npm i -g signoff-cli`, nothing else is typed.
3
+ //
4
+ // Two layers, both idempotent and silent unless they change something:
5
+ // 1. repo layer — the first gate run (or `npm i` inside a repo) writes .git/hooks,
6
+ // .signoff/config.json, .signoff/installed.json and the CI workflow.
7
+ // 2. machine layer — `core.hooksPath` points at ~/.signoff/hooks, so a fresh clone
8
+ // of any repo is guarded on the next commit without per-repo install.
9
+ // The machine layer delegates to the repo's own hook first, then to the gates, so an
10
+ // existing hook keeps its verdict.
11
+ //
12
+ // Opt out per shell with SIGNOFF_NO_BOOTSTRAP=1, per machine with `signoff uninstall --global`.
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const os = require("os");
17
+ const { spawnSync } = require("child_process");
18
+ const core = require("./core");
19
+
20
+ const HOOK_MARK = "# generated by signoff-cli";
21
+ const LEGACY_HOOK_MARK = "# generated by dsh-signoff";
22
+ function markedByUs(text) {
23
+ const t = String(text || "");
24
+ return t.includes(HOOK_MARK) || t.includes(LEGACY_HOOK_MARK);
25
+ }
26
+ const STATE = ".signoff";
27
+ const STAGES = ["pre-commit", "commit-msg", "pre-push"];
28
+ const DECLARATION = "signoff.json";
29
+
30
+ function gitRoot(cwd) {
31
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
32
+ if (r.status !== 0) return null;
33
+ const out = (r.stdout || "").trim().split(/\r?\n/).pop();
34
+ return out ? out : null;
35
+ }
36
+
37
+ function gitDir(root) {
38
+ const r = spawnSync("git", ["rev-parse", "--git-dir"], { cwd: root, encoding: "utf8" });
39
+ if (r.status !== 0) return null;
40
+ const raw = (r.stdout || "").trim();
41
+ return path.isAbsolute(raw) ? raw : path.join(root, raw);
42
+ }
43
+
44
+ // A committed `signoff.json` at the repo root is the repository saying "run all five".
45
+ // It has to be committed to be useful: `.signoff/` is gitignored, so a teammate's clone
46
+ // arrives with the declaration and nothing else, and the first commit builds the rest.
47
+ function declaration(p) {
48
+ const root = path.resolve(p || process.cwd());
49
+ const file = path.join(root, DECLARATION);
50
+ if (!isFile(file)) return null;
51
+ try {
52
+ const parsed = JSON.parse(readSafe(file));
53
+ return parsed && typeof parsed === "object" ? parsed : {};
54
+ } catch (e) {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // Declaration fields are a small, documented subset: anything else is ignored, because a
60
+ // committed file must never be able to widen what the gates check on someone else's clone.
61
+ function applyDeclaration(root, decl) {
62
+ const file = path.join(root, STATE, "config.json");
63
+ let cfg;
64
+ try {
65
+ cfg = JSON.parse(readSafe(file));
66
+ } catch (e) {
67
+ return;
68
+ }
69
+ if (!cfg || typeof cfg !== "object") return;
70
+ if (typeof decl.test_cmd === "string" && decl.test_cmd) cfg.test_cmd = decl.test_cmd;
71
+ if (typeof decl.budget === "number" && Number.isFinite(decl.budget)) cfg.budget = decl.budget;
72
+ if (Array.isArray(decl.docs) && decl.docs.length) cfg.docs = decl.docs.map(String);
73
+ if (typeof decl.pre_push === "boolean") cfg.pre_push = decl.pre_push;
74
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n", "utf8");
75
+ }
76
+
77
+ function readSafe(p) {
78
+ try {
79
+ return fs.readFileSync(p, "utf8");
80
+ } catch (e) {
81
+ return "";
82
+ }
83
+ }
84
+
85
+ function isFile(p) {
86
+ try {
87
+ return fs.statSync(p).isFile();
88
+ } catch (e) {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function hookState(root) {
94
+ const gd = gitDir(root);
95
+ const hooks = gd ? path.join(gd, "hooks") : null;
96
+ const out = {};
97
+ for (const stage of STAGES) {
98
+ const p = hooks ? path.join(hooks, stage) : "";
99
+ out[stage] = !!(p && isFile(p) && markedByUs(readSafe(p)));
100
+ }
101
+ return out;
102
+ }
103
+
104
+ // What a repo still needs. `guarded` means every piece the hooks depend on exists.
105
+ function inspect(root) {
106
+ const hooks = hookState(root);
107
+ const st = path.join(root, STATE);
108
+ const config = isFile(path.join(st, "config.json"));
109
+ const installed = isFile(path.join(st, "installed.json"));
110
+ const ci = isFile(path.join(root, ".github", "workflows", "signoff.yml"));
111
+ const guarded = hooks["pre-commit"] && hooks["commit-msg"] && config;
112
+ return { root, hooks, config, installed, ci, guarded };
113
+ }
114
+
115
+ // Write the package's init in `root` without touching the caller's cwd for longer than
116
+ // the call. guard.main reads process.cwd(), so the chdir is the seam.
117
+ function initRepo(root, quiet) {
118
+ const guard = require("./guard");
119
+ const before = process.cwd();
120
+ let code = 1;
121
+ try {
122
+ process.chdir(root);
123
+ code = guard.main(["init", "--chain", ...(quiet ? ["--quiet"] : [])]) || 0;
124
+ } catch (e) {
125
+ code = 1;
126
+ } finally {
127
+ process.chdir(before);
128
+ }
129
+ return code;
130
+ }
131
+
132
+ // Returns {ok, root, installed, changed[], code}. Never throws: it runs inside npm
133
+ // postinstall and inside git hooks, where a crash would be worse than a skipped install.
134
+ function ensureRepo(opts) {
135
+ const o = opts || {};
136
+ const cwd = o.cwd || process.cwd();
137
+ const root = gitRoot(cwd);
138
+ if (!root) {
139
+ return { ok: false, root: null, installed: false, changed: [], reason: "not a git repository", code: 2 };
140
+ }
141
+ const before = inspect(root);
142
+ if (before.guarded && before.installed) {
143
+ return { ok: true, root, installed: false, changed: [], code: 0, state: before };
144
+ }
145
+ const code = initRepo(root, true);
146
+ if (!before.config) {
147
+ const decl = o.decl || declaration(root);
148
+ if (decl) applyDeclaration(root, decl);
149
+ }
150
+ const after = inspect(root);
151
+ const changed = [];
152
+ if (!before.hooks["pre-commit"] && after.hooks["pre-commit"]) changed.push("hooks");
153
+ if (!before.config && after.config) changed.push("config");
154
+ if (!before.installed && after.installed) changed.push("installed.json");
155
+ if (!before.ci && after.ci) changed.push("ci");
156
+ return { ok: code === 0 && after.guarded, root, installed: changed.length > 0, changed, code, state: after };
157
+ }
158
+
159
+ // ---------------------------------------------------------------- machine layer
160
+
161
+ function globalHooksDir() {
162
+ return path.join(core.homeDir(), "hooks");
163
+ }
164
+
165
+ function dispatcherBody(stage) {
166
+ return `#!/bin/sh
167
+ ${HOOK_MARK} ${require("../package.json").version} -- remove with: signoff uninstall --global
168
+ # Machine-wide dispatcher: hand the verdict to the repo's own hook first. If that hook is
169
+ # ours it already ran the gates, so stop there; otherwise add the light guard on top.
170
+ STAGE=${stage}
171
+ GITDIR=$(git rev-parse --git-dir 2>/dev/null) || exit 0
172
+ LOCAL="$GITDIR/hooks/$STAGE"
173
+ if [ -f "$LOCAL" ]; then
174
+ if [ -x "$LOCAL" ]; then
175
+ "$LOCAL" "$@" || exit $?
176
+ else
177
+ case "$LOCAL" in
178
+ *.py) PY=$(command -v python3 || command -v python || command -v py) || { echo "[signoff] chained hook needs python: $LOCAL" >&2; exit 1; }; "$PY" "$LOCAL" "$@" || exit $? ;;
179
+ *.js) node "$LOCAL" "$@" || exit $? ;;
180
+ *.ps1) powershell -NoProfile -ExecutionPolicy Bypass -File "$LOCAL" "$@" || exit $? ;;
181
+ *.cmd) cmd //c "$LOCAL" "$@" || exit $? ;;
182
+ *) sh "$LOCAL" "$@" || exit $? ;;
183
+ esac
184
+ fi
185
+ if grep -q "${HOOK_MARK}" "$LOCAL" 2>/dev/null || grep -q "${LEGACY_HOOK_MARK}" "$LOCAL" 2>/dev/null; then
186
+ exit 0
187
+ fi
188
+ fi
189
+ if command -v signoff >/dev/null 2>&1; then
190
+ exec signoff hook $STAGE "$@"
191
+ fi
192
+ if [ -f "./node_modules/signoff-cli/bin/signoff.js" ]; then
193
+ exec node "./node_modules/signoff-cli/bin/signoff.js" hook $STAGE "$@"
194
+ fi
195
+ if [ -f "./node_modules/dsh-signoff/bin/signoff.js" ]; then
196
+ exec node "./node_modules/dsh-signoff/bin/signoff.js" hook $STAGE "$@"
197
+ fi
198
+ if [ "$STAGE" = "commit-msg" ]; then
199
+ echo "[signoff] CLI not found: the commit continues, but it gets no Signoff trailer" >&2
200
+ exit 0
201
+ fi
202
+ echo "[signoff] CLI not found: npm i -g signoff-cli (or add it as a devDependency)" >&2
203
+ exit 1
204
+ `;
205
+ }
206
+
207
+ function writeExec(p, text) {
208
+ fs.mkdirSync(path.dirname(p), { recursive: true });
209
+ fs.writeFileSync(p, text, "utf8");
210
+ try {
211
+ fs.chmodSync(p, 0o755);
212
+ } catch (e) {
213
+ /* Windows has no exec bit; git runs the hook through sh anyway. */
214
+ }
215
+ }
216
+
217
+ function globalHooksPathSetting() {
218
+ const r = spawnSync("git", ["config", "--global", "--get", "core.hooksPath"], { encoding: "utf8" });
219
+ if (r.status !== 0) return "";
220
+ return (r.stdout || "").trim();
221
+ }
222
+
223
+ function samePath(a, b) {
224
+ if (!a || !b) return false;
225
+ const norm = (p) => path.resolve(p).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
226
+ const na = norm(a);
227
+ const nb = norm(b);
228
+ if (na === nb) return true;
229
+ // git may store `~/...`; compare the expanded forms too.
230
+ if (a.startsWith("~/")) return norm(path.join(os.homedir(), a.slice(2))) === nb;
231
+ return false;
232
+ }
233
+
234
+ function setGlobalHooksPath(dir) {
235
+ const value = dir.replace(/\\/g, "/");
236
+ const r = spawnSync("git", ["config", "--global", "core.hooksPath", value], { encoding: "utf8" });
237
+ return r.status === 0;
238
+ }
239
+
240
+ function ensureGlobal(opts) {
241
+ const o = opts || {};
242
+ const dir = globalHooksDir();
243
+ const existing = globalHooksPathSetting();
244
+ if (existing && !samePath(existing, dir) && !o.force) {
245
+ return {
246
+ ok: false,
247
+ installed: false,
248
+ dir,
249
+ skipped: true,
250
+ reason: `core.hooksPath already points at ${existing} (use --force to replace it)`,
251
+ code: 0,
252
+ };
253
+ }
254
+ const changed = [];
255
+ for (const stage of STAGES) {
256
+ const p = path.join(dir, stage);
257
+ const body = dispatcherBody(stage);
258
+ if (readSafe(p) !== body) {
259
+ writeExec(p, body);
260
+ changed.push(stage);
261
+ }
262
+ }
263
+ const wrote = setGlobalHooksPath(dir);
264
+ if (wrote) changed.push("core.hooksPath");
265
+ return { ok: true, installed: changed.length > 0, dir, changed, code: 0 };
266
+ }
267
+
268
+ function uninstallGlobal() {
269
+ const dir = globalHooksDir();
270
+ const existing = globalHooksPathSetting();
271
+ const owned = existing && samePath(existing, dir);
272
+ if (owned) spawnSync("git", ["config", "--global", "--unset", "core.hooksPath"], { encoding: "utf8" });
273
+ for (const stage of STAGES) {
274
+ const p = path.join(dir, stage);
275
+ if (isFile(p) && markedByUs(readSafe(p))) fs.rmSync(p, { force: true });
276
+ }
277
+ return { ok: true, owned, dir, removed: owned };
278
+ }
279
+
280
+ function status() {
281
+ const root = gitRoot(process.cwd());
282
+ const cfg = globalHooksPathSetting();
283
+ const dir = globalHooksDir();
284
+ return {
285
+ repo: root,
286
+ repoState: root ? inspect(root) : null,
287
+ global: { configured: cfg, expected: dir.replace(/\\/g, "/"), active: !!cfg && samePath(cfg, dir) },
288
+ };
289
+ }
290
+
291
+ // ---------------------------------------------------------------- CLI
292
+
293
+ const HELP = `signoff ensure — one-time (and then automatic) install
294
+
295
+ signoff ensure guard this repo now: hooks, config, CI, ledger
296
+ signoff ensure --global guard every repo on this machine (core.hooksPath)
297
+ signoff ensure --json machine-readable result
298
+ signoff ensure --quiet say nothing unless something changed
299
+
300
+ Every gate command (p1..p5, all, guard, hook) runs the repo layer by itself, so a clone
301
+ that never saw \`signoff init\` is guarded on its first commit. Opt out with
302
+ SIGNOFF_NO_BOOTSTRAP=1; undo the machine layer with \`signoff uninstall --global\`.
303
+ `;
304
+
305
+ function run(argv) {
306
+ const args = argv || [];
307
+ if (args.includes("-h") || args.includes("--help")) {
308
+ console.log(HELP);
309
+ return 0;
310
+ }
311
+ const json = args.includes("--json");
312
+ const quiet = args.includes("--quiet");
313
+ const global = args.includes("--global");
314
+ const force = args.includes("--force");
315
+ const fromInstall = args.includes("--from-install");
316
+
317
+ let repo = null;
318
+ if (!global || fromInstall) {
319
+ const cwd = fromInstall && process.env.INIT_CWD ? process.env.INIT_CWD : process.cwd();
320
+ repo = ensureRepo({ cwd, quiet });
321
+ }
322
+ let machine = null;
323
+ if (global || fromInstall) machine = ensureGlobal({ force });
324
+
325
+ if (json) {
326
+ console.log(JSON.stringify({ repo, machine }, null, 2));
327
+ } else if (!quiet) {
328
+ if (repo && repo.root) {
329
+ console.log(repo.installed ? `signoff: guarded ${path.basename(repo.root)} (${repo.changed.join(", ")})` : `signoff: ${path.basename(repo.root)} already guarded`);
330
+ } else if (repo) {
331
+ console.log(`signoff: ${repo.reason}`);
332
+ }
333
+ if (machine) {
334
+ if (machine.skipped) console.log(`signoff: machine layer skipped — ${machine.reason}`);
335
+ else console.log(machine.installed ? `signoff: machine layer -> ${machine.dir}` : "signoff: machine layer already in place");
336
+ }
337
+ }
338
+
339
+ // postinstall must never fail an npm install.
340
+ if (fromInstall) return 0;
341
+ if (repo && !repo.ok && repo.code === 2 && !global) return 2;
342
+ if (repo && !repo.ok && repo.code) return 1;
343
+ return 0;
344
+ }
345
+
346
+ module.exports = {
347
+ HOOK_MARK,
348
+ LEGACY_HOOK_MARK,
349
+ markedByUs,
350
+ DECLARATION,
351
+ declaration,
352
+ gitRoot,
353
+ inspect,
354
+ ensureRepo,
355
+ ensureGlobal,
356
+ uninstallGlobal,
357
+ globalHooksDir,
358
+ globalHooksPathSetting,
359
+ status,
360
+ run,
361
+ };