create-byan-agent 2.54.0 → 2.58.1

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.
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HOME CREDENTIALS — the write side of the per-user install memory.
5
+ *
6
+ * The byan MCP server already RESOLVES its config from
7
+ * ~/.byan/credentials.json (env -> file -> default, see
8
+ * _byan/mcp/byan-mcp-server/lib/resolve-config.js). This module is the
9
+ * installer-side counterpart: read what a previous install stored, merge in
10
+ * what the user provides once, and never ask again on the next machine-wide
11
+ * install. os.homedir() covers Linux/macOS/Windows.
12
+ *
13
+ * Guarantees: reading never throws (missing/garbage file -> {}), writing is a
14
+ * merge (unknown keys already in the file are preserved), the file lands with
15
+ * mode 0600 (it holds tokens), and no value is ever logged by this module.
16
+ */
17
+
18
+ const fs = require('fs-extra');
19
+ const os = require('os');
20
+ const path = require('path');
21
+
22
+ // Same key set the MCP server resolver understands — one vocabulary, two sides.
23
+ const KNOWN_KEYS = Object.freeze([
24
+ 'BYAN_API_URL',
25
+ 'BYAN_API_TOKEN',
26
+ 'LEANTIME_API_URL',
27
+ 'LEANTIME_API_TOKEN',
28
+ 'GOOGLE_APPLICATION_CREDENTIALS',
29
+ 'GDOC_TEMPLATE_ID',
30
+ 'GDOC_LOGO_PNG_URL',
31
+ ]);
32
+
33
+ function credentialsPath(homeDir = os.homedir()) {
34
+ return path.join(homeDir, '.byan', 'credentials.json');
35
+ }
36
+
37
+ /** Read the stored credentials. Missing or invalid file -> {}. */
38
+ function readCredentials({ homeDir = os.homedir() } = {}) {
39
+ try {
40
+ const parsed = fs.readJSONSync(credentialsPath(homeDir));
41
+ return parsed && typeof parsed === 'object' ? parsed : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Merge-write: existing keys are kept unless the patch overrides them with a
49
+ * non-empty value. Empty/undefined patch values never erase a stored secret.
50
+ * Returns the merged object actually written.
51
+ */
52
+ function writeCredentials(patch, { homeDir = os.homedir() } = {}) {
53
+ const current = readCredentials({ homeDir });
54
+ const merged = { ...current };
55
+ for (const [key, value] of Object.entries(patch || {})) {
56
+ // Only known keys are written from a patch. The patch can reach here from
57
+ // an HTTP body (the web wizard) ; an unknown key would be junk in the
58
+ // secret store. Keys already present in the file are preserved (the spread
59
+ // above), unknown ones just cannot be ADDED by a patch.
60
+ if (!KNOWN_KEYS.includes(key)) continue;
61
+ if (typeof value === 'string' && value.trim() !== '') merged[key] = value;
62
+ }
63
+ const file = credentialsPath(homeDir);
64
+ fs.ensureDirSync(path.dirname(file));
65
+ try {
66
+ fs.chmodSync(path.dirname(file), 0o700); // the dir holds only secrets
67
+ } catch {
68
+ // best-effort (Windows ACLs) — the 0600 file mode below is the real guard
69
+ }
70
+ fs.writeJSONSync(file, merged, { spaces: 2, mode: 0o600 });
71
+ try {
72
+ fs.chmodSync(file, 0o600); // writeJSON mode applies on create only; enforce on rewrite
73
+ } catch {
74
+ // chmod best-effort (e.g. Windows ACLs) — the write itself succeeded
75
+ }
76
+ return merged;
77
+ }
78
+
79
+ /** The keys among KNOWN_KEYS that are present and non-empty in the store. */
80
+ function storedKeys({ homeDir = os.homedir() } = {}) {
81
+ const current = readCredentials({ homeDir });
82
+ return KNOWN_KEYS.filter((k) => typeof current[k] === 'string' && current[k].trim() !== '');
83
+ }
84
+
85
+ module.exports = {
86
+ KNOWN_KEYS,
87
+ credentialsPath,
88
+ readCredentials,
89
+ writeCredentials,
90
+ storedKeys,
91
+ };
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * INSTALL ENGINE — the single real installation engine, one engine for two
5
+ * faces (terminal CLI and local web wizard).
6
+ *
7
+ * Why this module exists (field record, 2026-07-21): the whole install logic
8
+ * lived inline in a 2103-line interactive bin, so the web installer could not
9
+ * call it and shipped a SIMULATION instead (9 broadcast steps around sleep()
10
+ * calls). Three install/update gaps were fixed in three days because the logic
11
+ * had no single owner. This engine is that owner.
12
+ *
13
+ * Contract:
14
+ * - options in, real actions out. No prompt in here, ever: consent-needing
15
+ * choices arrive AS options (the faces collect them).
16
+ * - progress is honest by construction: onStep fires once per step and each
17
+ * step body performs the real action it names. No step, no broadcast.
18
+ * - side steps that must not kill an install (rtk, skills sync, credentials)
19
+ * record ok:false instead of throwing; core steps (copy, config) throw.
20
+ *
21
+ * Defaults are the zero-question install: Claude + Codex when detected, every
22
+ * agent, creator soul, rtk installed when missing.
23
+ */
24
+
25
+ const fs = require('fs-extra');
26
+ const os = require('os');
27
+ const path = require('path');
28
+ const { execSync, spawnSync } = require('child_process');
29
+ const yaml = require('js-yaml');
30
+
31
+ const { setupClaudeNative } = require('./claude-native-setup');
32
+ const { setupCodexNative } = require('./codex-native-setup');
33
+ const { offerGlobalSkillsSync } = require('./global-skills-sync');
34
+ const homeCreds = require('./home-credentials');
35
+
36
+ // Gen3 by-type dirs copied from templates/_byan (same list as the legacy bin).
37
+ const BYAN_DIRS = ['agent', 'workflow', 'connaissance', 'command', 'worker', 'memoire',
38
+ 'core', 'bmb', 'bmm', 'tea', 'cis', '_config', 'data'];
39
+
40
+ function defaultTemplateDir() {
41
+ const p = path.join(__dirname, '..', 'templates');
42
+ return fs.existsSync(p) ? p : null;
43
+ }
44
+
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
+ }
52
+ }
53
+
54
+ /** Detection: platforms on this machine + stored credentials. Facts only. */
55
+ function detectEnvironment({ homeDir = os.homedir() } = {}) {
56
+ 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 }),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The Claude Code launch command for the end of install. The byan-channel is a
66
+ * Claude Code research-preview feature behind an explicit flag; when the
67
+ * installed CLI does not know the flag, the plain `claude` command is the
68
+ * fallback. We only BUILD the command here — executing it is the caller's move.
69
+ */
70
+ function claudeLaunchCommand() {
71
+ if (!commandExists('claude')) return null;
72
+ let helpText = '';
73
+ try {
74
+ helpText = execSync('claude --help', { encoding: 'utf8', timeout: 8000 });
75
+ } catch {
76
+ return { command: 'claude', channel: false };
77
+ }
78
+ const supportsChannel = helpText.includes('dangerously-load-development-channels');
79
+ return supportsChannel
80
+ ? { command: 'claude --dangerously-load-development-channels server:byan-channel', channel: true }
81
+ : { command: 'claude', channel: false };
82
+ }
83
+
84
+ /**
85
+ * Run the full installation.
86
+ *
87
+ * @param {object} options
88
+ * @param {string} options.projectRoot target directory (created if absent)
89
+ * @param {string} [options.projectName] defaults to basename(projectRoot)
90
+ * @param {object} [options.platforms] {claude, codex} — defaults to detection
91
+ * @param {string} [options.templateDir]
92
+ * @param {string} [options.userName='Developer']
93
+ * @param {string} [options.language='Francais']
94
+ * @param {boolean} [options.rtk=true] install rtk when missing
95
+ * @param {object} [options.credentials] values to persist in ~/.byan/credentials.json
96
+ * @param {string} [options.homeDir]
97
+ * @param {object} [hooks]
98
+ * @param {(step:{index:number,total:number,id:string,label:string})=>void} [hooks.onStep]
99
+ * @param {(line:string)=>void} [hooks.log]
100
+ * @param {(q:string)=>Promise<boolean>} [hooks.ask] consent collector for the
101
+ * global-skills offer (absent -> notice only, nothing written in home)
102
+ * @param {Function} [hooks.claudeSetup] [hooks.codexSetup] [hooks.rtkInstall] test injection
103
+ * @returns {Promise<{ok:boolean, steps:Array, verify:{passed:number,total:number,failed:string[]}, launch:{command:string,channel:boolean}|null}>}
104
+ */
105
+ async function runInstall(options = {}, hooks = {}) {
106
+ const {
107
+ projectRoot,
108
+ projectName = path.basename(options.projectRoot || ''),
109
+ templateDir = defaultTemplateDir(),
110
+ userName = 'Developer',
111
+ language = 'Francais',
112
+ rtk = true,
113
+ credentials = null,
114
+ homeDir = os.homedir(),
115
+ } = options;
116
+ const onStep = hooks.onStep || (() => {});
117
+ const log = hooks.log || (() => {});
118
+ const claudeSetup = hooks.claudeSetup || setupClaudeNative;
119
+ const codexSetup = hooks.codexSetup || setupCodexNative;
120
+
121
+ if (!projectRoot) throw new Error('projectRoot est requis');
122
+ if (!templateDir || !fs.existsSync(path.join(templateDir, '_byan'))) {
123
+ throw new Error(`templates introuvables (${templateDir || 'aucun chemin'})`);
124
+ }
125
+
126
+ // Detection is injectable so tests do not depend on what THIS machine has.
127
+ const detected = hooks.detect ? hooks.detect() : detectEnvironment({ homeDir });
128
+ const platforms = options.platforms || { claude: detected.claude, codex: detected.codex };
129
+ const byanDir = path.join(projectRoot, '_byan');
130
+ const results = [];
131
+
132
+ // Step plan is computed FIRST so index/total are stable and honest.
133
+ const plan = [
134
+ { id: 'detect', label: 'Detection de l\'environnement' },
135
+ { id: 'copy-byan', label: 'Copie de la plateforme _byan/' },
136
+ { id: 'config', label: 'Ecriture de la configuration du projet' },
137
+ ];
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
+ if (platforms.claude) plan.push({ id: 'skills-sync', label: 'Controle des copies globales de skills' });
142
+ if (rtk) plan.push({ id: 'rtk', label: 'Verification / installation de rtk' });
143
+ plan.push({ id: 'verify', label: 'Verification finale' });
144
+
145
+ let index = 0;
146
+ const step = async (id, label, fn, { critical = true } = {}) => {
147
+ index += 1;
148
+ onStep({ index, total: plan.length, id, label });
149
+ try {
150
+ const detail = await fn();
151
+ results.push({ id, ok: true, detail: detail || '' });
152
+ } catch (err) {
153
+ results.push({ id, ok: false, detail: err.message });
154
+ if (critical) throw err;
155
+ log(`[!] etape ${id} en echec (non bloquant) : ${err.message}`);
156
+ }
157
+ };
158
+
159
+ await step('detect', plan[0].label, async () => {
160
+ log(`Claude: ${detected.claude ? 'present' : 'absent'} ; Codex: ${detected.codex ? 'present' : 'absent'} ; rtk: ${detected.rtk ? 'present' : 'absent'}`);
161
+ if (detected.storedCredentialKeys.length) {
162
+ log(`Configuration memorisee reutilisee (${detected.storedCredentialKeys.length} cle(s), rien a re-saisir)`);
163
+ }
164
+ return `claude=${detected.claude} codex=${detected.codex} rtk=${detected.rtk}`;
165
+ });
166
+
167
+ await step('copy-byan', 'Copie de la plateforme _byan/', async () => {
168
+ const src = path.join(templateDir, '_byan');
169
+ await fs.ensureDir(byanDir);
170
+ let copied = 0;
171
+ for (const dir of BYAN_DIRS) {
172
+ const s = path.join(src, dir);
173
+ if (await fs.pathExists(s)) {
174
+ await fs.copy(s, path.join(byanDir, dir), { overwrite: true });
175
+ copied += 1;
176
+ }
177
+ }
178
+ for (const file of await fs.readdir(src)) {
179
+ const full = path.join(src, file);
180
+ if ((await fs.stat(full)).isFile()) await fs.copy(full, path.join(byanDir, file), { overwrite: true });
181
+ }
182
+ return `${copied} dossiers copies`;
183
+ });
184
+
185
+ await step('config', 'Ecriture de la configuration du projet', async () => {
186
+ const bmbDir = path.join(byanDir, 'bmb');
187
+ await fs.ensureDir(bmbDir);
188
+ const configContent = {
189
+ bmb_creations_output_folder: '{project-root}/_byan-output/bmb-creations',
190
+ user_name: userName,
191
+ communication_language: language,
192
+ document_output_language: language,
193
+ output_folder: '{project-root}/_byan-output',
194
+ project_name: projectName,
195
+ platform: Object.entries(platforms).filter(([, v]) => v).map(([k]) => k).join(',') || 'none',
196
+ install_mode: 'engine-auto',
197
+ byan_version: readOwnVersion(),
198
+ };
199
+ await fs.writeFile(path.join(bmbDir, 'config.yaml'), yaml.dump(configContent), 'utf8');
200
+ return 'config.yaml ecrit';
201
+ });
202
+
203
+ if (platforms.claude) {
204
+ await step('claude', 'Installation Claude Code', async () => {
205
+ const claudeSource = path.join(templateDir, '.claude');
206
+ if (await fs.pathExists(claudeSource)) {
207
+ await fs.ensureDir(path.join(projectRoot, '.claude', 'rules'));
208
+ await fs.copy(claudeSource, path.join(projectRoot, '.claude'), { overwrite: true });
209
+ }
210
+ await claudeSetup(projectRoot);
211
+ return '.claude copie + configuration native (hooks, skills, .mcp.json, dependances MCP)';
212
+ });
213
+ }
214
+
215
+ if (platforms.codex) {
216
+ await step('codex', 'Installation Codex', async () => {
217
+ await codexSetup(projectRoot, { templateDir, force: true });
218
+ return 'squelettes .codex + config';
219
+ }, { critical: false });
220
+ }
221
+
222
+ if (credentials && Object.keys(credentials).length) {
223
+ await step('credentials', 'Memorisation de la configuration', async () => {
224
+ const merged = homeCreds.writeCredentials(credentials, { homeDir });
225
+ return `${Object.keys(merged).length} cle(s) en memoire dans ${homeCreds.credentialsPath(homeDir)}`;
226
+ }, { critical: false });
227
+ }
228
+
229
+ if (platforms.claude) {
230
+ await step('skills-sync', 'Controle des copies globales de skills', async () => {
231
+ const r = await offerGlobalSkillsSync(projectRoot, templateDir, { ask: hooks.ask || null, homeDir, log });
232
+ return r.diverged.length === 0 ? 'copies globales fideles' : `${r.diverged.length} divergente(s), ${r.synced.length} synchronisee(s)`;
233
+ }, { critical: false });
234
+ }
235
+
236
+ if (rtk) {
237
+ await step('rtk', 'Verification / installation de rtk', async () => {
238
+ if (detected.rtk) return 'rtk deja present';
239
+ const rtkInstall = hooks.rtkInstall || (() => {
240
+ // The official rtk setup script shipped with this package. Non-TTY
241
+ // safe: it runs unattended; a failure is reported, never fatal.
242
+ const script = path.join(__dirname, '..', 'setup-rtk.js');
243
+ const r = spawnSync(process.execPath, [script], { encoding: 'utf8', timeout: 300000 });
244
+ if (r.status !== 0) throw new Error(`setup-rtk sortie ${r.status}: ${(r.stderr || '').slice(0, 200)}`);
245
+ return 'rtk installe';
246
+ });
247
+ return rtkInstall();
248
+ }, { critical: false });
249
+ }
250
+
251
+ let verify = { passed: 0, total: 0, failed: [] };
252
+ await step('verify', 'Verification finale', async () => {
253
+ const checks = [
254
+ { name: 'Dossier agents', p: path.join(byanDir, 'agent') },
255
+ { name: 'Agent BYAN', p: path.join(byanDir, 'agent', 'byan', 'byan.md') },
256
+ { name: 'Workflows', p: path.join(byanDir, 'workflow') },
257
+ { name: 'Config', p: path.join(byanDir, 'bmb', 'config.yaml') },
258
+ ];
259
+ if (platforms.claude) {
260
+ checks.push(
261
+ { name: 'CLAUDE.md', p: path.join(projectRoot, '.claude', 'CLAUDE.md') },
262
+ { name: 'Regles Claude', p: path.join(projectRoot, '.claude', 'rules') },
263
+ { name: 'Skill byan-byan', p: path.join(projectRoot, '.claude', 'skills', 'byan-byan', 'SKILL.md') },
264
+ { name: 'Workflow auto-dispatch', p: path.join(projectRoot, '.claude', 'workflows', 'byan-auto-dispatch.js') },
265
+ );
266
+ }
267
+ const failed = [];
268
+ for (const c of checks) {
269
+ if (!await fs.pathExists(c.p)) failed.push(c.name);
270
+ }
271
+ verify = { passed: checks.length - failed.length, total: checks.length, failed };
272
+ if (failed.length) throw new Error(`verification incomplete : ${failed.join(', ')}`);
273
+ return `${verify.passed}/${verify.total} controles OK`;
274
+ });
275
+
276
+ return {
277
+ ok: results.every((r) => r.ok || !['detect', 'copy-byan', 'config', 'claude', 'verify'].includes(r.id)),
278
+ steps: results,
279
+ verify,
280
+ launch: claudeLaunchCommand(),
281
+ };
282
+ }
283
+
284
+ function readOwnVersion() {
285
+ try {
286
+ return fs.readJSONSync(path.join(__dirname, '..', '..', 'package.json')).version;
287
+ } catch {
288
+ return 'unknown';
289
+ }
290
+ }
291
+
292
+ module.exports = {
293
+ runInstall,
294
+ detectEnvironment,
295
+ claudeLaunchCommand,
296
+ BYAN_DIRS,
297
+ };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ // Since 2.57.0 the default install experience is the graphical web wizard
4
+ // (create-byan-agent, which starts the local server and opens the browser).
5
+ // Two terminal escape hatches stay reachable by flag:
6
+ // --cli -> the zero-question automatic terminal install (installAuto)
7
+ // --legacy -> the original question-by-question interview (install)
8
+ // --legacy wins over --cli when both are passed: it is the more explicit,
9
+ // older path, so an operator who typed it means it.
10
+ function chooseInstallMode(options = {}) {
11
+ if (options && options.legacy) return 'legacy';
12
+ if (options && options.cli) return 'cli';
13
+ return 'web';
14
+ }
15
+
16
+ // Global-skills consent for the AUTOMATIC terminal install (installAuto).
17
+ //
18
+ // Field bug (2026-07-21): the auto install froze at "[6/8] Controle des copies
19
+ // globales de skills". Root cause — the engine's skills-sync step was handed an
20
+ // inquirer prompt as its `ask`, but installAuto keeps an ora progress spinner
21
+ // spinning during every step. The prompt was drawn UNDER the live spinner:
22
+ // invisible, waiting on input the user could not see -> apparent infinite load.
23
+ //
24
+ // The fix is a rule, not spinner choreography: the automatic install NEVER opens
25
+ // an interactive prompt. Two non-interactive behaviours only:
26
+ // - default -> null : the engine notices the divergence and prints the
27
+ // exact sync command, writes nothing in ~/.claude, does
28
+ // not block (keeps the "no silent home write" red line).
29
+ // - --sync-skills -> an unattended auto-approve (resolves true, no inquirer):
30
+ // the flag itself is the explicit consent, so the engine
31
+ // syncs the stale global copies without a prompt.
32
+ function skillsSyncConsent(options = {}) {
33
+ if (options && options.syncSkills) return async () => true;
34
+ return null;
35
+ }
36
+
37
+ module.exports = { chooseInstallMode, skillsSyncConsent };
@@ -12,6 +12,7 @@ const path = require('path');
12
12
  const { compareVersions, getLatestVersion } = require('../utils/version-compare');
13
13
  const { diffFiles } = require('../utils/file-differ');
14
14
  const { readManifest, writeManifest, generateManifest, detectUserModifications } = require('../utils/manifest');
15
+ const { setupClaudeNative } = require('../claude-native-setup');
15
16
  const backuper = require('./backuper');
16
17
  const logger = require('../utils/logger');
17
18
 
@@ -137,11 +138,15 @@ async function update(projectRoot, options = {}) {
137
138
  backupPath: null,
138
139
  filesUpdated: 0,
139
140
  filesAdded: 0,
140
- filesSkipped: 0
141
+ filesSkipped: 0,
142
+ claudeRefreshed: false,
143
+ claudeBackupPath: null
141
144
  };
142
145
  }
143
146
 
144
- const templateDir = getTemplateDir();
147
+ // templateDir is injectable so the flow is testable without touching the
148
+ // packaged templates (and so a caller can point at a staged template set).
149
+ const templateDir = options.templateDir || getTemplateDir();
145
150
  if (!templateDir) {
146
151
  throw new Error('Template directory not found. Is the package installed correctly?');
147
152
  }
@@ -170,6 +175,10 @@ async function update(projectRoot, options = {}) {
170
175
  let filesUpdated = 0;
171
176
  let filesAdded = 0;
172
177
  let filesSkipped = 0;
178
+ let claudeRefreshed = false;
179
+ let claudeBackupPath = null;
180
+ let globalSkillsDiverged = [];
181
+ const installedClaude = path.join(projectRoot, '.claude');
173
182
 
174
183
  try {
175
184
  // Apply updated files (skip user-modified unless forced)
@@ -195,6 +204,38 @@ async function update(projectRoot, options = {}) {
195
204
  filesAdded++;
196
205
  }
197
206
 
207
+ // --- .claude refresh -------------------------------------------------
208
+ // The update must deploy what the full install deploys. Historically it
209
+ // only diffed _byan/, so everything living under .claude/ (skills with the
210
+ // auto-dispatch rail, native workflows, hooks, settings, and the .mcp.json
211
+ // regenerated by the native setup) was NEVER refreshed by `update` — an
212
+ // up-to-date _byan/ next to a months-old .claude/. Same contract as the
213
+ // full install (copy with overwrite), with a dedicated backup first. The
214
+ // backup uses its own `.claude.backup-` prefix : reusing the `_byan.backup-`
215
+ // prefix would make pruneBackups/rollback treat it as a _byan backup.
216
+ const templateClaude = path.join(templateDir, '.claude');
217
+ if (await fs.pathExists(templateClaude) && await fs.pathExists(installedClaude)) {
218
+ claudeBackupPath = path.join(projectRoot, `.claude.backup-${Date.now()}`);
219
+ await fs.copy(installedClaude, claudeBackupPath);
220
+ await fs.copy(templateClaude, installedClaude, { overwrite: true });
221
+ // Same native pass as the full install : regenerates .mcp.json, installs
222
+ // the MCP server dependencies, wires git hooks. Injectable for tests.
223
+ const nativeSetup = options.nativeSetup || setupClaudeNative;
224
+ await nativeSetup(projectRoot);
225
+ claudeRefreshed = true;
226
+ logger.debug(`.claude refreshed from templates (backup: ${claudeBackupPath})`);
227
+
228
+ // Stale global copies check (notice-only on the update path : no TTY
229
+ // conversation here, and the home is never written without consent).
230
+ // The CLI surfaces the diverged names + the exact sync command.
231
+ try {
232
+ const { checkGlobalSkills } = require('../global-skills-sync');
233
+ globalSkillsDiverged = (await checkGlobalSkills(projectRoot, templateDir)).diverged;
234
+ } catch {
235
+ // the check must never fail an update
236
+ }
237
+ }
238
+
198
239
  // Regenerate manifest with new state
199
240
  const newManifest = await generateManifest(installedByan, latest);
200
241
  await writeManifest(projectRoot, newManifest);
@@ -206,6 +247,11 @@ async function update(projectRoot, options = {}) {
206
247
  // Rollback on failure
207
248
  logger.error(`Update failed, restoring backup: ${error.message}`);
208
249
  await backuper.restore(backupResult.backupPath, installedByan);
250
+ if (claudeBackupPath && await fs.pathExists(claudeBackupPath)) {
251
+ await fs.remove(installedClaude);
252
+ await fs.copy(claudeBackupPath, installedClaude);
253
+ logger.error('.claude restored from its pre-update backup');
254
+ }
209
255
  throw error;
210
256
  }
211
257
 
@@ -216,7 +262,10 @@ async function update(projectRoot, options = {}) {
216
262
  backupPath: backupResult.backupPath,
217
263
  filesUpdated,
218
264
  filesAdded,
219
- filesSkipped
265
+ filesSkipped,
266
+ claudeRefreshed,
267
+ claudeBackupPath,
268
+ globalSkillsDiverged
220
269
  };
221
270
  }
222
271
 
@@ -53,6 +53,18 @@ function json(res, statusCode, data) {
53
53
  res.end(JSON.stringify(data));
54
54
  }
55
55
 
56
+ // Confine an HTTP-supplied install/update directory to the server's launch
57
+ // directory. A relative value resolves inside base; base itself, or any path
58
+ // that escapes it (../, absolute elsewhere), collapses back to base. The
59
+ // installer targets the project the wizard was launched in — nothing else.
60
+ function confineToBase(candidate, base) {
61
+ if (!candidate || typeof candidate !== 'string') return base;
62
+ const resolved = path.resolve(base, candidate);
63
+ const rel = path.relative(base, resolved);
64
+ const escapes = rel === '' ? false : (rel.startsWith('..') || path.isAbsolute(rel));
65
+ return escapes ? base : resolved;
66
+ }
67
+
56
68
  function readPackageVersion() {
57
69
  try {
58
70
  const pkg = JSON.parse(
@@ -141,39 +153,54 @@ const routes = {
141
153
  });
142
154
  },
143
155
 
156
+ // Runs the REAL install engine (install/lib/install-engine.js) — the same
157
+ // one the CLI uses. Every progress broadcast maps 1:1 to a real action; the
158
+ // previous handler here simulated 9 steps around sleep() calls and only
159
+ // created directories, which is exactly the dishonest-progress trap the
160
+ // engine forbids. `server.installEngine` is injectable for tests.
144
161
  'POST install': async (req, res, server) => {
145
162
  json(res, 200, { status: 'started' });
146
163
 
147
164
  const config = req.body || {};
148
- const projectRoot = server.projectRoot;
149
- const steps = [
150
- 'Detecting environment',
151
- 'Validating prerequisites',
152
- 'Creating directory structure',
153
- 'Installing core module',
154
- 'Installing selected modules',
155
- 'Configuring platforms',
156
- 'Generating agent stubs',
157
- 'Writing configuration',
158
- 'Validating installation'
159
- ];
165
+ // projectDir comes from the HTTP body — it must NOT be an arbitrary path
166
+ // sink (the engine writes files and spawns processes). Confine it to the
167
+ // directory the server was launched in: the wizard installs "here", the
168
+ // exact intent of `create-byan-agent web` run in a project.
169
+ const projectRoot = confineToBase(config.projectDir, server.projectRoot);
160
170
 
161
171
  try {
162
- for (let i = 0; i < steps.length; i++) {
163
- server.broadcastProgress(i + 1, steps.length, steps[i]);
164
- server.broadcastLog('info', steps[i] + '...');
165
- await sleep(300);
166
- }
167
-
168
- ensureDirectoryStructure(projectRoot);
169
- writeBaseConfig(projectRoot, config);
172
+ const installEngine = server.installEngine || require('../../lib/install-engine');
173
+ // The wizard form sends platforms as an array (['claude','codex']); the
174
+ // engine takes an object. An empty selection means "let the engine detect".
175
+ const platformList = Array.isArray(config.platforms) ? config.platforms : null;
176
+ const platforms = platformList && platformList.length
177
+ ? { claude: platformList.includes('claude'), codex: platformList.includes('codex') }
178
+ : (config.platforms && !Array.isArray(config.platforms) ? config.platforms : undefined);
179
+ const result = await installEngine.runInstall({
180
+ projectRoot,
181
+ projectName: config.projectName || undefined,
182
+ platforms,
183
+ userName: config.userName || undefined,
184
+ language: config.language || undefined,
185
+ rtk: config.rtk !== false,
186
+ credentials: config.credentials || null,
187
+ }, {
188
+ onStep: ({ index, total, label }) => {
189
+ server.broadcastProgress(index, total, label);
190
+ server.broadcastLog('info', label);
191
+ },
192
+ log: (line) => server.broadcastLog('info', line),
193
+ // No consent prompt over HTTP: the global-skills offer degrades to a
194
+ // notice with the exact command (same non-interactive contract as CI).
195
+ ask: null,
196
+ });
170
197
 
171
- server.broadcastProgress(steps.length, steps.length, 'Complete');
172
- server.broadcastComplete(true, {
173
- message: 'BYAN installed successfully',
198
+ server.broadcastComplete(result.ok, {
199
+ message: result.ok ? 'Installation BYAN terminee' : 'Installation terminee avec des etapes en echec',
174
200
  projectRoot,
175
- mode: config.mode || 'auto',
176
- platforms: config.platforms || detectPlatforms(projectRoot)
201
+ verify: result.verify,
202
+ steps: result.steps,
203
+ launch: result.launch,
177
204
  });
178
205
  } catch (err) {
179
206
  server.broadcastLog('error', err.message);
@@ -181,34 +208,29 @@ const routes = {
181
208
  }
182
209
  },
183
210
 
211
+ // Runs the REAL updater (yanstaller.update, which since 2.54.1 refreshes
212
+ // _byan AND .claude + native setup). The previous handler simulated 6 steps,
213
+ // called update with a wrong argument, swallowed its failure and broadcast
214
+ // success regardless — the result is now the truth, including failures.
184
215
  'POST update': async (req, res, server) => {
185
216
  json(res, 200, { status: 'started' });
186
-
187
- const steps = [
188
- 'Checking current version',
189
- 'Creating backup',
190
- 'Downloading update',
191
- 'Applying changes',
192
- 'Merging configuration',
193
- 'Validating update'
194
- ];
217
+ const projectRoot = confineToBase(req.body && req.body.projectDir, server.projectRoot);
195
218
 
196
219
  try {
197
- for (let i = 0; i < steps.length; i++) {
198
- server.broadcastProgress(i + 1, steps.length, steps[i]);
199
- server.broadcastLog('info', steps[i] + '...');
200
- await sleep(400);
201
- }
202
-
203
- if (yanstaller && yanstaller.update) {
204
- try {
205
- await yanstaller.update('latest');
206
- } catch (err) {
207
- server.broadcastLog('warn', `Update module: ${err.message}`);
208
- }
220
+ if (!yanstaller || !yanstaller.update) {
221
+ throw new Error('Module de mise a jour indisponible dans ce paquet');
209
222
  }
210
-
211
- server.broadcastComplete(true, { message: 'BYAN updated successfully' });
223
+ server.broadcastProgress(1, 2, 'Mise a jour en cours (_byan + .claude + configuration native)');
224
+ const result = await (server.updater || yanstaller.update)(projectRoot, {});
225
+ server.broadcastProgress(2, 2, 'Terminee');
226
+ server.broadcastComplete(true, {
227
+ message: `Mise a jour ${result.previousVersion} -> ${result.newVersion}`,
228
+ filesUpdated: result.filesUpdated,
229
+ filesAdded: result.filesAdded,
230
+ filesSkipped: result.filesSkipped,
231
+ claudeRefreshed: result.claudeRefreshed === true,
232
+ globalSkillsDiverged: result.globalSkillsDiverged || [],
233
+ });
212
234
  } catch (err) {
213
235
  server.broadcastLog('error', err.message);
214
236
  server.broadcastComplete(false, { message: err.message });