novahiz 0.9.8 → 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,44 @@ 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
+
8
46
  ## [0.9.8] - 2026-10-15
9
47
 
10
48
  ### Removed
@@ -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.
@@ -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.8",
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",
@@ -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") {