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 仍失败,错误来自仓库既有缺失声明/缺失模块和无关类型问题,不来自本次改动。
This commit is contained in:
parent
95a4430fa2
commit
223b694965
|
|
@ -40,6 +40,7 @@ import {
|
||||||
type ParsedMouse,
|
type ParsedMouse,
|
||||||
parseMultipleKeypresses,
|
parseMultipleKeypresses,
|
||||||
} from '../core/parse-keypress.js';
|
} from '../core/parse-keypress.js';
|
||||||
|
import { supportsStdinRawMode } from '../core/raw-mode-support.js';
|
||||||
import reconciler from '../core/reconciler.js';
|
import reconciler from '../core/reconciler.js';
|
||||||
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../core/selection.js';
|
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../core/selection.js';
|
||||||
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../core/terminal.js';
|
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../core/terminal.js';
|
||||||
|
|
@ -157,6 +158,7 @@ export default class App extends PureComponent<Props, State> {
|
||||||
// Count how many components enabled raw mode to avoid disabling
|
// Count how many components enabled raw mode to avoid disabling
|
||||||
// raw mode until all components don't need it anymore
|
// raw mode until all components don't need it anymore
|
||||||
rawModeEnabledCount = 0;
|
rawModeEnabledCount = 0;
|
||||||
|
rawModeActive = false;
|
||||||
|
|
||||||
internal_eventEmitter = new EventEmitter();
|
internal_eventEmitter = new EventEmitter();
|
||||||
keyParseState = INITIAL_STATE;
|
keyParseState = INITIAL_STATE;
|
||||||
|
|
@ -195,7 +197,11 @@ export default class App extends PureComponent<Props, State> {
|
||||||
|
|
||||||
// Determines if TTY is supported on the provided stdin
|
// Determines if TTY is supported on the provided stdin
|
||||||
isRawModeSupported(): boolean {
|
isRawModeSupported(): boolean {
|
||||||
return this.props.stdin.isTTY;
|
return supportsStdinRawMode(this.props.stdin);
|
||||||
|
}
|
||||||
|
|
||||||
|
isCookedModeFallbackSupported(): boolean {
|
||||||
|
return this.props.stdin.isTTY && !this.isRawModeSupported();
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
|
|
@ -258,7 +264,7 @@ export default class App extends PureComponent<Props, State> {
|
||||||
this.pendingHyperlinkTimer = null;
|
this.pendingHyperlinkTimer = null;
|
||||||
}
|
}
|
||||||
// ignore calling setRawMode on an handle stdin it cannot be called
|
// ignore calling setRawMode on an handle stdin it cannot be called
|
||||||
if (this.isRawModeSupported()) {
|
if (this.rawModeEnabledCount > 0) {
|
||||||
this.handleSetRawMode(false);
|
this.handleSetRawMode(false);
|
||||||
} else {
|
} else {
|
||||||
// Even when raw mode was never enabled (e.g. non-TTY stdin on
|
// Even when raw mode was never enabled (e.g. non-TTY stdin on
|
||||||
|
|
@ -279,7 +285,8 @@ export default class App extends PureComponent<Props, State> {
|
||||||
handleSetRawMode = (isEnabled: boolean): void => {
|
handleSetRawMode = (isEnabled: boolean): void => {
|
||||||
const { stdin } = this.props;
|
const { stdin } = this.props;
|
||||||
|
|
||||||
if (!this.isRawModeSupported()) {
|
const rawModeSupported = this.isRawModeSupported();
|
||||||
|
if (!rawModeSupported && !this.isCookedModeFallbackSupported()) {
|
||||||
if (stdin === process.stdin) {
|
if (stdin === process.stdin) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
|
'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
|
||||||
|
|
@ -314,39 +321,50 @@ export default class App extends PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
stdin.ref();
|
stdin.ref();
|
||||||
stdin.setRawMode(true);
|
if (rawModeSupported) {
|
||||||
stdin.addListener('readable', this.handleReadable);
|
stdin.setRawMode(true);
|
||||||
// Enable bracketed paste mode
|
this.rawModeActive = true;
|
||||||
this.props.stdout.write(EBP);
|
} else {
|
||||||
// Enable terminal focus reporting (DECSET 1004)
|
this.rawModeActive = false;
|
||||||
this.props.stdout.write(EFE);
|
defaultCallbacks.logForDebugging(
|
||||||
// Enable extended key reporting so ctrl+shift+<letter> is
|
'[stdin] Raw mode disabled for this Windows terminal; using cooked input fallback',
|
||||||
// distinguishable from ctrl+<letter>. We write both the kitty stack
|
{ level: 'warn' },
|
||||||
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
|
);
|
||||||
// terminals honor whichever they implement (tmux only accepts the
|
|
||||||
// latter).
|
|
||||||
if (supportsExtendedKeys()) {
|
|
||||||
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
|
|
||||||
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
|
|
||||||
}
|
}
|
||||||
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
|
stdin.addListener('readable', this.handleReadable);
|
||||||
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
|
if (rawModeSupported) {
|
||||||
// detection when env vars are absent. Fire-and-forget: the DA1
|
// Enable bracketed paste mode
|
||||||
// sentinel bounds the round-trip, and if the terminal ignores the
|
this.props.stdout.write(EBP);
|
||||||
// query, flush() still resolves and name stays undefined.
|
// Enable terminal focus reporting (DECSET 1004)
|
||||||
// Deferred to next tick so it fires AFTER the current synchronous
|
this.props.stdout.write(EFE);
|
||||||
// init sequence completes — avoids interleaving with alt-screen/mouse
|
// Enable extended key reporting so ctrl+shift+<letter> is
|
||||||
// tracking enable writes that may happen in the same render cycle.
|
// distinguishable from ctrl+<letter>. We write both the kitty stack
|
||||||
setImmediate(() => {
|
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
|
||||||
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
|
// terminals honor whichever they implement (tmux only accepts the
|
||||||
if (r) {
|
// latter).
|
||||||
setXtversionName(r.name);
|
if (supportsExtendedKeys()) {
|
||||||
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
|
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
|
||||||
} else {
|
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
|
||||||
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)');
|
}
|
||||||
}
|
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
|
||||||
|
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
|
||||||
|
// detection when env vars are absent. Fire-and-forget: the DA1
|
||||||
|
// sentinel bounds the round-trip, and if the terminal ignores the
|
||||||
|
// query, flush() still resolves and name stays undefined.
|
||||||
|
// Deferred to next tick so it fires AFTER the current synchronous
|
||||||
|
// init sequence completes — avoids interleaving with alt-screen/mouse
|
||||||
|
// tracking enable writes that may happen in the same render cycle.
|
||||||
|
setImmediate(() => {
|
||||||
|
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
|
||||||
|
if (r) {
|
||||||
|
setXtversionName(r.name);
|
||||||
|
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
|
||||||
|
} else {
|
||||||
|
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rawModeEnabledCount++;
|
this.rawModeEnabledCount++;
|
||||||
|
|
@ -366,13 +384,16 @@ export default class App extends PureComponent<Props, State> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
|
if (this.rawModeActive) {
|
||||||
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
|
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
|
||||||
// Disable terminal focus reporting (DECSET 1004)
|
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
|
||||||
this.props.stdout.write(DFE);
|
// Disable terminal focus reporting (DECSET 1004)
|
||||||
// Disable bracketed paste mode
|
this.props.stdout.write(DFE);
|
||||||
this.props.stdout.write(DBP);
|
// Disable bracketed paste mode
|
||||||
stdin.setRawMode(false);
|
this.props.stdout.write(DBP);
|
||||||
|
stdin.setRawMode(false);
|
||||||
|
this.rawModeActive = false;
|
||||||
|
}
|
||||||
stdin.removeListener('readable', this.handleReadable);
|
stdin.removeListener('readable', this.handleReadable);
|
||||||
stdin.unref();
|
stdin.unref();
|
||||||
}
|
}
|
||||||
|
|
@ -480,7 +501,7 @@ export default class App extends PureComponent<Props, State> {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleExit = (error?: Error): void => {
|
handleExit = (error?: Error): void => {
|
||||||
if (this.isRawModeSupported()) {
|
if (this.rawModeEnabledCount > 0) {
|
||||||
this.handleSetRawMode(false);
|
this.handleSetRawMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import {
|
||||||
|
isWindowsRawModeUnsafe,
|
||||||
|
supportsStdinRawMode,
|
||||||
|
} from '../raw-mode-support.js';
|
||||||
|
|
||||||
|
describe('isWindowsRawModeUnsafe', () => {
|
||||||
|
test('does not disable raw mode for ordinary Windows shells', () => {
|
||||||
|
expect(
|
||||||
|
isWindowsRawModeUnsafe({
|
||||||
|
ComSpec: 'C:\\Windows\\System32\\cmd.exe',
|
||||||
|
}, 'win32'),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isWindowsRawModeUnsafe({
|
||||||
|
PSModulePath: 'C:\\Program Files\\PowerShell\\Modules',
|
||||||
|
}, 'win32'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables raw mode for MSYS2 and Cygwin terminals', () => {
|
||||||
|
expect(isWindowsRawModeUnsafe({ MSYSTEM: 'UCRT64' }, 'win32')).toBe(true);
|
||||||
|
expect(isWindowsRawModeUnsafe({ TERM: 'cygwin' }, 'win32')).toBe(true);
|
||||||
|
expect(isWindowsRawModeUnsafe({ TERM_PROGRAM: 'mintty' }, 'win32')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isWindowsRawModeUnsafe({ SHELL: '/usr/bin/bash' }, 'win32')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isWindowsRawModeUnsafe({ SHELL: '/usr/bin/msys2_shell.cmd' }, 'win32'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports an explicit emergency disable switch', () => {
|
||||||
|
expect(
|
||||||
|
isWindowsRawModeUnsafe({
|
||||||
|
CLAUDE_CODE_DISABLE_STDIN_RAW_MODE: '1',
|
||||||
|
}, 'win32'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('supportsStdinRawMode', () => {
|
||||||
|
test('requires a TTY stream with setRawMode', () => {
|
||||||
|
expect(supportsStdinRawMode({ isTTY: false } as NodeJS.ReadStream)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(supportsStdinRawMode({ isTTY: true } as NodeJS.ReadStream)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
packages/@ant/ink/src/core/raw-mode-support.ts
Normal file
43
packages/@ant/ink/src/core/raw-mode-support.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
function isEnvTruthy(value: string | undefined): boolean {
|
||||||
|
return value === '1' || value === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWindowsRawModeUnsafe(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): boolean {
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnvTruthy(env.CLAUDE_CODE_DISABLE_STDIN_RAW_MODE)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = env.TERM?.toLowerCase() ?? '';
|
||||||
|
const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? '';
|
||||||
|
const msystem = env.MSYSTEM?.toLowerCase() ?? '';
|
||||||
|
const shell = env.SHELL?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
term === 'cygwin' ||
|
||||||
|
term.includes('mintty') ||
|
||||||
|
termProgram.includes('mintty') ||
|
||||||
|
msystem.length > 0 ||
|
||||||
|
shell.includes('msys') ||
|
||||||
|
shell.includes('cygwin') ||
|
||||||
|
shell.includes('mingw')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsStdinRawMode(stdin: NodeJS.ReadStream): boolean {
|
||||||
|
if (!stdin.isTTY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWindowsRawModeUnsafe(process.env, process.platform)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof stdin.setRawMode === 'function';
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
const { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, chmodSync } =
|
const { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, chmodSync } =
|
||||||
require("fs")
|
require("fs")
|
||||||
const { spawnSync } = require("child_process")
|
const { spawn, spawnSync } = require("child_process")
|
||||||
const { setDefaultResultOrder } = require("node:dns")
|
const { setDefaultResultOrder } = require("node:dns")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const os = require("os")
|
const os = require("os")
|
||||||
|
|
@ -97,6 +97,110 @@ function getBinaryPath() {
|
||||||
return path.resolve(dir, subdir, binary)
|
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 ---
|
// --- Download helpers ---
|
||||||
|
|
||||||
function proxyEnvSet() {
|
function proxyEnvSet() {
|
||||||
|
|
@ -341,6 +445,8 @@ async function downloadAndExtract() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
cleanupWindowsPowerShellShim()
|
||||||
|
scheduleWindowsPowerShellShimCleanup()
|
||||||
await downloadAndExtract()
|
await downloadAndExtract()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
20
src/main.tsx
20
src/main.tsx
|
|
@ -233,6 +233,7 @@ import { getGhAuthStatus } from "./utils/github/ghAuthStatus.js";
|
||||||
import { safeParseJSON } from "./utils/json.js";
|
import { safeParseJSON } from "./utils/json.js";
|
||||||
import { logError } from "./utils/log.js";
|
import { logError } from "./utils/log.js";
|
||||||
import { getModelDeprecationWarning } from "./utils/model/deprecation.js";
|
import { getModelDeprecationWarning } from "./utils/model/deprecation.js";
|
||||||
|
import { shouldUseNonInteractiveSession } from "./utils/nonInteractiveSession.js";
|
||||||
import {
|
import {
|
||||||
getDefaultMainLoopModel,
|
getDefaultMainLoopModel,
|
||||||
getUserSpecifiedModelSetting,
|
getUserSpecifiedModelSetting,
|
||||||
|
|
@ -1134,15 +1135,16 @@ export async function main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for -p/--print and --init-only flags early to set isInteractiveSession before init()
|
// Check for -p/--print and --init-only flags early to set isInteractiveSession before init()
|
||||||
// This is needed because telemetry initialization calls auth functions that need this flag
|
// This is needed because telemetry initialization calls auth functions that need this flag
|
||||||
const cliArgs = process.argv.slice(2);
|
const cliArgs = process.argv.slice(2);
|
||||||
const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print");
|
const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE);
|
||||||
const hasInitOnlyFlag = cliArgs.includes("--init-only");
|
const isNonInteractive = shouldUseNonInteractiveSession({
|
||||||
const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url"));
|
args: cliArgs,
|
||||||
const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE);
|
forceInteractive,
|
||||||
const isNonInteractive =
|
stdinIsTTY: process.stdin.isTTY,
|
||||||
hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY);
|
stdoutIsTTY: process.stdout.isTTY,
|
||||||
|
});
|
||||||
|
|
||||||
// Stop capturing early input for non-interactive modes
|
// Stop capturing early input for non-interactive modes
|
||||||
if (isNonInteractive) {
|
if (isNonInteractive) {
|
||||||
|
|
|
||||||
80
src/utils/__tests__/nonInteractiveSession.test.ts
Normal file
80
src/utils/__tests__/nonInteractiveSession.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { shouldUseNonInteractiveSession } from '../nonInteractiveSession.js'
|
||||||
|
|
||||||
|
describe('shouldUseNonInteractiveSession', () => {
|
||||||
|
test('uses non-interactive mode for explicit headless flags', () => {
|
||||||
|
expect(
|
||||||
|
shouldUseNonInteractiveSession({
|
||||||
|
args: ['--print'],
|
||||||
|
forceInteractive: false,
|
||||||
|
stdinIsTTY: true,
|
||||||
|
stdoutIsTTY: true,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
shouldUseNonInteractiveSession({
|
||||||
|
args: ['-p'],
|
||||||
|
forceInteractive: true,
|
||||||
|
stdinIsTTY: true,
|
||||||
|
stdoutIsTTY: true,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
shouldUseNonInteractiveSession({
|
||||||
|
args: ['--init-only'],
|
||||||
|
forceInteractive: false,
|
||||||
|
stdinIsTTY: true,
|
||||||
|
stdoutIsTTY: true,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
shouldUseNonInteractiveSession({
|
||||||
|
args: ['--sdk-url=http://localhost:1234'],
|
||||||
|
forceInteractive: false,
|
||||||
|
stdinIsTTY: true,
|
||||||
|
stdoutIsTTY: true,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not enter print mode for PowerShell shims that preserve stdin TTY', () => {
|
||||||
|
expect(
|
||||||
|
shouldUseNonInteractiveSession({
|
||||||
|
args: [],
|
||||||
|
forceInteractive: false,
|
||||||
|
stdinIsTTY: true,
|
||||||
|
stdoutIsTTY: false,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps automatic non-interactive mode when both stdio sides are non-TTY', () => {
|
||||||
|
expect(
|
||||||
|
shouldUseNonInteractiveSession({
|
||||||
|
args: [],
|
||||||
|
forceInteractive: false,
|
||||||
|
stdinIsTTY: false,
|
||||||
|
stdoutIsTTY: false,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
shouldUseNonInteractiveSession({
|
||||||
|
args: [],
|
||||||
|
forceInteractive: false,
|
||||||
|
stdinIsTTY: undefined,
|
||||||
|
stdoutIsTTY: undefined,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forceInteractive only suppresses automatic non-interactive detection', () => {
|
||||||
|
expect(
|
||||||
|
shouldUseNonInteractiveSession({
|
||||||
|
args: [],
|
||||||
|
forceInteractive: true,
|
||||||
|
stdinIsTTY: false,
|
||||||
|
stdoutIsTTY: false,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -13,6 +13,39 @@
|
||||||
|
|
||||||
import { lastGrapheme } from './intl.js'
|
import { lastGrapheme } from './intl.js'
|
||||||
|
|
||||||
|
function isEnvTruthy(value: string | undefined): boolean {
|
||||||
|
return value === '1' || value === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsEarlyInputRawMode(): boolean {
|
||||||
|
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_STDIN_RAW_MODE)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = process.env.TERM?.toLowerCase() ?? ''
|
||||||
|
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() ?? ''
|
||||||
|
const msystem = process.env.MSYSTEM?.toLowerCase() ?? ''
|
||||||
|
const shell = process.env.SHELL?.toLowerCase() ?? ''
|
||||||
|
|
||||||
|
return !(
|
||||||
|
term === 'cygwin' ||
|
||||||
|
term.includes('mintty') ||
|
||||||
|
termProgram.includes('mintty') ||
|
||||||
|
msystem.length > 0 ||
|
||||||
|
shell.includes('msys') ||
|
||||||
|
shell.includes('cygwin') ||
|
||||||
|
shell.includes('mingw')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Buffer for early input characters
|
// Buffer for early input characters
|
||||||
let earlyInputBuffer = ''
|
let earlyInputBuffer = ''
|
||||||
// Flag to track if we're currently capturing
|
// Flag to track if we're currently capturing
|
||||||
|
|
@ -33,7 +66,7 @@ export function startCapturingEarlyInput(): void {
|
||||||
// be in print mode. Raw mode disables ISIG (terminal Ctrl+C → SIGINT),
|
// be in print mode. Raw mode disables ISIG (terminal Ctrl+C → SIGINT),
|
||||||
// which would make -p uninterruptible.
|
// which would make -p uninterruptible.
|
||||||
if (
|
if (
|
||||||
!process.stdin.isTTY ||
|
!supportsEarlyInputRawMode() ||
|
||||||
isCapturing ||
|
isCapturing ||
|
||||||
process.argv.includes('-p') ||
|
process.argv.includes('-p') ||
|
||||||
process.argv.includes('--print')
|
process.argv.includes('--print')
|
||||||
|
|
|
||||||
27
src/utils/nonInteractiveSession.ts
Normal file
27
src/utils/nonInteractiveSession.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
export type NonInteractiveSessionOptions = {
|
||||||
|
args: string[]
|
||||||
|
forceInteractive: boolean
|
||||||
|
stdinIsTTY: boolean | undefined
|
||||||
|
stdoutIsTTY: boolean | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseNonInteractiveSession({
|
||||||
|
args,
|
||||||
|
forceInteractive,
|
||||||
|
stdinIsTTY,
|
||||||
|
stdoutIsTTY,
|
||||||
|
}: NonInteractiveSessionOptions): boolean {
|
||||||
|
if (args.includes('-p') || args.includes('--print')) return true
|
||||||
|
if (args.includes('--init-only')) return true
|
||||||
|
if (args.some(arg => arg.startsWith('--sdk-url'))) return true
|
||||||
|
|
||||||
|
if (forceInteractive) return false
|
||||||
|
|
||||||
|
const hasInteractiveStdin = stdinIsTTY === true
|
||||||
|
const hasInteractiveStdout = stdoutIsTTY === true
|
||||||
|
|
||||||
|
// Some Windows launchers, notably npm's generated PowerShell .ps1 shim,
|
||||||
|
// can hide stdout TTY status while stdin is still an interactive terminal.
|
||||||
|
// Treat that as interactive so a bare `csc` does not fall into --print mode.
|
||||||
|
return !hasInteractiveStdout && !hasInteractiveStdin
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user