hypomnema 1.8.2 → 1.8.4

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/scripts/init.mjs CHANGED
@@ -48,8 +48,9 @@ import {
48
48
  SHELL_MARKER_END,
49
49
  SHELL_FUNCTION_BODY,
50
50
  wikiPreCommitContent,
51
+ uniqueBakPath,
51
52
  } from './lib/git-hooks-dir.mjs';
52
- import { readCoreHooksConfig } from './lib/core-hooks.mjs';
53
+ import { loadHookInventory } from './lib/hook-inventory.mjs';
53
54
  import {
54
55
  readPkgJson as readPkgJsonSafe,
55
56
  writePkgJsonAtomic,
@@ -61,7 +62,7 @@ import { syncExtensions } from './lib/extensions.mjs';
61
62
  import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
62
63
  import { templateSchemaVersion } from './lib/template-schema-version.mjs';
63
64
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
64
- import { resolvePluginChannel, usablePkgRoot } from './lib/plugin-detect.mjs';
65
+ import { resolvePluginChannel, isHypomnemaInstallRoot } from './lib/plugin-detect.mjs';
65
66
 
66
67
  const HOME = homedir();
67
68
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -367,90 +368,19 @@ function writeGitignore(hypoDir, dryRun) {
367
368
  // ── hook installation ────────────────────────────────────────────────────────
368
369
 
369
370
  function loadHookMap() {
370
- // Read + parse via the exit-free shared helper; keep init's own validation and
371
- // exit(1) behavior below. The helper omits `cfg` only on read/parse failure
372
- // (JSON.parse never yields undefined), so key presence discriminates a
373
- // read/parse failure from a parsed-but-malformed shape.
374
- const res = readCoreHooksConfig(PKG_ROOT);
375
- if (!('cfg' in res)) {
376
- console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
371
+ // loadHookInventory (scripts/lib/hook-inventory.mjs) owns the grammar AND the
372
+ // existence check now: every consumer that installs, refreshes, or removes a
373
+ // hook file reads through the same parser, and a name on either list that does
374
+ // not resolve to a real file under hooks/ fails this call before init writes
375
+ // anything at all (major C — a hook file dropped from the shipped package used
376
+ // to install a broken settings.json registration with nothing behind it).
377
+ const res = loadHookInventory(PKG_ROOT);
378
+ if (!res.ok) {
379
+ console.error(`Error: ${res.error}`);
377
380
  console.error(PKG_INTEGRITY_HINT);
378
381
  process.exit(1);
379
382
  }
380
- const cfg = res.cfg;
381
- if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
382
- console.error('Error: hooks/hooks.json must be a JSON object');
383
- console.error(PKG_INTEGRITY_HINT);
384
- process.exit(1);
385
- }
386
- if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
387
- console.error('Error: hooks/hooks.json must contain a "hooks" object');
388
- console.error(PKG_INTEGRITY_HINT);
389
- process.exit(1);
390
- }
391
- function _extractCommandFileName(command) {
392
- if (typeof command !== 'string') return null;
393
- const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
394
- if (matches.length > 0) return matches[matches.length - 1][1];
395
- const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
396
- return bare ? bare[1] : null;
397
- }
398
-
399
- function _isHookFileName(file) {
400
- return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
401
- }
402
-
403
- function _isHookGroup(group) {
404
- return (
405
- group &&
406
- typeof group === 'object' &&
407
- !Array.isArray(group) &&
408
- Array.isArray(group.hooks) &&
409
- group.hooks.length > 0 &&
410
- group.hooks.every(
411
- (hook) =>
412
- hook &&
413
- typeof hook === 'object' &&
414
- !Array.isArray(hook) &&
415
- hook.type === 'command' &&
416
- _extractCommandFileName(hook.command),
417
- )
418
- );
419
- }
420
-
421
- // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
422
- function _extractFileNames(groups) {
423
- return groups.flatMap((group) => {
424
- if (typeof group === 'string') return [group.trim()];
425
- return group.hooks.map((hook) => _extractCommandFileName(hook.command));
426
- });
427
- }
428
-
429
- for (const [event, groups] of Object.entries(cfg.hooks)) {
430
- const valid =
431
- Array.isArray(groups) &&
432
- groups.length > 0 &&
433
- groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
434
- _extractFileNames(groups).length > 0;
435
- if (!valid) {
436
- console.error(
437
- `Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
438
- );
439
- console.error(PKG_INTEGRITY_HINT);
440
- process.exit(1);
441
- }
442
- }
443
- if (
444
- cfg.shared !== undefined &&
445
- (!Array.isArray(cfg.shared) || !cfg.shared.every((f) => _isHookFileName(f)))
446
- ) {
447
- console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
448
- console.error(PKG_INTEGRITY_HINT);
449
- process.exit(1);
450
- }
451
- return Object.fromEntries(
452
- Object.entries(cfg.hooks).map(([event, groups]) => [event, _extractFileNames(groups)]),
453
- );
383
+ return res.hookMap;
454
384
  }
455
385
 
456
386
  function installHooks(targetDir, dryRun) {
@@ -578,8 +508,9 @@ function readPkgVersionAt(root) {
578
508
  }
579
509
  }
580
510
 
581
- // usablePkgRoot now lives in ./lib/plugin-detect.mjs (imported above), shared with
582
- // upgrade.mjs's dualSkip provenance correction so both agree on what is real.
511
+ // usablePkgRoot / isHypomnemaInstallRoot now live in ./lib/plugin-detect.mjs
512
+ // (imported above), shared with upgrade.mjs's dualSkip provenance correction so
513
+ // both agree on what is real.
583
514
 
584
515
  function writePkgJson(dryRun, extraFields = {}, root = PKG_ROOT) {
585
516
  const dest = pkgJsonPath();
@@ -759,12 +690,14 @@ function installPkgGitHook(dryRun) {
759
690
  // ── wiki pre-commit hook ─────────────────────────────────────────────────────
760
691
  //
761
692
  // shellSingleQuote and wikiPreCommitContent live in lib/git-hooks-dir.mjs, not
762
- // here: upgrade.mjs's self-heal (repointing an existing hook at the current
763
- // durable root) and doctor.mjs's drift check both need to build/parse the
764
- // exact same body this writer produces, so one copy is shared rather than
765
- // three independent copies silently drifting apart.
766
-
767
- function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
693
+ // here: upgrade.mjs's old-to-new-form migration and doctor.mjs's report both
694
+ // need to build/parse the exact same body this writer produces, so one copy is
695
+ // shared rather than three independent copies silently drifting apart.
696
+ // wikiPreCommitContent no longer takes an install root: the body
697
+ // it generates resolves that itself, at commit time, so there is nothing here
698
+ // for a plugin-channel version bump to make stale.
699
+
700
+ function installWikiPreCommitHook(hypoDir, dryRun, force, lintStrict) {
768
701
  const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
769
702
  if (!hooksDir) {
770
703
  // no git repo — silently skip, as before; anything else is worth surfacing
@@ -772,7 +705,7 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
772
705
  return;
773
706
  }
774
707
  const hookPath = join(hooksDir, 'pre-commit');
775
- const newContent = wikiPreCommitContent(root, hypoDir, lintStrict);
708
+ const newContent = wikiPreCommitContent(hypoDir, lintStrict);
776
709
 
777
710
  // Before every branch below, including --force: a symlink here would send the
778
711
  // write through to an arbitrary external file.
@@ -789,20 +722,36 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
789
722
  log('skipped', `${hookPath} (pre-commit up to date)`);
790
723
  return;
791
724
  }
725
+ // Back this up first. The marker's presence says our block is IN the
726
+ // file; it says nothing about what else is, and this branch replaces the
727
+ // whole file either way. upgrade.mjs refuses exactly this shape (content
728
+ // outside the marker span) and tells the user to run
729
+ // `init --force-commands` — so without a backup here, following our own
730
+ // recovery advice is what destroys their hook. Measured by a codex
731
+ // reviewer 2026-09-11; the `force` branch below already backed up, this
732
+ // one did not, and `force` is not even consulted here.
733
+ const bakPath = uniqueBakPath(hookPath);
734
+ if (!bakPath) {
735
+ log('skipped', `${hookPath} (unsafe backup path) — not overwriting without a safe backup`);
736
+ return;
737
+ }
792
738
  if (!dryRun) {
739
+ writeFileSync(bakPath, existing);
793
740
  writeFileSync(hookPath, newContent);
794
741
  chmodSync(hookPath, 0o755);
795
742
  }
796
- log('merged', `${hookPath} (pre-commit updated)`);
743
+ log('merged', `${hookPath} (pre-commit updated, backup at ${basename(bakPath)})`);
797
744
  } else if (force) {
798
745
  // The .bak is a SECOND write to a DIFFERENT path, so the guard on
799
746
  // hookPath above says nothing about it. Left unchecked, a pre-commit.bak
800
747
  // symlink turns --force-commands into an overwrite of whatever it points
801
748
  // at — deterministically, not as a race.
802
- const bakPath = hookPath + '.bak';
803
- const unsafeBak = unsafeHookTargetReason(bakPath);
804
- if (unsafeBak) {
805
- log('skipped', `${bakPath} (${unsafeBak}) — not force-overwriting without a safe backup`);
749
+ const bakPath = uniqueBakPath(hookPath);
750
+ if (!bakPath) {
751
+ log(
752
+ 'skipped',
753
+ `${hookPath} (unsafe backup path) — not force-overwriting without a safe backup`,
754
+ );
806
755
  return;
807
756
  }
808
757
  if (!dryRun) {
@@ -810,7 +759,7 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
810
759
  writeFileSync(hookPath, newContent);
811
760
  chmodSync(hookPath, 0o755);
812
761
  }
813
- log('merged', `${hookPath} (force-overwritten, backup at pre-commit.bak)`);
762
+ log('merged', `${hookPath} (force-overwritten, backup at ${basename(bakPath)})`);
814
763
  } else {
815
764
  log(
816
765
  'skipped',
@@ -1040,7 +989,12 @@ function resolveDurableRoot() {
1040
989
  if (!hypomnemaPluginEnabled) return PKG_ROOT;
1041
990
  if (pluginChannel.root) return pluginChannel.root;
1042
991
  const recorded = recordedPkgRoot();
1043
- if (usablePkgRoot(recorded)) return recorded;
992
+ // isHypomnemaInstallRoot, not the weak usablePkgRoot: this value is about to be
993
+ // adopted as the durable root and re-recorded (see writePkgJson's `root` param
994
+ // below), so a recorded pointer that merely resolves a readable version, without
995
+ // actually being a Hypomnema package, must not be trusted here (see
996
+ // lib/plugin-detect.mjs's comment on the two predicates).
997
+ if (isHypomnemaInstallRoot(recorded)) return recorded;
1044
998
  // pluginChannel.root is null here because the channel JUDGMENT FAILED (reason
1045
999
  // 'registry-unreadable' or 'unresolved'), not because no plugin is installed —
1046
1000
  // hypomnemaPluginEnabled is already true, and that case already returned
@@ -1321,13 +1275,7 @@ if (args.hooks) {
1321
1275
  // any) is left exactly as-is, and channelUnresolvedNotice() above says what to
1322
1276
  // do next.
1323
1277
  if (!channelUnresolved) {
1324
- installWikiPreCommitHook(
1325
- args.hypoDir,
1326
- args.dryRun,
1327
- args.forceCommands,
1328
- durableRoot,
1329
- args.lintStrict,
1330
- );
1278
+ installWikiPreCommitHook(args.hypoDir, args.dryRun, args.forceCommands, args.lintStrict);
1331
1279
  } else {
1332
1280
  log(
1333
1281
  'skipped',
@@ -1,4 +1,5 @@
1
- // core-hooks.mjs - read the packaged hooks/hooks.json without side effects.
1
+ // core-hooks.mjs - read the packaged hooks/hooks.json and hooks/shared.json
2
+ // without side effects.
2
3
  //
3
4
  // The init installer (init.mjs loadHookMap) reads hooks.json, validates it, and
4
5
  // calls process.exit(1) on any malformation. That exit-on-error behavior is
@@ -12,28 +13,39 @@
12
13
  // hooks type when the result is not ok (better to capture nothing than to
13
14
  // capture a core hook).
14
15
  //
15
- // fail-closed: a result is ok only when the file reads, parses, AND has the
16
- // expected shape (a hooks registration map plus a shared array). A parsed but
17
- // oddly shaped hooks.json would yield a thin basename set, which is exactly the
18
- // gap through which a core hook could leak into capture. When the shape is off
19
- // we still attach the parsed cfg so init can run its own validation and emit its
16
+ // The shared-file list used to live at hooks.json's own top-level "shared" key.
17
+ // It moved to a sibling file, hooks/shared.json: the harness started
18
+ // warning on hooks.json's unknown "shared" key once it began validating that
19
+ // file against its own hook-registration schema, and "shared" was never part of
20
+ // that schema, it was a Hypomnema-only convention the harness never asked for.
21
+ // This module still folds shared.json's contents onto `cfg.shared` after
22
+ // reading it, so every existing caller of deriveCoreHookBasenames keeps working
23
+ // against the same shape without a signature change.
24
+ //
25
+ // fail-closed: a result is ok only when both files read, parse, AND have the
26
+ // expected shape (a hooks registration map, and a shared array). A parsed but
27
+ // oddly shaped input would yield a thin basename set, which is exactly the gap
28
+ // through which a core hook could leak into capture. When the shape is off we
29
+ // still attach the parsed cfg so init can run its own validation and emit its
20
30
  // own specific error, but ok is false so capture stays conservative.
21
31
 
22
32
  import { readFileSync } from 'node:fs';
23
33
  import { join } from 'node:path';
24
34
 
25
35
  /**
26
- * Read and JSON-parse hooks/hooks.json from a package root. No process.exit, no
27
- * top-level side effects.
36
+ * Read and JSON-parse hooks/hooks.json and hooks/shared.json from a package
37
+ * root. No process.exit, no top-level side effects.
28
38
  *
29
39
  * @param {string} pkgRoot absolute path to the package root (contains hooks/)
30
40
  * @returns {{ ok: true, cfg: object }
31
41
  * | { ok: false, error: string }
32
42
  * | { ok: false, error: string, cfg: * }}
33
- * On read or parse failure the `cfg` key is absent. On a successful parse the
34
- * `cfg` key is always present (even for null/array/scalar/shape-off inputs),
35
- * so a caller can discriminate read/parse failure from shape failure by the
36
- * presence of the `cfg` key. `ok` is true only when the shape is as expected.
43
+ * On read or parse failure of hooks.json the `cfg` key is absent. Once
44
+ * hooks.json parses, `cfg` is always present (even for null/array/scalar/
45
+ * shape-off inputs), so a caller can discriminate a hooks.json read/parse
46
+ * failure from a shape failure (including a shared.json failure) by the
47
+ * presence of the `cfg` key. `ok` is true only when both files' shapes are
48
+ * as expected, and when true `cfg.shared` is the parsed shared.json array.
37
49
  */
38
50
  export function readCoreHooksConfig(pkgRoot) {
39
51
  let raw;
@@ -55,15 +67,12 @@ export function readCoreHooksConfig(pkgRoot) {
55
67
  if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
56
68
  return { ok: false, error: 'hooks/hooks.json must contain a "hooks" object', cfg };
57
69
  }
58
- if (!Array.isArray(cfg.shared)) {
59
- return { ok: false, error: 'hooks/hooks.json must contain a "shared" array', cfg };
60
- }
61
70
  // Nested shape: a structurally odd rung (event not an array, a group without a
62
- // hooks array, a hook entry with no string command, a non-string shared element)
63
- // is silently skipped by deriveCoreHookBasenames, yielding a THIN reserved set.
64
- // That is the gap through which a core hook could leak into reverse-capture, so
65
- // validate every rung and fail closed. cfg is still attached so init can run its
66
- // own detailed validation and emit its own specific error.
71
+ // hooks array, a hook entry with no string command) is silently skipped by
72
+ // deriveCoreHookBasenames, yielding a THIN reserved set. That is the gap
73
+ // through which a core hook could leak into reverse-capture, so validate
74
+ // every rung and fail closed. cfg is still attached so init can run its own
75
+ // detailed validation and emit its own specific error.
67
76
  for (const groups of Object.values(cfg.hooks)) {
68
77
  if (!Array.isArray(groups)) {
69
78
  return { ok: false, error: 'each hooks event must map to an array of groups', cfg };
@@ -86,11 +95,28 @@ export function readCoreHooksConfig(pkgRoot) {
86
95
  }
87
96
  }
88
97
  }
89
- for (const file of cfg.shared) {
98
+
99
+ let sharedRaw;
100
+ try {
101
+ sharedRaw = readFileSync(join(pkgRoot, 'hooks', 'shared.json'), 'utf-8');
102
+ } catch (err) {
103
+ return { ok: false, error: `cannot read hooks/shared.json: ${err.message}`, cfg };
104
+ }
105
+ let shared;
106
+ try {
107
+ shared = JSON.parse(sharedRaw);
108
+ } catch (err) {
109
+ return { ok: false, error: `hooks/shared.json is not valid JSON: ${err.message}`, cfg };
110
+ }
111
+ if (!Array.isArray(shared)) {
112
+ return { ok: false, error: 'hooks/shared.json must be a JSON array', cfg };
113
+ }
114
+ for (const file of shared) {
90
115
  if (typeof file !== 'string') {
91
- return { ok: false, error: 'each "shared" entry must be a string', cfg };
116
+ return { ok: false, error: 'each hooks/shared.json entry must be a string', cfg };
92
117
  }
93
118
  }
119
+ cfg.shared = shared;
94
120
  return { ok: true, cfg };
95
121
  }
96
122