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.
- package/CHANGELOG.md +142 -0
- package/README.md +27 -11
- package/install/bin/create-byan-agent-v2.js +155 -14
- package/install/lib/desktop-app.js +58 -0
- package/install/lib/global-skills-sync.js +118 -0
- package/install/lib/home-credentials.js +91 -0
- package/install/lib/install-engine.js +297 -0
- package/install/lib/install-mode.js +37 -0
- package/install/lib/yanstaller/updater.js +52 -3
- package/install/src/webui/api.js +70 -48
- package/install/src/webui/public/app.js +15 -1
- package/install/src/webui/server.js +6 -1
- package/install/templates/.claude/hooks/lib/plain-language.js +19 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +8 -2
- package/install/templates/docs/intelligent-dispatch.md +11 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
package/install/src/webui/api.js
CHANGED
|
@@ -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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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.
|
|
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 });
|