novahiz 0.11.2 → 0.11.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/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.11.4] - 2026-10-22
9
+
10
+ ### Fixed
11
+
12
+ - **`novahiz setup` now completes on Linux** - `runSync()` executed
13
+ `home/src/cli.ts` directly with Node, which cannot read TypeScript
14
+ without `--experimental-strip-types`. On a fresh global install the home
15
+ has no `dist/` build, so the sync step failed with
16
+ `ERR_UNKNOWN_FILE_EXTENSION`. The flag is now added so the two-pass
17
+ install (core then extras) completes on Linux, macOS and Windows.
18
+
19
+ ## [0.11.3] - 2026-10-22
20
+
21
+ ### Fixed
22
+
23
+ - **`--clean` now sees the files an older version left behind** - the install
24
+ manifest never recorded what earlier installers had copied, so a home
25
+ upgraded across several versions kept dead entry points (`install/bootstrap.mjs`,
26
+ `install/install.sh`, `install/install.ps1`, `bin/veille-mensuelle.ps1`) that
27
+ `--clean` could not detect. `install/retired.json` now lists them with the
28
+ sha256 of the published content, line endings normalised to LF, and cleanup
29
+ removes an entry only when the file on disk still matches that fingerprint.
30
+ A file edited by hand no longer matches, comes back as kept, and is never
31
+ deleted. The inventory is read from the package, never from the home, so an
32
+ installation cannot widen the list of what it erases.
33
+
8
34
  ## [0.11.2] - 2026-10-22
9
35
 
10
36
  ### Fixed
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  17 catégories, 94 skills, 13 gate rules, 8 MCP providers — tout déterministe, tout local, tout JSON.
6
6
 
7
- > **Version 0.11.0** — `npm install -g novahiz` pose le CLI, et le premier `novahiz` lancé termine l'installation (core puis extras). `CHANGELOG.md` et `scripts/` sont dans le tarball.
7
+ > **Version 0.11.3** — `npm install -g novahiz` pose le CLI, et le premier `novahiz` lancé termine l'installation (core puis extras). `CHANGELOG.md` et `scripts/` sont dans le tarball.
8
8
 
9
9
  ```
10
10
  ┌──────────────────────────────────────────────────────────────────────────┐
package/dist/cli.js CHANGED
@@ -35,7 +35,10 @@ function runSync() {
35
35
  const home = homeDir();
36
36
  const cli = join(home, "src", "cli.ts");
37
37
  if (existsSync(cli)) {
38
- spawnSync(process.execPath, [cli, "sync"], {
38
+ // src/cli.ts est du TypeScript : Node ne le lit pas nativement. On ajoute
39
+ // --experimental-strip-types pour pouvoir l'exécuter depuis le home,
40
+ // qui ne contient pas le build dist/ (réservé au paquet publié).
41
+ spawnSync(process.execPath, ["--experimental-strip-types", cli, "sync"], {
39
42
  encoding: "utf8",
40
43
  stdio: "inherit",
41
44
  env: { ...process.env, NOVAHIZ_HOME: home }
@@ -52,6 +52,21 @@ const STEPS = [
52
52
  "Cleanup previous install"
53
53
  ];
54
54
 
55
+ /**
56
+ * Inventaire des fichiers retires du paquet (`install/retired.json`).
57
+ *
58
+ * Le manifeste d'installation ne peut pas connaitre ce qu'une version
59
+ * anterieure a copie avant que ce champ existe : cet inventaire comble le trou.
60
+ * Il est lu depuis le paquet installe, jamais depuis le home, pour qu'une
61
+ * installation ne puisse pas elargir elle-meme la liste de ce qu'elle efface.
62
+ * Un fichier absent ou illisible rend une liste vide : le nettoyage retombe
63
+ * alors sur le seul manifeste, jamais sur une supposition.
64
+ */
65
+ function readRetired() {
66
+ const parsed = readJson(join(repoRoot(import.meta.url), "install", "retired.json"), { files: [] });
67
+ return Array.isArray(parsed?.files) ? parsed : { files: [] };
68
+ }
69
+
55
70
  const CORE_ITEMS = [
56
71
  "src",
57
72
  "catalog",
@@ -896,9 +911,10 @@ async function main() {
896
911
  // --- Nettoyage de l'installation precedente ------------------------------
897
912
  // Une mise a jour ne peut pas laisser derriere elle des skills renommes, des
898
913
  // plugins retires ou des commandes devenues obsoles : ils restent charges au
899
- // prochain demarrage. L'effacement ne s'appuie que sur une preuve de
900
- // propriete, le manifeste d'installation precedent ; un fichier pose par un
901
- // pack ou par l'utilisateur n'y figure pas et n'est jamais retenu.
914
+ // prochain demarrage. L'effacement s'appuie sur deux preuves de propriete : le
915
+ // manifeste d'installation precedent, et pour les fichiers anterieurs a ce
916
+ // manifeste, l'empreinte publiee dans `retired.json`. Un fichier pose par un
917
+ // pack, par l'utilisateur, ou modifie a la main n'est jamais retenu.
902
918
  ui.step("Cleanup previous install");
903
919
  const ownedRoots = [
904
920
  { dest: skillsDir, src: join(home, "skills") },
@@ -906,9 +922,11 @@ async function main() {
906
922
  { dest: join(configDir, "agent"), src: join(home, "adapters", "opencode", "agent") },
907
923
  { dest: join(configDir, "commands"), src: join(home, "adapters", "opencode", "commands") }
908
924
  ];
909
- const cleanup = cleanupPreviousInstall({ home, configDir, roots: ownedRoots, clean, dryRun, stepFailure });
925
+ const retired = readRetired();
926
+ const cleanup = cleanupPreviousInstall({ home, configDir, roots: ownedRoots, clean, dryRun, stepFailure, retired });
910
927
  for (const path of cleanup.removed) note(` removed: ${relative(home, path)}`);
911
928
  for (const path of cleanup.stale ?? []) note(` would remove: ${relative(home, path)}`);
929
+ for (const path of cleanup.kept ?? []) note(` kept (modified since install): ${relative(home, path)}`);
912
930
  if (cleanup.removed.length > 0) {
913
931
  // L'index tient encore la liste des skills supprimes : on le rejoue une
914
932
  // fois, sinon le prochain routage classifiera vers un skill parti.
package/install/lib.d.mts CHANGED
@@ -100,6 +100,8 @@ export interface CleanupResult {
100
100
  removed: string[];
101
101
  /** Present uniquement en mode a blanc : ce qui partirait. */
102
102
  stale?: string[];
103
+ /** Fichiers retires mais retouches depuis : jamais supprimes, seulement dits. */
104
+ kept: string[];
103
105
  }
104
106
 
105
107
  export declare function withinDir(value: string, dir: string): boolean;
@@ -108,6 +110,29 @@ export declare function staleOwnedPaths(
108
110
  roots: OwnedRoot[],
109
111
  exists?: (path: string) => boolean,
110
112
  ): string[];
113
+
114
+ // --- Fichiers retires du paquet -------------------------------------------
115
+ export interface RetiredFile {
116
+ /** Chemin relatif au home, tel que l'ancienne version l'installait. */
117
+ path: string;
118
+ /** Version qui a cesse de le livrer. */
119
+ version: string;
120
+ /** sha256 du contenu publie, fins de ligne normalisees en LF. */
121
+ sha256: string;
122
+ }
123
+
124
+ export interface RetiredInventory {
125
+ files: RetiredFile[];
126
+ }
127
+
128
+ /** Empreinte d'un contenu, `\r\n` ramene a `\n` avant hachage. */
129
+ export declare function normalisedSha256(content: string | Buffer | Uint8Array): string;
130
+ export declare function retiredOwnedPaths(
131
+ retired: RetiredInventory | null | undefined,
132
+ home: string,
133
+ readFile?: (path: string) => Buffer,
134
+ exists?: (path: string) => boolean,
135
+ ): { removed: string[]; kept: string[] };
111
136
  export declare function cleanupPreviousInstall(options: {
112
137
  home: string;
113
138
  configDir: string;
@@ -115,6 +140,7 @@ export declare function cleanupPreviousInstall(options: {
115
140
  clean: boolean;
116
141
  dryRun?: boolean;
117
142
  stepFailure?: string | null;
143
+ retired?: RetiredInventory | null;
118
144
  }): CleanupResult;
119
145
  export declare function detectedHarnesses(dirs: Record<string, string>, whichFn?: (cmd: string) => boolean): string[];
120
146
  /**
package/install/lib.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join, relative, resolve, sep } from "node:path";
4
5
  import { spawnSync } from "node:child_process";
@@ -306,14 +307,58 @@ export function staleOwnedPaths(created, roots, exists = existsSync) {
306
307
  return [...new Set(out)];
307
308
  }
308
309
 
310
+ /**
311
+ * Empreinte d'un fichier, fins de ligne normalisees en LF.
312
+ *
313
+ * Le paquet est livre en LF, l'installation ecrit en CRLF sous Windows : sans
314
+ * normalisation, le meme fichier ne se reconnait plus lui-meme d'une machine a
315
+ * l'autre. C'est cette empreinte, et pas les octets bruts, qui sert de preuve
316
+ * de propriete aux fichiers retires.
317
+ */
318
+ export function normalisedSha256(content) {
319
+ return createHash("sha256").update(content.toString("utf8").replace(/\r\n/g, "\n"), "utf8").digest("hex");
320
+ }
321
+
322
+ /**
323
+ * Fichiers retires du paquet et encore presents dans le home.
324
+ *
325
+ * Le manifeste ne peut pas les connaitre : un fichier copie par une version
326
+ * anterieure a l'invention du champ `created` n'y figure pas, et rien ne le
327
+ * reference plus cote source. La preuve de propriete devient donc le contenu :
328
+ * l'entree ne part que si le fichier sur disque porte exactement l'empreinte
329
+ * publiee dans `retired.json`. Un fichier modifie a la main ne correspond plus
330
+ * et n'est jamais supprime ; il ressort dans `kept` pour etre dit a l'utilisateur.
331
+ */
332
+ export function retiredOwnedPaths(retired, home, readFile = readFileSync, exists = existsSync) {
333
+ const removed = [];
334
+ const kept = [];
335
+ for (const entry of retired?.files ?? []) {
336
+ if (!entry || typeof entry.path !== "string" || typeof entry.sha256 !== "string") continue;
337
+ if (entry.path.length === 0 || entry.path.startsWith("..")) continue;
338
+ const target = join(home, entry.path);
339
+ if (!exists(target)) continue;
340
+ let digest;
341
+ try {
342
+ digest = normalisedSha256(readFile(target));
343
+ } catch {
344
+ continue;
345
+ }
346
+ if (digest === entry.sha256.toLowerCase()) removed.push(target);
347
+ else kept.push(target);
348
+ }
349
+ return { removed: [...new Set(removed)], kept: [...new Set(kept)] };
350
+ }
351
+
309
352
  /**
310
353
  * Decide et applique le nettoyage de l'installation precedente.
311
354
  *
312
- * Renvoie `{ status, detail, removed }`, `status` etant la valeur attendue par
313
- * ui.finishStep ("done" ou "skip"). Seule preuve de propriete : le manifeste
314
- * d'installation precedent, champ `created`, qui liste les chemins ecrits par
315
- * novahiz lui-meme. Un fichier de pack ou de l'utilisateur n'y figure pas, il
316
- * ne peut donc pas partir. Trois cas de sortie :
355
+ * Renvoie `{ status, detail, removed, kept }`, `status` etant la valeur attendue
356
+ * par ui.finishStep ("done" ou "skip"). Deux preuves de propriete, selon l'age
357
+ * du fichier : le manifeste d'installation (`created`, `backups`) pour ce que
358
+ * les versions recentes ont ecrit, et l'empreinte de `retired.json` pour ce que
359
+ * les versions anterieures ont pose sans jamais l'enregistrer. Un fichier de
360
+ * pack, de l'utilisateur, ou modifie a la main ne satisfait ni l'une ni l'autre
361
+ * et reste en place. Cas de sortie :
317
362
  * - `--clean` absent, ou echec d'une etape precedente : skip, rien ne bouge ;
318
363
  * - rien de retire : done ;
319
364
  * - au moins un fichier retire : done, manifeste mis a jour.
@@ -321,12 +366,20 @@ export function staleOwnedPaths(created, roots, exists = existsSync) {
321
366
  * Vit ici et non dans install.mjs : ce module est sans effet de bord a
322
367
  * l'import, l'installateur execute main() au chargement.
323
368
  */
324
- export function cleanupPreviousInstall({ home, configDir, roots, clean, dryRun = false, stepFailure = null }) {
369
+ export function cleanupPreviousInstall({
370
+ home,
371
+ configDir,
372
+ roots,
373
+ clean,
374
+ dryRun = false,
375
+ stepFailure = null,
376
+ retired = null
377
+ }) {
325
378
  if (!clean) {
326
- return { status: "skip", detail: "pass --clean to remove files from a previous install", removed: [] };
379
+ return { status: "skip", detail: "pass --clean to remove files from a previous install", removed: [], kept: [] };
327
380
  }
328
381
  if (stepFailure) {
329
- return { status: "skip", detail: `an earlier step failed (${stepFailure}), cleanup aborted`, removed: [] };
382
+ return { status: "skip", detail: `an earlier step failed (${stepFailure}), cleanup aborted`, removed: [], kept: [] };
330
383
  }
331
384
  const manifest = loadManifest(home);
332
385
  // Deux preuves de propriete, pas une. `created` ne liste que les fichiers
@@ -338,15 +391,18 @@ export function cleanupPreviousInstall({ home, configDir, roots, clean, dryRun =
338
391
  ...(manifest.created ?? []),
339
392
  ...(manifest.backups ?? []).map((entry) => (entry && entry.path) || "").filter((path) => path.length > 0)
340
393
  ];
341
- const stale = staleOwnedPaths(owned, roots);
394
+ const retiredScan = retiredOwnedPaths(retired, home);
395
+ const stale = [...new Set([...staleOwnedPaths(owned, roots), ...retiredScan.removed])];
396
+ const kept = retiredScan.kept.filter((item) => !stale.includes(item));
342
397
  if (stale.length === 0) {
343
- return { status: "done", detail: "no leftover file from a previous install", removed: [] };
398
+ return { status: "done", detail: "no leftover file from a previous install", removed: [], kept };
344
399
  }
345
400
  if (dryRun) {
346
401
  return {
347
402
  status: "skip",
348
403
  detail: `${stale.length} file(s) would be removed by a real run with --clean`,
349
404
  removed: [],
405
+ kept,
350
406
  stale
351
407
  };
352
408
  }
@@ -364,7 +420,7 @@ export function cleanupPreviousInstall({ home, configDir, roots, clean, dryRun =
364
420
  ),
365
421
  cleanedAt: new Date().toISOString()
366
422
  });
367
- return { status: "done", detail: `${removed.length} file(s) from a previous install removed`, removed };
423
+ return { status: "done", detail: `${removed.length} file(s) from a previous install removed`, removed, kept };
368
424
  }
369
425
 
370
426
  export function pruneEmptyDirs(paths, stops = []) {
@@ -0,0 +1,25 @@
1
+ {
2
+ "note": "Files Novahiz used to ship and no longer does. `install --clean` removes one only when the file on disk still carries the published sha256, computed with line endings normalised to LF. A file edited by hand no longer matches and is never deleted; the installer reports it as kept.",
3
+ "files": [
4
+ {
5
+ "path": "install/bootstrap.mjs",
6
+ "version": "0.11.0",
7
+ "sha256": "d4f90e6d091844b06590de1c7d201cc9bac2f2bd9f548f3c93b2bcfc68bd646c"
8
+ },
9
+ {
10
+ "path": "install/install.sh",
11
+ "version": "0.11.0",
12
+ "sha256": "9b2e7073f83c3aa9c0e9b269d8bec9187c22f0faebbcf0e71c2c74fb596dc2de"
13
+ },
14
+ {
15
+ "path": "install/install.ps1",
16
+ "version": "0.11.0",
17
+ "sha256": "6b44ef53bc9f0761c84a27776461c4c79683d832af037cc9fbbe82ec8dc7fb02"
18
+ },
19
+ {
20
+ "path": "bin/veille-mensuelle.ps1",
21
+ "version": "0.11.0",
22
+ "sha256": "4c88aeb86e274b4d5c2ddfd513e1c152189823e66959bbd578669277e4ffaabc"
23
+ }
24
+ ]
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Enforce AI agent skills before every file edit: prompt classifier, skill catalog, and pre-edit gate.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/cli.ts CHANGED
@@ -39,7 +39,10 @@ function runSync(): void {
39
39
  const home = homeDir();
40
40
  const cli = join(home, "src", "cli.ts");
41
41
  if (existsSync(cli)) {
42
- spawnSync(process.execPath, [cli, "sync"], {
42
+ // src/cli.ts est du TypeScript : Node ne le lit pas nativement. On ajoute
43
+ // --experimental-strip-types pour pouvoir l'exécuter depuis le home,
44
+ // qui ne contient pas le build dist/ (réservé au paquet publié).
45
+ spawnSync(process.execPath, ["--experimental-strip-types", cli, "sync"], {
43
46
  encoding: "utf8",
44
47
  stdio: "inherit",
45
48
  env: { ...process.env, NOVAHIZ_HOME: home }