hypomnema 1.7.0 → 1.7.2
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +79 -50
- package/README.md +63 -34
- package/hooks/hooks.json +11 -0
- package/hooks/hypo-auto-commit.mjs +92 -15
- package/hooks/hypo-auto-stage.mjs +28 -18
- package/hooks/hypo-close-guard.mjs +246 -0
- package/hooks/hypo-hot-rebuild.mjs +43 -5
- package/hooks/hypo-session-start.mjs +166 -13
- package/hooks/hypo-shared.mjs +1358 -130
- package/hooks/version-check.mjs +92 -0
- package/package.json +4 -1
- package/scripts/crystallize.mjs +112 -2
- package/scripts/doctor.mjs +627 -17
- package/scripts/graph.mjs +27 -12
- package/scripts/init.mjs +66 -7
- package/scripts/lib/git-hooks-dir.mjs +229 -0
- package/scripts/lib/pkg-provenance.mjs +166 -0
- package/scripts/lib/project-create.mjs +5 -1
- package/scripts/lib/rename-marker.mjs +39 -0
- package/scripts/lint.mjs +84 -2
- package/scripts/rename.mjs +223 -18
- package/scripts/stats.mjs +14 -2
- package/scripts/uninstall.mjs +12 -0
- package/scripts/upgrade.mjs +8 -0
- package/templates/gitignore +4 -0
- package/templates/hypo-config.md +1 -1
package/scripts/graph.mjs
CHANGED
|
@@ -91,7 +91,7 @@ function escapeDot(s) {
|
|
|
91
91
|
|
|
92
92
|
// ── formatters ────────────────────────────────────────────────────────────────
|
|
93
93
|
|
|
94
|
-
function formatJson(pages, graph, minEdges) {
|
|
94
|
+
function formatJson(pages, graph, minEdges, vaultFound) {
|
|
95
95
|
const nodes = pages
|
|
96
96
|
.map((p) => ({
|
|
97
97
|
slug: p.slug,
|
|
@@ -107,7 +107,9 @@ function formatJson(pages, graph, minEdges) {
|
|
|
107
107
|
return fn && tn;
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
// vaultFound: same reasoning as lint.mjs/stats.mjs — an empty graph from a
|
|
111
|
+
// vault that was never found must not read the same as a real, linkless one.
|
|
112
|
+
return JSON.stringify({ nodes, edges, vaultFound }, null, 2);
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
function formatMermaid(pages, graph, minEdges) {
|
|
@@ -156,7 +158,11 @@ function formatDot(pages, graph, minEdges) {
|
|
|
156
158
|
const args = parseArgs(process.argv);
|
|
157
159
|
// Only validate the auto-resolved path (env/marker/default). An explicit
|
|
158
160
|
// --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
|
|
159
|
-
|
|
161
|
+
// See lint.mjs's matching comment: source 'default'/stale-'marker' stays
|
|
162
|
+
// exit-0 (CI-safe).
|
|
163
|
+
const vaultMissing = args.hypoDirSource
|
|
164
|
+
? checkVaultOrExit(args.hypoDir, args.hypoDirSource)
|
|
165
|
+
: false;
|
|
160
166
|
|
|
161
167
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
162
168
|
const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
|
|
@@ -164,13 +170,22 @@ const pages = scanDirs.flatMap((d) => collectPagesGraph(d, args.hypoDir, ignoreP
|
|
|
164
170
|
const slugIndex = buildSlugIndex(pages);
|
|
165
171
|
const graph = buildGraph(pages, slugIndex);
|
|
166
172
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
if (vaultMissing && (args.format === 'mermaid' || args.format === 'dot')) {
|
|
174
|
+
// Same reasoning as lint.mjs/stats.mjs: mermaid/dot render straight to a
|
|
175
|
+
// diagram tool with no JSON envelope to carry `vaultFound`, so a valid but
|
|
176
|
+
// linkless diagram from a real (empty) vault and "no vault was found at
|
|
177
|
+
// all" would otherwise render identically. checkVaultOrExit already put the
|
|
178
|
+
// "No Hypomnema vault found" notice on stderr; suppress the stdout diagram
|
|
179
|
+
// rather than hand the renderer an empty-but-"real"-looking graph.
|
|
180
|
+
} else {
|
|
181
|
+
switch (args.format) {
|
|
182
|
+
case 'mermaid':
|
|
183
|
+
console.log(formatMermaid(pages, graph, args.minEdges));
|
|
184
|
+
break;
|
|
185
|
+
case 'dot':
|
|
186
|
+
console.log(formatDot(pages, graph, args.minEdges));
|
|
187
|
+
break;
|
|
188
|
+
default:
|
|
189
|
+
console.log(formatJson(pages, graph, args.minEdges, !vaultMissing));
|
|
190
|
+
}
|
|
176
191
|
}
|
package/scripts/init.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import { execSync, spawnSync } from 'child_process';
|
|
|
38
38
|
import { fileURLToPath } from 'url';
|
|
39
39
|
import { createHash } from 'crypto';
|
|
40
40
|
import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
|
|
41
|
+
import { hooksDirForInstall, unsafeHookTargetReason } from './lib/git-hooks-dir.mjs';
|
|
41
42
|
import { readCoreHooksConfig } from './lib/core-hooks.mjs';
|
|
42
43
|
import {
|
|
43
44
|
readPkgJson as readPkgJsonSafe,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
readFileIfRegular,
|
|
48
49
|
} from './lib/pkg-json.mjs';
|
|
49
50
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
51
|
+
import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
50
52
|
import { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
51
53
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
52
54
|
import {
|
|
@@ -142,6 +144,9 @@ Commands:
|
|
|
142
144
|
capture Reverse-capture ~/.claude/{commands,agents} extensions and
|
|
143
145
|
settings.json hooks into the wiki for cross-machine sync
|
|
144
146
|
(pass names or --all; --dry-run to preview)
|
|
147
|
+
proposal Manage staged crystallize proposals: list, apply, or
|
|
148
|
+
discard a parked write (also challenge/resolve for the
|
|
149
|
+
no-TTY agent approval channel)
|
|
145
150
|
|
|
146
151
|
Running \`hypomnema\` with no command is equivalent to \`hypomnema init\`.
|
|
147
152
|
|
|
@@ -164,11 +169,15 @@ Init options:
|
|
|
164
169
|
to a blocking error), sequenced after the .hypoignore
|
|
165
170
|
guard. Off by default; re-run init to add or drop it.
|
|
166
171
|
--dry-run Show what would be done without making changes
|
|
172
|
+
--version Print the installed package version and exit
|
|
167
173
|
--help, -h Show this help message
|
|
168
174
|
|
|
169
175
|
Subcommand-specific flags (upgrade/doctor/uninstall/capture) live in the
|
|
170
176
|
docstring at the top of scripts/<command>.mjs; capture also accepts \`--help\`.`);
|
|
171
177
|
process.exit(0);
|
|
178
|
+
} else if (arg === '--version') {
|
|
179
|
+
console.log(PKG_VERSION ?? 'unknown');
|
|
180
|
+
process.exit(0);
|
|
172
181
|
} else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
173
182
|
else if (arg === '--no-hooks') args.hooks = false;
|
|
174
183
|
else if (arg === '--no-commands') args.commands = false;
|
|
@@ -189,6 +198,18 @@ docstring at the top of scripts/<command>.mjs; capture also accepts \`--help\`.`
|
|
|
189
198
|
else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
|
|
190
199
|
else if (arg === '--allow-downgrade') args.allowDowngrade = true;
|
|
191
200
|
else if (arg === '--lint-strict') args.lintStrict = true;
|
|
201
|
+
else {
|
|
202
|
+
// An unrecognized argument used to fall through silently and run the
|
|
203
|
+
// default init flow (scaffold + hook install + settings.json merge) —
|
|
204
|
+
// a destructive write triggered by what looked like a query. `--version`
|
|
205
|
+
// was the concrete case that bit a user: it reads like an inspection
|
|
206
|
+
// flag, was never implemented, and its typo/mistake landed on a live
|
|
207
|
+
// wiki's pre-commit hook instead of printing anything. Fail loud and
|
|
208
|
+
// do nothing, rather than guess what the caller meant.
|
|
209
|
+
console.error(`Unknown option: ${arg}`);
|
|
210
|
+
console.error('Run `hypomnema --help` for usage.');
|
|
211
|
+
process.exit(2);
|
|
212
|
+
}
|
|
192
213
|
}
|
|
193
214
|
return args;
|
|
194
215
|
}
|
|
@@ -438,6 +459,14 @@ function installHooks(targetDir, dryRun) {
|
|
|
438
459
|
if (!dryRun) copyFileSync(join(HOOKS_SRC, file), dest);
|
|
439
460
|
log('created', dest);
|
|
440
461
|
}
|
|
462
|
+
// Refresh the provenance sidecar every run, even when every .mjs above was
|
|
463
|
+
// skipped as already-present: this is the standalone (manual/npm) channel
|
|
464
|
+
// only (the plugin channel never calls installHooks), and resolvePkgRoot()'s
|
|
465
|
+
// provenance fallback needs it to track the truth of what is actually on
|
|
466
|
+
// disk here, not just what a fresh copy left behind. See
|
|
467
|
+
// hooks/hypo-shared.mjs's readVerifiedProvenancePkgRoot() for the reader.
|
|
468
|
+
const sidecar = writeProvenanceSidecar(targetDir, PKG_ROOT, PKG_VERSION, HOOKS_SRC, dryRun);
|
|
469
|
+
if (sidecar) log('created', sidecar);
|
|
441
470
|
}
|
|
442
471
|
|
|
443
472
|
function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
|
|
@@ -693,10 +722,19 @@ function installCommands(targetDir, dryRun, force) {
|
|
|
693
722
|
}
|
|
694
723
|
|
|
695
724
|
function installPkgGitHook(dryRun) {
|
|
696
|
-
const
|
|
697
|
-
if (!
|
|
698
|
-
|
|
725
|
+
const { dir: hooksDir, skip } = hooksDirForInstall(PKG_ROOT);
|
|
726
|
+
if (!hooksDir) {
|
|
727
|
+
if (skip) log('skipped', `${PKG_ROOT} post-commit (${skip})`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
699
730
|
const hookPath = join(hooksDir, 'post-commit');
|
|
731
|
+
// Checked before existsSync: a dangling symlink reads as absent there, and
|
|
732
|
+
// writing would create its external target.
|
|
733
|
+
const unsafe = unsafeHookTargetReason(hookPath);
|
|
734
|
+
if (unsafe) {
|
|
735
|
+
log('skipped', `${hookPath} (${unsafe})`);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
700
738
|
if (existsSync(hookPath)) {
|
|
701
739
|
log('skipped', hookPath);
|
|
702
740
|
return;
|
|
@@ -758,12 +796,23 @@ function wikiPreCommitContent(root, hypoDir, lintStrict) {
|
|
|
758
796
|
}
|
|
759
797
|
|
|
760
798
|
function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
761
|
-
const
|
|
762
|
-
if (!
|
|
763
|
-
|
|
799
|
+
const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
|
|
800
|
+
if (!hooksDir) {
|
|
801
|
+
// no git repo — silently skip, as before; anything else is worth surfacing
|
|
802
|
+
if (skip) log('skipped', `${hypoDir} pre-commit (${skip})`);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
764
805
|
const hookPath = join(hooksDir, 'pre-commit');
|
|
765
806
|
const newContent = wikiPreCommitContent(root, hypoDir, lintStrict);
|
|
766
807
|
|
|
808
|
+
// Before every branch below, including --force: a symlink here would send the
|
|
809
|
+
// write through to an arbitrary external file.
|
|
810
|
+
const unsafe = unsafeHookTargetReason(hookPath);
|
|
811
|
+
if (unsafe) {
|
|
812
|
+
log('skipped', `${hookPath} (${unsafe})`);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
767
816
|
if (existsSync(hookPath)) {
|
|
768
817
|
const existing = readFileSync(hookPath, 'utf-8');
|
|
769
818
|
if (existing.includes(WIKI_PRE_COMMIT_MARKER_START)) {
|
|
@@ -777,8 +826,18 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
777
826
|
}
|
|
778
827
|
log('merged', `${hookPath} (pre-commit updated)`);
|
|
779
828
|
} else if (force) {
|
|
829
|
+
// The .bak is a SECOND write to a DIFFERENT path, so the guard on
|
|
830
|
+
// hookPath above says nothing about it. Left unchecked, a pre-commit.bak
|
|
831
|
+
// symlink turns --force-commands into an overwrite of whatever it points
|
|
832
|
+
// at — deterministically, not as a race.
|
|
833
|
+
const bakPath = hookPath + '.bak';
|
|
834
|
+
const unsafeBak = unsafeHookTargetReason(bakPath);
|
|
835
|
+
if (unsafeBak) {
|
|
836
|
+
log('skipped', `${bakPath} (${unsafeBak}) — not force-overwriting without a safe backup`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
780
839
|
if (!dryRun) {
|
|
781
|
-
writeFileSync(
|
|
840
|
+
writeFileSync(bakPath, existing);
|
|
782
841
|
writeFileSync(hookPath, newContent);
|
|
783
842
|
chmodSync(hookPath, 0o755);
|
|
784
843
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a repository's active git hooks directory by asking git, never by
|
|
3
|
+
* assuming the on-disk layout.
|
|
4
|
+
*
|
|
5
|
+
* The layout assumption this replaces (`join(root, '.git', 'hooks')`) is wrong
|
|
6
|
+
* in two ways that both showed up in practice:
|
|
7
|
+
*
|
|
8
|
+
* 1. In a linked worktree `.git` is a regular FILE holding `gitdir: <path>`,
|
|
9
|
+
* so `existsSync` passes and `mkdirSync` dies with ENOTDIR.
|
|
10
|
+
* 2. When `core.hooksPath` is set, git does not read `.git/hooks` at all, so
|
|
11
|
+
* a hook written there is inert.
|
|
12
|
+
*
|
|
13
|
+
* `git rev-parse --git-path hooks` handles both: it follows the worktree's
|
|
14
|
+
* gitdir pointer AND substitutes `core.hooksPath` (verified on git 2.50.1 —
|
|
15
|
+
* `/dev/null` stays `/dev/null`, a relative value stays relative, and `~` is
|
|
16
|
+
* expanded). That makes rev-parse the single authority here; reading
|
|
17
|
+
* `core.hooksPath` out of the config ourselves would be strictly worse, since
|
|
18
|
+
* it would leave `~` unexpanded and could not tell an empty-but-set value from
|
|
19
|
+
* an unset one.
|
|
20
|
+
*
|
|
21
|
+
* Two things rev-parse does NOT give us, so we add them:
|
|
22
|
+
*
|
|
23
|
+
* - `git -C <root>` does not neutralize ambient git environment variables.
|
|
24
|
+
* `GIT_DIR` + `GIT_WORK_TREE` redirect the probe at a foreign repository,
|
|
25
|
+
* and `GIT_CONFIG_COUNT`/`GIT_CONFIG_PARAMETERS` redirect it at an
|
|
26
|
+
* arbitrary hooks path. Every probe therefore runs under a scrubbed env,
|
|
27
|
+
* with the scrub list taken from git's own `--local-env-vars` when
|
|
28
|
+
* available.
|
|
29
|
+
* - `core.hooksPath` may point at a directory SHARED by many repositories
|
|
30
|
+
* (the documented centralized-hooks pattern). Auto-installing there would
|
|
31
|
+
* put our hook in front of unrelated repositories' commits, and our
|
|
32
|
+
* post-commit executes `$REPO_ROOT/scripts/upgrade.mjs` dynamically. So the
|
|
33
|
+
* result carries an `owned` flag, and callers that WRITE must refuse when
|
|
34
|
+
* it is false. Callers that only READ (doctor) may report the path.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { execFileSync } from 'child_process';
|
|
38
|
+
import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
|
|
39
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
|
|
40
|
+
|
|
41
|
+
// Fallback scrub list for git versions without `rev-parse --local-env-vars`.
|
|
42
|
+
// Mirrors scripts/install-git-hooks.mjs, which established this trust model.
|
|
43
|
+
const STATIC_LOCAL_ENV_VARS = [
|
|
44
|
+
'GIT_DIR',
|
|
45
|
+
'GIT_WORK_TREE',
|
|
46
|
+
'GIT_INDEX_FILE',
|
|
47
|
+
'GIT_OBJECT_DIRECTORY',
|
|
48
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
49
|
+
'GIT_COMMON_DIR',
|
|
50
|
+
'GIT_CONFIG',
|
|
51
|
+
'GIT_CONFIG_PARAMETERS',
|
|
52
|
+
'GIT_PREFIX',
|
|
53
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
54
|
+
'GIT_GRAFT_FILE',
|
|
55
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
56
|
+
'GIT_REPLACE_REF_BASE',
|
|
57
|
+
'GIT_SHALLOW_FILE',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function buildScrubbedEnv(localEnvList) {
|
|
61
|
+
const scrub = new Set([
|
|
62
|
+
...(localEnvList || STATIC_LOCAL_ENV_VARS),
|
|
63
|
+
'GIT_NAMESPACE',
|
|
64
|
+
'GIT_CEILING_DIRECTORIES',
|
|
65
|
+
// GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n inject config
|
|
66
|
+
// wholesale and are not always listed by --local-env-vars.
|
|
67
|
+
...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
|
|
68
|
+
]);
|
|
69
|
+
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Canonicalize a path that may not exist yet: realpath the deepest existing
|
|
73
|
+
// ancestor and re-append the rest. Without this, a hooks dir git will create
|
|
74
|
+
// lazily could evade the containment check via an unresolved symlinked parent.
|
|
75
|
+
function canonicalize(p) {
|
|
76
|
+
let cur = resolve(p);
|
|
77
|
+
const tail = [];
|
|
78
|
+
for (;;) {
|
|
79
|
+
try {
|
|
80
|
+
return join(realpathSync(cur), ...tail);
|
|
81
|
+
} catch {
|
|
82
|
+
const parent = dirname(cur);
|
|
83
|
+
if (parent === cur) return resolve(p); // hit the root; nothing to resolve
|
|
84
|
+
// basename, not a slice: when the parent IS the root it already ends in a
|
|
85
|
+
// separator, so `parent.length + 1` would eat the first real character
|
|
86
|
+
// ("/Nope/x" -> "ope/x") and could rewrite an external path into one that
|
|
87
|
+
// looks repository-owned.
|
|
88
|
+
tail.unshift(basename(cur));
|
|
89
|
+
cur = parent;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isInside(child, parent) {
|
|
95
|
+
return child === parent || child.startsWith(parent + sep);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} repoRoot working tree root to probe
|
|
100
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
101
|
+
* @returns {{ok: true, path: string, owned: boolean, gitDir: string, commonDir: string}
|
|
102
|
+
* |{ok: false, reason: string, detail?: string, path?: string}}
|
|
103
|
+
*
|
|
104
|
+
* Failure reasons are deliberately distinct so callers can react differently:
|
|
105
|
+
* not-a-repo no `.git` entry (also the case for a bare repo — matches
|
|
106
|
+
* the pre-existing behavior of every call site)
|
|
107
|
+
* git-unavailable git is not on PATH / not executable
|
|
108
|
+
* probe-failed git ran but could not resolve the repo (stale `.git`
|
|
109
|
+
* pointer, dubious ownership, timeout, ...)
|
|
110
|
+
* hooks-disabled the active hooks path is `/dev/null` or an existing
|
|
111
|
+
* non-directory — git's documented way to disable hooks
|
|
112
|
+
*/
|
|
113
|
+
export function resolveGitHooksDir(repoRoot, { timeoutMs = 5000 } = {}) {
|
|
114
|
+
if (!existsSync(join(repoRoot, '.git'))) return { ok: false, reason: 'not-a-repo' };
|
|
115
|
+
|
|
116
|
+
let env = buildScrubbedEnv(null);
|
|
117
|
+
const run = (args) =>
|
|
118
|
+
execFileSync('git', args, {
|
|
119
|
+
encoding: 'utf-8',
|
|
120
|
+
env,
|
|
121
|
+
cwd: repoRoot,
|
|
122
|
+
timeout: timeoutMs,
|
|
123
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
124
|
+
maxBuffer: 1024 * 1024,
|
|
125
|
+
}).trim();
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
// Enrich the scrub list from git's own truth when this git supports it.
|
|
129
|
+
try {
|
|
130
|
+
env = buildScrubbedEnv(run(['rev-parse', '--local-env-vars']).split(/\r?\n/).filter(Boolean));
|
|
131
|
+
} catch {
|
|
132
|
+
// Old git without --local-env-vars; the static list already applied.
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const gitDir = canonicalize(run(['rev-parse', '--absolute-git-dir']));
|
|
136
|
+
const rawCommon = run(['rev-parse', '--git-common-dir']);
|
|
137
|
+
// A relative --git-common-dir is relative to the command's cwd, which we
|
|
138
|
+
// pinned to repoRoot.
|
|
139
|
+
const commonDir = canonicalize(isAbsolute(rawCommon) ? rawCommon : join(repoRoot, rawCommon));
|
|
140
|
+
const topLevel = canonicalize(run(['rev-parse', '--show-toplevel']));
|
|
141
|
+
|
|
142
|
+
// --path-format is git 2.31+. Fall back to the plain form, whose output is
|
|
143
|
+
// relative to cwd (= repoRoot) when core.hooksPath is relative.
|
|
144
|
+
let raw;
|
|
145
|
+
try {
|
|
146
|
+
raw = run(['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
|
|
147
|
+
} catch {
|
|
148
|
+
raw = run(['rev-parse', '--git-path', 'hooks']);
|
|
149
|
+
}
|
|
150
|
+
if (!raw) return { ok: false, reason: 'probe-failed', detail: 'empty hooks path' };
|
|
151
|
+
|
|
152
|
+
const hooksPath = canonicalize(isAbsolute(raw) ? raw : join(repoRoot, raw));
|
|
153
|
+
|
|
154
|
+
// git documents core.hooksPath=/dev/null as "disable all hooks". Treat any
|
|
155
|
+
// existing non-directory the same way rather than failing on mkdir later.
|
|
156
|
+
if (existsSync(hooksPath) && !statSync(hooksPath).isDirectory()) {
|
|
157
|
+
return { ok: false, reason: 'hooks-disabled', path: hooksPath };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Repository-owned means: inside this repo's git directory (the normal
|
|
161
|
+
// `.git/hooks`, and in a linked worktree the shared common dir) or inside
|
|
162
|
+
// the working tree itself (the `core.hooksPath=.githooks` convention).
|
|
163
|
+
// Anything else is a location we do not own and must not write into.
|
|
164
|
+
const owned =
|
|
165
|
+
isInside(hooksPath, commonDir) ||
|
|
166
|
+
isInside(hooksPath, gitDir) ||
|
|
167
|
+
isInside(hooksPath, topLevel);
|
|
168
|
+
|
|
169
|
+
return { ok: true, path: hooksPath, owned, gitDir, commonDir };
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (e && e.code === 'ENOENT') return { ok: false, reason: 'git-unavailable' };
|
|
172
|
+
return { ok: false, reason: 'probe-failed', detail: e && (e.code || e.message) };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Guard the final hook ENTRY, not just the directory it lives in.
|
|
178
|
+
*
|
|
179
|
+
* An owned hooks directory can still contain a symlink pointing anywhere, and
|
|
180
|
+
* `writeFileSync` follows symlinks. Three ways that escapes the boundary the
|
|
181
|
+
* directory check appears to establish:
|
|
182
|
+
* - a live symlink to an external file gets its TARGET overwritten;
|
|
183
|
+
* - if that target happens to carry our managed marker, it is rewritten even
|
|
184
|
+
* without --force-commands;
|
|
185
|
+
* - a DANGLING symlink reads as absent through `existsSync`, so the "not
|
|
186
|
+
* installed yet" path creates the external target outright.
|
|
187
|
+
* So refuse to write through any symlink, and refuse anything that is not a
|
|
188
|
+
* regular file. Callers log the reason and move on.
|
|
189
|
+
*
|
|
190
|
+
* @returns {null | string} null when writing is safe, else a reason to log
|
|
191
|
+
*/
|
|
192
|
+
export function unsafeHookTargetReason(hookPath) {
|
|
193
|
+
let st;
|
|
194
|
+
try {
|
|
195
|
+
st = lstatSync(hookPath);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (e && e.code === 'ENOENT') return null; // genuinely absent — safe to create
|
|
198
|
+
return `cannot stat (${e.code || e.message})`;
|
|
199
|
+
}
|
|
200
|
+
if (st.isSymbolicLink()) return 'is a symlink — refusing to write through it';
|
|
201
|
+
if (!st.isFile()) return 'exists but is not a regular file';
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Write-side wrapper: the hooks directory only if it is safe to install into.
|
|
207
|
+
* Returns `{dir}` when installing is allowed, otherwise `{skip}` carrying a
|
|
208
|
+
* human-readable reason for the caller to log.
|
|
209
|
+
*/
|
|
210
|
+
export function hooksDirForInstall(repoRoot, opts) {
|
|
211
|
+
const r = resolveGitHooksDir(repoRoot, opts);
|
|
212
|
+
if (!r.ok) {
|
|
213
|
+
if (r.reason === 'not-a-repo') return { skip: null }; // silent, as before
|
|
214
|
+
if (r.reason === 'hooks-disabled') {
|
|
215
|
+
// Do not name core.hooksPath here: the same branch fires for a plain
|
|
216
|
+
// .git/hooks that happens to be a regular file, where no such setting
|
|
217
|
+
// exists and naming it would send the user hunting for a phantom config.
|
|
218
|
+
return { skip: `hooks path is not a directory, so git runs no hooks (${r.path})` };
|
|
219
|
+
}
|
|
220
|
+
if (r.reason === 'git-unavailable') return { skip: 'git not available on PATH' };
|
|
221
|
+
return { skip: `could not resolve hooks dir (${r.detail || r.reason})` };
|
|
222
|
+
}
|
|
223
|
+
if (!r.owned) {
|
|
224
|
+
return {
|
|
225
|
+
skip: `core.hooksPath points outside this repository (${r.path}) — refusing to install into a shared hooks directory`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return { dir: r.path };
|
|
229
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.hypo-provenance.json` sidecar: copy-time producer proof for the
|
|
3
|
+
* standalone (manual/npm) hooks channel.
|
|
4
|
+
*
|
|
5
|
+
* hooks/hypo-shared.mjs's resolvePkgRoot() falls back to this file, verified
|
|
6
|
+
* against its own package name + a SHA-256 of the running hypo-shared.mjs,
|
|
7
|
+
* whenever self-location can't confirm a package root — the standalone hooks
|
|
8
|
+
* copy has no package.json alongside it to walk up to, by design (hooks
|
|
9
|
+
* import only Node built-ins, nothing outside the hooks dir).
|
|
10
|
+
*
|
|
11
|
+
* This is accidental-staleness protection, not a security boundary: any
|
|
12
|
+
* process running as this OS user can edit this JSON file freely, same as it
|
|
13
|
+
* could edit hooks/hypo-shared.mjs itself. It only guards against the
|
|
14
|
+
* accidental drift init/upgrade left unguarded before this fix — a hook
|
|
15
|
+
* file skip (already-present) that still let hypo-pkg.json's cached pointer
|
|
16
|
+
* move on to a newer version, silently mismatching the code actually copied.
|
|
17
|
+
*
|
|
18
|
+
* The filename and the producer-name check ("hypomnema") here must stay
|
|
19
|
+
* byte-identical with hooks/hypo-shared.mjs's own copy of this contract —
|
|
20
|
+
* hooks can't import scripts/, so the two sides are duplicated, not shared.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'fs';
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
import { sha256 } from './pkg-json.mjs';
|
|
26
|
+
import { readCoreHooksConfig, deriveCoreHookBasenames } from './core-hooks.mjs';
|
|
27
|
+
|
|
28
|
+
export const PROVENANCE_FILENAME = '.hypo-provenance.json';
|
|
29
|
+
export const EXPECTED_PKG_NAME = 'hypomnema';
|
|
30
|
+
|
|
31
|
+
// Field name for the aggregate hook-set hash (BLOCKER A). Exported so
|
|
32
|
+
// scripts/doctor.mjs reads/writes the same key instead of duplicating the
|
|
33
|
+
// string literal — doctor already imports this module for
|
|
34
|
+
// PROVENANCE_FILENAME/EXPECTED_PKG_NAME, so importing one more constant costs
|
|
35
|
+
// nothing and closes the duplication CONCERN 1 flagged for the filename/name
|
|
36
|
+
// pair (those two stay duplicated only because hooks/hypo-shared.mjs can't
|
|
37
|
+
// import this file at all).
|
|
38
|
+
export const HOOKS_DIGEST_FIELD = 'hooksDigest';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Deterministic aggregate hash over every hook file hooks/hooks.json wires up
|
|
42
|
+
* (every event handler's file plus every `shared` entry) — the exact set
|
|
43
|
+
* installHooks/applyHookFiles copy, derived from hooks.json itself rather
|
|
44
|
+
* than hand-listed here so this can never drift from what actually gets
|
|
45
|
+
* copied. `dir` is read for CONTENT: pass hooksSrcDir at write time (the
|
|
46
|
+
* package's own hooks/, immediately after files were copied out of it — see
|
|
47
|
+
* writeProvenanceSidecar) and the installed hooksDir at doctor-check time
|
|
48
|
+
* (mirrors how hypoSharedSha256 below is written from source but verified
|
|
49
|
+
* against the installed copy).
|
|
50
|
+
*
|
|
51
|
+
* Deliberately NOT what the runtime resolver reads: hooks/hypo-shared.mjs's
|
|
52
|
+
* readVerifiedProvenancePkgRoot() only ever compares hypoSharedSha256, one
|
|
53
|
+
* file, because hook modules load on every single hook event and hashing an
|
|
54
|
+
* entire directory on every load would tax the hot path for a check only
|
|
55
|
+
* `hypomnema doctor` needs to run once per invocation. This digest is that
|
|
56
|
+
* doctor-only, directory-wide check: it catches a hooks/ dir where some OTHER
|
|
57
|
+
* file (e.g. hypo-personal-check.mjs) went stale while hypo-shared.mjs itself
|
|
58
|
+
* stayed byte-identical, which the single-file SHA the runtime trusts cannot
|
|
59
|
+
* see at all.
|
|
60
|
+
*
|
|
61
|
+
* Sorted basenames, `<file>\n<sha256-of-file-hex>\n` per file concatenated,
|
|
62
|
+
* then SHA-256 of that string — order and content only, never mtime/size.
|
|
63
|
+
* Returns null if hooks.json can't be read/parsed or any listed file can't be
|
|
64
|
+
* read, so a caller can treat "couldn't compute" the same as "nothing to
|
|
65
|
+
* compare" rather than writing/trusting a bogus digest.
|
|
66
|
+
*/
|
|
67
|
+
export function computeHooksDigest(pkgRoot, dir) {
|
|
68
|
+
const cfgRes = readCoreHooksConfig(pkgRoot);
|
|
69
|
+
if (!cfgRes.ok) return null;
|
|
70
|
+
const files = [...deriveCoreHookBasenames(cfgRes.cfg)].sort();
|
|
71
|
+
try {
|
|
72
|
+
let acc = '';
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
acc += `${file}\n${sha256(readFileSync(join(dir, file)))}\n`;
|
|
75
|
+
}
|
|
76
|
+
return sha256(acc);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function provenancePath(hooksDir) {
|
|
83
|
+
return join(hooksDir, PROVENANCE_FILENAME);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write/refresh the sidecar next to a standalone hooks copy at `hooksDir`.
|
|
88
|
+
* `hooksSrcDir` is the package's own hooks/ (the source hypo-shared.mjs was
|
|
89
|
+
* just copied FROM) — its content is what gets hashed, since that hash is
|
|
90
|
+
* what the copy left at `hooksDir` will actually match.
|
|
91
|
+
*
|
|
92
|
+
* Callers must invoke this every run that touches — or even just re-verifies
|
|
93
|
+
* — the hook file set, EVEN when every individual .mjs file was skipped as
|
|
94
|
+
* already-present. A skipped hook copy that still lets hypo-pkg.json's
|
|
95
|
+
* pkgVersion move on to a newer release is exactly the bug this sidecar
|
|
96
|
+
* exists to catch; refreshing it only when files actually changed would
|
|
97
|
+
* reintroduce that gap under a new name.
|
|
98
|
+
*
|
|
99
|
+
* Returns the sidecar path written, or null if the source couldn't be
|
|
100
|
+
* hashed (leaves any pre-existing sidecar untouched rather than write a
|
|
101
|
+
* broken one).
|
|
102
|
+
*
|
|
103
|
+
* `hooksDigest` (BLOCKER A) is best-effort: computed from the same
|
|
104
|
+
* `hooksSrcDir`, over the file set hooks.json actually wires up. Unlike
|
|
105
|
+
* hypoSharedSha256 above, a failure to compute it does not abort the write —
|
|
106
|
+
* it is a doctor-only freshness signal, not part of the runtime contract
|
|
107
|
+
* (hooks/hypo-shared.mjs never reads this field), so a sidecar the runtime
|
|
108
|
+
* can verify should not be withheld just because the digest step failed.
|
|
109
|
+
*/
|
|
110
|
+
export function writeProvenanceSidecar(hooksDir, pkgRoot, pkgVersion, hooksSrcDir, dryRun) {
|
|
111
|
+
let hypoSharedSha256;
|
|
112
|
+
try {
|
|
113
|
+
hypoSharedSha256 = sha256(readFileSync(join(hooksSrcDir, 'hypo-shared.mjs')));
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const hooksDigest = computeHooksDigest(pkgRoot, hooksSrcDir);
|
|
118
|
+
const dest = provenancePath(hooksDir);
|
|
119
|
+
const data = {
|
|
120
|
+
pkgRoot,
|
|
121
|
+
pkgVersion,
|
|
122
|
+
hypoSharedSha256,
|
|
123
|
+
...(hooksDigest ? { [HOOKS_DIGEST_FIELD]: hooksDigest } : {}),
|
|
124
|
+
copiedAt: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
if (!dryRun) atomicWriteJson(dest, data);
|
|
127
|
+
return dest;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Tmp file in the SAME directory as `dest`, then rename over it (CONCERN 2):
|
|
131
|
+
// a reader (readVerifiedProvenancePkgRoot in hooks/hypo-shared.mjs) that
|
|
132
|
+
// races a plain truncate-then-write can observe a half-written file and
|
|
133
|
+
// JSON.parse throws, degrading PKG_ROOT to null for that hook invocation.
|
|
134
|
+
// Same directory is required for the rename to be atomic — crossing a
|
|
135
|
+
// filesystem boundary would fall back to a non-atomic copy+delete.
|
|
136
|
+
function atomicWriteJson(dest, data) {
|
|
137
|
+
const tmp = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
138
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
139
|
+
try {
|
|
140
|
+
renameSync(tmp, dest);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
try {
|
|
143
|
+
unlinkSync(tmp);
|
|
144
|
+
} catch {
|
|
145
|
+
/* best-effort cleanup */
|
|
146
|
+
}
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Remove the sidecar at `hooksDir`, if present. Returns the path removed, or null. */
|
|
152
|
+
export function removeProvenanceSidecar(hooksDir, apply) {
|
|
153
|
+
const dest = provenancePath(hooksDir);
|
|
154
|
+
if (!existsSync(dest)) return null;
|
|
155
|
+
if (apply) unlinkSync(dest);
|
|
156
|
+
return dest;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Non-mutating read. Returns the parsed sidecar, or null on absence/corruption. */
|
|
160
|
+
export function readProvenanceSidecar(hooksDir) {
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(readFileSync(provenancePath(hooksDir), 'utf-8'));
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -28,7 +28,11 @@ import { resolveHypoRoot, expandHome } from './hypo-root.mjs';
|
|
|
28
28
|
|
|
29
29
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
30
30
|
const PKG_ROOT = join(SCRIPT_DIR, '..', '..');
|
|
31
|
-
|
|
31
|
+
// Exported so other project-scaffolding call sites (crystallize.mjs's
|
|
32
|
+
// session-close apply, which fills a MISSING index.md on a project that
|
|
33
|
+
// bypassed createProject entirely) resolve the same template dir instead of
|
|
34
|
+
// re-deriving the path.
|
|
35
|
+
export const TEMPLATE_DIR = join(PKG_ROOT, 'templates', 'projects', '_template');
|
|
32
36
|
|
|
33
37
|
const TEMPLATE_FILES = ['index.md', 'prd.md', 'hot.md', 'session-state.md'];
|
|
34
38
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// scripts/lib/rename-marker.mjs
|
|
2
|
+
//
|
|
3
|
+
// The crash-recovery marker for rename.mjs --apply. Shared read-only path/parse
|
|
4
|
+
// logic between the writer (rename.mjs) and the reader (doctor.mjs) so the two
|
|
5
|
+
// never drift on where the marker lives or what shape it is in.
|
|
6
|
+
//
|
|
7
|
+
// Kept under .cache/ deliberately, matching every other runtime marker this repo
|
|
8
|
+
// already writes there (sync-state.json, session-closed-*.marker, proposals/,
|
|
9
|
+
// project-suggestions.json). Two things fall out of that placement for free:
|
|
10
|
+
// - it is JSON, not .md, so every vault walker (scripts/lib/wikilink.mjs) that
|
|
11
|
+
// only ever collects .md files simply never sees it — no lint/rename/graph
|
|
12
|
+
// scan needs to know this file exists.
|
|
13
|
+
// - .cache/ ships in templates/.hypoignore's default pattern list, so a scan
|
|
14
|
+
// that DOES walk directories rather than just filtering by extension
|
|
15
|
+
// (doctor's own checkBrokenLinks walker) is also covered on any vault that
|
|
16
|
+
// carries that baseline — which every vault already needs, since .cache/
|
|
17
|
+
// holds the pre-commit secret-gate's own state.
|
|
18
|
+
// No .hyposcanignore entry or scan-logic change was needed to get this property;
|
|
19
|
+
// it comes from where the file lives, not from a new exclusion rule.
|
|
20
|
+
import { existsSync, readFileSync } from 'fs';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
|
|
23
|
+
export const RENAME_MARKER_REL = '.cache/rename-in-progress.json';
|
|
24
|
+
|
|
25
|
+
export function renameMarkerPath(hypoDir) {
|
|
26
|
+
return join(hypoDir, RENAME_MARKER_REL);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Best-effort read: a missing or corrupt marker both mean "nothing to report" —
|
|
30
|
+
// this runs inside doctor's health scan and must never throw.
|
|
31
|
+
export function readRenameMarker(hypoDir) {
|
|
32
|
+
const path = renameMarkerPath(hypoDir);
|
|
33
|
+
if (!existsSync(path)) return null;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|