novahiz 0.9.7 → 0.9.9

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,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.9] - 2026-10-16
9
+
10
+ ### Changed
11
+
12
+ - **`novahiz upgrade --apply` now finishes the job it started** - it stopped at
13
+ `npm install -g novahiz@latest`, which refreshes the global package and
14
+ nothing else: `~/.config/novahiz`, the plugin copy and the MCP entrypoints
15
+ stayed on the previous version. It now resyncs the whole installation from
16
+ the fresh package (`install.mjs --yes --clean`) on both channels, so
17
+ `novahiz update` behaves exactly the same. If the update or the resync
18
+ fails, the command stops and reports the error instead of announcing a
19
+ completed upgrade.
20
+
21
+ ### Added
22
+
23
+ - **A cleanup of the previous installation, opt-in** - an update used to leave
24
+ behind whatever it no longer shipped: a renamed skill stayed loadable, a
25
+ removed plugin stayed registered. `install.mjs --clean` now removes the files
26
+ a previous install wrote whose source has disappeared. The only proof of
27
+ ownership is the install manifest (`.novahiz-install.json`, field `created`):
28
+ a skill or a command placed by a pack is not in it, so it cannot be removed.
29
+ The rule "absent from the source" was the tempting one and it is wrong here —
30
+ it would have deleted `impeccable`, which the pack keeps in
31
+ `~/.agents/skills`. Three guards hold: only files under the four installed
32
+ roots, never after a failed step, never without `--clean`. A `.novahiz-bak`
33
+ goes with the file it backed up and never alone. `novahiz.config.json`,
34
+ `project-memory/` and `novahiz.sqlite` sit outside those roots, untouched by
35
+ construction.
36
+
37
+ ### Fixed
38
+
39
+ - **`pruneEmptyDirs` never removed anything** - it called
40
+ `rmSync(dir, { recursive: false })`, which throws `ERR_FS_EISDIR` on a
41
+ directory, and the surrounding `catch` swallowed the error. The function
42
+ looked like it worked and silently left every emptied directory behind, for
43
+ `novahiz uninstall` as well. It now uses `recursive: true`, which is safe
44
+ because the directory is already known to be empty.
45
+
46
+ ## [0.9.8] - 2026-10-15
47
+
48
+ ### Removed
49
+
50
+ - **The 7 `eas-*` skills from the catalogue** - `test` and `devops` listed them
51
+ as `defaultSkills` (`eas-app-stores`, `eas-hosting`, `eas-observe`,
52
+ `eas-simulator`, `eas-update`, `eas-update-insights`, `eas-workflows`) while
53
+ the expo pack deliberately excludes them: `providers.json` says so in the
54
+ provider's own purpose. No provider could install a single one, so `doctor`
55
+ named them and `novahiz deps --install` returned `install: []`. 0.9.5 made
56
+ that row tell the truth instead of blaming the reader; the references
57
+ themselves stayed, and the row reports without blocking, which is why nothing
58
+ had failed since. `referencedSkills` now spans 51 ids and none of them is
59
+ undeliverable, so `doctor` reports `51 present` with no caveat. The seven
60
+ inert `overrides.json` entries went with them; the `devops` keywords `eas`,
61
+ `eas build` and `eas update` stay, since they route a deployment question to
62
+ a roadmap that still applies.
63
+
64
+ ### Added
65
+
66
+ - **A test holding the catalogue to that invariant** -
67
+ `undeliverableReferences(spec, packageRootDir)` is exported from
68
+ `doctor.ts`, and `tests/doctor-referenced-skills.test.ts` runs it against the
69
+ shipped catalogue: it must return `[]`, no `defaultSkills` may carry an
70
+ `eas-*`, and a reintroduced one must be named. Checked by injection: putting
71
+ `eas-simulator` back into `test.defaultSkills` turns both assertions red.
72
+
8
73
  ## [0.9.7] - 2026-10-15
9
74
 
10
75
  ### Added
@@ -8,6 +8,6 @@ description: Check for a new Novahiz version and update it via npm. Arguments: $
8
8
 
9
9
  3. If an update is available, report the installed -> latest versions, then ask the user for explicit approval. Never run the update without that approval (skip this confirmation only when `$ARGUMENTS` contains `--yes`).
10
10
 
11
- 4. After approval, run `novahiz upgrade --apply` (same CLI used in step 1). This runs `npm install -g novahiz@latest` for npm installs, or `git pull --ff-only` plus a catalog rebuild for source checkouts. Report the exact result, including any npm/git error verbatim — never claim success you did not observe.
11
+ 4. After approval, run `novahiz upgrade --apply` (same CLI used in step 1). It installs `novahiz@latest` globally for npm installs, or pulls with `--ff-only` for source checkouts, then resyncs the Novahiz home and the opencode config from the fresh package and cleans what the previous version left behind. The cleanup only removes files the previous install manifest proves Novahiz wrote and whose source no longer exists: pack skills, `novahiz.config.json`, `project-memory/` and the vault are never touched. If the update or the resync fails, the command stops before the cleanup. Report the exact result, including any npm/git error verbatim — never claim success you did not observe.
12
12
 
13
13
  5. Finish by telling the user to restart OpenCode completely so the new version loads. Offer `novahiz doctor` as the follow-up check.
@@ -554,8 +554,7 @@
554
554
  "defaultSkills": [
555
555
  "flutter-add-widget-test",
556
556
  "dart-add-unit-test",
557
- "expo-skill-eval",
558
- "eas-simulator"
557
+ "expo-skill-eval"
559
558
  ],
560
559
  "roadmap": {
561
560
  "id": "testing",
@@ -1329,14 +1328,7 @@
1329
1328
  "tag a version",
1330
1329
  "changelog"
1331
1330
  ],
1332
- "defaultSkills": [
1333
- "eas-workflows",
1334
- "eas-app-stores",
1335
- "eas-hosting",
1336
- "eas-observe",
1337
- "eas-update",
1338
- "eas-update-insights"
1339
- ],
1331
+ "defaultSkills": [],
1340
1332
  "roadmap": {
1341
1333
  "id": "devops",
1342
1334
  "steps": [
@@ -634,83 +634,6 @@
634
634
  "docs-writing"
635
635
  ]
636
636
  },
637
- "eas-app-stores": {
638
- "power": 4,
639
- "stars": null,
640
- "tags": [
641
- "eas",
642
- "expo"
643
- ],
644
- "categories": [
645
- "expo"
646
- ]
647
- },
648
- "eas-hosting": {
649
- "power": 4,
650
- "stars": null,
651
- "tags": [
652
- "eas",
653
- "expo"
654
- ],
655
- "categories": [
656
- "expo"
657
- ]
658
- },
659
- "eas-observe": {
660
- "power": 4,
661
- "stars": null,
662
- "tags": [
663
- "eas",
664
- "expo"
665
- ],
666
- "categories": [
667
- "expo"
668
- ]
669
- },
670
- "eas-simulator": {
671
- "power": 4,
672
- "stars": null,
673
- "tags": [
674
- "eas",
675
- "expo"
676
- ],
677
- "categories": [
678
- "expo"
679
- ]
680
- },
681
- "eas-update": {
682
- "power": 4,
683
- "stars": null,
684
- "tags": [
685
- "eas",
686
- "expo"
687
- ],
688
- "categories": [
689
- "expo"
690
- ]
691
- },
692
- "eas-update-insights": {
693
- "power": 4,
694
- "stars": null,
695
- "tags": [
696
- "eas",
697
- "expo"
698
- ],
699
- "categories": [
700
- "expo"
701
- ]
702
- },
703
- "eas-workflows": {
704
- "power": 4,
705
- "stars": null,
706
- "tags": [
707
- "eas",
708
- "expo"
709
- ],
710
- "categories": [
711
- "expo"
712
- ]
713
- },
714
637
  "expo-animation": {
715
638
  "power": 4,
716
639
  "stars": null,
@@ -443,22 +443,15 @@ function referencedSkills(spec) {
443
443
  * `indexSkills` is null when the index is unreadable — the index check already
444
444
  * reports that, so the row stays quiet instead of double-failing.
445
445
  * Only skills the package ships (packageRoot/skills) can make a fresh install
446
- * broken. The remaining references — the dart/eas/expo/flutter packs and
447
- * impeccable — are optional delivery downloaded on request and are simply not
446
+ * broken. The remaining references, the dart/expo/flutter packs and
447
+ * impeccable, are optional delivery downloaded on request and are simply not
448
448
  * present on a machine that never asked for them; the gate already degrades
449
449
  * them to "not enforced". Blocking on them turned every healthy fresh install
450
450
  * red (`missing from index: dart-...` right after novahiz-install --yes). */
451
451
  export function referencedSkillsCheck(spec, indexSkills, packageRootDir) {
452
452
  const referenced = referencedSkills(spec);
453
453
  const absent = indexSkills ? referenced.filter((id) => !indexSkills.has(id)) : [];
454
- const shipped = new Set();
455
- const shippedDir = join(packageRootDir, "skills");
456
- if (existsSync(shippedDir)) {
457
- for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
458
- if (entry.isDirectory())
459
- shipped.add(entry.name);
460
- }
461
- }
454
+ const shipped = shippedSkills(packageRootDir);
462
455
  const absentShipped = absent.filter((id) => shipped.has(id));
463
456
  const absentPack = absent.filter((id) => !shipped.has(id));
464
457
  // Only promise the update command when a pack can actually deliver what is
@@ -493,11 +486,14 @@ export function referencedSkillsCheck(spec, indexSkills, packageRootDir) {
493
486
  * argv: an explicit `--skill a b c` names them, and `--skill *` delivers the
494
487
  * whole repo, whose ids start with the repo's leading segment with any hyphen
495
488
  * suffix dropped (`flutter/skills` -> `flutter-`, `dart-lang/skills` ->
496
- * `dart-`). Measured 2026-10-15: the 7 skills doctor reported as "pack skills
497
- * not installed" are `eas-*`, referenced as `defaultSkills` by the `test` and
498
- * `devops` categories while no provider ships them, so
499
- * "run /novahiz-skills-update, or novahiz deps --install" could not have
500
- * installed a single one. Advice no command can honour is a defect. */
489
+ * `dart-`). Measured 2026-10-15 on the 0.9.5 install: the 7 skills doctor
490
+ * reported as "pack skills not installed" were `eas-*`, referenced as
491
+ * `defaultSkills` by `test` and `devops` while the expo pack deliberately
492
+ * excludes them, so "run /novahiz-skills-update, or novahiz deps --install"
493
+ * could not have installed a single one. Advice no command can honour is a
494
+ * defect. Those references are gone from the catalog; the guard stays, since
495
+ * the same situation reappears whenever a pack is disabled or its `--skill`
496
+ * list stops naming an id the catalog still asks for. */
501
497
  function packDelivery(spec) {
502
498
  const exact = new Set();
503
499
  const prefixes = [];
@@ -526,6 +522,27 @@ function packDelivery(spec) {
526
522
  }
527
523
  return { exact, prefixes };
528
524
  }
525
+ /** Skill ids the package itself carries in `packageRootDir/skills`. */
526
+ function shippedSkills(packageRootDir) {
527
+ const shipped = new Set();
528
+ const shippedDir = join(packageRootDir, "skills");
529
+ if (existsSync(shippedDir)) {
530
+ for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
531
+ if (entry.isDirectory())
532
+ shipped.add(entry.name);
533
+ }
534
+ }
535
+ return shipped;
536
+ }
537
+ /** Referenced skill ids nothing in the system can supply: the package ships
538
+ * none of them and no enabled pack can install them. Exported so the catalog
539
+ * is held to this invariant in tests, since the row above reports it without
540
+ * blocking and therefore never failed on it. */
541
+ export function undeliverableReferences(spec, packageRootDir) {
542
+ const shipped = shippedSkills(packageRootDir);
543
+ const { exact, prefixes } = packDelivery(spec);
544
+ return referencedSkills(spec).filter((id) => !shipped.has(id) && !exact.has(id) && !prefixes.some((prefix) => id.startsWith(prefix)));
545
+ }
529
546
  /** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
530
547
  * (root -> .agents/context -> docs/). Rows appear only when the impeccable
531
548
  * skill is installed (opt-in) and are never blocking: a missing record is a
@@ -82,7 +82,7 @@ export function runNpm(args, stdio) {
82
82
  stderr: result.stderr ?? ""
83
83
  };
84
84
  }
85
- function npmFlow(parsed, root) {
85
+ function npmFlow(parsed, root, home) {
86
86
  const local = readLocalVersion(root);
87
87
  const view = runNpm(["view", "novahiz", "version"], "pipe");
88
88
  if (view.status !== 0) {
@@ -107,9 +107,48 @@ function npmFlow(parsed, root) {
107
107
  process.exitCode = 1;
108
108
  return;
109
109
  }
110
- process.stdout.write(`Updated to ${latest}.\n` +
110
+ // `npm install -g` ne met a jour QUE le package global : ~/.config/novahiz
111
+ // reste sur l'ancienne version, plugin et MCP comprises. Sans ce passage le
112
+ // user croit sa mise a jour faite alors que la moitie du systeme bouge pas.
113
+ if (!resyncAndClean(home))
114
+ return;
115
+ process.stdout.write(`Updated to ${latest}. The installation was resynced from the new package.\n` +
111
116
  "Restart OpenCode completely to load the new version, then run `novahiz doctor` if anything looks off.\n");
112
117
  }
118
+ /**
119
+ * Resynchronise le home novahiz depuis le package fraichement mis a jour, puis
120
+ * efface ce que l'ancienne version laissait derriere elle.
121
+ *
122
+ * Deux installations coexistent : le package (node_modules global ou checkout
123
+ * git) et le home (~/.config/novahiz, plus ses copies dans le dossier de
124
+ * config opencode). Ce passage est ce qui les remet d'accord. `--clean` ne
125
+ * retire que des fichiers dont le manifeste d'installation prouve que novahiz
126
+ * les a ecrits et dont la source a disparu.
127
+ */
128
+ function resyncAndClean(home) {
129
+ const installer = join(PKG_ROOT, "install", "install.mjs");
130
+ if (!existsSync(installer)) {
131
+ process.stderr.write(`novahiz upgrade: installer not found at ${installer}.\n` +
132
+ "Run `node install/install.mjs --yes --clean` from the package root to finish the update.\n");
133
+ process.exitCode = 1;
134
+ return false;
135
+ }
136
+ process.stdout.write("\nResyncing the installation and removing files no longer shipped...\n");
137
+ const env = { ...process.env, NOVAHIZ_HOME: home };
138
+ // S'execute-t-on depuis un script npm ? Alors npm_lifecycle_event est encore
139
+ // present dans l'environnement, et l'installateur bascule en phase differee
140
+ // (il attend la mort de npm avant de bouger). Ici npm n'a rien en cours :
141
+ // le defer laisserait la commande renvoyer avant la fin du resync.
142
+ delete env.npm_lifecycle_event;
143
+ const result = spawnSync(process.execPath, [installer, "--yes", "--clean"], { stdio: "inherit", env });
144
+ if (result.status !== 0) {
145
+ process.stderr.write("novahiz upgrade: resync failed, the previous installation was left as it is.\n" +
146
+ "Run `node install/install.mjs --yes --clean` from the package root to retry.\n");
147
+ process.exitCode = 1;
148
+ return false;
149
+ }
150
+ return true;
151
+ }
113
152
  /** Flux git historique (install source) : dry-run par defaut, --apply tire. */
114
153
  function gitFlow(parsed, home) {
115
154
  if (!existsSync(join(home, ".git"))) {
@@ -144,6 +183,11 @@ function gitFlow(parsed, home) {
144
183
  if (existsSync(syncCli)) {
145
184
  spawnSync(process.execPath, [syncCli, "sync"], { stdio: "inherit", env: { ...process.env, NOVAHIZ_HOME: home } });
146
185
  }
186
+ // Meme resynchronisation que le canal npm : les copies dans le dossier de
187
+ // config opencode viennent du home, pas du depot, et le nettoyage retire ce
188
+ // que l'ancienne version laissait.
189
+ if (!resyncAndClean(home))
190
+ return;
147
191
  process.stdout.write("\nUpgraded! Restart OpenCode to apply changes.\n");
148
192
  }
149
193
  export function commandUpgrade(parsed) {
@@ -159,7 +203,7 @@ export function commandUpgrade(parsed) {
159
203
  // `npm i -g` n'a aucun .git dans ~/.config/novahiz.
160
204
  const target = detectTarget(PKG_ROOT, existsSync(join(home, ".git")));
161
205
  if (target === "npm") {
162
- npmFlow(parsed, PKG_ROOT);
206
+ npmFlow(parsed, PKG_ROOT, home);
163
207
  return;
164
208
  }
165
209
  if (target === "git") {
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { cpSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { join, relative, resolve } from "node:path";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import {
8
+ cleanupPreviousInstall,
8
9
  copyFileWithBackup,
9
10
  copyInto,
10
11
  defaultConfig,
@@ -45,7 +46,8 @@ const STEPS = [
45
46
  "MCP servers",
46
47
  "opencode config",
47
48
  "Obsidian vault (second-memory)",
48
- "Finalize"
49
+ "Finalize",
50
+ "Cleanup previous install"
49
51
  ];
50
52
 
51
53
  const CORE_ITEMS = [
@@ -75,6 +77,11 @@ async function main() {
75
77
  // jamais dans le dossier de config opencode que le serveur surveille.
76
78
  const deferOpencode = deferOpencodePhase(flags);
77
79
  const force = Boolean(flags.force);
80
+ // --clean : efface, a la fin du passage, les fichiers poses par une
81
+ // installation precedente qui ne sont plus livres. Par defaut inactif : une
82
+ // premiere installation ne doit pas declencher de suppression. C'est
83
+ // `novahiz upgrade --apply` qui l'active.
84
+ const clean = Boolean(flags.clean);
78
85
  const withSkills = !flags["no-skills"];
79
86
  const root = repoRoot(import.meta.url);
80
87
  const home = NovahizHome(flags);
@@ -239,6 +246,10 @@ async function main() {
239
246
  const backups = [];
240
247
  let coreCopied = false;
241
248
  let configCreated = false;
249
+ // Cause d'arret du nettoyage final. Une exception empeche deja d'atteindre la
250
+ // fin du script ; ce drapeau couvre le cas qu'une exception ne signale pas :
251
+ // un sous-processus qui sort sur un code non nul (catalogue, dependances).
252
+ let stepFailure = null;
242
253
 
243
254
  const sameRoot = resolve(root) === resolve(home);
244
255
  ui.step("Core files");
@@ -420,6 +431,7 @@ async function main() {
420
431
  });
421
432
  if (result.stdout) ui.raw(result.stdout);
422
433
  if (result.status !== 0 && result.stderr) ui.raw(result.stderr);
434
+ if (result.status !== 0) stepFailure = stepFailure ?? "catalog sync";
423
435
  }
424
436
  }
425
437
 
@@ -810,7 +822,46 @@ async function main() {
810
822
  note("Dependencies up to date.");
811
823
  }
812
824
  }
825
+
826
+ }
827
+
828
+ // --- Nettoyage de l'installation precedente ------------------------------
829
+ // Une mise a jour ne peut pas laisser derriere elle des skills renommes, des
830
+ // plugins retires ou des commandes devenues obsoles : ils restent charges au
831
+ // prochain demarrage. L'effacement ne s'appuie que sur une preuve de
832
+ // propriete, le manifeste d'installation precedent ; un fichier pose par un
833
+ // pack ou par l'utilisateur n'y figure pas et n'est jamais retenu.
834
+ ui.step("Cleanup previous install");
835
+ const ownedRoots = [
836
+ { dest: skillsDir, src: join(home, "skills") },
837
+ { dest: pluginsDir, src: join(home, "adapters", "opencode") },
838
+ { dest: join(configDir, "agent"), src: join(home, "adapters", "opencode", "agent") },
839
+ { dest: join(configDir, "commands"), src: join(home, "adapters", "opencode", "commands") }
840
+ ];
841
+ const cleanup = cleanupPreviousInstall({ home, configDir, roots: ownedRoots, clean, dryRun, stepFailure });
842
+ for (const path of cleanup.removed) note(` removed: ${relative(home, path)}`);
843
+ for (const path of cleanup.stale ?? []) note(` would remove: ${relative(home, path)}`);
844
+ if (cleanup.removed.length > 0) {
845
+ // L'index tient encore la liste des skills supprimes : on le rejoue une
846
+ // fois, sinon le prochain routage classifiera vers un skill parti.
847
+ const cleanupCli = join(home, "src", "cli.ts");
848
+ if (existsSync(cleanupCli)) {
849
+ note("Rebuilding catalog after cleanup (sync)");
850
+ const rebuild = spawnSync(process.execPath, [cleanupCli, "sync"], {
851
+ encoding: "utf8",
852
+ env: { ...process.env, NOVAHIZ_HOME: home }
853
+ });
854
+ if (rebuild.stdout) ui.raw(rebuild.stdout);
855
+ if (rebuild.status !== 0 && rebuild.stderr) ui.raw(rebuild.stderr);
856
+ }
857
+ }
858
+ ui.finishStep(cleanup.status, cleanup.detail);
859
+
860
+ if (!dryRun) {
813
861
  const summary = [`Novahiz installed in ${home}.`];
862
+ if (cleanup.removed.length > 0) {
863
+ summary.push(`${cleanup.removed.length} stale file(s) from a previous install removed.`);
864
+ }
814
865
  if (deferLogPath) summary.push(`opencode config phase: deferred - see ${deferLogPath} (runs once npm exits).`);
815
866
  if (configured.length > 0) summary.push("Restart opencode to activate the plugin and MCP server.");
816
867
  summary.push("Gate can be disabled with the NOVAHIZ_GATE=off environment variable.");
package/install/lib.d.mts CHANGED
@@ -30,6 +30,8 @@ export interface NovahizManifest {
30
30
  configCreated?: boolean;
31
31
  created?: string[];
32
32
  backups?: BackupEntry[];
33
+ /** Horodatage du dernier nettoyage (install.mjs --clean). */
34
+ cleanedAt?: string;
33
35
  }
34
36
 
35
37
  export interface NovahizConfig {
@@ -74,6 +76,40 @@ export declare function saveManifest(home: string, manifest: NovahizManifest): v
74
76
  export declare function mergeCreated(previous?: string[], next?: string[]): string[];
75
77
  export declare function mergeBackups(previous?: BackupEntry[], next?: BackupEntry[]): BackupEntry[];
76
78
  export declare function pruneEmptyDirs(paths: string[], stops?: string[]): void;
79
+
80
+ // --- Nettoyage de l'installation precedente -------------------------------
81
+ export interface OwnedRoot {
82
+ /** Racine de destination : la ou novahiz pose ses fichiers. */
83
+ dest: string;
84
+ /** Racine source equivalente, dans le package. */
85
+ src: string;
86
+ }
87
+
88
+ export interface CleanupResult {
89
+ /** Valeur attendue par ui.finishStep : "done" ou "skip". */
90
+ status: "done" | "skip";
91
+ /** Phrase expliquant l'issue, affichee telle quelle par l'installateur. */
92
+ detail: string;
93
+ /** Chemins reels supprimes ; toujours vide en mode a blanc. */
94
+ removed: string[];
95
+ /** Present uniquement en mode a blanc : ce qui partirait. */
96
+ stale?: string[];
97
+ }
98
+
99
+ export declare function withinDir(value: string, dir: string): boolean;
100
+ export declare function staleOwnedPaths(
101
+ created: string[] | undefined,
102
+ roots: OwnedRoot[],
103
+ exists?: (path: string) => boolean,
104
+ ): string[];
105
+ export declare function cleanupPreviousInstall(options: {
106
+ home: string;
107
+ configDir: string;
108
+ roots: OwnedRoot[];
109
+ clean: boolean;
110
+ dryRun?: boolean;
111
+ stepFailure?: string | null;
112
+ }): CleanupResult;
77
113
  export declare function detectedHarnesses(dirs: Record<string, string>, whichFn?: (cmd: string) => boolean): string[];
78
114
  export declare function which(cmd: string): boolean;
79
115
  export declare function nodeVersionOk(minimum?: [number, number, number]): boolean;
package/install/lib.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve, sep } from "node:path";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
@@ -267,6 +267,106 @@ export function mergeBackups(previous = [], next = []) {
267
267
  return [...map.values()].sort((a, b) => (a.path < b.path ? -1 : 1));
268
268
  }
269
269
 
270
+ /** `dir` englobe `value` (egalite comprise), insensible a la casse sous Windows. */
271
+ export function withinDir(value, dir) {
272
+ const normalize = (item) => (process.platform === "win32" ? item.toLowerCase() : item);
273
+ const base = normalize(resolve(dir));
274
+ const target = normalize(resolve(value));
275
+ return target === base || target.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
276
+ }
277
+
278
+ /**
279
+ * Fichiers que novahiz a poses et qui n'ont plus de source.
280
+ *
281
+ * Le manifeste d'installation (.novahiz-install.json, champ `created`) est la
282
+ * seule preuve de propriete : un fichier ecrit par un pack ou par l'utilisateur
283
+ * n'y figure pas et n'est donc jamais retenu. Trois gardes, dans l'ordre :
284
+ * 1. le chemin est sous une racine declaree source -> destination,
285
+ * 2. sa contrepartie dans la source n'existe plus,
286
+ * 3. ce n'est ni la config de l'utilisateur ni une sauvegarde seule.
287
+ * Un `.novahiz-bak` part avec le fichier dont il est la copie : une sauvegarde
288
+ * dont le fichier d'origine existe toujours reste en place.
289
+ */
290
+ export function staleOwnedPaths(created, roots, exists = existsSync) {
291
+ const out = [];
292
+ for (const item of created ?? []) {
293
+ if (typeof item !== "string" || item.length === 0) continue;
294
+ if (item.endsWith("novahiz.config.json")) continue;
295
+ const root = roots.find((entry) => withinDir(item, entry.dest));
296
+ if (!root) continue;
297
+ const rel = relative(root.dest, item);
298
+ if (!rel || rel.startsWith("..")) continue;
299
+ if (exists(join(root.src, rel))) continue;
300
+ // Un manifeste peut nommer un fichier deja parti (supprime a la main).
301
+ // Il ne compte pas : `removed` doit ne lister que ce qui a vraiment bouge.
302
+ const backup = `${item}.novahiz-bak`;
303
+ if (exists(item)) out.push(item);
304
+ if (exists(backup)) out.push(backup);
305
+ }
306
+ return [...new Set(out)];
307
+ }
308
+
309
+ /**
310
+ * Decide et applique le nettoyage de l'installation precedente.
311
+ *
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 :
317
+ * - `--clean` absent, ou echec d'une etape precedente : skip, rien ne bouge ;
318
+ * - rien de retire : done ;
319
+ * - au moins un fichier retire : done, manifeste mis a jour.
320
+ *
321
+ * Vit ici et non dans install.mjs : ce module est sans effet de bord a
322
+ * l'import, l'installateur execute main() au chargement.
323
+ */
324
+ export function cleanupPreviousInstall({ home, configDir, roots, clean, dryRun = false, stepFailure = null }) {
325
+ if (!clean) {
326
+ return { status: "skip", detail: "pass --clean to remove files from a previous install", removed: [] };
327
+ }
328
+ if (stepFailure) {
329
+ return { status: "skip", detail: `an earlier step failed (${stepFailure}), cleanup aborted`, removed: [] };
330
+ }
331
+ const manifest = loadManifest(home);
332
+ // Deux preuves de propriete, pas une. `created` ne liste que les fichiers
333
+ // poses pour la premiere fois : sur une installation qui existait deja,
334
+ // chaque copie renvoyait `created: null` et rien n'etait enregistre, ce qui
335
+ // rendait le nettoyage inerte. `backups[].path` liste les fichiers ecrases,
336
+ // donc ecrits par novahiz — c'est la preuve qui manquait.
337
+ const owned = [
338
+ ...(manifest.created ?? []),
339
+ ...(manifest.backups ?? []).map((entry) => (entry && entry.path) || "").filter((path) => path.length > 0)
340
+ ];
341
+ const stale = staleOwnedPaths(owned, roots);
342
+ if (stale.length === 0) {
343
+ return { status: "done", detail: "no leftover file from a previous install", removed: [] };
344
+ }
345
+ if (dryRun) {
346
+ return {
347
+ status: "skip",
348
+ detail: `${stale.length} file(s) would be removed by a real run with --clean`,
349
+ removed: [],
350
+ stale
351
+ };
352
+ }
353
+ const removed = [];
354
+ for (const item of stale) {
355
+ rmSync(item, { force: true });
356
+ removed.push(item);
357
+ }
358
+ pruneEmptyDirs(removed, [configDir, ...roots.map((entry) => entry.dest)]);
359
+ saveManifest(home, {
360
+ ...manifest,
361
+ created: (manifest.created ?? []).filter((item) => !removed.includes(item)),
362
+ backups: (manifest.backups ?? []).filter(
363
+ (entry) => !removed.includes(entry.path) && !removed.includes(entry.backup)
364
+ ),
365
+ cleanedAt: new Date().toISOString()
366
+ });
367
+ return { status: "done", detail: `${removed.length} file(s) from a previous install removed`, removed };
368
+ }
369
+
270
370
  export function pruneEmptyDirs(paths, stops = []) {
271
371
  const stopSet = new Set(stops.map((value) => resolve(value)));
272
372
  const candidates = new Set();
@@ -282,7 +382,11 @@ export function pruneEmptyDirs(paths, stops = []) {
282
382
  while (current && existsSync(current) && !stopSet.has(resolve(current))) {
283
383
  try {
284
384
  if (readdirSync(current).length !== 0) break;
285
- rmSync(current, { recursive: false });
385
+ // recursive:false leve ERR_FS_EISDIR sur un repertoire : rm ne
386
+ // supprime qu'un fichier avec cet option, et le catch ci-dessous
387
+ // avalait l'erreur en faisant passer la fonction pour silencieuse.
388
+ // Le repertoire est deja vide ici, recursion sans effet de bord.
389
+ rmSync(current, { recursive: true });
286
390
  removed = true;
287
391
  const parent = dirname(current);
288
392
  if (parent === current) break;
@@ -2,7 +2,11 @@
2
2
  import { cpSync, existsSync, rmSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve, sep } from "node:path";
5
- import { loadManifest, NovahizHome, parseArgs, pruneEmptyDirs, saveManifest } from "./lib.mjs";
5
+ import { loadManifest, NovahizHome, parseArgs, pruneEmptyDirs, saveManifest, withinDir } from "./lib.mjs";
6
+
7
+ // Partage avec lib.mjs (staleOwnedPaths) ; re-exporte ici pour que l'API
8
+ // publique de ce module ne bouge pas.
9
+ export { withinDir };
6
10
 
7
11
  export function underHome(value) {
8
12
  const root = resolve(homedir());
@@ -13,13 +17,6 @@ export function underHome(value) {
13
17
  return candidate === normalize(root) || candidate.startsWith(prefix);
14
18
  }
15
19
 
16
- export function withinDir(value, dir) {
17
- const normalize = (item) => (process.platform === "win32" ? item.toLowerCase() : item);
18
- const base = normalize(resolve(dir));
19
- const target = normalize(resolve(value));
20
- return target === base || target.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
21
- }
22
-
23
20
  function main() {
24
21
  const flags = parseArgs(process.argv.slice(2));
25
22
  const dryRun = Boolean(flags["dry-run"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
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",
@@ -439,8 +439,8 @@ function referencedSkills(spec: ReturnType<typeof loadSpec>): string[] {
439
439
  * `indexSkills` is null when the index is unreadable — the index check already
440
440
  * reports that, so the row stays quiet instead of double-failing.
441
441
  * Only skills the package ships (packageRoot/skills) can make a fresh install
442
- * broken. The remaining references — the dart/eas/expo/flutter packs and
443
- * impeccable — are optional delivery downloaded on request and are simply not
442
+ * broken. The remaining references, the dart/expo/flutter packs and
443
+ * impeccable, are optional delivery downloaded on request and are simply not
444
444
  * present on a machine that never asked for them; the gate already degrades
445
445
  * them to "not enforced". Blocking on them turned every healthy fresh install
446
446
  * red (`missing from index: dart-...` right after novahiz-install --yes). */
@@ -451,13 +451,7 @@ export function referencedSkillsCheck(
451
451
  ): DoctorCheck {
452
452
  const referenced = referencedSkills(spec);
453
453
  const absent = indexSkills ? referenced.filter((id) => !indexSkills.has(id)) : [];
454
- const shipped = new Set<string>();
455
- const shippedDir = join(packageRootDir, "skills");
456
- if (existsSync(shippedDir)) {
457
- for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
458
- if (entry.isDirectory()) shipped.add(entry.name);
459
- }
460
- }
454
+ const shipped = shippedSkills(packageRootDir);
461
455
  const absentShipped = absent.filter((id) => shipped.has(id));
462
456
  const absentPack = absent.filter((id) => !shipped.has(id));
463
457
  // Only promise the update command when a pack can actually deliver what is
@@ -491,11 +485,14 @@ export function referencedSkillsCheck(
491
485
  * argv: an explicit `--skill a b c` names them, and `--skill *` delivers the
492
486
  * whole repo, whose ids start with the repo's leading segment with any hyphen
493
487
  * suffix dropped (`flutter/skills` -> `flutter-`, `dart-lang/skills` ->
494
- * `dart-`). Measured 2026-10-15: the 7 skills doctor reported as "pack skills
495
- * not installed" are `eas-*`, referenced as `defaultSkills` by the `test` and
496
- * `devops` categories while no provider ships them, so
497
- * "run /novahiz-skills-update, or novahiz deps --install" could not have
498
- * installed a single one. Advice no command can honour is a defect. */
488
+ * `dart-`). Measured 2026-10-15 on the 0.9.5 install: the 7 skills doctor
489
+ * reported as "pack skills not installed" were `eas-*`, referenced as
490
+ * `defaultSkills` by `test` and `devops` while the expo pack deliberately
491
+ * excludes them, so "run /novahiz-skills-update, or novahiz deps --install"
492
+ * could not have installed a single one. Advice no command can honour is a
493
+ * defect. Those references are gone from the catalog; the guard stays, since
494
+ * the same situation reappears whenever a pack is disabled or its `--skill`
495
+ * list stops naming an id the catalog still asks for. */
499
496
  function packDelivery(spec: ReturnType<typeof loadSpec>): { exact: Set<string>; prefixes: string[] } {
500
497
  const exact = new Set<string>();
501
498
  const prefixes: string[] = [];
@@ -518,6 +515,33 @@ function packDelivery(spec: ReturnType<typeof loadSpec>): { exact: Set<string>;
518
515
  return { exact, prefixes };
519
516
  }
520
517
 
518
+ /** Skill ids the package itself carries in `packageRootDir/skills`. */
519
+ function shippedSkills(packageRootDir: string): Set<string> {
520
+ const shipped = new Set<string>();
521
+ const shippedDir = join(packageRootDir, "skills");
522
+ if (existsSync(shippedDir)) {
523
+ for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
524
+ if (entry.isDirectory()) shipped.add(entry.name);
525
+ }
526
+ }
527
+ return shipped;
528
+ }
529
+
530
+ /** Referenced skill ids nothing in the system can supply: the package ships
531
+ * none of them and no enabled pack can install them. Exported so the catalog
532
+ * is held to this invariant in tests, since the row above reports it without
533
+ * blocking and therefore never failed on it. */
534
+ export function undeliverableReferences(
535
+ spec: ReturnType<typeof loadSpec>,
536
+ packageRootDir: string
537
+ ): string[] {
538
+ const shipped = shippedSkills(packageRootDir);
539
+ const { exact, prefixes } = packDelivery(spec);
540
+ return referencedSkills(spec).filter(
541
+ (id) => !shipped.has(id) && !exact.has(id) && !prefixes.some((prefix) => id.startsWith(prefix))
542
+ );
543
+ }
544
+
521
545
  /** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
522
546
  * (root -> .agents/context -> docs/). Rows appear only when the impeccable
523
547
  * skill is installed (opt-in) and are never blocking: a missing record is a
@@ -90,7 +90,7 @@ export function runNpm(args: string[], stdio: "inherit" | "pipe"): { status: num
90
90
  };
91
91
  }
92
92
 
93
- function npmFlow(parsed: Parsed, root: string): void {
93
+ function npmFlow(parsed: Parsed, root: string, home: string): void {
94
94
  const local = readLocalVersion(root);
95
95
  const view = runNpm(["view", "novahiz", "version"], "pipe");
96
96
  if (view.status !== 0) {
@@ -115,12 +115,55 @@ function npmFlow(parsed: Parsed, root: string): void {
115
115
  process.exitCode = 1;
116
116
  return;
117
117
  }
118
+ // `npm install -g` ne met a jour QUE le package global : ~/.config/novahiz
119
+ // reste sur l'ancienne version, plugin et MCP comprises. Sans ce passage le
120
+ // user croit sa mise a jour faite alors que la moitie du systeme bouge pas.
121
+ if (!resyncAndClean(home)) return;
118
122
  process.stdout.write(
119
- `Updated to ${latest}.\n` +
123
+ `Updated to ${latest}. The installation was resynced from the new package.\n` +
120
124
  "Restart OpenCode completely to load the new version, then run `novahiz doctor` if anything looks off.\n"
121
125
  );
122
126
  }
123
127
 
128
+ /**
129
+ * Resynchronise le home novahiz depuis le package fraichement mis a jour, puis
130
+ * efface ce que l'ancienne version laissait derriere elle.
131
+ *
132
+ * Deux installations coexistent : le package (node_modules global ou checkout
133
+ * git) et le home (~/.config/novahiz, plus ses copies dans le dossier de
134
+ * config opencode). Ce passage est ce qui les remet d'accord. `--clean` ne
135
+ * retire que des fichiers dont le manifeste d'installation prouve que novahiz
136
+ * les a ecrits et dont la source a disparu.
137
+ */
138
+ function resyncAndClean(home: string): boolean {
139
+ const installer = join(PKG_ROOT, "install", "install.mjs");
140
+ if (!existsSync(installer)) {
141
+ process.stderr.write(
142
+ `novahiz upgrade: installer not found at ${installer}.\n` +
143
+ "Run `node install/install.mjs --yes --clean` from the package root to finish the update.\n"
144
+ );
145
+ process.exitCode = 1;
146
+ return false;
147
+ }
148
+ process.stdout.write("\nResyncing the installation and removing files no longer shipped...\n");
149
+ const env = { ...process.env, NOVAHIZ_HOME: home };
150
+ // S'execute-t-on depuis un script npm ? Alors npm_lifecycle_event est encore
151
+ // present dans l'environnement, et l'installateur bascule en phase differee
152
+ // (il attend la mort de npm avant de bouger). Ici npm n'a rien en cours :
153
+ // le defer laisserait la commande renvoyer avant la fin du resync.
154
+ delete (env as Record<string, string | undefined>).npm_lifecycle_event;
155
+ const result = spawnSync(process.execPath, [installer, "--yes", "--clean"], { stdio: "inherit", env });
156
+ if (result.status !== 0) {
157
+ process.stderr.write(
158
+ "novahiz upgrade: resync failed, the previous installation was left as it is.\n" +
159
+ "Run `node install/install.mjs --yes --clean` from the package root to retry.\n"
160
+ );
161
+ process.exitCode = 1;
162
+ return false;
163
+ }
164
+ return true;
165
+ }
166
+
124
167
  /** Flux git historique (install source) : dry-run par defaut, --apply tire. */
125
168
  function gitFlow(parsed: Parsed, home: string): void {
126
169
  if (!existsSync(join(home, ".git"))) {
@@ -154,6 +197,10 @@ function gitFlow(parsed: Parsed, home: string): void {
154
197
  if (existsSync(syncCli)) {
155
198
  spawnSync(process.execPath, [syncCli, "sync"], { stdio: "inherit", env: { ...process.env, NOVAHIZ_HOME: home } });
156
199
  }
200
+ // Meme resynchronisation que le canal npm : les copies dans le dossier de
201
+ // config opencode viennent du home, pas du depot, et le nettoyage retire ce
202
+ // que l'ancienne version laissait.
203
+ if (!resyncAndClean(home)) return;
157
204
  process.stdout.write("\nUpgraded! Restart OpenCode to apply changes.\n");
158
205
  }
159
206
 
@@ -170,7 +217,7 @@ export function commandUpgrade(parsed: Parsed): void {
170
217
  // `npm i -g` n'a aucun .git dans ~/.config/novahiz.
171
218
  const target = detectTarget(PKG_ROOT, existsSync(join(home, ".git")));
172
219
  if (target === "npm") {
173
- npmFlow(parsed, PKG_ROOT);
220
+ npmFlow(parsed, PKG_ROOT, home);
174
221
  return;
175
222
  }
176
223
  if (target === "git") {