claude-code-best/scripts/postinstall.cjs
IronRookieCoder 223b694965 fix(windows): prevent terminal startup failures
## Bug 详情
- issues.md #46:Windows MSYS2/UCRT64 下执行 csc,欢迎信息输出后在 stdin raw mode/终端输入初始化阶段崩溃,出现 native std::bad_weak_ptr 和后续 Bun mutex corrupt。
- issues.md #47:cmd.exe 可启动,但 PowerShell 运行 csc 会走 npm 生成的 csc.ps1;截图显示 node.exe 报错 "Input must be provided either through stdin or as a prompt argument when using --print",说明裸 csc 被误判成 print/non-interactive 模式。

## 根因
- MSYS2/Cygwin/mintty 等 Windows 终端对 Node/Bun stdin raw mode 和终端扩展控制序列兼容性不稳定,启动早期强行 setRawMode/查询终端能力会触发崩溃风险。
- npm 的 PowerShell shim 解析优先级高于 csc.cmd,且可能让 stdout TTY 状态不可用;原 main.tsx 只要 stdout 非 TTY 就自动进入 non-interactive/--print 路径,最终在没有 prompt/stdin 时触发 #47 的报错。

## 修复方案
- 为 Ink stdin raw mode 增加 Windows 风险终端检测,在 MSYS2/Cygwin/mintty 或显式禁用开关下跳过 raw mode,使用 cooked input fallback。
- early input 捕获在启动早期复用同类风险判断,避免 REPL 渲染前就触发 raw mode 崩溃。
- postinstall 在 Windows 上清理属于本包的 csc.ps1,让 PowerShell 回落到与 cmd.exe 一致的 csc.cmd 启动路径。
- 新增 non-interactive 判定工具,保留显式 -p/--print、--init-only、--sdk-url 的 headless 行为,但要求 stdin/stdout 都不是交互 TTY 才自动进入 non-interactive,避免 PowerShell shim 下裸 csc 被误判。

## 变更要点
- 新增 packages/@ant/ink/src/core/raw-mode-support.ts 及单元测试,覆盖普通 Windows shell、MSYS2/Cygwin/mintty 和禁用开关
- 调整 Ink App raw mode 生命周期,只在真正启用 raw mode 后写入/清理 bracketed paste、focus reporting、extended key 等终端控制序列
- 调整 component unmount/exit 清理逻辑,按 rawModeEnabledCount/rawModeActive 关闭实际启用的输入监听和 raw mode
- 调整 src/utils/earlyInput.ts,避免不安全 Windows 终端在启动阶段启用 stdin raw mode
- 调整 scripts/postinstall.cjs,立即和延迟清理 npm 生成的 csc.ps1 shim
- 调整 src/main.tsx 使用 shouldUseNonInteractiveSession,并新增对应测试覆盖 PowerShell shim 场景

## 自测
- bun test packages/@ant/ink/src/core/__tests__/raw-mode-support.test.ts src/utils/__tests__/earlyInput.test.ts src/utils/__tests__/nonInteractiveSession.test.ts
- node --check scripts/postinstall.cjs
- bun run src/entrypoints/cli.tsx --version
- bun run lint
- git diff --check

## 已知限制
- bun run typecheck 仍失败,错误来自仓库既有缺失声明/缺失模块和无关类型问题,不来自本次改动。
2026-05-14 17:07:51 +08:00

460 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Postinstall script — runs automatically after `bun install` or `npm install`.
*
* Downloads ripgrep binary (idempotent, skips if exists).
* Works in dev mode (src/ exists), published mode (dist/ exists), with bun or node.
*
* Usage:
* node scripts/postinstall.js
* node scripts/postinstall.js --force
* bun run scripts/postinstall.js
*/
const { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, chmodSync } =
require("fs")
const { spawn, spawnSync } = require("child_process")
const { setDefaultResultOrder } = require("node:dns")
const path = require("path")
const os = require("os")
// Prefer IPv4 first — Bun on Windows sometimes fails GitHub over broken IPv6 paths.
try {
setDefaultResultOrder("ipv4first")
} catch {
/* ignore */
}
// --- Config ---
const RG_VERSION = "15.0.1"
const DEFAULT_RELEASE_BASE = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}`
const MIRROR_RELEASE_BASE = `https://ghproxy.net/https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}`
const RELEASE_BASE = (process.env.RIPGREP_DOWNLOAD_BASE ?? DEFAULT_RELEASE_BASE).replace(/\/$/, "")
// costrict change: fallback to private registry when GitHub and ghproxy are both unreachable
const COSTRICT_PRIVATE_BASE = "https://shenma.sangfor.com.cn/costrict-cli/pkg/ripgrep"
const scriptDir = path.dirname(__filename)
const projectRoot = path.resolve(scriptDir, "..")
// --- Platform mapping ---
function getPlatformMapping() {
const arch = process.arch
const platform = process.platform
if (platform === "darwin") {
if (arch === "arm64") return { target: "aarch64-apple-darwin", ext: "tar.gz" }
if (arch === "x64") return { target: "x86_64-apple-darwin", ext: "tar.gz" }
throw new Error(`Unsupported macOS arch: ${arch}`)
}
if (platform === "win32") {
if (arch === "x64") return { target: "x86_64-pc-windows-msvc", ext: "zip" }
if (arch === "arm64") return { target: "aarch64-pc-windows-msvc", ext: "zip" }
throw new Error(`Unsupported Windows arch: ${arch}`)
}
if (platform === "linux") {
const isMusl = detectMusl()
if (arch === "x64") {
return { target: "x86_64-unknown-linux-musl", ext: "tar.gz" }
}
if (arch === "arm64") {
return isMusl
? { target: "aarch64-unknown-linux-musl", ext: "tar.gz" }
: { target: "aarch64-unknown-linux-gnu", ext: "tar.gz" }
}
throw new Error(`Unsupported Linux arch: ${arch}`)
}
throw new Error(`Unsupported platform: ${platform}`)
}
function detectMusl() {
const muslArch = process.arch === "x64" ? "x86_64" : "aarch64"
try {
statSync(`/lib/libc.musl-${muslArch}.so.1`)
return true
} catch {
return false
}
}
// --- Paths ---
function getVendorDir() {
if (existsSync(path.join(projectRoot, "src"))) {
return path.resolve(projectRoot, "src", "utils", "vendor", "ripgrep")
}
return path.resolve(projectRoot, "dist", "vendor", "ripgrep")
}
function getBinaryPath() {
const dir = getVendorDir()
const subdir = `${process.arch}-${process.platform}`
const binary = process.platform === "win32" ? "rg.exe" : "rg"
return path.resolve(dir, subdir, binary)
}
function safeResolve(...parts) {
const filtered = parts.filter(Boolean)
if (filtered.length === 0) return null
return path.resolve(...filtered)
}
function removePowerShellShimIfOwned(shimPath) {
if (!shimPath || !existsSync(shimPath)) return false
const cmdShimPath = shimPath.replace(/\.ps1$/i, ".cmd")
if (!existsSync(cmdShimPath)) return false
let content
try {
content = readFileSync(shimPath, "utf8")
} catch {
return false
}
// Only remove npm-generated shims for this package. PowerShell resolves
// csc.ps1 before csc.cmd, and enterprise ExecutionPolicy often blocks .ps1
// launchers while cmd.exe works. Removing this shim makes PowerShell fall
// through to the same .cmd launcher used by cmd.exe.
const normalized = content.replace(/\\/g, "/")
if (!normalized.includes("@costrict") && !normalized.includes("dist/cli-node.js")) {
return false
}
try {
rmSync(shimPath, { force: true })
console.log(`[postinstall] Removed PowerShell shim ${shimPath}; PowerShell will use csc.cmd`)
return true
} catch (error) {
console.warn(
`[postinstall] Could not remove PowerShell shim ${shimPath}: ${
error instanceof Error ? error.message : String(error)
}`,
)
return false
}
}
function cleanupWindowsPowerShellShim() {
if (process.platform !== "win32") return
const dirs = getWindowsPowerShellShimDirs()
const seen = new Set()
for (const dir of dirs) {
const shimPath = path.join(dir, "csc.ps1")
if (seen.has(shimPath)) continue
seen.add(shimPath)
removePowerShellShimIfOwned(shimPath)
}
}
function getWindowsPowerShellShimDirs() {
return [
safeResolve(process.env.npm_config_prefix),
safeResolve(process.env.npm_config_local_prefix, "node_modules", ".bin"),
safeResolve(process.env.INIT_CWD, "node_modules", ".bin"),
safeResolve(projectRoot, "node_modules", ".bin"),
].filter(Boolean)
}
function scheduleWindowsPowerShellShimCleanup() {
if (process.platform !== "win32") return
const dirs = getWindowsPowerShellShimDirs()
if (dirs.length === 0) return
const script = String.raw`
const { existsSync, readFileSync, rmSync } = require("fs");
const path = require("path");
const dirs = JSON.parse(process.argv[1] || "[]");
setTimeout(() => {
for (const dir of dirs) {
const shimPath = path.join(dir, "csc.ps1");
const cmdShimPath = path.join(dir, "csc.cmd");
if (!existsSync(shimPath) || !existsSync(cmdShimPath)) continue;
let content = "";
try {
content = readFileSync(shimPath, "utf8").replace(/\\/g, "/");
} catch {
continue;
}
if (!content.includes("@costrict") && !content.includes("dist/cli-node.js")) continue;
try {
rmSync(shimPath, { force: true });
} catch {}
}
}, 3000);
`
try {
const child = spawn(process.execPath, ["-e", script, JSON.stringify(dirs)], {
detached: true,
stdio: "ignore",
windowsHide: true,
})
child.unref()
} catch {
// Best effort only. Immediate cleanup above covers the normal npm flow.
}
}
// --- Download helpers ---
function proxyEnvSet() {
const v = (s) => (s ?? "").trim()
return !!(v(process.env.HTTPS_PROXY) || v(process.env.HTTP_PROXY) || v(process.env.ALL_PROXY) || v(process.env.https_proxy) || v(process.env.http_proxy))
}
function tryPowerShellDownload(url, dest) {
const u = url.replace(/'/g, "''")
const d = dest.replace(/'/g, "''")
const cmd = `Invoke-WebRequest -Uri '${u}' -OutFile '${d}' -UseBasicParsing`
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd],
{ stdio: "pipe", windowsHide: true },
)
return result.status === 0 && existsSync(dest) && statSync(dest).size > 0
}
function tryCurlDownload(url, dest) {
const curl = process.platform === "win32" ? "curl.exe" : "curl"
const result = spawnSync(curl, ["-fsSL", "-L", "--fail", "-o", dest, url], {
stdio: "pipe",
windowsHide: true,
})
return result.status === 0 && existsSync(dest) && statSync(dest).size > 0
}
async function fetchRelease(url) {
if (proxyEnvSet()) {
// Dynamic require so it works in node without bundling issues
const undici = require("undici")
return await undici.fetch(url, {
redirect: "follow",
dispatcher: new undici.EnvHttpProxyAgent(),
})
}
// Node 18+ has global fetch, Bun has it too
return await fetch(url, { redirect: "follow" })
}
async function downloadUrlToBuffer(url) {
const response = await fetchRelease(url)
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`)
}
return Buffer.from(await response.arrayBuffer())
}
async function downloadUrlToBufferWithFallback(url) {
let firstError
try {
return await downloadUrlToBuffer(url)
} catch (e) {
firstError = e
}
const tmpRoot = path.join(os.tmpdir(), `ripgrep-dl-${process.pid}-${Date.now()}`)
const tmpFile = path.join(tmpRoot, "archive")
mkdirSync(tmpRoot, { recursive: true })
try {
if (process.platform === "win32" && tryPowerShellDownload(url, tmpFile)) {
return readFileSync(tmpFile)
}
if (tryCurlDownload(url, tmpFile)) {
return readFileSync(tmpFile)
}
} finally {
rmSync(tmpRoot, { recursive: true, force: true })
}
throw firstError
}
// --- Extract ---
function findZipEntryKey(files, want) {
return Object.keys(files).find((k) => {
const norm = k.replace(/\\/g, "/")
return norm === want || norm.endsWith(`/${want}`)
})
}
async function extractZip(buffer, binaryPath, extractedBinary) {
const binaryDir = path.dirname(binaryPath)
// Try fflate first (bundled dep)
let fflateError
try {
const { unzipSync } = require("fflate")
const unzipped = unzipSync(new Uint8Array(buffer))
const key = findZipEntryKey(unzipped, extractedBinary)
if (!key) {
throw new Error(`Binary ${extractedBinary} not found in zip`)
}
writeFileSync(binaryPath, Buffer.from(unzipped[key]))
return
} catch (e) {
fflateError = e
}
// Fallback: PowerShell Expand-Archive or unzip CLI
const tmpDir = path.join(binaryDir, ".tmp-download")
rmSync(tmpDir, { recursive: true, force: true })
mkdirSync(tmpDir, { recursive: true })
try {
const assetName = `archive.zip`
const archivePath = path.join(tmpDir, assetName)
writeFileSync(archivePath, buffer)
let extracted = false
if (process.platform === "win32") {
const psCmd = `Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${tmpDir.replace(/'/g, "''")}' -Force`
const psResult = spawnSync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", psCmd],
{ stdio: "pipe", windowsHide: true },
)
if (psResult.status === 0) {
extracted = true
}
}
if (!extracted) {
const result = spawnSync("unzip", ["-o", archivePath, "-d", tmpDir], { stdio: "pipe" })
if (result.status !== 0) {
const unzipErr = result.stderr?.toString().trim() || "command not found"
const fflateMsg = fflateError instanceof Error ? fflateError.message : String(fflateError)
throw new Error(`zip extraction failed (fflate: ${fflateMsg}; unzip: ${unzipErr})`)
}
}
const srcBinary = path.join(tmpDir, extractedBinary)
if (!existsSync(srcBinary)) {
throw new Error(`Binary not found at expected path: ${srcBinary}`)
}
renameSync(srcBinary, binaryPath)
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
}
async function extractTarGz(buffer, binaryPath, extractedBinary, assetName) {
const binaryDir = path.dirname(binaryPath)
const tmpDir = path.join(binaryDir, ".tmp-download")
rmSync(tmpDir, { recursive: true, force: true })
mkdirSync(tmpDir, { recursive: true })
try {
const archivePath = path.join(tmpDir, assetName)
writeFileSync(archivePath, buffer)
const result = spawnSync("tar", ["xzf", archivePath, "-C", tmpDir], { stdio: "pipe" })
if (result.status !== 0) {
throw new Error(`tar extract failed: ${result.stderr?.toString()}`)
}
const srcBinary = path.join(tmpDir, extractedBinary)
if (!existsSync(srcBinary)) {
throw new Error(`Binary not found at expected path: ${srcBinary}`)
}
renameSync(srcBinary, binaryPath)
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
}
// --- Main ---
async function downloadAndExtract() {
const { target, ext } = getPlatformMapping()
const assetName = `ripgrep-v${RG_VERSION}-${target}.${ext}`
const binaryPath = getBinaryPath()
const binaryDir = path.dirname(binaryPath)
const force = process.argv.includes("--force")
if (!force && existsSync(binaryPath)) {
const stat = statSync(binaryPath)
if (stat.size > 0) {
console.log(`[ripgrep] Binary already exists at ${binaryPath}, skipping.`)
return
}
}
console.log(`[ripgrep] Downloading v${RG_VERSION} for ${target}...`)
const extractedBinary = process.platform === "win32" ? "rg.exe" : "rg"
const mirrors = [RELEASE_BASE]
if (RELEASE_BASE === DEFAULT_RELEASE_BASE.replace(/\/$/, "")) {
mirrors.push(MIRROR_RELEASE_BASE.replace(/\/$/, ""))
}
let buffer
let lastError
for (const base of mirrors) {
const url = `${base}/${assetName}`
try {
console.log(`[ripgrep] Trying ${url}`)
buffer = await downloadUrlToBufferWithFallback(url)
break
} catch (e) {
console.warn(`[ripgrep] Download from ${base} failed: ${e instanceof Error ? e.message : e}`)
lastError = e
}
}
// costrict change: fallback to private registry (intranet) when all public mirrors fail
if (!buffer) {
const privateUrl = `${COSTRICT_PRIVATE_BASE}/ripgrep-v${RG_VERSION}-${target}.${ext}`
try {
console.log(`[ripgrep] Trying private registry: ${privateUrl}`)
buffer = await downloadUrlToBufferWithFallback(privateUrl)
} catch (e) {
console.warn(`[ripgrep] Private registry download failed: ${e instanceof Error ? e.message : e}`)
}
}
if (!buffer) {
throw lastError
}
try {
console.log(`[ripgrep] Downloaded ${Math.round(buffer.length / 1024)} KB`)
mkdirSync(binaryDir, { recursive: true })
if (ext === "tar.gz") {
await extractTarGz(buffer, binaryPath, extractedBinary, assetName)
} else {
await extractZip(buffer, binaryPath, extractedBinary)
}
if (process.platform !== "win32") {
chmodSync(binaryPath, 0o755)
}
console.log(`[ripgrep] Installed to ${binaryPath}`)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
const hint =
"Check network or set HTTPS_PROXY. If GitHub is blocked, set RIPGREP_DOWNLOAD_BASE to a mirror (see script header)."
throw new Error(`${msg} ${hint}`)
}
}
async function main() {
cleanupWindowsPowerShellShim()
scheduleWindowsPowerShellShimCleanup()
await downloadAndExtract()
}
main().catch((error) => {
const msg = error instanceof Error ? error.message : String(error)
console.error(`[postinstall] ripgrep download failed (non-fatal): ${msg}`)
console.error(`[postinstall] You can install ripgrep manually: https://github.com/BurntSushi/ripgrep#installation`)
// Never exit with error code — postinstall must not break install
process.exit(0)
})