feat: upgrade to React 19 and Ink 6, fix Windows compatibility

- Upgrade React from 18.3.1 to 19.1.1
- Upgrade Ink from 5.2.1 to 6.2.3
- Fix Windows spawn EINVAL error by removing --tsconfig-raw
- Fix top-level await issues in cli.tsx
- Update build scripts for better Windows support
- Version bump to 1.1.12
This commit is contained in:
CrazyBoyM 2025-08-29 23:48:14 +08:00
parent 8ae7cb47ce
commit 487aef295d
26 changed files with 455 additions and 251 deletions

View File

@ -1,13 +1,13 @@
{
"name": "@shareai-lab/kode",
"version": "1.0.82",
"version": "1.1.12",
"bin": {
"kode": "cli.js",
"kwa": "cli.js",
"kd": "cli.js"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.18.1"
},
"main": "cli.js",
"author": "ShareAI-lab <ai-lab@foxmail.com>",
@ -54,7 +54,7 @@
"@modelcontextprotocol/sdk": "^1.15.1",
"@statsig/js-client": "^3.18.2",
"@types/lodash-es": "^4.17.12",
"@types/react": "^19.1.8",
"@types/react": "^19.1.12",
"ansi-escapes": "^7.0.0",
"chalk": "^5.4.1",
"cli-highlight": "^2.1.11",
@ -68,7 +68,7 @@
"glob": "^11.0.3",
"gray-matter": "^4.0.3",
"highlight.js": "^11.11.1",
"ink": "^5.2.1",
"ink": "^6.2.3",
"ink-link": "^4.1.0",
"ink-select-input": "^6.2.0",
"ink-text-input": "^6.0.0",
@ -79,7 +79,7 @@
"node-fetch": "^3.3.2",
"node-html-parser": "^7.0.1",
"openai": "^4.104.0",
"react": "18.3.1",
"react": "^19.1.1",
"semver": "^7.7.2",
"shell-quote": "^1.8.3",
"spawn-rx": "^5.1.2",

View File

@ -12,8 +12,9 @@ async function build() {
rmSync(file, { recursive: true, force: true });
}
});
// No dist artifacts; wrapper-only build
// Create the CLI wrapper
// Create the CLI wrapper (exactly as in the referenced PR)
const wrapper = `#!/usr/bin/env node
const { spawn } = require('child_process');
@ -48,34 +49,50 @@ try {
}
function runWithNode() {
// Use local tsx installation
const tsxPath = path.join(__dirname, 'node_modules', '.bin', 'tsx');
// Use local tsx installation; if missing, try PATH-resolved tsx
const binDir = path.join(__dirname, 'node_modules', '.bin')
const tsxPath = process.platform === 'win32'
? path.join(binDir, 'tsx.cmd')
: path.join(binDir, 'tsx')
const runPathTsx = () => {
const child2 = spawn('tsx', [cliPath, ...args], {
stdio: 'inherit',
shell: process.platform === 'win32',
env: {
...process.env,
YOGA_WASM_PATH: path.join(__dirname, 'yoga.wasm'),
TSX_TSCONFIG_PATH: process.platform === 'win32' ? 'noop' : undefined
},
})
child2.on('error', () => {
console.error('\\nError: tsx is required but not found.')
console.error('Please install tsx globally: npm install -g tsx')
process.exit(1)
})
child2.on('exit', (code2) => process.exit(code2 || 0))
}
const child = spawn(tsxPath, [cliPath, ...args], {
stdio: 'inherit',
env: {
...process.env,
YOGA_WASM_PATH: path.join(__dirname, 'yoga.wasm')
}
});
shell: process.platform === 'win32',
env: {
...process.env,
YOGA_WASM_PATH: path.join(__dirname, 'yoga.wasm'),
TSX_TSCONFIG_PATH: process.platform === 'win32' ? 'noop' : undefined
},
})
child.on('error', (err) => {
if (err.code === 'ENOENT') {
console.error('\\nError: tsx is required but not found.');
console.error('Please run: npm install');
process.exit(1);
} else {
console.error('Failed to start Kode:', err.message);
process.exit(1);
}
});
child.on('exit', (code) => process.exit(code || 0));
child.on('error', () => runPathTsx())
child.on('exit', (code) => {
if (code && code !== 0) return runPathTsx()
process.exit(code || 0)
})
}
`;
writeFileSync('cli.js', wrapper);
chmodSync('cli.js', 0o755);
// Create .npmrc
const npmrc = `# Ensure tsx is installed
auto-install-peers=true
@ -100,4 +117,4 @@ if (import.meta.main) {
build();
}
export { build };
export { build };

View File

@ -1,56 +1,18 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
// This postinstall is intentionally minimal and cross-platform safe.
// npm/pnpm/yarn already create shims from package.json "bin" fields.
// We avoid attempting to create symlinks or relying on platform-specific tools like `which`/`where`.
const primaryCommand = 'kode';
const alternativeCommands = ['kwa', 'kd'];
function commandExists(cmd) {
function postinstallNotice() {
// Only print informational hints; never fail install.
try {
execSync(`which ${cmd}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
console.log('✅ @shareai-lab/kode installed. Commands available: kode, kwa, kd');
console.log(' If shell cannot find them, try reloading your terminal or reinstall globally:');
console.log(' npm i -g @shareai-lab/kode (or use: npx @shareai-lab/kode)');
} catch {}
}
function setupCommand() {
// Check if primary command exists
if (!commandExists(primaryCommand)) {
console.log(`✅ '${primaryCommand}' command is available and has been set up.`);
return;
}
console.log(`⚠️ '${primaryCommand}' command already exists on your system.`);
// Find an available alternative
for (const alt of alternativeCommands) {
if (!commandExists(alt)) {
// Create alternative command
const binPath = path.join(__dirname, '..', 'cli.js');
const altBinPath = path.join(__dirname, '..', '..', '..', '.bin', alt);
try {
fs.symlinkSync(binPath, altBinPath);
console.log(`✅ Created alternative command '${alt}' instead.`);
console.log(` You can run the tool using: ${alt}`);
return;
} catch (err) {
// Continue to next alternative
}
}
}
console.log(`
All common command names are taken. You can still run the tool using:
- npx @shareai-lab/kode
- Or create your own alias: alias myai='npx @shareai-lab/kode'
`);
}
// Only run in postinstall, not in development
if (process.env.npm_lifecycle_event === 'postinstall') {
setupCommand();
}
postinstallNotice();
}

View File

@ -39,4 +39,4 @@ console.log(` Version: ${pkg.version}`);
console.log(` Main: ${pkg.main}`);
console.log(` Bin: kode -> ${pkg.bin.kode}`);
console.log('\n🚀 Ready to publish!');
console.log(' Run: npm publish');
console.log(' Run: npm publish');

View File

@ -462,7 +462,16 @@ async function openInEditor(filePath: string): Promise<void> {
const projectDir = process.cwd()
const homeDir = os.homedir()
if (!resolvedPath.startsWith(projectDir) && !resolvedPath.startsWith(homeDir)) {
const isSub = (base: string, target: string) => {
const path = require('path')
const rel = path.relative(path.resolve(base), path.resolve(target))
if (!rel || rel === '') return true
if (rel.startsWith('..')) return false
if (path.isAbsolute(rel)) return false
return true
}
if (!isSub(projectDir, resolvedPath) && !isSub(homeDir, resolvedPath)) {
throw new Error('Access denied: File path outside allowed directories')
}

View File

@ -7,7 +7,7 @@ export function AsciiLogo(): React.ReactNode {
const theme = getTheme()
return (
<Box flexDirection="column" alignItems="flex-start">
<Text color={theme.claude}>{ASCII_LOGO}</Text>
<Text color={theme.kode}>{ASCII_LOGO}</Text>
</Box>
)
}

View File

@ -45,13 +45,13 @@ export function SelectOption({
paddingRight: 1,
}),
focusIndicator: () => ({
color: appTheme.claude,
color: appTheme.kode,
}),
label: ({ isFocused, isSelected }: { isFocused: boolean; isSelected: boolean }) => ({
color: isSelected
? appTheme.success
: isFocused
? appTheme.claude
? appTheme.kode
: appTheme.text,
bold: isSelected,
}),

View File

@ -66,7 +66,7 @@ export function Help({
return (
<Box flexDirection="column" padding={1}>
<Text bold color={theme.claude}>
<Text bold color={theme.kode}>
{`${PRODUCT_NAME} v${MACRO.VERSION}`}
</Text>
@ -150,7 +150,7 @@ export function Help({
<Box flexDirection="column">
{customCommands.map((cmd, i) => (
<Box key={i} marginLeft={1}>
<Text bold color={theme.claude}>{`/${cmd.name}`}</Text>
<Text bold color={theme.kode}>{`/${cmd.name}`}</Text>
<Text> - {cmd.description}</Text>
{cmd.aliases && cmd.aliases.length > 0 && (
<Text color={theme.secondaryText}>

View File

@ -13,9 +13,13 @@ export const MIN_LOGO_WIDTH = 50
export function Logo({
mcpClients,
isDefaultModel = false,
updateBannerVersion,
updateBannerCommands,
}: {
mcpClients: WrappedClient[]
isDefaultModel?: boolean
updateBannerVersion?: string | null
updateBannerCommands?: string[] | null
}): React.ReactNode {
const width = Math.max(MIN_LOGO_WIDTH, getCwd().length + 12)
const theme = getTheme()
@ -35,15 +39,36 @@ export function Logo({
return (
<Box flexDirection="column">
<Box
borderColor={theme.claude}
borderColor={theme.kode}
borderStyle="round"
flexDirection="column"
gap={1}
paddingLeft={1}
marginRight={2}
width={width}
>
{updateBannerVersion ? (
<Box flexDirection="column">
<Text color="yellow">New version available: {updateBannerVersion}</Text>
<Text>Run the following command to update:</Text>
<Text>
{' '}
{updateBannerCommands?.[0] ?? 'bun add -g @shareai-lab/kode@latest'}
</Text>
<Text>Or:</Text>
<Text>
{' '}
{updateBannerCommands?.[1] ?? 'npm install -g @shareai-lab/kode@latest'}
</Text>
{process.platform !== 'win32' && (
<Text dimColor>
Note: you may need to prefix with "sudo" on macOS/Linux.
</Text>
)}
</Box>
) : null}
<Text>
<Text color={theme.claude}></Text> Welcome to{' '}
<Text color={theme.kode}></Text> Welcome to{' '}
<Text bold>{PRODUCT_NAME}</Text> <Text>research preview!</Text>
</Text>
{/* <AsciiLogo /> */}

View File

@ -44,7 +44,7 @@ export function ModelStatusDisplay({ onClose }: Props): React.ReactNode {
<Box key={pointer} flexDirection="column" marginBottom={1}>
<Text>
🎯{' '}
<Text bold color={theme.claude}>
<Text bold color={theme.kode}>
{pointer.toUpperCase()}
</Text>{' '}
{model.name}
@ -76,7 +76,7 @@ export function ModelStatusDisplay({ onClose }: Props): React.ReactNode {
<Box key={pointer} flexDirection="column" marginBottom={1}>
<Text>
🎯{' '}
<Text bold color={theme.claude}>
<Text bold color={theme.kode}>
{pointer.toUpperCase()}
</Text>{' '}
<Text color={theme.error}> Not configured</Text>
@ -89,7 +89,7 @@ export function ModelStatusDisplay({ onClose }: Props): React.ReactNode {
<Box key={pointer} flexDirection="column" marginBottom={1}>
<Text>
🎯{' '}
<Text bold color={theme.claude}>
<Text bold color={theme.kode}>
{pointer.toUpperCase()}
</Text>{' '}
{' '}

View File

@ -260,13 +260,13 @@ export function WelcomeBox(): React.ReactNode {
const theme = getTheme()
return (
<Box
borderColor={theme.claude}
borderColor={theme.kode}
borderStyle="round"
paddingX={1}
width={MIN_LOGO_WIDTH}
>
<Text>
<Text color={theme.claude}></Text> Welcome to{' '}
<Text color={theme.kode}></Text> Welcome to{' '}
<Text bold>{PRODUCT_NAME}</Text> research preview!
</Text>
</Box>

View File

@ -596,7 +596,7 @@ function PromptInput({
mode === 'bash'
? theme.bashBorder
: mode === 'koding'
? theme.koding
? theme.noting
: theme.secondaryBorder
}
borderDimColor
@ -614,7 +614,7 @@ function PromptInput({
{mode === 'bash' ? (
<Text color={theme.bashBorder}>&nbsp;!&nbsp;</Text>
) : mode === 'koding' ? (
<Text color={theme.koding}>&nbsp;#&nbsp;</Text>
<Text color={theme.noting}>&nbsp;#&nbsp;</Text>
) : (
<Text color={isLoading ? theme.secondaryText : undefined}>
&nbsp;&gt;&nbsp;
@ -668,7 +668,7 @@ function PromptInput({
! for bash mode
</Text>
<Text
color={mode === 'koding' ? theme.koding : undefined}
color={mode === 'koding' ? theme.noting : undefined}
dimColor={mode !== 'koding'}
>
· # for AGENTS.md

View File

@ -96,9 +96,9 @@ export function Spinner(): React.ReactNode {
return (
<Box flexDirection="row" marginTop={1}>
<Box flexWrap="nowrap" height={1} width={2}>
<Text color={getTheme().claude}>{frames[frame]}</Text>
<Text color={getTheme().kode}>{frames[frame]}</Text>
</Box>
<Text color={getTheme().claude}>{message.current} </Text>
<Text color={getTheme().kode}>{message.current} </Text>
<Text color={getTheme().secondaryText}>
({elapsedTime}s · <Text bold>esc</Text> to interrupt)
</Text>
@ -123,7 +123,7 @@ export function SimpleSpinner(): React.ReactNode {
return (
<Box flexWrap="nowrap" height={1} width={2}>
<Text color={getTheme().claude}>{frames[frame]}</Text>
<Text color={getTheme().kode}>{frames[frame]}</Text>
</Box>
)
}

View File

@ -14,7 +14,7 @@ export function TaskProgressMessage({ agentType, status, toolCount }: Props) {
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="row">
<Text color={theme.claude}> </Text>
<Text color={theme.kode}> </Text>
<Text color={theme.text} bold>
[{agentType}]
</Text>
@ -29,4 +29,4 @@ export function TaskProgressMessage({ agentType, status, toolCount }: Props) {
)}
</Box>
)
}
}

View File

@ -20,7 +20,7 @@ export function UserKodingInputMessage({
return (
<Box flexDirection="column" marginTop={addMargin ? 1 : 0} width="100%">
<Box>
<Text color={getTheme().koding}>#</Text>
<Text color={getTheme().noting}>#</Text>
<Text color={getTheme().secondaryText}> {input}</Text>
</Box>
</Box>

View File

@ -1,8 +1,25 @@
#!/usr/bin/env -S node --no-warnings=ExperimentalWarning --enable-source-maps
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { initSentry } from '../services/sentry'
import { PRODUCT_COMMAND, PRODUCT_NAME } from '../constants/product'
initSentry() // Initialize Sentry as early as possible
// Ensure YOGA_WASM_PATH is set for Ink across run modes (wrapper/dev)
// Resolve yoga.wasm relative to this file when missing using ESM-friendly APIs
try {
if (!process.env.YOGA_WASM_PATH) {
const { existsSync: fsExistsSync } = require('fs')
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const devCandidate = join(__dirname, '../../yoga.wasm')
// Prefer dev path; wrapper already sets env for normal runs
process.env.YOGA_WASM_PATH = fsExistsSync(devCandidate)
? devCandidate
: process.env.YOGA_WASM_PATH
}
} catch {}
// XXX: Without this line (and the Object.keys, even though it seems like it does nothing!),
// there is a bug in Bun only on Win32 that causes this import to be removed, even though
// its use is solely because of its side-effects.
@ -74,8 +91,11 @@ import {
getLatestVersion,
installGlobalPackage,
assertMinVersion,
getUpdateCommandSuggestions,
} from '../utils/autoUpdater'
import { gt } from 'semver'
import { CACHE_PATHS } from '../utils/log'
// import { checkAndNotifyUpdate } from '../utils/autoUpdater'
import { PersistentShell } from '../utils/PersistentShell'
import { GATE_USE_EXTERNAL_UPDATER } from '../constants/betas'
import { clearTerminal } from '../utils/terminal'
@ -290,6 +310,8 @@ async function main() {
}
}
// Disabled background notifier to avoid mid-screen logs during REPL
let inputPrompt = ''
let renderContext: RenderOptions | undefined = {
exitOnCtrlC: false,
@ -417,6 +439,18 @@ ${commandList}`,
} else {
const isDefaultModel = await isDefaultSlowAndCapableModel()
// Prefetch update info before first render to place banner at top
const updateInfo = await (async () => {
try {
const latest = await getLatestVersion()
if (latest && gt(latest, MACRO.VERSION)) {
const cmds = await getUpdateCommandSuggestions()
return { version: latest as string, commands: cmds as string[] }
}
} catch {}
return { version: null as string | null, commands: null as string[] | null }
})()
render(
<REPL
commands={commands}
@ -429,6 +463,8 @@ ${commandList}`,
safeMode={safe}
mcpClients={mcpClients}
isDefaultModel={isDefaultModel}
initialUpdateVersion={updateInfo.version}
initialUpdateCommands={updateInfo.commands}
/>,
renderContext,
)
@ -1098,11 +1134,11 @@ ${commandList}`,
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.claude}
borderColor={theme.kode}
padding={1}
width={'100%'}
>
<Text bold color={theme.claude}>
<Text bold color={theme.kode}>
Import MCP Servers from Claude Desktop
</Text>
@ -1219,16 +1255,8 @@ ${commandList}`,
// claude update
program
.command('update')
.description('Check for updates and install if available')
.description('Show manual upgrade commands (no auto-install)')
.action(async () => {
const useExternalUpdater = await checkGate(GATE_USE_EXTERNAL_UPDATER)
if (useExternalUpdater) {
// The external updater intercepts calls to "claude update", which means if we have received
// this command at all, the extenral updater isn't installed on this machine.
console.log(`This version of ${PRODUCT_NAME} is no longer supported.`)
process.exit(0)
}
logEvent('tengu_update_check', {})
console.log(`Current version: ${MACRO.VERSION}`)
console.log('Checking for updates...')
@ -1246,30 +1274,12 @@ ${commandList}`,
}
console.log(`New version available: ${latestVersion}`)
console.log('Installing update...')
const status = await installGlobalPackage()
switch (status) {
case 'success':
console.log(`Successfully updated to version ${latestVersion}`)
break
case 'no_permissions':
console.error('Error: Insufficient permissions to install update')
console.error('Try running with sudo or fix npm permissions')
process.exit(1)
break
case 'install_failed':
console.error('Error: Failed to install update')
process.exit(1)
break
case 'in_progress':
console.error(
'Error: Another instance is currently performing an update',
)
console.error('Please wait and try again later')
process.exit(1)
break
const { getUpdateCommandSuggestions } = await import('../utils/autoUpdater')
const cmds = await getUpdateCommandSuggestions()
console.log('\nRun one of the following commands to update:')
for (const c of cmds) console.log(` ${c}`)
if (process.platform !== 'win32') {
console.log('\nNote: you may need to prefix with "sudo" on macOS/Linux.')
}
process.exit(0)
})
@ -1501,9 +1511,23 @@ process.on('exit', () => {
PersistentShell.getInstance().close()
})
process.on('SIGINT', () => {
console.log('SIGINT')
process.exit(0)
function gracefulExit(code = 0) {
try { resetCursor() } catch {}
try { PersistentShell.getInstance().close() } catch {}
process.exit(code)
}
process.on('SIGINT', () => gracefulExit(0))
process.on('SIGTERM', () => gracefulExit(0))
// Windows CTRL+BREAK
process.on('SIGBREAK', () => gracefulExit(0))
process.on('unhandledRejection', err => {
console.error('Unhandled rejection:', err)
gracefulExit(1)
})
process.on('uncaughtException', err => {
console.error('Uncaught exception:', err)
gracefulExit(1)
})
function resetCursor() {

View File

@ -44,6 +44,7 @@ import type { WrappedClient } from '../services/mcpClient'
import type { Tool } from '../Tool'
import { AutoUpdaterResult } from '../utils/autoUpdater'
import { getGlobalConfig, saveGlobalConfig } from '../utils/config'
import { MACRO } from '../constants/macros'
import { logEvent } from '../services/statsig'
import { getNextAvailableLogForkNumber } from '../utils/log'
import {
@ -87,6 +88,9 @@ type Props = {
mcpClients?: WrappedClient[]
// Flag to indicate if current model is default
isDefaultModel?: boolean
// Update banner info passed from CLI before first render
initialUpdateVersion?: string | null
initialUpdateCommands?: string[] | null
}
export type BinaryFeedbackContext = {
@ -108,6 +112,8 @@ export function REPL({
initialMessages,
mcpClients = [],
isDefaultModel = true,
initialUpdateVersion,
initialUpdateCommands,
}: Props): React.ReactNode {
// TODO: probably shouldn't re-read config from file synchronously on every keystroke
const verbose = verboseFromCLI ?? getGlobalConfig().verbose
@ -149,6 +155,10 @@ export function REPL({
const [binaryFeedbackContext, setBinaryFeedbackContext] =
useState<BinaryFeedbackContext | null>(null)
// New version banner: passed in from CLI to guarantee top placement
const updateAvailableVersion = initialUpdateVersion ?? null
const updateCommands = initialUpdateCommands ?? null
// No separate Static for banner; it renders inside Logo
const getBinaryFeedbackResponse = useCallback(
(
@ -209,6 +219,8 @@ export function REPL({
}
}, [messages, showCostDialog, haveShownCostDialog])
// Update banner is provided by CLI at startup; no async check here.
const canUseTool = useCanUseTool(setToolUseConfirm)
async function onInit() {
@ -478,7 +490,12 @@ export function REPL({
type: 'static',
jsx: (
<Box flexDirection="column" key={`logo${forkNumber}`}>
<Logo mcpClients={mcpClients} isDefaultModel={isDefaultModel} />
<Logo
mcpClients={mcpClients}
isDefaultModel={isDefaultModel}
updateBannerVersion={updateAvailableVersion}
updateBannerCommands={updateCommands}
/>
<ProjectOnboarding workspaceDir={getOriginalCwd()} />
</Box>
),
@ -602,6 +619,7 @@ export function REPL({
return (
<PermissionProvider isBypassPermissionsModeAvailable={!safeMode}>
{/* Update banner now renders inside Logo for stable placement */}
<ModeIndicator />
<React.Fragment key={`static-messages-${forkNumber}`}>
<Static

View File

@ -518,7 +518,7 @@ export async function getCompletionWithProfile(
messageCount: opts.messages?.length || 0,
streamMode: opts.stream,
timestamp: new Date().toISOString(),
modelProfileName: modelProfile?.modelName,
modelProfileModelName: modelProfile?.modelName,
modelProfileName: modelProfile?.name,
})

View File

@ -107,7 +107,8 @@ export function logEvent(
}
export const checkGate = memoize(async (gateName: string): Promise<boolean> => {
return true
// Default to disabled gates when Statsig is not active
return false
// if (env.isCI || process.env.NODE_ENV === 'test') {
// return false
// }
@ -120,7 +121,7 @@ export const checkGate = memoize(async (gateName: string): Promise<boolean> => {
})
export const useStatsigGate = (gateName: string, defaultValue = false) => {
return true
return false
// const [gateValue, setGateValue] = React.useState(defaultValue)
// React.useEffect(() => {
// checkGate(gateName).then(setGateValue)

View File

@ -263,22 +263,16 @@ export async function startAgentWatcher(onChange?: () => void): Promise<void> {
* Stop watching agent configuration directories
*/
export async function stopAgentWatcher(): Promise<void> {
const closePromises = watchers.map(watcher =>
new Promise<void>((resolve) => {
// FSWatcher.close() is synchronous and does not accept a callback on Node 18/20
try {
for (const watcher of watchers) {
try {
watcher.close((err) => {
if (err) {
console.error('Failed to close file watcher:', err)
}
resolve()
})
} catch (error) {
console.error('Error closing watcher:', error)
resolve()
watcher.close()
} catch (err) {
console.error('Failed to close file watcher:', err)
}
})
)
await Promise.allSettled(closePromises)
watchers = []
}
}
} finally {
watchers = []
}
}

View File

@ -12,13 +12,16 @@ import {
} from 'fs'
import { platform } from 'process'
import { execFileNoThrow } from './execFileNoThrow'
import { spawn } from 'child_process'
import { logError } from './log'
import { accessSync } from 'fs'
import { CLAUDE_BASE_DIR } from './env'
import { logEvent, getDynamicConfig } from '../services/statsig'
import { lt } from 'semver'
import { lt, gt } from 'semver'
import { MACRO } from '../constants/macros'
import { PRODUCT_COMMAND, PRODUCT_NAME } from '../constants/product'
import { getGlobalConfig, saveGlobalConfig, isAutoUpdaterDisabled } from './config'
import { env } from './env'
export type InstallStatus =
| 'success'
| 'no_permissions'
@ -49,14 +52,12 @@ export async function assertMinVersion(): Promise<void> {
versionConfig.minVersion &&
lt(MACRO.VERSION, versionConfig.minVersion)
) {
const suggestions = await getUpdateCommandSuggestions()
const cmdLines = suggestions.map(c => ` ${c}`).join('\n')
console.error(`
It looks like your version of ${PRODUCT_NAME} (${MACRO.VERSION}) needs an update.
A newer version (${versionConfig.minVersion} or higher) is required to continue.
To update, please run:
${PRODUCT_COMMAND} update
This will ensure you have access to the latest features and improvements.
${PRODUCT_NAME} (${MACRO.VERSION}) ${versionConfig.minVersion}
${cmdLines}
`)
process.exit(1)
}
@ -267,21 +268,48 @@ export function getPermissionsCommand(npmPrefix: string): string {
}
export async function getLatestVersion(): Promise<string | null> {
const abortController = new AbortController()
setTimeout(() => abortController.abort(), 5000)
// 1) Try npm CLI (fast when available)
try {
const abortController = new AbortController()
setTimeout(() => abortController.abort(), 5000)
const result = await execFileNoThrow(
'npm',
['view', MACRO.PACKAGE_URL, 'version'],
abortController.signal,
)
if (result.code === 0) {
const v = result.stdout.trim()
if (v) return v
}
} catch {}
const result = await execFileNoThrow(
'npm',
['view', MACRO.PACKAGE_URL, 'version'],
abortController.signal,
)
if (result.code !== 0) {
// 2) Fallback: fetch npm registry (works in Bun/Node without npm)
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 5000)
const res = await fetch(
`https://registry.npmjs.org/${encodeURIComponent(MACRO.PACKAGE_URL)}`,
{
method: 'GET',
headers: {
Accept: 'application/vnd.npm.install-v1+json',
'User-Agent': `${PRODUCT_NAME}/${MACRO.VERSION}`,
},
signal: controller.signal,
},
)
clearTimeout(timer)
if (!res.ok) return null
const json: any = await res.json().catch(() => null)
const latest = json && json['dist-tags'] && json['dist-tags'].latest
return typeof latest === 'string' ? latest : null
} catch {
return null
}
return result.stdout.trim()
}
export async function installGlobalPackage(): Promise<InstallStatus> {
// Detect preferred package manager and install accordingly
if (!acquireLock()) {
logError('Another process is currently installing an update')
// Log the lock contention to statsig
@ -293,26 +321,138 @@ export async function installGlobalPackage(): Promise<InstallStatus> {
}
try {
const manager = await detectPackageManager()
if (manager === 'npm') {
const { hasPermissions } = await checkNpmPermissions()
if (!hasPermissions) {
return 'no_permissions'
}
// Stream实时输出减少用户等待感
const code = await runStreaming('npm', ['install', '-g', MACRO.PACKAGE_URL])
if (code !== 0) {
logError(`Failed to install new version via npm (exit ${code})`)
return 'install_failed'
}
return 'success'
}
if (manager === 'bun') {
const code = await runStreaming('bun', ['add', '-g', `${MACRO.PACKAGE_URL}@latest`])
if (code !== 0) {
logError(`Failed to install new version via bun (exit ${code})`)
return 'install_failed'
}
return 'success'
}
// Fallback to npm if unknown
const { hasPermissions } = await checkNpmPermissions()
if (!hasPermissions) {
return 'no_permissions'
}
const installResult = await execFileNoThrow('npm', [
'install',
'-g',
MACRO.PACKAGE_URL,
])
if (installResult.code !== 0) {
logError(
`Failed to install new version of claude: ${installResult.stdout} ${installResult.stderr}`,
)
return 'install_failed'
}
if (!hasPermissions) return 'no_permissions'
const code = await runStreaming('npm', ['install', '-g', MACRO.PACKAGE_URL])
if (code !== 0) return 'install_failed'
return 'success'
} finally {
// Ensure we always release the lock
releaseLock()
}
}
export type PackageManager = 'npm' | 'bun'
export async function detectPackageManager(): Promise<PackageManager> {
// Respect explicit override if provided later via config/env (future-proof)
try {
// Heuristic 1: npm available and global root resolvable
const npmRoot = await execFileNoThrow('npm', ['-g', 'root'])
if (npmRoot.code === 0 && npmRoot.stdout.trim()) {
return 'npm'
}
} catch {}
try {
// Heuristic 2: running on a system with bun and installed path hints bun
const bunVer = await execFileNoThrow('bun', ['--version'])
if (bunVer.code === 0) {
// BUN_INSTALL defaults to ~/.bun; if our package lives under that tree, prefer bun
// If npm not detected but bun is available, choose bun
return 'bun'
}
} catch {}
// Default to npm when uncertain
return 'npm'
}
function runStreaming(cmd: string, args: string[]): Promise<number> {
return new Promise(resolve => {
// 打印正在使用的包管理器与命令,增强透明度
try {
// eslint-disable-next-line no-console
console.log(`> ${cmd} ${args.join(' ')}`)
} catch {}
const child = spawn(cmd, args, {
stdio: 'inherit',
env: process.env,
})
child.on('close', code => resolve(code ?? 0))
child.on('error', () => resolve(1))
})
}
/**
* Generate human-friendly update commands for the detected package manager.
* Also includes an alternative manager command as fallback for users.
*/
export async function getUpdateCommandSuggestions(): Promise<string[]> {
// Prefer Bun first, then npm (consistent, simple UX). Include @latest.
return [
`bun add -g ${MACRO.PACKAGE_URL}@latest`,
`npm install -g ${MACRO.PACKAGE_URL}@latest`,
]
}
/**
* Non-blocking update notifier (daily)
* - Respects CI and disabled auto-updater
* - Uses env.hasInternetAccess() to quickly skip offline cases
* - Stores last check timestamp + last suggested version in global config
*/
export async function checkAndNotifyUpdate(): Promise<void> {
try {
if (process.env.NODE_ENV === 'test') return
if (await isAutoUpdaterDisabled()) return
if (await env.getIsDocker()) return
if (!(await env.hasInternetAccess())) return
const config: any = getGlobalConfig()
const now = Date.now()
const DAY_MS = 24 * 60 * 60 * 1000
const lastCheck = Number(config.lastUpdateCheckAt || 0)
if (lastCheck && now - lastCheck < DAY_MS) return
const latest = await getLatestVersion()
if (!latest) {
// Still record the check to avoid spamming
saveGlobalConfig({ ...config, lastUpdateCheckAt: now })
return
}
if (gt(latest, MACRO.VERSION)) {
// Update stored state and print a low-noise hint
saveGlobalConfig({
...config,
lastUpdateCheckAt: now,
lastSuggestedVersion: latest,
})
const suggestions = await getUpdateCommandSuggestions()
const first = suggestions[0]
console.log(`New version available: ${latest}. Recommended: ${first}`)
} else {
saveGlobalConfig({ ...config, lastUpdateCheckAt: now })
}
} catch (error) {
// Never block or throw; just log and move on
logError(`update-notify: ${error}`)
}
}

View File

@ -173,6 +173,8 @@ export type GlobalConfig = {
modelProfiles?: ModelProfile[] // Model configuration list
modelPointers?: ModelPointers // Model pointer system
defaultModelName?: string // Default model
// Update notifications
lastDismissedUpdateVersion?: string
}
export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
@ -196,6 +198,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
reasoning: '',
quick: '',
},
lastDismissedUpdateVersion: undefined,
}
export const GLOBAL_CONFIG_KEYS = [

View File

@ -98,12 +98,15 @@ export function isInDirectory(
: normalizedCwd + sep
// Join with a base directory to make them absolute-like for comparison
// Using 'dummy' as base to avoid any actual file system dependencies
const fullPath = resolvePath(cwd(), normalizedCwd, normalizedPath)
const fullCwd = resolvePath(cwd(), normalizedCwd)
// Check if the path starts with the cwd
return fullPath.startsWith(fullCwd)
// Robust subpath check using path.relative (case-insensitive on Windows)
const rel = relative(fullCwd, fullPath)
if (!rel || rel === '') return true
if (rel.startsWith('..')) return false
if (isAbsolute(rel)) return false
return true
}
export function readTextContent(

View File

@ -1,4 +1,4 @@
import { isAbsolute, resolve } from 'path'
import { isAbsolute, resolve, relative, sep } from 'path'
import { getCwd, getOriginalCwd } from '../state'
// In-memory storage for file permissions that resets each session
@ -12,7 +12,26 @@ const writeFileAllowedDirectories: Set<string> = new Set()
* @returns Absolute path
*/
export function toAbsolutePath(path: string): string {
return isAbsolute(path) ? resolve(path) : resolve(getCwd(), path)
const abs = isAbsolute(path) ? resolve(path) : resolve(getCwd(), path)
return normalizeForCompare(abs)
}
function normalizeForCompare(p: string): string {
// Normalize separators and resolve .. and . segments
const norm = resolve(p)
// On Windows, comparisons should be case-insensitive
return process.platform === 'win32' ? norm.toLowerCase() : norm
}
function isSubpath(base: string, target: string): boolean {
const rel = relative(base, target)
// If different drive letters on Windows, relative returns the target path
if (!rel || rel === '') return true
// Not a subpath if it goes up to parent
if (rel.startsWith('..')) return false
// Not a subpath if absolute
if (isAbsolute(rel)) return false
return true
}
/**
@ -22,7 +41,8 @@ export function toAbsolutePath(path: string): string {
*/
export function pathInOriginalCwd(path: string): boolean {
const absolutePath = toAbsolutePath(path)
return absolutePath.startsWith(toAbsolutePath(getOriginalCwd()))
const base = toAbsolutePath(getOriginalCwd())
return isSubpath(base, absolutePath)
}
/**
@ -32,12 +52,8 @@ export function pathInOriginalCwd(path: string): boolean {
*/
export function hasReadPermission(directory: string): boolean {
const absolutePath = toAbsolutePath(directory)
for (const allowedPath of readFileAllowedDirectories) {
// Permission exists for this directory or a path prefix
if (absolutePath.startsWith(allowedPath)) {
return true
}
if (isSubpath(allowedPath, absolutePath)) return true
}
return false
}
@ -49,12 +65,8 @@ export function hasReadPermission(directory: string): boolean {
*/
export function hasWritePermission(directory: string): boolean {
const absolutePath = toAbsolutePath(directory)
for (const allowedPath of writeFileAllowedDirectories) {
// Permission exists for this directory or a path prefix
if (absolutePath.startsWith(allowedPath)) {
return true
}
if (isSubpath(allowedPath, absolutePath)) return true
}
return false
}
@ -65,10 +77,9 @@ export function hasWritePermission(directory: string): boolean {
*/
function saveReadPermission(directory: string): void {
const absolutePath = toAbsolutePath(directory)
// Clean up any existing subdirectories of this path
for (const allowedPath of readFileAllowedDirectories) {
if (allowedPath.startsWith(absolutePath)) {
// Remove any existing subpaths contained by this new path
for (const allowedPath of Array.from(readFileAllowedDirectories)) {
if (isSubpath(absolutePath, allowedPath)) {
readFileAllowedDirectories.delete(allowedPath)
}
}
@ -92,10 +103,8 @@ export function grantReadPermissionForOriginalDir(): void {
*/
function saveWritePermission(directory: string): void {
const absolutePath = toAbsolutePath(directory)
// Clean up any existing subdirectories of this path
for (const allowedPath of writeFileAllowedDirectories) {
if (allowedPath.startsWith(absolutePath)) {
for (const allowedPath of Array.from(writeFileAllowedDirectories)) {
if (isSubpath(absolutePath, allowedPath)) {
writeFileAllowedDirectories.delete(allowedPath)
}
}

View File

@ -1,5 +1,5 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, renameSync } from 'node:fs'
import { join, dirname, normalize, resolve, extname } from 'node:path'
import { join, dirname, normalize, resolve, extname, relative, isAbsolute } from 'node:path'
import { homedir } from 'node:os'
/**
@ -98,7 +98,12 @@ export class SecureFileService {
// 检查是否在允许的基础路径中
const isInAllowedPath = Array.from(this.allowedBasePaths).some(basePath => {
return absolutePath.startsWith(basePath)
const base = resolve(basePath)
const rel = relative(base, absolutePath)
if (!rel || rel === '') return true
if (rel.startsWith('..')) return false
if (isAbsolute(rel)) return false
return true
})
if (!isInAllowedPath) {
@ -556,4 +561,4 @@ export class SecureFileService {
}
// 导出单例实例
export const secureFileService = SecureFileService.getInstance()
export const secureFileService = SecureFileService.getInstance()

View File

@ -2,18 +2,16 @@ import { getGlobalConfig } from './config'
export interface Theme {
bashBorder: string
claude: string
koding: string
kode: string
noting: string
permission: string
secondaryBorder: string
text: string
secondaryText: string
suggestion: string
// Semantic colors
success: string
error: string
warning: string
// UI colors
primary: string
secondary: string
diff: {
@ -25,9 +23,9 @@ export interface Theme {
}
const lightTheme: Theme = {
bashBorder: '#ff0087',
claude: '#7aff59ff',
koding: '#9dff00ff',
bashBorder: '#FF6E57',
kode: '#FFC233',
noting: '#222222',
permission: '#e9c61aff',
secondaryBorder: '#999',
text: '#000',
@ -47,31 +45,31 @@ const lightTheme: Theme = {
}
const lightDaltonizedTheme: Theme = {
bashBorder: '#0066cc', // Blue instead of pink for better contrast
claude: '#5f97cd', // Orange adjusted for deuteranopia
koding: '#0000ff',
permission: '#3366ff', // Brighter blue for better visibility
bashBorder: '#FF6E57',
kode: '#FFC233',
noting: '#222222',
permission: '#3366ff',
secondaryBorder: '#999',
text: '#000',
secondaryText: '#666',
suggestion: '#3366ff',
success: '#006699', // Blue instead of green
error: '#cc0000', // Pure red for better distinction
warning: '#ff9900', // Orange adjusted for deuteranopia
success: '#006699',
error: '#cc0000',
warning: '#ff9900',
primary: '#000',
secondary: '#666',
diff: {
added: '#99ccff', // Light blue instead of green
removed: '#ffcccc', // Light red for better contrast
added: '#99ccff',
removed: '#ffcccc',
addedDimmed: '#d1e7fd',
removedDimmed: '#ffe9e9',
},
}
const darkTheme: Theme = {
bashBorder: '#fd5db1',
claude: '#5f97cd',
koding: '#0000ff',
bashBorder: '#FF6E57',
kode: '#FFC233',
noting: '#222222',
permission: '#b1b9f9',
secondaryBorder: '#888',
text: '#fff',
@ -91,32 +89,28 @@ const darkTheme: Theme = {
}
const darkDaltonizedTheme: Theme = {
bashBorder: '#3399ff', // Bright blue instead of pink
claude: '#5f97cd', // Orange adjusted for deuteranopia
koding: '#0000ff',
permission: '#99ccff', // Light blue for better contrast
bashBorder: '#FF6E57',
kode: '#FFC233',
noting: '#222222',
permission: '#99ccff',
secondaryBorder: '#888',
text: '#fff',
secondaryText: '#999',
suggestion: '#99ccff',
success: '#3399ff', // Bright blue instead of green
error: '#ff6666', // Bright red for better visibility
warning: '#ffcc00', // Yellow-orange for deuteranopia
success: '#3399ff',
error: '#ff6666',
warning: '#ffcc00',
primary: '#fff',
secondary: '#999',
diff: {
added: '#004466', // Dark blue instead of green
removed: '#660000', // Dark red for better contrast
added: '#004466',
removed: '#660000',
addedDimmed: '#3e515b',
removedDimmed: '#3e2c2c',
},
}
export type ThemeNames =
| 'dark'
| 'light'
| 'light-daltonized'
| 'dark-daltonized'
export type ThemeNames = 'dark' | 'light' | 'light-daltonized' | 'dark-daltonized'
export function getTheme(overrideTheme?: ThemeNames): Theme {
const config = getGlobalConfig()