diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index be492891..362b9329 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -175,6 +175,7 @@ async function renderMultipleLines(data: StatusJSON) { isPreview: false, minimalist: settings.minimalistMode, gitCacheTtlSeconds: settings.gitCacheTtlSeconds, + customCommandCacheTtlSeconds: settings.customCommandCacheTtlSeconds, gitReviewNeedsChecks: lines.some(line => line.some(item => item.type === 'git-ci-status')) }; diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 53973ee4..6dfe64d5 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -1360,6 +1360,7 @@ export const App: React.FC = () => { currentInterval={currentRefreshInterval} supportsRefreshInterval={supportsRefreshInterval} gitCacheTtlSeconds={settings.gitCacheTtlSeconds} + customCommandCacheTtlSeconds={settings.customCommandCacheTtlSeconds} onUpdate={(interval) => { const previous = currentRefreshInterval; setCurrentRefreshInterval(interval); @@ -1390,6 +1391,17 @@ export const App: React.FC = () => { }); setScreen('main'); }} + onCustomCommandCacheTtlUpdate={(ttlSeconds) => { + setSettings({ + ...settings, + customCommandCacheTtlSeconds: ttlSeconds + }); + setFlashMessage({ + text: '✓ Custom command cache TTL updated', + color: 'green' + }); + setScreen('main'); + }} onBack={() => { setScreen('main'); }} diff --git a/src/tui/components/RefreshIntervalMenu.tsx b/src/tui/components/RefreshIntervalMenu.tsx index 88be36cc..37ed0293 100644 --- a/src/tui/components/RefreshIntervalMenu.tsx +++ b/src/tui/components/RefreshIntervalMenu.tsx @@ -12,7 +12,8 @@ import { type ListEntry } from './List'; -type ConfigureStatusLineValue = 'refreshInterval' | 'gitCacheTtl'; +type TtlField = 'gitCacheTtl' | 'customCommandCacheTtl'; +type ConfigureStatusLineValue = 'refreshInterval' | TtlField; function getRefreshInputValue(interval: number | null): string { return interval === null ? '' : String(interval); @@ -36,10 +37,17 @@ function getGitCacheTtlSublabel(ttlSeconds: number): string { : `(${ttlSeconds}s)`; } +function getCustomCommandCacheTtlSublabel(ttlSeconds: number): string { + return ttlSeconds === 0 + ? '(disabled)' + : `(${ttlSeconds}s)`; +} + export function buildConfigureStatusLineItems( refreshInterval: number | null, supportsRefreshInterval: boolean, - gitCacheTtlSeconds: number + gitCacheTtlSeconds: number, + customCommandCacheTtlSeconds: number ): ListEntry[] { return [ { @@ -56,6 +64,12 @@ export function buildConfigureStatusLineItems( sublabel: getGitCacheTtlSublabel(gitCacheTtlSeconds), value: 'gitCacheTtl', description: 'How long git widget subprocess output can be reused while .git/HEAD and .git/index are unchanged. Enter 0-60 seconds;\n0 disables age-based expiry, so cached output is reused until those git metadata mtimes change.' + }, + { + label: '🔧 Custom Command Cache TTL', + sublabel: getCustomCommandCacheTtlSublabel(customCommandCacheTtlSeconds), + value: 'customCommandCacheTtl', + description: 'How long custom command output is reused before the command runs again. Enter 0-60 seconds;\n0 disables caching, so every status line render spawns the command.' } ]; } @@ -82,7 +96,7 @@ export function validateRefreshIntervalInput(value: string): string | null { return null; } -export function validateGitCacheTtlInput(value: string): string | null { +function validateTtlInput(value: string, label: string): string | null { const parsed = parseInt(value, 10); if (value === '' || isNaN(parsed)) { @@ -90,22 +104,41 @@ export function validateGitCacheTtlInput(value: string): string | null { } if (parsed < 0) { - return `Minimum Git cache TTL is 0s (you entered ${parsed}s)`; + return `Minimum ${label} is 0s (you entered ${parsed}s)`; } if (parsed > 60) { - return `Maximum Git cache TTL is 60s (you entered ${parsed}s)`; + return `Maximum ${label} is 60s (you entered ${parsed}s)`; } return null; } +export function validateGitCacheTtlInput(value: string): string | null { + return validateTtlInput(value, 'Git cache TTL'); +} + +export function validateCustomCommandCacheTtlInput(value: string): string | null { + return validateTtlInput(value, 'custom command cache TTL'); +} + +interface TtlFieldConfig { + currentValue: number; + prompt: string; + helperText: string; + hint: string; + validate: (value: string) => string | null; + onSave: (ttlSeconds: number) => void; +} + export interface RefreshIntervalMenuProps { currentInterval: number | null; supportsRefreshInterval: boolean; gitCacheTtlSeconds: number; + customCommandCacheTtlSeconds: number; onUpdate: (interval: number | null) => void; onGitCacheTtlUpdate: (ttlSeconds: number) => void; + onCustomCommandCacheTtlUpdate: (ttlSeconds: number) => void; onBack: () => void; } @@ -113,16 +146,37 @@ export const RefreshIntervalMenu: React.FC = ({ currentInterval, supportsRefreshInterval, gitCacheTtlSeconds, + customCommandCacheTtlSeconds, onUpdate, onGitCacheTtlUpdate, + onCustomCommandCacheTtlUpdate, onBack }) => { const [editingRefreshInterval, setEditingRefreshInterval] = useState(false); - const [editingGitCacheTtl, setEditingGitCacheTtl] = useState(false); + const [editingTtlField, setEditingTtlField] = useState(null); const [refreshInput, setRefreshInput] = useState(() => getRefreshInputValue(currentInterval)); - const [gitCacheTtlInput, setGitCacheTtlInput] = useState(() => String(gitCacheTtlSeconds)); + const [ttlInput, setTtlInput] = useState(() => String(gitCacheTtlSeconds)); const [validationError, setValidationError] = useState(null); + const ttlFields: Record = { + gitCacheTtl: { + currentValue: gitCacheTtlSeconds, + prompt: 'Enter Git cache TTL in seconds (0-60):', + helperText: 'This affects how quickly git widgets notice unstaged and untracked working-tree changes.', + hint: '0 disables age-based expiry; cache validity uses .git/HEAD and .git/index mtimes only.', + validate: validateGitCacheTtlInput, + onSave: onGitCacheTtlUpdate + }, + customCommandCacheTtl: { + currentValue: customCommandCacheTtlSeconds, + prompt: 'Enter custom command cache TTL in seconds (0-60):', + helperText: 'This affects how quickly custom command widgets show new output, and how often they spawn a shell.', + hint: '0 disables caching; every status line render spawns the command again.', + validate: validateCustomCommandCacheTtlInput, + onSave: onCustomCommandCacheTtlUpdate + } + }; + useInput((input, key) => { if (editingRefreshInterval) { if (key.return) { @@ -162,31 +216,33 @@ export const RefreshIntervalMenu: React.FC = ({ return; } - if (editingGitCacheTtl) { + if (editingTtlField) { + const field = ttlFields[editingTtlField]; + if (key.return) { - const error = validateGitCacheTtlInput(gitCacheTtlInput); + const error = field.validate(ttlInput); if (error) { setValidationError(error); } else { - const value = parseInt(gitCacheTtlInput, 10); - onGitCacheTtlUpdate(value); - setEditingGitCacheTtl(false); + const value = parseInt(ttlInput, 10); + field.onSave(value); + setEditingTtlField(null); setValidationError(null); } } else if (key.escape) { - setGitCacheTtlInput(String(gitCacheTtlSeconds)); - setEditingGitCacheTtl(false); + setTtlInput(String(field.currentValue)); + setEditingTtlField(null); setValidationError(null); } else if (key.backspace) { - setGitCacheTtlInput(gitCacheTtlInput.slice(0, -1)); + setTtlInput(ttlInput.slice(0, -1)); setValidationError(null); } else if (key.delete) { // No cursor position in simple input } else if (shouldInsertInput(input, key) && /\d/.test(input)) { - const newValue = gitCacheTtlInput + input; + const newValue = ttlInput + input; if (newValue.length <= 2) { - setGitCacheTtlInput(newValue); + setTtlInput(newValue); setValidationError(null); } } @@ -217,23 +273,23 @@ export const RefreshIntervalMenu: React.FC = ({ Press Enter to confirm, ESC to cancel. Leave empty to remove. )} - ) : editingGitCacheTtl ? ( + ) : editingTtlField ? ( - Enter Git cache TTL in seconds (0-60): + {ttlFields[editingTtlField].prompt} {' '} - {gitCacheTtlInput} - {gitCacheTtlInput.length > 0 ? 's' : ''} + {ttlInput} + {ttlInput.length > 0 ? 's' : ''} - This affects how quickly git widgets notice unstaged and untracked working-tree changes. + {ttlFields[editingTtlField].helperText} {validationError ? ( {validationError} ) : ( - 0 disables age-based expiry; cache validity uses .git/HEAD and .git/index mtimes only. + {ttlFields[editingTtlField].hint} )} Press Enter to confirm, ESC to cancel. @@ -241,7 +297,12 @@ export const RefreshIntervalMenu: React.FC = ({ ) : ( { if (value === 'back') { onBack(); @@ -254,8 +315,8 @@ export const RefreshIntervalMenu: React.FC = ({ return; } - setGitCacheTtlInput(String(gitCacheTtlSeconds)); - setEditingGitCacheTtl(true); + setTtlInput(String(ttlFields[value].currentValue)); + setEditingTtlField(value); }} showBackButton={true} /> diff --git a/src/tui/components/StatusLinePreview.tsx b/src/tui/components/StatusLinePreview.tsx index 945a3d01..8b756e04 100644 --- a/src/tui/components/StatusLinePreview.tsx +++ b/src/tui/components/StatusLinePreview.tsx @@ -48,6 +48,7 @@ const renderSingleLine = ( isPreview: true, minimalist: settings.minimalistMode, gitCacheTtlSeconds: settings.gitCacheTtlSeconds, + customCommandCacheTtlSeconds: settings.customCommandCacheTtlSeconds, lineIndex, globalSeparatorIndex, globalPowerlineThemeIndex, @@ -77,7 +78,8 @@ export const StatusLinePreview: React.FC = ({ lines, ter terminalWidth, isPreview: true, minimalist: settings.minimalistMode, - gitCacheTtlSeconds: settings.gitCacheTtlSeconds + gitCacheTtlSeconds: settings.gitCacheTtlSeconds, + customCommandCacheTtlSeconds: settings.customCommandCacheTtlSeconds }); const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); diff --git a/src/tui/components/__tests__/RefreshIntervalMenu.test.ts b/src/tui/components/__tests__/RefreshIntervalMenu.test.ts index fd300189..0c56bce6 100644 --- a/src/tui/components/__tests__/RefreshIntervalMenu.test.ts +++ b/src/tui/components/__tests__/RefreshIntervalMenu.test.ts @@ -11,6 +11,7 @@ import { import { RefreshIntervalMenu, buildConfigureStatusLineItems, + validateCustomCommandCacheTtlInput, validateGitCacheTtlInput, validateRefreshIntervalInput } from '../RefreshIntervalMenu'; @@ -103,43 +104,76 @@ describe('validateGitCacheTtlInput', () => { }); }); +describe('validateCustomCommandCacheTtlInput', () => { + it('should accept valid values within range', () => { + expect(validateCustomCommandCacheTtlInput('0')).toBeNull(); + expect(validateCustomCommandCacheTtlInput('5')).toBeNull(); + expect(validateCustomCommandCacheTtlInput('60')).toBeNull(); + }); + + it('should reject values outside the range', () => { + expect(validateCustomCommandCacheTtlInput('-1')).toContain('Minimum'); + expect(validateCustomCommandCacheTtlInput('61')).toContain('Maximum'); + }); + + it('should reject empty and non-numeric input', () => { + expect(validateCustomCommandCacheTtlInput('')).toContain('valid number'); + expect(validateCustomCommandCacheTtlInput('abc')).toContain('valid number'); + }); + + it('should name the field it rejects', () => { + expect(validateCustomCommandCacheTtlInput('61')).toContain('custom command cache TTL'); + }); +}); + describe('buildConfigureStatusLineItems', () => { it('should show (not set) when interval is null and supported', () => { - const items = buildConfigureStatusLineItems(null, true, 5); + const items = buildConfigureStatusLineItems(null, true, 5, 5); expect(items[0]?.sublabel).toBe('(not set)'); }); it('should show seconds for set intervals', () => { - const items = buildConfigureStatusLineItems(10, true, 5); + const items = buildConfigureStatusLineItems(10, true, 5, 5); expect(items[0]?.sublabel).toBe('(10s)'); }); it('should show seconds for small values', () => { - const items = buildConfigureStatusLineItems(1, true, 5); + const items = buildConfigureStatusLineItems(1, true, 5, 5); expect(items[0]?.sublabel).toBe('(1s)'); }); it('should show version requirement when not supported', () => { - const items = buildConfigureStatusLineItems(null, false, 5); + const items = buildConfigureStatusLineItems(null, false, 5, 5); expect(items[0]?.sublabel).toContain('requires Claude Code'); expect(items[0]?.disabled).toBe(true); }); it('should not be disabled when supported', () => { - const items = buildConfigureStatusLineItems(10, true, 5); + const items = buildConfigureStatusLineItems(10, true, 5, 5); expect(items[0]?.disabled).toBeFalsy(); }); it('should show the configured Git cache TTL', () => { - const items = buildConfigureStatusLineItems(10, true, 5); + const items = buildConfigureStatusLineItems(10, true, 5, 5); expect(items[1]?.label).toContain('Git Cache TTL'); expect(items[1]?.sublabel).toBe('(5s)'); }); it('should describe zero Git cache TTL as mtime-only', () => { - const items = buildConfigureStatusLineItems(10, true, 0); + const items = buildConfigureStatusLineItems(10, true, 0, 5); expect(items[1]?.sublabel).toBe('(mtime only)'); }); + + it('should show the configured custom command cache TTL', () => { + const items = buildConfigureStatusLineItems(10, true, 5, 3); + expect(items[2]?.label).toContain('Custom Command Cache TTL'); + expect(items[2]?.sublabel).toBe('(3s)'); + }); + + it('should describe zero custom command cache TTL as disabled', () => { + const items = buildConfigureStatusLineItems(10, true, 5, 0); + expect(items[2]?.sublabel).toBe('(disabled)'); + }); }); describe('RefreshIntervalMenu', () => { @@ -154,8 +188,10 @@ describe('RefreshIntervalMenu', () => { currentInterval: null, supportsRefreshInterval: true, gitCacheTtlSeconds: 5, + customCommandCacheTtlSeconds: 5, onUpdate, onGitCacheTtlUpdate: vi.fn(), + onCustomCommandCacheTtlUpdate: vi.fn(), onBack }), { @@ -201,8 +237,10 @@ describe('RefreshIntervalMenu', () => { currentInterval: 10, supportsRefreshInterval: true, gitCacheTtlSeconds: 0, + customCommandCacheTtlSeconds: 5, onUpdate, onGitCacheTtlUpdate, + onCustomCommandCacheTtlUpdate: vi.fn(), onBack }), { @@ -238,4 +276,59 @@ describe('RefreshIntervalMenu', () => { stderr.destroy(); } }); + + it('edits the custom command cache TTL without touching the Git cache TTL', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onGitCacheTtlUpdate = vi.fn(); + const onCustomCommandCacheTtlUpdate = vi.fn(); + const instance = render( + React.createElement(RefreshIntervalMenu, { + currentInterval: 10, + supportsRefreshInterval: true, + gitCacheTtlSeconds: 5, + customCommandCacheTtlSeconds: 0, + onUpdate: vi.fn(), + onGitCacheTtlUpdate, + onCustomCommandCacheTtlUpdate, + onBack: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write('\u001B[B'); + await flushInk(); + stdin.write('\u001B[B'); + await flushInk(); + stdin.write('\r'); + await flushInk(); + + expect(stdout.getOutput()).toContain('Enter custom command cache TTL in seconds (0-60):'); + expect(stdout.getOutput()).toContain('how often they spawn a shell'); + + stdin.write('7'); + await flushInk(); + stdin.write('\r'); + await flushInk(); + + expect(onCustomCommandCacheTtlUpdate).toHaveBeenCalledWith(7); + expect(onGitCacheTtlUpdate).not.toHaveBeenCalled(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 0a04489c..49b18df9 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -46,6 +46,7 @@ export interface RenderContext { isPreview?: boolean; minimalist?: boolean; gitCacheTtlSeconds?: number; + customCommandCacheTtlSeconds?: number; gitReviewNeedsChecks?: boolean; lineIndex?: number; // Index of the current line being rendered (for theme cycling) globalSeparatorIndex?: number; // Global separator index that continues across lines diff --git a/src/types/Settings.ts b/src/types/Settings.ts index a596dbcf..9065ae7c 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -74,6 +74,7 @@ export const SettingsSchema = z.object({ overrideForegroundColor: z.string().optional(), globalBold: z.boolean().default(false), gitCacheTtlSeconds: z.number().min(0).max(60).default(5), + customCommandCacheTtlSeconds: z.number().min(0).max(60).default(0), minimalistMode: z.boolean().default(false), powerline: PowerlineConfigSchema.default({ enabled: false, diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts index 167fb4f0..ac3cd0ab 100644 --- a/src/utils/__tests__/config.test.ts +++ b/src/utils/__tests__/config.test.ts @@ -102,6 +102,9 @@ describe('config utilities', () => { expect(Array.isArray(onDisk.lines)).toBe(true); expect(settings.gitCacheTtlSeconds).toBe(5); expect((onDisk as { gitCacheTtlSeconds?: number }).gitCacheTtlSeconds).toBe(5); + // Custom command caching is opt-in, so an untouched install keeps running + // the command on every repaint. + expect(settings.customCommandCacheTtlSeconds).toBe(0); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Default settings written to') ); diff --git a/src/utils/__tests__/custom-command.test.ts b/src/utils/__tests__/custom-command.test.ts new file mode 100644 index 00000000..62ac855e --- /dev/null +++ b/src/utils/__tests__/custom-command.test.ts @@ -0,0 +1,655 @@ +import type { SpawnSyncReturns } from 'child_process'; +import { spawnSync } from 'child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { CustomCommandRequest } from '../custom-command'; +import { + clearCustomCommandCache, + runCustomCommand +} from '../custom-command'; + +vi.mock('child_process', () => ({ + execSync: vi.fn(), + execFileSync: vi.fn(), + spawnSync: vi.fn() +})); + +interface SpawnOptions { + shell?: boolean; + detached?: boolean; + windowsHide?: boolean; + timeout?: number; + maxBuffer?: number; + input?: string; + stdio?: (string | number)[]; +} + +const mockSpawnSync = spawnSync as unknown as { + mock: { calls: [string, SpawnOptions][] }; + mockImplementation: (impl: (command: string, options: SpawnOptions) => SpawnSyncReturns) => void; +}; + +/** One scripted command run: what it prints, and how it terminated. */ +interface CommandResponse { + stdout?: string; + result?: Partial>; +} + +const CHILD_PID = 4242; +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_USERPROFILE = process.env.USERPROFILE; +const tempPaths: string[] = []; +let platformDescriptor: PropertyDescriptor | undefined; +let responses: CommandResponse[] = []; +let fallbackResponse: CommandResponse = {}; + +function spawnResult(overrides: Partial> = {}): SpawnSyncReturns { + return { + pid: CHILD_PID, + output: [], + stdout: '', + stderr: '', + status: 0, + signal: null, + ...overrides + }; +} + +function errnoError(code: string): Error { + return Object.assign(new Error(code), { code }); +} + +/** Queues one response per upcoming run, in order. */ +function queueRuns(...items: CommandResponse[]): void { + responses.push(...items); +} + +/** Sets the response for every run with no queued entry left. */ +function alwaysRespond(item: CommandResponse): void { + fallbackResponse = item; +} + +function lastSpawnOptions(): SpawnOptions { + const call = mockSpawnSync.mock.calls[mockSpawnSync.mock.calls.length - 1]; + if (!call) + throw new Error('expected a spawn call'); + return call[1]; +} + +function useTempHome(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-cmd-home-')); + tempPaths.push(home); + process.env.HOME = home; + process.env.USERPROFILE = home; + vi.spyOn(os, 'homedir').mockReturnValue(home); + return home; +} + +function useFixedCwd(): string { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-cmd-cwd-')); + tempPaths.push(cwd); + vi.spyOn(process, 'cwd').mockReturnValue(cwd); + return cwd; +} + +function useTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-cmd-tmp-')); + tempPaths.push(dir); + vi.spyOn(os, 'tmpdir').mockReturnValue(dir); + return dir; +} + +// process.kill must never run for real here: the tests feed it a pid that this +// machine may well have assigned to something unrelated. +function useKillSpy() { + return vi.spyOn(process, 'kill').mockImplementation(() => true); +} + +/** Runs the rest of the test as if it were on the given platform. */ +function usePlatform(platform: NodeJS.Platform): void { + platformDescriptor ??= Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true + }); +} + +function restorePlatform(): void { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + platformDescriptor = undefined; + } +} + +function getCacheDir(home: string): string { + return path.join(home, '.cache', 'ccstatusline', 'custom-command-cache'); +} + +function getOnlyCachePath(home: string): string { + const files = fs.readdirSync(getCacheDir(home)).filter(file => /^cmd-[a-f0-9]+\.json$/.test(file)); + expect(files).toHaveLength(1); + return path.join(getCacheDir(home), files[0] ?? ''); +} + +function readCacheJson(home: string): { cwd?: unknown; entries?: Record } { + return JSON.parse(fs.readFileSync(getOnlyCachePath(home), 'utf-8')) as { + cwd?: unknown; + entries?: Record; + }; +} + +function createRequest(overrides: Partial = {}): CustomCommandRequest { + return { + command: 'my-widget', + input: '{"session_id":"s1"}', + timeoutMs: 1000, + ttlSeconds: 5, + sessionId: 's1', + terminalWidth: 120, + ...overrides + }; +} + +describe('runCustomCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearCustomCommandCache(); + responses = []; + fallbackResponse = {}; + + // Stand in for the command itself: write the scripted output to whichever + // stdout the caller handed over, then report how the process ended. + mockSpawnSync.mockImplementation((_command, options) => { + const response = responses.shift() ?? fallbackResponse; + const target = options.stdio?.[1]; + + if (typeof target === 'number' && response.stdout !== undefined) { + fs.writeSync(target, response.stdout); + } + + return spawnResult({ stdout: response.stdout ?? '', ...response.result }); + }); + }); + + afterEach(() => { + clearCustomCommandCache(); + vi.restoreAllMocks(); + restorePlatform(); + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + if (ORIGINAL_USERPROFILE === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = ORIGINAL_USERPROFILE; + } + + while (tempPaths.length > 0) { + const tempPath = tempPaths.pop(); + if (tempPath) { + fs.rmSync(tempPath, { recursive: true, force: true }); + } + } + }); + + describe('caching', () => { + it('returns the trimmed stdout of the command', () => { + useTempHome(); + useFixedCwd(); + queueRuns({ stdout: ' branch: main \n' }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: 'branch: main' }); + }); + + it('reuses the in-process result while the TTL holds', () => { + useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'first' }, { stdout: 'second' }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: 'first' }); + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: 'first' }); + expect(mockSpawnSync.mock.calls).toHaveLength(1); + }); + + // Claude Code runs the status line as a fresh process per repaint, so this + // is the case the cache exists for: an in-process map would never hit. + it('reuses the persisted result after the in-process cache is gone', () => { + vi.spyOn(Date, 'now').mockReturnValue(1000); + const home = useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'persisted' }, { stdout: 'rerun' }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: 'persisted' }); + expect(fs.existsSync(getOnlyCachePath(home))).toBe(true); + + clearCustomCommandCache(); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: 'persisted' }); + expect(mockSpawnSync.mock.calls).toHaveLength(1); + }); + + it('runs the command again once the TTL elapses', () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000); + useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'old' }, { stdout: 'new' }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: 'old' }); + + clearCustomCommandCache(); + nowSpy.mockReturnValue(7000); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: 'new' }); + expect(mockSpawnSync.mock.calls).toHaveLength(2); + }); + + // The TTL has to start when the output became available. Measuring from + // before the run would leave anything slower than the TTL uncacheable, + // which is exactly the case worth caching. + it('gives a slow command a full TTL measured from when it finished', () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000); + useTempHome(); + useFixedCwd(); + mockSpawnSync.mockImplementation((_command, options) => { + // The command occupies 2s, twice the TTL under test. + nowSpy.mockReturnValue(3000); + const target = options.stdio?.[1]; + if (typeof target === 'number') { + fs.writeSync(target, 'slow'); + } + return spawnResult({ stdout: 'slow' }); + }); + + expect(runCustomCommand(createRequest({ ttlSeconds: 1 }))).toEqual({ status: 'ok', stdout: 'slow' }); + + clearCustomCommandCache(); + nowSpy.mockReturnValue(3500); + + expect(runCustomCommand(createRequest({ ttlSeconds: 1 }))).toEqual({ status: 'ok', stdout: 'slow' }); + expect(mockSpawnSync.mock.calls).toHaveLength(1); + }); + + it('runs the command on every call and writes nothing when the TTL is zero', () => { + const home = useTempHome(); + useFixedCwd(); + alwaysRespond({ stdout: 'live' }); + + expect(runCustomCommand(createRequest({ ttlSeconds: 0 }))).toEqual({ status: 'ok', stdout: 'live' }); + expect(runCustomCommand(createRequest({ ttlSeconds: 0 }))).toEqual({ status: 'ok', stdout: 'live' }); + + expect(mockSpawnSync.mock.calls).toHaveLength(2); + expect(fs.existsSync(getCacheDir(home))).toBe(false); + }); + + // Caching is opt-in, so an absent setting has to behave exactly as it did + // before the setting existed. + it('runs the command on every call when no TTL is configured', () => { + const home = useTempHome(); + useFixedCwd(); + alwaysRespond({ stdout: 'live' }); + + runCustomCommand(createRequest({ ttlSeconds: undefined })); + runCustomCommand(createRequest({ ttlSeconds: undefined })); + + expect(mockSpawnSync.mock.calls).toHaveLength(2); + expect(fs.existsSync(getCacheDir(home))).toBe(false); + }); + + it('caches per command, so a different command still runs', () => { + useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'one' }, { stdout: 'two' }); + + expect(runCustomCommand(createRequest({ command: 'widget-one' }))).toEqual({ status: 'ok', stdout: 'one' }); + expect(runCustomCommand(createRequest({ command: 'widget-two' }))).toEqual({ status: 'ok', stdout: 'two' }); + expect(mockSpawnSync.mock.calls).toHaveLength(2); + }); + + // Two widgets can run the same command under different timeouts. Without + // the timeout in the key the second would inherit the first's [Timeout]. + it('caches per timeout, so a longer-lived widget runs on its own terms', () => { + useTempHome(); + useFixedCwd(); + useKillSpy(); + queueRuns( + { result: { error: errnoError('ETIMEDOUT'), signal: 'SIGTERM' } }, + { stdout: 'finished in time' } + ); + + expect(runCustomCommand(createRequest({ timeoutMs: 100 }))).toEqual({ status: 'failed', marker: '[Timeout]' }); + expect(runCustomCommand(createRequest({ timeoutMs: 5000 }))).toEqual({ status: 'ok', stdout: 'finished in time' }); + expect(mockSpawnSync.mock.calls).toHaveLength(2); + }); + + it('caches per session, so a second session never reads the first session output', () => { + useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'session one' }, { stdout: 'session two' }); + + expect(runCustomCommand(createRequest({ sessionId: 's1' }))).toEqual({ status: 'ok', stdout: 'session one' }); + expect(runCustomCommand(createRequest({ sessionId: 's2' }))).toEqual({ status: 'ok', stdout: 'session two' }); + expect(mockSpawnSync.mock.calls).toHaveLength(2); + }); + + // Without a session id there is nothing to separate one session's output + // from another's, so the shared file has to stay out of it. + it('keeps output out of the shared file when the session id is missing', () => { + const home = useTempHome(); + useFixedCwd(); + alwaysRespond({ stdout: 'unattributed' }); + + expect(runCustomCommand(createRequest({ sessionId: undefined }))).toEqual({ status: 'ok', stdout: 'unattributed' }); + + expect(fs.existsSync(getCacheDir(home))).toBe(false); + }); + + it('still reuses a session-less result inside the same process', () => { + useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'once' }, { stdout: 'twice' }); + + runCustomCommand(createRequest({ sessionId: undefined })); + runCustomCommand(createRequest({ sessionId: undefined })); + + expect(mockSpawnSync.mock.calls).toHaveLength(1); + }); + + // Terminal width is part of the payload the command reads, so a resize has + // to re-run it rather than redisplay output measured for the old width. + it('caches per terminal width, so a resize runs the command again', () => { + useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'narrow' }, { stdout: 'wide' }); + + expect(runCustomCommand(createRequest({ terminalWidth: 80 }))).toEqual({ status: 'ok', stdout: 'narrow' }); + expect(runCustomCommand(createRequest({ terminalWidth: 200 }))).toEqual({ status: 'ok', stdout: 'wide' }); + expect(mockSpawnSync.mock.calls).toHaveLength(2); + }); + + it('caches failures too, so a broken command is not respawned every repaint', () => { + useTempHome(); + useFixedCwd(); + alwaysRespond({ result: { status: 3 } }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'failed', marker: '[Exit: 3]' }); + + clearCustomCommandCache(); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'failed', marker: '[Exit: 3]' }); + expect(mockSpawnSync.mock.calls).toHaveLength(1); + }); + + it('runs the command when the persisted cache file is malformed', () => { + vi.spyOn(Date, 'now').mockReturnValue(1000); + const home = useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'old' }, { stdout: 'new' }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: 'old' }); + fs.writeFileSync(getOnlyCachePath(home), '{ malformed json', 'utf-8'); + + clearCustomCommandCache(); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: 'new' }); + expect(mockSpawnSync.mock.calls).toHaveLength(2); + }); + + it('clamps a TTL above the supported maximum instead of caching indefinitely', () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000); + useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'old' }, { stdout: 'new' }); + + expect(runCustomCommand(createRequest({ ttlSeconds: 6000 }))).toEqual({ status: 'ok', stdout: 'old' }); + + clearCustomCommandCache(); + nowSpy.mockReturnValue(1000 + 61_000); + + expect(runCustomCommand(createRequest({ ttlSeconds: 6000 }))).toEqual({ status: 'ok', stdout: 'new' }); + expect(mockSpawnSync.mock.calls).toHaveLength(2); + }); + + // Every widget in a render pass rewrites this file whole, so one chatty + // command would otherwise make the pass quadratic in its output size. + it('caps how much output can enter the cache', () => { + const home = useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'x'.repeat(80_000) }); + + const result = runCustomCommand(createRequest()); + + expect(result.status).toBe('ok'); + expect(result.status === 'ok' && result.stdout.length).toBe(16_384); + expect(fs.statSync(getOnlyCachePath(home)).size).toBeLessThan(32_768); + }); + + // Session ids rotate, so without pruning the file would gain an entry per + // session and never lose one. + it('drops persisted entries that no TTL can still serve', () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000); + const home = useTempHome(); + useFixedCwd(); + alwaysRespond({ stdout: 'output' }); + + runCustomCommand(createRequest({ sessionId: 'stale-session' })); + expect(Object.keys(readCacheJson(home).entries ?? {})).toHaveLength(1); + + clearCustomCommandCache(); + nowSpy.mockReturnValue(1000 + 61_000); + runCustomCommand(createRequest({ sessionId: 'fresh-session' })); + + const entries = Object.keys(readCacheJson(home).entries ?? {}); + expect(entries).toHaveLength(1); + expect(entries[0]).toContain('fresh-session'); + }); + + it('records cwd once at the file level and keys entries by command, timeout, session and width', () => { + vi.spyOn(Date, 'now').mockReturnValue(1000); + const home = useTempHome(); + const cwd = useFixedCwd(); + queueRuns({ stdout: 'output' }); + + runCustomCommand(createRequest()); + + const cache = readCacheJson(home); + expect(cache.cwd).toBe(cwd); + expect(Object.keys(cache.entries ?? {})).toEqual(['my-widget\x001000\x00s1\x00120']); + }); + + // Custom command output is whatever its author chose to print, so the cache + // file must not be readable by other accounts on the machine. + it.skipIf(process.platform === 'win32')('writes the persisted cache owner-only', () => { + const home = useTempHome(); + useFixedCwd(); + queueRuns({ stdout: 'secret-ish output' }); + + runCustomCommand(createRequest()); + + expect(fs.statSync(getOnlyCachePath(home)).mode & 0o777).toBe(0o600); + }); + }); + + describe('process handling', () => { + it('runs the command line through a shell with a pinned output ceiling', () => { + useTempHome(); + useFixedCwd(); + + runCustomCommand(createRequest({ command: 'curl -s example | jq -r .x' })); + + expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('curl -s example | jq -r .x'); + expect(lastSpawnOptions().shell).toBe(true); + expect(lastSpawnOptions().timeout).toBe(1000); + expect(lastSpawnOptions().windowsHide).toBe(true); + expect(lastSpawnOptions().maxBuffer).toBe(1024 * 1024); + }); + + // Every process in the shell tree inherits the pipe handles, and spawnSync + // waits for all of them to close. One backgrounded descendant would hold + // the render open past the timeout, so neither stream is a pipe. + it('gives the command files for stdin and stdout rather than pipes', () => { + useTempHome(); + useFixedCwd(); + let deliveredPayload: string | null = null; + mockSpawnSync.mockImplementation((_command, options) => { + const stdin = options.stdio?.[0]; + deliveredPayload = typeof stdin === 'number' ? fs.readFileSync(stdin, 'utf-8') : null; + const target = options.stdio?.[1]; + if (typeof target === 'number') { + fs.writeSync(target, 'from the file'); + } + return spawnResult(); + }); + + const result = runCustomCommand(createRequest({ input: '{"session_id":"s1","terminal_width":120}' })); + + expect(deliveredPayload).toBe('{"session_id":"s1","terminal_width":120}'); + expect(result).toEqual({ status: 'ok', stdout: 'from the file' }); + expect(lastSpawnOptions().input).toBeUndefined(); + expect(typeof lastSpawnOptions().stdio?.[1]).toBe('number'); + expect(lastSpawnOptions().stdio?.[2]).toBe('ignore'); + }); + + it('removes the working directory once the command returns', () => { + useTempHome(); + useFixedCwd(); + const tempDir = useTempDir(); + + runCustomCommand(createRequest()); + + expect(fs.readdirSync(tempDir)).toEqual([]); + }); + + it('removes the working directory even when the command times out', () => { + useTempHome(); + useFixedCwd(); + const tempDir = useTempDir(); + useKillSpy(); + alwaysRespond({ result: { error: errnoError('ETIMEDOUT'), signal: 'SIGTERM' } }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'failed', marker: '[Timeout]' }); + expect(fs.readdirSync(tempDir)).toEqual([]); + }); + + // A predictable path could be pre-created as a symlink, or swapped between + // being written and being opened. mkdtemp rules both out. + it('uses a fresh unguessable directory for every run', () => { + useTempHome(); + useFixedCwd(); + const tempDir = useTempDir(); + const seen: string[] = []; + mockSpawnSync.mockImplementation(() => { + seen.push(fs.readdirSync(tempDir)[0] ?? ''); + return spawnResult(); + }); + + runCustomCommand(createRequest({ ttlSeconds: 0 })); + runCustomCommand(createRequest({ ttlSeconds: 0 })); + + expect(seen).toHaveLength(2); + expect(seen[0]).not.toBe(seen[1]); + expect(seen[0]).toMatch(/^ccstatusline-cmd-/); + }); + + it.skipIf(process.platform === 'win32')('keeps the working directory owner-only', () => { + useTempHome(); + useFixedCwd(); + const tempDir = useTempDir(); + let mode: number | null = null; + mockSpawnSync.mockImplementation(() => { + const entry = fs.readdirSync(tempDir)[0]; + mode = entry ? fs.statSync(path.join(tempDir, entry)).mode & 0o777 : null; + return spawnResult(); + }); + + runCustomCommand(createRequest()); + + expect(mode).toBe(0o700); + }); + + // Killing the shell alone leaves a pipeline's other members running. The + // shell leads its own process group, so the negated pid reaches all of them. + it('kills the whole process group when a command times out on POSIX', () => { + useTempHome(); + useFixedCwd(); + usePlatform('linux'); + const killSpy = useKillSpy(); + alwaysRespond({ result: { error: errnoError('ETIMEDOUT'), signal: 'SIGTERM' } }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'failed', marker: '[Timeout]' }); + + expect(lastSpawnOptions().detached).toBe(true); + expect(killSpy.mock.calls).toEqual([[-CHILD_PID, 'SIGKILL']]); + }); + + // detached would give the child its own console on Windows, and spawnSync + // has already terminated the shell by the time it returns, so there is no + // live pid for taskkill /T to walk down from. + it('does not detach or group-kill on Windows', () => { + useTempHome(); + useFixedCwd(); + usePlatform('win32'); + const killSpy = useKillSpy(); + alwaysRespond({ result: { error: errnoError('ETIMEDOUT'), signal: 'SIGTERM' } }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'failed', marker: '[Timeout]' }); + + expect(lastSpawnOptions().detached).toBe(false); + expect(killSpy.mock.calls).toEqual([]); + }); + + // A command that exited on its own may have deliberately left a background + // job running, so only a timeout justifies tearing the group down. + it('leaves the process group alone when the command exits on its own', () => { + useTempHome(); + useFixedCwd(); + const killSpy = useKillSpy(); + alwaysRespond({ result: { status: 7 } }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'failed', marker: '[Exit: 7]' }); + expect(killSpy.mock.calls).toEqual([]); + }); + }); + + describe('failure markers', () => { + const cases: { name: string; result: Partial>; marker: string }[] = [ + { name: 'a missing shell', result: { error: errnoError('ENOENT') }, marker: '[Cmd not found]' }, + { name: 'a timeout', result: { error: errnoError('ETIMEDOUT'), signal: 'SIGTERM' }, marker: '[Timeout]' }, + { name: 'a permission failure', result: { error: errnoError('EACCES') }, marker: '[Permission denied]' }, + { name: 'an unclassified spawn error', result: { error: new Error('boom') }, marker: '[Error]' }, + { name: 'a signalled command', result: { signal: 'SIGKILL', status: null }, marker: '[Signal: SIGKILL]' }, + { name: 'a non-zero exit', result: { status: 12 }, marker: '[Exit: 12]' }, + { name: 'a missing exit status', result: { status: null }, marker: '[Error]' } + ]; + + for (const testCase of cases) { + it(`reports ${testCase.name} as ${testCase.marker}`, () => { + useTempHome(); + useFixedCwd(); + useKillSpy(); + alwaysRespond({ result: testCase.result }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'failed', marker: testCase.marker }); + }); + } + + it('treats a zero exit with empty output as success', () => { + useTempHome(); + useFixedCwd(); + alwaysRespond({ stdout: '' }); + + expect(runCustomCommand(createRequest())).toEqual({ status: 'ok', stdout: '' }); + }); + }); +}); diff --git a/src/utils/custom-command.ts b/src/utils/custom-command.ts new file mode 100644 index 00000000..a7cd8336 --- /dev/null +++ b/src/utils/custom-command.ts @@ -0,0 +1,497 @@ +import type { + SpawnSyncOptionsWithStringEncoding, + SpawnSyncReturns +} from 'child_process'; +import { spawnSync } from 'child_process'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +/** Outcome of one custom command invocation. */ +export type CustomCommandResult + = | { status: 'ok'; stdout: string } + | { status: 'failed'; marker: string }; + +export interface CustomCommandRequest { + /** Shell command line to run. */ + command: string; + /** JSON payload piped to the command on stdin. */ + input: string; + /** Milliseconds the command may run before it is killed. */ + timeoutMs: number; + /** Seconds an earlier result stays reusable; 0 runs the command every time. */ + ttlSeconds?: number; + /** Claude Code session, so two sessions never read each other's output. */ + sessionId?: string; + /** + * Terminal width piped to the command, which width-sensitive output depends + * on. Keying on it means a resize shows correct output at once instead of + * waiting out the TTL. + */ + terminalWidth?: number | null; +} + +interface CustomCommandCacheEntry { + result: CustomCommandResult; + createdAt: number; +} + +interface PersistentCustomCommandCache { + version: 1; + cwd: string; + entries: Record; +} + +/** Owner-only temp directory holding the command's stdin payload and its stdout. */ +interface CommandIo { + dir: string; + payloadFd: number; + stdoutFd: number; + stdoutPath: string; +} + +/** + * Spawn options plus `detached`, which @types/node lists only for the async + * spawn. Node honors it for spawnSync too, and the POSIX tree kill depends on + * it: without it the shell shares our process group and no group exists to + * signal. + */ +interface SyncShellOptions extends SpawnSyncOptionsWithStringEncoding { detached?: boolean } + +const DEFAULT_CUSTOM_COMMAND_CACHE_TTL_SECONDS = 0; +const MAX_CUSTOM_COMMAND_CACHE_TTL_SECONDS = 60; +const CUSTOM_COMMAND_CACHE_SCHEMA_VERSION = 1 as const; + +// A status line is one terminal row, so anything past this cannot be displayed. +// Bounding it keeps a chatty command from bloating the cache file, which every +// widget in the render pass rewrites in full. +const MAX_CACHED_OUTPUT_CHARS = 16_384; + +// Only reachable on the fallback path, where stdout is still a pipe. +const MAX_PIPED_STDOUT_BYTES = 1024 * 1024; + +function isWindows(): boolean { + return process.platform === 'win32'; +} + +// In-process cache keeps cwd in the key. The persistent cache stores cwd once at +// the file level and keys entries by command, session and terminal width. +const customCommandCache = new Map(); + +function getCacheDir(): string { + return path.join(os.homedir(), '.cache', 'ccstatusline'); +} + +function getCachePath(cwd: string): string { + const cwdHash = createHash('sha256') + .update(cwd) + .digest('hex') + .slice(0, 16); + + return path.join(getCacheDir(), 'custom-command-cache', `cmd-${cwdHash}.json`); +} + +function getCacheTtlMs(ttlSeconds: number | undefined): number { + if (typeof ttlSeconds !== 'number' || !Number.isFinite(ttlSeconds)) { + return DEFAULT_CUSTOM_COMMAND_CACHE_TTL_SECONDS * 1000; + } + + return Math.min(MAX_CUSTOM_COMMAND_CACHE_TTL_SECONDS, Math.max(0, ttlSeconds)) * 1000; +} + +function getEntryKey(request: CustomCommandRequest): string { + return [ + request.command, + String(request.timeoutMs), + request.sessionId ?? '', + typeof request.terminalWidth === 'number' ? String(request.terminalWidth) : '' + ].join('\0'); +} + +function isCacheEntry(value: unknown): value is CustomCommandCacheEntry { + if (typeof value !== 'object' || value === null) { + return false; + } + + const entry = value as Record; + if (typeof entry.createdAt !== 'number' || typeof entry.result !== 'object' || entry.result === null) { + return false; + } + + const result = entry.result as Record; + if (result.status === 'ok') { + return typeof result.stdout === 'string'; + } + + return result.status === 'failed' && typeof result.marker === 'string'; +} + +function isCacheEntryFresh(entry: CustomCommandCacheEntry, ttlMs: number, now: number): boolean { + const age = now - entry.createdAt; + + // A negative age means the entry carries a clock ahead of ours, so treat it + // as a miss rather than trusting it until that clock catches up. + return age >= 0 && age <= ttlMs; +} + +function readPersistentCache(cachePath: string): PersistentCustomCommandCache | null { + try { + const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as unknown; + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + + const data = parsed as { version?: unknown; cwd?: unknown; entries?: unknown }; + if ( + data.version !== CUSTOM_COMMAND_CACHE_SCHEMA_VERSION + || typeof data.cwd !== 'string' + || typeof data.entries !== 'object' + || data.entries === null + ) { + return null; + } + + const entries: Record = {}; + for (const [key, value] of Object.entries(data.entries)) { + if (isCacheEntry(value)) { + entries[key] = value; + } + } + + return { + version: CUSTOM_COMMAND_CACHE_SCHEMA_VERSION, + cwd: data.cwd, + entries + }; + } catch { + return null; + } +} + +function writePersistentCache(cachePath: string, cache: PersistentCustomCommandCache): void { + try { + // Owner-only, because a custom command prints whatever its author chose to + // print. Git metadata is predictable, arbitrary command output is not. + fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 }); + const tempPath = `${cachePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(cache), { encoding: 'utf-8', mode: 0o600 }); + fs.renameSync(tempPath, cachePath); + } catch { + // Best-effort cache. Statusline rendering must never fail because of it. + } +} + +function readPersistentCacheEntry( + cwd: string, + entryKey: string, + ttlMs: number, + now: number +): CustomCommandCacheEntry | null { + const cache = readPersistentCache(getCachePath(cwd)); + if (cache?.cwd !== cwd) { + return null; + } + + const entry = cache.entries[entryKey]; + if (!entry || !isCacheEntryFresh(entry, ttlMs, now)) { + return null; + } + + return entry; +} + +function pruneExpiredEntries( + entries: Record, + now: number +): Record { + // Session ids keep changing, so drop anything no configurable TTL can still + // serve. Without this the file grows once per session forever. + const maxAgeMs = MAX_CUSTOM_COMMAND_CACHE_TTL_SECONDS * 1000; + const kept: Record = {}; + + for (const [key, entry] of Object.entries(entries)) { + if (isCacheEntryFresh(entry, maxAgeMs, now)) { + kept[key] = entry; + } + } + + return kept; +} + +function writePersistentCacheEntry( + cwd: string, + entryKey: string, + entry: CustomCommandCacheEntry, + now: number +): void { + const cachePath = getCachePath(cwd); + const existingCache = readPersistentCache(cachePath); + const entries = existingCache?.cwd === cwd + ? pruneExpiredEntries(existingCache.entries, now) + : {}; + + entries[entryKey] = entry; + writePersistentCache(cachePath, { + version: CUSTOM_COMMAND_CACHE_SCHEMA_VERSION, + cwd, + entries + }); +} + +/** Reads the errno string off a spawn error, which the Error type does not carry. */ +function getErrorCode(error: unknown): string | undefined { + if (error instanceof Error && 'code' in error && typeof error.code === 'string') { + return error.code; + } + + return undefined; +} + +function getFailureMarker(result: SpawnSyncReturns): string | null { + const errorCode = getErrorCode(result.error); + + if (errorCode === 'ENOENT') { + return '[Cmd not found]'; + } else if (errorCode === 'ETIMEDOUT') { + return '[Timeout]'; + } else if (errorCode === 'EACCES') { + return '[Permission denied]'; + } else if (result.error) { + return '[Error]'; + } else if (result.signal) { + return `[Signal: ${result.signal}]`; + } else if (typeof result.status !== 'number') { + return '[Error]'; + } else if (result.status !== 0) { + return `[Exit: ${result.status}]`; + } + + return null; +} + +function closeDescriptor(fd: number): void { + try { + fs.closeSync(fd); + } catch { + // Already closed, so there is nothing left to release. + } +} + +function removeDirectory(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // A descendant can still hold a handle here on Windows. The directory + // lives under the temp root and is safe to leave for the OS to reap. + } +} + +/** + * Give the command both of its stdio streams as files rather than pipes. + * + * @remarks + * Every process in the shell tree inherits the pipe handles, and spawnSync + * returns only once every inheritor has closed them. One backgrounded + * descendant therefore holds the render open for as long as it lives, whatever + * the configured timeout says. Measured on Linux, `( sleep 3 ; echo LATE ) &` + * held a piped spawnSync for 3006ms and delivered output written after the + * shell had exited. The same command against files returns in 3ms. + * + * mkdtemp is what makes the paths safe to use: it creates an owner-only + * directory with an unguessable name in one atomic step, so neither file can be + * pre-created as a symlink or swapped between being written and being opened. + */ +function openCommandIo(input: string): CommandIo | null { + let dir: string | undefined; + let payloadFd: number | undefined; + let stdoutFd: number | undefined; + + try { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-cmd-')); + const payloadPath = path.join(dir, 'stdin.json'); + const stdoutPath = path.join(dir, 'stdout.txt'); + + fs.writeFileSync(payloadPath, input, { encoding: 'utf-8', flag: 'wx', mode: 0o600 }); + payloadFd = fs.openSync(payloadPath, 'r'); + stdoutFd = fs.openSync(stdoutPath, 'wx', 0o600); + + return { + dir, + payloadFd, + stdoutFd, + stdoutPath + }; + } catch { + if (payloadFd !== undefined) { + closeDescriptor(payloadFd); + } + if (stdoutFd !== undefined) { + closeDescriptor(stdoutFd); + } + if (dir !== undefined) { + removeDirectory(dir); + } + + return null; + } +} + +function closeCommandIo(io: CommandIo): void { + closeDescriptor(io.payloadFd); + closeDescriptor(io.stdoutFd); + removeDirectory(io.dir); +} + +function readCapturedStdout(io: CommandIo): string { + try { + return fs.readFileSync(io.stdoutPath, 'utf-8'); + } catch { + return ''; + } +} + +/** + * Kill everything the shell started, not just the shell. + * + * @remarks + * A timeout signals the shell alone, so a pipeline such as `curl ... | jq ...` + * leaves its remaining members running. On POSIX `detached: true` gives the shell + * its own process group, and that group outlives its leader, so a negated pid + * still reaches every member. + * + * Windows has no equivalent here: spawnSync returns only after terminating the + * shell, and `taskkill /T` needs a live pid to walk down from, so descendants + * there run until they exit on their own. + */ +function killProcessGroup(pid: number | undefined): void { + if (isWindows() || typeof pid !== 'number') { + return; + } + + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // ESRCH once the group has already exited, which is the common case. + } +} + +function executeCommand(request: CustomCommandRequest): CustomCommandResult { + const io = openCommandIo(request.input); + + try { + const options: SyncShellOptions = { + shell: true, + encoding: 'utf8', + timeout: request.timeoutMs, + stdio: io ? [io.payloadFd, io.stdoutFd, 'ignore'] : ['pipe', 'pipe', 'ignore'], + // Pinned rather than inherited, so a change to Node's default cannot + // silently turn large output into a failure marker. + maxBuffer: MAX_PIPED_STDOUT_BYTES, + env: process.env, + windowsHide: true, + detached: !isWindows() + }; + + // Falling back to pipes keeps a temp directory problem from blanking the + // widget, at the cost of the timing guarantee above. + if (!io) { + options.input = request.input; + } + + const result = spawnSync(request.command, options); + + const marker = getFailureMarker(result); + if (marker !== null) { + // Only a timeout can leave the tree running. A command that exited on + // its own may have deliberately left a background job behind. + if (marker === '[Timeout]') { + killProcessGroup(result.pid); + } + + return { + status: 'failed', + marker + }; + } + + const stdout = io ? readCapturedStdout(io) : result.stdout; + + return { + status: 'ok', + stdout: stdout.slice(0, MAX_CACHED_OUTPUT_CHARS).trim() + }; + } catch { + return { + status: 'failed', + marker: '[Error]' + }; + } finally { + if (io) { + closeCommandIo(io); + } + } +} + +/** + * Run a custom command, reusing a recent result when one is still within the TTL. + * + * @remarks + * Claude Code runs the status line as a fresh process per repaint. An in-process + * map alone would therefore never hit, so the cache is persisted to disk next to + * the git cache. + * + * The key covers the command, its timeout, the session and the terminal width. It + * deliberately omits the rest of the piped payload, which carries token counts + * that change on nearly every repaint and would make every lookup a miss. + * + * Without a session id there is nothing to separate one session's output from + * another's, so the result stays in this process rather than reaching the file + * every session shares. + */ +export function runCustomCommand(request: CustomCommandRequest): CustomCommandResult { + const ttlMs = getCacheTtlMs(request.ttlSeconds); + if (ttlMs === 0) { + return executeCommand(request); + } + + const cwd = process.cwd(); + const entryKey = getEntryKey(request); + const memoryCacheKey = `${entryKey}\0${cwd}`; + const canShareAcrossProcesses = typeof request.sessionId === 'string' && request.sessionId.length > 0; + const now = Date.now(); + + const memoryEntry = customCommandCache.get(memoryCacheKey); + if (memoryEntry && isCacheEntryFresh(memoryEntry, ttlMs, now)) { + return memoryEntry.result; + } + + if (canShareAcrossProcesses) { + const persistentEntry = readPersistentCacheEntry(cwd, entryKey, ttlMs, now); + if (persistentEntry) { + customCommandCache.set(memoryCacheKey, persistentEntry); + return persistentEntry.result; + } + } + + const result = executeCommand(request); + // Stamped after the run, so a command slower than the TTL still gets the + // full TTL of reuse rather than expiring the moment it returns. + const entry: CustomCommandCacheEntry = { + result, + createdAt: Date.now() + }; + customCommandCache.set(memoryCacheKey, entry); + if (canShareAcrossProcesses) { + writePersistentCacheEntry(cwd, entryKey, entry, entry.createdAt); + } + + return result; +} + +/** + * Clear the in-process custom command cache - for testing only + */ +export function clearCustomCommandCache(): void { + customCommandCache.clear(); +} diff --git a/src/widgets/CustomCommand.tsx b/src/widgets/CustomCommand.tsx index 4e8dcfb9..4b9b3ef8 100644 --- a/src/widgets/CustomCommand.tsx +++ b/src/widgets/CustomCommand.tsx @@ -1,4 +1,3 @@ -import { execSync } from 'child_process'; import { Box, Text, @@ -16,6 +15,7 @@ import type { WidgetItem } from '../types/Widget'; import { getVisibleText } from '../utils/ansi'; +import { runCustomCommand } from '../utils/custom-command'; import { shouldInsertInput } from '../utils/input-guards'; export class CustomCommandWidget implements Widget { @@ -58,55 +58,37 @@ export class CustomCommandWidget implements Widget { if (context.isPreview) { return item.commandPath ? `[cmd: ${item.commandPath.substring(0, 20)}${item.commandPath.length > 20 ? '...' : ''}]` : '[No command]'; } else if (item.commandPath && context.data) { - try { - const timeout = item.timeout ?? 1000; - const jsonInput = JSON.stringify( - typeof context.terminalWidth === 'number' - ? { ...context.data, terminal_width: context.terminalWidth } - : context.data - ); - let output = execSync(item.commandPath, { - encoding: 'utf8', - input: jsonInput, - timeout: timeout, - stdio: ['pipe', 'pipe', 'ignore'], - env: process.env, - windowsHide: true - }).trim(); - - // Strip ANSI codes if preserveColors is false - if (!item.preserveColors) { - // Strip ANSI/OSC escape sequences and keep only visible text - output = getVisibleText(output); - } + const jsonInput = JSON.stringify( + typeof context.terminalWidth === 'number' + ? { ...context.data, terminal_width: context.terminalWidth } + : context.data + ); + const result = runCustomCommand({ + command: item.commandPath, + input: jsonInput, + timeoutMs: item.timeout ?? 1000, + ttlSeconds: context.customCommandCacheTtlSeconds, + sessionId: context.data.session_id, + terminalWidth: context.terminalWidth + }); + + if (result.status === 'failed') { + return result.marker; + } - if (item.maxWidth && output.length > item.maxWidth) { - output = output.substring(0, item.maxWidth - 3) + '...'; - } + let output = result.stdout; - return output || null; - } catch (error) { - // Provide more specific error messages - if (error instanceof Error) { - const execError = error as Error & { - code?: string; - signal?: string; - status?: number; - }; - if (execError.code === 'ENOENT') { - return '[Cmd not found]'; - } else if (execError.code === 'ETIMEDOUT') { - return '[Timeout]'; - } else if (execError.code === 'EACCES') { - return '[Permission denied]'; - } else if (execError.signal) { - return `[Signal: ${execError.signal}]`; - } else if (execError.status !== undefined) { - return `[Exit: ${execError.status}]`; - } - } - return '[Error]'; + // Strip ANSI codes if preserveColors is false + if (!item.preserveColors) { + // Strip ANSI/OSC escape sequences and keep only visible text + output = getVisibleText(output); } + + if (item.maxWidth && output.length > item.maxWidth) { + output = output.substring(0, item.maxWidth - 3) + '...'; + } + + return output || null; } return null; } diff --git a/src/widgets/__tests__/CurrentWorkingDir.test.ts b/src/widgets/__tests__/CurrentWorkingDir.test.ts index 08224a78..602486fd 100644 --- a/src/widgets/__tests__/CurrentWorkingDir.test.ts +++ b/src/widgets/__tests__/CurrentWorkingDir.test.ts @@ -43,6 +43,7 @@ describe('CurrentWorkingDirWidget', () => { inheritSeparatorColors: false, globalBold: false, gitCacheTtlSeconds: 5, + customCommandCacheTtlSeconds: 5, minimalistMode: false, powerline: { enabled: false, diff --git a/src/widgets/__tests__/CustomCommand.test.ts b/src/widgets/__tests__/CustomCommand.test.ts index 35d0956b..8437ecc2 100644 --- a/src/widgets/__tests__/CustomCommand.test.ts +++ b/src/widgets/__tests__/CustomCommand.test.ts @@ -1,4 +1,11 @@ +import type { SpawnSyncReturns } from 'child_process'; +import { spawnSync } from 'child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { + afterEach, + beforeEach, describe, expect, it, @@ -8,17 +15,60 @@ import { import type { RenderContext } from '../../types/RenderContext'; import type { Settings } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearCustomCommandCache } from '../../utils/custom-command'; import { CustomCommandWidget } from '../CustomCommand'; -// Mock the process boundary: echo back whatever is piped to stdin, the way +// Mock the process boundary: echo back whatever is handed to stdin, the way // `cat` would. The widget output then IS the JSON it sent, so we can assert -// exactly what the custom command received — without spawning a subprocess. -vi.mock('child_process', () => ({ execSync: vi.fn((_command: string, options?: { input?: string }) => options?.input ?? '') })); +// exactly what the custom command received, without spawning a subprocess. +vi.mock('child_process', () => ({ + execSync: vi.fn(), + execFileSync: vi.fn(), + spawnSync: vi.fn() +})); + +const mockSpawnSync = spawnSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: (command: string, options: { stdio?: (string | number)[] }) => SpawnSyncReturns) => void; +}; + +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_USERPROFILE = process.env.USERPROFILE; +const tempPaths: string[] = []; + +function echoStdin(): void { + mockSpawnSync.mockImplementation((_command, options) => { + const stdin = options.stdio?.[0]; + const stdout = options.stdio?.[1]; + const payload = typeof stdin === 'number' ? fs.readFileSync(stdin, 'utf-8') : ''; + + if (typeof stdout === 'number') { + fs.writeSync(stdout, payload); + } + + return { + pid: 4242, + output: [], + stdout: payload, + stderr: '', + status: 0, + signal: null + }; + }); +} + +function useTempHome(): void { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-widget-home-')); + tempPaths.push(home); + process.env.HOME = home; + process.env.USERPROFILE = home; + vi.spyOn(os, 'homedir').mockReturnValue(home); +} describe('CustomCommandWidget', () => { const widget = new CustomCommandWidget(); - const defaultSettings: Settings = { + const settings: Settings = { version: 3, lines: [], flexMode: 'full', @@ -29,6 +79,7 @@ describe('CustomCommandWidget', () => { inheritSeparatorColors: false, globalBold: false, gitCacheTtlSeconds: 5, + customCommandCacheTtlSeconds: 0, minimalistMode: false, powerline: { enabled: false, @@ -47,19 +98,53 @@ describe('CustomCommandWidget', () => { commandPath: 'echo' }); - const createContext = (terminalWidth: number | null | undefined): RenderContext => ({ + // The TTL reaches the widget through the render context, the same route the + // git cache TTL takes. + const createContext = ( + terminalWidth: number | null | undefined, + customCommandCacheTtlSeconds = 0 + ): RenderContext => ({ data: { model: { display_name: 'Sonnet' } }, terminalWidth, + customCommandCacheTtlSeconds, isPreview: false }); const renderParsed = (terminalWidth: number | null | undefined): Record => { - const output = widget.render(createItem(), createContext(terminalWidth), defaultSettings); + const output = widget.render(createItem(), createContext(terminalWidth), settings); if (output === null) throw new Error('expected command output'); return JSON.parse(output) as Record; }; + beforeEach(() => { + vi.clearAllMocks(); + clearCustomCommandCache(); + echoStdin(); + }); + + afterEach(() => { + clearCustomCommandCache(); + vi.restoreAllMocks(); + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + if (ORIGINAL_USERPROFILE === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = ORIGINAL_USERPROFILE; + } + + while (tempPaths.length > 0) { + const tempPath = tempPaths.pop(); + if (tempPath) { + fs.rmSync(tempPath, { recursive: true, force: true }); + } + } + }); + it('includes terminal_width in the JSON piped to the command', () => { expect(renderParsed(142).terminal_width).toBe(142); }); @@ -72,4 +157,30 @@ describe('CustomCommandWidget', () => { it('omits terminal_width when the width is unknown', () => { expect(renderParsed(null)).not.toHaveProperty('terminal_width'); }); + + it('runs the command on every render when no cache TTL is configured', () => { + renderParsed(142); + renderParsed(142); + + expect(mockSpawnSync.mock.calls).toHaveLength(2); + }); + + it('reuses command output across renders within the configured TTL', () => { + useTempHome(); + + const first = widget.render(createItem(), createContext(142, 5), settings); + const second = widget.render(createItem(), createContext(142, 5), settings); + + expect(second).toBe(first); + expect(mockSpawnSync.mock.calls).toHaveLength(1); + }); + + it('runs the command again when the terminal width changes', () => { + useTempHome(); + + widget.render(createItem(), createContext(80, 5), settings); + widget.render(createItem(), createContext(200, 5), settings); + + expect(mockSpawnSync.mock.calls).toHaveLength(2); + }); });