novahiz 0.8.0 → 0.8.2
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 +57 -0
- package/README.md +11 -7
- package/dist/commands/upgrade.js +7 -3
- package/install/install.mjs +99 -15
- package/install/lib.d.mts +90 -0
- package/install/lib.mjs +147 -0
- package/package.json +1 -1
- package/src/commands/upgrade.ts +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,8 +3,65 @@
|
|
|
3
3
|
All notable changes to Novahiz are documented in this file.
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [0.8.2] - 2026-10-10
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **In-place `npm install -g novahiz` now keeps the new package instead of
|
|
11
|
+
silently restoring the previous one** (`install/install.mjs`): the
|
|
12
|
+
`Finalize` step spawned `npm outdated` / `npm update` from inside npm's own
|
|
13
|
+
`postinstall` to refresh dependencies. npm exports its config to lifecycle
|
|
14
|
+
scripts (`npm_config_*`, `envExport` default true), so the inherited
|
|
15
|
+
`npm_config_global=true` from the outer `npm install -g` turned that refresh
|
|
16
|
+
into a **second reify of the same global prefix while npm's transaction was
|
|
17
|
+
still open**. Its retire of `node_modules\novahiz` fails with `EBUSY` (the
|
|
18
|
+
installer's cwd *is* the package directory), the nested npm crashes, and
|
|
19
|
+
arborist's rollback runs `rm(new)` + `rename(.novahiz-OLD → novahiz)` —
|
|
20
|
+
the previous install comes back byte-for-byte with its original mtimes
|
|
21
|
+
while the outer npm reports exit 0 / "changed 1 package". The refresh is now
|
|
22
|
+
pinned to the novahiz home (`npm_config_global=false`, `--ignore-scripts`),
|
|
23
|
+
a directory the outer transaction never touches. This was the actual cause
|
|
24
|
+
of "the in-place install does not replace": it reproduces with **no OpenCode
|
|
25
|
+
restart at all** (proved end-to-end 2026-10-10, see tests below).
|
|
26
|
+
|
|
27
|
+
- **The npm postinstall no longer writes the opencode config while npm is
|
|
28
|
+
still mid-transaction** (`install/install.mjs`, `install/lib.mjs`): a second,
|
|
29
|
+
independent failure mode — running `npm install -g novahiz` from an
|
|
30
|
+
OpenCode-owned shell restarted the OpenCode server during `postinstall`, and
|
|
31
|
+
the restart killed the process tree with npm still inside its reify
|
|
32
|
+
transaction. Depending on where the kill landed, the update either left
|
|
33
|
+
orphaned `.novahiz-*` retire directories and shims behind or aborted the
|
|
34
|
+
configuration pass. The installer now splits in two: the synchronous pass
|
|
35
|
+
only touches `NOVAHIZ_HOME` (the only zone never observed as the restart
|
|
36
|
+
trigger across three reproduced kills), while everything that writes the
|
|
37
|
+
OpenCode config — skills, plugin, agent, commands, `opencode.jsonc`,
|
|
38
|
+
provider skill packs — runs in a detached process that waits for the
|
|
39
|
+
installer and npm to exit first (`--deferred-opencode`, log:
|
|
40
|
+
`<novahiz-home>/install-deferred.log`). That child is spawned with
|
|
41
|
+
`cwd=novahiz-home` so it can never hold the package directory open (a
|
|
42
|
+
process whose cwd sits in `node_modules\novahiz` is what makes any npm
|
|
43
|
+
retire of it fail with `EBUSY` on Windows). Direct runs (`novahiz-install`,
|
|
44
|
+
CLI bootstrap) keep the historical fully synchronous behaviour, and
|
|
45
|
+
`--sync` forces that behaviour back under npm as an escape hatch.
|
|
46
|
+
Regression tests: `tests/install-defer.test.ts`.
|
|
47
|
+
|
|
6
48
|
## [Unreleased]
|
|
7
49
|
|
|
50
|
+
## [0.8.1] - 2026-10-10
|
|
51
|
+
|
|
52
|
+
Patch: `novahiz upgrade` always reported "up to date" on the npm channel.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **`novahiz upgrade` reads the published version again**
|
|
57
|
+
(`src/commands/upgrade.ts`): the `npm view` call ran with `stdio: "ignore"`,
|
|
58
|
+
which makes `spawnSync` return `stdout: null` — `latest` was always empty,
|
|
59
|
+
`compareVersions(local, "")` always won, and the command printed
|
|
60
|
+
`up to date (npm latest: )` even when a newer release existed, so `--apply`
|
|
61
|
+
could never trigger. The call now captures stdout (`"pipe"`), with a
|
|
62
|
+
regression test spawning `npm --version` locally (no network). Inherited
|
|
63
|
+
since 0.7.1.
|
|
64
|
+
|
|
8
65
|
## [0.8.0] - 2026-10-09
|
|
9
66
|
|
|
10
67
|
Minor release: total Impeccable coverage on the design roadmap, a repair
|
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
> **Couche d'application sans dépendance pour les agents de code IA** — classe les prompts, attribue des roadmaps d'exécution, bloque les éditions non sûres tant que les bonnes skills ne sont pas chargées, et persiste les décisions d'une session à l'autre — tout cela de façon déterministe, sans appel de modèle.
|
|
4
4
|
|
|
5
|
-
17 catégories,
|
|
5
|
+
17 catégories, 94 skills, 13 gate rules, 8 MCP providers — tout déterministe, tout local, tout JSON.
|
|
6
6
|
|
|
7
|
-
> **Version 0.
|
|
7
|
+
> **Version 0.8.0** — publiée sur npm (`npm install -g --allow-scripts=novahiz novahiz` installe et configure tout : core, plugin, agent, skills packs et Playwright MCP). `CHANGELOG.md` et `scripts/` sont dans le tarball.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
┌──────────────────────────────────────────────────────────────────────────┐
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
└─ tier, skills ──────│ └─ block : charger les skills nommées, retry une fois
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
**17 catégories**, **
|
|
38
|
+
**17 catégories**, **94 skills**, **13 gate rules**, **8 MCP providers** — tout déterministe, tout local, tout JSON.
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
@@ -66,7 +66,7 @@ npm antérieur à 11.16 exécute les scripts par défaut : `npm install -g novah
|
|
|
66
66
|
Puis vérifiez :
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
novahiz doctor # Santé :
|
|
69
|
+
novahiz doctor # Santé : 17 checks (22 avec --deep)
|
|
70
70
|
novahiz classify "fix the auth bug"
|
|
71
71
|
```
|
|
72
72
|
|
|
@@ -197,6 +197,8 @@ Huit catégories (`code`, `debug`, `browser`, `design-ui`, `database-supabase`,
|
|
|
197
197
|
|
|
198
198
|
Clarify renvoie le travail au plan quand une réponse change l'architecture ; converge le renvoie aux tâches quand il trouve un écart. `flutter` et `expo` gardent les six mêmes étapes et insèrent leurs skills qualité autour d'implement. Le **tier** du classifier filtre la suite : `trivial` n'exécute presque rien, `lite` garde implement et converge, `full` parcourt toute la roadmap. Référence complète : [docs/ROADMAPS.md](docs/ROADMAPS.md).
|
|
199
199
|
|
|
200
|
+
La roadmap `design-ui` porte en plus la couverture totale du playbook Impeccable : 23 étapes `skill` optionnelles couvrant l'intégralité des commandes officielles (`init`, `document`, `shape`, `critique`, ciblées `layout` → `generate`, `audit`, `harden`, `polish`, boucle `detect → verify`), avec les libellés des descriptions publiées sur [impeccable.style/cheatsheet](https://impeccable.style/cheatsheet). Elles forment une checklist pour l'agent ; l'application des règles design reste portée par `R14-impeccable`. `impeccable-live` est volontairement hors roadmap (session navigateur en direct, pas un chemin de code).
|
|
201
|
+
|
|
200
202
|
```mermaid
|
|
201
203
|
flowchart LR
|
|
202
204
|
subgraph "Fonctionnalité (code)"
|
|
@@ -310,7 +312,7 @@ Deux pièces complémentaires : la session consomme moins sans perdre en puissan
|
|
|
310
312
|
|
|
311
313
|
## Skills installées
|
|
312
314
|
|
|
313
|
-
Novahiz livre
|
|
315
|
+
Novahiz livre 94 skills couvrant toutes les catégories — 46 dans le cœur du dépôt, les autres dans les packs officiels installés par défaut au setup :
|
|
314
316
|
|
|
315
317
|
| Catégorie | Skills | Objectif |
|
|
316
318
|
|-----------|--------|----------|
|
|
@@ -344,7 +346,9 @@ Novahiz auto-enregistre les serveurs MCP externes selon la catégorie du prompt
|
|
|
344
346
|
| novahiz-scheduler | `mcp/clepsydre/index.mjs` (local, zéro dépendance) | Apache-2.0 | devops |
|
|
345
347
|
| dart | `dart mcp-server` (Dart SDK) | BSD-3-Clause | code, debug, design-ui, flutter |
|
|
346
348
|
|
|
347
|
-
Packs de skills (installés depuis les dépôts officiels, jamais vendorés) : `flutter/agent-plugins` (
|
|
349
|
+
Packs de skills (installés depuis les dépôts officiels, jamais vendorés, et livrés **par défaut** au setup via `providers.autoInstall`) : `flutter/agent-plugins` (10 skills), `dart-lang/skills` (15 skills), `expo/skills` (19 skills, le groupe `expo-*` seulement ; les services payants `eas-*` exclus), `pbakaus/impeccable` (1 skill, la skill design upstream `impeccable`).
|
|
350
|
+
|
|
351
|
+
**`git` est requis** par le CLI officiel des skills : sans lui, l'installation des packs échoue silencieusement et les skills manquent au premier lancement. `novahiz doctor` le nomme (ligne `git (skill packs)`), et **`/novahiz-skills-update`** — ou `novahiz deps --install` — réinstalle proprement tous les packs depuis leurs providers officiels. Voir [docs/PROVIDERS.md](docs/PROVIDERS.md).
|
|
348
352
|
|
|
349
353
|
Dépôts upstream et provenance complète des providers MCP et plugins opencode : [docs/PROVIDERS.md](docs/PROVIDERS.md), [docs/HARNESSES.md](docs/HARNESSES.md), [NOTICE.md](NOTICE.md).
|
|
350
354
|
|
|
@@ -374,7 +378,7 @@ Voir [docs/CONFIGURATION.md](docs/CONFIGURATION.md) pour toutes les options.
|
|
|
374
378
|
| Commande | Objectif |
|
|
375
379
|
|----------|----------|
|
|
376
380
|
| `novahiz init` | Installation en une passe |
|
|
377
|
-
| `novahiz doctor` | Diagnostic de santé
|
|
381
|
+
| `novahiz doctor` | Diagnostic de santé 17-check (22 avec `--deep`) |
|
|
378
382
|
| `novahiz status` | Classification + état du gate actuels |
|
|
379
383
|
| `novahiz classify <text>` | Classer un prompt |
|
|
380
384
|
| `novahiz gate` | Vérifier si une édition est autorisée |
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -64,8 +64,12 @@ function readLocalVersion(root) {
|
|
|
64
64
|
return "unknown";
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
/** npm sous Windows est npm.cmd : spawnSync a besoin du shell, sinon ENOENT.
|
|
68
|
-
|
|
67
|
+
/** npm sous Windows est npm.cmd : spawnSync a besoin du shell, sinon ENOENT.
|
|
68
|
+
* `pipe` capture stdout/stderr (obligatoire pour `npm view` : `ignore`
|
|
69
|
+
* retourne stdout=null et `latest` restait toujours vide), `inherit` pour
|
|
70
|
+
* les sorties visibles de l'utilisateur (installation). Exporte pour le
|
|
71
|
+
* test de regression. */
|
|
72
|
+
export function runNpm(args, stdio) {
|
|
69
73
|
const result = spawnSync("npm", args, {
|
|
70
74
|
encoding: "utf8",
|
|
71
75
|
stdio,
|
|
@@ -79,7 +83,7 @@ function runNpm(args, stdio) {
|
|
|
79
83
|
}
|
|
80
84
|
function npmFlow(parsed, root) {
|
|
81
85
|
const local = readLocalVersion(root);
|
|
82
|
-
const view = runNpm(["view", "novahiz", "version"], "
|
|
86
|
+
const view = runNpm(["view", "novahiz", "version"], "pipe");
|
|
83
87
|
if (view.status !== 0) {
|
|
84
88
|
process.stderr.write(`novahiz upgrade: npm view failed (${view.stderr.trim() || `exit ${view.status}`}).\n`);
|
|
85
89
|
process.exitCode = 1;
|
package/install/install.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { cpSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
6
7
|
import {
|
|
7
8
|
copyFileWithBackup,
|
|
8
9
|
copyInto,
|
|
9
10
|
defaultConfig,
|
|
11
|
+
deferOpencodePhase,
|
|
10
12
|
detectedHarnesses,
|
|
13
|
+
findNpmAncestorPid,
|
|
11
14
|
loadManifest,
|
|
12
15
|
mergeBackups,
|
|
13
16
|
mergeCreated,
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
saveManifest,
|
|
22
25
|
skillNamesIn,
|
|
23
26
|
spawnHost,
|
|
27
|
+
waitForDeferredPhase,
|
|
24
28
|
which,
|
|
25
29
|
writeJson
|
|
26
30
|
} from "./lib.mjs";
|
|
@@ -65,6 +69,10 @@ const CORE_ITEMS = [
|
|
|
65
69
|
async function main() {
|
|
66
70
|
const flags = parseArgs(process.argv.slice(2));
|
|
67
71
|
const dryRun = Boolean(flags["dry-run"]);
|
|
72
|
+
// Phase opencode rejetee hors du cycle de vie npm (voir lib.mjs) : sous
|
|
73
|
+
// postinstall, le passage synchronise n'ecrit plus que dans NOVAHIZ_HOME —
|
|
74
|
+
// jamais dans le dossier de config opencode que le serveur surveille.
|
|
75
|
+
const deferOpencode = deferOpencodePhase(flags);
|
|
68
76
|
const force = Boolean(flags.force);
|
|
69
77
|
const withSkills = !flags["no-skills"];
|
|
70
78
|
const root = repoRoot(import.meta.url);
|
|
@@ -95,6 +103,42 @@ async function main() {
|
|
|
95
103
|
}
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
// Phase differee, spawn des le debut du postinstall : l'enfant attendra la
|
|
107
|
+
// mort de ce postinstall puis de npm avant d'ecrire quoi que ce soit dans
|
|
108
|
+
// la config opencode. Detache tot (detached + unref) : un redemarrage
|
|
109
|
+
// opencode qui tuerait npm ne l'atteint plus — la configuration rejoint
|
|
110
|
+
// alors la nouvelle version au lieu de rester bloquee a l'ancienne.
|
|
111
|
+
const deferLogPath = deferOpencode && !dryRun ? join(home, "install-deferred.log") : null;
|
|
112
|
+
if (deferLogPath) {
|
|
113
|
+
mkdirSync(home, { recursive: true });
|
|
114
|
+
const npmAncestor = findNpmAncestorPid();
|
|
115
|
+
const logFd = openSync(deferLogPath, "a");
|
|
116
|
+
// argv[1] du parent = ce meme script : l'enfant doit le recevoir, sinon
|
|
117
|
+
// node interprete --yes comme une option sienne ("bad option: --yes").
|
|
118
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2), "--deferred-opencode"], {
|
|
119
|
+
// cwd = home, NOT the inherited package dir: a process whose cwd sits
|
|
120
|
+
// inside node_modules\novahiz makes any npm retire of that directory
|
|
121
|
+
// fail with EBUSY on Windows (that is what let the nested-update
|
|
122
|
+
// rollback restore the old package). The child lives for minutes, so
|
|
123
|
+
// it must never hold the package dir. Consequence: with an explicit
|
|
124
|
+
// `--scope project`, configDir resolves against home instead of the
|
|
125
|
+
// caller's cwd (postinstall never passes --scope).
|
|
126
|
+
cwd: home,
|
|
127
|
+
detached: true,
|
|
128
|
+
stdio: ["ignore", logFd, logFd],
|
|
129
|
+
env: {
|
|
130
|
+
...process.env,
|
|
131
|
+
NOVAHIZ_DEFERRED: "1",
|
|
132
|
+
NOVAHIZ_WAIT_PARENT_PID: String(process.pid),
|
|
133
|
+
NOVAHIZ_WAIT_PID: npmAncestor === null ? "" : String(npmAncestor),
|
|
134
|
+
},
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
});
|
|
137
|
+
child.unref();
|
|
138
|
+
closeSync(logFd);
|
|
139
|
+
note(`Opencode config phase deferred (npm still running) - log: ${deferLogPath}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
98
142
|
const harnessDirs = { opencode: configDir };
|
|
99
143
|
const harnessPackages = { opencode: "opencode-ai" };
|
|
100
144
|
const detected = detectedHarnesses(harnessDirs);
|
|
@@ -184,7 +228,7 @@ async function main() {
|
|
|
184
228
|
}
|
|
185
229
|
}
|
|
186
230
|
|
|
187
|
-
if (!dryRun) {
|
|
231
|
+
if (!dryRun && !deferOpencode) {
|
|
188
232
|
for (const name of configured) {
|
|
189
233
|
if (!existsSync(harnessDirs[name])) mkdirSync(harnessDirs[name], { recursive: true });
|
|
190
234
|
}
|
|
@@ -222,10 +266,14 @@ async function main() {
|
|
|
222
266
|
}
|
|
223
267
|
|
|
224
268
|
ui.step("Skills");
|
|
225
|
-
if (
|
|
269
|
+
if (deferOpencode) {
|
|
270
|
+
note("Opencode skills copy deferred to the post-npm phase.");
|
|
271
|
+
ui.finishStep("skip", "deferred");
|
|
272
|
+
}
|
|
273
|
+
if (!deferOpencode && withSkills && !configured.includes("opencode")) {
|
|
226
274
|
note("Skipping opencode skills copy (opencode not selected).");
|
|
227
275
|
}
|
|
228
|
-
if (withSkills && configured.includes("opencode")) {
|
|
276
|
+
if (!deferOpencode && withSkills && configured.includes("opencode")) {
|
|
229
277
|
const skillsSource = existsSync(join(home, "skills")) ? join(home, "skills") : join(root, "skills");
|
|
230
278
|
if (existsSync(skillsSource)) {
|
|
231
279
|
// Dedup only against roots the catalog actually scans (see
|
|
@@ -272,10 +320,14 @@ async function main() {
|
|
|
272
320
|
// du modele de config, une entree fichier y declenchait l'avertissement
|
|
273
321
|
// "configured plugin path must be a directory" a chaque demarrage.
|
|
274
322
|
ui.step("Plugin, agent & commands");
|
|
323
|
+
if (deferOpencode) {
|
|
324
|
+
note("Plugin, agent & commands deferred to the post-npm phase.");
|
|
325
|
+
ui.finishStep("skip", "deferred");
|
|
326
|
+
}
|
|
275
327
|
for (const pluginFile of ["novahiz-plugin.ts", "novahiz-token-economy.ts"]) {
|
|
276
328
|
const pluginSource = join(home, "adapters", "opencode", pluginFile);
|
|
277
329
|
const pluginTarget = join(pluginsDir, pluginFile);
|
|
278
|
-
if (configured.includes("opencode") && existsSync(pluginSource)) {
|
|
330
|
+
if (!deferOpencode && configured.includes("opencode") && existsSync(pluginSource)) {
|
|
279
331
|
note(`Installing opencode plugin in ${pluginTarget}`);
|
|
280
332
|
if (!dryRun) {
|
|
281
333
|
const result = copyFileWithBackup(pluginSource, pluginTarget, true);
|
|
@@ -289,7 +341,7 @@ async function main() {
|
|
|
289
341
|
? join(home, "adapters", "opencode", "agent", "novahiz.md")
|
|
290
342
|
: join(root, "adapters", "opencode", "agent", "novahiz.md");
|
|
291
343
|
const agentTarget = join(configDir, "agent", "novahiz.md");
|
|
292
|
-
if (configured.includes("opencode") && existsSync(agentSource)) {
|
|
344
|
+
if (!deferOpencode && configured.includes("opencode") && existsSync(agentSource)) {
|
|
293
345
|
note(`Installing Novahiz agent in ${agentTarget}`);
|
|
294
346
|
if (!dryRun) {
|
|
295
347
|
const result = copyFileWithBackup(agentSource, agentTarget, true);
|
|
@@ -302,7 +354,7 @@ async function main() {
|
|
|
302
354
|
? join(home, "adapters", "opencode", "commands")
|
|
303
355
|
: join(root, "adapters", "opencode", "commands");
|
|
304
356
|
const commandsTarget = join(configDir, "commands");
|
|
305
|
-
if (configured.includes("opencode") && existsSync(commandsSource)) {
|
|
357
|
+
if (!deferOpencode && configured.includes("opencode") && existsSync(commandsSource)) {
|
|
306
358
|
note(`Installing Novahiz commands in ${commandsTarget}`);
|
|
307
359
|
if (!dryRun) {
|
|
308
360
|
const result = copyInto(commandsSource, commandsTarget, true);
|
|
@@ -373,7 +425,9 @@ async function main() {
|
|
|
373
425
|
env: { ...process.env, NOVAHIZ_HOME: home }
|
|
374
426
|
});
|
|
375
427
|
if (check.stdout) ui.raw(check.stdout);
|
|
376
|
-
if (autoInstall) {
|
|
428
|
+
if (autoInstall && deferOpencode) {
|
|
429
|
+
note("Dependency/provider install deferred to the post-npm phase (skills and commands write into the opencode config).");
|
|
430
|
+
} else if (autoInstall) {
|
|
377
431
|
note("Installing dependencies and providers (MCP, skills, commands)");
|
|
378
432
|
const result = spawnSync(process.execPath, [cli, "deps", "--install", "--yes"], {
|
|
379
433
|
encoding: "utf8",
|
|
@@ -492,7 +546,9 @@ async function main() {
|
|
|
492
546
|
{ id: "flutter-skills", repo: "flutter/agent-plugins" },
|
|
493
547
|
{ id: "dart-skills", repo: "dart-lang/skills" },
|
|
494
548
|
];
|
|
495
|
-
if (!dryRun && flags["flutter-skills"]) {
|
|
549
|
+
if (!dryRun && flags["flutter-skills"] && deferOpencode) {
|
|
550
|
+
note("\nFlutter/Dart skill packs deferred to the post-npm phase.");
|
|
551
|
+
} else if (!dryRun && flags["flutter-skills"]) {
|
|
496
552
|
note("\nInstalling official Flutter/Dart skill packs...");
|
|
497
553
|
for (const pack of skillPacks) {
|
|
498
554
|
note(` ${pack.repo}...`);
|
|
@@ -540,7 +596,10 @@ async function main() {
|
|
|
540
596
|
if (!configured.includes("opencode")) {
|
|
541
597
|
note("Skipping opencode.jsonc (opencode not selected).");
|
|
542
598
|
}
|
|
543
|
-
if (!dryRun && configured.includes("opencode")) {
|
|
599
|
+
if (!dryRun && deferOpencode && configured.includes("opencode")) {
|
|
600
|
+
note("\nopencode.jsonc generation deferred to the post-npm phase.");
|
|
601
|
+
}
|
|
602
|
+
if (!dryRun && !deferOpencode && configured.includes("opencode")) {
|
|
544
603
|
const configPath = join(configDir, "opencode.jsonc");
|
|
545
604
|
if (!existsSync(configPath)) {
|
|
546
605
|
note(`\nCreating ${configPath}`);
|
|
@@ -682,19 +741,35 @@ async function main() {
|
|
|
682
741
|
|
|
683
742
|
ui.step("Finalize");
|
|
684
743
|
if (!dryRun) {
|
|
685
|
-
// Auto-update dependencies
|
|
744
|
+
// Auto-update dependencies (scoped to novahiz home, NEVER the global prefix).
|
|
745
|
+
//
|
|
746
|
+
// npm exports its config to lifecycle scripts (npm_config_*, envExport
|
|
747
|
+
// default true), so running under `npm install -g` leaks
|
|
748
|
+
// npm_config_global=true into this process. Without the explicit override
|
|
749
|
+
// below, `npm update` here silently becomes a SECOND reify of the global
|
|
750
|
+
// prefix while npm's own transaction is still open: its retire of
|
|
751
|
+
// node_modules\novahiz gets EBUSY (this process's cwd IS the package dir),
|
|
752
|
+
// the nested npm crashes, and arborist's rollback does
|
|
753
|
+
// rm(new) + rename(.novahiz-OLD -> novahiz) — the previous install comes
|
|
754
|
+
// back byte-for-byte with its original mtimes while the outer npm reports
|
|
755
|
+
// exit 0 / "changed 1 package". That is the "in-place install does not
|
|
756
|
+
// replace" bug. Forcing npm_config_global=false keeps this refresh in
|
|
757
|
+
// home (home/package.json), which shares no directory with the outer
|
|
758
|
+
// transaction. --ignore-scripts so nothing in home can re-enter the
|
|
759
|
+
// installer (home/package.json carries the postinstall script too).
|
|
686
760
|
note("Checking for dependency updates...");
|
|
687
761
|
const pkgPath = join(home, "package.json");
|
|
688
762
|
if (existsSync(pkgPath)) {
|
|
763
|
+
const homeEnv = { ...process.env, NOVAHIZ_HOME: home, npm_config_global: "false" };
|
|
689
764
|
const npmCheck = spawnHost("npm", ["outdated", "--json"], {
|
|
690
765
|
cwd: home,
|
|
691
|
-
env:
|
|
766
|
+
env: homeEnv
|
|
692
767
|
});
|
|
693
768
|
if (npmCheck.stdout && npmCheck.stdout.trim().length > 2) {
|
|
694
769
|
note("Updates available, installing...");
|
|
695
|
-
const npmUpdate = spawnHost("npm", ["update"], {
|
|
770
|
+
const npmUpdate = spawnHost("npm", ["update", "--ignore-scripts"], {
|
|
696
771
|
cwd: home,
|
|
697
|
-
env:
|
|
772
|
+
env: homeEnv
|
|
698
773
|
});
|
|
699
774
|
if (npmUpdate.stdout) ui.raw(npmUpdate.stdout);
|
|
700
775
|
if (npmUpdate.status !== 0 && npmUpdate.stderr) process.stderr.write(npmUpdate.stderr);
|
|
@@ -704,6 +779,7 @@ async function main() {
|
|
|
704
779
|
}
|
|
705
780
|
}
|
|
706
781
|
const summary = [`Novahiz installed in ${home}.`];
|
|
782
|
+
if (deferLogPath) summary.push(`opencode config phase: deferred - see ${deferLogPath} (runs once npm exits).`);
|
|
707
783
|
if (configured.length > 0) summary.push("Restart opencode to activate the plugin and MCP server.");
|
|
708
784
|
summary.push("Gate can be disabled with the NOVAHIZ_GATE=off environment variable.");
|
|
709
785
|
summary.push("Update anytime: `novahiz upgrade` (npm) or `/novahiz-upgrade` inside OpenCode.");
|
|
@@ -716,6 +792,14 @@ async function main() {
|
|
|
716
792
|
}
|
|
717
793
|
}
|
|
718
794
|
|
|
795
|
+
// Entree de la phase differee (NOVAHIZ_DEFERRED=1) : on attend la mort du
|
|
796
|
+
// postinstall puis de npm avant le moindre passage complet — pendant ce
|
|
797
|
+
// temps, npm finalise sa transaction reify sans risque d'etre tue par un
|
|
798
|
+
// redemarrage opencode.
|
|
799
|
+
if (process.env.NOVAHIZ_DEFERRED === "1") {
|
|
800
|
+
await waitForDeferredPhase();
|
|
801
|
+
}
|
|
802
|
+
|
|
719
803
|
main().catch((error) => {
|
|
720
804
|
process.stderr.write(`${String(error)}\n`);
|
|
721
805
|
// postinstall (`npm install novahiz`) runs --dry-run: an unexpected crash
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Declarations pour install/lib.mjs (module JS pur, consomme aussi par les
|
|
2
|
+
// tests TypeScript). Garde le contrat en phase avec lib.mjs — toute change de
|
|
3
|
+
// signature se reflete ici (typecheck = preuve que les deux coincident).
|
|
4
|
+
import type { SpawnSyncOptions, SpawnSyncReturns } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export interface BackupEntry {
|
|
7
|
+
path: string;
|
|
8
|
+
backup: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CopyFileResult {
|
|
12
|
+
created: string | null;
|
|
13
|
+
backup: BackupEntry | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CopyIntoResult {
|
|
17
|
+
created: string[];
|
|
18
|
+
backups: BackupEntry[];
|
|
19
|
+
total: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NovahizManifest {
|
|
23
|
+
version?: string;
|
|
24
|
+
installedAt?: string;
|
|
25
|
+
harness?: string;
|
|
26
|
+
harnesses?: string[];
|
|
27
|
+
configDir?: string;
|
|
28
|
+
home?: string;
|
|
29
|
+
coreCopied?: boolean;
|
|
30
|
+
configCreated?: boolean;
|
|
31
|
+
created?: string[];
|
|
32
|
+
backups?: BackupEntry[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NovahizConfig {
|
|
36
|
+
dbPath: string;
|
|
37
|
+
skillRoots: string[];
|
|
38
|
+
gate: { enabled: boolean; mode: string; envEscape: string; tools: string[] };
|
|
39
|
+
classify: { minScore: number; maxCategories: number; fallbackCategory: string };
|
|
40
|
+
providers: { autoRegister: boolean; autoInstall: boolean; disabled: string[] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DeferredWaitOptions {
|
|
44
|
+
/** Cadence de sonde entre deux verifications de vie (defaut 2000 ms). */
|
|
45
|
+
pollMs?: number;
|
|
46
|
+
/** Plafond inclusif par pid (defaut 10 min) : la fonction rend toujours la main. */
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
/** Patience fixe quand npm n'a pas pu etre identifie (defaut 30000 ms). */
|
|
49
|
+
graceMs?: number;
|
|
50
|
+
/** Sonde de vie injectable pour les tests (defaut pidAlive). */
|
|
51
|
+
isAlive?: (pid: number) => boolean;
|
|
52
|
+
log?: (message: string) => void;
|
|
53
|
+
sleep?: (ms: number) => Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export declare function parseArgs(argv: string[]): Record<string, string | boolean>;
|
|
57
|
+
export declare function expandHome(value: string): string;
|
|
58
|
+
export declare function repoRoot(metaUrl: string): string;
|
|
59
|
+
export declare function opencodeConfigDir(env?: NodeJS.ProcessEnv): string;
|
|
60
|
+
export declare function NovahizHome(flags?: Record<string, unknown>, env?: NodeJS.ProcessEnv): string;
|
|
61
|
+
export declare function listFiles(root: string): string[];
|
|
62
|
+
export declare function skillNamesIn(roots: string[]): Set<string>;
|
|
63
|
+
export declare function copyFileWithBackup(srcPath: string, destPath: string, useBackup?: boolean): CopyFileResult;
|
|
64
|
+
export declare function copyInto(srcDir: string, destDir: string, useBackup?: boolean): CopyIntoResult;
|
|
65
|
+
export declare function readJson(path: string): unknown;
|
|
66
|
+
export declare function readJson<T>(path: string, fallback: T): T;
|
|
67
|
+
export declare function writeJson(path: string, value: unknown): void;
|
|
68
|
+
export declare function playwrightBrowserFlag(platform?: string): string;
|
|
69
|
+
export declare function defaultConfig(): NovahizConfig;
|
|
70
|
+
export declare function loadManifest(home: string): NovahizManifest;
|
|
71
|
+
export declare function unsafeHostToken(tokens: string[]): string | null;
|
|
72
|
+
export declare function spawnHost(cmd: string, args: string[], opts?: SpawnSyncOptions): SpawnSyncReturns<string>;
|
|
73
|
+
export declare function saveManifest(home: string, manifest: NovahizManifest): void;
|
|
74
|
+
export declare function mergeCreated(previous?: string[], next?: string[]): string[];
|
|
75
|
+
export declare function mergeBackups(previous?: BackupEntry[], next?: BackupEntry[]): BackupEntry[];
|
|
76
|
+
export declare function pruneEmptyDirs(paths: string[], stops?: string[]): void;
|
|
77
|
+
export declare function detectedHarnesses(dirs: Record<string, string>, whichFn?: (cmd: string) => boolean): string[];
|
|
78
|
+
export declare function which(cmd: string): boolean;
|
|
79
|
+
export declare function nodeVersionOk(minimum?: [number, number, number]): boolean;
|
|
80
|
+
|
|
81
|
+
// --- Phase opencode differee (postinstall npm) ------------------------------
|
|
82
|
+
export declare function deferOpencodePhase(flags?: Record<string, unknown>, env?: NodeJS.ProcessEnv): boolean;
|
|
83
|
+
export declare function parsePid(value: unknown): number | null;
|
|
84
|
+
export declare function pidAlive(pid: number): boolean;
|
|
85
|
+
export declare function findNpmAncestorPid(startPid?: number): number | null;
|
|
86
|
+
export declare function waitForPids(
|
|
87
|
+
pids: ReadonlyArray<number | null | undefined>,
|
|
88
|
+
opts?: DeferredWaitOptions,
|
|
89
|
+
): Promise<void>;
|
|
90
|
+
export declare function waitForDeferredPhase(env?: NodeJS.ProcessEnv, opts?: DeferredWaitOptions): Promise<void>;
|
package/install/lib.mjs
CHANGED
|
@@ -319,3 +319,150 @@ export function nodeVersionOk(minimum = [22, 18, 0]) {
|
|
|
319
319
|
}
|
|
320
320
|
return true;
|
|
321
321
|
}
|
|
322
|
+
|
|
323
|
+
// --- Phase opencode differee (postinstall npm) ------------------------------
|
|
324
|
+
//
|
|
325
|
+
// Bug reproduit le 2026-10-10 (enquete npm, memoire slot-005) : quand
|
|
326
|
+
// install.mjs tourne comme script postinstall, ses ecritures dans le dossier
|
|
327
|
+
// de config opencode (skills, plugin, agent, commands, opencode.jsonc) font
|
|
328
|
+
// redemarrer le serveur opencode, qui tue alors son arbre de processus —
|
|
329
|
+
// npm compris, encore en pleine transaction reify. Le remplacement en place
|
|
330
|
+
// est interrompu : rollback vers l'ancien paquet (version et mtimes
|
|
331
|
+
// preserves a la milliseconde) ou dossiers .novahiz-* orphelins.
|
|
332
|
+
//
|
|
333
|
+
// Le correctif se pose sur deux piliers :
|
|
334
|
+
// 1. la phase opencode part en processus detache (detached + unref) qui
|
|
335
|
+
// attend la mort du postinstall puis de npm avant la moindre ecriture —
|
|
336
|
+
// un redemarrage opencode arrive alors que la transaction est finie ;
|
|
337
|
+
// 2. le passage synchronise ne touche plus qu'au NOVAHIZ_HOME, seule zone
|
|
338
|
+
// jamais observee comme declencheur lors des trois kills reproduits.
|
|
339
|
+
|
|
340
|
+
const deferDefaultLog = (message) => process.stdout.write(`${message}\n`);
|
|
341
|
+
const deferSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* La phase opencode doit-elle sortir du cycle de vie npm ?
|
|
345
|
+
*
|
|
346
|
+
* - `npm_lifecycle_event` present (postinstall) -> oui, sauf :
|
|
347
|
+
* - `--deferred-opencode` / `NOVAHIZ_DEFERRED=1` (le passage detache
|
|
348
|
+
* lui-meme) -> non : la phase deferree ne se defererait pas a l'infini ;
|
|
349
|
+
* - `--sync` (repli d'urgence vers l'ancien comportement) -> non ;
|
|
350
|
+
* - hors npm (novahiz-install direct, bootstrap CLI) -> non : le
|
|
351
|
+
* comportement historique entierement synchrone est conserve.
|
|
352
|
+
*/
|
|
353
|
+
export function deferOpencodePhase(flags = {}, env = process.env) {
|
|
354
|
+
if (flags.sync === true) return false;
|
|
355
|
+
if (flags["deferred-opencode"] === true) return false;
|
|
356
|
+
if (env.NOVAHIZ_DEFERRED === "1") return false;
|
|
357
|
+
return Boolean(env.npm_lifecycle_event);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** pid utilisable : entier strictement positif, sinon null. */
|
|
361
|
+
export function parsePid(value) {
|
|
362
|
+
const pid = Number.parseInt(String(value ?? ""), 10);
|
|
363
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Le processus existe-t-il encore ? EPERM = oui (proprietaire different). */
|
|
367
|
+
export function pidAlive(pid) {
|
|
368
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
369
|
+
try {
|
|
370
|
+
process.kill(pid, 0);
|
|
371
|
+
return true;
|
|
372
|
+
} catch (error) {
|
|
373
|
+
return Boolean(error) && error.code === "EPERM";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* PID du processus npm de l'installation en cours : on remonte la chaine des
|
|
379
|
+
* ParentProcessId jusqu'au premier ancetre dont la ligne de commande
|
|
380
|
+
* reference `npm-cli.js`. Le chemin de notre propre processus contient bien
|
|
381
|
+
* `\npm\` (paquet installe globalement dans Roaming\npm), jamais
|
|
382
|
+
* `npm-cli.js` : le filtre ne peut pas se retenir lui-meme.
|
|
383
|
+
* null = pas de npm au-dessus (ou plateforme non interrogeable) — l'appelant
|
|
384
|
+
* bascule alors sur une patience fixe.
|
|
385
|
+
*/
|
|
386
|
+
export function findNpmAncestorPid(startPid = process.pid) {
|
|
387
|
+
if (process.platform === "win32") {
|
|
388
|
+
const script =
|
|
389
|
+
`$cur = Get-CimInstance Win32_Process -Filter "ProcessId=${startPid}"; ` +
|
|
390
|
+
"while ($cur) { " +
|
|
391
|
+
"if ($cur.CommandLine -and $cur.CommandLine -match 'npm-cli\\.js') { Write-Output $cur.ProcessId; break }; " +
|
|
392
|
+
"if (-not $cur.ParentProcessId -or $cur.ParentProcessId -eq $cur.ProcessId) { break }; " +
|
|
393
|
+
"$cur = Get-CimInstance Win32_Process -Filter \"ProcessId=$($cur.ParentProcessId)\" -ErrorAction SilentlyContinue; " +
|
|
394
|
+
"}";
|
|
395
|
+
const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8" });
|
|
396
|
+
if (result.status === 0) {
|
|
397
|
+
const pid = parsePid((result.stdout ?? "").trim());
|
|
398
|
+
if (pid !== null && pid !== startPid) return pid;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
const result = spawnSync("ps", ["-o", "pid=", "-o", "ppid=", "-o", "command=", "-ax"], { encoding: "utf8" });
|
|
403
|
+
if (result.status !== 0) return null;
|
|
404
|
+
const rows = new Map();
|
|
405
|
+
for (const line of String(result.stdout ?? "").split("\n")) {
|
|
406
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+([\s\S]*)$/);
|
|
407
|
+
if (match) rows.set(Number.parseInt(match[1], 10), { ppid: Number.parseInt(match[2], 10), cmd: match[3] });
|
|
408
|
+
}
|
|
409
|
+
let current = startPid;
|
|
410
|
+
const seen = new Set();
|
|
411
|
+
while (current && rows.has(current) && !seen.has(current)) {
|
|
412
|
+
seen.add(current);
|
|
413
|
+
const row = rows.get(current);
|
|
414
|
+
if (current !== startPid && /npm-cli\.js/.test(row.cmd)) return current;
|
|
415
|
+
current = row.ppid;
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Attend la mort de chaque pid (cadence pollMs, plafond timeoutMs par pid).
|
|
422
|
+
* Le plafond est inclusif : la fonction rend TOUJOURS la main — un pid
|
|
423
|
+
* rebelle ne doit jamais bloquer la phase differee indefiniment.
|
|
424
|
+
*/
|
|
425
|
+
export async function waitForPids(pids, opts = {}) {
|
|
426
|
+
const pollMs = opts.pollMs ?? 2000;
|
|
427
|
+
const timeoutMs = opts.timeoutMs ?? 10 * 60 * 1000;
|
|
428
|
+
const isAlive = opts.isAlive ?? pidAlive;
|
|
429
|
+
const log = opts.log ?? deferDefaultLog;
|
|
430
|
+
const wait = opts.sleep ?? deferSleep;
|
|
431
|
+
for (const pid of pids) {
|
|
432
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
433
|
+
if (!isAlive(pid)) {
|
|
434
|
+
log(`novahiz defer: pid ${pid} already exited`);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const deadline = Date.now() + timeoutMs;
|
|
438
|
+
log(`novahiz defer: waiting for pid ${pid} to exit...`);
|
|
439
|
+
while (isAlive(pid)) {
|
|
440
|
+
if (Date.now() >= deadline) {
|
|
441
|
+
log(`novahiz defer: pid ${pid} still alive after ${timeoutMs}ms - proceeding anyway`);
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
await wait(pollMs);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Entree de la phase differee : attend que le postinstall (notre createur)
|
|
451
|
+
* ET npm soient sortis avant d'ecrire la moindre chose, puis rend la main.
|
|
452
|
+
* npm non identifie (chaine d'ancetres atypique, pnpm...) -> patience fixe :
|
|
453
|
+
* npm sort quelques secondes apres la fin du script de lifecycle.
|
|
454
|
+
*/
|
|
455
|
+
export async function waitForDeferredPhase(env = process.env, opts = {}) {
|
|
456
|
+
const log = opts.log ?? deferDefaultLog;
|
|
457
|
+
const wait = opts.sleep ?? deferSleep;
|
|
458
|
+
const parentPid = parsePid(env.NOVAHIZ_WAIT_PARENT_PID);
|
|
459
|
+
const npmPid = parsePid(env.NOVAHIZ_WAIT_PID);
|
|
460
|
+
log(`novahiz defer: opencode phase detached - parent ${parentPid ?? "(none)"}, npm ${npmPid ?? "(unknown)"}`);
|
|
461
|
+
await waitForPids(parentPid === null ? [] : [parentPid], opts);
|
|
462
|
+
if (npmPid !== null) {
|
|
463
|
+
await waitForPids([npmPid], opts);
|
|
464
|
+
} else {
|
|
465
|
+
await wait(opts.graceMs ?? 30000);
|
|
466
|
+
}
|
|
467
|
+
log("novahiz defer: npm exited - running the full pass (opencode config included)");
|
|
468
|
+
}
|
package/package.json
CHANGED
package/src/commands/upgrade.ts
CHANGED
|
@@ -71,8 +71,12 @@ function readLocalVersion(root: string): string {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/** npm sous Windows est npm.cmd : spawnSync a besoin du shell, sinon ENOENT.
|
|
75
|
-
|
|
74
|
+
/** npm sous Windows est npm.cmd : spawnSync a besoin du shell, sinon ENOENT.
|
|
75
|
+
* `pipe` capture stdout/stderr (obligatoire pour `npm view` : `ignore`
|
|
76
|
+
* retourne stdout=null et `latest` restait toujours vide), `inherit` pour
|
|
77
|
+
* les sorties visibles de l'utilisateur (installation). Exporte pour le
|
|
78
|
+
* test de regression. */
|
|
79
|
+
export function runNpm(args: string[], stdio: "inherit" | "pipe"): { status: number; stdout: string; stderr: string } {
|
|
76
80
|
const result = spawnSync("npm", args, {
|
|
77
81
|
encoding: "utf8",
|
|
78
82
|
stdio,
|
|
@@ -87,7 +91,7 @@ function runNpm(args: string[], stdio: "inherit" | "ignore"): { status: number;
|
|
|
87
91
|
|
|
88
92
|
function npmFlow(parsed: Parsed, root: string): void {
|
|
89
93
|
const local = readLocalVersion(root);
|
|
90
|
-
const view = runNpm(["view", "novahiz", "version"], "
|
|
94
|
+
const view = runNpm(["view", "novahiz", "version"], "pipe");
|
|
91
95
|
if (view.status !== 0) {
|
|
92
96
|
process.stderr.write(`novahiz upgrade: npm view failed (${view.stderr.trim() || `exit ${view.status}`}).\n`);
|
|
93
97
|
process.exitCode = 1;
|