diff --git a/src/capture/platform/darwin-watcher.ts b/src/capture/platform/darwin-watcher.ts index 348ef7a..cc87203 100644 --- a/src/capture/platform/darwin-watcher.ts +++ b/src/capture/platform/darwin-watcher.ts @@ -1,6 +1,5 @@ import { EventEmitter } from 'node:events' -import { stat, unlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { stat } from 'node:fs/promises' import path from 'node:path' import type { RawEvent } from '../types' @@ -12,6 +11,7 @@ import type { } from './types' import { COMMON_PRUNE_DIRS } from './prune-dirs' import { streamLines } from './stream-lines' +import { createMarkerFiles } from './marker-files' // FSEvents flag constants const ItemRemoved = 0x00000200 @@ -186,20 +186,14 @@ export class DarwinWatcher extends EventEmitter implements PlatformWatcher { private async *backfillFind(opts: BackfillOptions): AsyncIterable { const { since_ts, until_ts, roots, signal } = opts - const tmp = tmpdir() - const pid = process.pid - const timestamp = Date.now() - const startMarker = path.join(tmp, `wildcard-start-${pid}-${timestamp}`) - const endMarker = path.join(tmp, `wildcard-end-${pid}-${timestamp}`) + const markers = await createMarkerFiles('wildcard-find-') + const { start: startMarker, end: endMarker } = markers try { // Create marker files with precise timestamps const sinceDate = new Date(since_ts - CLOCK_SKEW_BUFFER_MS).toISOString() const untilDate = new Date(until_ts + CLOCK_SKEW_BUFFER_MS).toISOString() - await writeFile(startMarker, '') - await writeFile(endMarker, '') - await Bun.spawn(['touch', '-d', sinceDate, startMarker]).exited await Bun.spawn(['touch', '-d', untilDate, endMarker]).exited @@ -253,9 +247,7 @@ export class DarwinWatcher extends EventEmitter implements PlatformWatcher { } } } finally { - // Always cleanup marker files - await unlink(startMarker).catch(() => {}) - await unlink(endMarker).catch(() => {}) + await markers.cleanup() } } diff --git a/src/capture/platform/marker-files.test.ts b/src/capture/platform/marker-files.test.ts new file mode 100644 index 0000000..205187f --- /dev/null +++ b/src/capture/platform/marker-files.test.ts @@ -0,0 +1,22 @@ +import path from 'node:path' + +import { describe, expect, it } from 'bun:test' + +import { createMarkerFiles } from './marker-files' + +describe('createMarkerFiles', () => { + it('creates private marker files inside a random temp directory and cleans them up', async () => { + const markers = await createMarkerFiles('wildcard-marker-test-') + + expect(path.basename(markers.start)).toBe('start') + expect(path.basename(markers.end)).toBe('end') + expect(markers.start).not.toBe(markers.end) + expect(await Bun.file(markers.start).exists()).toBe(true) + expect(await Bun.file(markers.end).exists()).toBe(true) + + await markers.cleanup() + + expect(await Bun.file(markers.start).exists()).toBe(false) + expect(await Bun.file(markers.end).exists()).toBe(false) + }) +}) diff --git a/src/capture/platform/marker-files.ts b/src/capture/platform/marker-files.ts new file mode 100644 index 0000000..7d1f42b --- /dev/null +++ b/src/capture/platform/marker-files.ts @@ -0,0 +1,30 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +export type MarkerFiles = { + start: string + end: string + cleanup: () => Promise +} + +export async function createMarkerFiles(prefix: string): Promise { + const dir = await mkdtemp(path.join(tmpdir(), prefix)) + const start = path.join(dir, 'start') + const end = path.join(dir, 'end') + + await writeFile(start, '', { flag: 'wx', mode: 0o600 }) + await writeFile(end, '', { flag: 'wx', mode: 0o600 }) + + let cleaned = false + + return { + start, + end, + async cleanup() { + if (cleaned) return + cleaned = true + await rm(dir, { recursive: true, force: true }).catch(() => {}) + }, + } +} diff --git a/src/capture/platform/polling-watcher.ts b/src/capture/platform/polling-watcher.ts index ccef21a..f0cab39 100644 --- a/src/capture/platform/polling-watcher.ts +++ b/src/capture/platform/polling-watcher.ts @@ -1,10 +1,10 @@ import { EventEmitter } from 'node:events' -import { tmpdir } from 'node:os' import path from 'node:path' -import { stat, unlink } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import type { RawEvent } from '../types' import { POLLING_PRUNE_DIRS } from './prune-dirs' import { streamLines } from './stream-lines' +import { createMarkerFiles } from './marker-files' import type { PlatformWatcher, PlatformWatcherOptions, @@ -24,7 +24,6 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher { private roots: string[] private ignoreFn?: (path: string) => boolean private intervalMs: number - private pid: number constructor(options: PlatformWatcherOptions) { super() @@ -32,7 +31,6 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher { this.roots = roots.map((root) => path.resolve(root)) this.ignoreFn = ignore_fn this.intervalMs = poll_interval_ms ?? 300_000 - this.pid = process.pid } async start(): Promise { @@ -85,13 +83,10 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher { signal?: AbortSignal, ): AsyncIterable { const targetRoots = roots ?? this.roots - const startMarker = this.markerPath('start', sinceTs) - const endMarker = this.markerPath('end', untilTs) + const markers = await createMarkerFiles('wildcard-poll-') + const { start: startMarker, end: endMarker } = markers try { - await Bun.write(startMarker, '') - await Bun.write(endMarker, '') - await this.touchWithTime(startMarker, sinceTs) await this.touchWithTime(endMarker, untilTs) @@ -123,8 +118,7 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher { } satisfies WatcherError) } } finally { - await this.cleanupMarker(startMarker) - await this.cleanupMarker(endMarker) + await markers.cleanup() } } @@ -145,10 +139,6 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher { return args } - private markerPath(type: 'start' | 'end', timestamp: number): string { - return path.join(tmpdir(), `wildcard-poll-${this.pid}-${type}-${timestamp}`) - } - private async touchWithTime(filePath: string, timestamp: number): Promise { const dateStr = new Date(timestamp).toISOString() @@ -165,14 +155,6 @@ export class PollingWatcher extends EventEmitter implements PlatformWatcher { } } - private async cleanupMarker(filePath: string): Promise { - try { - await unlink(filePath) - } catch { - // Ignore errors - file may already be gone - } - } - private async fileMtimeMs(filePath: string): Promise { try { const st = await stat(filePath) diff --git a/src/commands/activity-report.test.ts b/src/commands/activity-report.test.ts index 66534bb..09a7d99 100644 --- a/src/commands/activity-report.test.ts +++ b/src/commands/activity-report.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'bun:test' -import { canUseReportEnrichment, shouldAutoEnrichReport } from './report-enrichment' +import { + canUseReportEnrichment, + isReportEnrichDisabled, + shouldAutoEnrichReport, +} from './report-enrichment' describe('report enrichment gating', () => { it('uses enrichment only for local/all-device AI reports', () => { @@ -43,5 +47,20 @@ describe('report enrichment gating', () => { noEnrich: true, }), ).toBe(false) + + expect( + shouldAutoEnrichReport({ + deviceScope: '*', + hasAi: true, + enrichmentEnabled: true, + enrich: false, + }), + ).toBe(false) + }) + + it('treats both commander negation forms as enrichment opt-out', () => { + expect(isReportEnrichDisabled({ noEnrich: true })).toBe(true) + expect(isReportEnrichDisabled({ enrich: false })).toBe(true) + expect(isReportEnrichDisabled({ noEnrich: false, enrich: true })).toBe(false) }) }) diff --git a/src/commands/activity-report.ts b/src/commands/activity-report.ts index 8793be6..77d5a4f 100644 --- a/src/commands/activity-report.ts +++ b/src/commands/activity-report.ts @@ -167,6 +167,7 @@ export async function cmdReport( hasAi, enrichmentEnabled: Boolean(cfg.enrichment), noEnrich: opts.noEnrich, + enrich: opts.enrich, }) if (shouldAutoEnrich) { diff --git a/src/commands/report-enrichment.ts b/src/commands/report-enrichment.ts index b893f40..b60debc 100644 --- a/src/commands/report-enrichment.ts +++ b/src/commands/report-enrichment.ts @@ -3,15 +3,20 @@ export function canUseReportEnrichment(deviceScope: string | undefined, hasAi: b return shouldInjectEnrichment && hasAi } +export function isReportEnrichDisabled(options: { noEnrich?: boolean; enrich?: boolean }): boolean { + return options.noEnrich === true || options.enrich === false +} + export function shouldAutoEnrichReport(options: { deviceScope: string | undefined hasAi: boolean enrichmentEnabled: boolean noEnrich?: boolean + enrich?: boolean }): boolean { return ( canUseReportEnrichment(options.deviceScope, options.hasAi) && options.enrichmentEnabled && - !options.noEnrich + !isReportEnrichDisabled(options) ) } diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts index c351b9c..5891924 100644 --- a/src/commands/setup.test.ts +++ b/src/commands/setup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test' -import { applyEnrichmentSetupChoice } from './setup' +import { applyEnrichmentSetupChoice, buildAiSetupConfig } from './setup' describe('applyEnrichmentSetupChoice', () => { it('removes stale enrichment config when disabled', () => { @@ -33,3 +33,34 @@ describe('applyEnrichmentSetupChoice', () => { }) }) }) + +describe('buildAiSetupConfig', () => { + it('does not retain provider credentials when switching to a different provider', () => { + const cfg = buildAiSetupConfig({ + provider: 'gemini', + model: 'gemini-3.1-flash-lite-preview', + }) + + expect(cfg).toEqual({ + enabled: true, + provider: 'gemini', + model: 'gemini-3.1-flash-lite-preview', + }) + }) + + it('keeps ollama base URL separate from remote-provider api keys', () => { + const cfg = buildAiSetupConfig({ + provider: 'ollama', + model: 'qwen3.5:latest', + baseUrl: 'http://localhost:11434', + apiKey: 'should-not-be-used', + }) + + expect(cfg).toEqual({ + enabled: true, + provider: 'ollama', + model: 'qwen3.5:latest', + base_url: 'http://localhost:11434', + }) + }) +}) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index a84abb3..a8865c0 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -12,6 +12,7 @@ import { expandHome } from '../config/path-utils' import { ensureDir } from '../utils/process' import { exitIfCancelled } from './cli-helpers' +import type { AiProviderKind } from '../ai/index' function resolveWatchRootsDefault(home: string, cwd: string): string { const candidates = [ @@ -52,6 +53,25 @@ export function applyEnrichmentSetupChoice( existingConfig.enrichment = enrichCfg } +export function buildAiSetupConfig(options: { + provider: AiProviderKind + model: string + baseUrl?: string + apiKey?: string +}): WildcardConfig['ai'] { + const base = { + enabled: true as const, + provider: options.provider, + model: options.model, + } + + if (options.provider === 'ollama') { + return options.baseUrl ? { ...base, base_url: options.baseUrl } : base + } + + return options.apiKey ? { ...base, api_key: options.apiKey } : base +} + export async function cmdSetup(): Promise { intro('Wildcard setup') @@ -217,14 +237,12 @@ export async function cmdSetup(): Promise { } if (enableAi) { - const aiCfg: Record = { - ...(existingAi ?? {}), - enabled: true, + const aiCfg = buildAiSetupConfig({ provider: aiProvider, model: aiModel, - } - if (aiBaseUrl) aiCfg.base_url = aiBaseUrl - if (aiNeedsApiKey && aiApiKey) aiCfg.api_key = aiApiKey + baseUrl: aiBaseUrl, + apiKey: aiNeedsApiKey ? aiApiKey : undefined, + }) cfg.ai = aiCfg } else if (existingAi) { cfg.ai = { diff --git a/src/commands/status.ts b/src/commands/status.ts index cb3ea6e..9b63945 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -214,9 +214,7 @@ function countPendingRows(db: TimelineDb, table: string, cursorKey: string): num try { const rawCursor = db.getState(cursorKey) const cursor = - rawCursor !== null && rawCursor !== undefined - ? Number.parseInt(rawCursor, 10) - : Number.NaN + rawCursor !== null && rawCursor !== undefined ? Number.parseInt(rawCursor, 10) : Number.NaN const hasCursor = Number.isFinite(cursor) && cursor >= 0 const sql = hasCursor ? `SELECT COUNT(*) as cnt FROM ${table} WHERE rowid > ?` diff --git a/src/commands/types.ts b/src/commands/types.ts index 80d1e7f..d5f0a87 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -23,6 +23,7 @@ export type StatusOptions = { export type ReportOptions = PeriodFlags & { ai?: boolean + enrich?: boolean noAi?: boolean noEnrich?: boolean forceRefresh?: boolean diff --git a/src/enrichment/git-context.ts b/src/enrichment/git-context.ts index 7804784..f4ea870 100644 --- a/src/enrichment/git-context.ts +++ b/src/enrichment/git-context.ts @@ -97,11 +97,11 @@ export async function gather_git_context( { timeout_ms: timeout }, ), run_git(repoRoot, ['status', '--short'], { timeout_ms: timeout }), - run_git(repoRoot, ['diff', '--cached'], { + run_git(repoRoot, ['diff', '--cached', '-U0'], { timeout_ms: timeout, max_bytes: maxDiff, }), - run_git(repoRoot, ['diff'], { + run_git(repoRoot, ['diff', '-U0'], { timeout_ms: timeout, max_bytes: maxDiff, }), diff --git a/src/enrichment/redact.test.ts b/src/enrichment/redact.test.ts new file mode 100644 index 0000000..ac3a0f1 --- /dev/null +++ b/src/enrichment/redact.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test' + +import { default_redact_config, redact_diff_content } from './redact' + +describe('redact_diff_content', () => { + it('redacts sensitive content that appears in diff context lines', () => { + const diff = [ + 'diff --git a/config.txt b/config.txt', + 'index 1111111..2222222 100644', + '--- a/config.txt', + '+++ b/config.txt', + '@@ -1,2 +1,2 @@', + ' API_KEY=super-secret-value', + '-API_KEY=old-secret', + '+API_KEY=new-secret', + ].join('\n') + + const redacted = redact_diff_content(diff, default_redact_config()) + + expect(redacted).toContain(' [REDACTED]') + expect(redacted).toContain('-[REDACTED]') + expect(redacted).toContain('+[REDACTED]') + expect(redacted).not.toContain('super-secret-value') + expect(redacted).not.toContain('old-secret') + expect(redacted).not.toContain('new-secret') + }) +}) diff --git a/src/enrichment/redact.ts b/src/enrichment/redact.ts index e2eb7a6..69b7245 100644 --- a/src/enrichment/redact.ts +++ b/src/enrichment/redact.ts @@ -70,7 +70,9 @@ function redact_content_line(line: string, patterns: RegExp[]): string { ? '+[REDACTED]' : line.startsWith('-') ? '-[REDACTED]' - : '[REDACTED]' + : line.startsWith(' ') + ? ' [REDACTED]' + : '[REDACTED]' } } return result @@ -100,8 +102,8 @@ export function redact_diff_content(diffText: string, config: RedactConfig): str if (skipCurrentFile) continue - // Redact sensitive content in changed lines - if (line.startsWith('+') || line.startsWith('-')) { + // Redact sensitive content in changed and context lines. + if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) { result.push(redact_content_line(line, config.sensitive_content_patterns)) } else { result.push(line) diff --git a/src/runtime/watch-pipeline.ts b/src/runtime/watch-pipeline.ts index 3912e9e..b5b2b20 100644 --- a/src/runtime/watch-pipeline.ts +++ b/src/runtime/watch-pipeline.ts @@ -1,10 +1,12 @@ import type { WildcardConfig } from '../config/index' +import type { TimelineDb } from '../storage/index' import { createWatchIngest } from './watch-pipeline-ingest' import { createWatchPipelineProcessor } from './watch-pipeline-processor' import type { WatchRuntimePaths } from './types' export type WatchPipeline = { + db: TimelineDb appUsageEnabled: boolean appUsagePollMs: number start: () => Promise @@ -24,6 +26,7 @@ export function createWatchPipeline(options: WatchPipelineOptions): WatchPipelin const processor = createWatchPipelineProcessor(ingest) return { + db: ingest.db, appUsageEnabled: ingest.appUsageEnabled, appUsagePollMs: ingest.appUsagePollMs, start: processor.start, diff --git a/src/runtime/watch-runtime.ts b/src/runtime/watch-runtime.ts index a0e0207..46c58cb 100644 --- a/src/runtime/watch-runtime.ts +++ b/src/runtime/watch-runtime.ts @@ -3,7 +3,6 @@ import fs from 'node:fs' import { createWatchPipeline } from './watch-pipeline' import { startWatchMemoryTimer } from './watch-timers' import type { WatchRuntimeOptions } from './types' -import { TimelineDb } from '../storage/index' import { SyncEngine } from '../sync/index' export type { WatchRuntimeOptions, WatchRuntimePaths } from './types' @@ -23,14 +22,11 @@ export async function runWatchRuntime(options: WatchRuntimeOptions): Promise void) | null = null - let syncDb: TimelineDb | null = null if (cfg.sync?.enabled && cfg.sync?.server_url) { - syncDb = new TimelineDb(paths.db_path) - try { const deviceId = cfg.identity?.device_id ?? 'local' - const backfill = syncDb.backfillIfNeeded(deviceId) + const backfill = pipeline.db.backfillIfNeeded(deviceId) if (backfill) { console.log( `wildcard: backfilled device_id='${deviceId}' — ${backfill.events} events, ${backfill.appUsage} app_usage rows`, @@ -40,7 +36,7 @@ export async function runWatchRuntime(options: WatchRuntimeOptions): Promise { try { await waitForHealth(`http://127.0.0.1:${port}/health`) - const summaryResponse = await fetch(`http://127.0.0.1:${port}/api/activity/summary?date=${date}`) + const summaryResponse = await fetch( + `http://127.0.0.1:${port}/api/activity/summary?date=${date}`, + ) expect(summaryResponse.status).toBe(200) const summaryBody = (await summaryResponse.json()) as { diff --git a/src/web/dashboard.html b/src/web/dashboard.html index 325eb3a..ef8a8c8 100644 --- a/src/web/dashboard.html +++ b/src/web/dashboard.html @@ -81,7 +81,6 @@ const MS_PER_MINUTE = 60000 const MS_PER_HOUR = 3600000 - const AUTH_STORAGE_KEY = 'wildcard-dashboard-api-key' const TABS = ['summary', 'projects', 'sessions'] // ── Utilities ───────────────────────────────────────── @@ -109,21 +108,6 @@ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } - function readStoredApiKey() { - try { - return localStorage.getItem(AUTH_STORAGE_KEY) || '' - } catch { - return '' - } - } - - function writeStoredApiKey(value) { - try { - if (value) localStorage.setItem(AUTH_STORAGE_KEY, value) - else localStorage.removeItem(AUTH_STORAGE_KEY) - } catch {} - } - function getInitialTab() { const hash = window.location.hash.slice(1) return TABS.includes(hash) ? hash : 'summary' @@ -675,13 +659,16 @@
API KEY REQUIRED

This server requires bearer auth for activity APIs. Enter the configured server API - key to load the dashboard. + key to load the dashboard. The key stays in memory only and is not persisted in the + browser.

onApiKeyChange(event.currentTarget.value)} placeholder="wildcard server api key" + autocomplete="off" + spellcheck="false" class="w-full bg-black border border-border-visible text-text-primary px-3 py-2 font-mono text-sm outline-none" />
@@ -703,7 +690,7 @@ const [currentDate, setCurrentDate] = useState(todayStr()) const [activeTab, setActiveTab] = useState(getInitialTab()) const [needsAuth, setNeedsAuth] = useState(false) - const [apiKey, setApiKey] = useState(readStoredApiKey()) + const [apiKey, setApiKey] = useState('') // Summary data (shared between Summary + Projects tabs) const [summaryData, setSummaryData] = useState(null) @@ -717,12 +704,12 @@ // Fetch summary data const fetchSummary = useCallback( - async (date) => { + async (date, authKey = apiKey) => { setSummaryLoading(true) setSummaryError(null) try { const res = await fetch('/api/activity/summary?date=' + date, { - headers: authHeaders(apiKey), + headers: authHeaders(authKey), }) if (res.status === 401) { setNeedsAuth(true) @@ -743,12 +730,12 @@ // Fetch sessions data (lazy - only when Sessions tab is active) const fetchSessions = useCallback( - async (date) => { + async (date, authKey = apiKey) => { setSessionsLoading(true) setSessionsError(null) try { const res = await fetch('/api/activity/daily?date=' + date, { - headers: authHeaders(apiKey), + headers: authHeaders(authKey), }) if (res.status === 401) { setNeedsAuth(true) @@ -800,8 +787,9 @@ }, []) const handleAuthSubmit = useCallback(() => { - writeStoredApiKey(apiKey.trim()) - fetchSummary(currentDate) + const trimmed = apiKey.trim() + setApiKey(trimmed) + fetchSummary(currentDate, trimmed) }, [apiKey, currentDate, fetchSummary]) const handleSummaryUpdate = useCallback((newData) => {