617 lines
24 KiB
JavaScript
617 lines
24 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
'use strict';
|
||
|
|
/**
|
||
|
|
* Bookworm Portable - 全自动安装引擎 v3.0
|
||
|
|
* @module setup-all
|
||
|
|
*/
|
||
|
|
|
||
|
|
// W3: Node.js 版本检查
|
||
|
|
if (parseInt(process.versions.node) < 14) {
|
||
|
|
console.error(' [!!] Node.js 版本过低 (' + process.version + '), 需要 14.0+');
|
||
|
|
console.error(' 请更新: https://nodejs.org/');
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const { execSync, spawnSync, spawn } = require('child_process');
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const readline = require('readline');
|
||
|
|
const crypto = require('crypto');
|
||
|
|
const os = require('os');
|
||
|
|
|
||
|
|
// ─── 配置 ───
|
||
|
|
const HOME = os.homedir();
|
||
|
|
const BOOT_DIR = path.join(HOME, 'bookworm-boot');
|
||
|
|
const CLAUDE_DIR = path.join(HOME, '.claude');
|
||
|
|
const GITEA_BOOT = 'https://code.letcareme.com/bookworm/bookworm-boot.git';
|
||
|
|
const GITEA_CONFIG = 'https://code.letcareme.com/bookworm/bookworm-config.git';
|
||
|
|
const NPM_MIRROR = 'https://registry.npmmirror.com';
|
||
|
|
const SCRIPT_DIR = __dirname;
|
||
|
|
|
||
|
|
// ─── 颜色输出 ───
|
||
|
|
const c = {
|
||
|
|
reset: '\x1b[0m', bold: '\x1b[1m',
|
||
|
|
red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
|
||
|
|
blue: '\x1b[34m', cyan: '\x1b[36m', dim: '\x1b[90m',
|
||
|
|
};
|
||
|
|
function ok(msg) { console.log(` ${c.green}[OK]${c.reset} ${msg}`); }
|
||
|
|
function warn(msg) { console.log(` ${c.yellow}[!]${c.reset} ${msg}`); }
|
||
|
|
function fail(msg) { console.log(` ${c.red}[!!]${c.reset} ${msg}`); }
|
||
|
|
function info(msg) { console.log(` ${c.dim}[..]${c.reset} ${msg}`); }
|
||
|
|
function step(n, total, msg) {
|
||
|
|
console.log(`\n ${c.bold}[${n}/${total}]${c.reset} ${c.cyan}${msg}${c.reset}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 工具函数 ───
|
||
|
|
function hasCmd(cmd) {
|
||
|
|
if (!/^[a-zA-Z0-9._-]+$/.test(cmd)) return false; // B3: 防命令注入
|
||
|
|
try {
|
||
|
|
execSync(`where ${cmd}`, { stdio: 'pipe' });
|
||
|
|
return true;
|
||
|
|
} catch { return false; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function run(cmd, opts = {}) {
|
||
|
|
try {
|
||
|
|
return execSync(cmd, {
|
||
|
|
stdio: opts.silent ? 'pipe' : 'inherit',
|
||
|
|
encoding: 'utf8',
|
||
|
|
timeout: opts.timeout || 300000,
|
||
|
|
env: { ...process.env, ...opts.env },
|
||
|
|
...opts,
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
if (opts.ignoreError) return '';
|
||
|
|
throw e;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function runSilent(cmd) {
|
||
|
|
try {
|
||
|
|
return execSync(cmd, { stdio: 'pipe', encoding: 'utf8', timeout: 60000 });
|
||
|
|
} catch { return ''; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function wingetInstall(id, name) {
|
||
|
|
if (!hasCmd('winget')) {
|
||
|
|
warn(`winget 不可用, 请手动安装 ${name}`);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
info(`通过 winget 安装 ${name}...`);
|
||
|
|
try {
|
||
|
|
run(`winget install ${id} --accept-source-agreements --accept-package-agreements --silent`, { timeout: 600000 });
|
||
|
|
refreshPath();
|
||
|
|
ok(`${name} 安装成功`);
|
||
|
|
return true;
|
||
|
|
} catch (e) {
|
||
|
|
fail(`${name} 安装失败: ${e.message}`);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshPath() {
|
||
|
|
try {
|
||
|
|
const sysPath = runSilent('reg query "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment" /v Path')
|
||
|
|
.match(/REG_\w+\s+(.+)/)?.[1] || '';
|
||
|
|
const usrPath = runSilent('reg query "HKCU\\Environment" /v Path')
|
||
|
|
.match(/REG_\w+\s+(.+)/)?.[1] || '';
|
||
|
|
const extra = [
|
||
|
|
'C:\\Program Files\\nodejs',
|
||
|
|
'C:\\Program Files\\Git\\cmd',
|
||
|
|
'C:\\Program Files\\Git\\usr\\bin',
|
||
|
|
'C:\\Program Files\\PowerShell\\7',
|
||
|
|
path.join(HOME, 'AppData\\Local\\Microsoft\\WinGet\\Packages'),
|
||
|
|
path.join(HOME, 'AppData\\Roaming\\npm'),
|
||
|
|
].join(';');
|
||
|
|
// I2: 动态扫描 Python 安装路径
|
||
|
|
const pyBase = path.join(HOME, 'AppData', 'Local', 'Programs', 'Python');
|
||
|
|
let pyPaths = '';
|
||
|
|
try {
|
||
|
|
if (fs.existsSync(pyBase)) {
|
||
|
|
for (const d of fs.readdirSync(pyBase)) {
|
||
|
|
pyPaths += ';' + path.join(pyBase, d) + ';' + path.join(pyBase, d, 'Scripts');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {}
|
||
|
|
process.env.PATH = `${sysPath};${usrPath};${extra}${pyPaths}`;
|
||
|
|
} catch {}
|
||
|
|
}
|
||
|
|
|
||
|
|
function askPassword(prompt) {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
process.stdout.write(prompt);
|
||
|
|
// Windows: 用 PowerShell 隐藏输入
|
||
|
|
try {
|
||
|
|
const result = execSync(
|
||
|
|
'powershell -Command "[Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR((Read-Host -AsSecureString)))"',
|
||
|
|
{ stdio: ['inherit', 'pipe', 'pipe'], encoding: 'utf8', timeout: 120000 }
|
||
|
|
).trim();
|
||
|
|
resolve(result);
|
||
|
|
} catch {
|
||
|
|
// W5: 回退明文输入, 给出警告
|
||
|
|
warn('PowerShell 不可用, 密码将以明文显示');
|
||
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||
|
|
rl.question('', (ans) => { rl.close(); resolve(ans.trim()); });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 加密/解密 (与 crypto-helper.js 同格式) ───
|
||
|
|
function decryptSecrets(filePath, password) {
|
||
|
|
const data = fs.readFileSync(filePath);
|
||
|
|
const magic = data.slice(0, 6).toString();
|
||
|
|
if (magic !== 'BWENC1') throw new Error('WRONG_FORMAT');
|
||
|
|
const salt = data.slice(6, 22);
|
||
|
|
const encrypted = data.slice(22);
|
||
|
|
const derived = crypto.pbkdf2Sync(password, salt, 600000, 48, 'sha256');
|
||
|
|
const key = derived.slice(0, 32);
|
||
|
|
const iv = derived.slice(32, 48);
|
||
|
|
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||
|
|
try {
|
||
|
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||
|
|
} catch {
|
||
|
|
throw new Error('WRONG_PASSWORD');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function escVbs(s) { return s.replace(/"/g, '""'); } // B4: 防 VBS 注入
|
||
|
|
|
||
|
|
function createShortcut(name, target, workDir) {
|
||
|
|
const vbs = path.join(os.tmpdir(), `bw_sc_${Date.now()}.vbs`);
|
||
|
|
const desktop = path.join(HOME, 'Desktop');
|
||
|
|
const lnkPath = escVbs(path.join(desktop, name + '.lnk'));
|
||
|
|
fs.writeFileSync(vbs, `Set ws = CreateObject("WScript.Shell")
|
||
|
|
Set sc = ws.CreateShortcut("${lnkPath}")
|
||
|
|
sc.TargetPath = "${escVbs(target)}"
|
||
|
|
sc.WorkingDirectory = "${escVbs(workDir)}"
|
||
|
|
sc.Description = "Bookworm Smart Assistant"
|
||
|
|
sc.Save
|
||
|
|
`);
|
||
|
|
try { execSync(`cscript //nologo "${vbs}"`, { stdio: 'pipe' }); return true; }
|
||
|
|
catch { return false; }
|
||
|
|
finally { try { fs.unlinkSync(vbs); } catch {} }
|
||
|
|
}
|
||
|
|
|
||
|
|
// W8: 动态检测 Git Bash 路径
|
||
|
|
function findGitBash() {
|
||
|
|
const candidates = ['C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\bin\\bash.exe'];
|
||
|
|
try {
|
||
|
|
const gitPath = runSilent('where git').trim().split('\n')[0];
|
||
|
|
if (gitPath) candidates.unshift(path.join(path.dirname(path.dirname(gitPath)), 'bin', 'bash.exe'));
|
||
|
|
} catch {}
|
||
|
|
return candidates.find(p => fs.existsSync(p)) || candidates[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Banner ───
|
||
|
|
function banner() {
|
||
|
|
console.log(`
|
||
|
|
${c.cyan}╔══════════════════════════════════════════════════╗
|
||
|
|
║ ____ _ ║
|
||
|
|
║ | __ ) ___ ___ | | ____ _____ _ __ ___ ║
|
||
|
|
║ | _ \\ / _ \\ / _ \\| |/ /\\ \\ /\\ / / _ \\| '__| ║
|
||
|
|
║ | |_) | (_) | (_) | < \\ V V / (_) | | ║
|
||
|
|
║ |____/ \\___/ \\___/|_|\\_\\ \\_/\\_/ \\___/|_| ║
|
||
|
|
║ ║
|
||
|
|
║ ${c.bold}全自动安装引擎 v3.0${c.cyan} ║
|
||
|
|
║ 双击即装: Node + Git + Python + PS7 + Claude + MCP║
|
||
|
|
╚══════════════════════════════════════════════════╝${c.reset}
|
||
|
|
`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════
|
||
|
|
// 主流程
|
||
|
|
// ═══════════════════════════════════════════
|
||
|
|
async function main() {
|
||
|
|
// ─── 启动模式: 已安装过 → 静默更新 + 直接启动 ───
|
||
|
|
const isInstalled = hasCmd('claude') && fs.existsSync(path.join(CLAUDE_DIR, 'CLAUDE.md'));
|
||
|
|
const startOnly = process.argv.includes('--start') || process.argv.includes('-s');
|
||
|
|
|
||
|
|
if (isInstalled && startOnly) {
|
||
|
|
return quickStart();
|
||
|
|
}
|
||
|
|
|
||
|
|
banner();
|
||
|
|
const TOTAL = 9;
|
||
|
|
let errors = 0;
|
||
|
|
|
||
|
|
// ─── 1. Git ───
|
||
|
|
step(1, TOTAL, '安装 Git');
|
||
|
|
if (hasCmd('git')) {
|
||
|
|
ok('Git 已安装');
|
||
|
|
} else {
|
||
|
|
if (!wingetInstall('Git.Git', 'Git')) errors++;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 2. Python ───
|
||
|
|
step(2, TOTAL, '安装 Python');
|
||
|
|
if (hasCmd('python') || hasCmd('python3') || hasCmd('py')) {
|
||
|
|
ok('Python 已安装');
|
||
|
|
} else {
|
||
|
|
if (!wingetInstall('Python.Python.3.12', 'Python 3.12')) errors++;
|
||
|
|
refreshPath();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 3. PowerShell 7 ───
|
||
|
|
step(3, TOTAL, '安装 PowerShell 7');
|
||
|
|
if (hasCmd('pwsh')) {
|
||
|
|
ok('PowerShell 7 已安装');
|
||
|
|
} else {
|
||
|
|
if (wingetInstall('Microsoft.PowerShell', 'PowerShell 7')) {
|
||
|
|
// 设 PS7 为 Windows Terminal 默认配置文件
|
||
|
|
try {
|
||
|
|
const wtSettings = path.join(HOME, 'AppData', 'Local', 'Packages',
|
||
|
|
'Microsoft.WindowsTerminal_8wekyb3d8bbwe', 'LocalState', 'settings.json');
|
||
|
|
if (fs.existsSync(wtSettings)) {
|
||
|
|
let wt = JSON.parse(fs.readFileSync(wtSettings, 'utf8'));
|
||
|
|
// 找到 PS7 的 profile GUID
|
||
|
|
const ps7Profile = (wt.profiles?.list || []).find(p =>
|
||
|
|
p.source === 'Windows.Terminal.PowershellCore' || (p.name || '').includes('PowerShell 7')
|
||
|
|
);
|
||
|
|
if (ps7Profile && ps7Profile.guid) {
|
||
|
|
wt.defaultProfile = ps7Profile.guid;
|
||
|
|
fs.writeFileSync(wtSettings, JSON.stringify(wt, null, 4));
|
||
|
|
ok('PS7 已设为 Windows Terminal 默认终端');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch { warn('Windows Terminal 默认配置未修改 - 不影响使用'); }
|
||
|
|
} else {
|
||
|
|
errors++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 4. Claude Code ───
|
||
|
|
step(4, TOTAL, '安装 Claude Code');
|
||
|
|
// I3: 用 --registry 参数, 不污染全局 .npmrc
|
||
|
|
if (hasCmd('claude')) {
|
||
|
|
ok('Claude Code 已安装');
|
||
|
|
} else {
|
||
|
|
info('通过 npm 安装 Claude Code - 淘宝镜像加速...');
|
||
|
|
try {
|
||
|
|
run(`npm i -g @anthropic-ai/claude-code --registry ${NPM_MIRROR}`, { timeout: 600000 });
|
||
|
|
ok('Claude Code 安装成功');
|
||
|
|
} catch {
|
||
|
|
fail('Claude Code 安装失败');
|
||
|
|
errors++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 5. 克隆 Bookworm 配置 ───
|
||
|
|
step(5, TOTAL, '同步 Bookworm 配置');
|
||
|
|
// 设置 git credential helper
|
||
|
|
run('git config --global credential.helper manager', { ignoreError: true, silent: true });
|
||
|
|
|
||
|
|
// 克隆 bookworm-boot
|
||
|
|
if (fs.existsSync(path.join(BOOT_DIR, '.git'))) {
|
||
|
|
info('bookworm-boot 已存在, 更新...');
|
||
|
|
run('git pull', { cwd: BOOT_DIR, ignoreError: true });
|
||
|
|
} else {
|
||
|
|
info('首次下载 bookworm-boot (需输入 Gitea 用户名密码)...');
|
||
|
|
try {
|
||
|
|
run(`git clone "${GITEA_BOOT}" "${BOOT_DIR}"`);
|
||
|
|
ok('bookworm-boot 克隆成功');
|
||
|
|
} catch {
|
||
|
|
fail('bookworm-boot 克隆失败 - 检查网络和 Gitea 凭证');
|
||
|
|
errors++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 克隆 bookworm-config → ~/.claude
|
||
|
|
if (fs.existsSync(path.join(CLAUDE_DIR, '.git', 'config'))) {
|
||
|
|
info('.claude 配置已存在, 更新...');
|
||
|
|
try {
|
||
|
|
const stashOut = run('git stash', { cwd: CLAUDE_DIR, ignoreError: true, silent: true }) || '';
|
||
|
|
run('git pull --rebase', { cwd: CLAUDE_DIR, ignoreError: true, silent: true });
|
||
|
|
if (stashOut.includes('Saved working directory')) {
|
||
|
|
run('git stash pop', { cwd: CLAUDE_DIR, ignoreError: true, silent: true });
|
||
|
|
}
|
||
|
|
} catch {}
|
||
|
|
} else if (!fs.existsSync(path.join(CLAUDE_DIR, 'CLAUDE.md'))) {
|
||
|
|
info('首次下载 .claude 配置...');
|
||
|
|
// 备份现有
|
||
|
|
if (fs.existsSync(CLAUDE_DIR)) {
|
||
|
|
const backup = CLAUDE_DIR + '.bak.' + Date.now();
|
||
|
|
fs.renameSync(CLAUDE_DIR, backup);
|
||
|
|
ok(`现有 .claude 已备份到 ${path.basename(backup)}`);
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
run(`git clone --depth 1 "${GITEA_CONFIG}" "${CLAUDE_DIR}"`);
|
||
|
|
ok('.claude 配置克隆成功');
|
||
|
|
} catch {
|
||
|
|
fail('.claude 配置克隆失败');
|
||
|
|
errors++;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
ok('.claude 配置已存在');
|
||
|
|
}
|
||
|
|
|
||
|
|
// 确保本地目录
|
||
|
|
for (const d of ['debug', 'sessions', 'cache', 'backups', 'memory', 'projects']) {
|
||
|
|
const p = path.join(CLAUDE_DIR, d);
|
||
|
|
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 6. 凭证解密 ───
|
||
|
|
step(6, TOTAL, '解密凭证');
|
||
|
|
const secretsFile = path.join(BOOT_DIR, 'secrets.enc');
|
||
|
|
if (!fs.existsSync(secretsFile)) {
|
||
|
|
warn('secrets.enc 不存在, 跳过凭证解密');
|
||
|
|
} else if (process.env.ANTHROPIC_API_KEY) {
|
||
|
|
ok('API Key 已设置 (缓存有效)');
|
||
|
|
} else {
|
||
|
|
let decrypted = false;
|
||
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||
|
|
const label = attempt > 1
|
||
|
|
? ` 重新输入主密码 (第 ${attempt}/3 次): `
|
||
|
|
: ' 输入主密码解密凭证: ';
|
||
|
|
const password = await askPassword(label);
|
||
|
|
try {
|
||
|
|
const text = decryptSecrets(secretsFile, password);
|
||
|
|
// 注入环境变量 (B5: 不打印 key 名称, 防截屏泄露)
|
||
|
|
let injectedCount = 0;
|
||
|
|
for (const line of text.split('\n')) {
|
||
|
|
const trimmed = line.trim();
|
||
|
|
if (!trimmed || !trimmed.includes('=')) continue;
|
||
|
|
const eqIdx = trimmed.indexOf('=');
|
||
|
|
const key = trimmed.slice(0, eqIdx).trim();
|
||
|
|
const val = trimmed.slice(eqIdx + 1).trim();
|
||
|
|
if (key && val) { process.env[key] = val; injectedCount++; }
|
||
|
|
}
|
||
|
|
ok(`已注入 ${injectedCount} 个环境变量`);
|
||
|
|
decrypted = true;
|
||
|
|
break;
|
||
|
|
} catch (e) {
|
||
|
|
if (e.message === 'WRONG_PASSWORD') {
|
||
|
|
const remaining = 3 - attempt;
|
||
|
|
if (remaining > 0) fail(`密码错误, 剩余重试: ${remaining} 次`);
|
||
|
|
} else if (e.message === 'WRONG_FORMAT') {
|
||
|
|
fail('secrets.enc 格式不兼容, 请联系管理员');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!decrypted) {
|
||
|
|
fail('凭证解密失败 — Claude Code 将以登录模式启动');
|
||
|
|
errors++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 渲染 settings.json
|
||
|
|
const templateFile = path.join(CLAUDE_DIR, 'settings.template.json');
|
||
|
|
const settingsFile = path.join(CLAUDE_DIR, 'settings.json');
|
||
|
|
if (fs.existsSync(templateFile)) {
|
||
|
|
let tpl = fs.readFileSync(templateFile, 'utf8');
|
||
|
|
const claudeRoot = CLAUDE_DIR.replace(/\\/g, '/');
|
||
|
|
tpl = tpl.replace(/\{\{CLAUDE_ROOT\}\}/g, claudeRoot);
|
||
|
|
tpl = tpl.replace(/\{\{HOME\}\}/g, HOME.replace(/\\/g, '/')); // B6: 统一正斜杠
|
||
|
|
fs.writeFileSync(settingsFile, tpl);
|
||
|
|
ok('settings.json 已渲染');
|
||
|
|
}
|
||
|
|
// 渲染 settings.local.template.json
|
||
|
|
const localTpl = path.join(CLAUDE_DIR, 'settings.local.template.json');
|
||
|
|
const localSet = path.join(CLAUDE_DIR, 'settings.local.json');
|
||
|
|
if (fs.existsSync(localTpl)) {
|
||
|
|
let tpl = fs.readFileSync(localTpl, 'utf8');
|
||
|
|
tpl = tpl.replace(/\{\{CLAUDE_ROOT\}\}/g, CLAUDE_DIR.replace(/\\/g, '/'));
|
||
|
|
tpl = tpl.replace(/\{\{HOME\}\}/g, HOME.replace(/\\/g, '/')); // B6: 统一正斜杠
|
||
|
|
tpl = tpl.replace(/\{\{USERNAME\}\}/g, os.userInfo().username);
|
||
|
|
fs.writeFileSync(localSet, tpl);
|
||
|
|
ok('settings.local.json 已渲染');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 7. MCP + hooks 依赖 ───
|
||
|
|
step(7, TOTAL, 'MCP 与 hooks 依赖');
|
||
|
|
|
||
|
|
// MCP 配置写入 ~/.claude.json (Claude Code 的全局 MCP 存储位置)
|
||
|
|
const claudeJson = path.join(HOME, '.claude.json');
|
||
|
|
try {
|
||
|
|
let globalCfg = {};
|
||
|
|
if (fs.existsSync(claudeJson)) {
|
||
|
|
globalCfg = JSON.parse(fs.readFileSync(claudeJson, 'utf8'));
|
||
|
|
}
|
||
|
|
|
||
|
|
// 基础 MCP 列表 (npx 方式, 无需预装, 首次调用自动下载)
|
||
|
|
const baseMcps = {
|
||
|
|
'context7': {
|
||
|
|
command: 'npx.cmd', args: ['-y', '@upstash/context7-mcp@latest'], type: 'stdio'
|
||
|
|
},
|
||
|
|
'sequential-thinking': {
|
||
|
|
command: 'npx.cmd', args: ['-y', '@modelcontextprotocol/server-sequential-thinking@latest'], type: 'stdio'
|
||
|
|
},
|
||
|
|
'playwright': {
|
||
|
|
command: 'npx.cmd', args: ['-y', '@playwright/mcp@latest', '--headless'], type: 'stdio'
|
||
|
|
},
|
||
|
|
'firecrawl': {
|
||
|
|
command: 'npx.cmd', args: ['-y', 'firecrawl-mcp'], type: 'stdio',
|
||
|
|
env: { FIRECRAWL_API_KEY: '${FIRECRAWL_API_KEY}' }
|
||
|
|
},
|
||
|
|
'github': {
|
||
|
|
command: 'npx.cmd', args: ['-y', '@modelcontextprotocol/server-github'], type: 'stdio',
|
||
|
|
env: { GITHUB_PERSONAL_ACCESS_TOKEN: '${GITHUB_PERSONAL_ACCESS_TOKEN}' }
|
||
|
|
},
|
||
|
|
'linear': { type: 'http', url: 'https://mcp.linear.app/mcp' },
|
||
|
|
'figma': { type: 'http', url: 'https://mcp.figma.com/mcp' },
|
||
|
|
'supabase': { type: 'http', url: 'https://mcp.supabase.com/mcp?project_ref=oepmihbtoylosbsxlmfo' },
|
||
|
|
};
|
||
|
|
|
||
|
|
// 合并: 不覆盖用户已有配置
|
||
|
|
if (!globalCfg.mcpServers) globalCfg.mcpServers = {};
|
||
|
|
let added = 0;
|
||
|
|
for (const [name, cfg] of Object.entries(baseMcps)) {
|
||
|
|
if (!globalCfg.mcpServers[name]) {
|
||
|
|
globalCfg.mcpServers[name] = cfg;
|
||
|
|
added++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fs.writeFileSync(claudeJson, JSON.stringify(globalCfg, null, 2));
|
||
|
|
ok(`MCP 配置已写入 ~/.claude.json (新增 ${added} 个, 总计 ${Object.keys(globalCfg.mcpServers).length} 个)`);
|
||
|
|
} catch (e) {
|
||
|
|
warn('MCP 配置写入失败: ' + e.message);
|
||
|
|
}
|
||
|
|
|
||
|
|
// npm install in .claude for hooks
|
||
|
|
if (fs.existsSync(path.join(CLAUDE_DIR, 'package.json'))) {
|
||
|
|
info('安装 hooks 依赖...');
|
||
|
|
run(`npm install --omit=dev --registry ${NPM_MIRROR}`, { cwd: CLAUDE_DIR, ignoreError: true, silent: true });
|
||
|
|
ok('hooks 依赖已安装');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Python MCP 依赖
|
||
|
|
const pyCmd = hasCmd('python') ? 'python' : (hasCmd('python3') ? 'python3' : (hasCmd('py') ? 'py' : null));
|
||
|
|
if (pyCmd) {
|
||
|
|
info('安装 Python MCP 依赖...');
|
||
|
|
run(`${pyCmd} -m pip install askui scrapling --quiet`, { ignoreError: true, silent: true });
|
||
|
|
ok('Python MCP 依赖已安装');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 8. 桌面快捷方式 ───
|
||
|
|
step(8, TOTAL, '创建桌面快捷方式');
|
||
|
|
|
||
|
|
// 启动脚本: 用 pwsh/powershell 运行 install.ps1
|
||
|
|
const launchBat = path.join(BOOT_DIR, '启动Bookworm.bat');
|
||
|
|
if (fs.existsSync(launchBat)) {
|
||
|
|
if (createShortcut('Bookworm', launchBat, BOOT_DIR)) ok('Bookworm 快捷方式');
|
||
|
|
} else {
|
||
|
|
// 回退: 直接创建 claude 启动快捷方式
|
||
|
|
const claudePath = runSilent('where claude').trim().split('\n')[0];
|
||
|
|
if (claudePath) {
|
||
|
|
if (createShortcut('Bookworm', claudePath, HOME)) ok('Bookworm 快捷方式');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const updateBat = path.join(BOOT_DIR, '更新并启动Bookworm.bat');
|
||
|
|
if (fs.existsSync(updateBat)) {
|
||
|
|
if (createShortcut('更新Bookworm', updateBat, BOOT_DIR)) ok('更新Bookworm 快捷方式');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 9. 完成 ───
|
||
|
|
step(9, TOTAL, '安装完成');
|
||
|
|
|
||
|
|
console.log(`
|
||
|
|
${c.green}╔══════════════════════════════════════════════════╗
|
||
|
|
║ ║
|
||
|
|
║ 安装完成! ║
|
||
|
|
║ ║
|
||
|
|
║ [v] Node.js [v] Git [v] Python ║
|
||
|
|
║ [v] PS7 [v] Claude [v] MCP ║
|
||
|
|
║ [v] Bookworm - 92 Skills / 18 Agents ║
|
||
|
|
║ ║
|
||
|
|
║ 桌面快捷方式: Bookworm / 更新Bookworm ║
|
||
|
|
║ ║
|
||
|
|
╚══════════════════════════════════════════════════╝${c.reset}
|
||
|
|
`);
|
||
|
|
|
||
|
|
if (errors > 0) {
|
||
|
|
warn(`安装过程中有 ${errors} 个警告, 请查看上方日志`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 打开使用教程
|
||
|
|
const guide = path.join(BOOT_DIR, 'guide.html');
|
||
|
|
if (fs.existsSync(guide)) {
|
||
|
|
try { execSync(`start "" "${guide}"`, { stdio: 'pipe' }); } catch {}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 询问是否启动
|
||
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||
|
|
rl.question('\n 按回车启动 Bookworm (或输入 n 退出): ', (ans) => {
|
||
|
|
rl.close();
|
||
|
|
if (ans.trim().toLowerCase() === 'n') return;
|
||
|
|
|
||
|
|
console.log(`\n ${c.cyan}正在启动 Claude Code...${c.reset}\n`);
|
||
|
|
// 设置必要环境变量
|
||
|
|
const env = { ...process.env };
|
||
|
|
// W10: 追加而非覆盖用户已有 NO_PROXY
|
||
|
|
const existingNoProxy = process.env.NO_PROXY || process.env.no_proxy || '';
|
||
|
|
const bwDomains = 'bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1';
|
||
|
|
env.NO_PROXY = existingNoProxy ? `${existingNoProxy},${bwDomains}` : bwDomains;
|
||
|
|
env.CLAUDE_CODE_GIT_BASH_PATH = findGitBash();
|
||
|
|
|
||
|
|
const child = spawn('claude', [], {
|
||
|
|
stdio: 'inherit',
|
||
|
|
env,
|
||
|
|
cwd: HOME,
|
||
|
|
shell: true,
|
||
|
|
});
|
||
|
|
child.on('exit', () => process.exit(0));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════
|
||
|
|
// 快速启动模式: 静默更新 + 直接启动 (已安装过的机器)
|
||
|
|
// ═══════════════════════════════════════════
|
||
|
|
async function quickStart() {
|
||
|
|
console.log(` ${c.cyan}Bookworm 快速启动${c.reset} — 检查更新中...`);
|
||
|
|
|
||
|
|
let updated = false;
|
||
|
|
|
||
|
|
// 1. 静默更新 bookworm-boot
|
||
|
|
if (fs.existsSync(path.join(BOOT_DIR, '.git'))) {
|
||
|
|
try {
|
||
|
|
const before = runSilent(`git -C "${BOOT_DIR}" rev-parse HEAD`).trim();
|
||
|
|
run(`git -C "${BOOT_DIR}" pull --ff-only`, { ignoreError: true, silent: true, timeout: 15000 });
|
||
|
|
const after = runSilent(`git -C "${BOOT_DIR}" rev-parse HEAD`).trim();
|
||
|
|
if (before !== after) { ok('bookworm-boot 已更新'); updated = true; }
|
||
|
|
} catch {}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. 静默更新 bookworm-config (.claude)
|
||
|
|
if (fs.existsSync(path.join(CLAUDE_DIR, '.git', 'config'))) {
|
||
|
|
try {
|
||
|
|
const before = runSilent(`git -C "${CLAUDE_DIR}" rev-parse HEAD`).trim();
|
||
|
|
const stashOut = run(`git -C "${CLAUDE_DIR}" stash`, { ignoreError: true, silent: true }) || '';
|
||
|
|
run(`git -C "${CLAUDE_DIR}" pull --rebase`, { ignoreError: true, silent: true, timeout: 15000 });
|
||
|
|
if (stashOut.includes('Saved working directory')) {
|
||
|
|
run(`git -C "${CLAUDE_DIR}" stash pop`, { ignoreError: true, silent: true });
|
||
|
|
}
|
||
|
|
const after = runSilent(`git -C "${CLAUDE_DIR}" rev-parse HEAD`).trim();
|
||
|
|
if (before !== after) { ok('.claude 配置已更新'); updated = true; }
|
||
|
|
} catch {}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. 更新后重新渲染模板
|
||
|
|
if (updated) {
|
||
|
|
const templateFile = path.join(CLAUDE_DIR, 'settings.template.json');
|
||
|
|
const settingsFile = path.join(CLAUDE_DIR, 'settings.json');
|
||
|
|
if (fs.existsSync(templateFile)) {
|
||
|
|
let tpl = fs.readFileSync(templateFile, 'utf8');
|
||
|
|
tpl = tpl.replace(/\{\{CLAUDE_ROOT\}\}/g, CLAUDE_DIR.replace(/\\/g, '/'));
|
||
|
|
tpl = tpl.replace(/\{\{HOME\}\}/g, HOME.replace(/\\/g, '/'));
|
||
|
|
fs.writeFileSync(settingsFile, tpl);
|
||
|
|
}
|
||
|
|
const localTpl = path.join(CLAUDE_DIR, 'settings.local.template.json');
|
||
|
|
const localSet = path.join(CLAUDE_DIR, 'settings.local.json');
|
||
|
|
if (fs.existsSync(localTpl)) {
|
||
|
|
let tpl = fs.readFileSync(localTpl, 'utf8');
|
||
|
|
tpl = tpl.replace(/\{\{CLAUDE_ROOT\}\}/g, CLAUDE_DIR.replace(/\\/g, '/'));
|
||
|
|
tpl = tpl.replace(/\{\{HOME\}\}/g, HOME.replace(/\\/g, '/'));
|
||
|
|
tpl = tpl.replace(/\{\{USERNAME\}\}/g, os.userInfo().username);
|
||
|
|
fs.writeFileSync(localSet, tpl);
|
||
|
|
}
|
||
|
|
ok('配置模板已重新渲染');
|
||
|
|
|
||
|
|
// hooks 依赖更新
|
||
|
|
if (fs.existsSync(path.join(CLAUDE_DIR, 'package.json'))) {
|
||
|
|
run(`npm install --omit=dev --registry ${NPM_MIRROR}`, { cwd: CLAUDE_DIR, ignoreError: true, silent: true });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!updated) ok('已是最新版本');
|
||
|
|
|
||
|
|
// 4. 启动 Claude Code
|
||
|
|
console.log(`\n ${c.cyan}启动 Claude Code...${c.reset}\n`);
|
||
|
|
const env = { ...process.env };
|
||
|
|
const existingNoProxy = process.env.NO_PROXY || process.env.no_proxy || '';
|
||
|
|
const bwDomains = 'bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1';
|
||
|
|
env.NO_PROXY = existingNoProxy ? `${existingNoProxy},${bwDomains}` : bwDomains;
|
||
|
|
env.CLAUDE_CODE_GIT_BASH_PATH = findGitBash();
|
||
|
|
|
||
|
|
const child = spawn('claude', [], { stdio: 'inherit', env, cwd: HOME, shell: true });
|
||
|
|
child.on('exit', () => process.exit(0));
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch(e => {
|
||
|
|
fail(`安装引擎异常: ${e.message}`);
|
||
|
|
console.error(e.stack);
|
||
|
|
process.exit(1);
|
||
|
|
});
|