create-byan-agent 2.59.2 → 2.60.0

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.
@@ -32,6 +32,10 @@ const { setupClaudeNative } = require('./claude-native-setup');
32
32
  const { setupCodexNative } = require('./codex-native-setup');
33
33
  const { offerGlobalSkillsSync } = require('./global-skills-sync');
34
34
  const homeCreds = require('./home-credentials');
35
+ const { resolveTargetUser, resolveHomeFor } = require('./target-user');
36
+ const { resolveBinary, commandExists: resolveCommandExists } = require('./resolve-binary');
37
+ const { ensureOwnership, ensureSharedGroup, resolveGroupGid } = require('./ownership');
38
+ const { resolveApiUrl } = require('./api-defaults');
35
39
 
36
40
  // Gen3 by-type dirs copied from templates/_byan (same list as the legacy bin).
37
41
  const BYAN_DIRS = ['agent', 'workflow', 'connaissance', 'command', 'worker', 'memoire',
@@ -42,22 +46,53 @@ function defaultTemplateDir() {
42
46
  return fs.existsSync(p) ? p : null;
43
47
  }
44
48
 
45
- function commandExists(cmd) {
46
- try {
47
- execSync(`command -v ${cmd}`, { stdio: 'ignore', shell: '/bin/sh' });
48
- return true;
49
- } catch {
50
- return false;
51
- }
49
+ // L'ancienne sonde lancait execSync('command -v X', { shell: '/bin/sh' }).
50
+ // Trois defauts mesures le 2026-08-11 : /bin/sh est absent de Windows ; avec le
51
+ // PATH POSIX par defaut (getconf PATH) la commande sort en 1 alors que claude
52
+ // EST installe, parce qu'il vit dans ~/.local/bin qui n'appartient a aucun PATH
53
+ // par defaut ; et sous elevation de privilege le PATH est celui de root.
54
+ // La sonde passe desormais par resolve-binary : parcours du PATH en JavaScript,
55
+ // puis emplacements connus du home de l'utilisateur CIBLE.
56
+ function commandExists(cmd, options = {}) {
57
+ return resolveCommandExists(cmd, options);
52
58
  }
53
59
 
54
- /** Detection: platforms on this machine + stored credentials. Facts only. */
55
- function detectEnvironment({ homeDir = os.homedir() } = {}) {
60
+ /**
61
+ * Detection: platforms on this machine + stored credentials. Facts only.
62
+ *
63
+ * Le home interroge est celui de l'utilisateur CIBLE, pas celui du processus.
64
+ * Sous sudo, os.homedir() rend /root : l'ancienne version cherchait ~/.claude
65
+ * dans /root, ne trouvait rien, et l'installateur sautait l'etape Claude en
66
+ * annoncant une reussite.
67
+ */
68
+ function detectEnvironment({
69
+ homeDir,
70
+ env = process.env,
71
+ platform = process.platform,
72
+ target = null,
73
+ } = {}) {
74
+ const cible = target || resolveTargetUser({ env, platform });
75
+ const home = homeDir || cible.home || os.homedir();
76
+ const sonde = (nom) => resolveBinary(nom, { env, platform, home });
77
+ const claudeBin = sonde('claude');
78
+ const codexBin = sonde('codex');
79
+ const rtkBin = sonde('rtk');
56
80
  return {
57
- claude: fs.existsSync(path.join(homeDir, '.claude')) || commandExists('claude'),
58
- codex: fs.existsSync(path.join(homeDir, '.codex')) || commandExists('codex'),
59
- rtk: commandExists('rtk'),
60
- storedCredentialKeys: homeCreds.storedKeys({ homeDir }),
81
+ claude: fs.existsSync(path.join(home, '.claude')) || Boolean(claudeBin.path),
82
+ codex: fs.existsSync(path.join(home, '.codex')) || Boolean(codexBin.path),
83
+ rtk: Boolean(rtkBin.path),
84
+ storedCredentialKeys: homeCreds.storedKeys({ homeDir: home }),
85
+ // De quoi rendre un rapport honnete : QUI on a pris pour cible, OU on a
86
+ // cherche, et ce qu'on a trouve. Sans ca, "Claude Code introuvable" est une
87
+ // affirmation que personne ne peut verifier.
88
+ targetUser: cible,
89
+ searched: {
90
+ home,
91
+ homeSource: cible.homeSource,
92
+ claude: { path: claudeBin.path, realPath: claudeBin.realPath, dirs: claudeBin.searched },
93
+ codex: { path: codexBin.path, dirs: codexBin.searched },
94
+ rtk: { path: rtkBin.path, dirs: rtkBin.searched },
95
+ },
61
96
  };
62
97
  }
63
98
 
@@ -66,12 +101,17 @@ function detectEnvironment({ homeDir = os.homedir() } = {}) {
66
101
  * Claude Code research-preview feature behind an explicit flag; when the
67
102
  * installed CLI does not know the flag, the plain `claude` command is the
68
103
  * fallback. We only BUILD the command here — executing it is the caller's move.
104
+ *
105
+ * Les deux sondes sont injectables parce que la version par defaut LANCE
106
+ * reellement `claude --help` avec un delai de 8 s. Mesure du 2026-08-11 : cet
107
+ * appel partait a chaque runInstall, tests compris, ce qui faisait dependre la
108
+ * suite de tests de ce que porte la machine hote.
69
109
  */
70
- function claudeLaunchCommand() {
71
- if (!commandExists('claude')) return null;
110
+ function claudeLaunchCommand({ exists = commandExists, run = execSync } = {}) {
111
+ if (!exists('claude')) return null;
72
112
  let helpText = '';
73
113
  try {
74
- helpText = execSync('claude --help', { encoding: 'utf8', timeout: 8000 });
114
+ helpText = run('claude --help', { encoding: 'utf8', timeout: 8000 });
75
115
  } catch {
76
116
  return { command: 'claude', channel: false };
77
117
  }
@@ -81,6 +121,125 @@ function claudeLaunchCommand() {
81
121
  : { command: 'claude', channel: false };
82
122
  }
83
123
 
124
+ /**
125
+ * Le premier dossier qui existe en remontant depuis le chemin donne.
126
+ *
127
+ * Le dossier d'installation peut ne pas exister encore : c'est alors son parent
128
+ * qui porte l'identite a lire. Remonte jusqu'a la racine, et retombe sur le
129
+ * dossier courant si le chemin est absent ou vide.
130
+ */
131
+ function plusProcheAncetreExistant(depart) {
132
+ if (!depart) return process.cwd();
133
+ let courant = path.resolve(depart);
134
+ for (;;) {
135
+ if (fs.existsSync(courant)) return courant;
136
+ const parent = path.dirname(courant);
137
+ if (parent === courant) return process.cwd();
138
+ courant = parent;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * --owner=<nom|uid> : l'appelant designe la cible a la place de la detection.
144
+ *
145
+ * `elevated` se lit sur l'uid effectif, comme dans resolveTargetUser. Le poser
146
+ * en dur a true faisait croire a une elevation qui n'existait pas : la reprise
147
+ * partait, se faisait refuser, et le rapport parlait de montage sans
148
+ * proprietaire POSIX au lieu de privilege manquant.
149
+ */
150
+ function forcedTarget(owner) {
151
+ const details = resolveHomeFor(owner);
152
+ const eleve = typeof process.geteuid === 'function' && process.geteuid() === 0;
153
+ return { ...details, source: 'option', elevated: eleve };
154
+ }
155
+
156
+ /** Lecture tolerante d'un YAML : un fichier absent ou abime rend un objet vide. */
157
+ function readYamlSafe(p) {
158
+ try {
159
+ const brut = fs.readFileSync(p, 'utf8');
160
+ const charge = yaml.load(brut);
161
+ return charge && typeof charge === 'object' ? charge : {};
162
+ } catch {
163
+ return {};
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Le dossier d'installation designe-t-il une installation deliberee en root ?
169
+ *
170
+ * LA QUESTION PORTE SUR LE DOSSIER, PAS SUR L'ECHELON DE RESOLUTION. La version
171
+ * precedente testait `cible.source === 'root'`, un echelon qui n'est atteint que
172
+ * lorsque AUCUNE variable d'elevation n'est posee. Sous un simple sudo, la
173
+ * source vaut 'sudo' et la protection ne se declenchait pas : installer dans
174
+ * /opt avec sudo remettait l'arborescence a l'utilisateur appelant et cassait un
175
+ * montage qui fonctionnait. Le critere reel est le proprietaire du dossier vise
176
+ * et de son parent.
177
+ */
178
+ function installationEnRootAssumee(projectRoot) {
179
+ const cible = plusProcheAncetreExistant(cheminResolu(projectRoot));
180
+ const st = statSafe(cible);
181
+ if (!st || st.uid !== 0) return false;
182
+ const parent = path.dirname(cible);
183
+ if (parent === cible) return true;
184
+ const stParent = statSafe(parent);
185
+ return Boolean(stParent && stParent.uid === 0);
186
+ }
187
+
188
+ function statSafe(p) {
189
+ try {
190
+ return fs.lstatSync(p);
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Le chemin d'installation, liens symboliques resolus.
198
+ *
199
+ * POURQUOI : `--dir /srv/byan` ou /srv/byan est un lien vers /mnt/data/byan.
200
+ * Les etapes d'ecriture suivent le lien (fs.copy et consorts le font), mais la
201
+ * reprise des droits lisait le lien lui-meme : lstat d'un lien rend
202
+ * isDirectory() faux, donc le parcours ne demarrait pas et l'arborescence reelle
203
+ * gardait son proprietaire. Meme cecite pour la garde d'installation en root.
204
+ * On resout donc une fois, a l'entree, et tout le monde vise la meme chose.
205
+ */
206
+ function cheminResolu(depart) {
207
+ const ancetre = plusProcheAncetreExistant(depart);
208
+ let reel = ancetre;
209
+ try {
210
+ reel = fs.realpathSync(ancetre);
211
+ } catch {
212
+ reel = ancetre;
213
+ }
214
+ const vise = path.resolve(depart || process.cwd());
215
+ if (vise === ancetre) return reel;
216
+ return path.join(reel, path.relative(ancetre, vise));
217
+ }
218
+
219
+ /**
220
+ * Faut-il reprendre les droits, et sinon pourquoi ? Chaque abstention porte une
221
+ * raison distincte, parce qu'un message faux coute plus cher qu'un message
222
+ * absent : dire "ce systeme ne porte pas de proprietaire POSIX" a quelqu'un qui
223
+ * a juste tape un nom d'utilisateur inexistant l'envoie chercher au mauvais
224
+ * endroit.
225
+ */
226
+ function raisonReprise({ chown, cible, groupePartage, rootAssume }) {
227
+ if (chown === false) return 'ecarte par l\'option --no-chown';
228
+ if (cible.uid === null || cible.uid === undefined) {
229
+ return cible.name
230
+ ? `l'utilisateur ${cible.name} est inconnu de ce systeme : aucun identifiant a poser`
231
+ : 'ce systeme ne porte pas de proprietaire POSIX';
232
+ }
233
+ // Cette reprise existe pour SORTIR une arborescence de root. La rendre A root
234
+ // est le geste inverse. Mesure du 2026-08-11 : avec SUDO_UID=0 dans
235
+ // l'environnement, la cible ressortait a uid 0 et l'arborescence de
236
+ // l'utilisateur passait en root:root — exactement la panne a corriger.
237
+ if (cible.uid === 0) return 'la cible resolue est root : cette reprise sert a sortir une arborescence de root, pas a l\'y mettre';
238
+ if (rootAssume) return 'installation en root assumee : l\'arborescence est laissee telle quelle';
239
+ if (!cible.elevated && !groupePartage) return 'aucune elevation : les fichiers appartiennent deja a l\'utilisateur';
240
+ return null;
241
+ }
242
+
84
243
  /**
85
244
  * Run the full installation.
86
245
  *
@@ -103,6 +262,17 @@ function claudeLaunchCommand() {
103
262
  * @returns {Promise<{ok:boolean, steps:Array, verify:{passed:number,total:number,failed:string[]}, launch:{command:string,channel:boolean}|null}>}
104
263
  */
105
264
  async function runInstall(options = {}, hooks = {}) {
265
+ // L'utilisateur CIBLE se resout avant tout le reste : c'est lui qui donne le
266
+ // home a interroger et le proprietaire a reposer. --owner le force a la main.
267
+ //
268
+ // Le dossier interroge est celui ou on INSTALLE, pas le dossier courant.
269
+ // `create-byan-agent --cli --dir /opt/byan` lance depuis le home donnait
270
+ // sinon le proprietaire du home, et la regle qui protege une installation
271
+ // deliberee en root ne se declenchait pas sur la bonne arborescence.
272
+ const cible = options.targetUser
273
+ || (options.owner ? forcedTarget(options.owner)
274
+ : resolveTargetUser({ cwd: plusProcheAncetreExistant(options.projectRoot) }));
275
+
106
276
  const {
107
277
  projectRoot,
108
278
  projectName = path.basename(options.projectRoot || ''),
@@ -111,8 +281,16 @@ async function runInstall(options = {}, hooks = {}) {
111
281
  language = 'Francais',
112
282
  rtk = true,
113
283
  credentials = null,
114
- homeDir = os.homedir(),
284
+ homeDir = cible.home || os.homedir(),
285
+ chown = true,
286
+ group = null,
287
+ apiUrl = null,
115
288
  } = options;
289
+
290
+ // Une seule source pour l'URL de l'API, resolue ICI et transmise aux etapes :
291
+ // explicite (--api-url, une reponse a --ask), puis BYAN_API_URL, puis la
292
+ // valeur memorisee d'une installation precedente, puis le host de production.
293
+ const api = resolveApiUrl({ explicit: apiUrl, homeDir });
116
294
  const onStep = hooks.onStep || (() => {});
117
295
  const log = hooks.log || (() => {});
118
296
  const claudeSetup = hooks.claudeSetup || setupClaudeNative;
@@ -124,40 +302,115 @@ async function runInstall(options = {}, hooks = {}) {
124
302
  }
125
303
 
126
304
  // Detection is injectable so tests do not depend on what THIS machine has.
127
- const detected = hooks.detect ? hooks.detect() : detectEnvironment({ homeDir });
305
+ const detected = hooks.detect ? hooks.detect() : detectEnvironment({ homeDir, target: cible });
128
306
  const platforms = options.platforms || { claude: detected.claude, codex: detected.codex };
129
307
  const byanDir = path.join(projectRoot, '_byan');
130
- const results = [];
131
308
 
132
- // Step plan is computed FIRST so index/total are stable and honest.
133
- const plan = [
309
+ // Le groupe partage survit a une reinstallation : --group le pose, et une
310
+ // installation ulterieure sans l'option le relit dans la configuration au lieu
311
+ // de l'effacer. L'ancienne etape config reecrivait config.yaml integralement
312
+ // depuis un objet fixe, ce qui aurait supprime la cle a chaque passage.
313
+ const configPath = path.join(byanDir, 'bmb', 'config.yaml');
314
+ const configExistante = readYamlSafe(configPath);
315
+ const groupePartage = group || configExistante.shared_group || null;
316
+
317
+ // Calculee UNE fois : le plan et la garde de l'etape lisaient chacun leur
318
+ // propre appel, et deux calculs d'une meme decision finissent par diverger.
319
+ const raisonDroits = raisonReprise({
320
+ chown,
321
+ cible,
322
+ groupePartage,
323
+ rootAssume: installationEnRootAssumee(projectRoot),
324
+ });
325
+
326
+ // LE HOME DE LA CIBLE EST-IL REELLEMENT RESOLU ?
327
+ //
328
+ // Quand il ne l'est pas — un uid absent de /etc/passwd, cas courant sous
329
+ // pkexec ou su avec un annuaire LDAP ou SSSD — homeDir retombe sur le home du
330
+ // PROCESSUS, c'est-a-dire /root sous elevation. Toutes les etapes qui ecrivent
331
+ // hors du projet partaient alors dans /root : le jeton d'API dans
332
+ // /root/.byan/credentials.json, la configuration Codex dans /root/.codex, les
333
+ // skills globales dans /root/.claude. Le rapport rendait ok:true.
334
+ //
335
+ // On ECARTE ces etapes plutot que de les rediriger. Un fichier de secrets pose
336
+ // dans le home de root n'est pas un demi-succes : c'est un fichier au mauvais
337
+ // endroit, que la reprise des droits ne peut plus rattraper puisqu'elle passe
338
+ // apres. Constat bloquant de la revue du 2026-08-12, reproduit.
339
+ const homeResolu = Boolean(cible.home);
340
+ const homeDouteux = !homeResolu && cible.elevated;
341
+ const raisonHome = homeDouteux
342
+ ? 'home de l\'utilisateur cible non resolu sous elevation : ecrire ici poserait le fichier dans /root'
343
+ : null;
344
+
345
+ // Le gid du groupe partage est resolu AVANT la reprise du proprietaire.
346
+ // Sinon ensureOwnership reposait cible.gid partout, et ensureSharedGroup ne
347
+ // rattrapait ensuite que la racine : le groupe partage ne portait que sur un
348
+ // seul dossier, ce qui ne donne acces a rien.
349
+ let gidGroupe = null;
350
+ if (groupePartage) {
351
+ const resolu = (hooks.resolveGroupGid || resolveGroupGid)(groupePartage);
352
+ gidGroupe = typeof resolu.gid === 'number' ? resolu.gid : null;
353
+ }
354
+
355
+ // LE PLAN PORTE L'ENSEMBLE INTENDU, pas seulement ce qui va tourner.
356
+ //
357
+ // L'ancien plan omettait purement les etapes non retenues. L'omission etait
358
+ // invisible dans le rapport : `results.every(...)` interrogeait une liste qui
359
+ // ne contenait plus l'etape, donc un plan ampute par une detection fausse
360
+ // repondait quand meme "installation reussie" (mesure du 2026-08-11 sur
361
+ // install-engine.js:285). Le denominateur retrecissait avec le plan.
362
+ //
363
+ // Desormais chaque etape possible figure ici avec, le cas echeant, la RAISON
364
+ // pour laquelle elle est ecartee. Une etape sautee reste dans le rapport.
365
+ const aDesCredentials = Boolean(credentials && Object.keys(credentials).length);
366
+ const intended = [
134
367
  { id: 'detect', label: 'Detection de l\'environnement' },
135
368
  { id: 'copy-byan', label: 'Copie de la plateforme _byan/' },
136
369
  { id: 'config', label: 'Ecriture de la configuration du projet' },
370
+ { id: 'claude', label: 'Installation Claude Code (.claude + serveur MCP + .mcp.json)',
371
+ skipReason: platforms.claude ? null : 'Claude Code non detecte sur cette machine' },
372
+ { id: 'codex', label: 'Installation Codex (~/.codex)',
373
+ skipReason: raisonHome || (platforms.codex ? null : 'Codex non detecte sur cette machine') },
374
+ { id: 'credentials', label: 'Memorisation de la configuration (~/.byan/credentials.json)',
375
+ skipReason: raisonHome || (aDesCredentials ? null : 'aucune valeur a memoriser') },
376
+ { id: 'google-purge', label: 'Retrait des cles Google obsoletes',
377
+ skipReason: raisonHome },
378
+ { id: 'skills-sync', label: 'Controle des copies globales de skills',
379
+ skipReason: raisonHome || (platforms.claude ? null : 'sans Claude Code, il n\'y a pas de copie globale a controler') },
380
+ { id: 'rtk', label: 'Verification / installation de rtk',
381
+ skipReason: rtk ? null : 'ecarte par l\'option rtk=false' },
382
+ // APRES le dernier sous-processus, et c'est une contrainte, pas un detail.
383
+ // npm install (serveur MCP), git config core.hooksPath et setup-rtk creent
384
+ // des fichiers qu'aucun crochet pose au niveau de fs ne peut voir. Une
385
+ // reprise placee plus tot laisserait node_modules en root:root — le dossier
386
+ // le plus lourd et le plus penible a reparer a la main.
387
+ { id: 'ownership', label: 'Reprise du proprietaire et des droits',
388
+ skipReason: raisonDroits },
389
+ { id: 'verify', label: 'Verification finale' },
137
390
  ];
138
- if (platforms.claude) plan.push({ id: 'claude', label: 'Installation Claude Code (.claude + serveur MCP + .mcp.json)' });
139
- if (platforms.codex) plan.push({ id: 'codex', label: 'Installation Codex (~/.codex)' });
140
- if (credentials && Object.keys(credentials).length) plan.push({ id: 'credentials', label: 'Memorisation de la configuration (~/.byan/credentials.json)' });
141
- plan.push({ id: 'google-purge', label: 'Retrait des cles Google obsoletes' });
142
- if (platforms.claude) plan.push({ id: 'skills-sync', label: 'Controle des copies globales de skills' });
143
- if (rtk) plan.push({ id: 'rtk', label: 'Verification / installation de rtk' });
144
- plan.push({ id: 'verify', label: 'Verification finale' });
391
+ const active = intended.filter((s) => !s.skipReason);
392
+
393
+ // Un etat par etape INTENDUE. Les sautees naissent deja renseignees, avec leur
394
+ // raison ; les autres passent de 'pending' a 'done' ou 'failed'.
395
+ const etats = new Map(intended.map((s) => [s.id, s.skipReason
396
+ ? { id: s.id, ok: true, status: 'skipped', reason: s.skipReason, detail: '' }
397
+ : { id: s.id, ok: false, status: 'pending', reason: null, detail: '' }]));
145
398
 
146
399
  let index = 0;
147
400
  const step = async (id, label, fn, { critical = true } = {}) => {
148
401
  index += 1;
149
- onStep({ index, total: plan.length, id, label });
402
+ onStep({ index, total: active.length, id, label });
150
403
  try {
151
404
  const detail = await fn();
152
- results.push({ id, ok: true, detail: detail || '' });
405
+ etats.set(id, { id, ok: true, status: 'done', reason: null, detail: detail || '' });
153
406
  } catch (err) {
154
- results.push({ id, ok: false, detail: err.message });
407
+ etats.set(id, { id, ok: false, status: 'failed', reason: null, detail: err.message });
155
408
  if (critical) throw err;
156
409
  log(`[!] etape ${id} en echec (non bloquant) : ${err.message}`);
157
410
  }
158
411
  };
159
412
 
160
- await step('detect', plan[0].label, async () => {
413
+ await step('detect', intended[0].label, async () => {
161
414
  log(`Claude: ${detected.claude ? 'present' : 'absent'} ; Codex: ${detected.codex ? 'present' : 'absent'} ; rtk: ${detected.rtk ? 'present' : 'absent'}`);
162
415
  if (detected.storedCredentialKeys.length) {
163
416
  log(`Configuration memorisee reutilisee (${detected.storedCredentialKeys.length} cle(s), rien a re-saisir)`);
@@ -186,7 +439,10 @@ async function runInstall(options = {}, hooks = {}) {
186
439
  await step('config', 'Ecriture de la configuration du projet', async () => {
187
440
  const bmbDir = path.join(byanDir, 'bmb');
188
441
  await fs.ensureDir(bmbDir);
442
+ // FUSION, pas reecriture. Les cles que cette version connait sont refaites ;
443
+ // celles posees ailleurs (shared_group, reglages d'un autre outil) survivent.
189
444
  const configContent = {
445
+ ...configExistante,
190
446
  bmb_creations_output_folder: '{project-root}/_byan-output/bmb-creations',
191
447
  user_name: userName,
192
448
  communication_language: language,
@@ -197,8 +453,9 @@ async function runInstall(options = {}, hooks = {}) {
197
453
  install_mode: 'engine-auto',
198
454
  byan_version: readOwnVersion(),
199
455
  };
200
- await fs.writeFile(path.join(bmbDir, 'config.yaml'), yaml.dump(configContent), 'utf8');
201
- return 'config.yaml ecrit';
456
+ if (groupePartage) configContent.shared_group = groupePartage;
457
+ await fs.writeFile(configPath, yaml.dump(configContent), 'utf8');
458
+ return groupePartage ? `config.yaml ecrit (groupe partage ${groupePartage})` : 'config.yaml ecrit';
202
459
  });
203
460
 
204
461
  if (platforms.claude) {
@@ -213,14 +470,16 @@ async function runInstall(options = {}, hooks = {}) {
213
470
  });
214
471
  }
215
472
 
216
- if (platforms.codex) {
473
+ if (platforms.codex && !homeDouteux) {
217
474
  await step('codex', 'Installation Codex', async () => {
218
- await codexSetup(projectRoot, { templateDir, force: true });
475
+ // homeDir est celui de l'utilisateur CIBLE : sans lui, ~/.codex partait
476
+ // dans /root sous elevation.
477
+ await codexSetup(projectRoot, { templateDir, force: true, homeDir, apiUrl: api.url });
219
478
  return 'squelettes .codex + config';
220
479
  }, { critical: false });
221
480
  }
222
481
 
223
- if (credentials && Object.keys(credentials).length) {
482
+ if (aDesCredentials && !homeDouteux) {
224
483
  await step('credentials', 'Memorisation de la configuration', async () => {
225
484
  const merged = homeCreds.writeCredentials(credentials, { homeDir });
226
485
  return `${Object.keys(merged).length} cle(s) en memoire dans ${homeCreds.credentialsPath(homeDir)}`;
@@ -229,12 +488,14 @@ async function runInstall(options = {}, hooks = {}) {
229
488
 
230
489
  // Chemin non-interactif : la purge des cles Google obsoletes tourne aussi
231
490
  // sans passage par byan-web-integration (parite avec le flux interactif).
232
- await step('google-purge', 'Retrait des cles Google obsoletes', async () => {
233
- const r = homeCreds.purgeGoogleKeys({ homeDir });
234
- return r.purged.length ? `${r.purged.length} cle(s) Google retiree(s)` : 'aucune cle Google presente';
235
- }, { critical: false });
491
+ if (!homeDouteux) {
492
+ await step('google-purge', 'Retrait des cles Google obsoletes', async () => {
493
+ const r = homeCreds.purgeGoogleKeys({ homeDir });
494
+ return r.purged.length ? `${r.purged.length} cle(s) Google retiree(s)` : 'aucune cle Google presente';
495
+ }, { critical: false });
496
+ }
236
497
 
237
- if (platforms.claude) {
498
+ if (platforms.claude && !homeDouteux) {
238
499
  await step('skills-sync', 'Controle des copies globales de skills', async () => {
239
500
  const r = await offerGlobalSkillsSync(projectRoot, templateDir, { ask: hooks.ask || null, homeDir, log });
240
501
  return r.diverged.length === 0 ? 'copies globales fideles' : `${r.diverged.length} divergente(s), ${r.synced.length} synchronisee(s)`;
@@ -247,8 +508,52 @@ async function runInstall(options = {}, hooks = {}) {
247
508
  const rtkInstall = hooks.rtkInstall || (() => {
248
509
  // The official rtk setup script shipped with this package. Non-TTY
249
510
  // safe: it runs unattended; a failure is reported, never fatal.
511
+ //
512
+ // DESCENTE DE PRIVILEGE, pas reprise apres coup. Ce script delegue a
513
+ // brew, curl ou cargo, qui ecrivent dans le HOME (~/.cargo, les caches).
514
+ // Une reprise posterieure ne saurait pas ce que cargo a touche hors du
515
+ // projet ; il faut donc que le sous-processus tourne d'emblee sous
516
+ // l'identite de la cible, avec SON home.
250
517
  const script = path.join(__dirname, '..', 'setup-rtk.js');
251
- const r = spawnSync(process.execPath, [script], { encoding: 'utf8', timeout: 300000 });
518
+ const spawnOptions = { encoding: 'utf8', timeout: 300000 };
519
+
520
+ // LA DESCENTE EST ENTIERE OU N'A PAS LIEU.
521
+ //
522
+ // Node ignore silencieusement un gid null : le sous-processus tournait
523
+ // alors sous l'uid de la cible mais gardait le GROUPE root, et tout ce
524
+ // que cargo posait dans son cache arrivait avec un groupe qu'elle ne
525
+ // controle pas. Un depouillement a moitie fait est pire qu'un refus
526
+ // clair, parce qu'il ne se voit pas.
527
+ const descendable = cible.elevated
528
+ && process.platform !== 'win32'
529
+ && typeof cible.uid === 'number' && cible.uid !== 0
530
+ && typeof cible.gid === 'number';
531
+ if (cible.elevated && !descendable && process.platform !== 'win32') {
532
+ throw new Error('descente de privilege impossible (uid ou gid de la cible inconnu) : rtk n\'est pas installe pour eviter de poser des fichiers au nom de root');
533
+ }
534
+ if (descendable) {
535
+ spawnOptions.uid = cible.uid;
536
+ spawnOptions.gid = cible.gid;
537
+ // L'environnement transmis est NETTOYE : garder SUDO_* ferait croire
538
+ // au sous-processus qu'il tourne encore sous elevation, et le PATH de
539
+ // root ne contient pas les dossiers de la cible.
540
+ const envPropre = { ...process.env };
541
+ for (const cle of Object.keys(envPropre)) {
542
+ if (/^(SUDO_|DOAS_|PKEXEC_)/.test(cle)) delete envPropre[cle];
543
+ }
544
+ spawnOptions.env = {
545
+ ...envPropre,
546
+ HOME: homeDir,
547
+ USER: cible.name || '',
548
+ LOGNAME: cible.name || '',
549
+ PATH: [path.join(homeDir, '.local', 'bin'), path.join(homeDir, '.cargo', 'bin'), envPropre.PATH || '']
550
+ .filter(Boolean).join(path.delimiter),
551
+ };
552
+ }
553
+ const r = spawnSync(process.execPath, [script], spawnOptions);
554
+ // La cause du refus fait partie du message : un EPERM sur le spawn ne
555
+ // se lit pas dans un code de sortie absent.
556
+ if (r.error) throw new Error(`setup-rtk n'a pas pu demarrer (${r.error.code || r.error.message})`);
252
557
  if (r.status !== 0) throw new Error(`setup-rtk sortie ${r.status}: ${(r.stderr || '').slice(0, 200)}`);
253
558
  return 'rtk installe';
254
559
  });
@@ -256,36 +561,162 @@ async function runInstall(options = {}, hooks = {}) {
256
561
  }, { critical: false });
257
562
  }
258
563
 
259
- let verify = { passed: 0, total: 0, failed: [] };
564
+ // La reprise des droits ferme la marche, apres tous les sous-processus.
565
+ let ownership = null;
566
+ if (!raisonDroits) {
567
+ await step('ownership', 'Reprise du proprietaire et des droits', async () => {
568
+ const dit = [];
569
+ const reprendre = hooks.ensureOwnership || ensureOwnership;
570
+ // Le chemin est RESOLU : les etapes d'ecriture suivent les liens
571
+ // symboliques, la reprise doit viser la meme arborescence qu'elles.
572
+ const racine = cheminResolu(projectRoot);
573
+ // Le groupe partage, quand il existe, s'applique a TOUTE l'arborescence.
574
+ // Le poser sur la seule racine ne donne acces a rien : les fichiers
575
+ // gardent le groupe primaire de la cible.
576
+ const gidVise = gidGroupe !== null ? gidGroupe : cible.gid;
577
+ const reprise = reprendre(racine, { uid: cible.uid, gid: gidVise }, { groupWritable: gidGroupe !== null });
578
+ ownership = { ...reprise, target: { uid: cible.uid, gid: cible.gid, name: cible.name, source: cible.source } };
579
+ dit.push(`proprietaire ${cible.name || cible.uid} : ${reprise.outcome}${reprise.changed ? ` (${reprise.changed} entree(s))` : ''}`);
580
+ for (const err of (reprise.errors || []).slice(0, 3)) log(`[!] droits : ${err.path || ''} ${err.message || err}`);
581
+
582
+ // LES ECRITURES DANS LE HOME SUIVENT L'UTILISATEUR, PAS LE DOSSIER CIBLE.
583
+ //
584
+ // Deux classes d'ecritures, deux regles. Router ~/.byan et ~/.codex vers
585
+ // le bon home ne suffit pas : sous elevation, le fichier y arrive quand
586
+ // meme en root:root. Pour credentials.json, ecrit en 0600, ca donne un
587
+ // fichier de secrets que son proprietaire ne peut plus lire — un etat pire
588
+ // que la panne d'origine, ou il etait seulement au mauvais endroit.
589
+ const reprisesHome = [];
590
+ if (!homeResolu) {
591
+ ownership.homeSkipped = 'home de la cible non resolu : les ecritures hors projet ne sont pas reprises';
592
+ log(`[i] ${ownership.homeSkipped}`);
593
+ } else {
594
+ const dansLeHome = [
595
+ path.join(homeDir, '.byan'),
596
+ path.join(homeDir, '.codex'),
597
+ path.join(homeDir, '.claude', 'skills'),
598
+ ];
599
+ for (const cheminHome of dansLeHome) {
600
+ if (!fs.existsSync(cheminHome)) continue;
601
+ reprisesHome.push({ path: cheminHome, ...reprendre(cheminHome, { uid: cible.uid, gid: cible.gid }) });
602
+ }
603
+ if (reprisesHome.length) {
604
+ ownership.home = reprisesHome;
605
+ const repris = reprisesHome.reduce((n, r) => n + (r.changed || 0), 0);
606
+ dit.push(`home : ${reprisesHome.length} emplacement(s), ${repris} entree(s)`);
607
+ }
608
+ }
609
+
610
+ if (groupePartage) {
611
+ const g = (hooks.ensureSharedGroup || ensureSharedGroup)(cheminResolu(projectRoot), groupePartage);
612
+ ownership.group = { name: groupePartage, ...g };
613
+ dit.push(`groupe ${groupePartage} : ${g.outcome}${g.gid ? ` (gid ${g.gid})` : ''}`);
614
+ for (const w of g.warnings || []) log(`[i] ${w}`);
615
+
616
+ // Le module abaisse le umask du PROCESSUS et rend l'ancienne valeur en
617
+ // ecrivant que l'appelant doit la restaurer. Sans cette restitution, le
618
+ // reste de la commande — et le `claude` qu'elle lance — heriterait d'un
619
+ // umask 002 qu'aucun d'eux n'a demande.
620
+ if (typeof g.previousUmask === 'number' && typeof process.umask === 'function') {
621
+ try {
622
+ process.umask(g.previousUmask);
623
+ ownership.group.umaskRestored = true;
624
+ } catch (err) {
625
+ log(`[i] umask non restaure : ${err.message}`);
626
+ }
627
+ }
628
+ }
629
+
630
+ // UNE ISSUE D'ENSEMBLE. La reprise du projet, celle du home et la pose du
631
+ // groupe peuvent diverger : sans agregation, un echec sur ~/.byan restait
632
+ // invisible dans le rapport, alors que c'est le fichier de secrets.
633
+ const issues = [reprise.outcome]
634
+ .concat(reprisesHome.map((r) => r.outcome))
635
+ .concat(ownership.group ? [ownership.group.outcome] : []);
636
+ ownership.overall = issues.includes('failed') ? 'failed'
637
+ : issues.every((o) => o === 'not-applicable') ? 'not-applicable'
638
+ : 'ok';
639
+ if (ownership.overall === 'failed') {
640
+ const rates = reprisesHome.filter((r) => r.outcome === 'failed').map((r) => r.path);
641
+ if (rates.length) {
642
+ ownership.homeFailed = rates;
643
+ dit.push(`echec dans le home : ${rates.join(', ')}`);
644
+ }
645
+ throw new Error(`reprise des droits incomplete : ${dit.join(' ; ')}`);
646
+ }
647
+ return dit.join(' ; ');
648
+ }, { critical: false });
649
+ }
650
+
651
+ // Meme principe que le plan : la liste des controles porte l'ensemble intendu.
652
+ // Avant, les quatre controles Claude n'etaient meme pas ajoutes quand Claude
653
+ // etait absent, et le rapport annoncait "4/4 controles OK" sur un perimetre
654
+ // reduit en silence. Ils figurent maintenant, marques sautes avec leur raison.
655
+ let verify = { passed: 0, total: 0, failed: [], skipped: [], intendedTotal: 0 };
260
656
  await step('verify', 'Verification finale', async () => {
657
+ const raisonClaude = platforms.claude ? null : 'Claude Code non detecte sur cette machine';
261
658
  const checks = [
262
659
  { name: 'Dossier agents', p: path.join(byanDir, 'agent') },
263
660
  { name: 'Agent BYAN', p: path.join(byanDir, 'agent', 'byan', 'byan.md') },
264
661
  { name: 'Workflows', p: path.join(byanDir, 'workflow') },
265
662
  { name: 'Config', p: path.join(byanDir, 'bmb', 'config.yaml') },
663
+ { name: 'CLAUDE.md', p: path.join(projectRoot, '.claude', 'CLAUDE.md'), skipReason: raisonClaude },
664
+ { name: 'Regles Claude', p: path.join(projectRoot, '.claude', 'rules'), skipReason: raisonClaude },
665
+ { name: 'Skill byan-byan', p: path.join(projectRoot, '.claude', 'skills', 'byan-byan', 'SKILL.md'), skipReason: raisonClaude },
666
+ { name: 'Workflow auto-dispatch', p: path.join(projectRoot, '.claude', 'workflows', 'byan-auto-dispatch.js'), skipReason: raisonClaude },
266
667
  ];
267
- if (platforms.claude) {
268
- checks.push(
269
- { name: 'CLAUDE.md', p: path.join(projectRoot, '.claude', 'CLAUDE.md') },
270
- { name: 'Regles Claude', p: path.join(projectRoot, '.claude', 'rules') },
271
- { name: 'Skill byan-byan', p: path.join(projectRoot, '.claude', 'skills', 'byan-byan', 'SKILL.md') },
272
- { name: 'Workflow auto-dispatch', p: path.join(projectRoot, '.claude', 'workflows', 'byan-auto-dispatch.js') },
273
- );
274
- }
668
+ const applicables = checks.filter((c) => !c.skipReason);
669
+ const skipped = checks.filter((c) => c.skipReason).map((c) => ({ name: c.name, reason: c.skipReason }));
275
670
  const failed = [];
276
- for (const c of checks) {
671
+ for (const c of applicables) {
277
672
  if (!await fs.pathExists(c.p)) failed.push(c.name);
278
673
  }
279
- verify = { passed: checks.length - failed.length, total: checks.length, failed };
674
+ verify = {
675
+ passed: applicables.length - failed.length,
676
+ total: applicables.length,
677
+ failed,
678
+ skipped,
679
+ intendedTotal: checks.length,
680
+ };
280
681
  if (failed.length) throw new Error(`verification incomplete : ${failed.join(', ')}`);
281
- return `${verify.passed}/${verify.total} controles OK`;
682
+ const suffixe = skipped.length ? ` — ${skipped.length} controle(s) sur ${checks.length} sautes : ${skipped[0].reason}` : '';
683
+ return `${verify.passed}/${verify.total} controles OK${suffixe}`;
282
684
  });
283
685
 
686
+ // OK EST FONDE SUR LES ECHECS, PAS SUR UNE LISTE D'ETAPES CRITIQUES.
687
+ //
688
+ // L'ancien calcul ne pouvait structurellement pas basculer : les cinq etapes
689
+ // listees comme critiques levent toutes, donc runInstall n'atteignait jamais
690
+ // ce point avec l'une d'elles en echec, et les etapes laterales n'entraient
691
+ // pas dans le test. ok valait vrai par construction.
692
+ //
693
+ // La question honnete est : une etape INTENDUE a-t-elle echoue ? Trois etapes
694
+ // restent tolerantes parce qu'elles n'alterent pas ce qui est livre — rtk est
695
+ // un outil d'appoint, la purge Google et le controle des copies globales ne
696
+ // touchent pas le projet. Tout le reste compte.
697
+ const TOLERANTES = new Set(['rtk', 'google-purge', 'skills-sync']);
698
+ const steps = intended.map((s) => etats.get(s.id));
699
+ const echecs = steps.filter((r) => r.status === 'failed' && !TOLERANTES.has(r.id));
700
+ const ok = echecs.length === 0;
701
+
284
702
  return {
285
- ok: results.every((r) => r.ok || !['detect', 'copy-byan', 'config', 'claude', 'verify'].includes(r.id)),
286
- steps: results,
703
+ ok,
704
+ steps,
705
+ skipped: steps.filter((r) => r.status === 'skipped').map((r) => ({ id: r.id, reason: r.reason })),
287
706
  verify,
288
- launch: claudeLaunchCommand(),
707
+ // Qui a ete pris pour cible, et ou la detection a cherche. Sans ces deux
708
+ // champs, "Claude Code introuvable" reste une affirmation invérifiable.
709
+ targetUser: { uid: cible.uid, gid: cible.gid, name: cible.name, home: cible.home, source: cible.source, elevated: cible.elevated },
710
+ detection: detected.searched || null,
711
+ ownership,
712
+ failed: echecs.map((r) => ({ id: r.id, detail: r.detail })),
713
+ // La sonde finale interroge le MEME home que la detection. Sans ca, elle
714
+ // repartait sur le PATH et le home du processus : sous elevation, elle
715
+ // annoncait "Claude Code introuvable" apres une detection qui l'avait
716
+ // trouve deux minutes plus tot.
717
+ launch: (hooks.launchProbe || (() => claudeLaunchCommand({
718
+ exists: (c) => resolveCommandExists(c, { home: homeDir }),
719
+ })))(),
289
720
  };
290
721
  }
291
722
 
@@ -299,6 +730,7 @@ function readOwnVersion() {
299
730
 
300
731
  module.exports = {
301
732
  runInstall,
733
+ installationEnRootAssumee,
302
734
  detectEnvironment,
303
735
  claudeLaunchCommand,
304
736
  BYAN_DIRS,