Path A: client switch to issuer token-exchange + strip orphan legacy files
auto-setup.ps1 / bw-ota.ps1: replace anonymous-first + Gitea-credential dialog branches with issuer token-exchange (auth.bookwormweb.com); embed public crypto payloads via base64 (no secrets). build.ps1: regenerate base64 payloads from source at compile time. Strip 4 zero-reference legacy/admin orphan files (setup-all.js, patches/admin-cb-routes.js, tools/legacy-gen-launcher-bats.ps1.archive, create-admin-powershell.bat). Coupled sensitive sources (gen-authcode.js, admin-authcode-gui.ps1, crypto-helper.js, install.ps1) deferred: still referenced by build.ps1 -Admin path and auto-setup priority-4 backward-compat decrypt chain; require dedicated surgery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
25cf34a0eb
commit
c6f89aad79
169
auto-setup.ps1
169
auto-setup.ps1
File diff suppressed because one or more lines are too long
47
build.ps1
47
build.ps1
@ -70,6 +70,53 @@ if ($buildSetup) {
|
||||
$bwVer = if ($versionLine) { $versionLine.Matches[0].Groups[1].Value } else { "0.0.0" }
|
||||
Write-Host " 版本: $bwVer" -ForegroundColor Gray
|
||||
|
||||
# ── [Path-A] 注入 token-exchange 载荷 base64 (单文件自包含 Setup.exe; 每次 build 与源同步) ──
|
||||
# 把三个公开加密载荷文件读出 → base64 → 替换 auto-setup.ps1 内 BEGIN/END 标记之间的内容。
|
||||
# 注意: 载荷全是公开加密代码, 不含任何 secret (authcode 运行时用户输入, pepper 永远服务器端)。
|
||||
Write-Step "注入 token-exchange 载荷 (base64 内嵌)"
|
||||
$PayloadSrcRoot = Join-Path $env:USERPROFILE "bookworm-pathA-design\phase1-skeleton"
|
||||
# 文件名 → 候选源路径 (优先 boot\issuer\ 暂存副本, 回退设计骨架仓)
|
||||
$payloadMap = [ordered]@{
|
||||
'seal-cli.js' = @((Join-Path $ScriptDir "issuer\seal-cli.js"), (Join-Path $PayloadSrcRoot "issuer\src\seal-cli.js"))
|
||||
'crypto-handshake.js' = @((Join-Path $ScriptDir "issuer\crypto-handshake.js"), (Join-Path $PayloadSrcRoot "issuer\src\crypto-handshake.js"))
|
||||
'bw-tokenexchange.ps1' = @((Join-Path $ScriptDir "issuer\bw-tokenexchange.ps1"), (Join-Path $PayloadSrcRoot "auto-setup-tokenexchange-draft\bw-tokenexchange.ps1"))
|
||||
}
|
||||
$b64Map = [ordered]@{}
|
||||
foreach ($name in $payloadMap.Keys) {
|
||||
$resolved = $null
|
||||
foreach ($cand in $payloadMap[$name]) { if (Test-Path $cand) { $resolved = $cand; break } }
|
||||
if (-not $resolved) {
|
||||
Write-Fail "载荷源缺失: $name (查找: $($payloadMap[$name] -join ' | '))"
|
||||
exit 1
|
||||
}
|
||||
$bytes = [IO.File]::ReadAllBytes($resolved)
|
||||
$b64Map[$name] = [Convert]::ToBase64String($bytes)
|
||||
Write-OK "$name <- $resolved ($($bytes.Length) B -> base64 $($b64Map[$name].Length) ch)"
|
||||
}
|
||||
# 生成 BEGIN..END 之间的内容 (4 空格缩进, 与 auto-setup.ps1 内 if 块对齐)
|
||||
$nl = "`r`n"
|
||||
$blockLines = New-Object System.Collections.Generic.List[string]
|
||||
$blockLines.Add("# <BW_PAYLOAD_BASE64_BEGIN> —— 由 build.ps1 从载荷源文件自动注入, 请勿手改")
|
||||
$blockLines.Add(" `$BwPayloadB64 = @{")
|
||||
foreach ($name in $b64Map.Keys) {
|
||||
$blockLines.Add(" '$name' = '$($b64Map[$name])'")
|
||||
}
|
||||
$blockLines.Add(" }")
|
||||
$blockLines.Add(" # <BW_PAYLOAD_BASE64_END>")
|
||||
$newBlock = $blockLines -join $nl
|
||||
# 整文件替换 BEGIN..END (含标记) —— 用 MatchEvaluator 避免 $ 被当作正则分组引用
|
||||
$content = [IO.File]::ReadAllText($inputPs1)
|
||||
$pattern = '(?s)# <BW_PAYLOAD_BASE64_BEGIN>.*?# <BW_PAYLOAD_BASE64_END>'
|
||||
if ($content -notmatch $pattern) {
|
||||
Write-Fail "auto-setup.ps1 缺少 BW_PAYLOAD_BASE64 标记, 无法注入 (检查 BEGIN/END 注释)"
|
||||
exit 1
|
||||
}
|
||||
$evaluator = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $newBlock }
|
||||
$content = [regex]::Replace($content, $pattern, $evaluator)
|
||||
# 保留 auto-setup.ps1 原编码: UTF-8 无 BOM (含中文; PS2EXE/pwsh 默认按 UTF-8 读)
|
||||
[IO.File]::WriteAllText($inputPs1, $content, (New-Object System.Text.UTF8Encoding($false)))
|
||||
Write-OK "base64 载荷已注入 auto-setup.ps1 (3 文件, 共 $($newBlock.Length) ch)"
|
||||
|
||||
# 优先用桌面专用 B 圆图标, 回退到 galaxy
|
||||
$iconFile = Join-Path $ScriptDir "bookworm-desktop.ico"
|
||||
if (-not (Test-Path $iconFile)) {
|
||||
|
||||
71
bw-ota.ps1
71
bw-ota.ps1
@ -35,6 +35,10 @@ $ConfigFile = Join-Path $OtaDir 'config.json'
|
||||
$CredFile = Join-Path $OtaDir 'pull-cred.dpapi'
|
||||
$PubKeyFile = Join-Path $OtaDir 'signing-pubkey.pem'
|
||||
|
||||
# [Path-A] dot-source token-exchange 共享模块 (随核心分发到 ~/.claude/issuer/)
|
||||
$BwTxModule = (Join-Path $env:USERPROFILE '.claude\issuer\bw-tokenexchange.ps1')
|
||||
if (Test-Path $BwTxModule) { . $BwTxModule }
|
||||
|
||||
# ========== 日志 ==========
|
||||
function Write-Ota ($m, $c = 'Cyan') { Write-Host "[Bookworm OTA] $m" -ForegroundColor $c }
|
||||
function Write-OtaOk ($m) { Write-Ota $m 'Green' }
|
||||
@ -92,6 +96,26 @@ function Read-Credential {
|
||||
}
|
||||
}
|
||||
|
||||
# ========== [Path-A] DPAPI authcode 读取 (替换 user/pass) ==========
|
||||
# [需完整宪法确认 OTA-authcode] 持久化 authcode 的安装时写入/轮换/撤销链 (DPAPI, CurrentUser 绑定)。
|
||||
# 复用 $CredFile, 期望明文 JSON 形如 { "authcode": "BWA-..." }。
|
||||
function Read-BwAuthcode {
|
||||
try {
|
||||
Add-Type -AssemblyName System.Security
|
||||
$encrypted = [IO.File]::ReadAllBytes($CredFile)
|
||||
$plain = [Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$encrypted,
|
||||
[Text.Encoding]::UTF8.GetBytes('bookworm-ota-salt'),
|
||||
[Security.Cryptography.DataProtectionScope]::CurrentUser
|
||||
)
|
||||
$json = [Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
|
||||
if ($json -and $json.authcode) { return [string]$json.authcode }
|
||||
return $null
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 语义版本比较 (major.minor.patch) ==========
|
||||
function Compare-SemVer ($local, $remote) {
|
||||
$lParts = ($local -replace '^v', '') -split '\.' | ForEach-Object { [int]$_ }
|
||||
@ -130,11 +154,11 @@ function Get-AuthHeaders ($cred) {
|
||||
}
|
||||
|
||||
# ========== 远端版本查询 (Gitea raw, 3s 超时) ==========
|
||||
function Get-RemoteVersion ($cred, $cfg) {
|
||||
function Get-RemoteVersion ($token, $cfg) {
|
||||
$repoUrl = if ($cfg.repoUrl) { $cfg.repoUrl } else { 'https://code.letcareme.com/bookworm/bookworm-smart-assistant' }
|
||||
$apiUrl = "$repoUrl/raw/branch/main/VERSION"
|
||||
try {
|
||||
$headers = Get-AuthHeaders $cred
|
||||
$headers = Get-BwTokenAuthHeader -ShortLivedToken $token # [Path-A] token 鉴权替换 Basic
|
||||
$resp = Invoke-WebRequest -Uri $apiUrl -Headers $headers -UseBasicParsing -TimeoutSec 3
|
||||
return $resp.Content.Trim()
|
||||
} catch {
|
||||
@ -143,13 +167,13 @@ function Get-RemoteVersion ($cred, $cfg) {
|
||||
}
|
||||
|
||||
# ========== 远端最新 tag 查询 (备用) ==========
|
||||
function Get-RemoteLatestTag ($cred, $cfg) {
|
||||
function Get-RemoteLatestTag ($token, $cfg) {
|
||||
$repoUrl = if ($cfg.repoUrl) { $cfg.repoUrl } else { 'https://code.letcareme.com/bookworm/bookworm-smart-assistant' }
|
||||
$host_ = ([Uri]$repoUrl).Host
|
||||
$repoPath = ([Uri]$repoUrl).AbsolutePath.TrimStart('/')
|
||||
$apiUrl = "https://$host_/api/v1/repos/$repoPath/tags?limit=1"
|
||||
try {
|
||||
$headers = Get-AuthHeaders $cred
|
||||
$headers = Get-BwTokenAuthHeader -ShortLivedToken $token # [Path-A] token 鉴权替换 Basic
|
||||
$resp = Invoke-WebRequest -Uri $apiUrl -Headers $headers -UseBasicParsing -TimeoutSec 3
|
||||
$tags = $resp.Content | ConvertFrom-Json
|
||||
if ($tags.Count -gt 0) { return $tags[0].name }
|
||||
@ -158,7 +182,7 @@ function Get-RemoteLatestTag ($cred, $cfg) {
|
||||
}
|
||||
|
||||
# ========== 同步核心 (精简版 bookworm-sync.ps1) ==========
|
||||
function Invoke-OtaSync ($cred, $cfg, $remoteVersion) {
|
||||
function Invoke-OtaSync ($token, $cfg, $remoteVersion) {
|
||||
$repoUrl = if ($cfg.repoUrl) { $cfg.repoUrl } else { 'https://code.letcareme.com/bookworm/bookworm-smart-assistant' }
|
||||
$host_ = ([Uri]$repoUrl).Host
|
||||
$repoPath = ([Uri]$repoUrl).AbsolutePath.TrimStart('/')
|
||||
@ -167,14 +191,19 @@ function Invoke-OtaSync ($cred, $cfg, $remoteVersion) {
|
||||
|
||||
$stageDir = Join-Path $env:TEMP "bw-ota-$([Guid]::NewGuid().ToString().Substring(0,8))"
|
||||
|
||||
# 1. Clone (凭证通过 credential manager 传递, 不嵌入 URL)
|
||||
# 1. [Path-A] token-exchange clone: 短期 token 嵌 URL + credential.helper= 临时覆盖
|
||||
# 不再 git credential approve / 不写 Credential Manager; clone 后立即清理 token 变量。
|
||||
Write-Ota "下载 $ref ..."
|
||||
$plainUrl = "https://${host_}/${repoPath}.git"
|
||||
$credInput = "protocol=https`nhost=${host_}`nusername=$($cred.user)`npassword=$($cred.pass)`n`n"
|
||||
$credInput | & git credential approve 2>$null
|
||||
$cloneArgs = @('-c', 'core.longpaths=true', 'clone', '--depth', '1', '--branch', $ref, '--single-branch', $plainUrl, $stageDir)
|
||||
& git @cloneArgs 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "clone 失败 (exit $LASTEXITCODE)" }
|
||||
$uri = [System.Uri]("https://${host_}/${repoPath}.git")
|
||||
$authedUrl = "$($uri.Scheme)://$([System.Uri]::EscapeDataString('bw-token')):$([System.Uri]::EscapeDataString($token))@$($uri.Host)$($uri.PathAndQuery)"
|
||||
$cloneArgs = @('-c', 'credential.helper=', '-c', 'core.longpaths=true', 'clone', '--depth', '1', '--branch', $ref, '--single-branch', $authedUrl, $stageDir)
|
||||
try {
|
||||
& git @cloneArgs 2>&1 | Out-Null
|
||||
$cloneExit = $LASTEXITCODE
|
||||
} finally {
|
||||
Remove-Variable -Name authedUrl -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($cloneExit -ne 0) { throw "clone 失败 (exit $cloneExit)" }
|
||||
$gitDir = Join-Path $stageDir '.git'
|
||||
if (Test-Path $gitDir) { Remove-Item -Recurse -Force $gitDir }
|
||||
|
||||
@ -321,21 +350,25 @@ function Invoke-OtaMain {
|
||||
return
|
||||
}
|
||||
|
||||
# 解密凭证
|
||||
$cred = Read-Credential
|
||||
if (-not $cred -or -not $cred.user -or -not $cred.pass) {
|
||||
Write-OtaWarn "凭证解密失败, 跳过更新检查"
|
||||
# [Path-A] 取持久化 authcode → 换短期 token (替换 DPAPI user/pass; 模块缺失则 fail-open 跳过)
|
||||
if (-not (Get-Command Get-BwShortLivedToken -ErrorAction SilentlyContinue)) {
|
||||
Write-OtaWarn "token-exchange 模块未加载, 跳过更新检查"
|
||||
return
|
||||
}
|
||||
$authcode = Read-BwAuthcode
|
||||
if (-not $authcode) { Write-OtaWarn "未找到 authcode, 跳过更新检查"; return }
|
||||
$token = Get-BwShortLivedToken -Authcode $authcode -DeviceId (Get-BwDeviceId)
|
||||
Remove-Variable -Name authcode -ErrorAction SilentlyContinue
|
||||
if (-not $token) { Write-OtaWarn "token 换取失败, 跳过更新检查"; return }
|
||||
|
||||
# 本地 vs 远端版本
|
||||
$localVer = Get-LocalVersion
|
||||
if ($DryRun) { Write-Ota "[DryRun 模式] 仅验证, 不替换文件" 'Yellow' }
|
||||
Write-Ota "当前版本: $localVer"
|
||||
|
||||
$remoteVer = Get-RemoteVersion $cred $cfg
|
||||
$remoteVer = Get-RemoteVersion $token $cfg
|
||||
if (-not $remoteVer) {
|
||||
$remoteVer = Get-RemoteLatestTag $cred $cfg
|
||||
$remoteVer = Get-RemoteLatestTag $token $cfg
|
||||
if ($remoteVer) { $remoteVer = $remoteVer -replace '^v', '' }
|
||||
}
|
||||
|
||||
@ -375,7 +408,7 @@ function Invoke-OtaMain {
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-OtaSync $cred $cfg $remoteClean
|
||||
Invoke-OtaSync $token $cfg $remoteClean
|
||||
Write-Host ""
|
||||
}
|
||||
catch {
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Circuit Breaker 管理端点补丁
|
||||
* 追加到 routes/admin.js 末尾 (在 module.exports 函数内)
|
||||
*
|
||||
* 用法: 部署时将此内容追加到 admin.js 的 registerAdminRoutes 函数体内
|
||||
*/
|
||||
|
||||
// ─── 熔断器状态查看 (admin) ───
|
||||
// routes['GET:/v1/admin/circuit-breaker'] = async (req, res) => {
|
||||
// requireAdmin(req);
|
||||
// const cbStatus = deps.circuitBreaker.getStatus();
|
||||
// const cbLog = deps.circuitBreaker.getTransitionLog(20);
|
||||
// json(res, 200, { ok: true, breakers: cbStatus, recentTransitions: cbLog });
|
||||
// };
|
||||
|
||||
// ─── 熔断器重置 (admin) ───
|
||||
// routes['POST:/v1/admin/circuit-breaker/reset'] = async (req, res) => {
|
||||
// requireAdmin(req);
|
||||
// const body = await parseJsonBody(req);
|
||||
// if (body.provider) {
|
||||
// deps.circuitBreaker.reset(body.provider);
|
||||
// json(res, 200, { ok: true, reset: body.provider });
|
||||
// } else {
|
||||
// deps.circuitBreaker.resetAll();
|
||||
// json(res, 200, { ok: true, reset: 'all' });
|
||||
// }
|
||||
// };
|
||||
|
||||
module.exports = function patchAdminRoutes(routes, deps) {
|
||||
const { json, parseJsonBody, requireAdmin } = deps;
|
||||
|
||||
routes['GET:/v1/admin/circuit-breaker'] = async (req, res) => {
|
||||
requireAdmin(req);
|
||||
const cbStatus = deps.circuitBreaker.getStatus();
|
||||
const cbLog = deps.circuitBreaker.getTransitionLog ? deps.circuitBreaker.getTransitionLog(20) : [];
|
||||
json(res, 200, { ok: true, breakers: cbStatus, recentTransitions: cbLog });
|
||||
};
|
||||
|
||||
routes['POST:/v1/admin/circuit-breaker/reset'] = async (req, res) => {
|
||||
requireAdmin(req);
|
||||
const body = await parseJsonBody(req);
|
||||
if (body.provider) {
|
||||
deps.circuitBreaker.reset(body.provider);
|
||||
json(res, 200, { ok: true, reset: body.provider });
|
||||
} else if (deps.circuitBreaker.resetAll) {
|
||||
deps.circuitBreaker.resetAll();
|
||||
json(res, 200, { ok: true, reset: 'all' });
|
||||
} else {
|
||||
json(res, 400, { error: '请指定 provider' });
|
||||
}
|
||||
};
|
||||
};
|
||||
616
setup-all.js
616
setup-all.js
@ -1,616 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
@ -1,218 +0,0 @@
|
||||
# Bookworm Portable 启动器 bat 生成工具 (v3.0.6)
|
||||
# 用途: 从单一明文 PowerShell 脚本生成两个 bat, 避免手工同步 Base64 字符串不一致
|
||||
# 用法: pwsh -NoProfile -File tools/gen-launcher-bats.ps1
|
||||
# 输出: 启动Bookworm.bat + 更新并启动Bookworm.bat (覆盖写入)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$launchBat = Join-Path $repoRoot "启动Bookworm.bat"
|
||||
$updateBat = Join-Path $repoRoot "更新并启动Bookworm.bat"
|
||||
|
||||
# ─── 明文: 三层 PATH 修复 + DPAPI 加载 + claude 诊断 + 启动 ─────────
|
||||
# v3.0.9: 增加 npm config get prefix 动态查询, 兼容 nvm/fnm/Program Files 等非标准 npm 位置
|
||||
$plainScript = @'
|
||||
Add-Type -AssemblyName System.Security
|
||||
# 层 1: Machine + User env PATH (标准 Windows 环境变量)
|
||||
$env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')
|
||||
# 层 2: npm config get prefix (真实 npm 全局目录, 兼容 nvm/fnm/标准安装/Program Files)
|
||||
try {
|
||||
$npmPrefix = (& npm config get prefix 2>$null | Select-Object -First 1).Trim()
|
||||
if ($npmPrefix -and (Test-Path $npmPrefix) -and ($env:Path -notlike "*$npmPrefix*")) {
|
||||
$env:Path = "$npmPrefix;$env:Path"
|
||||
}
|
||||
} catch {}
|
||||
# 层 3: 常见 npm global 硬编码兜底 (npm 本身不在 PATH 时无法 query)
|
||||
$npmCandidates = @(
|
||||
"$env:APPDATA\npm",
|
||||
"$env:ProgramFiles\nodejs",
|
||||
"${env:ProgramFiles(x86)}\nodejs",
|
||||
"$env:LOCALAPPDATA\npm"
|
||||
)
|
||||
foreach ($p in $npmCandidates) {
|
||||
if (-not (Test-Path $p)) { continue }
|
||||
$hasClaude = (Test-Path (Join-Path $p 'claude.ps1')) -or (Test-Path (Join-Path $p 'claude.cmd')) -or (Test-Path (Join-Path $p 'claude'))
|
||||
if ($hasClaude -and ($env:Path -notlike "*$p*")) {
|
||||
$env:Path = "$p;$env:Path"
|
||||
}
|
||||
}
|
||||
# DPAPI 加载缓存凭证
|
||||
$r = 'HKCU:\Software\Bookworm\CachedEnv'
|
||||
try {
|
||||
(Get-ItemProperty $r -EA Stop).PSObject.Properties | Where-Object { $_.Name -match '^[A-Z_]+$' } | ForEach-Object {
|
||||
$v = $_.Value
|
||||
try {
|
||||
$b = [Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($v), $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
||||
$v = [Text.Encoding]::UTF8.GetString($b)
|
||||
} catch {}
|
||||
[Environment]::SetEnvironmentVariable($_.Name, $v, 'Process')
|
||||
}
|
||||
} catch {}
|
||||
if (-not (Get-Command claude -ErrorAction SilentlyContinue)) {
|
||||
Write-Host ''
|
||||
Write-Host ' [!] claude 命令未找到 (已尝试 3 层 PATH 修复仍失败)' -ForegroundColor Red
|
||||
Write-Host ''
|
||||
Write-Host ' 诊断信息:' -ForegroundColor Yellow
|
||||
Write-Host " npm prefix: $(try { (& npm config get prefix 2>$null) } catch { '(npm 不可用)' })" -ForegroundColor Gray
|
||||
Write-Host ' PATH 片段 (npm/nodejs/pwsh/Git):' -ForegroundColor Gray
|
||||
($env:Path -split ';') | Where-Object { $_ -match 'npm|nodejs|pwsh|Git' } | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
Write-Host ''
|
||||
Write-Host ' 修复: 重新运行 Bookworm-Setup.exe (v3.0.9+) 即可自动补全' -ForegroundColor Green
|
||||
Write-Host ''
|
||||
Read-Host '按回车关闭'
|
||||
return
|
||||
}
|
||||
& claude --dangerously-skip-permissions
|
||||
'@
|
||||
|
||||
# ─── Base64-UTF-16LE 编码 ─────────────────────────────────
|
||||
$bytes = [System.Text.Encoding]::Unicode.GetBytes($plainScript)
|
||||
$enc = [Convert]::ToBase64String($bytes)
|
||||
|
||||
# 健康检查
|
||||
if ($enc.Length -gt 7500) { throw "Base64 长度 $($enc.Length) 超 bat 变量安全上限 7500" }
|
||||
$bad = $enc -replace '[A-Za-z0-9+/=]', ''
|
||||
if ($bad) { throw "Base64 含非法字符: [$bad]" }
|
||||
|
||||
Write-Host "[gen-launcher-bats] Base64 长度: $($enc.Length), 纯字符集检查 OK" -ForegroundColor Green
|
||||
|
||||
# ─── bat 1: 启动Bookworm.bat ──────────────────────────────
|
||||
$launch = @"
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: 中转站在国内,不走代理
|
||||
set NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1
|
||||
set no_proxy=%NO_PROXY%
|
||||
|
||||
:: 静默自动更新 (bookworm-boot + .claude 配置, 失败不阻断启动)
|
||||
echo [..] 检查更新...
|
||||
git pull --rebase >nul 2>nul
|
||||
git -C "%USERPROFILE%\.claude" pull --rebase >nul 2>nul
|
||||
|
||||
set USE_WT=0
|
||||
where wt >nul 2>nul && set USE_WT=1
|
||||
|
||||
set USE_PWSH7=0
|
||||
where pwsh >nul 2>nul && set USE_PWSH7=1
|
||||
|
||||
:: v3.0.6: Base64-UTF-16LE (PATH 重载 + DPAPI 凭证加载 + claude 诊断 + 启动)
|
||||
:: 纯 A-Za-z0-9+/= 字符集, 避免 wt.exe 的 ';' 切 tab 误切 (修复 64856bc 症状一)
|
||||
:: -d "%CD%" 无尾反斜杠, 避免 -d "%~dp0" 的转义引号 (修复 0c33109 症状二)
|
||||
:: 重新生成: pwsh -NoProfile -File tools/gen-launcher-bats.ps1
|
||||
set ENC=$enc
|
||||
|
||||
:: 优先路径: wt + pwsh7
|
||||
if %USE_WT% equ 1 if %USE_PWSH7% equ 1 (
|
||||
start "" wt new-tab --title "Bookworm Smart Assistant" -d "%CD%" -- pwsh -NoLogo -NoExit -EncodedCommand %ENC%
|
||||
exit
|
||||
)
|
||||
|
||||
:: 路径 2: wt + powershell 5.1
|
||||
if %USE_WT% equ 1 if %USE_PWSH7% equ 0 (
|
||||
start "" wt new-tab --title "Bookworm Smart Assistant" -d "%CD%" -- powershell -NoLogo -ExecutionPolicy Bypass -NoExit -EncodedCommand %ENC%
|
||||
exit
|
||||
)
|
||||
|
||||
:: 路径 3: conhost + pwsh7 (无 wt 就不会有 ; 切 tab 问题, 但仍用 Base64 统一)
|
||||
if %USE_PWSH7% equ 1 (
|
||||
start "Bookworm Smart Assistant" pwsh -NoLogo -NoExit -EncodedCommand %ENC%
|
||||
exit
|
||||
)
|
||||
|
||||
:: 路径 4: 回退 PowerShell 5.1 (最低保障, 交给 install.ps1 -StartOnly 处理)
|
||||
title Bookworm Portable
|
||||
echo.
|
||||
echo [!] PowerShell 7 未安装, 使用 PowerShell 5.1
|
||||
echo.
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 -StartOnly -AutoAccept
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo 启动失败,按任意键退出...
|
||||
pause > nul
|
||||
)
|
||||
"@
|
||||
|
||||
# ─── bat 2: 更新并启动Bookworm.bat ───────────────────────
|
||||
$update = @"
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: 中转站在国内,不走代理
|
||||
set NO_PROXY=bww.letcareme.com,code.letcareme.com,letcareme.com,localhost,127.0.0.1
|
||||
set no_proxy=%NO_PROXY%
|
||||
|
||||
:: 静默自动更新 (bookworm-boot + .claude 配置)
|
||||
echo [..] 同步更新...
|
||||
git pull --rebase >nul 2>nul
|
||||
git -C "%USERPROFILE%\.claude" pull --rebase >nul 2>nul
|
||||
|
||||
:: v3.0.6: 同启动Bookworm.bat 的 Base64 (DPAPI + PATH 重载 + claude 启动)
|
||||
set ENC=$enc
|
||||
|
||||
:: 检测 pwsh7 可用性
|
||||
where pwsh >nul 2>nul
|
||||
if %errorlevel% equ 0 (
|
||||
:: pwsh7: 先同步配置 (SkipLaunch 不启动 claude), 再用 -EncodedCommand 在新窗口启动
|
||||
pwsh -NoLogo -ExecutionPolicy Bypass -File "%~dp0install.ps1" -AutoAccept -SkipLaunch
|
||||
start "Bookworm Smart Assistant" pwsh -NoLogo -NoExit -EncodedCommand %ENC%
|
||||
exit
|
||||
)
|
||||
|
||||
:: 回退 PowerShell 5.1: 一次调用完成更新+加载凭证+启动 (消除双次调用)
|
||||
title Bookworm Portable
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0install.ps1" -AutoAccept
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo 启动失败,按任意键退出...
|
||||
pause > nul
|
||||
)
|
||||
"@
|
||||
|
||||
# ─── 写入 ─────────────────────────────────────────────────
|
||||
# bat 文件默认期望 GBK/ANSI, 但脚本顶部 chcp 65001 已切换到 UTF-8, 用无 BOM UTF-8 写入
|
||||
[System.IO.File]::WriteAllText($launchBat, $launch, [System.Text.UTF8Encoding]::new($false))
|
||||
[System.IO.File]::WriteAllText($updateBat, $update, [System.Text.UTF8Encoding]::new($false))
|
||||
Write-Host "[gen-launcher-bats] ✓ 启动Bookworm.bat ($((Get-Item $launchBat).Length) bytes)" -ForegroundColor Green
|
||||
Write-Host "[gen-launcher-bats] ✓ 更新并启动Bookworm.bat ($((Get-Item $updateBat).Length) bytes)" -ForegroundColor Green
|
||||
|
||||
# ─── Round-trip 验证 (v3.0.10: 除了 PARSE 还要 lint + 实跑不启动 claude) ────
|
||||
$decoded = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($enc))
|
||||
$err = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseInput($decoded, [ref]$null, [ref]$err)
|
||||
if ($err) { throw "解码后脚本 PARSE ERR: $($err[0])" }
|
||||
Write-Host "[gen-launcher-bats] ✓ PARSE OK ($($decoded.Length) chars)" -ForegroundColor Green
|
||||
|
||||
# 实跑验证 (v3.0.10: 截断到 & claude 之前, 只跑 PATH 修复 + DPAPI 加载, 不启动 claude)
|
||||
# 这能抓出 PARSE 通过但运行时报错的 bug (例如 -or 被当 Test-Path 参数)
|
||||
$runnable = $decoded -replace '& claude --dangerously-skip-permissions', 'Write-Host "__BW_DRYRUN_OK__"'
|
||||
$tmpPs1 = Join-Path $env:TEMP "bw-launcher-dryrun-$(Get-Random).ps1"
|
||||
Set-Content -Path $tmpPs1 -Value $runnable -Encoding UTF8
|
||||
try {
|
||||
$dryRunOutput = (& pwsh -NoProfile -ExecutionPolicy Bypass -File $tmpPs1 2>&1 | Out-String)
|
||||
# 只抓真正的 PS 错误 (ErrorRecord / cannot be found / parameter name / 等)
|
||||
$errorPatterns = @(
|
||||
'cannot be found that matches parameter name',
|
||||
'A parameter cannot be found',
|
||||
'CommandNotFoundException',
|
||||
'ParameterBindingException',
|
||||
'is not recognized as',
|
||||
'cannot find.*because it does not exist',
|
||||
'RuntimeException'
|
||||
)
|
||||
$hasError = $false
|
||||
foreach ($pat in $errorPatterns) {
|
||||
if ($dryRunOutput -match $pat) { $hasError = $true; break }
|
||||
}
|
||||
# 必须看到 dry-run 成功标记才算通过
|
||||
$reachedEnd = $dryRunOutput -match '__BW_DRYRUN_OK__'
|
||||
if ($hasError -or -not $reachedEnd) {
|
||||
Write-Host "[gen-launcher-bats] ✗ 实跑验证失败:" -ForegroundColor Red
|
||||
Write-Host $dryRunOutput -ForegroundColor DarkRed
|
||||
throw "Base64 解码后脚本运行时错误 (hasError=$hasError, reachedEnd=$reachedEnd)"
|
||||
}
|
||||
Write-Host "[gen-launcher-bats] ✓ 实跑通过 (dry-run 到达 __BW_DRYRUN_OK__ 标记)" -ForegroundColor Green
|
||||
} finally {
|
||||
Remove-Item $tmpPs1 -Force -EA SilentlyContinue
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ========================================================
|
||||
echo Create Admin PowerShell Shortcut for Claude Code
|
||||
echo ========================================================
|
||||
echo.
|
||||
|
||||
:: Check admin privileges
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [!] Requesting administrator privileges...
|
||||
powershell -Command "Start-Process '%~f0' -Verb RunAs"
|
||||
exit /b
|
||||
)
|
||||
|
||||
:: Detect PowerShell path
|
||||
set "PS_PATH="
|
||||
for %%p in (
|
||||
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
"%ProgramFiles%\PowerShell\7\pwsh.exe"
|
||||
"%ProgramFiles(x86)%\PowerShell\7\pwsh.exe"
|
||||
) do (
|
||||
if exist %%p (
|
||||
set "PS_PATH=%%p"
|
||||
goto :ps_found
|
||||
)
|
||||
)
|
||||
|
||||
:ps_found
|
||||
if "!PS_PATH!" == "" (
|
||||
echo [ERROR] PowerShell not found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [OK] PowerShell: !PS_PATH!
|
||||
|
||||
:: Create admin shortcut
|
||||
set "SHORTCUT_PATH=%USERPROFILE%\Desktop\Claude Code (Admin Terminal).lnk"
|
||||
|
||||
powershell -NoProfile -Command "$WshShell = New-Object -ComObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%SHORTCUT_PATH%'); $Shortcut.TargetPath = '!PS_PATH!'; $Shortcut.Arguments = '-NoExit -Command \"Write-Host ''Claude Code Admin Terminal Ready'' -ForegroundColor Green; Write-Host ''Type: claude'' -ForegroundColor Yellow\"'; $Shortcut.WorkingDirectory = '%USERPROFILE%'; $Shortcut.Description = 'PowerShell with Admin Rights for Claude Code'; $Shortcut.Save(); $bytes = [System.IO.File]::ReadAllBytes('%SHORTCUT_PATH%'); $bytes[0x15] = $bytes[0x15] -bor 0x20; [System.IO.File]::WriteAllBytes('%SHORTCUT_PATH%', $bytes)"
|
||||
|
||||
if exist "%SHORTCUT_PATH%" (
|
||||
echo.
|
||||
echo [SUCCESS] Admin terminal shortcut created!
|
||||
echo.
|
||||
echo [Usage]
|
||||
echo 1. Double-click "Claude Code (Admin Terminal)" on desktop
|
||||
echo 2. Type: claude
|
||||
echo 3. Claude Code will run with administrator privileges
|
||||
echo.
|
||||
) else (
|
||||
echo [ERROR] Failed to create shortcut
|
||||
)
|
||||
|
||||
pause
|
||||
Loading…
Reference in New Issue
Block a user