From 84037b7bb9a27f202ebdf6f41effc2255080cf95 Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Tue, 10 Feb 2026 17:06:58 +0100 Subject: [PATCH 1/4] feat(cli): add watch command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `devw watch` — a long-running process that watches `.dwf/` for YAML changes and recompiles automatically. Thin wrapper around compile with debounced filesystem events via chokidar v3. - Extract `executePipeline` from compile.ts with structured return types - New watch.ts with chokidar watcher, 200ms debounce, SIGINT cleanup - Add `reload` icon to shared ICONS in utils/ui.ts - Register watch command in index.ts - Add unit tests for executePipeline (8) and integration tests for watch (6) - Update test scripts to build dist before test compilation --- packages/cli/package.json | 5 +- packages/cli/src/commands/compile.ts | 208 +++++++++------ packages/cli/src/commands/watch.ts | 126 +++++++++ packages/cli/src/index.ts | 2 + packages/cli/src/utils/ui.ts | 1 + packages/cli/tests/commands/compile.test.ts | 149 +++++++++++ packages/cli/tests/commands/watch.test.ts | 272 ++++++++++++++++++++ pnpm-lock.yaml | 64 +++++ 8 files changed, 743 insertions(+), 84 deletions(-) create mode 100644 packages/cli/src/commands/watch.ts create mode 100644 packages/cli/tests/commands/compile.test.ts create mode 100644 packages/cli/tests/commands/watch.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 3aa97d6..da27736 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,12 +47,13 @@ "build": "tsc", "dev": "tsc --watch", "typecheck": "tsc --noEmit", - "test": "tsc -p tsconfig.test.json && find .test-build/tests -name '*.test.js' -exec node --test {} +", + "test": "tsc && tsc -p tsconfig.test.json && find .test-build/tests -name '*.test.js' -exec node --test {} +", "test:unit": "tsc -p tsconfig.test.json && find .test-build/tests -name '*.test.js' -not -path '*e2e*' -exec node --test {} +", - "test:e2e": "tsc -p tsconfig.test.json && find .test-build/tests/e2e -name '*.test.js' -exec node --test {} +" + "test:e2e": "tsc && tsc -p tsconfig.test.json && find .test-build/tests/e2e -name '*.test.js' -exec node --test {} +" }, "dependencies": { "@inquirer/prompts": "^7.0.0", + "chokidar": "^3.6.0", "commander": "^13.0.0", "yaml": "^2.7.0", "chalk": "^5.4.0" diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 7548f54..6ce71aa 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -21,132 +21,176 @@ export interface CompileOptions { verbose?: boolean; } +export interface BridgeResult { + bridgeId: string; + outputPath: string; + success: boolean; + error?: string; + content?: string; +} + +export interface CompileResult { + results: BridgeResult[]; + activeRuleCount: number; + elapsedMs: number; +} + +export interface PipelineOptions { + cwd: string; + tool?: string; + write?: boolean; +} + const BRIDGES: Bridge[] = [claudeBridge, cursorBridge, geminiBridge, windsurfBridge, copilotBridge]; function getBridge(id: string): Bridge | undefined { return BRIDGES.find((b) => b.id === id); } -async function runCompile(options: CompileOptions): Promise { - const cwd = process.cwd(); +export async function executePipeline(options: PipelineOptions): Promise { + const { cwd, tool, write = true } = options; const startTime = performance.now(); - if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { - ui.error('.dwf/config.yml not found', 'Run devw init to initialize the project'); - process.exitCode = 1; - return; - } - const config = await readConfig(cwd); const rules = await readRules(cwd); - // Determine which tools to compile let toolIds = config.tools; - if (options.tool) { - if (!config.tools.includes(options.tool)) { - ui.error(`Tool "${options.tool}" is not configured in .dwf/config.yml`, `Configured tools: ${config.tools.join(', ')}`); - process.exitCode = 1; - return; + if (tool) { + if (!config.tools.includes(tool)) { + throw new Error(`Tool "${tool}" is not configured in .dwf/config.yml. Configured tools: ${config.tools.join(', ')}`); } - toolIds = [options.tool]; - } - - if (options.verbose) { - ui.keyValue('Project:', chalk.bold(config.project.name)); - ui.keyValue('Mode:', config.mode); - ui.keyValue('Rules:', String(rules.length)); - ui.keyValue('Tools:', chalk.cyan(toolIds.join(', '))); - ui.newline(); + toolIds = [tool]; } const activeRules = rules.filter((r) => r.enabled); - - let filesWritten = 0; - const writtenPaths: string[] = []; + const results: BridgeResult[] = []; for (const toolId of toolIds) { const bridge = getBridge(toolId); if (!bridge) { - ui.warn(`No bridge for tool "${toolId}", skipping`); continue; } - // When zero active rules, clean up output files instead of writing empty content - if (activeRules.length === 0 && !options.dryRun) { - for (const relativePath of bridge.outputPaths) { - const absolutePath = join(cwd, relativePath); - if (!(await fileExists(absolutePath))) continue; - - if (bridge.usesMarkers) { - const existing = await readFile(absolutePath, 'utf-8'); - const cleaned = removeMarkedBlock(existing); - if (cleaned.length === 0) { - await unlink(absolutePath); + try { + if (activeRules.length === 0 && write) { + for (const relativePath of bridge.outputPaths) { + const absolutePath = join(cwd, relativePath); + if (!(await fileExists(absolutePath))) continue; + + if (bridge.usesMarkers) { + const existing = await readFile(absolutePath, 'utf-8'); + const cleaned = removeMarkedBlock(existing); + if (cleaned.length === 0) { + await unlink(absolutePath); + } else { + await writeFile(absolutePath, cleaned + '\n', 'utf-8'); + } } else { - await writeFile(absolutePath, cleaned + '\n', 'utf-8'); + await unlink(absolutePath); } - } else { - await unlink(absolutePath); + results.push({ bridgeId: bridge.id, outputPath: relativePath, success: true }); } - writtenPaths.push(relativePath); - filesWritten++; + continue; } - continue; - } - const outputs = bridge.compile(rules, config); - - for (const [relativePath, rawContent] of outputs) { - let content = rawContent; - if (bridge.usesMarkers) { - const absoluteCheck = join(cwd, relativePath); - let existing: string | null = null; - try { - existing = await readFile(absoluteCheck, 'utf-8'); - } catch { - existing = null; + const outputs = bridge.compile(rules, config); + + for (const [relativePath, rawContent] of outputs) { + let content = rawContent; + if (bridge.usesMarkers) { + const absoluteCheck = join(cwd, relativePath); + let existing: string | null = null; + try { + existing = await readFile(absoluteCheck, 'utf-8'); + } catch { + existing = null; + } + content = mergeMarkedContent(existing, rawContent); } - content = mergeMarkedContent(existing, rawContent); - } - if (options.dryRun) { - console.log(chalk.cyan(`--- ${relativePath} ---`)); - console.log(content); - continue; - } + if (!write) { + results.push({ bridgeId: bridge.id, outputPath: relativePath, success: true, content }); + continue; + } - const absolutePath = join(cwd, relativePath); - await mkdir(dirname(absolutePath), { recursive: true }); + const absolutePath = join(cwd, relativePath); + await mkdir(dirname(absolutePath), { recursive: true }); - if (config.mode === 'link') { - // Write to .dwf/.cache/, then symlink - const cachePath = join(cwd, '.dwf', '.cache', relativePath); - await mkdir(dirname(cachePath), { recursive: true }); - await writeFile(cachePath, content, 'utf-8'); + if (config.mode === 'link') { + const cachePath = join(cwd, '.dwf', '.cache', relativePath); + await mkdir(dirname(cachePath), { recursive: true }); + await writeFile(cachePath, content, 'utf-8'); - // Remove existing file/symlink before creating new one - if (await fileExists(absolutePath)) { - await unlink(absolutePath); + if (await fileExists(absolutePath)) { + await unlink(absolutePath); + } + await symlink(cachePath, absolutePath); + } else { + await writeFile(absolutePath, content, 'utf-8'); } - await symlink(cachePath, absolutePath); - } else { - await writeFile(absolutePath, content, 'utf-8'); - } - writtenPaths.push(relativePath); - filesWritten++; + results.push({ bridgeId: bridge.id, outputPath: relativePath, success: true }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + for (const relativePath of bridge.outputPaths) { + results.push({ bridgeId: bridge.id, outputPath: relativePath, success: false, error: message }); + } } } - if (!options.dryRun) { + if (write) { const hash = computeRulesHash(activeRules); await writeHash(cwd, hash); + } + + const elapsedMs = performance.now() - startTime; + return { results, activeRuleCount: activeRules.length, elapsedMs }; +} + +async function runCompile(options: CompileOptions): Promise { + const cwd = process.cwd(); + + if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { + ui.error('.dwf/config.yml not found', 'Run devw init to initialize the project'); + process.exitCode = 1; + return; + } + + try { + if (options.verbose) { + const config = await readConfig(cwd); + const rules = await readRules(cwd); + ui.keyValue('Project:', chalk.bold(config.project.name)); + ui.keyValue('Mode:', config.mode); + ui.keyValue('Rules:', String(rules.length)); + const toolIds = options.tool ? [options.tool] : config.tools; + ui.keyValue('Tools:', chalk.cyan(toolIds.join(', '))); + ui.newline(); + } + + if (options.dryRun) { + const result = await executePipeline({ cwd, tool: options.tool, write: false }); + for (const br of result.results) { + if (br.content !== undefined) { + console.log(chalk.cyan(`--- ${br.outputPath} ---`)); + console.log(br.content); + } + } + return; + } + + const result = await executePipeline({ cwd, tool: options.tool }); + const writtenPaths = result.results.filter((r) => r.success).map((r) => r.outputPath); - const elapsed = performance.now() - startTime; ui.newline(); - ui.success(`Compiled ${String(activeRules.length)} rules ${ICONS.arrow} ${String(filesWritten)} file${filesWritten !== 1 ? 's' : ''} ${ui.timing(elapsed)}`); + ui.success(`Compiled ${String(result.activeRuleCount)} rules ${ICONS.arrow} ${String(writtenPaths.length)} file${writtenPaths.length !== 1 ? 's' : ''} ${ui.timing(result.elapsedMs)}`); ui.newline(); ui.list(writtenPaths); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ui.error(message); + process.exitCode = 1; } } diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts new file mode 100644 index 0000000..c178927 --- /dev/null +++ b/packages/cli/src/commands/watch.ts @@ -0,0 +1,126 @@ +import { join } from 'node:path'; +import type { Command } from 'commander'; +import chalk from 'chalk'; +import chokidar from 'chokidar'; +import { executePipeline } from './compile.js'; +import type { CompileResult } from './compile.js'; +import { fileExists } from '../utils/fs.js'; +import * as ui from '../utils/ui.js'; +import { ICONS } from '../utils/ui.js'; + +interface WatchOptions { + tool?: string; +} + +const DEBOUNCE_MS = 200; + +function printCompileResult(result: CompileResult): void { + for (const br of result.results) { + if (br.success) { + ui.success(`${br.bridgeId.padEnd(10)} ${ICONS.arrow} ${br.outputPath}`); + } else { + ui.error(`${br.bridgeId.padEnd(10)} ${ICONS.arrow} ${br.error ?? 'failed to write output'}`); + } + } + ui.info(`Done in ${String(Math.round(result.elapsedMs))}ms`); +} + +function printWaiting(withHint = false): void { + ui.newline(); + if (withHint) { + ui.info('Waiting for changes... (Ctrl+C to stop)'); + } else { + ui.info('Waiting for changes...'); + } +} + +async function runWatch(options: WatchOptions): Promise { + const cwd = process.cwd(); + const dwfDir = join(cwd, '.dwf'); + + if (!(await fileExists(join(dwfDir, 'config.yml')))) { + ui.error('.dwf/ not found', 'Run devw init to initialize the project'); + process.exitCode = 1; + return; + } + + const watcher = chokidar.watch(['**/*.yml', '**/*.yaml'], { + cwd: dwfDir, + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }, + }); + + await new Promise((resolve) => { + watcher.on('ready', () => resolve()); + }); + + let debounceTimer: ReturnType | undefined; + let lastChangedPath = ''; + + const runCompileOnChange = async (): Promise => { + ui.newline(); + ui.header(`${ICONS.reload} Change detected: .dwf/${lastChangedPath}`); + ui.info('Compiling...'); + ui.newline(); + + try { + const result = await executePipeline({ cwd, tool: options.tool }); + printCompileResult(result); + + const hasFailures = result.results.some((r) => !r.success); + if (hasFailures) { + ui.newline(); + ui.info('Watch mode continues running.'); + } + + printWaiting(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ui.error(message); + ui.info('Watch mode is still running. Fix the error and save again.'); + } + }; + + watcher.on('all', (_event: string, filePath: string) => { + lastChangedPath = filePath; + + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(() => { + void runCompileOnChange(); + }, DEBOUNCE_MS); + }); + + process.on('SIGINT', () => { + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer); + } + void watcher.close(); + process.exit(0); + }); + + ui.newline(); + ui.header(chalk.green('Watching .dwf/ for changes...')); + ui.info('Running initial compile...'); + ui.newline(); + + try { + const result = await executePipeline({ cwd, tool: options.tool }); + printCompileResult(result); + printWaiting(true); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ui.error(message); + ui.info('Watch mode is still running. Fix the error and save again.'); + } +} + +export function registerWatchCommand(program: Command): void { + program + .command('watch') + .description('Watch .dwf/ for changes and recompile automatically') + .option('--tool ', 'Recompile only a specific bridge (claude, cursor, gemini, windsurf, copilot)') + .action((options: WatchOptions) => runWatch(options)); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0bd79cc..f688cdd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,7 @@ import { registerAddCommand } from './commands/add.js'; import { registerRemoveCommand } from './commands/remove.js'; import { registerListCommand } from './commands/list.js'; import { registerExplainCommand } from './commands/explain.js'; +import { registerWatchCommand } from './commands/watch.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version: string }; @@ -25,6 +26,7 @@ registerAddCommand(program); registerRemoveCommand(program); registerListCommand(program); registerExplainCommand(program); +registerWatchCommand(program); program.parse(); diff --git a/packages/cli/src/utils/ui.ts b/packages/cli/src/utils/ui.ts index 7833a85..815c4ff 100644 --- a/packages/cli/src/utils/ui.ts +++ b/packages/cli/src/utils/ui.ts @@ -10,6 +10,7 @@ export const ICONS = { arrow: '\u2192', dash: '\u2014', separator: '\u2500', + reload: '\u27F3', } as const; const INDENT = { diff --git a/packages/cli/tests/commands/compile.test.ts b/packages/cli/tests/commands/compile.test.ts new file mode 100644 index 0000000..dc5e642 --- /dev/null +++ b/packages/cli/tests/commands/compile.test.ts @@ -0,0 +1,149 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm, readFile, access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { executePipeline } from '../../src/commands/compile.js'; + +const VALID_CONFIG = `version: "0.1" +project: + name: "test-project" +tools: + - claude + - cursor +mode: copy +blocks: [] +`; + +const VALID_RULES = `scope: conventions +rules: + - id: named-exports + severity: error + content: Always use named exports. + - id: no-barrel + severity: warning + content: Avoid barrel files. +`; + +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function setupProject(tmpDir: string, config?: string, rules?: string): Promise { + await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); + await writeFile(join(tmpDir, '.dwf', 'config.yml'), config ?? VALID_CONFIG); + if (rules !== undefined) { + await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), rules); + } +} + +describe('executePipeline', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'devw-compile-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns success results for all configured bridges', async () => { + await setupProject(tmpDir, VALID_CONFIG, VALID_RULES); + + const result = await executePipeline({ cwd: tmpDir }); + + assert.ok(result.results.length > 0); + assert.equal(result.activeRuleCount, 2); + assert.ok(result.elapsedMs > 0); + + const claudeResult = result.results.find((r) => r.bridgeId === 'claude'); + assert.ok(claudeResult); + assert.equal(claudeResult.success, true); + assert.ok(await fileExists(join(tmpDir, 'CLAUDE.md'))); + + const cursorResult = result.results.find((r) => r.bridgeId === 'cursor'); + assert.ok(cursorResult); + assert.equal(cursorResult.success, true); + }); + + it('tool option filters to single bridge', async () => { + await setupProject(tmpDir, VALID_CONFIG, VALID_RULES); + + const result = await executePipeline({ cwd: tmpDir, tool: 'claude' }); + + const bridgeIds = new Set(result.results.map((r) => r.bridgeId)); + assert.equal(bridgeIds.size, 1); + assert.ok(bridgeIds.has('claude')); + }); + + it('throws on invalid tool filter', async () => { + await setupProject(tmpDir, VALID_CONFIG, VALID_RULES); + + await assert.rejects( + () => executePipeline({ cwd: tmpDir, tool: 'noexiste' }), + (err: Error) => { + assert.ok(err.message.includes('not configured')); + return true; + } + ); + }); + + it('throws on missing config', async () => { + await assert.rejects( + () => executePipeline({ cwd: tmpDir }), + (err: Error) => { + assert.ok(err.message.length > 0); + return true; + } + ); + }); + + it('throws on invalid YAML syntax', async () => { + await mkdir(join(tmpDir, '.dwf'), { recursive: true }); + await writeFile(join(tmpDir, '.dwf', 'config.yml'), ':\ninvalid: [yaml: {broken'); + + await assert.rejects( + () => executePipeline({ cwd: tmpDir }) + ); + }); + + it('write: false returns content without writing files', async () => { + await setupProject(tmpDir, VALID_CONFIG, VALID_RULES); + + const result = await executePipeline({ cwd: tmpDir, tool: 'claude', write: false }); + + const claudeResult = result.results.find((r) => r.bridgeId === 'claude'); + assert.ok(claudeResult); + assert.equal(claudeResult.success, true); + assert.ok(claudeResult.content); + assert.ok(claudeResult.content.includes('named exports')); + + assert.ok(!(await fileExists(join(tmpDir, 'CLAUDE.md')))); + }); + + it('writes hash file on successful compile', async () => { + await setupProject(tmpDir, VALID_CONFIG, VALID_RULES); + + await executePipeline({ cwd: tmpDir }); + + const hashPath = join(tmpDir, '.dwf', '.cache', 'rules.hash'); + assert.ok(await fileExists(hashPath)); + const hash = await readFile(hashPath, 'utf-8'); + assert.ok(hash.length > 0); + }); + + it('does not write hash when write is false', async () => { + await setupProject(tmpDir, VALID_CONFIG, VALID_RULES); + + await executePipeline({ cwd: tmpDir, write: false }); + + const hashPath = join(tmpDir, '.dwf', '.cache', 'rules.hash'); + assert.ok(!(await fileExists(hashPath))); + }); +}); diff --git a/packages/cli/tests/commands/watch.test.ts b/packages/cli/tests/commands/watch.test.ts new file mode 100644 index 0000000..b1a1b13 --- /dev/null +++ b/packages/cli/tests/commands/watch.test.ts @@ -0,0 +1,272 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { execFile as execFileCb } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFile = promisify(execFileCb); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DEVW = join(__dirname, '..', '..', '..', 'bin', 'devw.js'); +const NODE = process.execPath; + +const VALID_CONFIG = `version: "0.1" +project: + name: "test-project" +tools: + - claude + - cursor +mode: copy +blocks: [] +`; + +const VALID_CONFIG_CLAUDE_ONLY = `version: "0.1" +project: + name: "test-project" +tools: + - claude +mode: copy +blocks: [] +`; + +const VALID_RULES = `scope: conventions +rules: + - id: named-exports + severity: error + content: Always use named exports. +`; + +interface WatchHandle { + process: ChildProcess; + waitForOutput: (pattern: string | RegExp, timeout?: number) => Promise; + getAllOutput: () => string; + kill: () => void; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function spawnWatch(cwd: string, args: string[] = []): WatchHandle { + let output = ''; + const pending: Array<{ + pattern: string | RegExp; + resolve: (value: string | PromiseLike) => void; + reject: (reason: Error) => void; + }> = []; + + const child = spawn(NODE, [DEVW, 'watch', ...args], { + cwd, + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const checkPending = (): void => { + for (let i = pending.length - 1; i >= 0; i--) { + const entry = pending[i]; + if (!entry) continue; + const matches = typeof entry.pattern === 'string' + ? output.includes(entry.pattern) + : entry.pattern.test(output); + if (matches) { + pending.splice(i, 1); + entry.resolve(output); + } + } + }; + + child.stdout?.on('data', (data: Buffer) => { + output += data.toString(); + checkPending(); + }); + + child.stderr?.on('data', (data: Buffer) => { + output += data.toString(); + checkPending(); + }); + + return { + process: child, + getAllOutput: () => output, + waitForOutput: (pattern: string | RegExp, timeout = 10000): Promise => { + const matches = typeof pattern === 'string' + ? output.includes(pattern) + : pattern.test(output); + if (matches) return Promise.resolve(output); + + return new Promise((resolve, reject) => { + const entry = { pattern, resolve, reject }; + pending.push(entry); + + const timer = setTimeout(() => { + const idx = pending.indexOf(entry); + if (idx !== -1) pending.splice(idx, 1); + reject(new Error( + `Timeout waiting for "${String(pattern)}" after ${String(timeout)}ms. Output so far:\n${output}` + )); + }, timeout); + + const originalResolve = entry.resolve; + entry.resolve = (value: string | PromiseLike): void => { + clearTimeout(timer); + originalResolve(value); + }; + }); + }, + kill: (): void => { + child.kill('SIGINT'); + }, + }; +} + +describe('devw watch', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'devw-watch-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('missing .dwf/ exits with code 1', async () => { + try { + await execFile(NODE, [DEVW, 'watch'], { + cwd: tmpDir, + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + }); + assert.fail('Expected process to exit with non-zero code'); + } catch (err: unknown) { + const e = err as { stdout: string; stderr: string; code: number }; + assert.ok( + (e.stderr ?? '').includes('.dwf/ not found') || (e.stdout ?? '').includes('.dwf/ not found'), + `Expected ".dwf/ not found" in output. stderr: ${e.stderr ?? ''}, stdout: ${e.stdout ?? ''}` + ); + assert.equal(e.code, 1); + } + }); + + it('startup displays watch message and initial compile', async () => { + await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); + await writeFile(join(tmpDir, '.dwf', 'config.yml'), VALID_CONFIG_CLAUDE_ONLY); + await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), VALID_RULES); + + const handle = spawnWatch(tmpDir); + + try { + await handle.waitForOutput('Waiting for changes'); + const output = handle.getAllOutput(); + assert.ok(output.includes('Watching .dwf/ for changes')); + assert.ok(output.includes('Running initial compile')); + assert.ok(output.includes('claude')); + assert.ok(output.includes('Ctrl+C to stop')); + } finally { + handle.kill(); + } + }); + + it('file change triggers recompilation', async () => { + await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); + await writeFile(join(tmpDir, '.dwf', 'config.yml'), VALID_CONFIG_CLAUDE_ONLY); + await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), VALID_RULES); + + const handle = spawnWatch(tmpDir); + + try { + await handle.waitForOutput('Waiting for changes'); + await delay(500); + + const updatedRules = `scope: conventions +rules: + - id: named-exports + severity: error + content: Always use named exports in modules. + - id: no-default + severity: warning + content: Avoid default exports. +`; + await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), updatedRules); + + await handle.waitForOutput('Change detected'); + await handle.waitForOutput('Compiling...'); + const output = handle.getAllOutput(); + assert.ok(output.includes('Change detected')); + assert.ok(output.includes('Compiling...')); + } finally { + handle.kill(); + } + }); + + it('validation error does not kill process', async () => { + await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); + await writeFile(join(tmpDir, '.dwf', 'config.yml'), VALID_CONFIG_CLAUDE_ONLY); + await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), VALID_RULES); + + const handle = spawnWatch(tmpDir); + + try { + await handle.waitForOutput('Waiting for changes'); + await delay(500); + + // Write invalid config to trigger validation error + await writeFile(join(tmpDir, '.dwf', 'config.yml'), ':\ninvalid: [yaml: {broken'); + + await handle.waitForOutput('still running', 10000); + const output = handle.getAllOutput(); + assert.ok(output.includes('still running')); + + // Fix the config and verify recompile works + await writeFile(join(tmpDir, '.dwf', 'config.yml'), VALID_CONFIG_CLAUDE_ONLY); + + await handle.waitForOutput(/Done in.*\n.*Done in/s, 10000); + } finally { + handle.kill(); + } + }); + + it('--tool flag filters bridges', async () => { + await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); + await writeFile(join(tmpDir, '.dwf', 'config.yml'), VALID_CONFIG); + await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), VALID_RULES); + + const handle = spawnWatch(tmpDir, ['--tool', 'claude']); + + try { + await handle.waitForOutput('Waiting for changes'); + const output = handle.getAllOutput(); + assert.ok(output.includes('claude')); + assert.ok(!output.includes('cursor')); + } finally { + handle.kill(); + } + }); + + it('SIGINT exits cleanly', async () => { + await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); + await writeFile(join(tmpDir, '.dwf', 'config.yml'), VALID_CONFIG_CLAUDE_ONLY); + await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), VALID_RULES); + + const handle = spawnWatch(tmpDir); + + try { + await handle.waitForOutput('Watching .dwf/ for changes'); + } finally { + handle.kill(); + } + + const exitCode = await new Promise((resolve) => { + handle.process.on('exit', (code) => resolve(code)); + if (handle.process.exitCode !== null) { + resolve(handle.process.exitCode); + } + }); + + assert.ok(exitCode === null || exitCode === 0, `Expected clean exit, got code ${String(exitCode)}`); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0f34fa..2ee518a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: chalk: specifier: ^5.4.0 version: 5.6.2 + chokidar: + specifier: ^3.6.0 + version: 3.6.0 commander: specifier: ^13.0.0 version: 13.1.0 @@ -265,6 +268,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -279,6 +286,10 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -290,6 +301,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -359,6 +374,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -382,6 +402,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -443,6 +467,10 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -507,6 +535,10 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -913,6 +945,11 @@ snapshots: dependencies: color-convert: 2.0.1 + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -925,6 +962,8 @@ snapshots: dependencies: is-windows: 1.0.2 + binary-extensions@2.3.0: {} + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -933,6 +972,18 @@ snapshots: chardet@2.1.1: {} + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + ci-info@3.9.0: {} cli-width@4.1.0: {} @@ -1001,6 +1052,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.3: + optional: true + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -1024,6 +1078,10 @@ snapshots: ignore@5.3.2: {} + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -1072,6 +1130,8 @@ snapshots: mute-stream@2.0.0: {} + normalize-path@3.0.0: {} + outdent@0.5.0: {} p-filter@2.1.0: @@ -1119,6 +1179,10 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + resolve-from@5.0.0: {} reusify@1.1.0: {} From 0d4c65835dbeef9be3036fbb982b42f86196df70 Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Tue, 10 Feb 2026 17:07:09 +0100 Subject: [PATCH 2/4] docs: add watch command documentation - Add devw watch row to README commands table - Create docs/commands/watch.mdx for Mintlify docs site - Register watch page in docs.json navigation between compile and doctor --- CLAUDE.md | 5 ++-- README.md | 1 + docs/commands/watch.mdx | 62 +++++++++++++++++++++++++++++++++++++++++ docs/docs.json | 1 + 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 docs/commands/watch.mdx diff --git a/CLAUDE.md b/CLAUDE.md index 33bfd3b..456c5aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,9 @@ pnpm dev # dev mode - `docs/internal/CLI_SPEC.md` → v0.1 specification (COMPLETE) - `docs/internal/CLI_SPEC_v0.2.md` → v0.2 specification (COMPLETE) -- `docs/internal/CLI_SPEC_v0.2.1.md` → v0.2.1 UX polish specification (ACTIVE — implement this) -- `docs/internal/DOCS_SPEC.md` → Mintlify documentation spec +- `docs/internal/CLI_SPEC_v0.2.1.md` → v0.2.1 UX polish specification (COMPLETE) +- `docs/internal/DOCS_SPEC.md` → Mintlify documentation spec (COMPLETE) +- `docs/internal/WATCH_SPEC.md` → v0.3 watch mode specification (ACTIVE — implement this) - `docs/internal/DECISIONS.md` → accepted decisions (source of truth if conflict) - `docs/internal/` is gitignored — internal specs not published diff --git a/README.md b/README.md index c8f6826..ffbcd74 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ You define rules in YAML. The compiler generates each editor's native format. Ch | `devw add ` | Install a prebuilt rule block | | `devw remove ` | Remove a rule block | | `devw compile` | Generate editor-specific rule files | +| `devw watch` | Watch `.dwf/` and recompile on changes | | `devw doctor` | Validate config and detect rule drift | | `devw list rules` | List all active rules | | `devw list blocks` | List installed blocks | diff --git a/docs/commands/watch.mdx b/docs/commands/watch.mdx new file mode 100644 index 0000000..23bbcb5 --- /dev/null +++ b/docs/commands/watch.mdx @@ -0,0 +1,62 @@ +--- +title: "devw watch" +description: "Watch .dwf/ and recompile automatically on changes" +--- + +```bash +devw watch +``` + +Watches your `.dwf/` directory and recompiles automatically whenever a rule file changes. This keeps your editor config files always in sync without running `devw compile` manually. + +## What it watches + +All `.yml` and `.yaml` files inside `.dwf/`, including: + +- `.dwf/config.yml` +- `.dwf/rules/**/*.yml` + +## Behavior + +1. Runs an initial compile on startup +2. Watches `.dwf/` for file changes (add, edit, delete) +3. Debounces rapid changes into a single recompile +4. Prints per-bridge results after each compile +5. Errors are printed but never stop the watch process +6. `Ctrl+C` exits cleanly + +## Flags + +| Flag | Description | +|------|-------------| +| `--tool claude` | Recompile only a specific bridge | + +## Output + +### On change + +``` +⟳ Change detected: .dwf/rules/conventions.yml +Compiling... +✔ claude → CLAUDE.md +✔ cursor → .cursor/rules/devworkflows.mdc +✔ gemini → GEMINI.md +Done in 42ms +``` + +### On error + +``` +✖ Duplicate rule id "react-no-inline-styles" +Watch mode is still running. Fix the error and save again. +``` + +## Examples + +```bash +# Watch all configured tools +devw watch + +# Watch only Claude bridge +devw watch --tool claude +``` diff --git a/docs/docs.json b/docs/docs.json index b9042f3..52463bd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -44,6 +44,7 @@ "pages": [ "commands/init", "commands/compile", + "commands/watch", "commands/doctor", "commands/explain", "commands/add", From 7bd8046a74b70011223c07a16452808930a3f967 Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Tue, 10 Feb 2026 17:07:17 +0100 Subject: [PATCH 3/4] feat(landing): add watch tab to terminal demo and fix focus styles - Add watch tab to terminal demo section between compile and doctor - Update command count from six to seven - Add global *:focus-visible rule using var(--accent) for consistent styling - Fix active tab border to use border: 1px solid var(--accent) --- apps/landing/index.html | 10 ++++++---- apps/landing/scripts/terminal.js | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/landing/index.html b/apps/landing/index.html index 0356c4e..e3814d9 100644 --- a/apps/landing/index.html +++ b/apps/landing/index.html @@ -98,6 +98,7 @@ } ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } * { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } +*:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; } /* Reduced motion */ @media (prefers-reduced-motion: reduce) { @@ -1050,8 +1051,8 @@ } .term-tab.active { color: var(--accent); - border-bottom-color: var(--accent); - background: rgba(34,211,126,0.03); + border: 1px solid var(--accent); + background: rgba(34,211,126,0.08); } .term-tab-arrow { font-weight: 700; @@ -1439,7 +1440,7 @@