From 33b68f18964f2969e57cbeace781d32ffd42a354 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Wed, 5 Aug 2026 09:58:48 +0300 Subject: [PATCH 1/3] fix(cli): keep activity-ingest stdout JSON-only for Cursor gate hooks Skip the post-command upgrade notice for activity-ingest so provider gate hooks receive parseable permission/continue JSON instead of JSON plus banner text. Co-authored-by: Cursor --- src/cli/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index d9c7ca4..36ab10f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -53,8 +53,13 @@ if (process.argv[2] === '__server__') { (async () => { try { // Start version check in the background while the command runs - const isUpgradeCommand = process.argv[2] === 'upgrade'; - const updateCheckPromise = isUpgradeCommand ? Promise.resolve(null) : checkForUpdates(); + const subcommand = process.argv[2]; + // Hook subprocesses (activity-ingest) must own stdout — only valid JSON for gate events. + const skipVersionCheck = + subcommand === 'upgrade' || subcommand === 'activity-ingest'; + const updateCheckPromise = skipVersionCheck + ? Promise.resolve(null) + : checkForUpdates(); const program = new Command(); @@ -436,7 +441,7 @@ if (process.argv[2] === '__server__') { // Show update notice after the command completes (if a newer version is available) const updateInfo = await updateCheckPromise; - if (updateInfo?.hasUpdate) { + if (!skipVersionCheck && updateInfo?.hasUpdate) { console.log(`\n A new version of capa is available: ${updateInfo.latestVersion} (current: ${updateInfo.currentVersion})`); console.log(' Run "capa upgrade" to update.\n'); } From 2fcf90fb8f1b2482003fda406cb2310bb560526b Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Wed, 5 Aug 2026 10:04:05 +0300 Subject: [PATCH 2/3] fix(cli): detect activity-ingest when global flags precede subcommand Parse the first non-option argv token for version-check suppression so `capa --no-color activity-ingest` keeps stdout JSON-only for gate hooks. Co-authored-by: Cursor --- src/cli/index.ts | 9 +++-- .../utils/__tests__/cli-subcommand.test.ts | 36 +++++++++++++++++++ src/cli/utils/cli-subcommand.ts | 13 +++++++ 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/cli/utils/__tests__/cli-subcommand.test.ts create mode 100644 src/cli/utils/cli-subcommand.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index 36ab10f..f2a1881 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -26,6 +26,10 @@ import { import type { RegistrySourceType } from '../types/database'; import type { RegistryCapability } from '../types/registry'; import { checkForUpdates } from './utils/version-check'; +import { + cliSubcommandFromArgv, + shouldSkipVersionCheck, +} from './utils/cli-subcommand'; import { VERSION } from '../version'; import { setFlags, ExitCode, error } from './ui'; @@ -53,10 +57,9 @@ if (process.argv[2] === '__server__') { (async () => { try { // Start version check in the background while the command runs - const subcommand = process.argv[2]; + const subcommand = cliSubcommandFromArgv(process.argv); // Hook subprocesses (activity-ingest) must own stdout — only valid JSON for gate events. - const skipVersionCheck = - subcommand === 'upgrade' || subcommand === 'activity-ingest'; + const skipVersionCheck = shouldSkipVersionCheck(subcommand); const updateCheckPromise = skipVersionCheck ? Promise.resolve(null) : checkForUpdates(); diff --git a/src/cli/utils/__tests__/cli-subcommand.test.ts b/src/cli/utils/__tests__/cli-subcommand.test.ts new file mode 100644 index 0000000..067af1c --- /dev/null +++ b/src/cli/utils/__tests__/cli-subcommand.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'bun:test'; +import { + cliSubcommandFromArgv, + shouldSkipVersionCheck, +} from '../cli-subcommand'; + +describe('cliSubcommandFromArgv', () => { + it('returns the first non-option token', () => { + expect(cliSubcommandFromArgv(['bun', 'capa', 'activity-ingest'])).toBe( + 'activity-ingest', + ); + expect( + cliSubcommandFromArgv([ + 'bun', + 'capa', + '--no-color', + 'activity-ingest', + '--event', + 'beforeFileRead', + ]), + ).toBe('activity-ingest'); + expect(cliSubcommandFromArgv(['bun', 'capa', '-q', 'upgrade'])).toBe( + 'upgrade', + ); + expect(cliSubcommandFromArgv(['bun', 'capa', 'install'])).toBe('install'); + }); +}); + +describe('shouldSkipVersionCheck', () => { + it('skips for upgrade and activity-ingest only', () => { + expect(shouldSkipVersionCheck('upgrade')).toBe(true); + expect(shouldSkipVersionCheck('activity-ingest')).toBe(true); + expect(shouldSkipVersionCheck('install')).toBe(false); + expect(shouldSkipVersionCheck(undefined)).toBe(false); + }); +}); diff --git a/src/cli/utils/cli-subcommand.ts b/src/cli/utils/cli-subcommand.ts new file mode 100644 index 0000000..63bbe1e --- /dev/null +++ b/src/cli/utils/cli-subcommand.ts @@ -0,0 +1,13 @@ +/** + * Resolve the CLI subcommand from raw argv (after `node` / `bun` and script path). + * Global root flags may precede the command token (`capa --no-color activity-ingest`). + */ +export function cliSubcommandFromArgv(argv: string[]): string | undefined { + const rest = argv.slice(2); + return rest.find((token) => token.length > 0 && !token.startsWith('-')); +} + +/** Subcommands that must not append human-readable text to stdout after they run. */ +export function shouldSkipVersionCheck(subcommand: string | undefined): boolean { + return subcommand === 'upgrade' || subcommand === 'activity-ingest'; +} From cceeb69d9862602af29be859aa86cc21e1e1c5be Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Wed, 5 Aug 2026 10:33:37 +0300 Subject: [PATCH 3/3] fix(test): reduce Windows CI flakiness for clean and database tests Skip wrap process-table scans when a project has no workspace markers, raise the Windows job test timeout to 20s, and retry temp dir cleanup after SQLite close on EBUSY/EPERM. Co-authored-by: Cursor --- .github/workflows/test.yml | 5 ++++ src/cli/utils/wrap/__tests__/sessions.test.ts | 16 ++++++++++++ src/cli/utils/wrap/sessions.ts | 6 +++++ src/db/__tests__/database.test.ts | 26 +++++++++++++++---- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abe3a99..209515d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,8 +31,13 @@ jobs: - name: Build web UI run: bun run build:web + - name: Run tests + run: bun test --timeout 20000 + if: matrix.os == 'windows-latest' + - name: Run tests run: bun test + if: matrix.os != 'windows-latest' - name: Run tests with coverage if: matrix.os == 'ubuntu-latest' diff --git a/src/cli/utils/wrap/__tests__/sessions.test.ts b/src/cli/utils/wrap/__tests__/sessions.test.ts index c7fd4a1..1c52a3e 100644 --- a/src/cli/utils/wrap/__tests__/sessions.test.ts +++ b/src/cli/utils/wrap/__tests__/sessions.test.ts @@ -1,6 +1,10 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; import { describe, it, expect } from 'bun:test'; import { findWrapPids, + findWrapPidsForProject, isPidRunning, stopAllWrapSessions, commandLineMatchesProject, @@ -18,6 +22,18 @@ describe('wrap process discovery', () => { expect(pids.includes(process.pid)).toBe(false); }); + it('findWrapPidsForProject skips process scan when project has no wrap workspace', async () => { + const dir = mkdtempSync(join(tmpdir(), 'capa-wrap-scan-skip-')); + try { + const pids = await findWrapPidsForProject(dir); + expect(pids).toEqual([]); + } finally { + try { + rmSync(dir, { recursive: true, force: true }); + } catch {} + } + }); + it('stopAllWrapSessions is a no-op when nothing is wrapping', async () => { // May still find unrelated wraps on the machine; just ensure it resolves. const n = await stopAllWrapSessions(); diff --git a/src/cli/utils/wrap/sessions.ts b/src/cli/utils/wrap/sessions.ts index 481e414..1727ccd 100644 --- a/src/cli/utils/wrap/sessions.ts +++ b/src/cli/utils/wrap/sessions.ts @@ -234,6 +234,12 @@ export async function findWrapPidsForProject(realProjectPath: string): Promise commandLineMatchesProject(p.commandLine, real, workspacePaths)) diff --git a/src/db/__tests__/database.test.ts b/src/db/__tests__/database.test.ts index ef9122a..fde1c56 100644 --- a/src/db/__tests__/database.test.ts +++ b/src/db/__tests__/database.test.ts @@ -4,6 +4,26 @@ import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +/** Windows CI can briefly keep SQLite files open after close(). */ +function removeTempDirWithRetry(dir: string, attempts = 8): void { + for (let i = 0; i < attempts; i++) { + try { + rmSync(dir, { recursive: true, force: true }); + return; + } catch (error: unknown) { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code: unknown }).code) + : ''; + if (code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY') { + throw error; + } + if (i === attempts - 1) return; + Bun.sleepSync(50 * (i + 1)); + } + } +} + describe('CapaDatabase', () => { let db: CapaDatabase; let tempDir: string; @@ -17,11 +37,7 @@ describe('CapaDatabase', () => { afterEach(() => { db.close(); - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch (error: any) { - if (error?.code !== 'EBUSY') throw error; - } + removeTempDirWithRetry(tempDir); }); describe('Project operations', () => {