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 +65 -0
- package/adapters/opencode/commands/novahiz-upgrade.md +1 -1
- package/catalog/categories.json +2 -10
- package/catalog/overrides.json +0 -77
- package/dist/commands/doctor.js +32 -15
- package/dist/commands/upgrade.js +47 -3
- package/install/install.mjs +53 -2
- package/install/lib.d.mts +36 -0
- package/install/lib.mjs +106 -2
- package/install/uninstall.mjs +5 -8
- package/package.json +1 -1
- package/src/commands/doctor.ts +38 -14
- package/src/commands/upgrade.ts +50 -3
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).
|
|
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.
|
package/catalog/categories.json
CHANGED
|
@@ -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": [
|
package/catalog/overrides.json
CHANGED
|
@@ -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,
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
447
|
-
* impeccable
|
|
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 =
|
|
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
|
|
497
|
-
* not installed"
|
|
498
|
-
* `devops`
|
|
499
|
-
* "run /novahiz-skills-update, or novahiz deps --install"
|
|
500
|
-
* installed a single one. Advice no command can honour is a
|
|
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
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -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
|
-
|
|
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") {
|
package/install/install.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/install/uninstall.mjs
CHANGED
|
@@ -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
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
|
443
|
-
* impeccable
|
|
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 =
|
|
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
|
|
495
|
-
* not installed"
|
|
496
|
-
* `devops`
|
|
497
|
-
* "run /novahiz-skills-update, or novahiz deps --install"
|
|
498
|
-
* installed a single one. Advice no command can honour is a
|
|
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
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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") {
|