diff --git a/package.json b/package.json index 2303153..c55f289 100644 --- a/package.json +++ b/package.json @@ -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 ", @@ -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", diff --git a/scripts/build.ts b/scripts/build.ts index 1045f6d..c03b8f5 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -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 }; \ No newline at end of file +export { build }; diff --git a/scripts/postinstall.js b/scripts/postinstall.js index ca39683..2d8a55a 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -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(); -} \ No newline at end of file + postinstallNotice(); +} diff --git a/scripts/prepublish-check.js b/scripts/prepublish-check.js index 9b06d76..2c8f29e 100755 --- a/scripts/prepublish-check.js +++ b/scripts/prepublish-check.js @@ -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'); \ No newline at end of file +console.log(' Run: npm publish'); diff --git a/src/commands/agents.tsx b/src/commands/agents.tsx index a3c7675..7ade325 100644 --- a/src/commands/agents.tsx +++ b/src/commands/agents.tsx @@ -462,7 +462,16 @@ async function openInEditor(filePath: string): Promise { 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') } diff --git a/src/components/AsciiLogo.tsx b/src/components/AsciiLogo.tsx index d6eccf7..fbe6daf 100644 --- a/src/components/AsciiLogo.tsx +++ b/src/components/AsciiLogo.tsx @@ -7,7 +7,7 @@ export function AsciiLogo(): React.ReactNode { const theme = getTheme() return ( - {ASCII_LOGO} + {ASCII_LOGO} ) } diff --git a/src/components/CustomSelect/select-option.tsx b/src/components/CustomSelect/select-option.tsx index 9103368..5125a9e 100644 --- a/src/components/CustomSelect/select-option.tsx +++ b/src/components/CustomSelect/select-option.tsx @@ -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, }), diff --git a/src/components/Help.tsx b/src/components/Help.tsx index a68d7be..366d0a8 100644 --- a/src/components/Help.tsx +++ b/src/components/Help.tsx @@ -66,7 +66,7 @@ export function Help({ return ( - + {`${PRODUCT_NAME} v${MACRO.VERSION}`} @@ -150,7 +150,7 @@ export function Help({ {customCommands.map((cmd, i) => ( - {`/${cmd.name}`} + {`/${cmd.name}`} - {cmd.description} {cmd.aliases && cmd.aliases.length > 0 && ( diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx index 98118a0..bbb2ee2 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -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 ( + {updateBannerVersion ? ( + + New version available: {updateBannerVersion} + Run the following command to update: + + {' '} + {updateBannerCommands?.[0] ?? 'bun add -g @shareai-lab/kode@latest'} + + Or: + + {' '} + {updateBannerCommands?.[1] ?? 'npm install -g @shareai-lab/kode@latest'} + + {process.platform !== 'win32' && ( + + Note: you may need to prefix with "sudo" on macOS/Linux. + + )} + + ) : null} - Welcome to{' '} + Welcome to{' '} {PRODUCT_NAME} research preview! {/* */} diff --git a/src/components/ModelStatusDisplay.tsx b/src/components/ModelStatusDisplay.tsx index 332bbac..542df2d 100644 --- a/src/components/ModelStatusDisplay.tsx +++ b/src/components/ModelStatusDisplay.tsx @@ -44,7 +44,7 @@ export function ModelStatusDisplay({ onClose }: Props): React.ReactNode { 🎯{' '} - + {pointer.toUpperCase()} {' '} → {model.name} @@ -76,7 +76,7 @@ export function ModelStatusDisplay({ onClose }: Props): React.ReactNode { 🎯{' '} - + {pointer.toUpperCase()} {' '} → ❌ Not configured @@ -89,7 +89,7 @@ export function ModelStatusDisplay({ onClose }: Props): React.ReactNode { 🎯{' '} - + {pointer.toUpperCase()} {' '} →{' '} diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index 05c7144..f035e65 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -260,13 +260,13 @@ export function WelcomeBox(): React.ReactNode { const theme = getTheme() return ( - Welcome to{' '} + Welcome to{' '} {PRODUCT_NAME} research preview! diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx index e3013e9..ad3b2e4 100644 --- a/src/components/PromptInput.tsx +++ b/src/components/PromptInput.tsx @@ -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' ? (  !  ) : mode === 'koding' ? ( -  #  +  #  ) : (  >  @@ -668,7 +668,7 @@ function PromptInput({ ! for bash mode · # for AGENTS.md diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 5b648d0..346a9cb 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -96,9 +96,9 @@ export function Spinner(): React.ReactNode { return ( - {frames[frame]} + {frames[frame]} - {message.current}… + {message.current}… ({elapsedTime}s · esc to interrupt) @@ -123,7 +123,7 @@ export function SimpleSpinner(): React.ReactNode { return ( - {frames[frame]} + {frames[frame]} ) } diff --git a/src/components/messages/TaskProgressMessage.tsx b/src/components/messages/TaskProgressMessage.tsx index ce2de21..db205cf 100644 --- a/src/components/messages/TaskProgressMessage.tsx +++ b/src/components/messages/TaskProgressMessage.tsx @@ -14,7 +14,7 @@ export function TaskProgressMessage({ agentType, status, toolCount }: Props) { return ( - + [{agentType}] @@ -29,4 +29,4 @@ export function TaskProgressMessage({ agentType, status, toolCount }: Props) { )} ) -} \ No newline at end of file +} diff --git a/src/components/messages/UserKodingInputMessage.tsx b/src/components/messages/UserKodingInputMessage.tsx index 9b3c865..6c04126 100644 --- a/src/components/messages/UserKodingInputMessage.tsx +++ b/src/components/messages/UserKodingInputMessage.tsx @@ -20,7 +20,7 @@ export function UserKodingInputMessage({ return ( - # + # {input} diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 8470bac..e3d1ce0 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -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( , renderContext, ) @@ -1098,11 +1134,11 @@ ${commandList}`, - + Import MCP Servers from Claude Desktop @@ -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() { diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index eaeff0b..eb1f841 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -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(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: ( - + ), @@ -602,6 +619,7 @@ export function REPL({ return ( + {/* Update banner now renders inside Logo for stable placement */} => { - 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 => { }) export const useStatsigGate = (gateName: string, defaultValue = false) => { - return true + return false // const [gateValue, setGateValue] = React.useState(defaultValue) // React.useEffect(() => { // checkGate(gateName).then(setGateValue) diff --git a/src/utils/agentLoader.ts b/src/utils/agentLoader.ts index 6f8f4a5..55e2a12 100644 --- a/src/utils/agentLoader.ts +++ b/src/utils/agentLoader.ts @@ -263,22 +263,16 @@ export async function startAgentWatcher(onChange?: () => void): Promise { * Stop watching agent configuration directories */ export async function stopAgentWatcher(): Promise { - const closePromises = watchers.map(watcher => - new Promise((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 = [] -} \ No newline at end of file + } + } finally { + watchers = [] + } +} diff --git a/src/utils/autoUpdater.ts b/src/utils/autoUpdater.ts index d2ca3b7..c015418 100644 --- a/src/utils/autoUpdater.ts +++ b/src/utils/autoUpdater.ts @@ -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 { 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 { - 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 { + // 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 { } 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 { + // 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 { + 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 { + // 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 { + 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}`) + } +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 25b4c4c..96466e0 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -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 = [ diff --git a/src/utils/file.ts b/src/utils/file.ts index 703c294..05f7a5a 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -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( diff --git a/src/utils/permissions/filesystem.ts b/src/utils/permissions/filesystem.ts index d8437bf..fcdd42d 100644 --- a/src/utils/permissions/filesystem.ts +++ b/src/utils/permissions/filesystem.ts @@ -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 = 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) } } diff --git a/src/utils/secureFile.ts b/src/utils/secureFile.ts index f50c2bc..c01f3eb 100644 --- a/src/utils/secureFile.ts +++ b/src/utils/secureFile.ts @@ -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() \ No newline at end of file +export const secureFileService = SecureFileService.getInstance() diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 4317c58..a4508bd 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -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()