hypomnema 1.7.1 → 1.7.3
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/commands/crystallize.md +5 -5
- package/hooks/hypo-hot-rebuild.mjs +22 -2
- package/hooks/hypo-session-start.mjs +60 -4
- package/hooks/hypo-shared.mjs +364 -118
- package/hooks/version-check.mjs +47 -0
- package/package.json +2 -1
- package/scripts/capture.mjs +15 -20
- package/scripts/crystallize.mjs +97 -12
- package/scripts/doctor.mjs +151 -7
- package/scripts/init.mjs +17 -7
- package/scripts/lib/extensions.mjs +43 -4
- package/scripts/lib/git-hooks-dir.mjs +10 -0
- package/scripts/lib/pkg-provenance.mjs +166 -0
- package/scripts/lint.mjs +45 -1
- package/scripts/uninstall.mjs +266 -2
- package/scripts/upgrade.mjs +8 -0
- package/templates/hypo-config.md +1 -1
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
unlinkSync,
|
|
29
29
|
mkdirSync,
|
|
30
30
|
lstatSync,
|
|
31
|
+
statSync,
|
|
32
|
+
chmodSync,
|
|
31
33
|
rmdirSync,
|
|
32
34
|
} from 'fs';
|
|
33
35
|
import { join, dirname, relative, resolve, posix, sep } from 'path';
|
|
@@ -894,9 +896,26 @@ export function readExtensionPkgStateNoMutate(pkgPath, target) {
|
|
|
894
896
|
|
|
895
897
|
// ── sync orchestration ─────────────────────────────────────────────────────────
|
|
896
898
|
|
|
897
|
-
|
|
899
|
+
/** Carry only src's execute bits (owner/group/other) onto a mode value; every
|
|
900
|
+
* other permission bit (dest's own read/write, however umask shaped it) is
|
|
901
|
+
* left alone. This is the one place forward-sync decides "should this file be
|
|
902
|
+
* executable", so every writer of dest routes through it. Exported so
|
|
903
|
+
* capture.mjs's own atomic writer (a captured file's FIRST write into the wiki)
|
|
904
|
+
* applies the identical rule; the wiki copy must not lose the bit before
|
|
905
|
+
* forward-sync ever gets a chance to carry it further. */
|
|
906
|
+
export function withSrcExecBits(destMode, srcMode) {
|
|
907
|
+
return (destMode & ~0o111) | (srcMode & 0o111);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function writeFreshAtomic(dest, content, srcMode) {
|
|
898
911
|
const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
|
|
899
912
|
writeFileSync(tmp, content);
|
|
913
|
+
if (srcMode != null) {
|
|
914
|
+
// writeFileSync has no mode option that survives umask, so the execute bit
|
|
915
|
+
// has to be applied explicitly, and before the rename: otherwise dest is
|
|
916
|
+
// briefly visible with the wrong mode to anything racing this write.
|
|
917
|
+
chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
|
|
918
|
+
}
|
|
900
919
|
try {
|
|
901
920
|
renameSync(tmp, dest);
|
|
902
921
|
} catch (err) {
|
|
@@ -914,13 +933,19 @@ function writeFreshAtomic(dest, content) {
|
|
|
914
933
|
* overwrites user-modified / unowned files; without it those are left untouched and
|
|
915
934
|
* surface as drift/conflict. A symlink/non-regular dest is never followed even under
|
|
916
935
|
* force (the isRegularFile guard precedes the force branch).
|
|
936
|
+
*
|
|
937
|
+
* The executable bit is not tracked anywhere (no mode column in the SHA map's
|
|
938
|
+
* ownership model), so src's mode is the only source of truth for it and gets
|
|
939
|
+
* carried onto dest on every branch that (re)writes it, including the
|
|
940
|
+
* content-identical `up-to-date` branch below.
|
|
917
941
|
*/
|
|
918
942
|
function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
|
|
919
943
|
const srcContent = readFileSync(srcPath);
|
|
920
944
|
const srcSHA = sha256(srcContent);
|
|
945
|
+
const srcMode = statSync(srcPath).mode;
|
|
921
946
|
|
|
922
947
|
if (!existsSync(destPath)) {
|
|
923
|
-
if (apply) writeFreshAtomic(destPath, srcContent);
|
|
948
|
+
if (apply) writeFreshAtomic(destPath, srcContent, srcMode);
|
|
924
949
|
return { action: 'create', sha: srcSHA };
|
|
925
950
|
}
|
|
926
951
|
if (!isRegularFile(destPath)) {
|
|
@@ -933,6 +958,20 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
|
|
|
933
958
|
}
|
|
934
959
|
const onDiskSHA = sha256(onDisk);
|
|
935
960
|
if (onDiskSHA === srcSHA) {
|
|
961
|
+
// Content already matches, but the exec bit can still be stale: this used to
|
|
962
|
+
// be a pure no-op, which is exactly why a mismatched bit here never healed.
|
|
963
|
+
// Reported as 'update' (not a new action) so every existing "N to sync" /
|
|
964
|
+
// "N synced" count and log line already keyed on create/update/force-update
|
|
965
|
+
// picks it up for free.
|
|
966
|
+
// ponytail: this also overwrites an exec bit a user deliberately flipped on a
|
|
967
|
+
// file whose content happens to still match src (chmod -x'd a script they
|
|
968
|
+
// like read-only). Upgrade path: record mode next to sha in the pkg-json map
|
|
969
|
+
// so a user's own bit can be told apart from our default and left alone.
|
|
970
|
+
const destMode = statSync(destPath).mode;
|
|
971
|
+
if ((destMode & 0o111) !== (srcMode & 0o111)) {
|
|
972
|
+
if (apply) chmodSync(destPath, withSrcExecBits(destMode, srcMode));
|
|
973
|
+
return { action: 'update', sha: srcSHA };
|
|
974
|
+
}
|
|
936
975
|
return { action: 'up-to-date', sha: srcSHA };
|
|
937
976
|
}
|
|
938
977
|
if (recordedSHA && onDiskSHA === recordedSHA) {
|
|
@@ -942,7 +981,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
|
|
|
942
981
|
if (!verify || sha256(verify) !== recordedSHA) {
|
|
943
982
|
return { action: 'skip-changed', sha: recordedSHA };
|
|
944
983
|
}
|
|
945
|
-
writeFreshAtomic(destPath, srcContent);
|
|
984
|
+
writeFreshAtomic(destPath, srcContent, srcMode);
|
|
946
985
|
}
|
|
947
986
|
return { action: 'update', sha: srcSHA };
|
|
948
987
|
}
|
|
@@ -950,7 +989,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
|
|
|
950
989
|
if (force) {
|
|
951
990
|
if (apply) {
|
|
952
991
|
writeFreshAtomic(`${destPath}.bak`, onDisk);
|
|
953
|
-
writeFreshAtomic(destPath, srcContent);
|
|
992
|
+
writeFreshAtomic(destPath, srcContent, srcMode);
|
|
954
993
|
}
|
|
955
994
|
return { action: 'force-update', sha: srcSHA };
|
|
956
995
|
}
|
|
@@ -38,6 +38,16 @@ import { execFileSync } from 'child_process';
|
|
|
38
38
|
import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
|
|
39
39
|
import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
|
|
40
40
|
|
|
41
|
+
// ── shared install/uninstall markers ────────────────────────────────────────
|
|
42
|
+
// init.mjs writes these when it installs the wiki's git pre-commit hook and
|
|
43
|
+
// the shell rc block; uninstall.mjs reads them back to remove exactly what
|
|
44
|
+
// init created. Defined once here, imported by both, so the two scripts can
|
|
45
|
+
// never drift into recognizing different markers.
|
|
46
|
+
export const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
|
|
47
|
+
export const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
|
|
48
|
+
export const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
|
|
49
|
+
export const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
|
|
50
|
+
|
|
41
51
|
// Fallback scrub list for git versions without `rev-parse --local-env-vars`.
|
|
42
52
|
// Mirrors scripts/install-git-hooks.mjs, which established this trust model.
|
|
43
53
|
const STATIC_LOCAL_ENV_VARS = [
|
|
@@ -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
|
+
}
|
package/scripts/lint.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
20
20
|
import { join, extname, basename } from 'path';
|
|
21
21
|
import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
|
|
22
|
-
import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
|
|
22
|
+
import { SESSION_STATE_NEXT_HEADINGS, closeFileTargetsGlobal } from '../hooks/hypo-shared.mjs';
|
|
23
23
|
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
24
24
|
import {
|
|
25
25
|
parseSchemaVocab,
|
|
@@ -554,6 +554,50 @@ const validTypes = new Set([...VALID_TYPES, ...parseSchemaTypes(args.hypoDir)]);
|
|
|
554
554
|
|
|
555
555
|
for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs, validTypes);
|
|
556
556
|
|
|
557
|
+
// W4 (broken-wikilink only, NOT the full lintPage) for close's root-level
|
|
558
|
+
// write targets that fall outside pages/projects/journal: hot.md and log.md
|
|
559
|
+
// today. closeFileTargetsGlobal is the one list of what close writes
|
|
560
|
+
// (hooks/hypo-shared.mjs), reused here instead of re-derived, so a future
|
|
561
|
+
// close target is covered automatically. Only the entries NOT already under
|
|
562
|
+
// a scanDir are new; closeFileTargetsGlobal's projects/<slug>/* entries are
|
|
563
|
+
// already linted in full above.
|
|
564
|
+
//
|
|
565
|
+
// Why link-only and not the full lintPage: measured, not assumed. On the
|
|
566
|
+
// packaged templates/ (hot.md type:reference, log.md type:log, both declared
|
|
567
|
+
// in templates/SCHEMA.md's taxonomy), full lintPage on both files comes back
|
|
568
|
+
// completely clean, so "these types trip W2" is not the reason to hold back.
|
|
569
|
+
// The real reason is that live vaults do not all match the template. A vault
|
|
570
|
+
// whose SCHEMA.md predates the `log` type row (or has none) gets a fresh
|
|
571
|
+
// "Unknown type: log" W2 the moment log.md is run through lintPage, and a
|
|
572
|
+
// vault whose root log.md predates the frontmatter convention entirely (no
|
|
573
|
+
// leading `---` block at all, confirmed against a real maintainer vault)
|
|
574
|
+
// gets a fresh "No frontmatter found" W1. Both W1 and W2 are in
|
|
575
|
+
// STRICT_PROMOTE_IDS, so under --strict a vault that lint has always passed
|
|
576
|
+
// would start failing on a file whose content this fix never touched. Full
|
|
577
|
+
// lintPage stays reserved for pages/projects/journal, where every file was
|
|
578
|
+
// already being linted before this change and there is no such newly-exposed
|
|
579
|
+
// vault. W4 itself stays warn-only outside --strict, and postApply
|
|
580
|
+
// (crystallize.mjs) gates only on errors, so this addition can add a new
|
|
581
|
+
// warning to a close's lint output but can never fail one.
|
|
582
|
+
const closeRootTargets = [...closeFileTargetsGlobal(args.hypoDir)].filter(
|
|
583
|
+
(f) => !f.startsWith('pages/') && !f.startsWith('projects/') && !f.startsWith('journal/'),
|
|
584
|
+
);
|
|
585
|
+
for (const rel of closeRootTargets) {
|
|
586
|
+
const full = join(args.hypoDir, rel);
|
|
587
|
+
if (!existsSync(full) || isScanIgnored(full, args.hypoDir, ignorePatterns)) continue;
|
|
588
|
+
let content;
|
|
589
|
+
try {
|
|
590
|
+
content = readFileSync(full, 'utf-8');
|
|
591
|
+
} catch {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
for (const link of extractWikilinks(content)) {
|
|
595
|
+
if (!slugMap.has(link)) {
|
|
596
|
+
issue('warn', rel, `Broken wikilink: [[${link}]]`, null, 'W4');
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
557
601
|
// W8: design-history.md stale relative to session-log.md. Emitted once per
|
|
558
602
|
// project (not per page) — runs outside the page loop. POSIX-separated path
|
|
559
603
|
// literal (not path.join) so consumers can rely on `file.split('/')` shape
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* --force-commands Remove user-modified slash commands instead of preserving them
|
|
15
15
|
* --force-extensions Remove user-modified extension files (hypo-ext-*) instead of preserving them
|
|
16
16
|
* --hooks-dir=<path> Override Claude hooks directory (default: ~/.claude/hooks)
|
|
17
|
+
* --hypo-dir=<path> Wiki vault to remove the pre-commit hook from (default: auto-resolve,
|
|
18
|
+
* same rules as init/lint/query)
|
|
19
|
+
* --shell-config=<path> Shell rc file to strip the shell block from (default: checks both
|
|
20
|
+
* ~/.zshrc and ~/.bashrc, since init may have run under either shell)
|
|
21
|
+
*
|
|
22
|
+
* The wiki's git pre-commit hook and the shell rc's `claude()` wrapper function are removed
|
|
23
|
+
* only when they still carry the marker init.mjs wrote (WIKI_PRE_COMMIT_MARKER_START /
|
|
24
|
+
* SHELL_MARKER_START, both from ./lib/git-hooks-dir.mjs). A user's own pre-commit hook, a
|
|
25
|
+
* symlinked hook target, and any rc content outside the marker block are never touched.
|
|
17
26
|
*
|
|
18
27
|
* Extensions: hypo-ext-* hard-copies under
|
|
19
28
|
* ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
|
|
@@ -23,7 +32,16 @@
|
|
|
23
32
|
* does not follow them). The wiki source (~/hypomnema/extensions/) is preserved.
|
|
24
33
|
*/
|
|
25
34
|
|
|
26
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
existsSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
rmSync,
|
|
40
|
+
rmdirSync,
|
|
41
|
+
readdirSync,
|
|
42
|
+
statSync,
|
|
43
|
+
realpathSync,
|
|
44
|
+
} from 'fs';
|
|
27
45
|
import { join } from 'path';
|
|
28
46
|
import { homedir } from 'os';
|
|
29
47
|
import { fileURLToPath } from 'url';
|
|
@@ -47,6 +65,16 @@ import {
|
|
|
47
65
|
hasSymlinkAncestor,
|
|
48
66
|
buildHookCommand,
|
|
49
67
|
} from './lib/extensions.mjs';
|
|
68
|
+
import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
69
|
+
import {
|
|
70
|
+
hooksDirForInstall,
|
|
71
|
+
unsafeHookTargetReason,
|
|
72
|
+
WIKI_PRE_COMMIT_MARKER_START,
|
|
73
|
+
WIKI_PRE_COMMIT_MARKER_END,
|
|
74
|
+
SHELL_MARKER_START,
|
|
75
|
+
SHELL_MARKER_END,
|
|
76
|
+
} from './lib/git-hooks-dir.mjs';
|
|
77
|
+
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
50
78
|
|
|
51
79
|
const HOME = homedir();
|
|
52
80
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -427,6 +455,182 @@ function stripExtensionSettings(settingsPath, hooksDir, apply, ownedCommands = n
|
|
|
427
455
|
return { stripped };
|
|
428
456
|
}
|
|
429
457
|
|
|
458
|
+
// ── marker-span validation (shared by both removal paths below) ────────────
|
|
459
|
+
|
|
460
|
+
// Two independent indexOf() calls cannot tell "well-formed" apart from
|
|
461
|
+
// "duplicated" or "swapped": if a file happens to hold two full copies of the
|
|
462
|
+
// block, indexOf finds only the first END, so slicing [firstStart, firstEnd]
|
|
463
|
+
// leaves the second copy's install behind with no report of it. If END
|
|
464
|
+
// precedes START (a hand-edited or corrupted file), slicing [start, end) with
|
|
465
|
+
// start > end does not error, it silently duplicates whatever sits between
|
|
466
|
+
// them into the "removed" span. Neither this script nor the file it is
|
|
467
|
+
// touching has a way back from either outcome, so a span is only trusted when
|
|
468
|
+
// both markers appear EXACTLY once and START comes before END.
|
|
469
|
+
function countOccurrences(content, needle) {
|
|
470
|
+
let count = 0;
|
|
471
|
+
let idx = 0;
|
|
472
|
+
while ((idx = content.indexOf(needle, idx)) !== -1) {
|
|
473
|
+
count++;
|
|
474
|
+
idx += needle.length;
|
|
475
|
+
}
|
|
476
|
+
return count;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function findMarkerSpan(content, startMarker, endMarker) {
|
|
480
|
+
const startCount = countOccurrences(content, startMarker);
|
|
481
|
+
const endCount = countOccurrences(content, endMarker);
|
|
482
|
+
if (startCount !== 1 || endCount !== 1) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
reason: `expected exactly one start and one end marker, found ${startCount} start / ${endCount} end`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const startIdx = content.indexOf(startMarker);
|
|
489
|
+
const endIdx = content.indexOf(endMarker);
|
|
490
|
+
if (!(startIdx < endIdx)) {
|
|
491
|
+
return { ok: false, reason: 'the end marker appears before the start marker' };
|
|
492
|
+
}
|
|
493
|
+
return { ok: true, startIdx, endIdx };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── wiki pre-commit hook removal ────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
// Mirrors init.mjs's own resolution (hooksDirForInstall) as the PRIMARY
|
|
499
|
+
// candidate, so this finds the hook wherever init would put it today,
|
|
500
|
+
// including under a core.hooksPath override. A second, best-effort candidate
|
|
501
|
+
// (the vault's plain .git/hooks/pre-commit) is checked too: if core.hooksPath
|
|
502
|
+
// changed after install, the current resolution no longer points at the file
|
|
503
|
+
// init actually wrote, and that file would otherwise never be found. Both
|
|
504
|
+
// candidates go through the same marker/ownership gate below, so widening the
|
|
505
|
+
// search costs nothing in safety, only in how many places we bother to look.
|
|
506
|
+
// A hook that still carries our marker is removed; a user's own pre-commit (no
|
|
507
|
+
// marker), a symlinked/non-regular target, and a hook whose marker is
|
|
508
|
+
// duplicated, swapped, or missing its shebang are all left standing.
|
|
509
|
+
// `pre-commit.bak` (init's --force-commands backup of the user's original
|
|
510
|
+
// hook) is never removed here, only reported so the user knows it exists.
|
|
511
|
+
function removeWikiPreCommitHook(hypoDir, apply) {
|
|
512
|
+
const result = { removed: [], skipped: [], bakPresent: [] };
|
|
513
|
+
|
|
514
|
+
if (!hypoDir || !existsSync(join(hypoDir, 'hypo-config.md'))) {
|
|
515
|
+
result.skipped.push(`no Hypomnema vault found${hypoDir ? ` at ${hypoDir}` : ''} — nothing to remove`);
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const candidateDirs = [];
|
|
520
|
+
const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
|
|
521
|
+
if (hooksDir) candidateDirs.push(hooksDir);
|
|
522
|
+
else if (skip) result.skipped.push(skip);
|
|
523
|
+
|
|
524
|
+
// Best-effort fallback: only when `.git` is a real directory here (a plain
|
|
525
|
+
// checkout, not a linked worktree's gitdir-pointer FILE, which this join
|
|
526
|
+
// would misread entirely — resolveGitHooksDir already handles that layout
|
|
527
|
+
// correctly via the primary candidate above).
|
|
528
|
+
const legacyGitDir = join(hypoDir, '.git');
|
|
529
|
+
if (existsSync(legacyGitDir) && statSync(legacyGitDir).isDirectory()) {
|
|
530
|
+
candidateDirs.push(join(legacyGitDir, 'hooks'));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const seen = new Set();
|
|
534
|
+
for (const dir of candidateDirs) {
|
|
535
|
+
const hookPath = join(dir, 'pre-commit');
|
|
536
|
+
// Dedupe by the resolved real path, not the string: the primary candidate
|
|
537
|
+
// is realpath'd internally (resolveGitHooksDir's canonicalize) while the
|
|
538
|
+
// legacy join above is not, so the same physical file can arrive under two
|
|
539
|
+
// different-looking paths. Falling back to the raw path when the file does
|
|
540
|
+
// not exist is fine — two distinct absent candidates never collide.
|
|
541
|
+
let key = hookPath;
|
|
542
|
+
try {
|
|
543
|
+
key = realpathSync(hookPath);
|
|
544
|
+
} catch {
|
|
545
|
+
// leave key as hookPath
|
|
546
|
+
}
|
|
547
|
+
if (seen.has(key)) continue;
|
|
548
|
+
seen.add(key);
|
|
549
|
+
|
|
550
|
+
const bakPath = `${hookPath}.bak`;
|
|
551
|
+
if (existsSync(bakPath)) result.bakPresent.push(bakPath);
|
|
552
|
+
|
|
553
|
+
const unsafe = unsafeHookTargetReason(hookPath);
|
|
554
|
+
if (unsafe) {
|
|
555
|
+
result.skipped.push(`${hookPath} (${unsafe})`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (!existsSync(hookPath)) continue; // already absent, nothing to report
|
|
559
|
+
|
|
560
|
+
let content;
|
|
561
|
+
try {
|
|
562
|
+
content = readFileSync(hookPath, 'utf-8');
|
|
563
|
+
} catch (e) {
|
|
564
|
+
result.skipped.push(`${hookPath} (cannot read: ${e.code || e.message})`);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (!content.includes(WIKI_PRE_COMMIT_MARKER_START) || !content.includes(WIKI_PRE_COMMIT_MARKER_END)) {
|
|
568
|
+
result.skipped.push(`${hookPath} (not managed by Hypomnema — preserving)`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const span = findMarkerSpan(content, WIKI_PRE_COMMIT_MARKER_START, WIKI_PRE_COMMIT_MARKER_END);
|
|
573
|
+
if (!span.ok) {
|
|
574
|
+
result.skipped.push(`${hookPath} (${span.reason} — preserving)`);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// init writes the marker as the ENTIRE hook body: a bare "#!/bin/sh\n"
|
|
579
|
+
// right before WIKI_PRE_COMMIT_MARKER_START, nothing after
|
|
580
|
+
// WIKI_PRE_COMMIT_MARKER_END. A file with no shebang there was never
|
|
581
|
+
// written by init even if it happens to carry a well-formed marker span
|
|
582
|
+
// (hand-authored or copy-pasted) — deleting it on marker presence alone
|
|
583
|
+
// would remove code we do not own. A user who appended their own check
|
|
584
|
+
// after the block turned this into a file we only partly own. init's own
|
|
585
|
+
// --force-commands path handles the analogous case by OVERWRITING with
|
|
586
|
+
// equivalent content (a safe merge); uninstall has no such repair, only
|
|
587
|
+
// rmSync, so treating "extra content" the same as "fully ours" would
|
|
588
|
+
// silently delete the user's check with no way back.
|
|
589
|
+
const before = content.slice(0, span.startIdx);
|
|
590
|
+
const after = content.slice(span.endIdx + WIKI_PRE_COMMIT_MARKER_END.length);
|
|
591
|
+
if (!/^#![^\n]*\n$/.test(before)) {
|
|
592
|
+
result.skipped.push(`${hookPath} (hook carries content before the Hypomnema block — preserving)`);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (after.trim() !== '') {
|
|
596
|
+
result.skipped.push(`${hookPath} (hook carries content after the Hypomnema block — preserving)`);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (apply) rmSync(hookPath);
|
|
601
|
+
result.removed.push(hookPath);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return result;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── shell function block removal ────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
// init picks ONE rc file at install time from $SHELL (or --shell-config), but
|
|
610
|
+
// $SHELL by uninstall time may point somewhere else, or init may have run in
|
|
611
|
+
// a different shell session altogether — so both common rc files are checked
|
|
612
|
+
// by default rather than guessing one. Only the marker span itself is
|
|
613
|
+
// stripped; every other byte in the file, including surrounding blank lines,
|
|
614
|
+
// is left exactly as it was. A malformed span (duplicated or swapped markers,
|
|
615
|
+
// see findMarkerSpan above) leaves the file completely untouched: an rc file
|
|
616
|
+
// is the user's own, and this script has no backup to restore it from.
|
|
617
|
+
function removeShellFunctionBlock(shellConfigPath, apply) {
|
|
618
|
+
if (!existsSync(shellConfigPath)) return null;
|
|
619
|
+
const content = readFileSync(shellConfigPath, 'utf-8');
|
|
620
|
+
if (!content.includes(SHELL_MARKER_START) && !content.includes(SHELL_MARKER_END)) {
|
|
621
|
+
return null; // block not present here at all
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const span = findMarkerSpan(content, SHELL_MARKER_START, SHELL_MARKER_END);
|
|
625
|
+
if (!span.ok) {
|
|
626
|
+
return { path: shellConfigPath, removed: false, skipped: span.reason };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const updated = content.slice(0, span.startIdx) + content.slice(span.endIdx + SHELL_MARKER_END.length);
|
|
630
|
+
if (apply) writeFileSync(shellConfigPath, updated);
|
|
631
|
+
return { path: shellConfigPath, removed: true, skipped: null };
|
|
632
|
+
}
|
|
633
|
+
|
|
430
634
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
431
635
|
|
|
432
636
|
function parseArgs(argv) {
|
|
@@ -436,6 +640,8 @@ function parseArgs(argv) {
|
|
|
436
640
|
hooksDir: null,
|
|
437
641
|
forceCommands: false,
|
|
438
642
|
forceExtensions: false,
|
|
643
|
+
hypoDir: null,
|
|
644
|
+
shellConfig: null,
|
|
439
645
|
};
|
|
440
646
|
for (const arg of argv.slice(2)) {
|
|
441
647
|
if (arg === '--apply') args.apply = true;
|
|
@@ -443,6 +649,16 @@ function parseArgs(argv) {
|
|
|
443
649
|
else if (arg === '--force-commands') args.forceCommands = true;
|
|
444
650
|
else if (arg === '--force-extensions') args.forceExtensions = true;
|
|
445
651
|
else if (arg.startsWith('--hooks-dir=')) args.hooksDir = arg.slice(12);
|
|
652
|
+
// expandHome mirrors init.mjs's own --hypo-dir/--shell-config parsing
|
|
653
|
+
// (init.mjs's parseArgs) exactly, reusing the same function from
|
|
654
|
+
// ./lib/hypo-root.mjs rather than re-deriving it. Uninstall must undo
|
|
655
|
+
// whatever path init actually wrote to disk, and init resolves a leading
|
|
656
|
+
// "~/" itself (the shell never does, since the value arrives already
|
|
657
|
+
// quoted inside "--flag=value"); skipping that step here would silently
|
|
658
|
+
// fail to find the vault or rc file a user installed with "~/..." to
|
|
659
|
+
// begin with.
|
|
660
|
+
else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
661
|
+
else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
|
|
446
662
|
}
|
|
447
663
|
return args;
|
|
448
664
|
}
|
|
@@ -510,6 +726,17 @@ function removeHookFiles(hooksDir, hookFiles, apply) {
|
|
|
510
726
|
missing.push(p);
|
|
511
727
|
}
|
|
512
728
|
}
|
|
729
|
+
// .hypo-provenance.json (scripts/lib/pkg-provenance.mjs) is written next to
|
|
730
|
+
// this exact hooksDir by installHooks/applyHookFiles — same lifecycle as the
|
|
731
|
+
// hook files themselves, so it is removed in the same pass rather than left
|
|
732
|
+
// behind to describe a package root that no longer has any hooks pointing
|
|
733
|
+
// through it. Unlike hookFiles above, this is an optional file (only the
|
|
734
|
+
// manual/npm channel ever writes one) — so, mirroring the loop's own
|
|
735
|
+
// present/absent split, it is only added to `removed` (dry-run: "to remove")
|
|
736
|
+
// when it actually exists; a channel that never wrote one gets no line at
|
|
737
|
+
// all, not a spurious "already absent".
|
|
738
|
+
const sidecar = removeProvenanceSidecar(hooksDir, apply);
|
|
739
|
+
if (sidecar) removed.push(sidecar);
|
|
513
740
|
return { removed, missing };
|
|
514
741
|
}
|
|
515
742
|
|
|
@@ -577,6 +804,20 @@ const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
|
|
|
577
804
|
const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
|
|
578
805
|
const commandResult = removeCommands(args.apply, args.forceCommands);
|
|
579
806
|
|
|
807
|
+
// Wiki-side cleanup: the git pre-commit hook and the shell rc block init.mjs
|
|
808
|
+
// installs outside ~/.claude entirely. Both are independent of --codex/--hooks-dir.
|
|
809
|
+
const hypoDir = args.hypoDir ?? resolveHypoRoot();
|
|
810
|
+
const preCommitResult = removeWikiPreCommitHook(hypoDir, args.apply);
|
|
811
|
+
|
|
812
|
+
const shellConfigCandidates = args.shellConfig
|
|
813
|
+
? [args.shellConfig]
|
|
814
|
+
: [join(HOME, '.zshrc'), join(HOME, '.bashrc')];
|
|
815
|
+
const shellBlockOutcomes = shellConfigCandidates
|
|
816
|
+
.map((p) => removeShellFunctionBlock(p, args.apply))
|
|
817
|
+
.filter(Boolean);
|
|
818
|
+
const shellBlockResults = shellBlockOutcomes.filter((r) => r.removed).map((r) => r.path);
|
|
819
|
+
const shellBlockSkipped = shellBlockOutcomes.filter((r) => !r.removed);
|
|
820
|
+
|
|
580
821
|
// Extensions. Order matters: remove files first, then strip
|
|
581
822
|
// settings, then surgically clear the per-target SHA map. The SHA strip uses
|
|
582
823
|
// removedKeys so a user-modified file we left in place keeps its recorded SHA
|
|
@@ -724,6 +965,27 @@ if (hookResult.missing.length)
|
|
|
724
965
|
lines.push(
|
|
725
966
|
`⊘ Already absent (${hookResult.missing.length}):\n${hookResult.missing.map((p) => ` ${p}`).join('\n')}`,
|
|
726
967
|
);
|
|
968
|
+
if (preCommitResult.removed.length)
|
|
969
|
+
lines.push(
|
|
970
|
+
`✓ Wiki pre-commit hook ${dryRun ? 'to remove' : 'removed'} (${preCommitResult.removed.length}):\n${preCommitResult.removed.map((p) => ` ${p}`).join('\n')}`,
|
|
971
|
+
);
|
|
972
|
+
if (preCommitResult.skipped.length)
|
|
973
|
+
lines.push(
|
|
974
|
+
`⊘ Wiki pre-commit hook preserved:\n${preCommitResult.skipped.map((p) => ` ${p}`).join('\n')}`,
|
|
975
|
+
);
|
|
976
|
+
if (preCommitResult.bakPresent.length)
|
|
977
|
+
lines.push(
|
|
978
|
+
`ⓘ Pre-commit backup left in place (from --force-commands, never touched by uninstall):\n${preCommitResult.bakPresent.map((p) => ` ${p}`).join('\n')}`,
|
|
979
|
+
);
|
|
980
|
+
if (shellBlockResults.length)
|
|
981
|
+
lines.push(
|
|
982
|
+
`✓ Shell function block ${dryRun ? 'to remove' : 'removed'} (${shellBlockResults.length}):\n${shellBlockResults.map((p) => ` ${p}`).join('\n')}`,
|
|
983
|
+
);
|
|
984
|
+
if (shellBlockSkipped.length)
|
|
985
|
+
lines.push(
|
|
986
|
+
`⊘ Shell function block preserved:\n${shellBlockSkipped.map((r) => ` ${r.path} (${r.skipped})`).join('\n')}`,
|
|
987
|
+
);
|
|
988
|
+
|
|
727
989
|
if (settingsResult.error) lines.push(`⚠ ${settingsResult.error}`);
|
|
728
990
|
if (claudeExtSettings.error) lines.push(`⚠ ${claudeExtSettings.error}`);
|
|
729
991
|
if (codexExtSettings.error) lines.push(`⚠ ${codexExtSettings.error}`);
|
|
@@ -738,7 +1000,9 @@ if (
|
|
|
738
1000
|
!pkgJsonRemoved &&
|
|
739
1001
|
!commandResult.skippedUserModified.length &&
|
|
740
1002
|
!extSkippedUserModified.length &&
|
|
741
|
-
!extSkippedNonRegular.length
|
|
1003
|
+
!extSkippedNonRegular.length &&
|
|
1004
|
+
!preCommitResult.removed.length &&
|
|
1005
|
+
!shellBlockResults.length
|
|
742
1006
|
) {
|
|
743
1007
|
lines.push('Nothing to uninstall — Hypomnema does not appear to be installed.');
|
|
744
1008
|
}
|