hypomnema 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,8 @@ import {
38
38
  EXT_TYPES,
39
39
  CODEX_TYPES,
40
40
  readExtensionPkgStateNoMutate,
41
+ parseExtKey,
42
+ buildHookCommand,
41
43
  } from './lib/extensions.mjs';
42
44
 
43
45
  const HOME = homedir();
@@ -138,13 +140,23 @@ function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply) {
138
140
  return changed;
139
141
  }
140
142
 
141
- // Remove hypo-ext-* hard-copies for one target (claude | codex). Mirrors
142
- // removeCommands' 3-way model: filesystem scan + per-target recorded SHA from
143
- // hypo-pkg.json#extensions[target] decides ownership. A file we never installed
144
- // (no recorded SHA) is left alone that may be a foreign hypo-ext-* file the
145
- // user created by hand, never ours to delete. A user-modified file is preserved
146
- // unless --force-extensions; a symlink/non-regular target is always preserved
147
- // (force does not follow them, matching install/upgrade E3's guard).
143
+ // Remove Hypomnema-installed extension hard-copies for one target (claude |
144
+ // codex). Ownership is decided by the per-target recorded SHA map in
145
+ // hypo-pkg.json#extensions[target]: we iterate the RECORDED KEYS (not a
146
+ // filesystem+prefix scan) because the reverse-capture design decoupled the install filename from
147
+ // the wiki `hypo-ext-*` storage name a reverse-captured command installs as
148
+ // `commands/mycmd.md`, which a prefix scan would never reach. The SHA map key
149
+ // IS the install path relative to the target root, so it names every file we
150
+ // own regardless of its filename.
151
+ //
152
+ // Safety (capture design §8): a recorded key comes from an on-disk JSON we do not
153
+ // fully control, so each key is validated by `parseExtKey` to a single covered
154
+ // `<type>/<safe-basename>` segment (no separators / traversal) before any
155
+ // join/rm — a corrupt or malicious key can never delete outside the extension
156
+ // directory. "Never delete unowned" holds via SHA MATCHING, not the prefix: a
157
+ // file whose on-disk SHA differs from the recorded one is user-modified and
158
+ // preserved unless --force-extensions; a symlink/non-regular target is always
159
+ // preserved (force does not follow them, matching install/upgrade E3's guard).
148
160
  function removeExtensions(target, apply, force) {
149
161
  const targetRoot = target === 'codex' ? join(HOME, '.codex') : join(HOME, '.claude');
150
162
  const types = target === 'codex' ? CODEX_TYPES : EXT_TYPES;
@@ -158,37 +170,27 @@ function removeExtensions(target, apply, force) {
158
170
  const skippedUserModified = [];
159
171
  const skippedNonRegular = [];
160
172
 
161
- for (const type of types) {
162
- const typeDir = join(targetRoot, type);
163
- if (!existsSync(typeDir)) continue;
164
- let entries;
165
- try {
166
- entries = readdirSync(typeDir);
167
- } catch {
173
+ for (const [key, recordedSHA] of Object.entries(recorded)) {
174
+ if (!recordedSHA) continue; // no ownership baseline → nothing to remove
175
+ const parsed = parseExtKey(key, types);
176
+ if (!parsed) continue; // untrusted / cross-target key → never join+rm
177
+ const fullPath = join(targetRoot, parsed.type, parsed.installFile);
178
+ if (!existsSync(fullPath)) continue; // already gone
179
+
180
+ if (!isRegularFile(fullPath)) {
181
+ // Refuse to follow symlinks/sockets even under --force-extensions.
182
+ skippedNonRegular.push(fullPath);
168
183
  continue;
169
184
  }
170
- for (const fname of entries) {
171
- if (!fname.startsWith(EXT_PREFIX)) continue;
172
- const key = `${type}/${fname}`;
173
- const recordedSHA = recorded[key];
174
- if (!recordedSHA) continue; // not tracked by us → leave alone
175
- const fullPath = join(typeDir, fname);
176
-
177
- if (!isRegularFile(fullPath)) {
178
- // Refuse to follow symlinks/sockets even under --force-extensions.
179
- skippedNonRegular.push(fullPath);
180
- continue;
181
- }
182
- const buf = readFileIfRegular(fullPath);
183
- const sha = buf ? sha256(buf) : null;
184
-
185
- if (sha === recordedSHA || force) {
186
- if (apply) rmSync(fullPath);
187
- removed.push(fullPath);
188
- removedKeys.push(key);
189
- } else {
190
- skippedUserModified.push(fullPath);
191
- }
185
+ const buf = readFileIfRegular(fullPath);
186
+ const sha = buf ? sha256(buf) : null;
187
+
188
+ if (sha === recordedSHA || force) {
189
+ if (apply) rmSync(fullPath);
190
+ removed.push(fullPath);
191
+ removedKeys.push(key);
192
+ } else {
193
+ skippedUserModified.push(fullPath);
192
194
  }
193
195
  }
194
196
  return { target, removed, removedKeys, skippedUserModified, skippedNonRegular };
@@ -226,12 +228,39 @@ function unprocessedExtensionTargetRemains(pkgPath, processedTargets) {
226
228
  return false;
227
229
  }
228
230
 
229
- // settings.json: strip groups whose command points to a hypo-ext-* path under
230
- // the target's hooks dir. Identity is path-based (plan §0 D1) no hookMap needed
231
+ // Build the set of settings.json commands owned by reverse-captured hooks that
232
+ // install under their ORIGINAL name (not the hypo-ext-* storage name). The prefix
233
+ // scan below only reaches hypo-ext-* commands; a captured hook registers as
234
+ // `node $HOME/.claude/hooks/foo.mjs`, which no prefix would ever match. The
235
+ // recorded per-target SHA map names every file we own, so its hooks `.mjs` keys
236
+ // reconstruct exactly the commands we registered. Read WITHOUT mutation and while
237
+ // the map is still whole. stripExtensionsFromPkg (which clears it) runs later.
238
+ // Only `.mjs` keys are commands; the paired `.manifest.json` sidecar keys (which
239
+ // parseExtKey also admits for hooks) are dropped by the extension check.
240
+ function ownedHookCommands(pkgPath, target, hooksDir) {
241
+ const types = target === 'codex' ? CODEX_TYPES : EXT_TYPES;
242
+ const recorded = readExtensionPkgStateNoMutate(pkgPath, target);
243
+ const commands = new Set();
244
+ for (const key of Object.keys(recorded)) {
245
+ const parsed = parseExtKey(key, types);
246
+ if (!parsed || parsed.type !== 'hooks') continue;
247
+ if (!parsed.installFile.endsWith('.mjs')) continue;
248
+ commands.add(buildHookCommand(hooksDir, parsed.installFile));
249
+ }
250
+ return commands;
251
+ }
252
+
253
+ // settings.json: strip groups whose command is one of ours under the target's
254
+ // hooks dir. Ownership is the UNION of two path-based signals (plan §0 D1, P2):
255
+ // the hypo-ext-* command prefix (catches wiki-authored hooks and hypo-ext-*
256
+ // leftovers whose recorded key was already cleared by a partial uninstall) and
257
+ // the recorded owned-command set (`ownedCommands`, catches reverse-captured hooks
258
+ // registered under their original name, which carry no hypo-ext-* marker). Both
259
+ // branches are needed, so this is a union, not a replacement. No hookMap needed
231
260
  // because ext hooks are never enumerated there. Mixed groups (foreign hook +
232
261
  // ours) keep the foreign hook; ours-only groups are dropped entirely. Mirrors
233
262
  // stripSettingsJson's flatMap pattern so other-plugin invariants (§7.3) hold.
234
- function stripExtensionSettings(settingsPath, hooksDir, apply) {
263
+ function stripExtensionSettings(settingsPath, hooksDir, apply, ownedCommands = new Set()) {
235
264
  if (!existsSync(settingsPath)) return { stripped: [] };
236
265
  let settings;
237
266
  try {
@@ -243,7 +272,10 @@ function stripExtensionSettings(settingsPath, hooksDir, apply) {
243
272
 
244
273
  const cmdPrefix = `node ${hooksDir.replace(HOME, '$HOME')}/${EXT_PREFIX}`;
245
274
  const isExtHook = (h) =>
246
- h && h.type === 'command' && typeof h.command === 'string' && h.command.startsWith(cmdPrefix);
275
+ h &&
276
+ h.type === 'command' &&
277
+ typeof h.command === 'string' &&
278
+ (h.command.startsWith(cmdPrefix) || ownedCommands.has(h.command));
247
279
 
248
280
  const stripped = [];
249
281
  let changed = false;
@@ -432,7 +464,12 @@ const commandResult = removeCommands(args.apply, args.forceCommands);
432
464
  // hook copy was deleted by hand (force-commands legacy state).
433
465
  const pkgJsonPath = join(HOME, '.claude', 'hypo-pkg.json');
434
466
  const claudeExtResult = removeExtensions('claude', args.apply, args.forceExtensions);
435
- const claudeExtSettings = stripExtensionSettings(claudeSettings, claudeHooksDir, args.apply);
467
+ const claudeExtSettings = stripExtensionSettings(
468
+ claudeSettings,
469
+ claudeHooksDir,
470
+ args.apply,
471
+ ownedHookCommands(pkgJsonPath, 'claude', claudeHooksDir),
472
+ );
436
473
 
437
474
  let codexHookResult = { removed: [], missing: [] };
438
475
  let codexSettingsResult = { stripped: [] };
@@ -452,7 +489,12 @@ if (args.codex) {
452
489
  codexHookResult = removeHookFiles(codexHooksDir, hookFiles, args.apply);
453
490
  codexSettingsResult = stripSettingsJson(codexSettings, codexHooksDir, hookMap, args.apply);
454
491
  codexExtResult = removeExtensions('codex', args.apply, args.forceExtensions);
455
- codexExtSettings = stripExtensionSettings(codexSettings, codexHooksDir, args.apply);
492
+ codexExtSettings = stripExtensionSettings(
493
+ codexSettings,
494
+ codexHooksDir,
495
+ args.apply,
496
+ ownedHookCommands(pkgJsonPath, 'codex', codexHooksDir),
497
+ );
456
498
  }
457
499
 
458
500
  // Surgical per-target ext SHA strip — only for files we actually removed.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.5.1"
4
+ version: "1.6.0"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7