diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ac78dd2..b6d55a4 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { - "packages/core": "0.1.0", - "packages/cli": "0.1.0", + "packages/core": "0.1.7", + "packages/cli": "0.1.7", "packages/vscode": "0.1.7" } diff --git a/AGENTS.md b/AGENTS.md index 80e001b..fa5126c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ packages/ **Core** is ESM (`"type": "module"`). **CLI** is ESM. **VS Code extension** is CommonJS (esbuild bundles to CJS). Core uses `sql.js` (ASM.js build, not WASM) for cross-IDE compatibility — no native modules. +**File watcher uses path-routing**: Chokidar watches multiple source directories; `path-router.ts` pattern-matches each changed file to its specific parser (skill, agent, command, plugin, rule, hook) — avoids full rescans on file change events. + ## Build, Test, and Development Commands ```bash @@ -56,10 +58,14 @@ pnpm --filter @commandvault/core test -- src/__tests__/search.test.ts ## Commit & Pull Request Guidelines -Conventional commits: `(): `. Types: `feat`, `fix`, `refactor`, `perf`, `chore`, `docs`, `test`, `ci`, `build`. Scope is the package (`core`, `cli`, `vscode`) or area. +Conventional commits: `(): `. Types: `feat`, `fix`, `refactor`, `perf`, `chore`, `docs`, `test`, `ci`, `build`. Scope is the package (`core`, `cli`, `vscode`, `deps`, `ci`) or area. + +**Commitlint** enforces format via `.commitlintrc.json` — invalid types or scopes will be rejected. Branches from `develop`: `feat/` or `fix/`. PRs target `develop`, not `main`. Do not include `Co-Authored-By` lines. +**PR template** (`.github/PULL_REQUEST_TEMPLATE.md`): requires Summary, Changes list, type checkbox, and a checklist gate — `pnpm typecheck`, `pnpm test`, `pnpm format:check` must pass. CHANGELOG update required for user-facing changes. + ## CI GitHub Actions on push to `develop`/`main` and PRs. Matrix: Node 20 + 22. Steps: install → build → test core → typecheck → package VSIX. pnpm version is read from `packageManager` field in root `package.json` (do not set `version` in the workflow). diff --git a/package.json b/package.json index 5016f10..188a78e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "commandvault", - "version": "0.1.0", + "version": "0.1.7", "private": true, "description": "Universal AI command manager — browse, search, and organize slash commands, skills, agents, plugins, rules, and hooks across all AI coding assistants", "author": "nothumanslabs", @@ -28,6 +28,13 @@ "format": "prettier --write 'packages/*/src/**/*.ts'", "format:check": "prettier --check 'packages/*/src/**/*.ts'" }, + "pnpm": { + "overrides": { + "fast-uri": ">=3.1.2", + "ws": ">=8.20.1", + "brace-expansion": ">=5.0.6" + } + }, "devDependencies": { "@commitlint/cli": "^20.5.3", "@commitlint/config-conventional": "^20.5.3", diff --git a/packages/cli/package.json b/packages/cli/package.json index 337b86c..a0a60f5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commandvault/cli", - "version": "0.1.0", + "version": "0.1.7", "description": "Terminal companion for CommandVault — list, search, and inspect AI commands", "author": "nothumanslabs", "license": "MIT", diff --git a/packages/cli/src/__tests__/commands/doctor.test.ts b/packages/cli/src/__tests__/commands/doctor.test.ts new file mode 100644 index 0000000..dbfe332 --- /dev/null +++ b/packages/cli/src/__tests__/commands/doctor.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MOCK_ENTRIES } from '../fixtures/mock-vault.js'; + +const _testHomeDir = vi.hoisted(() => ({ value: '' })); + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { + ...actual, + homedir: () => _testHomeDir.value, + }; +}); + +vi.mock('../../helpers.js', async () => { + const actual = await vi.importActual('../../helpers.js'); + return { + ...actual, + createVaultInstance: vi.fn(), + }; +}); + +import { createVaultInstance } from '../../helpers.js'; +import { createDoctorCommand } from '../../commands/doctor.js'; +import { Command } from 'commander'; + +function buildProgram() { + const program = new Command(); + program.option('--json', 'JSON output'); + program.addCommand(createDoctorCommand()); + return program; +} + +function createMockVault(entries = MOCK_ENTRIES) { + return { + getAllEntries: () => entries, + dispose: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('doctor command', () => { + let tmpDir: string; + let consoleSpy: ReturnType; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'vault-doctor-test-')); + _testHomeDir.value = tmpDir; + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + consoleSpy.mockRestore(); + vi.clearAllMocks(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('reports healthy status when all checks pass', async () => { + // Create all expected directories and files + const claudeDir = join(tmpDir, '.claude'); + await mkdir(join(claudeDir, 'skills'), { recursive: true }); + await mkdir(join(claudeDir, 'agents'), { recursive: true }); + await mkdir(join(claudeDir, 'commands'), { recursive: true }); + await mkdir(join(claudeDir, 'plugins'), { recursive: true }); + await mkdir(join(tmpDir, '.commandvault'), { recursive: true }); + + await writeFile(join(claudeDir, 'plugins', 'installed_plugins.json'), JSON.stringify([])); + await writeFile(join(claudeDir, 'settings.json'), JSON.stringify({ hooks: {} })); + await writeFile(join(tmpDir, '.commandvault', 'vault.db'), ''); + + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'doctor']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('checks passed'); + }); + + it('detects missing ~/.claude directory', async () => { + // Don't create .claude directory + await mkdir(join(tmpDir, '.commandvault'), { recursive: true }); + await writeFile(join(tmpDir, '.commandvault', 'vault.db'), ''); + + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'doctor']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('~/.claude/ directory'); + expect(output).toContain('not found'); + }); + + it('detects missing vault.db', async () => { + const claudeDir = join(tmpDir, '.claude'); + await mkdir(claudeDir, { recursive: true }); + // Don't create .commandvault directory or vault.db + + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'doctor']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('vault.db'); + expect(output).toContain('Not found'); + }); + + it('reports scan pipeline failure when vault throws', async () => { + const claudeDir = join(tmpDir, '.claude'); + await mkdir(claudeDir, { recursive: true }); + + vi.mocked(createVaultInstance).mockRejectedValue(new Error('DB corrupted')); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'doctor']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Vault scan pipeline'); + expect(output).toContain('DB corrupted'); + }); + + it('detects invalid plugins JSON', async () => { + const claudeDir = join(tmpDir, '.claude'); + await mkdir(join(claudeDir, 'plugins'), { recursive: true }); + await writeFile(join(claudeDir, 'plugins', 'installed_plugins.json'), '{broken json!!!'); + await mkdir(join(tmpDir, '.commandvault'), { recursive: true }); + await writeFile(join(tmpDir, '.commandvault', 'vault.db'), ''); + + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'doctor']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('installed_plugins.json'); + expect(output).toContain('invalid JSON'); + }); + + it('reports scan entry count on success', async () => { + const claudeDir = join(tmpDir, '.claude'); + await mkdir(claudeDir, { recursive: true }); + + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'doctor']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain(`${MOCK_ENTRIES.length} entries successfully`); + }); +}); diff --git a/packages/cli/src/__tests__/commands/favorite.test.ts b/packages/cli/src/__tests__/commands/favorite.test.ts new file mode 100644 index 0000000..8ec5c44 --- /dev/null +++ b/packages/cli/src/__tests__/commands/favorite.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { makeMockEntry } from '../fixtures/mock-vault.js'; +import type { SearchResult } from '@commandvault/core'; + +vi.mock('../../helpers.js', async () => { + const actual = await vi.importActual('../../helpers.js'); + return { + ...actual, + createVaultInstance: vi.fn(), + }; +}); + +import { createVaultInstance } from '../../helpers.js'; +import { createFavoriteCommand } from '../../commands/favorite.js'; +import { Command } from 'commander'; + +function buildProgram() { + const program = new Command(); + program.option('--json', 'JSON output'); + program.addCommand(createFavoriteCommand()); + return program; +} + +const TEST_ENTRY = makeMockEntry({ + id: 'fav-test-1', + name: 'browse', + type: 'skill', + source: 'gstack', + favorite: false, +}); + +function createMockVault(options?: { searchResult?: SearchResult[]; toggleResult?: boolean }) { + const searchResult = options?.searchResult ?? [ + { entry: TEST_ENTRY, score: 1, matchedFields: ['name'] }, + ]; + const toggleResult = options?.toggleResult ?? true; + + return { + quickSearch: vi.fn().mockReturnValue(searchResult), + toggleFavorite: vi.fn().mockReturnValue(toggleResult), + dispose: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('favorite command', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + vi.clearAllMocks(); + }); + + it('toggles favorite on an entry (adds favorite)', async () => { + const vault = createMockVault({ toggleResult: true }); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'favorite', 'browse']); + + expect(vault.toggleFavorite).toHaveBeenCalledWith('fav-test-1'); + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Favorited'); + expect(output).toContain('browse'); + }); + + it('toggles favorite off an entry (removes favorite)', async () => { + const vault = createMockVault({ toggleResult: false }); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'favorite', 'browse']); + + expect(vault.toggleFavorite).toHaveBeenCalledWith('fav-test-1'); + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Unfavorited'); + expect(output).toContain('browse'); + }); + + it('reports error for non-existent entry', async () => { + const vault = createMockVault({ searchResult: [] }); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'favorite', 'nonexistent']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('No entry found'); + expect(output).toContain('nonexistent'); + expect(vault.toggleFavorite).not.toHaveBeenCalled(); + }); + + it('uses fuzzy search to match entry by name', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'favorite', 'brows']); + + // quickSearch should be called with the fuzzy input + expect(vault.quickSearch).toHaveBeenCalledWith('brows', 1); + }); + + it('disposes vault after operation completes', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'favorite', 'browse']); + + expect(vault.dispose).toHaveBeenCalledOnce(); + }); + + it('disposes vault even when entry is not found', async () => { + const vault = createMockVault({ searchResult: [] }); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'favorite', 'nothing']); + + expect(vault.dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/src/__tests__/commands/list.test.ts b/packages/cli/src/__tests__/commands/list.test.ts new file mode 100644 index 0000000..78a2c72 --- /dev/null +++ b/packages/cli/src/__tests__/commands/list.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { MOCK_ENTRIES, makeMockEntry } from '../fixtures/mock-vault.js'; + +vi.mock('../../helpers.js', async () => { + const actual = await vi.importActual('../../helpers.js'); + return { + ...actual, + createVaultInstance: vi.fn(), + }; +}); + +import { createVaultInstance } from '../../helpers.js'; +import { createListCommand } from '../../commands/list.js'; +import { Command } from 'commander'; + +function buildProgram() { + const program = new Command(); + program.option('--json', 'JSON output'); + program.addCommand(createListCommand()); + return program; +} + +function createMockVault(entries = MOCK_ENTRIES) { + return { + getAllEntries: () => entries, + dispose: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('list command', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + vi.clearAllMocks(); + }); + + it('lists all entries when no filters specified', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'list']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain(`Total: ${MOCK_ENTRIES.length} entries`); + expect(vault.dispose).toHaveBeenCalled(); + }); + + it('filters by type (--type skill)', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'list', '--type', 'skill']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + const skillCount = MOCK_ENTRIES.filter((e) => e.type === 'skill').length; + expect(output).toContain(`Total: ${skillCount} entries`); + }); + + it('filters by source (--source gstack)', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'list', '--source', 'gstack']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + const gstackCount = MOCK_ENTRIES.filter((e) => e.source === 'gstack').length; + expect(output).toContain(`Total: ${gstackCount} entries`); + }); + + it('shows empty message when no entries match filter', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'list', '--source', 'nonexistent']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('No entries found matching your filters'); + }); + + it('outputs JSON when --json flag is set', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', '--json', 'list']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty('entries'); + expect(parsed.entries).toHaveLength(MOCK_ENTRIES.length); + }); + + it('rejects invalid type values', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'list', '--type', 'invalid']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Invalid type'); + expect(output).toContain('Valid types'); + }); + + it('filters by favorites (--favorites)', async () => { + const entriesWithFav = [ + ...MOCK_ENTRIES.slice(0, 2), + makeMockEntry({ id: 'fav1', name: 'my-fav', favorite: true }), + ]; + const vault = createMockVault(entriesWithFav); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'list', '--favorites']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Total: 1 entries'); + }); +}); diff --git a/packages/cli/src/__tests__/commands/search.test.ts b/packages/cli/src/__tests__/commands/search.test.ts new file mode 100644 index 0000000..2c5735c --- /dev/null +++ b/packages/cli/src/__tests__/commands/search.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { MOCK_ENTRIES, mockSearchResults } from '../fixtures/mock-vault.js'; +import type { SearchOptions } from '@commandvault/core'; + +vi.mock('../../helpers.js', async () => { + const actual = await vi.importActual('../../helpers.js'); + return { + ...actual, + createVaultInstance: vi.fn(), + }; +}); + +import { createVaultInstance } from '../../helpers.js'; +import { createSearchCommand } from '../../commands/search.js'; +import { Command } from 'commander'; + +function buildProgram() { + const program = new Command(); + program.option('--json', 'JSON output'); + program.option('--tier ', 'Search tier'); + program.addCommand(createSearchCommand()); + return program; +} + +function createMockVault(searchFn?: (opts: SearchOptions) => any[]) { + return { + search: searchFn ?? ((opts: SearchOptions) => mockSearchResults(opts.query)), + dispose: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('search command', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + vi.clearAllMocks(); + }); + + it('searches by query string and returns matching results', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'search', 'browse']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('result'); + expect(output).toContain('browse'); + }); + + it('returns empty results message for no matches', async () => { + const vault = createMockVault(() => []); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'search', 'zzzznonexistent']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('No results found'); + }); + + it('passes type filter alongside query', async () => { + let capturedOpts: SearchOptions | undefined; + const vault = createMockVault((opts) => { + capturedOpts = opts; + return mockSearchResults(opts.query); + }); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'search', 'browse', '--type', 'skill']); + + expect(capturedOpts).toBeDefined(); + expect(capturedOpts!.type).toBe('skill'); + }); + + it('outputs JSON when --json flag is set', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', '--json', 'search', 'browse']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty('query', 'browse'); + expect(parsed).toHaveProperty('results'); + expect(Array.isArray(parsed.results)).toBe(true); + }); + + it('respects --limit option', async () => { + let capturedOpts: SearchOptions | undefined; + const vault = createMockVault((opts) => { + capturedOpts = opts; + return mockSearchResults(opts.query).slice(0, opts.limit ?? 20); + }); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'search', 'test', '--limit', '3']); + + expect(capturedOpts).toBeDefined(); + expect(capturedOpts!.limit).toBe(3); + }); + + it('rejects invalid --limit values', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'search', 'test', '--limit', '0']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('--limit must be a number between 1 and 1000'); + }); +}); diff --git a/packages/cli/src/__tests__/commands/stats.test.ts b/packages/cli/src/__tests__/commands/stats.test.ts new file mode 100644 index 0000000..005601a --- /dev/null +++ b/packages/cli/src/__tests__/commands/stats.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { MOCK_ENTRIES, MOCK_STATS, makeMockEntry } from '../fixtures/mock-vault.js'; +import type { VaultStats } from '@commandvault/core'; + +vi.mock('../../helpers.js', async () => { + const actual = await vi.importActual('../../helpers.js'); + return { + ...actual, + createVaultInstance: vi.fn(), + }; +}); + +import { createVaultInstance } from '../../helpers.js'; +import { createStatsCommand } from '../../commands/stats.js'; +import { Command } from 'commander'; + +function buildProgram() { + const program = new Command(); + program.option('--json', 'JSON output'); + program.addCommand(createStatsCommand()); + return program; +} + +function createMockVault(stats: VaultStats = MOCK_STATS, entries = MOCK_ENTRIES) { + return { + getStats: () => stats, + getAllEntries: () => entries, + dispose: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('stats command', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + vi.clearAllMocks(); + }); + + it('returns correct entry counts by type', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'stats']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + // Should display the total count + expect(output).toContain(String(MOCK_STATS.totalEntries)); + // Should display type names + expect(output).toContain('skill'); + expect(output).toContain('agent'); + expect(output).toContain('command'); + }); + + it('returns correct counts by source', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'stats']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + // Source section header + expect(output).toContain('Source'); + // Source names from MOCK_STATS + expect(output).toContain('gstack'); + expect(output).toContain('custom'); + }); + + it('shows favorite count', async () => { + const statsWithFavorites: VaultStats = { + ...MOCK_STATS, + favoriteCount: 5, + }; + const vault = createMockVault(statsWithFavorites); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'stats']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('5'); + }); + + it('outputs JSON when --json flag is set', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', '--json', 'stats']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty('totalEntries', MOCK_STATS.totalEntries); + expect(parsed).toHaveProperty('byType'); + expect(parsed).toHaveProperty('bySource'); + expect(parsed).toHaveProperty('favoriteCount'); + }); + + it('shows top used entries when usage data exists', async () => { + const entriesWithUsage = [ + makeMockEntry({ id: 'u1', name: 'popular-skill', usageCount: 50 }), + makeMockEntry({ id: 'u2', name: 'unused-skill', usageCount: 0 }), + ]; + const vault = createMockVault(MOCK_STATS, entriesWithUsage); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'stats']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('popular-skill'); + expect(output).toContain('50x'); + }); + + it('shows "no usage data" message when all entries have zero usage', async () => { + const noUsageEntries = [makeMockEntry({ id: 'z1', name: 'zero-usage', usageCount: 0 })]; + const vault = createMockVault(MOCK_STATS, noUsageEntries); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'stats']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('No usage data yet'); + }); +}); diff --git a/packages/cli/src/__tests__/commands/tag.test.ts b/packages/cli/src/__tests__/commands/tag.test.ts new file mode 100644 index 0000000..e995150 --- /dev/null +++ b/packages/cli/src/__tests__/commands/tag.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { MOCK_ENTRIES, makeMockEntry } from '../fixtures/mock-vault.js'; +import type { SearchResult } from '@commandvault/core'; + +vi.mock('../../helpers.js', async () => { + const actual = await vi.importActual('../../helpers.js'); + return { + ...actual, + createVaultInstance: vi.fn(), + }; +}); + +import { createVaultInstance } from '../../helpers.js'; +import { createTagCommand } from '../../commands/tag.js'; +import { Command } from 'commander'; + +function buildProgram() { + const program = new Command(); + program.option('--json', 'JSON output'); + program.addCommand(createTagCommand()); + return program; +} + +const TEST_ENTRY = makeMockEntry({ + id: 'tag-test-1', + name: 'browse', + type: 'skill', + source: 'gstack', + tags: ['browser', 'testing'], +}); + +function createMockVault(options?: { searchResult?: SearchResult[]; tags?: string[] }) { + const searchResult = options?.searchResult ?? [ + { entry: TEST_ENTRY, score: 1, matchedFields: ['name'] }, + ]; + const userTags = options?.tags ?? []; + + return { + quickSearch: vi.fn().mockReturnValue(searchResult), + addTag: vi.fn(), + removeTag: vi.fn(), + getEntry: vi.fn().mockReturnValue(TEST_ENTRY), + getTagsForEntry: vi.fn().mockReturnValue(userTags), + dispose: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('tag command', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + vi.clearAllMocks(); + }); + + it('adds a tag to an entry', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'tag', 'add', 'browse', 'my-tag']); + + expect(vault.addTag).toHaveBeenCalledWith('tag-test-1', 'my-tag'); + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Added tag'); + expect(output).toContain('my-tag'); + expect(output).toContain('browse'); + }); + + it('removes a tag from an entry', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'tag', 'remove', 'browse', 'old-tag']); + + expect(vault.removeTag).toHaveBeenCalledWith('tag-test-1', 'old-tag'); + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Removed tag'); + expect(output).toContain('old-tag'); + }); + + it('lists tags for an entry', async () => { + const entryWithTags = makeMockEntry({ + id: 'tag-test-1', + name: 'browse', + type: 'skill', + tags: ['browser', 'testing', 'user-added'], + }); + const vault = createMockVault({ + searchResult: [{ entry: entryWithTags, score: 1, matchedFields: ['name'] }], + tags: ['user-added'], + }); + vault.getEntry.mockReturnValue(entryWithTags); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'tag', 'list', 'browse']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Tags for'); + expect(output).toContain('browse'); + expect(output).toContain('browser'); + expect(output).toContain('user-added'); + }); + + it('rejects empty tag name on add', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + // No tag argument provided + await program.parseAsync(['node', 'vault', 'tag', 'add', 'browse']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Usage: vault tag add '); + expect(vault.addTag).not.toHaveBeenCalled(); + }); + + it('reports error for non-existent entry', async () => { + const vault = createMockVault({ searchResult: [] }); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'tag', 'add', 'nonexistent', 'my-tag']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('No entry found'); + }); + + it('rejects unknown action', async () => { + const vault = createMockVault(); + vi.mocked(createVaultInstance).mockResolvedValue(vault as any); + + const program = buildProgram(); + await program.parseAsync(['node', 'vault', 'tag', 'invalid-action', 'browse', 'tag']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Unknown action'); + }); +}); diff --git a/packages/cli/src/commands/audit.ts b/packages/cli/src/commands/audit.ts new file mode 100644 index 0000000..fdde02e --- /dev/null +++ b/packages/cli/src/commands/audit.ts @@ -0,0 +1,123 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { detectStaleness, scoreEntries } from '@commandvault/core'; +import { createVaultInstance, jsonOutput, type CliGlobalOptions } from '../helpers.js'; + +export function createAuditCommand(): Command { + const cmd = new Command('audit') + .description('Detect stale entries and score vault quality') + .option('--threshold ', 'Staleness threshold in days', '30') + .option('--min-score ', 'Minimum quality score threshold', '40') + .action(async (opts, command) => { + const globalOpts = command.optsWithGlobals() as CliGlobalOptions; + const thresholdDays = parseInt(opts.threshold, 10); + const minScore = parseInt(opts.minScore, 10); + + const vault = await createVaultInstance(globalOpts); + + try { + const entries = vault.getAllEntries(); + const [stalenessResults, qualityScores] = await Promise.all([ + detectStaleness(entries, thresholdDays), + Promise.resolve(scoreEntries(entries)), + ]); + + const staleEntries = stalenessResults.filter((r) => r.isStale); + const missingEntries = stalenessResults.filter((r) => !r.sourceFileExists); + const lowQuality = qualityScores.filter((q) => q.score < minScore); + const avgScore = + entries.length > 0 + ? Math.round(qualityScores.reduce((sum, q) => sum + q.score, 0) / entries.length) + : 0; + + if (globalOpts.json) { + jsonOutput({ + totalEntries: entries.length, + stale: staleEntries.map((r) => ({ + name: r.entry.name, + daysSinceModified: r.daysSinceModified, + filePath: r.entry.filePath, + sourceFileExists: r.sourceFileExists, + })), + lowQuality: lowQuality.map((q) => ({ + name: q.entry.name, + score: q.score, + breakdown: q.breakdown, + filePath: q.entry.filePath, + })), + summary: { + staleCount: staleEntries.length, + missingCount: missingEntries.length, + lowQualityCount: lowQuality.length, + averageScore: avgScore, + }, + }); + return; + } + + console.log(''); + console.log(chalk.bold.white(' === Vault Audit Report ===')); + console.log(''); + + // Stale entries section + console.log(chalk.bold.white(` Stale Entries (not modified in ${thresholdDays}+ days):`)); + + const staleOnly = staleEntries.filter((r) => r.sourceFileExists); + if (staleOnly.length === 0 && missingEntries.length === 0) { + console.log(chalk.dim(' No stale entries found.')); + } else { + for (const result of staleOnly.slice(0, 20)) { + const days = + result.daysSinceModified === Infinity ? '?' : String(result.daysSinceModified); + console.log( + ` ${chalk.yellow('⚠')} ${chalk.white(result.entry.name)} ${chalk.dim(`(${days} days)`)} ${chalk.dim('—')} ${chalk.dim(result.entry.filePath)}`, + ); + } + for (const result of missingEntries.slice(0, 10)) { + console.log( + ` ${chalk.red('✗')} ${chalk.red(result.entry.name)} ${chalk.dim('— source file no longer exists')}`, + ); + } + } + + console.log(''); + + // Low quality section + console.log(chalk.bold.white(` Low Quality Entries (score < ${minScore}):`)); + + if (lowQuality.length === 0) { + console.log(chalk.dim(' No low-quality entries found.')); + } else { + for (const q of lowQuality.slice(0, 20)) { + const reasons: string[] = []; + if (q.breakdown.completeness < 8) reasons.push('minimal content'); + if (q.breakdown.recency === 0) reasons.push('very old'); + if (q.breakdown.usage === 0 && q.breakdown.engagement === 0) reasons.push('never used'); + const reasonStr = reasons.length > 0 ? ` — ${reasons.join(', ')}` : ''; + console.log( + ` ${chalk.dim('●')} ${chalk.white(q.entry.name)} ${chalk.dim(`(score: ${q.score})`)}${chalk.dim(reasonStr)}`, + ); + } + } + + console.log(''); + + // Summary + console.log(chalk.bold.white(' Summary:')); + console.log(` Total entries: ${chalk.bold(String(entries.length))}`); + const stalePct = + entries.length > 0 ? ((staleEntries.length / entries.length) * 100).toFixed(1) : '0'; + console.log(` Stale: ${chalk.yellow(String(staleEntries.length))} (${stalePct}%)`); + console.log(` Missing source: ${chalk.red(String(missingEntries.length))}`); + const lowPct = + entries.length > 0 ? ((lowQuality.length / entries.length) * 100).toFixed(1) : '0'; + console.log(` Low quality: ${chalk.yellow(String(lowQuality.length))} (${lowPct}%)`); + console.log(` Average quality score: ${chalk.bold(`${avgScore}/100`)}`); + console.log(''); + } finally { + await vault.dispose(); + } + }); + + return cmd; +} diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 14cae75..8944a31 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -24,9 +24,23 @@ function parseValue(raw: string): unknown { if (raw === 'true') return true; if (raw === 'false') return false; + // Parse JSON arrays and objects + if (raw.startsWith('[') || raw.startsWith('{')) { + try { + return JSON.parse(raw); + } catch { + // Fall through to treat as plain string + } + } + const asNum = Number(raw); if (!Number.isNaN(asNum) && raw.trim() !== '') return asNum; + // Expand tilde to home directory + if (raw.startsWith('~')) { + return raw.replace(/^~/, homedir()); + } + return raw; } diff --git a/packages/cli/src/commands/favorite.ts b/packages/cli/src/commands/favorite.ts index 6bbb781..da35c32 100644 --- a/packages/cli/src/commands/favorite.ts +++ b/packages/cli/src/commands/favorite.ts @@ -1,18 +1,66 @@ import { Command } from 'commander'; import chalk from 'chalk'; +import type { EntryType } from '@commandvault/core'; import { createVaultInstance, typeEmoji, typeColor, type CliGlobalOptions } from '../helpers.js'; +const VALID_TYPES = ['skill', 'agent', 'command', 'plugin', 'rule', 'hook'] as const; + export function createFavoriteCommand(): Command { const cmd = new Command('favorite') .alias('fav') - .description('Toggle favorite status on an entry') - .argument('', 'Entry name (fuzzy matched)') - .action(async (name: string, _opts, command) => { + .description('Toggle favorite status on an entry (or bulk with --type)') + .argument('[name]', 'Entry name (fuzzy matched)') + .option('--type ', 'Apply to all entries of this type (bulk operation)') + .action(async (name: string | undefined, _opts, command) => { const globalOpts = command.optsWithGlobals() as CliGlobalOptions; + const opts = command.opts(); const vault = await createVaultInstance(globalOpts); try { + // Bulk mode: toggle favorites for all entries of a given type + if (opts.type) { + if (!VALID_TYPES.includes(opts.type as any)) { + console.log(chalk.red(`Invalid type: "${opts.type}"`)); + console.log(chalk.dim(`Valid types: ${VALID_TYPES.join(', ')}`)); + return; + } + + const allEntries = vault.getAllEntries(); + const filtered = allEntries.filter((e) => e.type === (opts.type as EntryType)); + + if (filtered.length === 0) { + console.log(chalk.yellow(`\nNo entries found of type "${opts.type}".\n`)); + return; + } + + let favorited = 0; + let unfavorited = 0; + for (const entry of filtered) { + const isFav = vault.toggleFavorite(entry.id); + if (isFav) { + favorited++; + } else { + unfavorited++; + } + } + + console.log(''); + console.log( + ` Toggled ${chalk.bold(String(filtered.length))} ${opts.type} entries: ${chalk.yellow(`${favorited} favorited`)}, ${chalk.dim(`${unfavorited} unfavorited`)}`, + ); + console.log(''); + return; + } + + // Single entry mode + if (!name) { + console.log( + chalk.red('\nUsage: vault favorite or vault favorite --type \n'), + ); + return; + } + const results = vault.quickSearch(name, 1); if (results.length === 0) { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index bb96b9d..1ddf40a 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -90,30 +90,19 @@ export function createInitCommand(): Command { if (isReset && configExists) { console.log(chalk.green(' Config reset to defaults.')); } else { - console.log(chalk.green(' Created ~/.commandvault/ directory')); - console.log(chalk.green(` Created config at ${CONFIG_PATH}`)); + console.log(chalk.green(' ✓ CommandVault initialized successfully!')); } console.log(''); - console.log(chalk.white(' Default configuration:')); - console.log(''); - - const entries = Object.entries(DEFAULT_CONFIG); - for (const [key, value] of entries) { - const formatted = Array.isArray(value) - ? value.length > 0 - ? value.join(', ') - : chalk.dim('(empty)') - : String(value); - console.log(` ${chalk.dim(key + ':')} ${formatted}`); - } - + console.log(` ${chalk.dim('Scanned:')} ~/.claude/`); + console.log(` ${chalk.dim('Database:')} ~/.commandvault/vault.db`); console.log(''); console.log(chalk.bold.white(' Next steps:')); console.log(chalk.dim(' ' + '-'.repeat(40))); - console.log(` 1. Run ${chalk.cyan('vault list')} to see all indexed entries`); - console.log(` 2. Run ${chalk.cyan('vault search ')} to search commands`); - console.log(` 3. Run ${chalk.cyan('vault doctor')} to verify your setup`); + console.log(` ${chalk.cyan('vault list')} List all indexed entries`); + console.log(` ${chalk.cyan('vault search ')} Search your vault`); + console.log(` ${chalk.cyan('vault stats')} View entry statistics`); + console.log(` ${chalk.cyan('vault doctor')} Run health check`); console.log(''); }); diff --git a/packages/cli/src/commands/interactive.tsx b/packages/cli/src/commands/interactive.tsx index 48fe6ef..ab0f502 100644 --- a/packages/cli/src/commands/interactive.tsx +++ b/packages/cli/src/commands/interactive.tsx @@ -154,11 +154,22 @@ export function createInteractiveCommand(): Command { return new Command('interactive') .alias('i') .description('Interactive fuzzy search mode (full TUI in terminal, legacy mode in pipes)') + .option('--tui', 'Force TUI mode') + .option('--no-tui', 'Force legacy non-interactive mode') .action(async (_opts, command) => { const globalOpts = command.optsWithGlobals() as CliGlobalOptions; - const isTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY); + const localOpts = command.opts(); - if (isTTY) { + let useTui: boolean; + if (localOpts.tui === true) { + useTui = true; + } else if (localOpts.tui === false) { + useTui = false; + } else { + useTui = Boolean(process.stdout.isTTY && process.stdin.isTTY); + } + + if (useTui) { await runTuiMode(globalOpts); } else { await runLegacyMode(globalOpts); diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 9cad13a..8edd816 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -89,7 +89,16 @@ export function createListCommand(): Command { } if (entries.length === 0) { - console.log(chalk.yellow('\nNo entries found matching your filters.\n')); + const hasFilter = opts.type || opts.source || opts.tag || opts.favorites; + if (hasFilter) { + console.log(chalk.yellow('\nNo entries found matching your filters.\n')); + } else { + console.log( + chalk.yellow( + "\nNo entries found. Try running 'vault init' or check that ~/.claude/ exists.\n", + ), + ); + } return; } diff --git a/packages/cli/src/commands/registry.ts b/packages/cli/src/commands/registry.ts new file mode 100644 index 0000000..6333c5c --- /dev/null +++ b/packages/cli/src/commands/registry.ts @@ -0,0 +1,133 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import chalk from 'chalk'; +import { RegistryManager } from '@commandvault/core'; +import type { RegistryConfig } from '@commandvault/core'; + +const CONFIG_DIR = join(homedir(), '.commandvault'); +const CONFIG_PATH = join(CONFIG_DIR, 'config.json'); + +async function loadRegistries(): Promise { + try { + const raw = await readFile(CONFIG_PATH, 'utf-8'); + const parsed = JSON.parse(raw) as Record; + const registries = parsed.registries; + if (!Array.isArray(registries)) return []; + return registries as RegistryConfig[]; + } catch { + return []; + } +} + +async function saveRegistries(registries: readonly RegistryConfig[]): Promise { + await mkdir(CONFIG_DIR, { recursive: true }); + let existing: Record = {}; + try { + const raw = await readFile(CONFIG_PATH, 'utf-8'); + existing = JSON.parse(raw) as Record; + } catch { + // file doesn't exist yet + } + const updated = { ...existing, registries }; + await writeFile(CONFIG_PATH, JSON.stringify(updated, null, 2), 'utf-8'); +} + +function buildManager(configs: readonly RegistryConfig[]): RegistryManager { + const manager = new RegistryManager(); + for (const config of configs) { + manager.addRegistry(config); + } + return manager; +} + +export function createRegistryCommand(): Command { + const cmd = new Command('registry').description('Manage remote skill registries'); + + cmd + .command('add ') + .option('--type ', 'Registry type (json|api)', 'json') + .description('Add a remote registry') + .action(async (name: string, url: string, opts: { type: string }) => { + try { + new URL(url); + } catch { + console.error(chalk.red(`Invalid URL: ${url}`)); + process.exit(1); + } + const type = opts.type === 'api' ? 'api' : 'json'; + const registries = [...(await loadRegistries())]; + if (registries.some((r) => r.name === name)) { + console.error(chalk.red(`Registry "${name}" already exists. Remove it first.`)); + process.exit(1); + } + const config: RegistryConfig = { name, url, type }; + registries.push(config); + await saveRegistries(registries); + console.log(chalk.green(`Added registry "${name}" (${type}) → ${url}`)); + }); + + cmd + .command('remove ') + .description('Remove a registry') + .action(async (name: string) => { + const registries = await loadRegistries(); + const filtered = registries.filter((r) => r.name !== name); + if (filtered.length === registries.length) { + console.error(chalk.red(`Registry "${name}" not found.`)); + process.exit(1); + } + await saveRegistries(filtered); + console.log(chalk.green(`Removed registry "${name}"`)); + }); + + cmd + .command('list') + .description('List configured registries') + .action(async () => { + const registries = await loadRegistries(); + if (registries.length === 0) { + console.log( + chalk.dim('No registries configured. Use `vault registry add ` to add one.'), + ); + return; + } + console.log(chalk.bold('Configured registries:\n')); + for (const r of registries) { + console.log(` ${chalk.cyan(r.name)} (${r.type}) → ${chalk.dim(r.url)}`); + } + }); + + cmd + .command('search ') + .description('Search across all registries') + .option('--limit ', 'Max results', '10') + .action(async (query: string, opts: { limit: string }) => { + const registries = await loadRegistries(); + if (registries.length === 0) { + console.log( + chalk.dim('No registries configured. Use `vault registry add ` to add one.'), + ); + return; + } + const manager = buildManager(registries); + const limit = parseInt(opts.limit, 10) || 10; + const result = await manager.search(query, { limit }); + + if (result.entries.length === 0) { + console.log(chalk.dim(`No results for "${query}"`)); + return; + } + console.log(chalk.bold(`Found ${result.total} result(s) for "${query}":\n`)); + for (const entry of result.entries) { + const tags = entry.tags?.length ? chalk.dim(` [${entry.tags.join(', ')}]`) : ''; + console.log(` ${chalk.cyan(entry.name)} ${chalk.dim(`(${entry.type})`)}${tags}`); + console.log(` ${entry.description}`); + console.log(` ${chalk.dim(`from: ${entry.source}`)}`); + console.log(''); + } + }); + + return cmd; +} diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index cea259b..c695968 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -44,6 +44,7 @@ export function createSearchCommand(): Command { .argument('', 'Search query') .option('-t, --type ', 'Filter by entry type') .option('-s, --source ', 'Filter by source') + .option('--tag ', 'Filter results by tag') .option('-l, --limit ', 'Maximum results', '20') .action(async (query: string, _opts, command) => { const globalOpts = command.optsWithGlobals() as CliGlobalOptions; @@ -62,6 +63,7 @@ export function createSearchCommand(): Command { query, type: opts.type as EntryType | undefined, source: opts.source as EntrySource | undefined, + tags: opts.tag ? [opts.tag as string] : undefined, limit, tier: globalOpts.tier, }); diff --git a/packages/cli/src/commands/tag.ts b/packages/cli/src/commands/tag.ts index 7fd4740..5e3c292 100644 --- a/packages/cli/src/commands/tag.ts +++ b/packages/cli/src/commands/tag.ts @@ -1,25 +1,86 @@ import { Command } from 'commander'; import chalk from 'chalk'; +import type { EntryType } from '@commandvault/core'; import { createVaultInstance, typeEmoji, typeColor, type CliGlobalOptions } from '../helpers.js'; +const VALID_TYPES = ['skill', 'agent', 'command', 'plugin', 'rule', 'hook'] as const; + export function createTagCommand(): Command { const cmd = new Command('tag') .description('Manage user-defined tags on vault entries') .argument('', 'Action to perform (add|remove|list)') - .argument('', 'Entry name (fuzzy matched)') + .argument('[name]', 'Entry name (fuzzy matched)') .argument('[tag]', 'Tag to add or remove') + .option('--type ', 'Apply to all entries of this type (bulk operation)') .action( async ( action: string, - name: string, + name: string | undefined, tag: string | undefined, _opts: unknown, command: Command, ) => { const globalOpts = command.optsWithGlobals() as CliGlobalOptions; + const opts = command.opts(); const vault = await createVaultInstance(globalOpts); try { + // Bulk mode: apply tag operation to all entries of a given type + if (opts.type) { + if (!VALID_TYPES.includes(opts.type as any)) { + console.log(chalk.red(`Invalid type: "${opts.type}"`)); + console.log(chalk.dim(`Valid types: ${VALID_TYPES.join(', ')}`)); + return; + } + + if (action !== 'add' && action !== 'remove') { + console.log(chalk.red('\nBulk mode only supports "add" and "remove" actions.\n')); + return; + } + + // In bulk mode, if no name is given, the tag is the second positional arg (name position) + const bulkTag = tag ?? name; + if (!bulkTag) { + console.log(chalk.red(`\nUsage: vault tag ${action} --type \n`)); + return; + } + + const allEntries = vault.getAllEntries(); + const filtered = allEntries.filter((e) => e.type === (opts.type as EntryType)); + + if (filtered.length === 0) { + console.log(chalk.yellow(`\nNo entries found of type "${opts.type}".\n`)); + return; + } + + for (const entry of filtered) { + if (action === 'add') { + vault.addTag(entry.id, bulkTag); + } else { + vault.removeTag(entry.id, bulkTag); + } + } + + const verb = action === 'add' ? 'Added' : 'Removed'; + const symbol = action === 'add' ? chalk.green('+') : chalk.red('-'); + console.log(''); + console.log( + ` ${symbol} ${verb} tag ${chalk.bold(bulkTag)} ${action === 'add' ? 'to' : 'from'} ${chalk.bold(String(filtered.length))} ${opts.type} entries`, + ); + console.log(''); + return; + } + + // Single entry mode requires name + if (!name) { + console.log( + chalk.red( + '\nUsage: vault tag [tag] or vault tag --type \n', + ), + ); + return; + } + const results = vault.quickSearch(name, 1); if (results.length === 0) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1576fe6..56bc319 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { createRequire } from 'node:module'; import { Command } from 'commander'; import { createListCommand } from './commands/list.js'; import { createSearchCommand } from './commands/search.js'; @@ -14,24 +15,41 @@ import { createSyncCommand } from './commands/sync.js'; import { createTagCommand } from './commands/tag.js'; import { createDiffCommand } from './commands/diff.js'; import { createWatchCommand } from './commands/watch.js'; -import { createInteractiveCommand } from './commands/interactive.js'; import { createOpenCommand } from './commands/open.js'; import { createRunCommand } from './commands/run.js'; import { createBackupCommand } from './commands/backup.js'; import { createRestoreCommand } from './commands/restore.js'; import { createConfigCommand } from './commands/config.js'; import { createCompletionsCommand } from './commands/completions.js'; +import { createRegistryCommand } from './commands/registry.js'; +import { createAuditCommand } from './commands/audit.js'; + +const require = createRequire(import.meta.url); +const { version } = require('../package.json'); const program = new Command(); program .name('vault') - .version('0.1.0') + .version(version) .description('CommandVault — terminal companion for managing AI slash commands') .option('--claude-path ', 'Override ~/.claude config location') .option('--tier ', 'Search engine tier (fuse|minisearch|sqlite)') .option('--json', 'Output as JSON (for scripting)'); +program.addHelpText( + 'after', + ` +Commands grouped: + Discovery: list, search, info, stats, interactive + Management: favorite, tag, open, run + Data: export, import, sync, backup, restore, diff + Quality: audit + Registry: registry add|remove|list|search + Setup: init, config, doctor, watch, completions +`, +); + program.addCommand(createListCommand()); program.addCommand(createSearchCommand()); program.addCommand(createInfoCommand()); @@ -45,19 +63,48 @@ program.addCommand(createSyncCommand()); program.addCommand(createTagCommand()); program.addCommand(createDiffCommand()); program.addCommand(createWatchCommand()); -program.addCommand(createInteractiveCommand()); +// Interactive command is lazy-loaded because it imports heavy deps (@inquirer/prompts, ink, react) +const interactiveCmd = new Command('interactive') + .alias('i') + .description('Interactive fuzzy search mode (full TUI in terminal, legacy mode in pipes)') + .option('--tui', 'Force TUI mode') + .option('--no-tui', 'Force legacy non-interactive mode') + .action(async (_opts, command) => { + const { createInteractiveCommand } = await import('./commands/interactive.js'); + const realCmd = createInteractiveCommand(); + const args: string[] = []; + const localOpts = command.opts(); + if (localOpts.tui === true) { + args.push('--tui'); + } else if (localOpts.tui === false) { + args.push('--no-tui'); + } + await realCmd.parseAsync(args, { from: 'user' }); + }); +program.addCommand(interactiveCmd); program.addCommand(createOpenCommand()); program.addCommand(createRunCommand()); program.addCommand(createBackupCommand()); program.addCommand(createRestoreCommand()); program.addCommand(createConfigCommand()); program.addCommand(createCompletionsCommand()); +program.addCommand(createRegistryCommand()); +program.addCommand(createAuditCommand()); // Default action: launch interactive mode when no subcommand is given +program.option('--tui', 'Force TUI mode').option('--no-tui', 'Force legacy non-interactive mode'); + program.action(async (_opts, command) => { - await command.commands - .find((c: Command) => c.name() === 'interactive')! - .parseAsync([], { from: 'user' }); + const { createInteractiveCommand } = await import('./commands/interactive.js'); + const realCmd = createInteractiveCommand(); + const globalOpts = command.opts(); + const args: string[] = []; + if (globalOpts.tui === true) { + args.push('--tui'); + } else if (globalOpts.tui === false) { + args.push('--no-tui'); + } + await realCmd.parseAsync(args, { from: 'user' }); }); program.parseAsync(process.argv).catch((error: unknown) => { diff --git a/packages/core/package.json b/packages/core/package.json index adda62e..0e490ff 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@commandvault/core", - "version": "0.1.0", + "version": "0.1.7", "description": "Core engine for CommandVault — parsers, three-tier search, file watcher", "author": "nothumanslabs", "license": "MIT", @@ -50,6 +50,7 @@ "test": "vitest run" }, "dependencies": { + "better-sqlite3": "^12.10.0", "chokidar": "^4.0.0", "fuse.js": "^7.1.0", "gray-matter": "^4.0.3", @@ -57,6 +58,7 @@ "sql.js": "^1.14.1" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", "typescript": "^5.8.0", "vitest": "^3.1.0" diff --git a/packages/core/src/__tests__/error-paths.test.ts b/packages/core/src/__tests__/error-paths.test.ts new file mode 100644 index 0000000..3f3377d --- /dev/null +++ b/packages/core/src/__tests__/error-paths.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomBytes } from 'node:crypto'; +import { SqliteEngine } from '../indexer/sqlite-engine.js'; +import { parseSkills } from '../parsers/skill-parser.js'; +import { parseHooks } from '../parsers/hook-parser.js'; +import { parseRules } from '../parsers/rule-parser.js'; +import { parseAgents } from '../parsers/agent-parser.js'; +import type { VaultEntry } from '../types/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEntry(overrides: Partial = {}): VaultEntry { + return { + id: overrides.id ?? `test-${Math.random().toString(36).slice(2, 8)}`, + name: overrides.name ?? 'test-entry', + type: overrides.type ?? 'skill', + source: overrides.source ?? 'custom', + description: overrides.description ?? 'A test entry', + filePath: overrides.filePath ?? '/tmp/test.md', + tags: overrides.tags ?? ['test'], + metadata: overrides.metadata ?? {}, + content: overrides.content ?? 'test content', + lastModified: overrides.lastModified ?? new Date(), + favorite: overrides.favorite ?? false, + usageCount: overrides.usageCount ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Error Path Tests +// --------------------------------------------------------------------------- + +describe('Error Paths', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'cv-error-paths-test-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + // ========================================================================= + // Corrupt SQLite Database + // ========================================================================= + + describe('Corrupt SQLite DB', () => { + it('archives corrupt file and starts fresh database', async () => { + const dbPath = join(tempDir, 'corrupt.db'); + + // Write random bytes to simulate a corrupt database file + await writeFile(dbPath, randomBytes(1024)); + + // SqliteEngine.create should handle the corrupt file gracefully + const engine = await SqliteEngine.create(dbPath); + + // Should be able to use the fresh database normally + engine.index([makeEntry({ id: 'e1', name: 'fresh-entry' })]); + const entry = engine.getEntry('e1'); + expect(entry).toBeDefined(); + expect(entry!.name).toBe('fresh-entry'); + + engine.close(); + }); + + it('fresh database after corruption has working schema', async () => { + const dbPath = join(tempDir, 'corrupt2.db'); + + // Write invalid bytes + await writeFile(dbPath, Buffer.from('not a database file at all')); + + const engine = await SqliteEngine.create(dbPath); + + // Verify all operations work on the fresh DB + engine.index([makeEntry({ id: 'e1', name: 'tool', tags: ['alpha'] })]); + engine.toggleFavorite('e1'); + engine.incrementUsage('e1'); + engine.addTag('e1', 'user-tag'); + + const entry = engine.getEntry('e1'); + expect(entry!.favorite).toBe(true); + expect(entry!.usageCount).toBe(1); + expect(entry!.tags).toContain('user-tag'); + + engine.close(); + }); + }); + + // ========================================================================= + // Malformed YAML Frontmatter + // ========================================================================= + + describe('Malformed YAML Frontmatter', () => { + it('parser returns error entry for malformed frontmatter without crashing', async () => { + const skillsDir = join(tempDir, 'skills'); + const malformedDir = join(skillsDir, 'broken-skill'); + await mkdir(malformedDir, { recursive: true }); + + // Write a SKILL.md with broken YAML (unclosed frontmatter) + const malformedContent = `--- +name: broken +description: [unclosed bracket + invalid: yaml: : : content +--- + +# Some content +`; + await writeFile(join(malformedDir, 'SKILL.md'), malformedContent); + + const result = await parseSkills(skillsDir); + + // gray-matter is lenient with malformed YAML — it may still parse, + // but the parser should not throw + expect(result).toBeDefined(); + // Either we get an entry (gray-matter tolerates it) or an error + const totalResults = result.entries.length + result.errors.length; + expect(totalResults).toBeGreaterThanOrEqual(0); + }); + + it('parser handles file with only frontmatter delimiters', async () => { + const skillsDir = join(tempDir, 'skills-empty-fm'); + const emptyFmDir = join(skillsDir, 'empty-fm'); + await mkdir(emptyFmDir, { recursive: true }); + + await writeFile(join(emptyFmDir, 'SKILL.md'), '---\n---\n'); + + const result = await parseSkills(skillsDir); + + // Should not crash; may produce an entry with defaults + expect(result).toBeDefined(); + if (result.entries.length > 0) { + expect(result.entries[0].name).toBe('empty-fm'); // Falls back to dir name + } + }); + }); + + // ========================================================================= + // Empty/Binary Files in Skills Directory + // ========================================================================= + + describe('Empty/Binary Files', () => { + it('parser skips directory without SKILL.md gracefully', async () => { + const skillsDir = join(tempDir, 'skills-no-md'); + const noMdDir = join(skillsDir, 'empty-dir'); + await mkdir(noMdDir, { recursive: true }); + + // Create a binary file instead of SKILL.md + await writeFile(join(noMdDir, 'random.bin'), randomBytes(512)); + + const result = await parseSkills(skillsDir); + + // Should return empty entries (ENOENT for SKILL.md is silently skipped) + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('parser handles empty SKILL.md file', async () => { + const skillsDir = join(tempDir, 'skills-empty'); + const emptyDir = join(skillsDir, 'empty-skill'); + await mkdir(emptyDir, { recursive: true }); + + await writeFile(join(emptyDir, 'SKILL.md'), ''); + + const result = await parseSkills(skillsDir); + + // Should not crash; empty file may produce entry with defaults + expect(result).toBeDefined(); + if (result.entries.length > 0) { + expect(result.entries[0].name).toBe('empty-skill'); + expect(result.entries[0].content).toBe(''); + } + }); + }); + + // ========================================================================= + // Missing Directory Passed to Parser + // ========================================================================= + + describe('Missing Directory', () => { + it('parseSkills returns empty results for non-existent directory', async () => { + const result = await parseSkills(join(tempDir, 'does-not-exist')); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('not found'); + }); + + it('parseRules returns empty results for non-existent directory', async () => { + const result = await parseRules(join(tempDir, 'no-rules-here')); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('not found'); + }); + + it('parseAgents returns empty results for non-existent directory', async () => { + const result = await parseAgents(join(tempDir, 'no-agents-here')); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('not found'); + }); + }); + + // ========================================================================= + // File Disappears (ENOENT during read) + // ========================================================================= + + describe('File Disappears Between Scan and Read', () => { + it('parser handles ENOENT for a skill that exists in readdir but not on disk', async () => { + const skillsDir = join(tempDir, 'skills-vanish'); + const vanishDir = join(skillsDir, 'vanishing-skill'); + await mkdir(vanishDir, { recursive: true }); + + // Do NOT create SKILL.md — simulates file disappearing + // The parser will try to read it and get ENOENT + + const result = await parseSkills(skillsDir); + + // ENOENT is explicitly handled — no entries, no errors + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + }); + + // ========================================================================= + // Extremely Long File Content + // ========================================================================= + + describe('Extremely Long File Content', () => { + it('handles very long content without OOM (truncation in engines)', async () => { + const skillsDir = join(tempDir, 'skills-long'); + const longDir = join(skillsDir, 'long-skill'); + await mkdir(longDir, { recursive: true }); + + // Generate a large file (100KB) — well beyond the 500-char truncation in engines + const longContent = `--- +name: long-skill +description: A skill with very long content +--- + +# Long Skill + +${'Lorem ipsum dolor sit amet. '.repeat(5000)}`; + + await writeFile(join(longDir, 'SKILL.md'), longContent); + + const result = await parseSkills(skillsDir); + + expect(result.errors).toHaveLength(0); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].name).toBe('long-skill'); + // Content is stored in full by the parser + expect(result.entries[0].content.length).toBeGreaterThan(500); + + // Now verify the search engines handle indexing without OOM + const dbPath = join(tempDir, 'long-test.db'); + const engine = await SqliteEngine.create(dbPath); + engine.index(result.entries); + + // Retrieve by id to confirm indexing worked despite large content + const retrieved = engine.getEntry(result.entries[0].id); + expect(retrieved).toBeDefined(); + expect(retrieved!.name).toBe('long-skill'); + expect(retrieved!.content.length).toBeGreaterThan(500); + + engine.close(); + }); + }); + + // ========================================================================= + // Invalid JSON in settings.json (Hook Parser) + // ========================================================================= + + describe('Invalid JSON in settings.json', () => { + it('hook parser returns error for completely invalid JSON', async () => { + const settingsPath = join(tempDir, 'bad-settings.json'); + await writeFile(settingsPath, 'this is not json {{{[[['); + + const result = await parseHooks(settingsPath); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('not found'); + }); + + it('hook parser throws on JSON with non-iterable hooks structure', async () => { + const settingsPath = join(tempDir, 'weird-hooks.json'); + // Valid JSON but hooks matchers are a string instead of an array + await writeFile( + settingsPath, + JSON.stringify({ + hooks: { + PreToolUse: 'not-an-array', + }, + }), + ); + + // The parser iterates over PreToolUse with for..of — a non-iterable string + // will iterate char-by-char, then .hooks on each char is undefined, causing TypeError + await expect(parseHooks(settingsPath)).rejects.toThrow(); + }); + + it('hook parser handles missing hooks key gracefully', async () => { + const settingsPath = join(tempDir, 'no-hooks.json'); + await writeFile(settingsPath, JSON.stringify({ permissions: ['Bash'] })); + + const result = await parseHooks(settingsPath); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + }); + + // ========================================================================= + // Database Busy / Locked + // ========================================================================= + + describe('Database Concurrency', () => { + it('handles sequential writes from separate connections without crashing', async () => { + const dbPath = join(tempDir, 'concurrent.db'); + const engine1 = await SqliteEngine.create(dbPath); + + // Write entries, close, then reopen with a second connection + engine1.index([ + makeEntry({ id: 'e1', name: 'from-engine1' }), + makeEntry({ id: 'e2', name: 'shared-entry' }), + ]); + engine1.close(); + + const engine2 = await SqliteEngine.create(dbPath); + + // Engine2 should see the entries written by engine1 + const entry1 = engine2.getEntry('e1'); + const entry2 = engine2.getEntry('e2'); + + expect(entry1).toBeDefined(); + expect(entry1!.name).toBe('from-engine1'); + expect(entry2).toBeDefined(); + expect(entry2!.name).toBe('shared-entry'); + + engine2.close(); + }); + + it('database remains usable after close and reopen', async () => { + const dbPath = join(tempDir, 'reopen.db'); + const engine1 = await SqliteEngine.create(dbPath); + engine1.index([makeEntry({ id: 'e1', name: 'persisted' })]); + engine1.close(); + + const engine2 = await SqliteEngine.create(dbPath); + const entry = engine2.getEntry('e1'); + expect(entry).toBeDefined(); + expect(entry!.name).toBe('persisted'); + engine2.close(); + }); + }); +}); diff --git a/packages/core/src/__tests__/multi-agent-parser.test.ts b/packages/core/src/__tests__/multi-agent-parser.test.ts new file mode 100644 index 0000000..cb68ad9 --- /dev/null +++ b/packages/core/src/__tests__/multi-agent-parser.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { join } from 'node:path'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { detectAgentConfigs } from '../parsers/multi-agent-parser.js'; + +let tempDir: string; + +beforeAll(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'commandvault-multiagent-')); +}); + +afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Cursor Configs +// --------------------------------------------------------------------------- +describe('detectAgentConfigs — Cursor', () => { + let projectRoot: string; + + beforeAll(async () => { + projectRoot = join(tempDir, 'cursor-project'); + await mkdir(projectRoot, { recursive: true }); + + // .cursorrules in project root + await writeFile( + join(projectRoot, '.cursorrules'), + '# Cursor Rules\n\nAlways use TypeScript strict mode.\n', + ); + + // .cursor/rules/ directory with markdown files + const cursorRulesDir = join(projectRoot, '.cursor', 'rules'); + await mkdir(cursorRulesDir, { recursive: true }); + await writeFile( + join(cursorRulesDir, 'code-style.md'), + '---\nname: Code Style\ndescription: Enforce consistent code style\n---\n\n# Code Style\n\nUse 2-space indentation.\n', + ); + await writeFile( + join(cursorRulesDir, 'testing-rules.md'), + '# Testing Rules\n\nAlways write tests before implementation.\n', + ); + }); + + it('parses .cursorrules file as rule with source cursor', async () => { + const result = await detectAgentConfigs(projectRoot); + const rootRule = result.entries.find((e) => e.filePath.endsWith('.cursorrules')); + + expect(rootRule).toBeDefined(); + expect(rootRule!.type).toBe('rule'); + expect(rootRule!.source).toBe('cursor'); + expect(rootRule!.name).toBe('Cursor Rules (project root)'); + expect(rootRule!.content).toContain('TypeScript strict mode'); + expect(rootRule!.tags).toContain('cursor'); + expect(rootRule!.tags).toContain('ai-agent-config'); + }); + + it('parses .cursor/rules/*.md files as rule entries', async () => { + const result = await detectAgentConfigs(projectRoot); + const dirRules = result.entries.filter((e) => e.filePath.includes('.cursor/rules')); + + expect(dirRules).toHaveLength(2); + expect(dirRules.every((e) => e.type === 'rule')).toBe(true); + expect(dirRules.every((e) => e.source === 'cursor')).toBe(true); + }); + + it('extracts frontmatter name and description from cursor rules', async () => { + const result = await detectAgentConfigs(projectRoot); + const codeStyle = result.entries.find((e) => e.name === 'Code Style'); + + expect(codeStyle).toBeDefined(); + expect(codeStyle!.description).toBe('Enforce consistent code style'); + }); + + it('generates deterministic IDs across invocations', async () => { + const r1 = await detectAgentConfigs(projectRoot); + const r2 = await detectAgentConfigs(projectRoot); + const ids1 = r1.entries.map((e) => e.id).sort(); + const ids2 = r2.entries.map((e) => e.id).sort(); + expect(ids1).toEqual(ids2); + }); +}); + +// --------------------------------------------------------------------------- +// Copilot Configs +// --------------------------------------------------------------------------- +describe('detectAgentConfigs — Copilot', () => { + let projectRoot: string; + + beforeAll(async () => { + projectRoot = join(tempDir, 'copilot-project'); + await mkdir(join(projectRoot, '.github'), { recursive: true }); + await writeFile( + join(projectRoot, '.github', 'copilot-instructions.md'), + '# Copilot Instructions\n\nPrefer functional patterns over imperative code.\n', + ); + }); + + it('parses .github/copilot-instructions.md as rule with source copilot', async () => { + const result = await detectAgentConfigs(projectRoot); + const entry = result.entries.find((e) => e.source === 'copilot'); + + expect(entry).toBeDefined(); + expect(entry!.type).toBe('rule'); + expect(entry!.source).toBe('copilot'); + expect(entry!.name).toBe('Copilot Instructions'); + expect(entry!.content).toContain('functional patterns'); + expect(entry!.tags).toContain('copilot'); + expect(entry!.tags).toContain('ai-agent-config'); + }); +}); + +// --------------------------------------------------------------------------- +// Windsurf Configs +// --------------------------------------------------------------------------- +describe('detectAgentConfigs — Windsurf', () => { + let projectRoot: string; + + beforeAll(async () => { + projectRoot = join(tempDir, 'windsurf-project'); + await mkdir(projectRoot, { recursive: true }); + await writeFile( + join(projectRoot, '.windsurfrules'), + '# Windsurf Rules\n\nUse error boundaries in all React components.\n', + ); + }); + + it('parses .windsurfrules as rule with source windsurf', async () => { + const result = await detectAgentConfigs(projectRoot); + const entry = result.entries.find((e) => e.source === 'windsurf'); + + expect(entry).toBeDefined(); + expect(entry!.type).toBe('rule'); + expect(entry!.source).toBe('windsurf'); + expect(entry!.name).toBe('Windsurf Rules (project root)'); + expect(entry!.content).toContain('error boundaries'); + expect(entry!.tags).toContain('windsurf'); + expect(entry!.tags).toContain('ai-agent-config'); + }); +}); + +// --------------------------------------------------------------------------- +// Aider Configs +// --------------------------------------------------------------------------- +describe('detectAgentConfigs — Aider', () => { + let projectRoot: string; + + beforeAll(async () => { + projectRoot = join(tempDir, 'aider-project'); + await mkdir(projectRoot, { recursive: true }); + await writeFile( + join(projectRoot, '.aider.conf.yml'), + '# Aider configuration for this project\nmodel: claude-3-opus\nauto-commits: false\n', + ); + }); + + it('parses .aider.conf.yml as rule with source aider', async () => { + const result = await detectAgentConfigs(projectRoot); + const entry = result.entries.find( + (e) => e.source === 'aider' && e.filePath.includes(projectRoot), + ); + + expect(entry).toBeDefined(); + expect(entry!.type).toBe('rule'); + expect(entry!.source).toBe('aider'); + expect(entry!.name).toBe('Aider Config (project)'); + expect(entry!.content).toContain('auto-commits: false'); + expect(entry!.tags).toContain('aider'); + expect(entry!.tags).toContain('ai-agent-config'); + }); + + it('extracts description from first YAML comment', async () => { + const result = await detectAgentConfigs(projectRoot); + const entry = result.entries.find( + (e) => e.source === 'aider' && e.filePath.includes(projectRoot), + ); + + expect(entry!.description).toBe('Aider configuration for this project'); + }); +}); + +// --------------------------------------------------------------------------- +// Continue.dev Configs +// --------------------------------------------------------------------------- +describe('detectAgentConfigs — Continue', () => { + let projectRoot: string; + + beforeAll(async () => { + projectRoot = join(tempDir, 'continue-project'); + const continueDir = join(tempDir, 'fake-home-continue', '.continue'); + await mkdir(continueDir, { recursive: true }); + await writeFile( + join(continueDir, 'config.json'), + JSON.stringify( + { + description: 'Continue.dev development configuration', + models: [{ provider: 'anthropic', model: 'claude-3-opus' }], + tabAutocompleteModel: { provider: 'ollama', model: 'codellama' }, + }, + null, + 2, + ), + ); + }); + + it('parses continue config.json with correct metadata', async () => { + // This test verifies parsing of a Continue config when present in the homedir. + // Since the parser uses homedir(), we test structure expectations via a real + // homedir-based detection attempt. The entry may not exist if ~/.continue/config.json + // is absent, so we verify no crash and correct result shape. + const result = await detectAgentConfigs(projectRoot); + + expect(result).toHaveProperty('entries'); + expect(result).toHaveProperty('errors'); + expect(Array.isArray(result.entries)).toBe(true); + expect(Array.isArray(result.errors)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Edge Cases +// --------------------------------------------------------------------------- +describe('detectAgentConfigs — edge cases', () => { + it('returns empty results for a directory with no agent configs', async () => { + const emptyRoot = join(tempDir, 'empty-project'); + await mkdir(emptyRoot, { recursive: true }); + + const result = await detectAgentConfigs(emptyRoot); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('does not crash on a nonexistent project root', async () => { + const result = await detectAgentConfigs(join(tempDir, 'does-not-exist')); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('handles malformed JSON gracefully with errors array', async () => { + const malformedRoot = join(tempDir, 'malformed-project'); + const continueDir = join(malformedRoot, '.continue'); + await mkdir(continueDir, { recursive: true }); + // Create invalid JSON that will be "found" but fail to parse meaningfully + await writeFile(join(continueDir, 'config.json'), '{ invalid json content !!!'); + + // The parser won't scan .continue in project root for single-file (it uses homedir), + // but testing .cursorrules with binary content + await writeFile(join(malformedRoot, '.cursorrules'), Buffer.from([0x00, 0x01, 0x02, 0xff])); + + const result = await detectAgentConfigs(malformedRoot); + + // Should not throw — graceful handling + expect(result).toHaveProperty('entries'); + expect(result).toHaveProperty('errors'); + }); + + it('detects multiple configs in the same project directory', async () => { + const multiRoot = join(tempDir, 'multi-config-project'); + await mkdir(join(multiRoot, '.github'), { recursive: true }); + await mkdir(join(multiRoot, '.cursor', 'rules'), { recursive: true }); + + await writeFile(join(multiRoot, '.cursorrules'), '# Cursor project rules\n'); + await writeFile( + join(multiRoot, '.cursor', 'rules', 'naming.md'), + '# Naming Conventions\n\nUse camelCase.\n', + ); + await writeFile( + join(multiRoot, '.github', 'copilot-instructions.md'), + '# Copilot\n\nFollow team standards.\n', + ); + await writeFile(join(multiRoot, '.windsurfrules'), '# Windsurf\n\nUse TypeScript.\n'); + await writeFile( + join(multiRoot, '.aider.conf.yml'), + '# Multi-tool project aider config\nmodel: gpt-4\n', + ); + + const result = await detectAgentConfigs(multiRoot); + + const sources = new Set(result.entries.map((e) => e.source)); + expect(sources.has('cursor')).toBe(true); + expect(sources.has('copilot')).toBe(true); + expect(sources.has('windsurf')).toBe(true); + expect(sources.has('aider')).toBe(true); + + // At least 5 entries: .cursorrules + naming.md + copilot + windsurf + aider + expect(result.entries.length).toBeGreaterThanOrEqual(5); + }); + + it('all entries have required fields populated', async () => { + const multiRoot = join(tempDir, 'multi-config-project'); + const result = await detectAgentConfigs(multiRoot); + + for (const entry of result.entries) { + expect(entry.id).toMatch(/^[a-f0-9]{12}$/); + expect(entry.name.length).toBeGreaterThan(0); + expect(entry.type).toBe('rule'); + expect(entry.filePath.length).toBeGreaterThan(0); + expect(entry.tags.length).toBeGreaterThan(0); + expect(entry.favorite).toBe(false); + expect(entry.usageCount).toBe(0); + expect(entry.lastModified).toBeInstanceOf(Date); + } + }); +}); diff --git a/packages/core/src/__tests__/query-parser.test.ts b/packages/core/src/__tests__/query-parser.test.ts new file mode 100644 index 0000000..0eef607 --- /dev/null +++ b/packages/core/src/__tests__/query-parser.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import { parseQuery, applyQueryFilters, type ParsedQuery } from '../indexer/query-parser.js'; + +// --------------------------------------------------------------------------- +// parseQuery +// --------------------------------------------------------------------------- + +describe('parseQuery', () => { + it('treats plain query with no operators as regular terms', () => { + const result = parseQuery('deploy server'); + expect(result.terms).toEqual(['deploy', 'server']); + expect(result.exactTerms).toEqual([]); + expect(result.prefixTerms).toEqual([]); + expect(result.excludeTerms).toEqual([]); + expect(result.filters).toEqual({}); + }); + + it('parses exact match operator', () => { + const result = parseQuery("'deploy"); + expect(result.exactTerms).toEqual(['deploy']); + expect(result.terms).toEqual([]); + }); + + it('parses prefix match operator', () => { + const result = parseQuery('^pre'); + expect(result.prefixTerms).toEqual(['pre']); + expect(result.terms).toEqual([]); + }); + + it('parses exclude operator', () => { + const result = parseQuery('!exclude'); + expect(result.excludeTerms).toEqual(['exclude']); + expect(result.terms).toEqual([]); + }); + + it('parses tag filter', () => { + const result = parseQuery('tag:security'); + expect(result.filters.tags).toEqual(['security']); + expect(result.terms).toEqual([]); + }); + + it('parses type filter', () => { + const result = parseQuery('type:skill'); + expect(result.filters.type).toBe('skill'); + expect(result.terms).toEqual([]); + }); + + it('parses source filter', () => { + const result = parseQuery('source:gstack'); + expect(result.filters.source).toBe('gstack'); + expect(result.terms).toEqual([]); + }); + + it('parses mixed query with all operators', () => { + const result = parseQuery("deploy 'exact !bad type:skill"); + expect(result.terms).toEqual(['deploy']); + expect(result.exactTerms).toEqual(['exact']); + expect(result.excludeTerms).toEqual(['bad']); + expect(result.filters.type).toBe('skill'); + }); + + it('returns empty arrays for empty query', () => { + const result = parseQuery(''); + expect(result.terms).toEqual([]); + expect(result.exactTerms).toEqual([]); + expect(result.prefixTerms).toEqual([]); + expect(result.excludeTerms).toEqual([]); + expect(result.filters).toEqual({}); + }); + + it('returns empty arrays for whitespace-only query', () => { + const result = parseQuery(' '); + expect(result.terms).toEqual([]); + expect(result.exactTerms).toEqual([]); + expect(result.prefixTerms).toEqual([]); + expect(result.excludeTerms).toEqual([]); + expect(result.filters).toEqual({}); + }); + + it('collects multiple tags', () => { + const result = parseQuery('tag:security tag:auth'); + expect(result.filters.tags).toEqual(['security', 'auth']); + }); + + it('ignores bare operator prefix with no value', () => { + const result = parseQuery("' ^ !"); + // Single-char tokens (just the operator) should be treated as regular terms + expect(result.terms).toEqual(["'", '^', '!']); + expect(result.exactTerms).toEqual([]); + expect(result.prefixTerms).toEqual([]); + expect(result.excludeTerms).toEqual([]); + }); + + it('handles colon in value without matching a known filter key', () => { + const result = parseQuery('foo:bar'); + expect(result.terms).toEqual(['foo:bar']); + expect(result.filters).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// applyQueryFilters +// --------------------------------------------------------------------------- + +describe('applyQueryFilters', () => { + const entries = [ + { name: 'deploy-server', description: 'Deploy to production', content: 'handles deploys' }, + { name: 'browse', description: 'Headless browser QA', content: 'navigate URLs' }, + { name: 'security-scan', description: 'Run OWASP checks', content: 'check vulnerabilities' }, + { name: 'prefix-match', description: 'Starts with prefix', content: 'testing prefix' }, + ]; + + it('returns all entries when no query filters are active', () => { + const parsed: ParsedQuery = { + terms: ['foo'], + exactTerms: [], + prefixTerms: [], + excludeTerms: [], + filters: {}, + }; + const result = applyQueryFilters(entries, parsed); + expect(result).toHaveLength(4); + }); + + it('filters by exact term (case-insensitive)', () => { + const parsed: ParsedQuery = { + terms: [], + exactTerms: ['OWASP'], + prefixTerms: [], + excludeTerms: [], + filters: {}, + }; + const result = applyQueryFilters(entries, parsed); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('security-scan'); + }); + + it('filters by prefix term on name', () => { + const parsed: ParsedQuery = { + terms: [], + exactTerms: [], + prefixTerms: ['deploy'], + excludeTerms: [], + filters: {}, + }; + const result = applyQueryFilters(entries, parsed); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('deploy-server'); + }); + + it('filters by prefix term on description', () => { + const parsed: ParsedQuery = { + terms: [], + exactTerms: [], + prefixTerms: ['Starts'], + excludeTerms: [], + filters: {}, + }; + const result = applyQueryFilters(entries, parsed); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('prefix-match'); + }); + + it('excludes entries containing the exclude term', () => { + const parsed: ParsedQuery = { + terms: [], + exactTerms: [], + prefixTerms: [], + excludeTerms: ['browser'], + filters: {}, + }; + const result = applyQueryFilters(entries, parsed); + expect(result).toHaveLength(3); + expect(result.find((e) => e.name === 'browse')).toBeUndefined(); + }); + + it('applies multiple filters together', () => { + const parsed: ParsedQuery = { + terms: [], + exactTerms: ['deploy'], + prefixTerms: [], + excludeTerms: ['production'], + filters: {}, + }; + // "deploy-server" has "deploy" in name but "production" in description → excluded + const result = applyQueryFilters(entries, parsed); + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/core/src/__tests__/search-engine.test.ts b/packages/core/src/__tests__/search-engine.test.ts new file mode 100644 index 0000000..68bb285 --- /dev/null +++ b/packages/core/src/__tests__/search-engine.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { SearchEngine } from '../indexer/search-engine.js'; +import type { VaultEntry } from '../types/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEntry(overrides: Partial & { id: string; name: string }): VaultEntry { + return { + type: 'skill', + source: 'custom', + description: '', + filePath: `/fake/${overrides.name}.md`, + tags: [], + metadata: {}, + content: '', + lastModified: new Date(), + favorite: false, + usageCount: 0, + ...overrides, + }; +} + +const TEST_ENTRIES: readonly VaultEntry[] = [ + makeEntry({ + id: '001', + name: 'browse', + type: 'skill', + source: 'gstack', + description: 'Headless browser for QA testing', + tags: ['browser', 'qa', 'testing'], + content: 'Navigate URLs and interact with elements', + usageCount: 42, + favorite: true, + }), + makeEntry({ + id: '002', + name: 'review', + type: 'skill', + source: 'gstack', + description: 'Pre-landing PR review for code quality', + tags: ['review', 'code'], + content: 'Analyze diff for SQL safety and trust boundaries', + usageCount: 30, + }), + makeEntry({ + id: '003', + name: 'bmad-create-prd', + type: 'skill', + source: 'bmad', + description: 'Create a product requirements document', + tags: ['planning', 'prd'], + content: 'Guided PRD creation with requirements discovery', + usageCount: 15, + }), + makeEntry({ + id: '004', + name: 'security-scan', + type: 'command', + source: 'mindforge', + description: 'Run OWASP security scan on changed files', + tags: ['security', 'owasp'], + content: 'Scans for vulnerabilities', + usageCount: 20, + }), + makeEntry({ + id: '005', + name: 'deploy-hook', + type: 'hook', + source: 'custom', + description: 'PostToolUse hook that triggers deploys', + tags: ['hook', 'deploy'], + content: '// deploy script', + usageCount: 5, + }), +]; + +// --------------------------------------------------------------------------- +// SearchEngine Orchestrator Tests +// --------------------------------------------------------------------------- + +describe('SearchEngine', () => { + let engine: SearchEngine; + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'cv-search-engine-test-')); + engine = await SearchEngine.create(join(tempDir, 'test.db'), 'fuse'); + engine.index(TEST_ENTRIES); + }); + + afterEach(async () => { + engine.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + // ========================================================================= + // Tier Routing + // ========================================================================= + + describe('Tier Routing', () => { + it('routes to Fuse engine when tier is explicitly fuse', () => { + const results = engine.search({ query: 'browse', tier: 'fuse' }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].entry.name).toBe('browse'); + }); + + it('routes to MiniSearch engine when tier is explicitly minisearch', () => { + const results = engine.search({ query: 'browse', tier: 'minisearch' }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].entry.name).toBe('browse'); + }); + + it('routes to SQLite engine when tier is explicitly sqlite', () => { + const results = engine.search({ query: 'browse', tier: 'sqlite' }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].entry.name).toBe('browse'); + }); + + it('uses the default tier (fuse) when no tier is specified', async () => { + // Create engine with minisearch as default + const msEngine = await SearchEngine.create(join(tempDir, 'ms.db'), 'minisearch'); + msEngine.index(TEST_ENTRIES); + + const results = msEngine.search({ query: 'browse' }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].entry.name).toBe('browse'); + + msEngine.close(); + }); + + it('returns consistent results across all tiers for exact name match', () => { + const fuseResults = engine.search({ query: 'security', tier: 'fuse' }); + const miniResults = engine.search({ query: 'security', tier: 'minisearch' }); + const sqliteResults = engine.search({ query: 'security', tier: 'sqlite' }); + + // All tiers should find the security-scan entry (via name/description/tags match) + const fuseNames = fuseResults.map((r) => r.entry.name); + const miniNames = miniResults.map((r) => r.entry.name); + const sqliteNames = sqliteResults.map((r) => r.entry.name); + + expect(fuseNames).toContain('security-scan'); + expect(miniNames).toContain('security-scan'); + expect(sqliteNames).toContain('security-scan'); + }); + }); + + // ========================================================================= + // LRU Cache Behavior + // ========================================================================= + + describe('LRU Cache', () => { + it('returns cached results on second identical query', () => { + const first = engine.search({ query: 'browse', tier: 'fuse' }); + const second = engine.search({ query: 'browse', tier: 'fuse' }); + + // Results should be reference-equal (same cached array) + expect(first).toBe(second); + }); + + it('invalidates cache when index() is called', () => { + const first = engine.search({ query: 'browse', tier: 'fuse' }); + + // Re-index with modified entries + const updatedEntries = TEST_ENTRIES.map((e) => + e.id === '001' ? { ...e, description: 'Updated browser tool' } : e, + ); + engine.index(updatedEntries); + + const second = engine.search({ query: 'browse', tier: 'fuse' }); + + // Should NOT be reference-equal (cache was cleared) + expect(first).not.toBe(second); + }); + + it('returns fresh results after TTL expires', () => { + vi.useFakeTimers(); + + const first = engine.search({ query: 'browse', tier: 'fuse' }); + + // Advance time beyond the 30s TTL + vi.advanceTimersByTime(31_000); + + const second = engine.search({ query: 'browse', tier: 'fuse' }); + + // Should NOT be reference-equal (TTL expired) + expect(first).not.toBe(second); + // But results should contain the same data + expect(second.length).toBeGreaterThan(0); + expect(second[0].entry.name).toBe('browse'); + + vi.useRealTimers(); + }); + + it('caches different queries independently', () => { + const browseResults = engine.search({ query: 'browse', tier: 'fuse' }); + const reviewResults = engine.search({ query: 'review', tier: 'fuse' }); + + // Both should be cached independently + expect(engine.search({ query: 'browse', tier: 'fuse' })).toBe(browseResults); + expect(engine.search({ query: 'review', tier: 'fuse' })).toBe(reviewResults); + }); + }); + + // ========================================================================= + // Lazy Initialization + // ========================================================================= + + describe('Lazy Initialization', () => { + it('does not initialize Fuse engine until first fuse query', async () => { + const lazyEngine = await SearchEngine.create(join(tempDir, 'lazy.db'), 'sqlite'); + lazyEngine.index(TEST_ENTRIES); + + // SQLite queries should work without initializing Fuse/MiniSearch + const sqlResults = lazyEngine.search({ query: 'browse', tier: 'sqlite' }); + expect(sqlResults.length).toBeGreaterThan(0); + + // Now trigger Fuse initialization + const fuseResults = lazyEngine.search({ query: 'browse', tier: 'fuse' }); + expect(fuseResults.length).toBeGreaterThan(0); + + lazyEngine.close(); + }); + + it('does not initialize MiniSearch engine until first minisearch query', async () => { + const lazyEngine = await SearchEngine.create(join(tempDir, 'lazy2.db'), 'sqlite'); + lazyEngine.index(TEST_ENTRIES); + + // SQLite queries should work without initializing MiniSearch + const sqlResults = lazyEngine.search({ query: 'review', tier: 'sqlite' }); + expect(sqlResults.length).toBeGreaterThan(0); + + // Now trigger MiniSearch initialization + const miniResults = lazyEngine.search({ query: 'review', tier: 'minisearch' }); + expect(miniResults.length).toBeGreaterThan(0); + + lazyEngine.close(); + }); + }); + + // ========================================================================= + // suggest() and auxiliary methods + // ========================================================================= + + describe('suggest()', () => { + it('returns autocomplete suggestions based on indexed entries', () => { + const suggestions = engine.suggest('brow'); + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions[0]).toMatch(/brow/i); + }); + + it('respects the limit parameter', () => { + const suggestions = engine.suggest('s', 2); + expect(suggestions.length).toBeLessThanOrEqual(2); + }); + + it('returns empty array for non-matching prefix', () => { + const suggestions = engine.suggest('zzzznonexistent'); + expect(suggestions).toHaveLength(0); + }); + }); + + // ========================================================================= + // Delegated operations + // ========================================================================= + + describe('Delegated Operations', () => { + it('toggleFavorite clears the cache', () => { + const first = engine.search({ query: '', tier: 'sqlite' }); + engine.toggleFavorite('001'); + const second = engine.search({ query: '', tier: 'sqlite' }); + + // Cache should have been cleared + expect(first).not.toBe(second); + }); + + it('getStats returns entry statistics', () => { + const stats = engine.getStats(); + expect(stats.totalEntries).toBe(TEST_ENTRIES.length); + }); + + it('getEntry retrieves a specific entry by id', () => { + const entry = engine.getEntry('001'); + expect(entry).toBeDefined(); + expect(entry!.name).toBe('browse'); + }); + + it('getEntry returns undefined for nonexistent id', () => { + expect(engine.getEntry('nonexistent')).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/__tests__/sync.test.ts b/packages/core/src/__tests__/sync.test.ts new file mode 100644 index 0000000..b8f6691 --- /dev/null +++ b/packages/core/src/__tests__/sync.test.ts @@ -0,0 +1,543 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + exportEntries, + exportToFile, + importFromFile, + importFromUrl, + type VaultExportBundle, + type ExportedEntry, +} from '../sync/index.js'; +import type { VaultEntry } from '../types/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEntry(overrides: Partial = {}): VaultEntry { + return { + id: overrides.id ?? `test-${Math.random().toString(36).slice(2, 8)}`, + name: overrides.name ?? 'test-entry', + type: overrides.type ?? 'skill', + source: overrides.source ?? 'custom', + description: overrides.description ?? 'A test entry', + filePath: overrides.filePath ?? '/tmp/test.md', + tags: overrides.tags ?? ['test'], + metadata: overrides.metadata ?? {}, + content: overrides.content ?? 'test content', + lastModified: overrides.lastModified ?? new Date('2024-01-01'), + favorite: overrides.favorite ?? false, + usageCount: overrides.usageCount ?? 0, + }; +} + +function makeValidBundle(overrides: Partial = {}): VaultExportBundle { + const entries: readonly ExportedEntry[] = overrides.entries ?? [ + { + name: 'deploy-tool', + type: 'skill', + source: 'gstack', + description: 'Deploys applications', + tags: ['deploy', 'ci'], + metadata: {}, + content: 'deploy command content', + }, + ]; + return { + version: overrides.version ?? '1.0', + exportedAt: overrides.exportedAt ?? '2024-06-15T10:00:00.000Z', + source: overrides.source ?? 'test-vault', + totalEntries: overrides.totalEntries ?? entries.length, + entries, + }; +} + +// --------------------------------------------------------------------------- +// Test Suite +// --------------------------------------------------------------------------- + +describe('Sync Module', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'vault-sync-test-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + // ========================================================================= + // Export + // ========================================================================= + + describe('exportEntries()', () => { + it('returns a valid VaultBundle structure', () => { + const entries = [makeEntry({ name: 'alpha' }), makeEntry({ name: 'beta' })]; + + const bundle = exportEntries(entries, 'my-vault'); + + expect(bundle.version).toBe('1.0'); + expect(bundle.source).toBe('my-vault'); + expect(bundle.totalEntries).toBe(2); + expect(Array.isArray(bundle.entries)).toBe(true); + expect(bundle.entries).toHaveLength(2); + }); + + it('includes an ISO timestamp in exportedAt', () => { + const entries = [makeEntry()]; + + const bundle = exportEntries(entries, 'test'); + + expect(bundle.exportedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + // Verify it parses as a valid date + expect(new Date(bundle.exportedAt).getTime()).not.toBeNaN(); + }); + + it('serializes entry fields correctly (no id, filePath, lastModified, favorite, usageCount)', () => { + const entry = makeEntry({ + id: 'should-not-appear', + name: 'my-skill', + type: 'agent', + source: 'bmad', + description: 'does things', + filePath: '/some/path.md', + tags: ['tag1', 'tag2'], + metadata: { author: 'test' }, + content: 'body content', + favorite: true, + usageCount: 42, + }); + + const bundle = exportEntries([entry], 'source'); + const exported = bundle.entries[0]; + + expect(exported.name).toBe('my-skill'); + expect(exported.type).toBe('agent'); + expect(exported.source).toBe('bmad'); + expect(exported.description).toBe('does things'); + expect(exported.tags).toEqual(['tag1', 'tag2']); + expect(exported.metadata).toEqual({ author: 'test' }); + expect(exported.content).toBe('body content'); + // These fields should NOT be in exported entry + const raw = exported as unknown as Record; + expect(raw['id']).toBeUndefined(); + expect(raw['filePath']).toBeUndefined(); + expect(raw['lastModified']).toBeUndefined(); + expect(raw['favorite']).toBeUndefined(); + expect(raw['usageCount']).toBeUndefined(); + }); + + it('handles empty entries array', () => { + const bundle = exportEntries([], 'empty-vault'); + + expect(bundle.totalEntries).toBe(0); + expect(bundle.entries).toHaveLength(0); + }); + }); + + describe('exportToFile()', () => { + it('writes a valid JSON file and returns entry count', async () => { + const entries = [makeEntry({ name: 'tool-a' }), makeEntry({ name: 'tool-b' })]; + const outputPath = join(tempDir, 'export.json'); + + const count = await exportToFile(entries, outputPath, 'test-export'); + + expect(count).toBe(2); + const raw = await readFile(outputPath, 'utf-8'); + const parsed = JSON.parse(raw) as VaultExportBundle; + expect(parsed.version).toBe('1.0'); + expect(parsed.entries).toHaveLength(2); + }); + }); + + // ========================================================================= + // Import from File + // ========================================================================= + + describe('importFromFile()', () => { + it('reads and parses a valid bundle file', async () => { + const bundle = makeValidBundle(); + const filePath = join(tempDir, 'valid-bundle.json'); + await writeFile(filePath, JSON.stringify(bundle), 'utf-8'); + + const result = await importFromFile(filePath); + + expect(result.errors).toHaveLength(0); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].name).toBe('deploy-tool'); + expect(result.entries[0].type).toBe('skill'); + expect(result.entries[0].source).toBe('gstack'); + expect(result.entries[0].tags).toContain('imported'); + expect(result.entries[0].tags).toContain('from:test-vault'); + }); + + it('returns error for non-existent file', async () => { + const result = await importFromFile('/nonexistent/path.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Cannot read file'); + }); + + it('rejects malformed JSON', async () => { + const filePath = join(tempDir, 'bad.json'); + await writeFile(filePath, '{ this is not json !!!', 'utf-8'); + + const result = await importFromFile(filePath); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Invalid JSON format'); + }); + + it('rejects bundles with missing version field', async () => { + const filePath = join(tempDir, 'no-version.json'); + await writeFile(filePath, JSON.stringify({ entries: [] }), 'utf-8'); + + const result = await importFromFile(filePath); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('missing version or entries'); + }); + + it('rejects bundles with missing entries field', async () => { + const filePath = join(tempDir, 'no-entries.json'); + await writeFile(filePath, JSON.stringify({ version: '1.0' }), 'utf-8'); + + const result = await importFromFile(filePath); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('missing version or entries'); + }); + + it('skips entries with missing name or type', async () => { + const bundle = makeValidBundle({ + entries: [ + { + name: '', + type: 'skill', + source: 'custom', + description: '', + tags: [], + metadata: {}, + content: '', + }, + { + name: 'valid', + type: 'skill', + source: 'custom', + description: 'ok', + tags: [], + metadata: {}, + content: 'body', + }, + ], + }); + const filePath = join(tempDir, 'partial.json'); + await writeFile(filePath, JSON.stringify(bundle), 'utf-8'); + + const result = await importFromFile(filePath); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].name).toBe('valid'); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('missing name or type'); + }); + + it('rejects entries with invalid type', async () => { + const bundle = makeValidBundle({ + entries: [ + { + name: 'bad-type', + type: 'invalid-type' as VaultEntry['type'], + source: 'custom', + description: '', + tags: [], + metadata: {}, + content: '', + }, + ], + }); + const filePath = join(tempDir, 'bad-type.json'); + await writeFile(filePath, JSON.stringify(bundle), 'utf-8'); + + const result = await importFromFile(filePath); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Invalid entry type'); + }); + + it('falls back to "custom" source for unrecognized source values', async () => { + const bundle = makeValidBundle({ + entries: [ + { + name: 'unknown-src', + type: 'hook', + source: 'totally-unknown' as VaultEntry['source'], + description: '', + tags: [], + metadata: {}, + content: '', + }, + ], + }); + const filePath = join(tempDir, 'unknown-source.json'); + await writeFile(filePath, JSON.stringify(bundle), 'utf-8'); + + const result = await importFromFile(filePath); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].source).toBe('custom'); + }); + }); + + // ========================================================================= + // URL Validation (SSRF Protection) + // ========================================================================= + + describe('URL validation (SSRF protection)', () => { + it('rejects localhost URL', async () => { + const result = await importFromUrl('https://localhost/bundle.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Blocked'); + expect(result.errors[0].message).toContain('localhost'); + }); + + it('rejects 127.0.0.1', async () => { + const result = await importFromUrl('https://127.0.0.1/bundle.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('Blocked'); + }); + + it('rejects IPv6 loopback [::1]', async () => { + const result = await importFromUrl('https://[::1]/bundle.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('Blocked'); + }); + + it('rejects private 10.x.x.x network', async () => { + const result = await importFromUrl('https://10.0.0.1/bundle.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('Blocked'); + }); + + it('rejects private 172.16-31.x.x network', async () => { + const result = await importFromUrl('https://172.16.0.1/bundle.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('Blocked'); + }); + + it('rejects private 192.168.x.x network', async () => { + const result = await importFromUrl('https://192.168.1.1/bundle.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('Blocked'); + }); + + it('rejects cloud metadata endpoint 169.254.169.254', async () => { + const result = await importFromUrl('https://169.254.169.254/latest/meta-data/'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('Blocked'); + }); + + it('rejects non-HTTPS protocols (HTTP)', async () => { + const result = await importFromUrl('http://example.com/bundle.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('Only HTTPS URLs are supported'); + }); + + it('rejects file:// protocol', async () => { + const result = await importFromUrl('file:///etc/passwd'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('URL validation failed'); + }); + }); + + // ========================================================================= + // Import from URL + // ========================================================================= + + describe('importFromUrl()', () => { + it('successfully fetches and parses a valid remote bundle', async () => { + const bundle = makeValidBundle(); + const mockResponse = new Response(JSON.stringify(bundle), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const result = await importFromUrl('https://example.com/vault-bundle.json'); + + expect(result.errors).toHaveLength(0); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].name).toBe('deploy-tool'); + expect(result.entries[0].tags).toContain('synced'); + expect(result.entries[0].tags).toContain('from:example.com'); + }); + + it('returns error for non-200 HTTP responses', async () => { + const mockResponse = new Response('Not Found', { + status: 404, + statusText: 'Not Found', + }); + vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const result = await importFromUrl('https://example.com/missing.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('HTTP 404'); + }); + + it('rejects responses over size limit (content-length header)', async () => { + const mockResponse = new Response('', { + status: 200, + headers: { 'content-length': String(11 * 1024 * 1024) }, // 11MB + }); + vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const result = await importFromUrl('https://example.com/huge.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('too large'); + }); + + it('rejects responses over size limit (body exceeds 10MB)', async () => { + const hugeText = 'x'.repeat(11 * 1024 * 1024); + const mockResponse = new Response(hugeText, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const result = await importFromUrl('https://example.com/huge-body.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('10MB'); + }); + + it('returns error for invalid JSON response', async () => { + const mockResponse = new Response('not json at all', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const result = await importFromUrl('https://example.com/bad.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('not valid JSON'); + }); + + it('returns error for JSON that is not a valid bundle', async () => { + const mockResponse = new Response(JSON.stringify({ foo: 'bar' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const result = await importFromUrl('https://example.com/not-bundle.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('not a valid CommandVault bundle'); + }); + + it('handles network errors gracefully', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network unreachable')); + + const result = await importFromUrl('https://example.com/down.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('Fetch failed'); + expect(result.errors[0].message).toContain('Network unreachable'); + }); + + it('handles abort/timeout errors', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + vi.spyOn(global, 'fetch').mockRejectedValue(abortError); + + const result = await importFromUrl('https://example.com/slow.json'); + + expect(result.entries).toHaveLength(0); + expect(result.errors[0].message).toContain('timed out'); + }); + + it('filters out entries with invalid types from remote bundle', async () => { + const bundle = makeValidBundle({ + entries: [ + { + name: 'good', + type: 'skill', + source: 'custom', + description: '', + tags: [], + metadata: {}, + content: 'ok', + }, + { + name: 'bad', + type: 'nope' as VaultEntry['type'], + source: 'custom', + description: '', + tags: [], + metadata: {}, + content: '', + }, + ], + }); + const mockResponse = new Response(JSON.stringify(bundle), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const result = await importFromUrl('https://example.com/mixed.json'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].name).toBe('good'); + }); + + it('defaults unrecognized source to "community" for URL imports', async () => { + const bundle = makeValidBundle({ + entries: [ + { + name: 'ext-tool', + type: 'plugin', + source: 'unknown-platform' as VaultEntry['source'], + description: '', + tags: [], + metadata: {}, + content: '', + }, + ], + }); + const mockResponse = new Response(JSON.stringify(bundle), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const result = await importFromUrl('https://example.com/ext.json'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].source).toBe('community'); + }); + }); +}); diff --git a/packages/core/src/__tests__/watcher.test.ts b/packages/core/src/__tests__/watcher.test.ts new file mode 100644 index 0000000..f9a8635 --- /dev/null +++ b/packages/core/src/__tests__/watcher.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { join } from 'node:path'; +import { EventEmitter } from 'node:events'; + +// Mock chokidar before importing VaultWatcher +const mockWatcher = new EventEmitter() as EventEmitter & { close: ReturnType }; +mockWatcher.close = vi.fn().mockResolvedValue(undefined); + +vi.mock('chokidar', () => ({ + watch: vi.fn(() => mockWatcher), +})); + +import { watch } from 'chokidar'; +import { VaultWatcher, type WatcherCallback } from '../watcher/index.js'; + +const CLAUDE_PATH = '/home/user/.claude'; + +describe('VaultWatcher', () => { + let watcher: VaultWatcher; + let callback: WatcherCallback; + + beforeEach(() => { + vi.clearAllMocks(); + // Remove all listeners from previous tests + mockWatcher.removeAllListeners(); + mockWatcher.close = vi.fn().mockResolvedValue(undefined); + + watcher = new VaultWatcher(CLAUDE_PATH); + callback = vi.fn(); + }); + + afterEach(async () => { + // Reset watcher state via stop + if (watcher.isWatching) { + await watcher.stop(); + } + }); + + // ------------------------------------------------------------------------- + // Basic event handling + // ------------------------------------------------------------------------- + it('triggers callback with "add" event when a file is added', () => { + watcher.start(callback); + + const filePath = join(CLAUDE_PATH, 'skills', 'new-skill', 'SKILL.md'); + mockWatcher.emit('add', filePath); + + expect(callback).toHaveBeenCalledWith('add', filePath); + }); + + it('triggers callback with "change" event when a file is modified', () => { + watcher.start(callback); + + const filePath = join(CLAUDE_PATH, 'rules', 'coding-style.md'); + mockWatcher.emit('change', filePath); + + expect(callback).toHaveBeenCalledWith('change', filePath); + }); + + it('triggers callback with "unlink" event when a file is deleted', () => { + watcher.start(callback); + + const filePath = join(CLAUDE_PATH, 'agents', 'old-agent.md'); + mockWatcher.emit('unlink', filePath); + + expect(callback).toHaveBeenCalledWith('unlink', filePath); + }); + + // ------------------------------------------------------------------------- + // Watch paths configuration + // ------------------------------------------------------------------------- + it('watches the correct glob patterns for all entry types', () => { + watcher.start(callback); + + const watchCall = vi.mocked(watch).mock.calls[0]; + const watchPaths = watchCall[0] as string[]; + + expect(watchPaths).toContain(join(CLAUDE_PATH, 'skills', '**', 'SKILL.md')); + expect(watchPaths).toContain(join(CLAUDE_PATH, 'agents', '*.md')); + expect(watchPaths).toContain(join(CLAUDE_PATH, 'commands', '**', '*.md')); + expect(watchPaths).toContain(join(CLAUDE_PATH, 'plugins', 'installed_plugins.json')); + expect(watchPaths).toContain(join(CLAUDE_PATH, 'rules', '*.md')); + expect(watchPaths).toContain(join(CLAUDE_PATH, 'settings.json')); + }); + + it('passes followSymlinks: false to chokidar options', () => { + watcher.start(callback); + + const watchCall = vi.mocked(watch).mock.calls[0]; + const options = watchCall[1] as Record; + + expect(options.followSymlinks).toBe(false); + }); + + it('passes ignoreInitial: true to chokidar options', () => { + watcher.start(callback); + + const watchCall = vi.mocked(watch).mock.calls[0]; + const options = watchCall[1] as Record; + + expect(options.ignoreInitial).toBe(true); + }); + + it('configures awaitWriteFinish for debounce stability', () => { + watcher.start(callback); + + const watchCall = vi.mocked(watch).mock.calls[0]; + const options = watchCall[1] as Record; + + expect(options.awaitWriteFinish).toEqual({ + stabilityThreshold: 300, + pollInterval: 100, + }); + }); + + // ------------------------------------------------------------------------- + // Lifecycle management + // ------------------------------------------------------------------------- + it('reports isWatching as false before start', () => { + expect(watcher.isWatching).toBe(false); + }); + + it('reports isWatching as true after start', () => { + watcher.start(callback); + expect(watcher.isWatching).toBe(true); + }); + + it('can be stopped and sets isWatching to false', async () => { + watcher.start(callback); + expect(watcher.isWatching).toBe(true); + + await watcher.stop(); + + expect(watcher.isWatching).toBe(false); + expect(mockWatcher.close).toHaveBeenCalledOnce(); + }); + + it('stop is idempotent when no watcher is active', async () => { + await watcher.stop(); + + expect(mockWatcher.close).not.toHaveBeenCalled(); + expect(watcher.isWatching).toBe(false); + }); + + it('does not create a second watcher if start is called twice', () => { + watcher.start(callback); + watcher.start(callback); + + expect(vi.mocked(watch)).toHaveBeenCalledTimes(1); + }); + + // ------------------------------------------------------------------------- + // Multiple events + // ------------------------------------------------------------------------- + it('handles multiple sequential events correctly', () => { + watcher.start(callback); + + const path1 = join(CLAUDE_PATH, 'rules', 'a.md'); + const path2 = join(CLAUDE_PATH, 'rules', 'b.md'); + const path3 = join(CLAUDE_PATH, 'agents', 'c.md'); + + mockWatcher.emit('add', path1); + mockWatcher.emit('change', path2); + mockWatcher.emit('unlink', path3); + + expect(callback).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenNthCalledWith(1, 'add', path1); + expect(callback).toHaveBeenNthCalledWith(2, 'change', path2); + expect(callback).toHaveBeenNthCalledWith(3, 'unlink', path3); + }); + + it('does not invoke callback after stop is called', async () => { + watcher.start(callback); + await watcher.stop(); + + // After stop, the internal watcher reference is null so even if mock emits, + // the real watcher would not forward events. We verify stop was called. + expect(mockWatcher.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 00e57c7..c4b4c4e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,3 +37,12 @@ export type { } from './types/index.js'; export { getContentExcerpt } from './utils/excerpt.js'; export type { ContentExcerpt } from './utils/excerpt.js'; +export { RegistryManager, JsonRegistryAdapter } from './registry/index.js'; +export type { + RegistryAdapter, + RegistryConfig, + RegistryEntry, + RegistrySearchResult, +} from './registry/types.js'; +export { detectStaleness, type StalenessResult } from './indexer/staleness-detector.js'; +export { scoreEntries, type QualityScore } from './indexer/quality-scorer.js'; diff --git a/packages/core/src/indexer/better-sqlite-adapter.ts b/packages/core/src/indexer/better-sqlite-adapter.ts new file mode 100644 index 0000000..adf380e --- /dev/null +++ b/packages/core/src/indexer/better-sqlite-adapter.ts @@ -0,0 +1,101 @@ +import { chmodSync, existsSync, mkdirSync, renameSync } from 'node:fs'; +import { dirname } from 'node:path'; +import Database from 'better-sqlite3'; +import type { DatabaseAdapter, DatabaseAdapterOptions } from './database-adapter.js'; + +const DEFAULT_BUSY_TIMEOUT = 5000; + +export class BetterSqliteAdapter implements DatabaseAdapter { + readonly path: string; + private readonly db: Database.Database; + + private constructor(db: Database.Database, dbPath: string) { + this.db = db; + this.path = dbPath; + } + + static async create( + dbPath: string, + options?: DatabaseAdapterOptions, + ): Promise { + return new BetterSqliteAdapter(openDatabase(dbPath, options), dbPath); + } + + queryAll(sql: string, params: Record = {}): T[] { + const stmt = this.db.prepare(sql); + return stmt.all(stripParamPrefix(params)) as T[]; + } + + queryOne(sql: string, params: Record = {}): T | undefined { + const stmt = this.db.prepare(sql); + return stmt.get(stripParamPrefix(params)) as T | undefined; + } + + execute(sql: string, params: Record = {}): void { + const hasParams = Object.keys(params).length > 0; + if (hasParams) { + const stmt = this.db.prepare(sql); + stmt.run(stripParamPrefix(params)); + } else { + // DDL, PRAGMAs, and multi-statement SQL require db.exec (not prepare) + this.db.exec(sql); // better-sqlite3 Database.exec, not child_process + } + } + + transaction(fn: () => T): T { + const wrapped = this.db.transaction(fn); + return wrapped(); + } + + close(): void { + this.db.close(); + } +} + +/** better-sqlite3 expects param keys without $ prefix (SQL uses $id, binding uses id) */ +function stripParamPrefix(params: Record): Record { + const stripped: Record = {}; + for (const [key, value] of Object.entries(params)) { + stripped[key.startsWith('$') ? key.slice(1) : key] = value; + } + return stripped; +} + +function openDatabase(dbPath: string, options?: DatabaseAdapterOptions): Database.Database { + const walMode = options?.walMode ?? true; + const busyTimeout = options?.busyTimeout ?? DEFAULT_BUSY_TIMEOUT; + const readonly = options?.readonly ?? false; + + const dir = dirname(dbPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + if (existsSync(dbPath)) { + try { + return configureDatabase(new Database(dbPath, { readonly }), walMode, busyTimeout); + } catch { + // DB file is corrupt — archive it and start fresh + const timestamp = Date.now(); + const corruptPath = `${dbPath}.corrupt.${timestamp}.bak`; + renameSync(dbPath, corruptPath); + } + } + + const db = configureDatabase(new Database(dbPath), walMode, busyTimeout); + chmodSync(dbPath, 0o600); + return db; +} + +function configureDatabase( + db: Database.Database, + walMode: boolean, + busyTimeout: number, +): Database.Database { + if (walMode) { + db.pragma('journal_mode = WAL'); + } + db.pragma(`busy_timeout = ${busyTimeout}`); + db.pragma('foreign_keys = ON'); + return db; +} diff --git a/packages/core/src/indexer/database-adapter.ts b/packages/core/src/indexer/database-adapter.ts new file mode 100644 index 0000000..b18c152 --- /dev/null +++ b/packages/core/src/indexer/database-adapter.ts @@ -0,0 +1,33 @@ +/** + * Abstract database adapter interface for CommandVault storage. + * Supports both better-sqlite3 (native) and sql.js (fallback) backends. + */ + +export interface DatabaseAdapterOptions { + /** Enable WAL journal mode (default true for better-sqlite3, ignored by sql.js) */ + readonly walMode?: boolean; + /** Milliseconds to wait when the database is locked (default 5000) */ + readonly busyTimeout?: number; + /** Open the database in read-only mode */ + readonly readonly?: boolean; +} + +export interface DatabaseAdapter { + /** The database file path */ + readonly path: string; + + /** Execute a query and return all matching rows */ + queryAll(sql: string, params?: Record): T[]; + + /** Execute a query and return the first row, or undefined if none match */ + queryOne(sql: string, params?: Record): T | undefined; + + /** Execute a statement that modifies data (INSERT, UPDATE, DELETE) */ + execute(sql: string, params?: Record): void; + + /** Wrap a set of operations in an atomic transaction */ + transaction(fn: () => T): T; + + /** Close the database connection and release resources */ + close(): void; +} diff --git a/packages/core/src/indexer/database-factory.ts b/packages/core/src/indexer/database-factory.ts new file mode 100644 index 0000000..4d2e138 --- /dev/null +++ b/packages/core/src/indexer/database-factory.ts @@ -0,0 +1,20 @@ +import type { DatabaseAdapter, DatabaseAdapterOptions } from './database-adapter.js'; +import { SqlJsAdapter } from './sqljs-adapter.js'; + +/** + * Creates the appropriate database adapter based on environment. + * Prefers better-sqlite3 (native, WAL, fast) but falls back to sql.js + * when native dependencies are unavailable. + */ +export async function createDatabaseAdapter( + dbPath: string, + options?: DatabaseAdapterOptions, +): Promise { + try { + await import('better-sqlite3'); + const { BetterSqliteAdapter } = await import('./better-sqlite-adapter.js'); + return BetterSqliteAdapter.create(dbPath, options); + } catch { + return SqlJsAdapter.create(dbPath, options); + } +} diff --git a/packages/core/src/indexer/entry-store.ts b/packages/core/src/indexer/entry-store.ts index d562c9f..6180517 100644 --- a/packages/core/src/indexer/entry-store.ts +++ b/packages/core/src/indexer/entry-store.ts @@ -1,5 +1,5 @@ import type { VaultEntry, SearchResult, SearchOptions } from '../types/index.js'; -import type { SqliteConnection } from './sqlite-connection.js'; +import type { DatabaseAdapter } from './database-adapter.js'; interface EntryRow { id: string; @@ -49,7 +49,7 @@ function rowToEntry( }; } -function buildTagMaps(conn: SqliteConnection): { +function buildTagMaps(conn: DatabaseAdapter): { entryTagMap: Map; userTagMap: Map; } { @@ -76,19 +76,38 @@ function buildTagMaps(conn: SqliteConnection): { } export class EntryStore { - private readonly conn: SqliteConnection; + private readonly conn: DatabaseAdapter; + private tagMapCache: { + entryTagMap: Map; + userTagMap: Map; + } | null = null; - constructor(conn: SqliteConnection) { + constructor(conn: DatabaseAdapter) { this.conn = conn; } + /** Invalidate the cached tag maps. Call after any tag mutation or re-index. */ + invalidateTagCache(): void { + this.tagMapCache = null; + } + + private getTagMaps(): { entryTagMap: Map; userTagMap: Map } { + if (this.tagMapCache === null) { + this.tagMapCache = buildTagMaps(this.conn); + } + return this.tagMapCache; + } + index(entries: readonly VaultEntry[]): void { - const existingRows = this.conn.queryAll<{ id: string }>('SELECT id FROM entries'); - const existingIds = new Set(existingRows.map((r) => r.id)); + // Batch-load existing favorite/usage_count to avoid N+1 query + const existingRows = this.conn.queryAll<{ id: string; favorite: number; usage_count: number }>( + 'SELECT id, favorite, usage_count FROM entries', + ); + const existingMap = new Map(existingRows.map((r) => [r.id, r])); + const existingIds = new Set(existingMap.keys()); const newIds = new Set(entries.map((e) => e.id)); - this.conn.db.run('BEGIN'); - try { + this.conn.transaction(() => { for (const id of existingIds) { if (!newIds.has(id)) { this.conn.execute('DELETE FROM entries WHERE id = $id', { $id: id }); @@ -97,10 +116,7 @@ export class EntryStore { } for (const entry of entries) { - const existing = this.conn.queryOne<{ favorite: number; usage_count: number }>( - 'SELECT favorite, usage_count FROM entries WHERE id = $id', - { $id: entry.id }, - ); + const existing = existingMap.get(entry.id); this.conn.execute( `INSERT OR REPLACE INTO entries @@ -133,17 +149,116 @@ export class EntryStore { } } } + }); - this.conn.db.run('COMMIT'); - } catch (e) { - this.conn.db.run('ROLLBACK'); - throw e; - } + this.invalidateTagCache(); + + // Rebuild FTS5 index after entries transaction + this.rebuildFts(); + } - this.conn.persist(); + /** Rebuild the FTS5 index from the entries table. Gracefully no-ops if FTS5 is unavailable. */ + private rebuildFts(): void { + try { + this.conn.execute('DELETE FROM entries_fts'); + this.conn.execute(` + INSERT INTO entries_fts(id, name, description, content, tags) + SELECT id, name, description, content, tags FROM entries + `); + } catch { + // FTS5 table may not exist yet (pre-migration) — silently skip + } } search(options: SearchOptions): SearchResult[] { + const queryText = options.query.trim(); + const hasTextQuery = queryText.length > 0; + + // Attempt FTS5 search when a text query is present + if (hasTextQuery) { + try { + return this.searchFts(options, queryText); + } catch { + // FTS5 MATCH failed (malformed query or table missing) — fall back to LIKE + } + } + + return this.searchLike(options); + } + + /** FTS5-based search using MATCH for full-text relevance ranking. */ + private searchFts(options: SearchOptions, queryText: string): SearchResult[] { + const conditions: string[] = []; + const params: Record = {}; + + // Build FTS5 match expression: sanitize each word and join with AND + const sanitized = queryText.split(/\s+/).map(sanitizeFtsToken).filter(Boolean); + if (sanitized.length === 0) { + return this.searchLike(options); + } + // Use prefix matching with * for better partial-word matches + const ftsQuery = sanitized.map((w) => `"${w}"*`).join(' AND '); + params.$ftsQuery = ftsQuery; + + // Apply non-text filters on the entries table + if (options.type) { + conditions.push('e.type = $type'); + params.$type = options.type; + } + if (options.source) { + conditions.push('e.source = $source'); + params.$source = options.source; + } + if (options.favoritesOnly) { + conditions.push('e.favorite = 1'); + } + if (options.tags && options.tags.length > 0) { + for (let i = 0; i < options.tags.length; i++) { + const paramName = `$tag${i}`; + conditions.push( + `(EXISTS (SELECT 1 FROM entry_tags WHERE entry_id = e.id AND tag = ${paramName}) OR EXISTS (SELECT 1 FROM user_tags WHERE entry_id = e.id AND tag = ${paramName}))`, + ); + params[paramName] = options.tags[i]; + } + } + if (options.modifiedAfter) { + conditions.push('e.last_modified >= $modifiedAfter'); + params.$modifiedAfter = options.modifiedAfter.toISOString(); + } + if (options.modifiedBefore) { + conditions.push('e.last_modified <= $modifiedBefore'); + params.$modifiedBefore = options.modifiedBefore.toISOString(); + } + + const filterClause = conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : ''; + const limit = options.limit ?? 50; + params.$limit = limit; + + const offsetClause = options.offset ? 'OFFSET $offset' : ''; + if (options.offset) { + params.$offset = options.offset; + } + + const sql = ` + SELECT e.* FROM entries e + JOIN entries_fts f ON e.id = f.id + WHERE entries_fts MATCH $ftsQuery ${filterClause} + ORDER BY f.rank, e.usage_count DESC, e.name ASC + LIMIT $limit ${offsetClause} + `; + + const rows = this.conn.queryAll(sql, params); + const { entryTagMap, userTagMap } = this.getTagMaps(); + + return rows.map((row, idx) => ({ + entry: rowToEntry(row, entryTagMap, userTagMap), + score: 1 - idx / Math.max(rows.length, 1), + matchedFields: ['name', 'description', 'content'], + })); + } + + /** Fallback LIKE-based search for when FTS5 is unavailable or query is malformed. */ + private searchLike(options: SearchOptions): SearchResult[] { const conditions: string[] = []; const params: Record = {}; @@ -203,7 +318,7 @@ export class EntryStore { const sql = `SELECT * FROM entries ${where} ${orderBy} LIMIT $limit ${offsetClause}`; const rows = this.conn.queryAll(sql, params); - const { entryTagMap, userTagMap } = buildTagMaps(this.conn); + const { entryTagMap, userTagMap } = this.getTagMaps(); return rows.map((row, idx) => ({ entry: rowToEntry(row, entryTagMap, userTagMap), @@ -223,7 +338,6 @@ export class EntryStore { $fav: newVal, $id: id, }); - this.conn.persist(); return newVal === 1; } @@ -231,7 +345,6 @@ export class EntryStore { this.conn.execute('UPDATE entries SET usage_count = usage_count + 1 WHERE id = $id', { $id: id, }); - this.conn.persist(); } getEntry(id: string): VaultEntry | undefined { @@ -264,7 +377,7 @@ export class EntryStore { getEntries(): VaultEntry[] { const rows = this.conn.queryAll('SELECT * FROM entries'); - const { entryTagMap, userTagMap } = buildTagMaps(this.conn); + const { entryTagMap, userTagMap } = this.getTagMaps(); return rows.map((row) => rowToEntry(row, entryTagMap, userTagMap)); } } diff --git a/packages/core/src/indexer/filter-utils.ts b/packages/core/src/indexer/filter-utils.ts new file mode 100644 index 0000000..d4e29d7 --- /dev/null +++ b/packages/core/src/indexer/filter-utils.ts @@ -0,0 +1,24 @@ +import type { VaultEntry, SearchOptions } from '../types/index.js'; + +/** + * Shared filter predicate used by both Fuse and MiniSearch engines. + * Returns true if the entry matches all active filter criteria. + */ +export function matchesFilters(entry: VaultEntry, options: SearchOptions): boolean { + if (options.type && entry.type !== options.type) return false; + if (options.source && entry.source !== options.source) return false; + if (options.favoritesOnly && !entry.favorite) return false; + if (options.tags && options.tags.length > 0) { + if (!options.tags.every((t) => entry.tags.includes(t))) return false; + } + if (options.modifiedAfter && entry.lastModified < options.modifiedAfter) return false; + if (options.modifiedBefore && entry.lastModified > options.modifiedBefore) return false; + return true; +} + +/** + * Applies filters to an array of entries, returning only those that match. + */ +export function applyFilters(entries: readonly VaultEntry[], options: SearchOptions): VaultEntry[] { + return entries.filter((e) => matchesFilters(e, options)); +} diff --git a/packages/core/src/indexer/fuse-engine.ts b/packages/core/src/indexer/fuse-engine.ts index a9f74cf..d1c4ae6 100644 --- a/packages/core/src/indexer/fuse-engine.ts +++ b/packages/core/src/indexer/fuse-engine.ts @@ -1,7 +1,16 @@ import Fuse, { type IFuseOptions } from 'fuse.js'; import type { VaultEntry, SearchResult, SearchOptions } from '../types/index.js'; +import { matchesFilters, applyFilters } from './filter-utils.js'; +import { parseQuery, applyQueryFilters } from './query-parser.js'; -const FUSE_OPTIONS: IFuseOptions = { +/** Maximum content length indexed by Fuse to avoid bloating the in-memory index. */ +const CONTENT_TRUNCATE_LENGTH = 500; + +interface TruncatedEntry extends VaultEntry { + readonly content: string; +} + +const FUSE_OPTIONS: IFuseOptions = { keys: [ { name: 'name', weight: 3 }, { name: 'description', weight: 2 }, @@ -28,57 +37,78 @@ function buildFilterKey(options: SearchOptions): string { }); } -function matchesDateRange(entry: VaultEntry, options: SearchOptions): boolean { - if (options.modifiedAfter && entry.lastModified < options.modifiedAfter) return false; - if (options.modifiedBefore && entry.lastModified > options.modifiedBefore) return false; - return true; -} - export class FuseEngine { - private fuse: Fuse; - private entries: VaultEntry[]; - private filterCache: Map> = new Map(); + private fuse: Fuse; + private entries: TruncatedEntry[]; + private filterCache: Map> = new Map(); constructor() { this.entries = []; - this.fuse = new Fuse([] as VaultEntry[], FUSE_OPTIONS); + this.fuse = new Fuse([] as TruncatedEntry[], FUSE_OPTIONS); } index(entries: readonly VaultEntry[]): void { - this.entries = [...entries]; - this.fuse = new Fuse(this.entries as VaultEntry[], FUSE_OPTIONS); + this.entries = entries.map((e) => ({ + ...e, + content: e.content.slice(0, CONTENT_TRUNCATE_LENGTH), + })); + this.fuse = new Fuse(this.entries, FUSE_OPTIONS); this.filterCache.clear(); } search(options: SearchOptions): SearchResult[] { + const parsed = parseQuery(options.query); + + // Merge inline filters from query operators into search options + const mergedOptions: SearchOptions = { + ...options, + ...(parsed.filters.type ? { type: parsed.filters.type as SearchOptions['type'] } : {}), + ...(parsed.filters.source + ? { source: parsed.filters.source as SearchOptions['source'] } + : {}), + ...(parsed.filters.tags ? { tags: parsed.filters.tags } : {}), + }; + const hasFilters = - options.type || - options.source || - options.tags?.length || - options.favoritesOnly || - options.modifiedAfter || - options.modifiedBefore; - const offset = options.offset ?? 0; - const limit = options.limit ?? 50; - - if (!options.query.trim()) { - const filtered = hasFilters ? this.applyFilters(this.entries, options) : this.entries; - return filtered + mergedOptions.type || + mergedOptions.source || + mergedOptions.tags?.length || + mergedOptions.favoritesOnly || + mergedOptions.modifiedAfter || + mergedOptions.modifiedBefore; + const offset = mergedOptions.offset ?? 0; + const limit = mergedOptions.limit ?? 50; + + const fuzzyQuery = parsed.terms.join(' '); + + if (!fuzzyQuery) { + const filtered = hasFilters ? applyFilters(this.entries, mergedOptions) : this.entries; + const postFiltered = applyQueryFilters(filtered, parsed); + return postFiltered .slice(offset, offset + limit) .map((entry) => ({ entry, score: 1, matchedFields: [] })); } - const fuseInstance = hasFilters ? this.getFilteredFuse(options) : this.fuse; - const results = fuseInstance.search(options.query); - - return results.slice(offset, offset + limit).map((r) => ({ - entry: r.item, - score: 1 - (r.score ?? 0), - matchedFields: r.matches?.map((m) => m.key ?? '') ?? [], - })); + const fuseInstance = hasFilters ? this.getFilteredFuse(mergedOptions) : this.fuse; + const results = fuseInstance.search(fuzzyQuery); + + const postFiltered = applyQueryFilters( + results.map((r) => r.item), + parsed, + ); + const postFilteredIds = new Set(postFiltered.map((e) => e.id)); + + return results + .filter((r) => postFilteredIds.has(r.item.id)) + .slice(offset, offset + limit) + .map((r) => ({ + entry: r.item, + score: 1 - (r.score ?? 0), + matchedFields: r.matches?.map((m) => m.key ?? '') ?? [], + })); } - private getFilteredFuse(options: SearchOptions): Fuse { + private getFilteredFuse(options: SearchOptions): Fuse { const key = buildFilterKey(options); const cached = this.filterCache.get(key); if (cached) return cached; @@ -88,31 +118,9 @@ export class FuseEngine { this.filterCache.delete(oldest); } - const filtered = this.applyFilters(this.entries, options); + const filtered = applyFilters(this.entries, options); const instance = new Fuse(filtered, FUSE_OPTIONS); this.filterCache.set(key, instance); return instance; } - - private applyFilters(entries: readonly VaultEntry[], options: SearchOptions): VaultEntry[] { - let filtered = [...entries]; - - if (options.type) { - filtered = filtered.filter((e) => e.type === options.type); - } - if (options.source) { - filtered = filtered.filter((e) => e.source === options.source); - } - if (options.tags && options.tags.length > 0) { - filtered = filtered.filter((e) => options.tags!.every((t) => e.tags.includes(t))); - } - if (options.favoritesOnly) { - filtered = filtered.filter((e) => e.favorite); - } - if (options.modifiedAfter || options.modifiedBefore) { - filtered = filtered.filter((e) => matchesDateRange(e, options)); - } - - return filtered; - } } diff --git a/packages/core/src/indexer/migrations.ts b/packages/core/src/indexer/migrations.ts deleted file mode 100644 index 3fc9708..0000000 --- a/packages/core/src/indexer/migrations.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { Database as SqlJsDatabase } from 'sql.js'; - -interface Migration { - readonly version: number; - readonly description: string; - readonly up: (db: SqlJsDatabase) => void; -} - -function stableId(type: string, name: string, source: string): string { - return createHash('sha256').update(`${type}:${name}:${source}`).digest('hex').slice(0, 12); -} - -/** Helper: run a SELECT and return rows as plain objects. */ -function queryAll>( - db: SqlJsDatabase, - sql: string, - params: Record = {}, -): T[] { - const stmt = db.prepare(sql); - if (Object.keys(params).length > 0) { - stmt.bind(params); - } - const results: T[] = []; - while (stmt.step()) { - results.push(stmt.getAsObject() as T); - } - stmt.free(); - return results; -} - -const MIGRATIONS: readonly Migration[] = [ - { - version: 1, - description: 'Add entry_tags junction table for exact tag matching', - up: (db) => { - db.run(` - CREATE TABLE IF NOT EXISTS entry_tags ( - entry_id TEXT NOT NULL, - tag TEXT NOT NULL, - PRIMARY KEY (entry_id, tag) - ); - `); - db.run(`CREATE INDEX IF NOT EXISTS idx_entry_tags_tag ON entry_tags(tag);`); - }, - }, - { - version: 2, - description: 'Migrate entry IDs from filePath-based to type+name-based', - up: (db) => { - const rows = queryAll<{ - id: string; - name: string; - type: string; - source: string; - }>(db, 'SELECT id, name, type, source FROM entries'); - - const groups = new Map(); - for (const row of rows) { - const newId = stableId(row.type, row.name, row.source); - const existing = groups.get(newId) ?? []; - existing.push(row.id); - groups.set(newId, existing); - } - - for (const [newId, oldIds] of groups) { - for (let i = 1; i < oldIds.length; i++) { - db.run('DELETE FROM entry_tags WHERE entry_id = $id', { $id: oldIds[i] }); - db.run('DELETE FROM user_tags WHERE entry_id = $id', { $id: oldIds[i] }); - db.run('DELETE FROM scan_snapshots WHERE id = $id', { $id: oldIds[i] }); - db.run('DELETE FROM entries WHERE id = $id', { $id: oldIds[i] }); - } - if (newId !== oldIds[0]) { - db.run('UPDATE entries SET id = $new WHERE id = $old', { $new: newId, $old: oldIds[0] }); - db.run('UPDATE user_tags SET entry_id = $new WHERE entry_id = $old', { - $new: newId, - $old: oldIds[0], - }); - db.run('UPDATE entry_tags SET entry_id = $new WHERE entry_id = $old', { - $new: newId, - $old: oldIds[0], - }); - db.run('UPDATE scan_snapshots SET id = $new WHERE id = $old', { - $new: newId, - $old: oldIds[0], - }); - } - } - }, - }, -]; - -function removeLegacyFts(db: SqlJsDatabase): void { - db.run('DROP TRIGGER IF EXISTS entries_ai'); - db.run('DROP TRIGGER IF EXISTS entries_ad'); - db.run('DROP TRIGGER IF EXISTS entries_au'); - for (const suffix of ['_data', '_idx', '_docsize', '_config']) { - db.run(`DROP TABLE IF EXISTS entries_fts${suffix}`); - } - // The FTS5 virtual table can't be dropped without the fts5 module loaded; - // remove it directly from sqlite_master instead. - db.run('PRAGMA writable_schema = ON'); - db.run("DELETE FROM sqlite_master WHERE name = 'entries_fts' AND type = 'table'"); - db.run('PRAGMA writable_schema = OFF'); -} - -export function runMigrations(db: SqlJsDatabase): void { - removeLegacyFts(db); - - db.run(` - CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL DEFAULT (datetime('now')), - description TEXT NOT NULL - ); - `); - - const versionRows = queryAll<{ v: number | null }>( - db, - 'SELECT MAX(version) as v FROM schema_version', - ); - const currentVersion = versionRows[0]?.v ?? 0; - - const pending = MIGRATIONS.filter((m) => m.version > currentVersion); - if (pending.length === 0) return; - - db.run('BEGIN'); - try { - for (const migration of pending) { - migration.up(db); - db.run('INSERT INTO schema_version (version, description) VALUES ($version, $description)', { - $version: migration.version, - $description: migration.description, - }); - } - db.run('COMMIT'); - } catch (e) { - db.run('ROLLBACK'); - throw e; - } -} diff --git a/packages/core/src/indexer/minisearch-engine.ts b/packages/core/src/indexer/minisearch-engine.ts index 953c5f9..03fc7ff 100644 --- a/packages/core/src/indexer/minisearch-engine.ts +++ b/packages/core/src/indexer/minisearch-engine.ts @@ -1,6 +1,8 @@ import { createHash } from 'node:crypto'; import MiniSearch from 'minisearch'; import type { VaultEntry, SearchResult, SearchOptions } from '../types/index.js'; +import { matchesFilters, applyFilters } from './filter-utils.js'; +import { parseQuery, applyQueryFilters } from './query-parser.js'; const FIELDS = ['name', 'description', 'tags', 'source', 'type', 'content']; const STORED_FIELDS = ['id']; @@ -86,18 +88,33 @@ export class MiniSearchEngine { } search(options: SearchOptions): SearchResult[] { + const parsed = parseQuery(options.query); + + // Merge inline filters from query operators into search options + const mergedOptions: SearchOptions = { + ...options, + ...(parsed.filters.type ? { type: parsed.filters.type as SearchOptions['type'] } : {}), + ...(parsed.filters.source + ? { source: parsed.filters.source as SearchOptions['source'] } + : {}), + ...(parsed.filters.tags ? { tags: parsed.filters.tags } : {}), + }; + const allEntries = [...this.entriesById.values()]; - const offset = options.offset ?? 0; - const limit = options.limit ?? 50; + const offset = mergedOptions.offset ?? 0; + const limit = mergedOptions.limit ?? 50; + + const fuzzyQuery = parsed.terms.join(' '); - if (!options.query.trim()) { - const filtered = this.applyFilters(allEntries, options); - return filtered + if (!fuzzyQuery) { + const filtered = applyFilters(allEntries, mergedOptions); + const postFiltered = applyQueryFilters(filtered, parsed); + return postFiltered .slice(offset, offset + limit) .map((entry) => ({ entry, score: 1, matchedFields: [] })); } - const rawResults = this.engine.search(options.query, { + const rawResults = this.engine.search(fuzzyQuery, { boost: BOOST, prefix: true, fuzzy: 0.2, @@ -109,7 +126,7 @@ export class MiniSearchEngine { for (const raw of rawResults) { const entry = this.entriesById.get(raw.id as string); if (!entry) continue; - if (!this.matchesFilters(entry, options)) continue; + if (!matchesFilters(entry, mergedOptions)) continue; results.push({ entry, @@ -118,7 +135,13 @@ export class MiniSearchEngine { }); } - return results.slice(offset, offset + limit); + const postFiltered = applyQueryFilters( + results.map((r) => r.entry), + parsed, + ); + const postFilteredIds = new Set(postFiltered.map((e) => e.id)); + + return results.filter((r) => postFilteredIds.has(r.entry.id)).slice(offset, offset + limit); } suggest(query: string, limit = 10): string[] { @@ -127,20 +150,4 @@ export class MiniSearchEngine { .slice(0, limit) .map((s) => s.suggestion); } - - private applyFilters(entries: VaultEntry[], options: SearchOptions): VaultEntry[] { - return entries.filter((e) => this.matchesFilters(e, options)); - } - - private matchesFilters(entry: VaultEntry, options: SearchOptions): boolean { - if (options.type && entry.type !== options.type) return false; - if (options.source && entry.source !== options.source) return false; - if (options.favoritesOnly && !entry.favorite) return false; - if (options.tags && options.tags.length > 0) { - if (!options.tags.every((t) => entry.tags.includes(t))) return false; - } - if (options.modifiedAfter && entry.lastModified < options.modifiedAfter) return false; - if (options.modifiedBefore && entry.lastModified > options.modifiedBefore) return false; - return true; - } } diff --git a/packages/core/src/indexer/quality-scorer.ts b/packages/core/src/indexer/quality-scorer.ts new file mode 100644 index 0000000..979cf36 --- /dev/null +++ b/packages/core/src/indexer/quality-scorer.ts @@ -0,0 +1,64 @@ +import type { VaultEntry } from '../types/index.js'; + +export interface QualityScore { + readonly entry: VaultEntry; + readonly score: number; + readonly breakdown: { + readonly recency: number; + readonly usage: number; + readonly completeness: number; + readonly engagement: number; + }; +} + +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +function scoreRecency(entry: VaultEntry): number { + const daysAgo = Math.floor((Date.now() - entry.lastModified.getTime()) / MS_PER_DAY); + if (daysAgo <= 0) return 25; + if (daysAgo <= 7) return 20; + if (daysAgo <= 30) return 15; + if (daysAgo <= 90) return 10; + if (daysAgo <= 365) return 5; + return 0; +} + +function scoreUsage(entry: VaultEntry, maxUsageCount: number): number { + if (maxUsageCount === 0) return 0; + return Math.round((entry.usageCount / maxUsageCount) * 25); +} + +function scoreCompleteness(entry: VaultEntry): number { + let score = 0; + if (entry.description.length > 10) score += 8; + if (entry.tags.length >= 1) score += 5; + if (entry.tags.length >= 3) score += 4; + if (entry.content.length > 50) score += 8; + return score; +} + +function scoreEngagement(entry: VaultEntry): number { + let score = 0; + if (entry.favorite) score += 15; + if (entry.usageCount > 0) score += 10; + return score; +} + +export function scoreEntries(entries: readonly VaultEntry[]): QualityScore[] { + const maxUsageCount = entries.reduce((max, e) => Math.max(max, e.usageCount), 0); + + const scores = entries.map((entry): QualityScore => { + const recency = scoreRecency(entry); + const usage = scoreUsage(entry, maxUsageCount); + const completeness = scoreCompleteness(entry); + const engagement = scoreEngagement(entry); + + return { + entry, + score: recency + usage + completeness + engagement, + breakdown: { recency, usage, completeness, engagement }, + }; + }); + + return [...scores].sort((a, b) => b.score - a.score); +} diff --git a/packages/core/src/indexer/query-parser.ts b/packages/core/src/indexer/query-parser.ts new file mode 100644 index 0000000..eb67c91 --- /dev/null +++ b/packages/core/src/indexer/query-parser.ts @@ -0,0 +1,109 @@ +/** + * fzf-style query parser for CommandVault search. + * + * Operators: + * 'term — exact match (case-insensitive substring) + * ^term — prefix match (name/description starts with) + * !term — exclude (entries containing this term are removed) + * tag:v — inline tag filter + * type:v — inline type filter + * source:v — inline source filter + * + * Everything else is treated as a regular fuzzy search term. + */ + +export interface ParsedQuery { + readonly terms: string[]; + readonly exactTerms: string[]; + readonly prefixTerms: string[]; + readonly excludeTerms: string[]; + readonly filters: { + readonly tags?: string[]; + readonly type?: string; + readonly source?: string; + }; +} + +const FILTER_PATTERN = /^(tag|type|source):(.+)$/; + +export function parseQuery(rawQuery: string): ParsedQuery { + const tokens = rawQuery.trim().split(/\s+/).filter(Boolean); + + const terms: string[] = []; + const exactTerms: string[] = []; + const prefixTerms: string[] = []; + const excludeTerms: string[] = []; + const tags: string[] = []; + let type: string | undefined; + let source: string | undefined; + + for (const token of tokens) { + if (token.startsWith("'") && token.length > 1) { + exactTerms.push(token.slice(1)); + } else if (token.startsWith('^') && token.length > 1) { + prefixTerms.push(token.slice(1)); + } else if (token.startsWith('!') && token.length > 1) { + excludeTerms.push(token.slice(1)); + } else { + const filterMatch = FILTER_PATTERN.exec(token); + if (filterMatch) { + const [, key, value] = filterMatch; + if (key === 'tag') tags.push(value); + else if (key === 'type') type = value; + else if (key === 'source') source = value; + } else { + terms.push(token); + } + } + } + + return { + terms, + exactTerms, + prefixTerms, + excludeTerms, + filters: { + ...(tags.length > 0 ? { tags } : {}), + ...(type !== undefined ? { type } : {}), + ...(source !== undefined ? { source } : {}), + }, + }; +} + +/** + * Post-filter entries based on parsed exact, prefix, and exclude terms. + * Works on the combined searchable text (name + description + content). + */ +export function applyQueryFilters( + entries: readonly T[], + parsed: ParsedQuery, +): T[] { + if ( + parsed.exactTerms.length === 0 && + parsed.prefixTerms.length === 0 && + parsed.excludeTerms.length === 0 + ) { + return [...entries]; + } + + return entries.filter((entry) => { + const searchText = `${entry.name} ${entry.description} ${entry.content}`.toLowerCase(); + const nameLower = entry.name.toLowerCase(); + const descLower = entry.description.toLowerCase(); + + for (const exact of parsed.exactTerms) { + if (!searchText.includes(exact.toLowerCase())) return false; + } + + for (const prefix of parsed.prefixTerms) { + const prefixLower = prefix.toLowerCase(); + if (!nameLower.startsWith(prefixLower) && !descLower.startsWith(prefixLower)) return false; + } + + for (const exclude of parsed.excludeTerms) { + if (searchText.includes(exclude.toLowerCase())) return false; + } + + return true; + }); +} diff --git a/packages/core/src/indexer/snapshot-store.ts b/packages/core/src/indexer/snapshot-store.ts index f9be7f6..c68fb63 100644 --- a/packages/core/src/indexer/snapshot-store.ts +++ b/packages/core/src/indexer/snapshot-store.ts @@ -1,17 +1,16 @@ import { createHash } from 'node:crypto'; import type { VaultEntry } from '../types/index.js'; -import type { SqliteConnection } from './sqlite-connection.js'; +import type { DatabaseAdapter } from './database-adapter.js'; export class SnapshotStore { - private readonly conn: SqliteConnection; + private readonly conn: DatabaseAdapter; - constructor(conn: SqliteConnection) { + constructor(conn: DatabaseAdapter) { this.conn = conn; } saveSnapshot(entries: readonly VaultEntry[]): void { - this.conn.db.run('BEGIN'); - try { + this.conn.transaction(() => { this.conn.execute('DELETE FROM scan_snapshots'); for (const entry of entries) { const hash = createHash('sha256') @@ -22,12 +21,7 @@ export class SnapshotStore { { $id: entry.id, $name: entry.name, $type: entry.type, $hash: hash }, ); } - this.conn.db.run('COMMIT'); - } catch (e) { - this.conn.db.run('ROLLBACK'); - throw e; - } - this.conn.persist(); + }); } getDiff(currentEntries: readonly VaultEntry[]): { diff --git a/packages/core/src/indexer/sqlite-connection.ts b/packages/core/src/indexer/sqlite-connection.ts deleted file mode 100644 index d95780a..0000000 --- a/packages/core/src/indexer/sqlite-connection.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; -import type { Database as SqlJsDatabase } from 'sql.js'; -import { runMigrations } from './migrations.js'; - -const SCHEMA = ` - CREATE TABLE IF NOT EXISTS entries ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL, - source TEXT NOT NULL, - description TEXT NOT NULL, - file_path TEXT NOT NULL, - tags TEXT NOT NULL, - metadata TEXT NOT NULL, - content TEXT NOT NULL, - last_modified TEXT NOT NULL, - favorite INTEGER NOT NULL DEFAULT 0, - usage_count INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS user_tags ( - entry_id TEXT NOT NULL, - tag TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY(entry_id, tag) - ); - - CREATE TABLE IF NOT EXISTS scan_snapshots ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL, - content_hash TEXT NOT NULL, - scanned_at TEXT NOT NULL DEFAULT (datetime('now')) - ); -`; - -export class SqliteConnection { - readonly db: SqlJsDatabase; - private readonly dbPath: string; - - private constructor(db: SqlJsDatabase, dbPath: string) { - this.db = db; - this.dbPath = dbPath; - } - - static async create(dbPath: string): Promise { - const sqlAsmModule = await import('sql.js/dist/sql-asm.js'); - const initSqlJs = (sqlAsmModule.default ?? sqlAsmModule) as ( - config?: Record, - ) => Promise<{ Database: new (data?: ArrayLike | Buffer | null) => SqlJsDatabase }>; - const SQL = await initSqlJs(); - let db: SqlJsDatabase; - if (existsSync(dbPath)) { - try { - const buffer = readFileSync(dbPath); - db = new SQL.Database(buffer); - } catch { - // DB file is corrupt — archive it and start fresh - const corruptPath = dbPath.replace(/\.db$/, '.corrupt'); - renameSync(dbPath, corruptPath); - db = new SQL.Database(); - } - } else { - db = new SQL.Database(); - } - db.run('PRAGMA journal_mode = DELETE'); - db.exec(SCHEMA); - const conn = new SqliteConnection(db, dbPath); - runMigrations(db); - conn.persist(); - return conn; - } - - queryAll(sql: string, params: Record = {}): T[] { - const stmt = this.db.prepare(sql); - if (Object.keys(params).length > 0) { - stmt.bind(params); - } - const results: T[] = []; - while (stmt.step()) { - results.push(stmt.getAsObject() as T); - } - stmt.free(); - return results; - } - - queryOne(sql: string, params: Record = {}): T | undefined { - const results = this.queryAll(sql, params); - return results[0]; - } - - execute(sql: string, params: Record = {}): void { - this.db.run(sql, params as Record); - } - - persist(): void { - const data = this.db.export(); - writeFileSync(this.dbPath, Buffer.from(data), { mode: 0o600 }); - } - - close(): void { - this.persist(); - this.db.close(); - } -} diff --git a/packages/core/src/indexer/sqlite-engine.ts b/packages/core/src/indexer/sqlite-engine.ts index e60148e..f4c833c 100644 --- a/packages/core/src/indexer/sqlite-engine.ts +++ b/packages/core/src/indexer/sqlite-engine.ts @@ -1,18 +1,185 @@ +import { createHash } from 'node:crypto'; import type { VaultEntry, SearchResult, SearchOptions, VaultStats } from '../types/index.js'; -import { SqliteConnection } from './sqlite-connection.js'; +import type { DatabaseAdapter } from './database-adapter.js'; +import { createDatabaseAdapter } from './database-factory.js'; import { EntryStore } from './entry-store.js'; import { TagStore } from './tag-store.js'; import { SnapshotStore } from './snapshot-store.js'; import { StatsStore } from './stats-store.js'; +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS entries ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + source TEXT NOT NULL, + description TEXT NOT NULL, + file_path TEXT NOT NULL, + tags TEXT NOT NULL, + metadata TEXT NOT NULL, + content TEXT NOT NULL, + last_modified TEXT NOT NULL, + favorite INTEGER NOT NULL DEFAULT 0, + usage_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS user_tags ( + entry_id TEXT NOT NULL, + tag TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(entry_id, tag) + ); + + CREATE TABLE IF NOT EXISTS scan_snapshots ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + content_hash TEXT NOT NULL, + scanned_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`; + +function stableId(type: string, name: string, source: string): string { + return createHash('sha256').update(`${type}:${name}:${source}`).digest('hex').slice(0, 12); +} + +function runAdapterMigrations(conn: DatabaseAdapter): void { + // Ensure schema_version table exists + conn.execute(` + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')), + description TEXT NOT NULL + ); + `); + + const versionRows = conn.queryAll<{ v: number | null }>( + 'SELECT MAX(version) as v FROM schema_version', + ); + const currentVersion = versionRows[0]?.v ?? 0; + + // Remove legacy FTS artifacts only if we haven't yet created the new FTS5 table (migration 3) + if (currentVersion < 3) { + conn.execute('DROP TRIGGER IF EXISTS entries_ai'); + conn.execute('DROP TRIGGER IF EXISTS entries_ad'); + conn.execute('DROP TRIGGER IF EXISTS entries_au'); + for (const suffix of ['_data', '_idx', '_docsize', '_config']) { + conn.execute(`DROP TABLE IF EXISTS entries_fts${suffix}`); + } + try { + conn.execute('PRAGMA writable_schema = ON'); + conn.execute("DELETE FROM sqlite_master WHERE name = 'entries_fts' AND type = 'table'"); + conn.execute('PRAGMA writable_schema = OFF'); + } catch { + // better-sqlite3 may not allow sqlite_master modification — safe to skip + // since the FTS shadow tables were already dropped above + } + } + + // Migration 1: entry_tags junction table + if (currentVersion < 1) { + conn.transaction(() => { + conn.execute(` + CREATE TABLE IF NOT EXISTS entry_tags ( + entry_id TEXT NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY (entry_id, tag) + ); + `); + conn.execute('CREATE INDEX IF NOT EXISTS idx_entry_tags_tag ON entry_tags(tag)'); + conn.execute( + "INSERT INTO schema_version (version, description) VALUES (1, 'Add entry_tags junction table for exact tag matching')", + ); + }); + } + + // Migration 2: stable IDs + if (currentVersion < 2) { + conn.transaction(() => { + const rows = conn.queryAll<{ id: string; name: string; type: string; source: string }>( + 'SELECT id, name, type, source FROM entries', + ); + + const groups = new Map(); + for (const row of rows) { + const newId = stableId(row.type, row.name, row.source); + const existing = groups.get(newId) ?? []; + groups.set(newId, [...existing, row.id]); + } + + for (const [newId, oldIds] of groups) { + for (let i = 1; i < oldIds.length; i++) { + conn.execute('DELETE FROM entry_tags WHERE entry_id = $id', { $id: oldIds[i] }); + conn.execute('DELETE FROM user_tags WHERE entry_id = $id', { $id: oldIds[i] }); + conn.execute('DELETE FROM scan_snapshots WHERE id = $id', { $id: oldIds[i] }); + conn.execute('DELETE FROM entries WHERE id = $id', { $id: oldIds[i] }); + } + if (newId !== oldIds[0]) { + conn.execute('UPDATE entries SET id = $new WHERE id = $old', { + $new: newId, + $old: oldIds[0], + }); + conn.execute('UPDATE user_tags SET entry_id = $new WHERE entry_id = $old', { + $new: newId, + $old: oldIds[0], + }); + conn.execute('UPDATE entry_tags SET entry_id = $new WHERE entry_id = $old', { + $new: newId, + $old: oldIds[0], + }); + conn.execute('UPDATE scan_snapshots SET id = $new WHERE id = $old', { + $new: newId, + $old: oldIds[0], + }); + } + } + + conn.execute( + "INSERT INTO schema_version (version, description) VALUES (2, 'Migrate entry IDs from filePath-based to type+name-based')", + ); + }); + } + + // Migration 3: FTS5 full-text search + column indexes + if (currentVersion < 3) { + conn.transaction(() => { + // Create standalone FTS5 virtual table (not content-synced to avoid rowid issues) + conn.execute(` + CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5( + id UNINDEXED, + name, + description, + content, + tags + ) + `); + + // Column indexes for common filter queries + conn.execute('CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type)'); + conn.execute('CREATE INDEX IF NOT EXISTS idx_entries_source ON entries(source)'); + conn.execute('CREATE INDEX IF NOT EXISTS idx_entries_favorite ON entries(favorite)'); + + // Populate FTS5 from existing entries + conn.execute(` + INSERT INTO entries_fts(id, name, description, content, tags) + SELECT id, name, description, content, tags FROM entries + `); + + conn.execute( + "INSERT INTO schema_version (version, description) VALUES (3, 'Add FTS5 full-text search table and column indexes')", + ); + }); + } +} + export class SqliteEngine { - private readonly conn: SqliteConnection; + private readonly conn: DatabaseAdapter; private readonly entryStore: EntryStore; private readonly tagStore: TagStore; private readonly snapshotStore: SnapshotStore; private readonly statsStore: StatsStore; - private constructor(conn: SqliteConnection) { + private constructor(conn: DatabaseAdapter) { this.conn = conn; this.entryStore = new EntryStore(conn); this.tagStore = new TagStore(conn); @@ -21,7 +188,18 @@ export class SqliteEngine { } static async create(dbPath: string): Promise { - const conn = await SqliteConnection.create(dbPath); + const conn = await createDatabaseAdapter(dbPath); + + // Initialize base schema + for (const statement of SCHEMA.split(';') + .map((s) => s.trim()) + .filter(Boolean)) { + conn.execute(statement); + } + + // Run migrations + runAdapterMigrations(conn); + return new SqliteEngine(conn); } @@ -51,10 +229,12 @@ export class SqliteEngine { addTag(entryId: string, tag: string): void { this.tagStore.addTag(entryId, tag); + this.entryStore.invalidateTagCache(); } removeTag(entryId: string, tag: string): void { this.tagStore.removeTag(entryId, tag); + this.entryStore.invalidateTagCache(); } getTagsForEntry(entryId: string): string[] { diff --git a/packages/core/src/indexer/sqljs-adapter.ts b/packages/core/src/indexer/sqljs-adapter.ts new file mode 100644 index 0000000..11a2e9c --- /dev/null +++ b/packages/core/src/indexer/sqljs-adapter.ts @@ -0,0 +1,130 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { Database as SqlJsDatabase } from 'sql.js'; +import type { DatabaseAdapter, DatabaseAdapterOptions } from './database-adapter.js'; + +const PERSIST_DEBOUNCE_MS = 2000; + +export class SqlJsAdapter implements DatabaseAdapter { + readonly path: string; + private readonly db: SqlJsDatabase; + private dirty = false; + private persistTimer: ReturnType | null = null; + + private constructor(db: SqlJsDatabase, dbPath: string) { + this.db = db; + this.path = dbPath; + } + + static async create(dbPath: string, options: DatabaseAdapterOptions = {}): Promise { + const parentDir = dirname(dbPath); + if (!existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }); + } + + const sqlAsmModule = await import('sql.js/dist/sql-asm.js'); + const initSqlJs = (sqlAsmModule.default ?? sqlAsmModule) as ( + config?: Record, + ) => Promise<{ Database: new (data?: ArrayLike | Buffer | null) => SqlJsDatabase }>; + const SQL = await initSqlJs(); + + let db: SqlJsDatabase; + if (existsSync(dbPath)) { + try { + const buffer = readFileSync(dbPath); + db = new SQL.Database(buffer); + } catch { + const corruptPath = dbPath.replace(/\.db$/, '.corrupt'); + renameSync(dbPath, corruptPath); + db = new SQL.Database(); + } + } else { + db = new SQL.Database(); + } + + if (options.walMode) { + db.run('PRAGMA journal_mode = WAL'); + } else { + db.run('PRAGMA journal_mode = DELETE'); + } + + if (options.busyTimeout) { + db.run(`PRAGMA busy_timeout = ${options.busyTimeout}`); + } + + const adapter = new SqlJsAdapter(db, dbPath); + adapter.persist(); + return adapter; + } + + queryAll(sql: string, params: Record = {}): T[] { + const stmt = this.db.prepare(sql); + if (Object.keys(params).length > 0) { + stmt.bind(params as Record); + } + const results: T[] = []; + while (stmt.step()) { + results.push(stmt.getAsObject() as T); + } + stmt.free(); + return results; + } + + queryOne(sql: string, params: Record = {}): T | undefined { + const results = this.queryAll(sql, params); + return results[0]; + } + + execute(sql: string, params: Record = {}): void { + this.db.run(sql, params as Record); + this.dirty = true; + this.persistDebounced(); + } + + transaction(fn: () => T): T { + this.db.run('BEGIN'); + try { + const result = fn(); + this.db.run('COMMIT'); + this.dirty = true; + this.persistDebounced(); + return result; + } catch (error) { + this.db.run('ROLLBACK'); + throw error; + } + } + + close(): void { + this.flushPersist(); + this.db.close(); + } + + persist(): void { + const data = this.db.export(); + writeFileSync(this.path, Buffer.from(data), { mode: 0o600 }); + this.dirty = false; + } + + private persistDebounced(): void { + if (this.persistTimer !== null) { + clearTimeout(this.persistTimer); + } + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + if (this.dirty) { + this.persist(); + } + }, PERSIST_DEBOUNCE_MS); + } + + private flushPersist(): void { + if (this.persistTimer !== null) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + if (this.dirty) { + this.persist(); + } + } +} diff --git a/packages/core/src/indexer/staleness-detector.ts b/packages/core/src/indexer/staleness-detector.ts new file mode 100644 index 0000000..be42307 --- /dev/null +++ b/packages/core/src/indexer/staleness-detector.ts @@ -0,0 +1,52 @@ +import { stat } from 'node:fs/promises'; +import type { VaultEntry } from '../types/index.js'; + +export interface StalenessResult { + readonly entry: VaultEntry; + readonly daysSinceModified: number; + readonly sourceFileExists: boolean; + readonly isStale: boolean; +} + +const BATCH_SIZE = 50; +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +async function checkEntry(entry: VaultEntry, thresholdDays: number): Promise { + try { + const fileStat = await stat(entry.filePath); + const daysSinceModified = Math.floor((Date.now() - fileStat.mtime.getTime()) / MS_PER_DAY); + return { + entry, + daysSinceModified, + sourceFileExists: true, + isStale: daysSinceModified > thresholdDays, + }; + } catch { + return { + entry, + daysSinceModified: Infinity, + sourceFileExists: false, + isStale: true, + }; + } +} + +export async function detectStaleness( + entries: readonly VaultEntry[], + thresholdDays: number = 30, +): Promise { + const results: StalenessResult[] = []; + + for (let i = 0; i < entries.length; i += BATCH_SIZE) { + const batch = entries.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all(batch.map((entry) => checkEntry(entry, thresholdDays))); + results.push(...batchResults); + } + + return [...results].sort((a, b) => { + if (a.daysSinceModified === Infinity && b.daysSinceModified === Infinity) return 0; + if (a.daysSinceModified === Infinity) return -1; + if (b.daysSinceModified === Infinity) return -1; + return b.daysSinceModified - a.daysSinceModified; + }); +} diff --git a/packages/core/src/indexer/stats-store.ts b/packages/core/src/indexer/stats-store.ts index d5614aa..57fc28e 100644 --- a/packages/core/src/indexer/stats-store.ts +++ b/packages/core/src/indexer/stats-store.ts @@ -1,10 +1,10 @@ import type { VaultStats, EntryType } from '../types/index.js'; -import type { SqliteConnection } from './sqlite-connection.js'; +import type { DatabaseAdapter } from './database-adapter.js'; export class StatsStore { - private readonly conn: SqliteConnection; + private readonly conn: DatabaseAdapter; - constructor(conn: SqliteConnection) { + constructor(conn: DatabaseAdapter) { this.conn = conn; } diff --git a/packages/core/src/indexer/tag-store.ts b/packages/core/src/indexer/tag-store.ts index e901377..c3bb704 100644 --- a/packages/core/src/indexer/tag-store.ts +++ b/packages/core/src/indexer/tag-store.ts @@ -1,9 +1,9 @@ -import type { SqliteConnection } from './sqlite-connection.js'; +import type { DatabaseAdapter } from './database-adapter.js'; export class TagStore { - private readonly conn: SqliteConnection; + private readonly conn: DatabaseAdapter; - constructor(conn: SqliteConnection) { + constructor(conn: DatabaseAdapter) { this.conn = conn; } @@ -12,7 +12,6 @@ export class TagStore { $entryId: entryId, $tag: tag, }); - this.conn.persist(); } removeTag(entryId: string, tag: string): void { @@ -20,7 +19,6 @@ export class TagStore { $entryId: entryId, $tag: tag, }); - this.conn.persist(); } getTagsForEntry(entryId: string): string[] { diff --git a/packages/core/src/parsers/hook-parser.ts b/packages/core/src/parsers/hook-parser.ts index 3807c4e..829cda4 100644 --- a/packages/core/src/parsers/hook-parser.ts +++ b/packages/core/src/parsers/hook-parser.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; -import { basename } from 'node:path'; +import { basename, dirname } from 'node:path'; import type { VaultEntry, ParserResult, ParseError } from '../types/index.js'; -import { generateStableId, getLastModified } from './utils.js'; +import { generateStableId, getLastModified, safePath } from './utils.js'; interface HookDefinition { readonly type: string; @@ -26,6 +26,11 @@ export async function parseHooks(settingsPath: string): Promise { const entries: VaultEntry[] = []; const errors: ParseError[] = []; + // Allowed roots for script path containment: + // 1. The directory containing the settings file (e.g., ~/.claude/) + // 2. The current working directory (for project-level hooks) + const allowedRoots = [dirname(settingsPath), process.cwd()] as const; + let settings: SettingsJson; try { const raw = await readFile(settingsPath, 'utf-8'); @@ -56,11 +61,19 @@ export async function parseHooks(settingsPath: string): Promise { let content = ''; let lastModified = new Date(); - try { - content = await readFile(scriptPath, 'utf-8'); - lastModified = await getLastModified(scriptPath); - } catch { - content = `// Script at: ${scriptPath}`; + + // Validate script path stays within allowed roots (path containment) + const validatedPath = await safePath(scriptPath, allowedRoots); + if (validatedPath) { + try { + content = await readFile(validatedPath, 'utf-8'); + lastModified = await getLastModified(validatedPath); + } catch { + content = `// Script at: ${scriptPath}`; + } + } else { + // Path escapes containment or doesn't exist — use command string as content + content = `// Command: ${hook.command}`; } const entry: VaultEntry = { diff --git a/packages/core/src/parsers/utils.ts b/packages/core/src/parsers/utils.ts index b03ec09..a7026b5 100644 --- a/packages/core/src/parsers/utils.ts +++ b/packages/core/src/parsers/utils.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; -import { stat } from 'node:fs/promises'; +import { stat, realpath } from 'node:fs/promises'; +import { resolve, normalize } from 'node:path'; import matter from 'gray-matter'; import type { EntrySource, ParsedFrontmatter } from '../types/index.js'; @@ -102,3 +103,43 @@ export function extractTags( return [...tags]; } + +/** + * Validates that a file path resolves within one of the allowed root directories. + * Returns the resolved real path if safe, or null if the path escapes containment. + */ +export async function safePath( + filePath: string, + allowedRoots: readonly string[], +): Promise { + try { + const resolved = await realpath(filePath); + const normalizedResolved = normalize(resolved); + for (const root of allowedRoots) { + const normalizedRoot = normalize(resolve(root)); + if ( + normalizedResolved.startsWith(normalizedRoot + '/') || + normalizedResolved === normalizedRoot + ) { + return resolved; + } + } + return null; + } catch { + return null; // File doesn't exist or can't be resolved + } +} + +const SECRET_PATTERNS = [ + /\.env($|\.)/, + /credentials/i, + /secret/i, + /\.pem$/, + /id_rsa/, + /id_ed25519/, + /\.key$/, +]; + +export function isSecretFile(fileName: string): boolean { + return SECRET_PATTERNS.some((pattern) => pattern.test(fileName)); +} diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts new file mode 100644 index 0000000..2b044d1 --- /dev/null +++ b/packages/core/src/registry/index.ts @@ -0,0 +1,79 @@ +import { JsonRegistryAdapter } from './json-adapter.js'; +import type { + RegistryAdapter, + RegistryConfig, + RegistryEntry, + RegistrySearchResult, +} from './types.js'; + +export class RegistryManager { + private readonly adapters: Map = new Map(); + + addRegistry(config: RegistryConfig): void { + const adapter = this.createAdapter(config); + this.adapters.set(config.name, adapter); + } + + removeRegistry(name: string): boolean { + return this.adapters.delete(name); + } + + getRegistries(): readonly RegistryConfig[] { + return [...this.adapters.values()].map((a) => a.config); + } + + async search( + query: string, + options?: { page?: number; limit?: number }, + ): Promise { + const limit = options?.limit ?? 20; + const page = options?.page ?? 1; + + const results = await Promise.all( + [...this.adapters.values()].map((a) => + a.search(query, options).catch( + (): RegistrySearchResult => ({ + entries: [], + total: 0, + page: 1, + pageSize: limit, + }), + ), + ), + ); + + const allEntries = results.flatMap((r) => r.entries); + const total = results.reduce((sum, r) => sum + r.total, 0); + + return { + entries: allEntries.slice(0, limit), + total, + page, + pageSize: limit, + }; + } + + async getEntry(registryName: string, entryName: string): Promise { + const adapter = this.adapters.get(registryName); + if (!adapter) return null; + return adapter.getEntry(entryName); + } + + private createAdapter(config: RegistryConfig): RegistryAdapter { + switch (config.type) { + case 'json': + case 'api': + return new JsonRegistryAdapter(config); + default: + return new JsonRegistryAdapter(config); + } + } +} + +export { JsonRegistryAdapter } from './json-adapter.js'; +export type { + RegistryAdapter, + RegistryConfig, + RegistryEntry, + RegistrySearchResult, +} from './types.js'; diff --git a/packages/core/src/registry/json-adapter.ts b/packages/core/src/registry/json-adapter.ts new file mode 100644 index 0000000..8cbbb14 --- /dev/null +++ b/packages/core/src/registry/json-adapter.ts @@ -0,0 +1,97 @@ +import type { + RegistryAdapter, + RegistryConfig, + RegistryEntry, + RegistrySearchResult, +} from './types.js'; + +export class JsonRegistryAdapter implements RegistryAdapter { + readonly config: RegistryConfig; + private cache: readonly RegistryEntry[] | null = null; + private cacheExpiry = 0; + private static readonly CACHE_TTL = 5 * 60 * 1000; + + constructor(config: RegistryConfig) { + this.config = config; + } + + async search( + query: string, + options?: { page?: number; limit?: number }, + ): Promise { + const entries = await this.fetchEntries(); + const q = query.toLowerCase(); + const matched = entries.filter( + (e) => + e.name.toLowerCase().includes(q) || + e.description.toLowerCase().includes(q) || + (e.tags?.some((t) => t.toLowerCase().includes(q)) ?? false), + ); + return this.paginate(matched, options); + } + + async getEntry(name: string): Promise { + const entries = await this.fetchEntries(); + return entries.find((e) => e.name === name) ?? null; + } + + async list(options?: { page?: number; limit?: number }): Promise { + const entries = await this.fetchEntries(); + return this.paginate([...entries], options); + } + + private async fetchEntries(): Promise { + if (this.cache && Date.now() < this.cacheExpiry) { + return this.cache; + } + + const response = await fetch(this.config.url, { + signal: AbortSignal.timeout(10_000), + redirect: 'error', + }); + + if (!response.ok) { + throw new Error(`Registry fetch failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const raw: unknown[] = Array.isArray(data) + ? data + : (((data as Record).entries as unknown[]) ?? []); + + const entries: readonly RegistryEntry[] = raw.map((e: unknown) => { + const entry = e as Record; + return { + name: String(entry.name ?? ''), + description: String(entry.description ?? ''), + type: String(entry.type ?? 'skill'), + author: entry.author ? String(entry.author) : undefined, + version: entry.version ? String(entry.version) : undefined, + tags: Array.isArray(entry.tags) ? entry.tags.map(String) : undefined, + downloads: typeof entry.downloads === 'number' ? entry.downloads : undefined, + url: String(entry.url ?? ''), + source: this.config.name, + }; + }); + + this.cache = entries; + this.cacheExpiry = Date.now() + JsonRegistryAdapter.CACHE_TTL; + + return entries; + } + + private paginate( + entries: readonly RegistryEntry[], + options?: { page?: number; limit?: number }, + ): RegistrySearchResult { + const page = options?.page ?? 1; + const limit = options?.limit ?? 20; + const start = (page - 1) * limit; + return { + entries: entries.slice(start, start + limit), + total: entries.length, + page, + pageSize: limit, + }; + } +} diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts new file mode 100644 index 0000000..77ddc01 --- /dev/null +++ b/packages/core/src/registry/types.ts @@ -0,0 +1,31 @@ +export interface RegistryEntry { + readonly name: string; + readonly description: string; + readonly type: string; + readonly author?: string; + readonly version?: string; + readonly tags?: readonly string[]; + readonly downloads?: number; + readonly url: string; + readonly source: string; +} + +export interface RegistrySearchResult { + readonly entries: readonly RegistryEntry[]; + readonly total: number; + readonly page: number; + readonly pageSize: number; +} + +export interface RegistryConfig { + readonly name: string; + readonly url: string; + readonly type: 'json' | 'api'; +} + +export interface RegistryAdapter { + readonly config: RegistryConfig; + search(query: string, options?: { page?: number; limit?: number }): Promise; + getEntry(name: string): Promise; + list(options?: { page?: number; limit?: number }): Promise; +} diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index ec37d83..801f44c 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -204,7 +204,7 @@ export async function importFromUrl(url: string): Promise { const timeoutId = setTimeout(() => controller.abort(), 15_000); try { - const response = await fetch(url, { signal: controller.signal }); + const response = await fetch(url, { signal: controller.signal, redirect: 'error' }); clearTimeout(timeoutId); if (!response.ok) { diff --git a/packages/core/src/vault.ts b/packages/core/src/vault.ts index b4a4cc9..1a36b75 100644 --- a/packages/core/src/vault.ts +++ b/packages/core/src/vault.ts @@ -38,7 +38,8 @@ export class Vault { private searchEngine: SearchEngine | null = null; private readonly watcher: VaultWatcher; private entries: VaultEntry[] = []; - private listeners: Map> = new Map(); + private listeners: Map>> = + new Map(); private scanErrors: ParseError[] = []; private pendingChanges: Map> = new Map(); private debounceTimer: ReturnType | null = null; @@ -117,11 +118,16 @@ export class Vault { allErrors.push(...result.errors); } - this.entries = allEntries; + // Deterministic ordering: sort by stable ID to avoid non-determinism from Promise.all + this.entries = [...allEntries].sort((a, b) => a.id.localeCompare(b.id)); this.scanErrors = allErrors; - this.getSearchEngine().index(allEntries); + this.getSearchEngine().index(this.entries); - this.diffAndEmit(oldEntries, this.entries); + // Skip per-entry events on first scan (oldEntries empty) to avoid 1000+ individual emissions + const isFirstScan = oldEntries.length === 0; + if (!isFirstScan) { + this.diffAndEmit(oldEntries, this.entries); + } this.emit('scan:complete', this.getStats()); for (const error of allErrors) { @@ -259,15 +265,14 @@ export class Vault { } on(event: K, handler: VaultEventHandler): void { - const key = event as string; - if (!this.listeners.has(key)) { - this.listeners.set(key, new Set()); + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); } - this.listeners.get(key)!.add(handler); + this.listeners.get(event)!.add(handler as VaultEventHandler); } off(event: K, handler: VaultEventHandler): void { - this.listeners.get(event as string)?.delete(handler); + this.listeners.get(event)?.delete(handler as VaultEventHandler); } async dispose(): Promise { @@ -430,10 +435,14 @@ export class Vault { } private emit(event: K, data: VaultEventMap[K]): void { - const handlers = this.listeners.get(event as string); + const handlers = this.listeners.get(event); if (handlers) { for (const handler of handlers) { - (handler as VaultEventHandler)(data); + try { + (handler as VaultEventHandler)(data); + } catch { + // Isolate handler errors — one failing listener shouldn't break others + } } } } diff --git a/packages/core/src/watcher/index.ts b/packages/core/src/watcher/index.ts index 66f6ea9..2bea376 100644 --- a/packages/core/src/watcher/index.ts +++ b/packages/core/src/watcher/index.ts @@ -25,6 +25,7 @@ export class VaultWatcher { this.watcher = watch(watchPaths, { ignoreInitial: true, + followSymlinks: false, awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }, }); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 7df0c53..28c1b93 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -6,6 +6,14 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'json-summary', 'json'], reportsDirectory: './coverage', + include: ['src/**/*.ts'], + exclude: ['src/__tests__/**', 'src/types/**'], + thresholds: { + lines: 80, + functions: 75, + branches: 70, + statements: 80, + }, }, }, }); diff --git a/packages/vscode/package.json b/packages/vscode/package.json index a418c37..bae4773 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -126,6 +126,11 @@ "command": "commandvault.import", "title": "CommandVault: Import Collection", "icon": "$(cloud-download)" + }, + { + "command": "commandvault.filterByType", + "title": "CommandVault: Filter by Type", + "icon": "$(filter)" } ], "menus": { @@ -149,6 +154,11 @@ "command": "commandvault.filterEntries", "when": "view == commandvault.entries", "group": "navigation" + }, + { + "command": "commandvault.filterByType", + "when": "view == commandvault.entries", + "group": "navigation" } ], "view/item/context": [ diff --git a/packages/vscode/src/commands/index.ts b/packages/vscode/src/commands/index.ts index 2c0c0fb..1942c67 100644 --- a/packages/vscode/src/commands/index.ts +++ b/packages/vscode/src/commands/index.ts @@ -67,25 +67,33 @@ export function registerCommands( const searchCommand = vscode.commands.registerCommand('commandvault.search', async () => { const quickPick = vscode.window.createQuickPick(); - quickPick.placeholder = 'Search commands (try type:skill or tag:security)'; + quickPick.placeholder = + "Search entries (try: 'exact ^prefix !exclude type:skill tag:security)"; quickPick.matchOnDescription = true; quickPick.matchOnDetail = true; let debounceTimer: ReturnType | undefined; let isDisposed = false; + const syntaxHelpItem: SearchQuickPickItem = { + label: "$(lightbulb) Search syntax: 'exact ^prefix !exclude type:skill tag:security", + description: '', + detail: '', + entry: undefined as unknown as VaultEntry, + alwaysShow: true, + }; + const updateResults = (input: string): void => { if (isDisposed) return; const { query, type, tags } = parseSearchInput(input); + let items: SearchQuickPickItem[]; + if (!query.trim() && !type && !tags) { const allEntries = getVault().getAllEntries(); - quickPick.items = allEntries.slice(0, 50).map((entry) => createQuickPickItem(entry)); - return; - } - - if (!query.trim() && (type || tags)) { + items = allEntries.slice(0, 50).map((entry) => createQuickPickItem(entry)); + } else if (!query.trim() && (type || tags)) { const allEntries = getVault() .getAllEntries() .filter((entry) => { @@ -93,18 +101,18 @@ export function registerCommands( if (tags && !tags.every((t) => entry.tags.includes(t))) return false; return true; }); - quickPick.items = allEntries.slice(0, 50).map((entry) => createQuickPickItem(entry)); - return; + items = allEntries.slice(0, 50).map((entry) => createQuickPickItem(entry)); + } else { + const results: readonly SearchResult[] = getVault().search({ + query, + type, + tags, + limit: 50, + }); + items = results.map((result) => createQuickPickItem(result.entry, result.score)); } - const results: readonly SearchResult[] = getVault().search({ - query, - type, - tags, - limit: 50, - }); - - quickPick.items = results.map((result) => createQuickPickItem(result.entry, result.score)); + quickPick.items = [...items, syntaxHelpItem]; }; quickPick.onDidChangeValue((value) => { @@ -122,7 +130,7 @@ export function registerCommands( clearTimeout(debounceTimer); } const selected = quickPick.selectedItems[0]; - if (selected) { + if (selected && selected.entry) { getVault().recordUsage(selected.entry.id); recentProvider.refresh(); vscode.commands.executeCommand('commandvault.openDetail', selected.entry); @@ -316,6 +324,34 @@ export function registerCommands( } }); + const filterByTypeCommand = vscode.commands.registerCommand( + 'commandvault.filterByType', + async () => { + const currentFilter = entriesProvider.getTypeFilter(); + const items: Array<{ label: string; value: EntryType | null }> = [ + { label: '$(list-flat) All Types', value: null }, + { label: '$(symbol-event) Skills', value: 'skill' as EntryType }, + { label: '$(person) Agents', value: 'agent' as EntryType }, + { label: '$(terminal) Commands', value: 'command' as EntryType }, + { label: '$(extensions) Plugins', value: 'plugin' as EntryType }, + { label: '$(law) Rules', value: 'rule' as EntryType }, + { label: '$(zap) Hooks', value: 'hook' as EntryType }, + ]; + + const pick = await vscode.window.showQuickPick( + items.map((item) => ({ + ...item, + description: item.value === currentFilter ? '(active)' : '', + })), + { placeHolder: `Filter by type (current: ${currentFilter ?? 'All Types'})` }, + ); + + if (pick) { + entriesProvider.setTypeFilter(pick.value); + } + }, + ); + return [ searchCommand, refreshCommand, @@ -329,6 +365,7 @@ export function registerCommands( statsCommand, exportCommand, importCommand, + filterByTypeCommand, ]; } diff --git a/packages/vscode/src/providers/entries-provider.ts b/packages/vscode/src/providers/entries-provider.ts index cba21d6..c507307 100644 --- a/packages/vscode/src/providers/entries-provider.ts +++ b/packages/vscode/src/providers/entries-provider.ts @@ -59,6 +59,7 @@ export class EntriesProvider implements vscode.TreeDataProvider { private sortBy: SortMode = 'name'; private filterText = ''; + private filterType: EntryType | null = null; constructor(private readonly vault: Vault) {} @@ -72,6 +73,15 @@ export class EntriesProvider implements vscode.TreeDataProvider { this.refresh(); } + setTypeFilter(type: EntryType | null): void { + this.filterType = type; + this.refresh(); + } + + getTypeFilter(): EntryType | null { + return this.filterType; + } + getSortMode(): SortMode { return this.sortBy; } @@ -114,6 +124,10 @@ export class EntriesProvider implements vscode.TreeDataProvider { private getRootNodes(): TypeGroupNode[] { let entries = [...this.vault.getAllEntries()]; + if (this.filterType) { + entries = entries.filter((e) => e.type === this.filterType); + } + if (this.filterText) { entries = entries.filter( (e) => @@ -139,6 +153,10 @@ export class EntriesProvider implements vscode.TreeDataProvider { private getSourceNodes(type: EntryType): SourceGroupNode[] { let entries = [...this.vault.getEntriesByType(type)]; + if (this.filterType && type !== this.filterType) { + return []; + } + if (this.filterText) { entries = entries.filter( (e) => diff --git a/packages/vscode/src/webview/detail-panel.ts b/packages/vscode/src/webview/detail-panel.ts index a2f5af2..2ff9d8d 100644 --- a/packages/vscode/src/webview/detail-panel.ts +++ b/packages/vscode/src/webview/detail-panel.ts @@ -1,7 +1,15 @@ import * as vscode from 'vscode'; import * as crypto from 'crypto'; +import * as path from 'path'; +import * as os from 'os'; import type { VaultEntry } from '@commandvault/core'; +const allowedRoots = [ + path.join(os.homedir(), '.claude'), + path.join(os.homedir(), '.cursor'), + path.join(os.homedir(), '.continue'), +]; + const PANEL_COLUMN = vscode.ViewColumn.One; const activePanels = new Map(); @@ -39,7 +47,17 @@ export function createDetailPanel( vscode.window.showInformationMessage('CommandVault: Copied to clipboard'); } else if (message.type === 'openFile' && message.path) { try { - const uri = vscode.Uri.file(message.path); + const normalizedPath = path.normalize(message.path); + const isWithinAllowed = allowedRoots.some( + (root) => normalizedPath.startsWith(root + path.sep) || normalizedPath === root, + ); + if (!isWithinAllowed) { + vscode.window.showErrorMessage( + 'CommandVault: Cannot open file outside allowed directories', + ); + return; + } + const uri = vscode.Uri.file(normalizedPath); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); } catch (err) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9365d15..dc3442f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + fast-uri: '>=3.1.2' + ws: '>=8.20.1' + brace-expansion: '>=5.0.6' + importers: .: @@ -22,7 +27,7 @@ importers: version: 3.8.3 turbo: specifier: ^2.9.12 - version: 2.9.12 + version: 2.9.14 typescript: specifier: ^5.8.0 version: 5.9.3 @@ -49,20 +54,20 @@ importers: version: 13.1.0 ink: specifier: ^7.0.1 - version: 7.0.1(@types/react@19.2.14)(react@19.2.5) + version: 7.0.1(@types/react@19.2.14)(react@19.2.6) ink-text-input: specifier: ^6.0.0 - version: 6.0.0(ink@7.0.1(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + version: 6.0.0(ink@7.0.1(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) ora: specifier: ^8.2.0 version: 8.2.0 react: specifier: ^19.0.0 - version: 19.2.5 + version: 19.2.6 devDependencies: '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/node': specifier: ^22.0.0 version: 22.19.17 @@ -84,6 +89,9 @@ importers: packages/core: dependencies: + better-sqlite3: + specifier: ^12.10.0 + version: 12.10.0 chokidar: specifier: ^4.0.0 version: 4.0.3 @@ -100,6 +108,9 @@ importers: specifier: ^1.14.1 version: 1.14.1 devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/node': specifier: ^22.0.0 version: 22.19.17 @@ -697,6 +708,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1030,33 +1047,33 @@ packages: '@types/react-dom': optional: true - '@turbo/darwin-64@2.9.7': - resolution: {integrity: sha512-wnvOWuVWJ5EUHNKxExEWiGlTeVpLG1L0PCu5MUozyC1P2SHGiWsmpW6/yAuShH91Fa2TAHOvdCRBzriZh4j4Eg==} + '@turbo/darwin-64@2.9.14': + resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.9.12': - resolution: {integrity: sha512-RUkAE404z/J8NsyrUosMcBaXT6M4bRFxTQrmkDQBLQVXaC8Jl0e9bMvYDSX0GW7Ffm2m3j9y7RXgR1foeUAM9w==} + '@turbo/darwin-arm64@2.9.14': + resolution: {integrity: sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.9.12': - resolution: {integrity: sha512-InIUtH7cw/vqXNX1Gr7QgWfmw3ct08pV5CpfdEOR48z2u2rzdmpIuk00B/Q2xCb0PMWtKgiMQynfuphmEuUyTQ==} + '@turbo/linux-64@2.9.14': + resolution: {integrity: sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==} cpu: [x64] os: [linux] - '@turbo/linux-arm64@2.9.12': - resolution: {integrity: sha512-lC6nD//Xh67fmJM0LKaLsg74Wry0aYrgMklpiNgCbUaMdPIOqj0A00iri3NU7Lb7pZHx8ViisgpeDKlpSgFUCA==} + '@turbo/linux-arm64@2.9.14': + resolution: {integrity: sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==} cpu: [arm64] os: [linux] - '@turbo/windows-64@2.9.12': - resolution: {integrity: sha512-conYri8VUl72JOdYnLDPYwzqbPcY5ECoHmo9FWoKznemhaAIilj4maHqs9Uar0aKfNoZIULniy+6iWaLtLO34A==} + '@turbo/windows-64@2.9.14': + resolution: {integrity: sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.9.12': - resolution: {integrity: sha512-XoR4bsg62/L/esRVcmoMESEiNZ36+YmyjYGLpoqk8nwMgXzzVjNOgX0lRSz5w/U/ajLGv3nhMsS0Q2QOdvp2AQ==} + '@turbo/windows-arm64@2.9.14': + resolution: {integrity: sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==} cpu: [arm64] os: [win32] @@ -1075,6 +1092,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1192,26 +1212,33 @@ packages: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.25: resolution: {integrity: sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==} engines: {node: '>=6.0.0'} hasBin: true + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} browserslist@4.28.2: @@ -1219,6 +1246,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1249,6 +1279,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cli-boxes@4.0.1: resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} engines: {node: '>=18.20 <19 || >=20.10'} @@ -1301,9 +1334,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -1376,14 +1409,26 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1406,6 +1451,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -1465,6 +1513,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1482,8 +1534,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -1501,10 +1553,16 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1539,6 +1597,9 @@ packages: engines: {node: '>=18'} hasBin: true + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1575,6 +1636,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1586,6 +1650,12 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@6.0.0: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -1820,6 +1890,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1838,6 +1912,9 @@ packages: minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1850,6 +1927,13 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} @@ -1861,6 +1945,9 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -1929,6 +2016,12 @@ packages: resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -1942,12 +2035,19 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: react: ^19.2.6 @@ -1968,6 +2068,10 @@ packages: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -2005,6 +2109,9 @@ packages: resolution: {integrity: sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2046,6 +2153,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + slice-ansi@9.0.0: resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} engines: {node: '>=22'} @@ -2090,6 +2203,9 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2110,6 +2226,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -2132,6 +2252,13 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + terminal-size@4.0.1: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} @@ -2181,8 +2308,11 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - turbo@2.9.7: - resolution: {integrity: sha512-epxzqVO2s0IxcSWcgb+qKrtco8isfe7g3VtiS6hkYnEK4A9XQDZbrtavQ6MtWR1KoQn+1fUomaQth2rfRHlUlg==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + turbo@2.9.14: + resolution: {integrity: sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==} hasBin: true type-fest@1.4.0: @@ -2227,6 +2357,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2342,8 +2475,11 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -2833,6 +2969,9 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@exodus/bytes@1.15.0': {} '@inquirer/ansi@2.0.5': {} @@ -3085,32 +3224,32 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.2 '@testing-library/dom': 10.4.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@turbo/darwin-64@2.9.7': + '@turbo/darwin-64@2.9.14': optional: true - '@turbo/darwin-arm64@2.9.12': + '@turbo/darwin-arm64@2.9.14': optional: true - '@turbo/linux-64@2.9.12': + '@turbo/linux-64@2.9.14': optional: true - '@turbo/linux-arm64@2.9.12': + '@turbo/linux-arm64@2.9.14': optional: true - '@turbo/windows-64@2.9.12': + '@turbo/windows-64@2.9.14': optional: true - '@turbo/windows-arm64@2.9.12': + '@turbo/windows-arm64@2.9.14': optional: true '@types/aria-query@5.0.4': {} @@ -3136,6 +3275,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.19.17 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3235,7 +3378,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3277,21 +3420,32 @@ snapshots: auto-bind@5.0.1: {} - balanced-match@1.0.2: {} - balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.25: {} + better-sqlite3@12.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 - brace-expansion@2.1.0: + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: dependencies: - balanced-match: 1.0.2 + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -3303,6 +3457,11 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + cac@6.7.14: {} callsites@3.1.0: {} @@ -3327,6 +3486,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@1.1.4: {} + cli-boxes@4.0.1: {} cli-cursor@4.0.0: @@ -3381,7 +3542,7 @@ snapshots: color-name@1.1.4: {} - commander@14.0.3: {} + commander@13.1.0: {} compare-func@2.0.0: dependencies: @@ -3451,10 +3612,18 @@ snapshots: decimal.js@10.6.0: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} dot-prop@5.3.0: @@ -3471,6 +3640,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@8.0.0: {} env-paths@2.2.1: {} @@ -3582,6 +3755,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expect-type@1.3.0: {} extend-shallow@2.0.1: @@ -3596,7 +3771,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fast-wrap-ansi@0.2.0: dependencies: @@ -3610,11 +3785,15 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true @@ -3641,6 +3820,8 @@ snapshots: - conventional-commits-filter - conventional-commits-parser + github-from-package@0.0.0: {} + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -3679,6 +3860,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3688,20 +3871,24 @@ snapshots: indent-string@5.0.0: {} + inherits@2.0.4: {} + + ini@1.3.8: {} + ini@6.0.0: {} ink-testing-library@4.0.0(@types/react@19.2.14): optionalDependencies: '@types/react': 19.2.14 - ink-text-input@6.0.0(ink@7.0.1(@types/react@19.2.14)(react@19.2.5))(react@19.2.5): + ink-text-input@6.0.0(ink@7.0.1(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: chalk: 5.6.2 - ink: 7.0.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + ink: 7.0.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 type-fest: 4.41.0 - ink@7.0.1(@types/react@19.2.14)(react@19.2.5): + ink@7.0.1(@types/react@19.2.14)(react@19.2.6): dependencies: '@alcalzone/ansi-tokenize': 0.3.0 ansi-escapes: 7.3.0 @@ -3716,8 +3903,8 @@ snapshots: indent-string: 5.0.0 is-in-ci: 2.0.0 patch-console: 2.0.0 - react: 19.2.5 - react-reconciler: 0.33.0(react@19.2.5) + react: 19.2.6 + react-reconciler: 0.33.0(react@19.2.6) scheduler: 0.27.0 signal-exit: 3.0.7 slice-ansi: 9.0.0 @@ -3727,7 +3914,7 @@ snapshots: type-fest: 5.6.0 widest-line: 6.0.0 wrap-ansi: 10.0.0 - ws: 8.20.0 + ws: 8.20.1 yoga-layout: 3.2.1 optionalDependencies: '@types/react': 19.2.14 @@ -3908,13 +4095,15 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@3.1.0: {} + minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 5.0.6 minimist@1.2.8: {} @@ -3922,12 +4111,20 @@ snapshots: minisearch@7.2.0: {} + mkdirp-classic@0.5.3: {} + ms@2.1.3: {} mute-stream@3.0.0: {} nanoid@3.3.12: {} + napi-build-utils@2.0.0: {} + + node-abi@3.92.0: + dependencies: + semver: 7.7.4 + node-releases@2.0.38: {} npm-run-path@4.0.1: @@ -3939,6 +4136,10 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -4005,6 +4206,21 @@ snapshots: powershell-utils@0.2.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prettier@3.8.3: {} pretty-format@27.5.1: @@ -4017,24 +4233,42 @@ snapshots: dependencies: parse-ms: 4.0.0 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} - react-dom@19.2.5(react@19.2.5): + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 scheduler: 0.27.0 react-is@17.0.2: {} - react-reconciler@0.33.0(react@19.2.5): + react-reconciler@0.33.0(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 scheduler: 0.27.0 react-refresh@0.17.0: {} react@19.2.6: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@4.1.2: {} require-directory@2.1.1: {} @@ -4093,6 +4327,8 @@ snapshots: subsume: 4.0.0 type-fest: 2.19.0 + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -4122,6 +4358,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + slice-ansi@9.0.0: dependencies: ansi-styles: 6.2.3 @@ -4166,6 +4410,10 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -4180,6 +4428,8 @@ snapshots: strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -4199,6 +4449,21 @@ snapshots: tagged-tag@1.0.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + terminal-size@4.0.1: {} test-exclude@7.0.2: @@ -4238,14 +4503,18 @@ snapshots: dependencies: punycode: 2.3.1 - turbo@2.9.7: + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + turbo@2.9.14: optionalDependencies: - '@turbo/darwin-64': 2.9.12 - '@turbo/darwin-arm64': 2.9.12 - '@turbo/linux-64': 2.9.12 - '@turbo/linux-arm64': 2.9.12 - '@turbo/windows-64': 2.9.12 - '@turbo/windows-arm64': 2.9.12 + '@turbo/darwin-64': 2.9.14 + '@turbo/darwin-arm64': 2.9.14 + '@turbo/linux-64': 2.9.14 + '@turbo/linux-arm64': 2.9.14 + '@turbo/windows-64': 2.9.14 + '@turbo/windows-arm64': 2.9.14 type-fest@1.4.0: {} @@ -4275,6 +4544,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + util-deprecate@1.0.2: {} + vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1): dependencies: cac: 6.7.14 @@ -4398,7 +4669,9 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 - ws@8.20.0: {} + wrappy@1.0.2: {} + + ws@8.20.1: {} xml-name-validator@5.0.0: {}