create-byan-agent 2.15.0 → 2.16.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/install/GUIDE-INSTALLATION-BYAN-SIMPLE.md +40 -0
- package/install/bin/create-byan-agent-v2.js +9 -0
- package/install/lib/mcp-extensions/gdrive.js +256 -0
- package/install/lib/mcp-extensions/index.js +147 -0
- package/install/package.json +1 -1
- package/install/packages/platform-config/lib/mcp-config.js +107 -8
- package/package.json +1 -1
- package/update-byan-agent/__tests__/migrate-mcp-config.test.js +74 -24
- package/update-byan-agent/lib/migrate-mcp-config.js +33 -27
|
@@ -416,6 +416,46 @@ Claude reconnaît l'agent BYAN et vous pouvez interagir avec lui.
|
|
|
416
416
|
|
|
417
417
|
---
|
|
418
418
|
|
|
419
|
+
### 🔌 Extensions MCP optionnelles (depuis 2.16.0)
|
|
420
|
+
|
|
421
|
+
Pendant l'installation, BYAN propose d'activer des MCP servers tiers en plus du serveur byan natif.
|
|
422
|
+
|
|
423
|
+
**Disponibles aujourd'hui :**
|
|
424
|
+
|
|
425
|
+
| Extension | Description | Setup |
|
|
426
|
+
|-----------|-------------|-------|
|
|
427
|
+
| `gdrive` | Google Workspace : Docs, Sheets, Slides, Drive, Gmail, Calendar (95+ tools via `google-workspace-mcp`) | Interactif — guide Google Cloud + OAuth flow |
|
|
428
|
+
|
|
429
|
+
**Sécurité — où vivent les credentials :**
|
|
430
|
+
|
|
431
|
+
- Aucune credential n'est écrite dans le repo BYAN ni dans `.mcp.json` (source : [CLAIM L1] code de `install/packages/platform-config/lib/mcp-config.js`, fonction `assertNoSecretInEntry`)
|
|
432
|
+
- `~/.google-mcp/credentials.json` (perm 600) — Client OAuth Google
|
|
433
|
+
- `~/.google-mcp/tokens/<account>.json` — Tokens d'accès persistés par compte
|
|
434
|
+
- `BYAN_API_TOKEN` — vit uniquement dans `.env` (gitignored) + `.claude/settings.local.json` (gitignored)
|
|
435
|
+
|
|
436
|
+
**Si tu skip l'extension à l'install et veux l'activer plus tard**, relance `npx create-byan-agent` ou ajoute manuellement l'entry suivante à `.mcp.json` :
|
|
437
|
+
|
|
438
|
+
```json
|
|
439
|
+
{
|
|
440
|
+
"mcpServers": {
|
|
441
|
+
"gdrive": {
|
|
442
|
+
"command": "npx",
|
|
443
|
+
"args": ["-y", "google-workspace-mcp", "serve"]
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Puis exécute le setup interactif du package :
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
npx -y google-workspace-mcp setup
|
|
453
|
+
npx -y google-workspace-mcp accounts add default
|
|
454
|
+
npx -y google-workspace-mcp status # vérifier que tout est OK
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
419
459
|
## 5. Cas d'Usage Typiques
|
|
420
460
|
|
|
421
461
|
### 🎯 Cas 1 : Créer un Nouvel Agent
|
|
@@ -17,6 +17,7 @@ const { launchPhase2Chat, generateDefaultConfig } = require('../lib/phase2-chat'
|
|
|
17
17
|
const { setupByanWebIntegration, validateByanWebReachability } = require('../lib/byan-web-integration');
|
|
18
18
|
const { setupClaudeNative } = require('../lib/claude-native-setup');
|
|
19
19
|
const { setupCodexNative } = require('../lib/codex-native-setup');
|
|
20
|
+
const { setupMcpExtensions } = require('../lib/mcp-extensions');
|
|
20
21
|
const { setupStagingConsent } = require('../lib/staging-consent');
|
|
21
22
|
const { getLatestVersion, compareVersions } = require('../lib/utils/version-compare');
|
|
22
23
|
|
|
@@ -1491,6 +1492,14 @@ async function install(options = {}) {
|
|
|
1491
1492
|
}
|
|
1492
1493
|
}
|
|
1493
1494
|
|
|
1495
|
+
if (needsClaude) {
|
|
1496
|
+
try {
|
|
1497
|
+
await setupMcpExtensions(projectRoot, {});
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
console.log(chalk.yellow(` ⚠ MCP extensions setup skipped: ${error.message}`));
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1494
1503
|
// Step 8: Create config.yaml
|
|
1495
1504
|
const configSpinner = ora('Generating configuration...').start();
|
|
1496
1505
|
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Workspace MCP extension (gdrive)
|
|
3
|
+
*
|
|
4
|
+
* Wraps the npm package `google-workspace-mcp` (Docs, Sheets, Slides, Drive,
|
|
5
|
+
* Gmail, Calendar, Forms — 95+ tools). The package ships its own CLI for
|
|
6
|
+
* interactive credential setup and persists everything under
|
|
7
|
+
* ~/.google-mcp/ (gitignored by virtue of being in $HOME).
|
|
8
|
+
*
|
|
9
|
+
* Our role here is:
|
|
10
|
+
* 1. Detect if the package is reachable (npx).
|
|
11
|
+
* 2. Detect if credentials are already on disk.
|
|
12
|
+
* 3. If not, walk the user through the Google Cloud setup steps with
|
|
13
|
+
* direct console links, and delegate the actual OAuth flow to the
|
|
14
|
+
* package's `setup` / `accounts add` CLI subcommands.
|
|
15
|
+
* 4. Provide the .mcp.json entry to register the server.
|
|
16
|
+
*
|
|
17
|
+
* No credential ever touches the project tree. The .mcp.json entry only
|
|
18
|
+
* declares command/args — every secret stays in ~/.google-mcp/.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const fs = require('fs-extra');
|
|
26
|
+
const chalk = require('chalk');
|
|
27
|
+
const inquirer = require('inquirer');
|
|
28
|
+
const { execSync, spawnSync } = require('child_process');
|
|
29
|
+
|
|
30
|
+
const PACKAGE_NAME = 'google-workspace-mcp';
|
|
31
|
+
const CONFIG_DIR = path.join(os.homedir(), '.google-mcp');
|
|
32
|
+
const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
|
|
33
|
+
|
|
34
|
+
const SETUP_LINKS = [
|
|
35
|
+
{
|
|
36
|
+
step: 'Créer un projet Google Cloud',
|
|
37
|
+
url: 'https://console.cloud.google.com/projectcreate',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
step: 'Activer les APIs (Drive, Docs, Sheets, Slides, Gmail, Calendar, Forms)',
|
|
41
|
+
url: 'https://console.cloud.google.com/apis/library',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
step: 'Configurer l\'écran de consentement OAuth (External, mode Test)',
|
|
45
|
+
url: 'https://console.cloud.google.com/apis/credentials/consent',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
step: 'Créer un OAuth Client ID type "Desktop App"',
|
|
49
|
+
url: 'https://console.cloud.google.com/apis/credentials/oauthclient',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
async function isPackageInstallable() {
|
|
54
|
+
try {
|
|
55
|
+
execSync(`npm view ${PACKAGE_NAME} version --silent`, {
|
|
56
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
57
|
+
timeout: 30_000,
|
|
58
|
+
});
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function hasCredentials() {
|
|
66
|
+
return fs.pathExists(CREDENTIALS_PATH);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function isConfigured() {
|
|
70
|
+
return hasCredentials();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildEntry() {
|
|
74
|
+
return {
|
|
75
|
+
command: 'npx',
|
|
76
|
+
args: ['-y', PACKAGE_NAME, 'serve'],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function buildMcpEntry() {
|
|
81
|
+
return buildEntry();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function printSetupGuide(log) {
|
|
85
|
+
log();
|
|
86
|
+
log(chalk.cyan('Étapes Google Cloud (faire dans le navigateur, dans cet ordre) :'));
|
|
87
|
+
SETUP_LINKS.forEach((s, i) => {
|
|
88
|
+
log(chalk.gray(` ${i + 1}. ${s.step}`));
|
|
89
|
+
log(chalk.gray(` → ${s.url}`));
|
|
90
|
+
});
|
|
91
|
+
log();
|
|
92
|
+
log(chalk.gray(` 5. Télécharger le JSON OAuth Client (bouton "Download JSON")`));
|
|
93
|
+
log(chalk.gray(` 6. Renommer ce fichier en : credentials.json`));
|
|
94
|
+
log(chalk.gray(` 7. Le placer dans : ${CREDENTIALS_PATH}`));
|
|
95
|
+
log();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function importCredentialsFromPath(srcPath, log) {
|
|
99
|
+
const abs = path.resolve(srcPath);
|
|
100
|
+
if (!(await fs.pathExists(abs))) {
|
|
101
|
+
throw new Error(`Fichier introuvable : ${abs}`);
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = await fs.readJson(abs);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
throw new Error(`JSON invalide : ${e.message}`);
|
|
108
|
+
}
|
|
109
|
+
// Sanity check — Google OAuth client JSON has either "installed" or "web"
|
|
110
|
+
if (!parsed.installed && !parsed.web) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Format inattendu : ce fichier ne ressemble pas à un OAuth Client Google (clé "installed" ou "web" absente)`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
await fs.ensureDir(CONFIG_DIR);
|
|
116
|
+
// Tighten dir perms : 700 (owner only). Best-effort on non-POSIX.
|
|
117
|
+
try {
|
|
118
|
+
await fs.chmod(CONFIG_DIR, 0o700);
|
|
119
|
+
} catch {
|
|
120
|
+
// ignore on platforms where this fails
|
|
121
|
+
}
|
|
122
|
+
await fs.writeFile(CREDENTIALS_PATH, JSON.stringify(parsed, null, 2), {
|
|
123
|
+
mode: 0o600,
|
|
124
|
+
});
|
|
125
|
+
log(chalk.green(` ✓ credentials.json copié vers ${CREDENTIALS_PATH} (perm 600)`));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runOAuthFlow(log) {
|
|
129
|
+
log();
|
|
130
|
+
log(chalk.cyan('Lancement du flow OAuth Google (le navigateur va s\'ouvrir)'));
|
|
131
|
+
log(chalk.gray(' Suis les instructions à l\'écran. Ferme la fenêtre quand le flow est terminé.'));
|
|
132
|
+
log();
|
|
133
|
+
|
|
134
|
+
const { accountName } = await inquirer.prompt([
|
|
135
|
+
{
|
|
136
|
+
type: 'input',
|
|
137
|
+
name: 'accountName',
|
|
138
|
+
message: 'Nom du compte Google (un slug — ex : "perso", "work") :',
|
|
139
|
+
default: 'default',
|
|
140
|
+
validate: (v) => /^[a-z0-9_-]+$/i.test(v) || 'Caractères autorisés : a-z, 0-9, _, -',
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const result = spawnSync('npx', ['-y', PACKAGE_NAME, 'accounts', 'add', accountName], {
|
|
145
|
+
stdio: 'inherit',
|
|
146
|
+
timeout: 600_000, // 10 minutes
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (result.status !== 0) {
|
|
150
|
+
throw new Error(`google-workspace-mcp accounts add a échoué (exit ${result.status})`);
|
|
151
|
+
}
|
|
152
|
+
log(chalk.green(` ✓ Compte "${accountName}" ajouté`));
|
|
153
|
+
return accountName;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function setup({ quiet } = {}) {
|
|
157
|
+
const log = quiet ? () => {} : (...a) => console.log(...a);
|
|
158
|
+
|
|
159
|
+
if (!(await isPackageInstallable())) {
|
|
160
|
+
return {
|
|
161
|
+
configured: false,
|
|
162
|
+
skipReason: `Le package npm "${PACKAGE_NAME}" n'est pas accessible (réseau / registre indisponible). Réessaie plus tard.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (await hasCredentials()) {
|
|
167
|
+
log(chalk.gray(` · credentials.json déjà présent à ${CREDENTIALS_PATH}`));
|
|
168
|
+
const { reuse } = await inquirer.prompt([
|
|
169
|
+
{
|
|
170
|
+
type: 'confirm',
|
|
171
|
+
name: 'reuse',
|
|
172
|
+
message: 'Réutiliser la config existante (sans relancer OAuth) ?',
|
|
173
|
+
default: true,
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
if (reuse) {
|
|
177
|
+
return { configured: true, message: 'reused existing credentials' };
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
printSetupGuide(log);
|
|
181
|
+
|
|
182
|
+
const { hasJson } = await inquirer.prompt([
|
|
183
|
+
{
|
|
184
|
+
type: 'confirm',
|
|
185
|
+
name: 'hasJson',
|
|
186
|
+
message: 'Tu as téléchargé le credentials.json (étapes 1-5) ?',
|
|
187
|
+
default: false,
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
if (!hasJson) {
|
|
192
|
+
return {
|
|
193
|
+
configured: false,
|
|
194
|
+
skipReason:
|
|
195
|
+
'Setup interrompu — relance l\'installer une fois le credentials.json téléchargé.',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { jsonPath } = await inquirer.prompt([
|
|
200
|
+
{
|
|
201
|
+
type: 'input',
|
|
202
|
+
name: 'jsonPath',
|
|
203
|
+
message: 'Chemin local vers le credentials.json téléchargé :',
|
|
204
|
+
validate: (v) => v && v.trim().length > 0 || 'Chemin requis',
|
|
205
|
+
},
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await importCredentialsFromPath(jsonPath.trim(), log);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
return { configured: false, skipReason: `Import des credentials échoué : ${err.message}` };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const { runAuth } = await inquirer.prompt([
|
|
216
|
+
{
|
|
217
|
+
type: 'confirm',
|
|
218
|
+
name: 'runAuth',
|
|
219
|
+
message: 'Lancer le flow OAuth maintenant (ouvre le navigateur) ?',
|
|
220
|
+
default: true,
|
|
221
|
+
},
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
if (!runAuth) {
|
|
225
|
+
return {
|
|
226
|
+
configured: true,
|
|
227
|
+
message:
|
|
228
|
+
'credentials importés ; lance le flow OAuth plus tard via : npx -y google-workspace-mcp accounts add <name>',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const account = await runOAuthFlow(log);
|
|
234
|
+
return { configured: true, message: `compte "${account}" authentifié` };
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return {
|
|
237
|
+
configured: false,
|
|
238
|
+
skipReason:
|
|
239
|
+
`OAuth a échoué : ${err.message}. Relance manuellement : npx -y ${PACKAGE_NAME} accounts add <name>`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
id: 'gdrive',
|
|
246
|
+
name: 'Google Workspace (Docs / Sheets / Slides / Drive / Gmail / Calendar)',
|
|
247
|
+
description: '95+ tools via google-workspace-mcp, OAuth2, creds in ~/.google-mcp/',
|
|
248
|
+
isConfigured,
|
|
249
|
+
setup,
|
|
250
|
+
buildMcpEntry,
|
|
251
|
+
// exposed for tests
|
|
252
|
+
buildEntry,
|
|
253
|
+
CONFIG_DIR,
|
|
254
|
+
CREDENTIALS_PATH,
|
|
255
|
+
PACKAGE_NAME,
|
|
256
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Extensions Registry — discovers and orchestrates third-party MCP
|
|
3
|
+
* server integrations (Google Workspace, etc.) during yanstaller install.
|
|
4
|
+
*
|
|
5
|
+
* Each extension is a module under this directory exposing the contract:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* id : string // unique slug (becomes mcpServers key)
|
|
9
|
+
* name : string // human-readable name
|
|
10
|
+
* description : string // shown to user during prompt
|
|
11
|
+
* async isConfigured(): Promise<boolean> // already set up on this machine?
|
|
12
|
+
* async setup(options): Promise<{
|
|
13
|
+
* configured: boolean,
|
|
14
|
+
* message: string,
|
|
15
|
+
* skipReason?: string,
|
|
16
|
+
* }> // interactive: walks the user
|
|
17
|
+
* // through credential setup
|
|
18
|
+
* async buildMcpEntry(): Promise<object> // returns the entry to write
|
|
19
|
+
* // into mcpServers.<id>
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* The registry walks the list, prompts the user for each, runs setup if
|
|
23
|
+
* accepted, and writes the resulting MCP entry into .mcp.json via
|
|
24
|
+
* addMcpEntry (which refuses any entry containing a value that looks like
|
|
25
|
+
* a secret — see mcp-config.js).
|
|
26
|
+
*
|
|
27
|
+
* No secret value is ever stored by this module. Per-extension credential
|
|
28
|
+
* persistence is the extension's responsibility (typically under ~/.config/
|
|
29
|
+
* or ~/.<package>/, never inside the project).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const fs = require('fs-extra');
|
|
36
|
+
const chalk = require('chalk');
|
|
37
|
+
const inquirer = require('inquirer');
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
mcpConfig: { addMcpEntry },
|
|
41
|
+
} = require('byan-platform-config');
|
|
42
|
+
|
|
43
|
+
const gdrive = require('./gdrive');
|
|
44
|
+
|
|
45
|
+
const EXTENSIONS = [gdrive];
|
|
46
|
+
|
|
47
|
+
function listExtensions() {
|
|
48
|
+
return EXTENSIONS.map((ext) => ({
|
|
49
|
+
id: ext.id,
|
|
50
|
+
name: ext.name,
|
|
51
|
+
description: ext.description,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getExtension(id) {
|
|
56
|
+
return EXTENSIONS.find((ext) => ext.id === id) || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Walk every registered extension and offer it to the user. For each
|
|
61
|
+
* extension the user accepts, run its setup flow, then register the
|
|
62
|
+
* resulting MCP entry in .mcp.json.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} projectRoot
|
|
65
|
+
* @param {{
|
|
66
|
+
* skipPrompts?: boolean,
|
|
67
|
+
* presetSelections?: Record<string, boolean>, // { gdrive: true }
|
|
68
|
+
* quiet?: boolean,
|
|
69
|
+
* }} options
|
|
70
|
+
* @returns {Promise<Array<{ id: string, configured: boolean, message: string }>>}
|
|
71
|
+
*/
|
|
72
|
+
async function setupMcpExtensions(projectRoot, options = {}) {
|
|
73
|
+
const log = options.quiet ? () => {} : (...a) => console.log(...a);
|
|
74
|
+
const results = [];
|
|
75
|
+
|
|
76
|
+
if (EXTENSIONS.length === 0) return results;
|
|
77
|
+
|
|
78
|
+
log();
|
|
79
|
+
log(chalk.cyan('MCP extensions (optional third-party integrations)'));
|
|
80
|
+
|
|
81
|
+
for (const ext of EXTENSIONS) {
|
|
82
|
+
let want;
|
|
83
|
+
if (options.skipPrompts) {
|
|
84
|
+
want = options.presetSelections && options.presetSelections[ext.id] === true;
|
|
85
|
+
} else {
|
|
86
|
+
const answers = await inquirer.prompt([
|
|
87
|
+
{
|
|
88
|
+
type: 'confirm',
|
|
89
|
+
name: 'enable',
|
|
90
|
+
message: `${ext.name} — ${ext.description}\n Activer ?`,
|
|
91
|
+
default: false,
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
want = answers.enable === true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!want) {
|
|
98
|
+
log(chalk.gray(` · ${ext.id}: skipped`));
|
|
99
|
+
results.push({ id: ext.id, configured: false, message: 'skipped by user' });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let setupResult;
|
|
104
|
+
try {
|
|
105
|
+
setupResult = await ext.setup({ projectRoot, quiet: options.quiet });
|
|
106
|
+
} catch (err) {
|
|
107
|
+
log(chalk.red(` ✘ ${ext.id} setup failed: ${err.message}`));
|
|
108
|
+
results.push({ id: ext.id, configured: false, message: `setup error: ${err.message}` });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!setupResult || setupResult.configured !== true) {
|
|
113
|
+
const reason = (setupResult && (setupResult.skipReason || setupResult.message)) || 'setup not completed';
|
|
114
|
+
log(chalk.yellow(` ⚠ ${ext.id}: ${reason}`));
|
|
115
|
+
results.push({ id: ext.id, configured: false, message: reason });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let entry;
|
|
120
|
+
try {
|
|
121
|
+
entry = await ext.buildMcpEntry({ projectRoot });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
log(chalk.red(` ✘ ${ext.id} buildMcpEntry failed: ${err.message}`));
|
|
124
|
+
results.push({ id: ext.id, configured: false, message: `buildMcpEntry error: ${err.message}` });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await addMcpEntry(projectRoot, ext.id, entry);
|
|
130
|
+
log(chalk.green(` ✓ ${ext.id} registered in .mcp.json`));
|
|
131
|
+
results.push({ id: ext.id, configured: true, message: 'registered' });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log(chalk.red(` ✘ ${ext.id} addMcpEntry failed: ${err.message}`));
|
|
134
|
+
results.push({ id: ext.id, configured: false, message: `addMcpEntry error: ${err.message}` });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
listExtensions,
|
|
143
|
+
getExtension,
|
|
144
|
+
setupMcpExtensions,
|
|
145
|
+
// re-exported for tests / programmatic callers
|
|
146
|
+
EXTENSIONS,
|
|
147
|
+
};
|
package/install/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-byan-agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.1",
|
|
4
4
|
"description": "BYAN v2.2.2 - Intelligent AI agent installer with multi-platform native support (GitHub Copilot CLI, Claude Code, Codex/OpenCode)",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-byan-agent": "bin/create-byan-agent-v2.js"
|
|
@@ -3,7 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* READ-MERGE-WRITE semantics : preserves all existing mcpServers.* entries
|
|
5
5
|
* and, if byan entry already exists, preserves its command/args. Only the
|
|
6
|
-
* env.BYAN_API_URL
|
|
6
|
+
* env.BYAN_API_URL is authoritative from caller.
|
|
7
|
+
*
|
|
8
|
+
* Security: BYAN_API_TOKEN is NEVER written into .mcp.json (which is
|
|
9
|
+
* checked into git). The token lives exclusively in:
|
|
10
|
+
* - .env (gitignored, for shell tools and Codex CLI)
|
|
11
|
+
* - .claude/settings.local.json (gitignored, for Claude Code MCP injection)
|
|
12
|
+
*
|
|
13
|
+
* Claude Code reads .claude/settings.local.json's "env" block at startup and
|
|
14
|
+
* injects those vars into every MCP server it spawns. So the token reaches
|
|
15
|
+
* the byan MCP server via that channel — no need to declare it in .mcp.json.
|
|
16
|
+
*
|
|
17
|
+
* The `token` parameter on this module's API is kept for backward-compat
|
|
18
|
+
* but is intentionally discarded (with a one-line audit trail in the
|
|
19
|
+
* returned result). Callers that supply a token should instead use
|
|
20
|
+
* envConfig.updateDotenv + envConfig.updateSettingsLocal.
|
|
7
21
|
*/
|
|
8
22
|
|
|
9
23
|
const path = require('path');
|
|
@@ -11,6 +25,7 @@ const fs = require('fs-extra');
|
|
|
11
25
|
const { stripApiSuffix } = require('./url-utils');
|
|
12
26
|
|
|
13
27
|
const MCP_SERVER_REL_PATH = '_byan/mcp/byan-mcp-server/server.js';
|
|
28
|
+
const TOKEN_PLACEHOLDER = '${BYAN_API_TOKEN}';
|
|
14
29
|
|
|
15
30
|
async function readJsonOrEmpty(filePath) {
|
|
16
31
|
if (await fs.pathExists(filePath)) {
|
|
@@ -43,11 +58,14 @@ async function readMcpConfig(projectRoot) {
|
|
|
43
58
|
* Pure merge — no I/O. Returns a new config object with byan entry merged.
|
|
44
59
|
* Useful for migrations that inspect the diff before writing.
|
|
45
60
|
*
|
|
61
|
+
* BYAN_API_TOKEN is intentionally stripped from env (never written into
|
|
62
|
+
* .mcp.json). See module header for the rationale and the persistence path.
|
|
63
|
+
*
|
|
46
64
|
* @param {object} existingConfig — current parsed config (may be {} or {mcpServers:{...}})
|
|
47
|
-
* @param {{ apiUrl: string, token?: string }} opts
|
|
65
|
+
* @param {{ apiUrl: string, token?: string }} opts — `token` is accepted but discarded
|
|
48
66
|
* @returns {object} new merged config
|
|
49
67
|
*/
|
|
50
|
-
function mergeByanEntry(existingConfig, { apiUrl
|
|
68
|
+
function mergeByanEntry(existingConfig, { apiUrl } = {}) {
|
|
51
69
|
const cfg = existingConfig && typeof existingConfig === 'object' ? { ...existingConfig } : {};
|
|
52
70
|
cfg.mcpServers = { ...(cfg.mcpServers || {}) };
|
|
53
71
|
|
|
@@ -56,11 +74,7 @@ function mergeByanEntry(existingConfig, { apiUrl, token } = {}) {
|
|
|
56
74
|
|
|
57
75
|
const env = { ...(existing.env || {}) };
|
|
58
76
|
env.BYAN_API_URL = cleanUrl;
|
|
59
|
-
|
|
60
|
-
env.BYAN_API_TOKEN = token;
|
|
61
|
-
} else {
|
|
62
|
-
delete env.BYAN_API_TOKEN;
|
|
63
|
-
}
|
|
77
|
+
delete env.BYAN_API_TOKEN;
|
|
64
78
|
|
|
65
79
|
cfg.mcpServers.byan = {
|
|
66
80
|
command: 'node',
|
|
@@ -87,9 +101,94 @@ async function ensureMcpConfig(projectRoot, { apiUrl, token } = {}) {
|
|
|
87
101
|
return { path: filePath };
|
|
88
102
|
}
|
|
89
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Adds (or replaces) an arbitrary MCP server entry under mcpServers.<name>.
|
|
106
|
+
* Used by mcp-extensions to register third-party MCPs (gdrive, etc.) without
|
|
107
|
+
* touching the byan entry. Other entries are preserved.
|
|
108
|
+
*
|
|
109
|
+
* Security guard : refuses to write any value matching common token shapes
|
|
110
|
+
* directly into .mcp.json. Callers must put secrets in .env / settings.local
|
|
111
|
+
* and reference env-var names elsewhere.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} projectRoot
|
|
114
|
+
* @param {string} name — server identifier (becomes the mcpServers key)
|
|
115
|
+
* @param {object} entry — { command, args?, env?, ... }
|
|
116
|
+
* @returns {Promise<{ path: string }>}
|
|
117
|
+
*/
|
|
118
|
+
async function addMcpEntry(projectRoot, name, entry) {
|
|
119
|
+
if (!name || typeof name !== 'string') {
|
|
120
|
+
throw new Error('addMcpEntry: name must be a non-empty string');
|
|
121
|
+
}
|
|
122
|
+
if (!entry || typeof entry !== 'object') {
|
|
123
|
+
throw new Error('addMcpEntry: entry must be an object');
|
|
124
|
+
}
|
|
125
|
+
assertNoSecretInEntry(entry);
|
|
126
|
+
|
|
127
|
+
const filePath = path.join(projectRoot, '.mcp.json');
|
|
128
|
+
const current = await readJsonOrEmpty(filePath);
|
|
129
|
+
const cfg = current && typeof current === 'object' ? { ...current } : {};
|
|
130
|
+
cfg.mcpServers = { ...(cfg.mcpServers || {}) };
|
|
131
|
+
cfg.mcpServers[name] = entry;
|
|
132
|
+
await fs.writeJson(filePath, cfg, { spaces: 2 });
|
|
133
|
+
return { path: filePath };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Removes an MCP server entry. No-op if the entry does not exist.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} projectRoot
|
|
140
|
+
* @param {string} name
|
|
141
|
+
* @returns {Promise<{ path: string, removed: boolean }>}
|
|
142
|
+
*/
|
|
143
|
+
async function removeMcpEntry(projectRoot, name) {
|
|
144
|
+
const filePath = path.join(projectRoot, '.mcp.json');
|
|
145
|
+
const current = await readJsonOrEmpty(filePath);
|
|
146
|
+
if (!current || !current.mcpServers || !(name in current.mcpServers)) {
|
|
147
|
+
return { path: filePath, removed: false };
|
|
148
|
+
}
|
|
149
|
+
const cfg = { ...current, mcpServers: { ...current.mcpServers } };
|
|
150
|
+
delete cfg.mcpServers[name];
|
|
151
|
+
await fs.writeJson(filePath, cfg, { spaces: 2 });
|
|
152
|
+
return { path: filePath, removed: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const SECRET_SHAPES = [
|
|
156
|
+
/^byan_[a-f0-9]{20,}$/i, // byan tokens
|
|
157
|
+
/^ghp_[A-Za-z0-9]{30,}$/, // GitHub PAT
|
|
158
|
+
/^gho_[A-Za-z0-9]{30,}$/, // GitHub OAuth
|
|
159
|
+
/^github_pat_[A-Za-z0-9_]{60,}$/, // GitHub PAT (new format)
|
|
160
|
+
/^sk-ant-[A-Za-z0-9_-]{20,}$/, // Anthropic
|
|
161
|
+
/^sk-proj-[A-Za-z0-9_-]{20,}$/, // OpenAI project
|
|
162
|
+
/^AIza[0-9A-Za-z_-]{30,}$/, // Google API key
|
|
163
|
+
/^xox[bpao]-[0-9]+-[0-9]+-/, // Slack
|
|
164
|
+
/^AKIA[0-9A-Z]{16}$/, // AWS
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
function looksLikeSecret(value) {
|
|
168
|
+
if (typeof value !== 'string') return false;
|
|
169
|
+
return SECRET_SHAPES.some((re) => re.test(value));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function assertNoSecretInEntry(entry) {
|
|
173
|
+
const env = (entry && entry.env) || {};
|
|
174
|
+
for (const [key, val] of Object.entries(env)) {
|
|
175
|
+
if (looksLikeSecret(val)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`addMcpEntry: env.${key} looks like a secret. Refusing to write it into .mcp.json. ` +
|
|
178
|
+
`Put the value in .env (gitignored) and reference it via .claude/settings.local.json env, ` +
|
|
179
|
+
`or use \${VAR_NAME} if your MCP client supports env-var expansion.`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
90
185
|
module.exports = {
|
|
91
186
|
ensureMcpConfig,
|
|
92
187
|
readMcpConfig,
|
|
93
188
|
mergeByanEntry,
|
|
189
|
+
addMcpEntry,
|
|
190
|
+
removeMcpEntry,
|
|
191
|
+
looksLikeSecret,
|
|
94
192
|
MCP_SERVER_REL_PATH,
|
|
193
|
+
TOKEN_PLACEHOLDER,
|
|
95
194
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-byan-agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.1",
|
|
4
4
|
"description": "BYAN v2.8 - Intelligent AI agent creator with ELO trust system + scientific fact-check + Hermes universal dispatcher + native Claude Code integration (hooks, skills, MCP server). Multi-platform (Copilot CLI, Claude Code, Codex). Merise Agile + TDD + 64 Mantras. ~54% LLM cost savings.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@ const fs = require('fs-extra');
|
|
|
13
13
|
|
|
14
14
|
// Module under test (loaded once; all I/O goes to tmpdir per test)
|
|
15
15
|
const { runMigration } = require('../lib/migrate-mcp-config');
|
|
16
|
+
const { mcpConfig: { TOKEN_PLACEHOLDER } } = require('byan-platform-config');
|
|
16
17
|
|
|
17
18
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
18
19
|
// Helpers
|
|
@@ -69,16 +70,16 @@ test('returns no-byan-server if .mcp.json has other servers but no byan entry',
|
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
72
|
-
// Test 3 — byan entry already has
|
|
73
|
+
// Test 3 — byan entry already has placeholder + clean URL → already-ok
|
|
73
74
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
74
|
-
test('returns already-ok if byan entry has token
|
|
75
|
+
test('returns already-ok if byan entry has no BYAN_API_TOKEN (token belongs in .env / settings.local.json)', async () => {
|
|
75
76
|
const dir = await makeTmp();
|
|
76
77
|
await writeMcp(dir, {
|
|
77
78
|
mcpServers: {
|
|
78
79
|
byan: {
|
|
79
80
|
command: 'node',
|
|
80
81
|
args: ['_byan/mcp/byan-mcp-server/server.js'],
|
|
81
|
-
env: { BYAN_API_URL: 'https://api.byan.io'
|
|
82
|
+
env: { BYAN_API_URL: 'https://api.byan.io' },
|
|
82
83
|
},
|
|
83
84
|
},
|
|
84
85
|
});
|
|
@@ -91,7 +92,7 @@ test('returns already-ok if byan entry has token and no /api suffix', async () =
|
|
|
91
92
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
92
93
|
// Test 4 — needs token but .env + settings.local.json both empty → no-token-available
|
|
93
94
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
94
|
-
test('returns
|
|
95
|
+
test('returns already-ok if .mcp.json has no BYAN_API_TOKEN (token absence is now the desired state)', async () => {
|
|
95
96
|
const dir = await makeTmp();
|
|
96
97
|
await writeMcp(dir, {
|
|
97
98
|
mcpServers: {
|
|
@@ -99,23 +100,19 @@ test('returns no-token-available if .mcp.json needs token but sources are empty'
|
|
|
99
100
|
command: 'node',
|
|
100
101
|
args: ['_byan/mcp/byan-mcp-server/server.js'],
|
|
101
102
|
env: { BYAN_API_URL: 'https://api.byan.io' },
|
|
102
|
-
// no BYAN_API_TOKEN
|
|
103
103
|
},
|
|
104
104
|
},
|
|
105
105
|
});
|
|
106
|
-
// no .env, no settings.local.json
|
|
107
106
|
const result = await runMigration(dir);
|
|
108
107
|
expect(result.migrated).toBe(false);
|
|
109
|
-
expect(result.reason).toBe('
|
|
110
|
-
expect(typeof result.hint).toBe('string');
|
|
111
|
-
expect(result.hint.length).toBeGreaterThan(0);
|
|
108
|
+
expect(result.reason).toBe('already-ok');
|
|
112
109
|
await fs.remove(dir);
|
|
113
110
|
});
|
|
114
111
|
|
|
115
112
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
116
113
|
// Test 5 — migrates successfully: token from .env
|
|
117
114
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
118
|
-
test('
|
|
115
|
+
test('with token already absent in .mcp.json + .env populated: no-op (already-ok), .env preserved untouched', async () => {
|
|
119
116
|
const dir = await makeTmp();
|
|
120
117
|
await writeMcp(dir, {
|
|
121
118
|
mcpServers: {
|
|
@@ -129,19 +126,18 @@ test('migrates: token from .env → .mcp.json env.BYAN_API_TOKEN', async () => {
|
|
|
129
126
|
await writeDotenv(dir, 'BYAN_API_TOKEN=byan_from_dotenv\n');
|
|
130
127
|
|
|
131
128
|
const result = await runMigration(dir);
|
|
132
|
-
expect(result.migrated).toBe(
|
|
133
|
-
expect(result.reason).toBe('
|
|
134
|
-
expect(result.changes.length).toBeGreaterThanOrEqual(1);
|
|
129
|
+
expect(result.migrated).toBe(false);
|
|
130
|
+
expect(result.reason).toBe('already-ok');
|
|
135
131
|
|
|
136
|
-
const
|
|
137
|
-
expect(
|
|
132
|
+
const dotenvAfter = await fs.readFile(path.join(dir, '.env'), 'utf8');
|
|
133
|
+
expect(dotenvAfter).toBe('BYAN_API_TOKEN=byan_from_dotenv\n');
|
|
138
134
|
await fs.remove(dir);
|
|
139
135
|
});
|
|
140
136
|
|
|
141
137
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
142
138
|
// Test 6 — migrates successfully: token from .claude/settings.local.json fallback
|
|
143
139
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
144
|
-
test('
|
|
140
|
+
test('with token in settings.local.json + absent from .mcp.json: no-op (already-ok), settings preserved', async () => {
|
|
145
141
|
const dir = await makeTmp();
|
|
146
142
|
await writeMcp(dir, {
|
|
147
143
|
mcpServers: {
|
|
@@ -152,22 +148,20 @@ test('migrates: token from settings.local.json fallback', async () => {
|
|
|
152
148
|
},
|
|
153
149
|
},
|
|
154
150
|
});
|
|
155
|
-
// no .env, token in settings.local.json
|
|
156
151
|
await writeSettingsLocal(dir, { env: { BYAN_API_TOKEN: 'byan_from_settings' } });
|
|
157
152
|
|
|
158
153
|
const result = await runMigration(dir);
|
|
159
|
-
expect(result.migrated).toBe(
|
|
160
|
-
expect(result.reason).toBe('
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBe('byan_from_settings');
|
|
154
|
+
expect(result.migrated).toBe(false);
|
|
155
|
+
expect(result.reason).toBe('already-ok');
|
|
156
|
+
const settings = await fs.readJson(path.join(dir, '.claude', 'settings.local.json'));
|
|
157
|
+
expect(settings.env.BYAN_API_TOKEN).toBe('byan_from_settings');
|
|
164
158
|
await fs.remove(dir);
|
|
165
159
|
});
|
|
166
160
|
|
|
167
161
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
168
162
|
// Test 7 — strips /api suffix from BYAN_API_URL
|
|
169
163
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
170
|
-
test('strips /api suffix from
|
|
164
|
+
test('strips /api suffix and extracts clear token to .env (token removed from .mcp.json)', async () => {
|
|
171
165
|
const dir = await makeTmp();
|
|
172
166
|
await writeMcp(dir, {
|
|
173
167
|
mcpServers: {
|
|
@@ -184,7 +178,60 @@ test('strips /api suffix from BYAN_API_URL', async () => {
|
|
|
184
178
|
|
|
185
179
|
const written = await readMcp(dir);
|
|
186
180
|
expect(written.mcpServers.byan.env.BYAN_API_URL).toBe('https://api.byan.io');
|
|
187
|
-
expect(written.mcpServers.byan.env.BYAN_API_TOKEN).
|
|
181
|
+
expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBeUndefined();
|
|
182
|
+
const dotenvContent = await fs.readFile(path.join(dir, '.env'), 'utf8');
|
|
183
|
+
expect(dotenvContent).toContain('BYAN_API_TOKEN=byan_tok');
|
|
184
|
+
const raw = await fs.readFile(path.join(dir, '.mcp.json'), 'utf8');
|
|
185
|
+
expect(raw).not.toContain('byan_tok');
|
|
186
|
+
await fs.remove(dir);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('extracts clear token from .mcp.json into .env and removes it from .mcp.json (security migration)', async () => {
|
|
190
|
+
const dir = await makeTmp();
|
|
191
|
+
await writeMcp(dir, {
|
|
192
|
+
mcpServers: {
|
|
193
|
+
byan: {
|
|
194
|
+
command: 'node',
|
|
195
|
+
args: ['_byan/mcp/byan-mcp-server/server.js'],
|
|
196
|
+
env: { BYAN_API_URL: 'https://api.byan.io', BYAN_API_TOKEN: 'byan_leaked_in_clear' },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const result = await runMigration(dir);
|
|
202
|
+
expect(result.migrated).toBe(true);
|
|
203
|
+
expect(result.reason).toBe('healed');
|
|
204
|
+
expect(result.changes.some((c) => /extracted/i.test(c))).toBe(true);
|
|
205
|
+
|
|
206
|
+
const written = await readMcp(dir);
|
|
207
|
+
expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBeUndefined();
|
|
208
|
+
|
|
209
|
+
const raw = await fs.readFile(path.join(dir, '.mcp.json'), 'utf8');
|
|
210
|
+
expect(raw).not.toContain('byan_leaked_in_clear');
|
|
211
|
+
|
|
212
|
+
const dotenvContent = await fs.readFile(path.join(dir, '.env'), 'utf8');
|
|
213
|
+
expect(dotenvContent).toContain('BYAN_API_TOKEN=byan_leaked_in_clear');
|
|
214
|
+
await fs.remove(dir);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('removes ${BYAN_API_TOKEN} placeholder from .mcp.json (no value to extract)', async () => {
|
|
218
|
+
const dir = await makeTmp();
|
|
219
|
+
await writeMcp(dir, {
|
|
220
|
+
mcpServers: {
|
|
221
|
+
byan: {
|
|
222
|
+
command: 'node',
|
|
223
|
+
args: ['_byan/mcp/byan-mcp-server/server.js'],
|
|
224
|
+
env: { BYAN_API_URL: 'https://api.byan.io', BYAN_API_TOKEN: TOKEN_PLACEHOLDER },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const result = await runMigration(dir);
|
|
230
|
+
expect(result.migrated).toBe(true);
|
|
231
|
+
expect(result.reason).toBe('healed');
|
|
232
|
+
|
|
233
|
+
const written = await readMcp(dir);
|
|
234
|
+
expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBeUndefined();
|
|
188
235
|
await fs.remove(dir);
|
|
189
236
|
});
|
|
190
237
|
|
|
@@ -215,6 +262,9 @@ test('dry-run: does not write .mcp.json but returns changes', async () => {
|
|
|
215
262
|
const afterMcp = await readMcp(dir);
|
|
216
263
|
expect(afterMcp.mcpServers.byan.env.BYAN_API_URL).toBe('https://api.byan.io/api');
|
|
217
264
|
expect(afterMcp.mcpServers.byan.env.BYAN_API_TOKEN).toBeUndefined();
|
|
265
|
+
// .env must NOT have been touched on dry-run either
|
|
266
|
+
const dotenvAfter = await fs.readFile(path.join(dir, '.env'), 'utf8');
|
|
267
|
+
expect(dotenvAfter).toBe('BYAN_API_TOKEN=byan_dryrun_tok\n');
|
|
218
268
|
await fs.remove(dir);
|
|
219
269
|
});
|
|
220
270
|
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* migrate-mcp-config — self-healing migration for pre-fix BYAN installs.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Heals three drift cases on .mcp.json:
|
|
5
|
+
* 1. token missing → injects ${BYAN_API_TOKEN} placeholder
|
|
6
|
+
* 2. url has /api suffix → strips it
|
|
7
|
+
* 3. token in clear → extracts to .env and replaces with placeholder
|
|
8
|
+
* (security fix from BYAN >=2.16.0)
|
|
9
|
+
*
|
|
10
|
+
* Uses byan-platform-config primitives exclusively; no duplicated logic here.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
const chalk = require('chalk');
|
|
10
14
|
const ora = require('ora');
|
|
11
15
|
const {
|
|
12
|
-
mcpConfig: { readMcpConfig, ensureMcpConfig },
|
|
13
|
-
envConfig: { readEnvToken },
|
|
16
|
+
mcpConfig: { readMcpConfig, ensureMcpConfig, TOKEN_PLACEHOLDER },
|
|
17
|
+
envConfig: { readEnvToken, updateDotenv },
|
|
14
18
|
urlUtils: { stripApiSuffix },
|
|
15
19
|
} = require('byan-platform-config');
|
|
16
20
|
|
|
@@ -65,32 +69,27 @@ async function runMigration(projectRoot, { dryRun = false, verbose = false } = {
|
|
|
65
69
|
stopSpinner(chalk.gray('.mcp.json lu'), true);
|
|
66
70
|
|
|
67
71
|
// 3. Diagnose
|
|
68
|
-
const
|
|
69
|
-
const
|
|
72
|
+
const currentToken = (byan.env && byan.env.BYAN_API_TOKEN) || '';
|
|
73
|
+
const tokenIsPlaceholder = currentToken === TOKEN_PLACEHOLDER;
|
|
74
|
+
const tokenInClear = !!currentToken && !tokenIsPlaceholder;
|
|
75
|
+
const tokenIsLegacy = !!currentToken; // any non-empty value is legacy: token must not live in .mcp.json
|
|
76
|
+
const urlHasApiSuffix = /\/api\/?$/.test(byan.env && byan.env.BYAN_API_URL || '');
|
|
70
77
|
|
|
71
78
|
// 4. Nothing to do?
|
|
72
|
-
if (!
|
|
73
|
-
log(chalk.green(' .mcp.json est deja a jour (token
|
|
79
|
+
if (!tokenIsLegacy && !urlHasApiSuffix) {
|
|
80
|
+
log(chalk.green(' .mcp.json est deja a jour (token absent du fichier, URL correcte)'));
|
|
74
81
|
return { migrated: false, reason: 'already-ok' };
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
const changes = [];
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return {
|
|
87
|
-
migrated: false,
|
|
88
|
-
reason: 'no-token-available',
|
|
89
|
-
hint: 'Re-run npx create-byan-agent to prompt for a token',
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
stopSpinner(chalk.green('Token trouve'), true);
|
|
93
|
-
changes.push('BYAN_API_TOKEN injected into .mcp.json env');
|
|
85
|
+
let extractedClearToken = null;
|
|
86
|
+
|
|
87
|
+
// 5. Detect a clear-text token to relocate to .env
|
|
88
|
+
if (tokenInClear) {
|
|
89
|
+
extractedClearToken = currentToken;
|
|
90
|
+
changes.push('BYAN_API_TOKEN extracted from .mcp.json (clear) into .env, removed from .mcp.json');
|
|
91
|
+
} else if (tokenIsPlaceholder) {
|
|
92
|
+
changes.push('BYAN_API_TOKEN placeholder removed from .mcp.json (token now resolved via settings.local.json env)');
|
|
94
93
|
}
|
|
95
94
|
|
|
96
95
|
// 6. Resolve URL
|
|
@@ -108,9 +107,16 @@ async function runMigration(projectRoot, { dryRun = false, verbose = false } = {
|
|
|
108
107
|
return { migrated: false, reason: 'dry-run', changes };
|
|
109
108
|
}
|
|
110
109
|
|
|
111
|
-
// 8. Apply
|
|
110
|
+
// 8. Apply
|
|
112
111
|
startSpinner('Application de la migration...');
|
|
113
|
-
|
|
112
|
+
if (extractedClearToken) {
|
|
113
|
+
const existingDotenvToken = await readEnvToken(projectRoot);
|
|
114
|
+
if (!existingDotenvToken || existingDotenvToken !== extractedClearToken) {
|
|
115
|
+
await updateDotenv(projectRoot, { BYAN_API_TOKEN: extractedClearToken });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// ensureMcpConfig always strips BYAN_API_TOKEN from .mcp.json (security)
|
|
119
|
+
await ensureMcpConfig(projectRoot, { apiUrl: cleanUrl || existingUrl });
|
|
114
120
|
stopSpinner(chalk.green('.mcp.json migre avec succes'), true);
|
|
115
121
|
|
|
116
122
|
// 9. Return result
|