clean code
This commit is contained in:
parent
78b49355cd
commit
d4abb2abee
@ -123,9 +123,9 @@ kd
|
||||
### Non-Interactive Mode
|
||||
Get a quick response:
|
||||
```bash
|
||||
kode -p "explain this function" main.js
|
||||
kode -p "explain this function" path/to/file.js
|
||||
# or
|
||||
kwa -p "explain this function" main.js
|
||||
kwa -p "explain this function" path/to/file.js
|
||||
```
|
||||
|
||||
### Using the @ Mention System
|
||||
|
||||
@ -76,9 +76,9 @@ kd
|
||||
### 非交互模式
|
||||
获取快速响应:
|
||||
```bash
|
||||
kode -p "解释这个函数" main.js
|
||||
kode -p "解释这个函数" 路径/到/文件.js
|
||||
# 或
|
||||
kwa -p "解释这个函数" main.js
|
||||
kwa -p "解释这个函数" 路径/到/文件.js
|
||||
```
|
||||
|
||||
### Docker 使用说明
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
const pkg = require('./package.json');
|
||||
const fs = require('fs');
|
||||
|
||||
const declared = new Set(Object.keys(pkg.dependencies || {}));
|
||||
const used = [
|
||||
'@anthropic-ai/bedrock-sdk',
|
||||
'@anthropic-ai/sdk',
|
||||
'@anthropic-ai/vertex-sdk',
|
||||
'@commander-js/extra-typings',
|
||||
'@inkjs/ui',
|
||||
'@modelcontextprotocol/sdk',
|
||||
'@statsig/js-client',
|
||||
'@statsig/client-core',
|
||||
'ansi-escapes',
|
||||
'chalk',
|
||||
'cli-highlight',
|
||||
'cli-table3',
|
||||
'debug',
|
||||
'diff',
|
||||
'env-paths',
|
||||
'figures',
|
||||
'glob',
|
||||
'gray-matter',
|
||||
'ink',
|
||||
'ink-link',
|
||||
'ink-text-input',
|
||||
'lodash-es',
|
||||
'lru-cache',
|
||||
'marked',
|
||||
'nanoid',
|
||||
'node-fetch',
|
||||
'node-html-parser',
|
||||
'openai',
|
||||
'react',
|
||||
'semver',
|
||||
'shell-quote',
|
||||
'spawn-rx',
|
||||
'turndown',
|
||||
'undici',
|
||||
'wrap-ansi',
|
||||
'zod',
|
||||
'zod-to-json-schema'
|
||||
];
|
||||
|
||||
const builtins = new Set(['child_process','crypto','fs','fs/promises','http','os','path','process','tty','url','util','module','node:fs','node:os','node:path','node:url','node:util']);
|
||||
const missing = [];
|
||||
|
||||
used.forEach(pkg => {
|
||||
if (\!builtins.has(pkg) && \!declared.has(pkg)) {
|
||||
missing.push(pkg);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('=== MISSING DEPENDENCIES ===');
|
||||
if (missing.length === 0) {
|
||||
console.log('No missing dependencies found');
|
||||
} else {
|
||||
missing.forEach(pkg => console.log(pkg));
|
||||
}
|
||||
|
||||
console.log('=== DECLARED DEPENDENCIES ===');
|
||||
Array.from(declared).sort().forEach(pkg => console.log(pkg));
|
||||
152
scripts/build.ts
152
scripts/build.ts
@ -1,152 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { existsSync, rmSync, writeFileSync, chmodSync } from 'fs';
|
||||
|
||||
async function build() {
|
||||
console.log('🚀 Building Kode CLI...\n');
|
||||
|
||||
try {
|
||||
// Clean previous builds
|
||||
console.log('🧹 Cleaning previous builds...');
|
||||
['cli.js', '.npmrc'].forEach(file => {
|
||||
if (existsSync(file)) {
|
||||
rmSync(file, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
// Ensure dist folder exists
|
||||
if (!existsSync('dist')) {
|
||||
// @ts-ignore
|
||||
await import('node:fs/promises').then(m => m.mkdir('dist', { recursive: true }))
|
||||
}
|
||||
|
||||
// Create the CLI wrapper (prefer dist when available, then bun, then node+tsx)
|
||||
const wrapper = `#!/usr/bin/env node
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Prefer dist (pure Node) if available, otherwise try bun, then node+tsx
|
||||
const args = process.argv.slice(2);
|
||||
const cliPath = path.join(__dirname, 'src', 'entrypoints', 'cli.tsx');
|
||||
const distEntrypoint = path.join(__dirname, 'dist', 'entrypoints', 'cli.js');
|
||||
|
||||
// 1) Run compiled dist with Node if present (Windows-friendly, no bun/tsx needed)
|
||||
try {
|
||||
if (fs.existsSync(distEntrypoint)) {
|
||||
const child = spawn(process.execPath, [distEntrypoint, ...args], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
YOGA_WASM_PATH: path.join(__dirname, 'yoga.wasm'),
|
||||
},
|
||||
});
|
||||
child.on('exit', code => process.exit(code || 0));
|
||||
child.on('error', () => runWithBunOrTsx());
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// fallthrough to bun/tsx
|
||||
}
|
||||
|
||||
// 2) Otherwise, try bun first, then fall back to node+tsx
|
||||
runWithBunOrTsx();
|
||||
|
||||
function runWithBunOrTsx() {
|
||||
// Try bun first
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
execSync('bun --version', { stdio: 'ignore' });
|
||||
const child = spawn('bun', ['run', cliPath, ...args], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
YOGA_WASM_PATH: path.join(__dirname, 'yoga.wasm'),
|
||||
},
|
||||
});
|
||||
child.on('exit', code => process.exit(code || 0));
|
||||
child.on('error', () => runWithNodeTsx());
|
||||
return;
|
||||
} catch {
|
||||
// ignore and try tsx path
|
||||
}
|
||||
|
||||
runWithNodeTsx();
|
||||
}
|
||||
|
||||
function runWithNodeTsx() {
|
||||
// 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',
|
||||
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', () => runPathTsx())
|
||||
child.on('exit', (code) => {
|
||||
if (code && code !== 0) return runPathTsx()
|
||||
process.exit(code || 0)
|
||||
})
|
||||
}
|
||||
`;
|
||||
|
||||
writeFileSync('cli.js', wrapper);
|
||||
chmodSync('cli.js', 0o755);
|
||||
|
||||
// Create a slim dist/index.js that imports the real entrypoint
|
||||
const distIndex = `#!/usr/bin/env node
|
||||
import './entrypoints/cli.js';
|
||||
`;
|
||||
writeFileSync('dist/index.js', distIndex);
|
||||
chmodSync('dist/index.js', 0o755);
|
||||
// Create .npmrc
|
||||
const npmrc = `# Ensure tsx is installed
|
||||
auto-install-peers=true
|
||||
`;
|
||||
|
||||
writeFileSync('.npmrc', npmrc);
|
||||
|
||||
console.log('✅ Build completed successfully!\n');
|
||||
console.log('📋 Generated files:');
|
||||
console.log(' - cli.js (Smart CLI wrapper)');
|
||||
console.log(' - .npmrc (NPM configuration)');
|
||||
console.log('\n🚀 Ready to publish!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Build failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run build if called directly
|
||||
if (import.meta.main) {
|
||||
build();
|
||||
}
|
||||
|
||||
export { build };
|
||||
@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
async function publish() {
|
||||
console.log('🚀 Starting publish workaround...\n');
|
||||
|
||||
const packagePath = path.join(__dirname, '..', 'package.json');
|
||||
|
||||
try {
|
||||
// Read package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
const originalBundled = packageJson.bundledDependencies;
|
||||
|
||||
// Remove bundledDependencies temporarily
|
||||
console.log('📦 Removing bundledDependencies temporarily...');
|
||||
delete packageJson.bundledDependencies;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
|
||||
|
||||
// Set proxy and publish
|
||||
console.log('🌍 Setting proxy and publishing...');
|
||||
process.env.https_proxy = 'http://127.0.0.1:7890';
|
||||
process.env.http_proxy = 'http://127.0.0.1:7890';
|
||||
process.env.all_proxy = 'socks5://127.0.0.1:7890';
|
||||
process.env.SKIP_BUNDLED_CHECK = 'true';
|
||||
|
||||
execSync('npm publish --access public', {
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
});
|
||||
|
||||
// Restore bundledDependencies
|
||||
console.log('✅ Restoring bundledDependencies...');
|
||||
packageJson.bundledDependencies = originalBundled;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
|
||||
|
||||
console.log('🎉 Published successfully!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Publish failed:', error.message);
|
||||
|
||||
// Restore package.json on error
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
packageJson.bundledDependencies = ["tsx"];
|
||||
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
|
||||
} catch (e) {
|
||||
console.error('Failed to restore package.json');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
publish();
|
||||
@ -1,93 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from 'ink'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config'
|
||||
import { getTheme } from '../utils/theme'
|
||||
import { Select } from './CustomSelect/select'
|
||||
import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD'
|
||||
import chalk from 'chalk'
|
||||
|
||||
type Props = {
|
||||
customApiKeyTruncated: string
|
||||
onDone(): void
|
||||
}
|
||||
|
||||
export function ApproveApiKey({
|
||||
customApiKeyTruncated,
|
||||
onDone,
|
||||
}: Props): React.ReactNode {
|
||||
const theme = getTheme()
|
||||
|
||||
function onChange(value: 'yes' | 'no') {
|
||||
const config = getGlobalConfig()
|
||||
switch (value) {
|
||||
case 'yes': {
|
||||
saveGlobalConfig({
|
||||
...config,
|
||||
customApiKeyResponses: {
|
||||
...config.customApiKeyResponses,
|
||||
approved: [
|
||||
...(config.customApiKeyResponses?.approved ?? []),
|
||||
customApiKeyTruncated,
|
||||
],
|
||||
},
|
||||
})
|
||||
onDone()
|
||||
break
|
||||
}
|
||||
case 'no': {
|
||||
saveGlobalConfig({
|
||||
...config,
|
||||
customApiKeyResponses: {
|
||||
...config.customApiKeyResponses,
|
||||
rejected: [
|
||||
...(config.customApiKeyResponses?.rejected ?? []),
|
||||
customApiKeyTruncated,
|
||||
],
|
||||
},
|
||||
})
|
||||
onDone()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const exitState = useExitOnCtrlCD(() => process.exit(0))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
padding={1}
|
||||
borderStyle="round"
|
||||
borderColor={theme.warning}
|
||||
>
|
||||
<Text bold color={theme.warning}>
|
||||
Detected a custom API key in your environment
|
||||
</Text>
|
||||
<Text>
|
||||
Your environment sets{' '}
|
||||
<Text color={theme.warning}>ANTHROPIC_API_KEY</Text>:{' '}
|
||||
<Text bold>sk-ant-...{customApiKeyTruncated}</Text>
|
||||
</Text>
|
||||
<Text>Do you want to use this API key?</Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: `No (${chalk.bold('recommended')})`, value: 'no' },
|
||||
{ label: 'Yes', value: 'yes' },
|
||||
]}
|
||||
onChange={value => onChange(value as 'yes' | 'no')}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginLeft={3}>
|
||||
<Text dimColor>
|
||||
{exitState.pending ? (
|
||||
<>Press {exitState.keyName} again to exit</>
|
||||
) : (
|
||||
<>Enter to confirm</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import { Box, Text, useInput } from 'ink'
|
||||
import { sample } from 'lodash-es'
|
||||
import { getExampleCommands } from '../utils/exampleCommands'
|
||||
import * as React from 'react'
|
||||
import { type Message } from '../query'
|
||||
import { processUserInput } from '../utils/messages'
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface FormData {
|
||||
// Define form data structure as needed
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface StickerRequestFormProps {
|
||||
// Define props as needed
|
||||
onSubmit?: (data: FormData) => void
|
||||
}
|
||||
|
||||
export const StickerRequestForm: React.FC<StickerRequestFormProps> = () => {
|
||||
// Minimal component implementation
|
||||
return null
|
||||
}
|
||||
@ -17,8 +17,7 @@ type BinaryFeedbackConfig = {
|
||||
sampleFrequency: number
|
||||
}
|
||||
|
||||
async function getBinaryFeedbackStatsigConfig(): Promise<BinaryFeedbackConfig> {
|
||||
|
||||
async function getBinaryFeedbackConfig(): Promise<BinaryFeedbackConfig> {
|
||||
return { sampleFrequency: 0 }
|
||||
}
|
||||
|
||||
@ -30,32 +29,7 @@ function getMessageBlockSequence(m: AssistantMessage) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function logBinaryFeedbackEvent(
|
||||
m1: AssistantMessage,
|
||||
m2: AssistantMessage,
|
||||
choice: BinaryFeedbackChoice,
|
||||
): Promise<void> {
|
||||
const modelA = m1.message.model
|
||||
const modelB = m2.message.model
|
||||
const gitState = await getGitState()
|
||||
|
||||
}
|
||||
|
||||
export async function logBinaryFeedbackSamplingDecision(
|
||||
decision: boolean,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
|
||||
}
|
||||
|
||||
export async function logBinaryFeedbackDisplayDecision(
|
||||
decision: boolean,
|
||||
m1: AssistantMessage,
|
||||
m2: AssistantMessage,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
|
||||
}
|
||||
// Logging removed to minimize runtime surface area; behavior unaffected
|
||||
|
||||
function textContentBlocksEqual(cb1: TextBlock, cb2: TextBlock): boolean {
|
||||
return cb1.text === cb2.text
|
||||
@ -89,34 +63,27 @@ function allContentBlocksEqual(
|
||||
|
||||
export async function shouldUseBinaryFeedback(): Promise<boolean> {
|
||||
if (process.env.DISABLE_BINARY_FEEDBACK) {
|
||||
logBinaryFeedbackSamplingDecision(false, 'disabled_by_env_var')
|
||||
return false
|
||||
}
|
||||
if (process.env.FORCE_BINARY_FEEDBACK) {
|
||||
logBinaryFeedbackSamplingDecision(true, 'forced_by_env_var')
|
||||
return true
|
||||
}
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
logBinaryFeedbackSamplingDecision(false, 'not_ant')
|
||||
return false
|
||||
}
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// Binary feedback breaks a couple tests related to checking for permission,
|
||||
// so we have to disable it in tests at the risk of hiding bugs
|
||||
logBinaryFeedbackSamplingDecision(false, 'test')
|
||||
return false
|
||||
}
|
||||
|
||||
const config = await getBinaryFeedbackStatsigConfig()
|
||||
const config = await getBinaryFeedbackConfig()
|
||||
if (config.sampleFrequency === 0) {
|
||||
logBinaryFeedbackSamplingDecision(false, 'top_level_frequency_zero')
|
||||
return false
|
||||
}
|
||||
if (Math.random() > config.sampleFrequency) {
|
||||
logBinaryFeedbackSamplingDecision(false, 'top_level_frequency_rng')
|
||||
return false
|
||||
}
|
||||
logBinaryFeedbackSamplingDecision(true)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -124,9 +91,8 @@ export function messagePairValidForBinaryFeedback(
|
||||
m1: AssistantMessage,
|
||||
m2: AssistantMessage,
|
||||
): boolean {
|
||||
const logPass = () => logBinaryFeedbackDisplayDecision(true, m1, m2)
|
||||
const logFail = (reason: string) =>
|
||||
logBinaryFeedbackDisplayDecision(false, m1, m2, reason)
|
||||
const logPass = () => {}
|
||||
const logFail = (_reason: string) => {}
|
||||
|
||||
// Ignore thinking blocks, on the assumption that users don't find them very relevant
|
||||
// compared to other content types
|
||||
@ -185,3 +151,9 @@ export function getBinaryFeedbackResultForChoice(
|
||||
return { message: null, shouldSkipPermissionCheck: false }
|
||||
}
|
||||
}
|
||||
// Keep a minimal exported stub to satisfy imports without side effects
|
||||
export async function logBinaryFeedbackEvent(
|
||||
_m1: AssistantMessage,
|
||||
_m2: AssistantMessage,
|
||||
_choice: BinaryFeedbackChoice,
|
||||
): Promise<void> {}
|
||||
|
||||
@ -10,8 +10,7 @@ type UnaryEventType = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs permission request events using Statsig and unary logging.
|
||||
* Handles both the Statsig event and the unary event logging.
|
||||
* Logs permission request events via unary logging.
|
||||
* Can handle either a string or Promise<string> for language_name.
|
||||
*/
|
||||
export function usePermissionRequestLogging(
|
||||
|
||||
@ -61,7 +61,6 @@ import { dateToFilename, logError, parseLogFilename } from '../utils/log'
|
||||
import { initDebugLogger } from '../utils/debugLogger'
|
||||
import { Onboarding } from '../components/Onboarding'
|
||||
import { Doctor } from '../screens/Doctor'
|
||||
import { ApproveApiKey } from '../components/ApproveApiKey'
|
||||
import { TrustDialog } from '../components/TrustDialog'
|
||||
import { checkHasTrustDialogAccepted, McpServerConfig } from '../utils/config'
|
||||
import { isDefaultSlowAndCapableModel } from '../utils/model'
|
||||
@ -90,14 +89,12 @@ import {
|
||||
} from '../services/mcpClient'
|
||||
import { handleMcprcServerApprovals } from '../services/mcpServerApproval'
|
||||
|
||||
import { getExampleCommands } from '../utils/exampleCommands'
|
||||
import { cursorShow } from 'ansi-escapes'
|
||||
import { getLatestVersion, 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'
|
||||
// Vendor beta gates removed
|
||||
import { clearTerminal } from '../utils/terminal'
|
||||
import { showInvalidConfigDialog } from '../components/InvalidConfigDialog'
|
||||
import { ConfigParseError } from '../utils/errors'
|
||||
@ -143,29 +140,7 @@ async function showSetupScreens(
|
||||
})
|
||||
}
|
||||
|
||||
// // Check for custom API key (only allowed for ants)
|
||||
// if (process.env.ANTHROPIC_API_KEY && process.env.USER_TYPE === 'ant') {
|
||||
// const customApiKeyTruncated = normalizeApiKeyForConfig(
|
||||
// process.env.ANTHROPIC_API_KEY!,
|
||||
// )
|
||||
// const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated)
|
||||
// if (keyStatus === 'new') {
|
||||
// await new Promise<void>(resolve => {
|
||||
// render(
|
||||
// <ApproveApiKey
|
||||
// customApiKeyTruncated={customApiKeyTruncated}
|
||||
// onDone={async () => {
|
||||
// await clearTerminal()
|
||||
// resolve()
|
||||
// }}
|
||||
// />,
|
||||
// {
|
||||
// exitOnCtrlC: false,
|
||||
// },
|
||||
// )
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
// In non-interactive mode, only show trust dialog in safe mode
|
||||
if (!print && safeMode) {
|
||||
@ -244,9 +219,7 @@ async function setup(cwd: string, safeMode?: boolean): Promise<void> {
|
||||
}
|
||||
|
||||
cleanupOldMessageFilesInBackground()
|
||||
// getExampleCommands() // Pre-fetch example commands
|
||||
getContext() // Pre-fetch all context data at once
|
||||
// initializeStatsig() // Kick off statsig initialization
|
||||
|
||||
// Migrate old iterm2KeyBindingInstalled config to new shiftEnterKeyBindingInstalled
|
||||
const globalConfig = getGlobalConfig()
|
||||
|
||||
@ -10,8 +10,7 @@ export type UnaryEvent = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs permission request events using Statsig and unary logging.
|
||||
* Handles both the Statsig event and the unary event logging.
|
||||
* Logs permission request events via unary logging.
|
||||
* Can handle either a string or Promise<string> for language_name.
|
||||
*/
|
||||
export function usePermissionRequestLogging(
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
// Mock browser APIs needed by @statsig/js-client in Node.js environment
|
||||
|
||||
// Document mock with visibility state tracking
|
||||
const mockDocument = {
|
||||
visibilityState: 'visible' as const,
|
||||
documentElement: {
|
||||
lang: 'en',
|
||||
},
|
||||
addEventListener: (
|
||||
_event: string,
|
||||
_handler: EventListenerOrEventListenerObject,
|
||||
) => {
|
||||
// Visibility change events are handled through window.document reference
|
||||
},
|
||||
} as const
|
||||
|
||||
// Window mock with focus/blur and beforeunload handling
|
||||
export const mockWindow = {
|
||||
document: mockDocument,
|
||||
location: {
|
||||
href: 'node://localhost',
|
||||
pathname: '/',
|
||||
},
|
||||
addEventListener: (
|
||||
event: string,
|
||||
handler: EventListenerOrEventListenerObject,
|
||||
) => {
|
||||
if (event === 'beforeunload') {
|
||||
// Capture beforeunload handlers and run them on process exit
|
||||
process.on('exit', () => {
|
||||
if (typeof handler === 'function') {
|
||||
handler({} as Event)
|
||||
} else {
|
||||
handler.handleEvent({} as Event)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Other events (focus/blur) are not critically needed in Node.js
|
||||
},
|
||||
focus: () => {
|
||||
// Focus is a no-op in Node.js
|
||||
},
|
||||
innerHeight: 768,
|
||||
innerWidth: 1024,
|
||||
} as const
|
||||
|
||||
// Navigator mock with minimal beacon support
|
||||
export const mockNavigator = {
|
||||
sendBeacon: (_url: string, _data: string | Blob): boolean => {
|
||||
// Beacons are used for analytics - return success but don't actually send
|
||||
return true
|
||||
},
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Node.js) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0',
|
||||
language: 'en-US',
|
||||
} as const
|
||||
|
||||
// Only assign mocks if running in Node.js environment
|
||||
if (typeof window === 'undefined') {
|
||||
// @ts-expect-error: intentionally applying partial mocks for Node.js environment
|
||||
global.window = mockWindow
|
||||
}
|
||||
if (typeof navigator === 'undefined') {
|
||||
// @ts-expect-error: intentionally applying partial mocks for Node.js environment
|
||||
global.navigator = mockNavigator
|
||||
}
|
||||
@ -1088,7 +1088,7 @@ export function assistantMessageToMessageParam(
|
||||
|
||||
function splitSysPromptPrefix(systemPrompt: string[]): string[] {
|
||||
// split out the first block of the system prompt as the "prefix" for API
|
||||
// to match on in https://console.statsig.com/4aF3Ewatb6xPVpCwxb5nA3/dynamic_configs/claude_cli_system_prompt_prefixes
|
||||
|
||||
const systemPromptFirstBlock = systemPrompt[0] || ''
|
||||
const systemPromptRest = systemPrompt.slice(1)
|
||||
return [systemPromptFirstBlock, systemPromptRest.join('\n')].filter(Boolean)
|
||||
@ -1821,7 +1821,7 @@ async function queryOpenAI(
|
||||
}
|
||||
// Prepend system prompt block for easy API identification
|
||||
if (options?.prependCLISysprompt) {
|
||||
// Log stats about first block for analyzing prefix matching config (see https://console.statsig.com/4aF3Ewatb6xPVpCwxb5nA3/dynamic_configs/claude_cli_system_prompt_prefixes)
|
||||
|
||||
const [firstSyspromptBlock] = splitSysPromptPrefix(systemPrompt)
|
||||
|
||||
systemPrompt = [getCLISyspromptPrefix() + systemPrompt] // some openai-like providers need the entire system prompt as a single block
|
||||
|
||||
@ -29,8 +29,7 @@ export const MemoryReadTool = {
|
||||
return 'Read Memory'
|
||||
},
|
||||
async isEnabled() {
|
||||
// TODO: Use a statsig gate
|
||||
// TODO: Figure out how to do that without regressing app startup perf
|
||||
// TODO: Gate with a setting or feature flag
|
||||
return false
|
||||
},
|
||||
isReadOnly() {
|
||||
|
||||
@ -28,8 +28,7 @@ export const MemoryWriteTool = {
|
||||
return 'Write Memory'
|
||||
},
|
||||
async isEnabled() {
|
||||
// TODO: Use a statsig gate
|
||||
// TODO: Figure out how to do that without regressing app startup perf
|
||||
// TODO: Gate with a setting or feature flag
|
||||
return false
|
||||
},
|
||||
isReadOnly() {
|
||||
|
||||
@ -1,98 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import React from 'react'
|
||||
import { Text } from 'ink'
|
||||
import { Tool, ToolUseContext, ExtendedToolUseContext } from '../../Tool'
|
||||
import { DESCRIPTION, PROMPT } from './prompt'
|
||||
import {
|
||||
StickerRequestForm,
|
||||
FormData,
|
||||
} from '../../components/StickerRequestForm'
|
||||
// Telemetry and gates removed
|
||||
import { getTheme } from '../../utils/theme'
|
||||
|
||||
const stickerRequestSchema = z.object({
|
||||
trigger: z.string(),
|
||||
})
|
||||
|
||||
export const StickerRequestTool: Tool = {
|
||||
name: 'StickerRequest',
|
||||
userFacingName: () => 'Stickers',
|
||||
description: async () => DESCRIPTION,
|
||||
inputSchema: stickerRequestSchema,
|
||||
isEnabled: async () => false,
|
||||
isReadOnly: () => false,
|
||||
isConcurrencySafe: () => false, // StickerRequestTool modifies state, not safe for concurrent execution
|
||||
needsPermissions: () => false,
|
||||
prompt: async () => PROMPT,
|
||||
|
||||
async *call(_, context: ToolUseContext) {
|
||||
|
||||
|
||||
// Create a promise to track form completion and status
|
||||
let resolveForm: (success: boolean) => void
|
||||
const formComplete = new Promise<boolean>(resolve => {
|
||||
resolveForm = success => resolve(success)
|
||||
})
|
||||
|
||||
// Check if setToolJSX is available (cast context if needed)
|
||||
const extendedContext = context as ExtendedToolUseContext
|
||||
if (extendedContext.setToolJSX) {
|
||||
extendedContext.setToolJSX({
|
||||
jsx: (
|
||||
<StickerRequestForm
|
||||
onSubmit={(formData: FormData) => {
|
||||
|
||||
resolveForm(true)
|
||||
if (extendedContext.setToolJSX) {
|
||||
extendedContext.setToolJSX(null) // Clear the JSX
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
|
||||
resolveForm(false)
|
||||
if (extendedContext.setToolJSX) {
|
||||
extendedContext.setToolJSX(null) // Clear the JSX
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
shouldHidePromptInput: true,
|
||||
})
|
||||
} else {
|
||||
// Fallback if setToolJSX is not available
|
||||
console.log('Sticker form would be displayed here, but setToolJSX is not available')
|
||||
resolveForm(false)
|
||||
}
|
||||
|
||||
// Wait for form completion and get status
|
||||
const success = await formComplete
|
||||
|
||||
if (!success) {
|
||||
context.abortController.abort()
|
||||
throw new Error('Sticker request cancelled')
|
||||
}
|
||||
|
||||
// Return success message
|
||||
yield {
|
||||
type: 'result',
|
||||
resultForAssistant:
|
||||
'Sticker request completed! Please tell the user that they will receive stickers in the mail if they have submitted the form!',
|
||||
data: { success },
|
||||
}
|
||||
},
|
||||
|
||||
renderToolUseMessage(_input) {
|
||||
return ''
|
||||
},
|
||||
|
||||
renderToolUseRejectedMessage() {
|
||||
return (
|
||||
<Text>
|
||||
⎿
|
||||
<Text color={getTheme().error}>No (Sticker request cancelled)</Text>
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
|
||||
renderResultForAssistant: (content: string) => content,
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
export const DESCRIPTION =
|
||||
'Sends the user swag stickers with love from Anthropic.'
|
||||
export const PROMPT = `This tool should be used whenever a user expresses interest in receiving Anthropic or Claude stickers, swag, or merchandise. When triggered, it will display a shipping form for the user to enter their mailing address and contact details. Once submitted, Anthropic will process the request and ship stickers to the provided address.
|
||||
|
||||
Common trigger phrases to watch for:
|
||||
- "Can I get some Anthropic stickers please?"
|
||||
- "How do I get Anthropic swag?"
|
||||
- "I'd love some Claude stickers"
|
||||
- "Where can I get merchandise?"
|
||||
- Any mention of wanting stickers or swag
|
||||
|
||||
The tool handles the entire request process by showing an interactive form to collect shipping information.
|
||||
|
||||
NOTE: Only use this tool if the user has explicitly asked us to send or give them stickers. If there are other requests that include the word "sticker", but do not explicitly ask us to send them stickers, do not use this tool.
|
||||
For example:
|
||||
- "How do I make custom stickers for my project?" - Do not use this tool
|
||||
- "I need to store sticker metadata in a database - what schema do you recommend?" - Do not use this tool
|
||||
- "Show me how to implement drag-and-drop sticker placement with React" - Do not use this tool
|
||||
`
|
||||
@ -5,7 +5,6 @@ import { Tool } from '../../Tool'
|
||||
import { DESCRIPTION, PROMPT } from './prompt'
|
||||
import { getTheme } from '../../utils/theme'
|
||||
import { MessageResponse } from '../../components/MessageResponse'
|
||||
// Telemetry and gates removed
|
||||
import { USE_BEDROCK, USE_VERTEX } from '../../utils/model'
|
||||
|
||||
const thinkToolSchema = z.object({
|
||||
|
||||
@ -388,9 +388,6 @@ export const debug = {
|
||||
// 新增UI相关的调试函数 (只记录到文件,不显示在终端)
|
||||
ui: (phase: string, data: any, requestId?: string) =>
|
||||
debugLog(LogLevel.STATE, `UI_${phase}`, data, requestId),
|
||||
|
||||
// 新增Statsig事件追踪
|
||||
statsig: (phase: string, data: any) => debugLog(LogLevel.TRACE, phase, data),
|
||||
}
|
||||
|
||||
// 请求生命周期管理
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
import {
|
||||
getGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
getCurrentProjectConfig,
|
||||
saveCurrentProjectConfig,
|
||||
} from './config.js'
|
||||
import { env } from './env'
|
||||
import { getCwd } from './state'
|
||||
import { exec } from 'child_process'
|
||||
import { logError } from './log'
|
||||
import { memoize, sample } from 'lodash-es'
|
||||
import { promisify } from 'util'
|
||||
import { getIsGit } from './git'
|
||||
import { queryQuick } from '../services/claude'
|
||||
|
||||
const execPromise = promisify(exec)
|
||||
|
||||
async function getFrequentlyModifiedFiles(): Promise<string[]> {
|
||||
if (process.env.NODE_ENV === 'test') return []
|
||||
if (env.platform === 'windows') return []
|
||||
if (!(await getIsGit())) return []
|
||||
|
||||
try {
|
||||
let filenames = ''
|
||||
// Look up files modified by the user's recent commits
|
||||
// Be careful to do it async, so it doesn't block the main thread
|
||||
const { stdout: userFilenames } = await execPromise(
|
||||
'git log -n 1000 --pretty=format: --name-only --diff-filter=M --author=$(git config user.email) | sort | uniq -c | sort -nr | head -n 20',
|
||||
{ cwd: getCwd(), encoding: 'utf8' },
|
||||
)
|
||||
|
||||
filenames = 'Files modified by user:\n' + userFilenames
|
||||
|
||||
// Look at other users' commits if we don't have enough files
|
||||
if (userFilenames.split('\n').length < 10) {
|
||||
const { stdout: allFilenames } = await execPromise(
|
||||
'git log -n 1000 --pretty=format: --name-only --diff-filter=M | sort | uniq -c | sort -nr | head -n 20',
|
||||
{ cwd: getCwd(), encoding: 'utf8' },
|
||||
)
|
||||
filenames += '\n\nFiles modified by other users:\n' + allFilenames
|
||||
}
|
||||
const response = await queryQuick({
|
||||
systemPrompt: [
|
||||
"You are an expert at analyzing git history. Given a list of files and their modification counts, return exactly five filenames that are frequently modified and represent core application logic (not auto-generated files, dependencies, or configuration). Make sure filenames are diverse, not all in the same folder, and are a mix of user and other users. Return only the filenames' basenames (without the path) separated by newlines with no explanation.",
|
||||
],
|
||||
userPrompt: filenames,
|
||||
})
|
||||
|
||||
const content = response.message.content[0]
|
||||
if (!content || content.type !== 'text') return []
|
||||
const chosenFilenames = content.text.trim().split('\n')
|
||||
if (chosenFilenames.length < 5) {
|
||||
// Likely error
|
||||
return []
|
||||
}
|
||||
return chosenFilenames
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const getExampleCommands = memoize(async (): Promise<string[]> => {
|
||||
const globalConfig = getGlobalConfig()
|
||||
const projectConfig = getCurrentProjectConfig()
|
||||
const now = Date.now()
|
||||
const lastGenerated = projectConfig.exampleFilesGeneratedAt ?? 0
|
||||
const oneWeek = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// Regenerate examples if they're over a week old
|
||||
if (now - lastGenerated > oneWeek) {
|
||||
projectConfig.exampleFiles = []
|
||||
}
|
||||
|
||||
// Update global startup count
|
||||
const newGlobalConfig = {
|
||||
...globalConfig,
|
||||
numStartups: (globalConfig.numStartups ?? 0) + 1,
|
||||
}
|
||||
saveGlobalConfig(newGlobalConfig)
|
||||
|
||||
// // If no example files cached, kickstart fetch in background
|
||||
// if (!projectConfig.exampleFiles?.length) {
|
||||
// getFrequentlyModifiedFiles().then(files => {
|
||||
// if (files.length) {
|
||||
// saveCurrentProjectConfig({
|
||||
// ...getCurrentProjectConfig(),
|
||||
// exampleFiles: files,
|
||||
// exampleFilesGeneratedAt: Date.now(),
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
const frequentFile = projectConfig.exampleFiles?.length
|
||||
? sample(projectConfig.exampleFiles)
|
||||
: '<filepath>'
|
||||
|
||||
return [
|
||||
'fix lint errors',
|
||||
'fix typecheck errors',
|
||||
`how does ${frequentFile} work?`,
|
||||
`refactor ${frequentFile}`,
|
||||
'how do I log an error?',
|
||||
`edit ${frequentFile} to...`,
|
||||
`write a test for ${frequentFile}`,
|
||||
'create a util logging.py that...',
|
||||
]
|
||||
})
|
||||
@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 统一的全局日志系统
|
||||
* 普通模式:完全静默,零日志输出
|
||||
* 调试模式:详细日志输出
|
||||
*/
|
||||
|
||||
// 环境检测 - 只在明确的调试标志下才启用日志
|
||||
const isDebugMode = () =>
|
||||
process.argv.includes('--debug') ||
|
||||
process.argv.includes('--verbose') ||
|
||||
process.env.NODE_ENV === 'development'
|
||||
|
||||
// 全局日志开关 - 普通模式下完全关闭
|
||||
const LOGGING_ENABLED = isDebugMode()
|
||||
|
||||
/**
|
||||
* 统一的日志接口
|
||||
* 普通模式下所有调用都是空操作
|
||||
*/
|
||||
export const globalLogger = {
|
||||
// 标准日志级别
|
||||
debug: (...args: any[]) => {
|
||||
if (LOGGING_ENABLED) console.debug(...args)
|
||||
},
|
||||
|
||||
info: (...args: any[]) => {
|
||||
if (LOGGING_ENABLED) console.info(...args)
|
||||
},
|
||||
|
||||
warn: (...args: any[]) => {
|
||||
if (LOGGING_ENABLED) console.warn(...args)
|
||||
},
|
||||
|
||||
error: (...args: any[]) => {
|
||||
if (LOGGING_ENABLED) console.error(...args)
|
||||
},
|
||||
|
||||
log: (...args: any[]) => {
|
||||
if (LOGGING_ENABLED) console.log(...args)
|
||||
},
|
||||
|
||||
// 兼容现有的console.log调用
|
||||
console: (...args: any[]) => {
|
||||
if (LOGGING_ENABLED) console.log(...args)
|
||||
},
|
||||
|
||||
// 模型切换相关日志
|
||||
modelSwitch: (message: string, data?: any) => {
|
||||
if (LOGGING_ENABLED) {
|
||||
console.log(`🔄 Model Switch: ${message}`, data ? data : '')
|
||||
}
|
||||
},
|
||||
|
||||
// API 相关日志
|
||||
api: (message: string, data?: any) => {
|
||||
if (LOGGING_ENABLED) {
|
||||
console.log(`🌐 API: ${message}`, data ? data : '')
|
||||
}
|
||||
},
|
||||
|
||||
// 用户友好的状态日志 - 只在调试模式下显示
|
||||
status: (message: string) => {
|
||||
if (LOGGING_ENABLED) {
|
||||
console.log(`ℹ️ ${message}`)
|
||||
}
|
||||
},
|
||||
|
||||
// 检查日志是否启用
|
||||
isEnabled: () => LOGGING_ENABLED
|
||||
}
|
||||
|
||||
// 兼容性:导出为默认console替代
|
||||
export const logger = globalLogger
|
||||
|
||||
// 用于替换现有的console.log调用
|
||||
export const debugLog = globalLogger.console
|
||||
export const statusLog = globalLogger.status
|
||||
@ -24,8 +24,7 @@ const DEFAULT_MODEL_CONFIG: ModelConfig = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the model config from statsig or defaults
|
||||
* Relies on the built-in caching from StatsigClient
|
||||
* Helper to get the model config from defaults.
|
||||
*/
|
||||
async function getModelConfig(): Promise<ModelConfig> {
|
||||
return DEFAULT_MODEL_CONFIG
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Response state management for Responses API
|
||||
* Tracks previous_response_id for conversation chaining
|
||||
*/
|
||||
|
||||
// Store the last response ID for each conversation
|
||||
const responseIdCache = new Map<string, string>()
|
||||
|
||||
export function getLastResponseId(conversationId: string): string | undefined {
|
||||
return responseIdCache.get(conversationId)
|
||||
}
|
||||
|
||||
export function setLastResponseId(conversationId: string, responseId: string): void {
|
||||
responseIdCache.set(conversationId, responseId)
|
||||
}
|
||||
|
||||
export function clearResponseId(conversationId: string): void {
|
||||
responseIdCache.delete(conversationId)
|
||||
}
|
||||
|
||||
export function clearAllResponseIds(): void {
|
||||
responseIdCache.clear()
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
import { parseFrontmatter, loadCustomCommands } from '../src/services/customCommands'
|
||||
import { describe, expect, test } from '@jest/globals'
|
||||
|
||||
describe('Custom Commands', () => {
|
||||
describe('parseFrontmatter', () => {
|
||||
test('should parse YAML frontmatter correctly', () => {
|
||||
const content = `---
|
||||
name: test-command
|
||||
description: A test command
|
||||
aliases: [tc, test]
|
||||
enabled: true
|
||||
hidden: false
|
||||
---
|
||||
|
||||
This is the command content.`
|
||||
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
expect(result.frontmatter.name).toBe('test-command')
|
||||
expect(result.frontmatter.description).toBe('A test command')
|
||||
expect(result.frontmatter.aliases).toEqual(['tc', 'test'])
|
||||
expect(result.frontmatter.enabled).toBe(true)
|
||||
expect(result.frontmatter.hidden).toBe(false)
|
||||
expect(result.content.trim()).toBe('This is the command content.')
|
||||
})
|
||||
|
||||
test('should handle missing frontmatter', () => {
|
||||
const content = 'Just some content without frontmatter.'
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
expect(result.frontmatter).toEqual({})
|
||||
expect(result.content).toBe(content)
|
||||
})
|
||||
|
||||
test('should handle multi-line arrays', () => {
|
||||
const content = `---
|
||||
name: multi-array
|
||||
aliases:
|
||||
- alias1
|
||||
- alias2
|
||||
- alias3
|
||||
---
|
||||
|
||||
Content here.`
|
||||
|
||||
const result = parseFrontmatter(content)
|
||||
expect(result.frontmatter.aliases).toEqual(['alias1', 'alias2', 'alias3'])
|
||||
})
|
||||
|
||||
test('should handle inline arrays', () => {
|
||||
const content = `---
|
||||
name: inline-array
|
||||
aliases: [a1, a2, a3]
|
||||
argNames: ["env", "version"]
|
||||
---
|
||||
|
||||
Content here.`
|
||||
|
||||
const result = parseFrontmatter(content)
|
||||
expect(result.frontmatter.aliases).toEqual(['a1', 'a2', 'a3'])
|
||||
expect(result.frontmatter.argNames).toEqual(['env', 'version'])
|
||||
})
|
||||
|
||||
test('should handle boolean values', () => {
|
||||
const content = `---
|
||||
enabled: true
|
||||
hidden: false
|
||||
---
|
||||
|
||||
Content`
|
||||
|
||||
const result = parseFrontmatter(content)
|
||||
expect(result.frontmatter.enabled).toBe(true)
|
||||
expect(result.frontmatter.hidden).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,566 +0,0 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from '@jest/globals'
|
||||
import { SecureFileService } from '../src/utils/secureFile'
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, rmdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
describe('SecureFileService', () => {
|
||||
let secureFileService: SecureFileService
|
||||
let testDir: string
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
secureFileService = SecureFileService.getInstance()
|
||||
testDir = join(process.cwd(), 'test-temp')
|
||||
tempDir = '/tmp/secure-file-test'
|
||||
|
||||
// Create test directories
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true })
|
||||
}
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test files
|
||||
const cleanupDir = (dir: string) => {
|
||||
if (existsSync(dir)) {
|
||||
const files = require('node:fs').readdirSync(dir)
|
||||
for (const file of files) {
|
||||
const filePath = join(dir, file)
|
||||
if (require('node:fs').statSync(filePath).isDirectory()) {
|
||||
cleanupDir(filePath)
|
||||
rmdirSync(filePath)
|
||||
} else {
|
||||
unlinkSync(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanupDir(testDir)
|
||||
cleanupDir(tempDir)
|
||||
|
||||
try {
|
||||
rmdirSync(testDir)
|
||||
rmdirSync(tempDir)
|
||||
} catch {
|
||||
// Ignore errors if directories don't exist
|
||||
}
|
||||
})
|
||||
|
||||
describe('validateFilePath', () => {
|
||||
test('should validate valid file paths', () => {
|
||||
const validPaths = [
|
||||
join(testDir, 'test.txt'),
|
||||
join(process.cwd(), 'test.js'),
|
||||
join(tempDir, 'test.json'),
|
||||
join(require('node:os').homedir(), '.testrc')
|
||||
]
|
||||
|
||||
validPaths.forEach(path => {
|
||||
const result = secureFileService.validateFilePath(path)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test('should reject paths with traversal characters', () => {
|
||||
// Test with absolute paths that would traverse outside allowed directories
|
||||
const invalidPaths = [
|
||||
'/etc/passwd',
|
||||
'/usr/bin/ls',
|
||||
'/root/.ssh/id_rsa'
|
||||
]
|
||||
|
||||
invalidPaths.forEach(path => {
|
||||
const result = secureFileService.validateFilePath(path)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('outside allowed directories')
|
||||
})
|
||||
})
|
||||
|
||||
test('should reject paths with tilde character', () => {
|
||||
const result = secureFileService.validateFilePath('~/some/file')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('traversal')
|
||||
})
|
||||
|
||||
test('should reject paths with suspicious patterns', () => {
|
||||
const suspiciousPaths = [
|
||||
join(testDir, 'test${HOME}.txt'),
|
||||
join(testDir, 'test`command`.txt'),
|
||||
join(testDir, 'test|pipe.txt'),
|
||||
join(testDir, 'test;command.txt'),
|
||||
join(testDir, 'test&background.txt'),
|
||||
join(testDir, 'test>redirect.txt'),
|
||||
join(testDir, 'test<input.txt')
|
||||
]
|
||||
|
||||
suspiciousPaths.forEach(path => {
|
||||
const result = secureFileService.validateFilePath(path)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('suspicious pattern')
|
||||
})
|
||||
})
|
||||
|
||||
test('should reject paths outside allowed directories', () => {
|
||||
const restrictedPaths = [
|
||||
'/etc/passwd',
|
||||
'/usr/bin/ls',
|
||||
'/root/.ssh/id_rsa',
|
||||
'/var/log/syslog'
|
||||
]
|
||||
|
||||
restrictedPaths.forEach(path => {
|
||||
const result = secureFileService.validateFilePath(path)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('outside allowed directories')
|
||||
})
|
||||
})
|
||||
|
||||
test('should reject paths that are too long', () => {
|
||||
const longPath = 'a'.repeat(5000)
|
||||
const result = secureFileService.validateFilePath(longPath)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('Path too long')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFileName', () => {
|
||||
test('should validate valid filenames', () => {
|
||||
const validFilenames = [
|
||||
'test.txt',
|
||||
'my-file.js',
|
||||
'data.json',
|
||||
'config.yml',
|
||||
'script.sh',
|
||||
'file.with.multiple.dots',
|
||||
'UPPERCASE.TXT',
|
||||
'mixedCase.Js'
|
||||
]
|
||||
|
||||
validFilenames.forEach(filename => {
|
||||
const result = secureFileService.validateFileName(filename)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test('should reject invalid filenames', () => {
|
||||
const invalidFilenames = [
|
||||
'', // empty
|
||||
'a'.repeat(300), // too long
|
||||
'test<file>.txt', // contains <
|
||||
'test>file.txt', // contains >
|
||||
'test:file.txt', // contains :
|
||||
'test"file".txt', // contains "
|
||||
'test/file.txt', // contains /
|
||||
'test\\file.txt', // contains \
|
||||
'test|file.txt', // contains |
|
||||
'test?file.txt', // contains ?
|
||||
'test*file.txt', // contains *
|
||||
'test\x00file.txt', // contains null character
|
||||
'CON', // reserved name
|
||||
'PRN.txt', // reserved name
|
||||
'AUX.js', // reserved name
|
||||
'NUL.json', // reserved name
|
||||
'COM1.bat', // reserved name
|
||||
'LPT1.sh', // reserved name
|
||||
'.hidden', // starts with dot
|
||||
'file.', // ends with dot
|
||||
' file.txt', // starts with space
|
||||
'file.txt ' // ends with space
|
||||
]
|
||||
|
||||
invalidFilenames.forEach(filename => {
|
||||
const result = secureFileService.validateFileName(filename)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeExists', () => {
|
||||
test('should return true for existing files in allowed directories', () => {
|
||||
const testFile = join(testDir, 'existing.txt')
|
||||
writeFileSync(testFile, 'test content')
|
||||
|
||||
const result = secureFileService.safeExists(testFile)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false for non-existing files', () => {
|
||||
const nonExistentFile = join(testDir, 'nonexistent.txt')
|
||||
const result = secureFileService.safeExists(nonExistentFile)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false for invalid paths', () => {
|
||||
const invalidPath = join(testDir, '..', 'etc', 'passwd')
|
||||
const result = secureFileService.safeExists(invalidPath)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeReadFile', () => {
|
||||
test('should read existing files successfully', () => {
|
||||
const testFile = join(testDir, 'test.txt')
|
||||
const content = 'Hello, World!'
|
||||
writeFileSync(testFile, content)
|
||||
|
||||
const result = secureFileService.safeReadFile(testFile)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.content).toBe(content)
|
||||
expect(result.stats).toBeDefined()
|
||||
expect(result.stats?.size).toBe(content.length)
|
||||
})
|
||||
|
||||
test('should reject non-existing files', () => {
|
||||
const nonExistentFile = join(testDir, 'nonexistent.txt')
|
||||
const result = secureFileService.safeReadFile(nonExistentFile)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('File does not exist')
|
||||
})
|
||||
|
||||
test('should reject invalid paths', () => {
|
||||
// Create a directory that is definitely not allowed
|
||||
const invalidPath = '/root/secure-test.txt'
|
||||
const result = secureFileService.safeReadFile(invalidPath)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('outside allowed directories')
|
||||
})
|
||||
|
||||
test('should reject files with disallowed extensions', () => {
|
||||
const testFile = join(testDir, 'test.exe')
|
||||
writeFileSync(testFile, 'executable content')
|
||||
|
||||
const result = secureFileService.safeReadFile(testFile)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('File extension \'.exe\' is not allowed')
|
||||
})
|
||||
|
||||
test('should allow files with custom allowed extensions', () => {
|
||||
const testFile = join(testDir, 'test.custom')
|
||||
writeFileSync(testFile, 'custom content')
|
||||
|
||||
const result = secureFileService.safeReadFile(testFile, {
|
||||
allowedExtensions: ['.custom']
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.content).toBe('custom content')
|
||||
})
|
||||
|
||||
test('should reject files that are too large', () => {
|
||||
const testFile = join(testDir, 'large.txt')
|
||||
const largeContent = 'a'.repeat(1024 * 1024) // 1MB
|
||||
writeFileSync(testFile, largeContent)
|
||||
|
||||
const result = secureFileService.safeReadFile(testFile, {
|
||||
maxFileSize: 512 * 1024 // 512KB
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('File too large')
|
||||
})
|
||||
|
||||
test('should handle directories', () => {
|
||||
const result = secureFileService.safeReadFile(testDir, { checkFileExtension: false })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Path is not a file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeWriteFile', () => {
|
||||
test('should write files successfully', () => {
|
||||
const testFile = join(testDir, 'output.txt')
|
||||
const content = 'Hello, World!'
|
||||
|
||||
const result = secureFileService.safeWriteFile(testFile, content)
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Verify file was created
|
||||
expect(existsSync(testFile)).toBe(true)
|
||||
expect(readFileSync(testFile, 'utf8')).toBe(content)
|
||||
})
|
||||
|
||||
test('should reject invalid paths', () => {
|
||||
const invalidPath = '/root/secure-test.txt'
|
||||
const result = secureFileService.safeWriteFile(invalidPath, 'malicious')
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('outside allowed directories')
|
||||
})
|
||||
|
||||
test('should reject files with disallowed extensions', () => {
|
||||
const testFile = join(testDir, 'test.exe')
|
||||
const result = secureFileService.safeWriteFile(testFile, 'executable content')
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('File extension \'.exe\' is not allowed')
|
||||
})
|
||||
|
||||
test('should reject content that is too large', () => {
|
||||
const testFile = join(testDir, 'large.txt')
|
||||
const largeContent = 'a'.repeat(1024 * 1024) // 1MB
|
||||
|
||||
const result = secureFileService.safeWriteFile(testFile, largeContent, {
|
||||
maxSize: 512 * 1024 // 512KB
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Content too large')
|
||||
})
|
||||
|
||||
test('should create directories when requested', () => {
|
||||
const nestedFile = join(testDir, 'nested', 'subdir', 'file.txt')
|
||||
const content = 'nested content'
|
||||
|
||||
const result = secureFileService.safeWriteFile(nestedFile, content, {
|
||||
createDirectory: true
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
expect(existsSync(nestedFile)).toBe(true)
|
||||
expect(readFileSync(nestedFile, 'utf8')).toBe(content)
|
||||
})
|
||||
|
||||
test('should perform atomic writes when requested', () => {
|
||||
const testFile = join(testDir, 'atomic.txt')
|
||||
const content = 'atomic content'
|
||||
|
||||
const result = secureFileService.safeWriteFile(testFile, content, {
|
||||
atomic: true
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
expect(existsSync(testFile)).toBe(true)
|
||||
expect(readFileSync(testFile, 'utf8')).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeDeleteFile', () => {
|
||||
test('should delete existing files successfully', () => {
|
||||
const testFile = join(testDir, 'to-delete.txt')
|
||||
writeFileSync(testFile, 'content to delete')
|
||||
|
||||
const result = secureFileService.safeDeleteFile(testFile)
|
||||
expect(result.success).toBe(true)
|
||||
expect(existsSync(testFile)).toBe(false)
|
||||
})
|
||||
|
||||
test('should reject non-existing files', () => {
|
||||
const nonExistentFile = join(testDir, 'nonexistent.txt')
|
||||
const result = secureFileService.safeDeleteFile(nonExistentFile)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('File does not exist')
|
||||
})
|
||||
|
||||
test('should reject invalid paths', () => {
|
||||
const invalidPath = '/root/secure-test.txt'
|
||||
const result = secureFileService.safeDeleteFile(invalidPath)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('outside allowed directories')
|
||||
})
|
||||
|
||||
test('should handle directories', () => {
|
||||
const result = secureFileService.safeDeleteFile(testDir)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Path is not a file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeCreateDirectory', () => {
|
||||
test('should create directories successfully', () => {
|
||||
const newDir = join(testDir, 'new-dir')
|
||||
|
||||
const result = secureFileService.safeCreateDirectory(newDir)
|
||||
expect(result.success).toBe(true)
|
||||
expect(existsSync(newDir)).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle existing directories', () => {
|
||||
const result = secureFileService.safeCreateDirectory(testDir)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test('should reject invalid paths', () => {
|
||||
const invalidPath = '/root/secure-test'
|
||||
const result = secureFileService.safeCreateDirectory(invalidPath)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('outside allowed directories')
|
||||
})
|
||||
|
||||
test('should handle existing files', () => {
|
||||
const existingFile = join(testDir, 'existing.txt')
|
||||
writeFileSync(existingFile, 'content')
|
||||
|
||||
const result = secureFileService.safeCreateDirectory(existingFile)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Path already exists and is not a directory')
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeGetFileInfo', () => {
|
||||
test('should get file info successfully', () => {
|
||||
const testFile = join(testDir, 'info.txt')
|
||||
const content = 'file info test'
|
||||
writeFileSync(testFile, content)
|
||||
|
||||
const result = secureFileService.safeGetFileInfo(testFile)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.stats).toBeDefined()
|
||||
expect(result.stats?.isFile).toBe(true)
|
||||
expect(result.stats?.size).toBe(content.length)
|
||||
expect(result.stats?.isDirectory).toBe(false)
|
||||
})
|
||||
|
||||
test('should get directory info successfully', () => {
|
||||
const result = secureFileService.safeGetFileInfo(testDir)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.stats).toBeDefined()
|
||||
expect(result.stats?.isFile).toBe(false)
|
||||
expect(result.stats?.isDirectory).toBe(true)
|
||||
})
|
||||
|
||||
test('should reject non-existing paths', () => {
|
||||
const nonExistentPath = join(testDir, 'nonexistent.txt')
|
||||
const result = secureFileService.safeGetFileInfo(nonExistentPath)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('File does not exist')
|
||||
})
|
||||
|
||||
test('should reject invalid paths', () => {
|
||||
const invalidPath = '/root/secure-test.txt'
|
||||
const result = secureFileService.safeGetFileInfo(invalidPath)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('outside allowed directories')
|
||||
})
|
||||
})
|
||||
|
||||
describe('configuration methods', () => {
|
||||
test('should add allowed base paths', () => {
|
||||
const customDir = join(testDir, 'custom')
|
||||
mkdirSync(customDir, { recursive: true })
|
||||
|
||||
const result = secureFileService.addAllowedBasePath(customDir)
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Test that the new path is now allowed
|
||||
const testFile = join(customDir, 'test.txt')
|
||||
const validation = secureFileService.validateFilePath(testFile)
|
||||
expect(validation.isValid).toBe(true)
|
||||
})
|
||||
|
||||
test('should reject non-existing base paths', () => {
|
||||
const nonExistentDir = join(testDir, 'nonexistent')
|
||||
const result = secureFileService.addAllowedBasePath(nonExistentDir)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Base path does not exist')
|
||||
})
|
||||
|
||||
test('should set max file size', () => {
|
||||
secureFileService.setMaxFileSize(2048)
|
||||
|
||||
const testFile = join(testDir, 'size-test.txt')
|
||||
const largeContent = 'a'.repeat(3000) // 3KB
|
||||
writeFileSync(testFile, largeContent)
|
||||
|
||||
const result = secureFileService.safeReadFile(testFile)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('File too large')
|
||||
})
|
||||
|
||||
test('should add allowed extensions', () => {
|
||||
secureFileService.addAllowedExtensions(['.custom', '.special'])
|
||||
|
||||
const testFile = join(testDir, 'test.custom')
|
||||
writeFileSync(testFile, 'custom content')
|
||||
|
||||
const result = secureFileService.safeReadFile(testFile)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.content).toBe('custom content')
|
||||
})
|
||||
|
||||
test('should check if path is allowed', () => {
|
||||
const allowedPath = join(testDir, 'allowed.txt')
|
||||
const disallowedPath = '/etc/passwd'
|
||||
|
||||
expect(secureFileService.isPathAllowed(allowedPath)).toBe(true)
|
||||
expect(secureFileService.isPathAllowed(disallowedPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
test('should return the same instance', () => {
|
||||
const instance1 = SecureFileService.getInstance()
|
||||
const instance2 = SecureFileService.getInstance()
|
||||
const instance3 = secureFileService
|
||||
|
||||
expect(instance1).toBe(instance2)
|
||||
expect(instance2).toBe(instance3)
|
||||
})
|
||||
|
||||
test('should maintain configuration across instances', () => {
|
||||
const instance1 = SecureFileService.getInstance()
|
||||
const instance2 = SecureFileService.getInstance()
|
||||
|
||||
instance1.setMaxFileSize(2048)
|
||||
instance2.addAllowedExtensions(['.test'])
|
||||
|
||||
const testFile = join(testDir, 'test.test')
|
||||
writeFileSync(testFile, 'test')
|
||||
|
||||
const result = instance1.safeReadFile(testFile)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
test('should handle permission errors gracefully', () => {
|
||||
// This test simulates permission errors by trying to read a directory as a file
|
||||
const result = secureFileService.safeReadFile(testDir, {
|
||||
checkFileExtension: false,
|
||||
maxFileSize: 10 * 1024 * 1024 // Use default size
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Path is not a file')
|
||||
})
|
||||
|
||||
test('should handle file system errors gracefully', () => {
|
||||
// Test with a path that contains invalid characters for the file system
|
||||
const invalidPath = join(testDir, 'invalid\0path.txt')
|
||||
const result = secureFileService.validateFilePath(invalidPath)
|
||||
// The validation might handle this differently, but it should still fail
|
||||
if (!result.isValid) {
|
||||
expect(result.error).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle empty files', () => {
|
||||
const testFile = join(testDir, 'empty.txt')
|
||||
writeFileSync(testFile, '')
|
||||
|
||||
const result = secureFileService.safeReadFile(testFile)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.content).toBe('')
|
||||
expect(result.stats?.size).toBe(0)
|
||||
})
|
||||
|
||||
test('should handle files with special characters in name', () => {
|
||||
const testFile = join(testDir, 'file-with-hyphens_and_underscores.txt')
|
||||
const content = 'special characters test'
|
||||
writeFileSync(testFile, content)
|
||||
|
||||
const result = secureFileService.safeReadFile(testFile)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.content).toBe(content)
|
||||
})
|
||||
|
||||
test('should handle different encodings', () => {
|
||||
const testFile = join(testDir, 'utf8.txt')
|
||||
const content = 'Hello 世界 🌍'
|
||||
writeFileSync(testFile, content, 'utf8')
|
||||
|
||||
const result = secureFileService.safeReadFile(testFile, { encoding: 'utf8' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.content).toBe(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user