diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 26d696b..5ea860c 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -13,6 +13,7 @@ import { convert } from '../core/converter.js'; import { isAssetType, parseAssetFrontmatter } from '../core/assets.js'; import { fileExists } from '../utils/fs.js'; import { readConfig } from '../core/parser.js'; +import { resolveContext } from '../core/resolve-context.js'; import { selectPrompt, multiselectPrompt, @@ -915,14 +916,20 @@ export async function runAdd(ruleArg: string | undefined, options: AddOptions): return; } - const cwd = process.cwd(); + const resolved = await resolveContext(process.cwd()); - if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { - ui.error('.dwf/config.yml not found', 'Run devw init to initialize the project'); + if (!resolved) { + ui.error('No devw configuration found.', 'Run "devw init" to set up a project or global configuration.'); process.exitCode = 1; return; } + const cwd = resolved.configRoot; + + if (resolved.globalMode) { + ui.info('Adding to global config (~/.dwf)'); + } + if (!ruleArg) { if (!isInteractiveSession()) { ui.error('No rule specified', 'Usage: devw add /'); diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index f206c42..6bff71d 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -1,5 +1,5 @@ import { mkdir, writeFile, readFile, symlink, unlink } from 'node:fs/promises'; -import { join, dirname, basename } from 'node:path'; +import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import type { Command } from 'commander'; import pc from 'picocolors'; @@ -19,6 +19,7 @@ import { cleanStaleFiles } from '../core/scope-filename.js'; import { detectLegacyFiles, migrateLegacyFiles } from '../core/cleanup.js'; import { buildCanonicalOutputs, writeCanonical } from '../core/canonical.js'; import { fileExists } from '../utils/fs.js'; +import { resolveContext } from '../core/resolve-context.js'; import * as ui from '../utils/ui.js'; import { ICONS } from '../utils/ui.js'; import { renderTable } from '../utils/table.js'; @@ -95,6 +96,7 @@ interface CompileContext { configRoot: string; outputRoot: string; globalMode: boolean; + dwfDir: string; } function toCompileSummaryRows(result: CompileResult): string[][] { @@ -124,26 +126,17 @@ function toCompileSummaryRows(result: CompileResult): string[][] { } async function resolveCompileContext(cwd: string): Promise { - const projectConfigPath = join(cwd, '.dwf', 'config.yml'); - if (await fileExists(projectConfigPath)) { - return { - configRoot: cwd, - outputRoot: cwd, - globalMode: false, - }; + const resolved = await resolveContext(cwd); + if (!resolved) { + throw new Error('No devw configuration found.\nRun "devw init" to set up a project or global configuration.'); } - const inGlobalConfigDir = basename(cwd) === '.dwf'; - const globalConfigPath = join(cwd, 'config.yml'); - if (inGlobalConfigDir && await fileExists(globalConfigPath)) { - return { - configRoot: cwd, - outputRoot: homedir(), - globalMode: true, - }; - } - - throw new Error('.dwf/config.yml not found. Run devw init to initialize the project'); + return { + configRoot: resolved.configRoot, + outputRoot: resolved.outputRoot, + globalMode: resolved.globalMode, + dwfDir: resolved.dwfDir, + }; } export async function executePipeline(options: PipelineOptions): Promise { @@ -151,7 +144,7 @@ export async function executePipeline(options: PipelineOptions): Promise { const context = await resolveCompileContext(cwd); if (options.verbose) { - const config = context.globalMode ? await readConfigFromDwfDir(context.configRoot) : await readConfig(context.configRoot); + const config = context.globalMode ? await readConfigFromDwfDir(context.dwfDir) : await readConfig(context.configRoot); const projectRules = await readRules(context.configRoot); const globalRules = context.globalMode || config.global === false ? [] diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 9045d63..953dbd0 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -12,6 +12,7 @@ import { copilotBridge } from '../bridges/copilot.js'; import type { Bridge, DirectoryBridge, ProjectConfig, PulledEntry, AssetEntry, Rule } from '../bridges/types.js'; import { getBridgeOutputPaths, isDirectoryBridge } from '../bridges/types.js'; import { fileExists } from '../utils/fs.js'; +import { resolveContext } from '../core/resolve-context.js'; import { isValidScope } from '../core/schema.js'; import { buildCanonicalOutputs } from '../core/canonical.js'; import { detectLegacyFiles } from '../core/cleanup.js'; @@ -479,14 +480,32 @@ export async function runDoctor(): Promise { const results: CheckResult[] = []; let hasFailed = false; + // Resolve context: local project or global ~/.dwf + const resolved = await resolveContext(cwd); + + if (!resolved) { + ui.error('No devw configuration found.', 'Run "devw init" to set up a project or global configuration.'); + process.exitCode = 1; + return; + } + + const effectiveCwd = resolved.configRoot; + + if (resolved.globalMode) { + ui.info('Running in global mode (~/.dwf)'); + ui.newline(); + } + // Check 1: .dwf/config.yml exists - const configExistsResult = await checkConfigExists(cwd); + const configExistsResult = await checkConfigExists(effectiveCwd); results.push(configExistsResult); if (!configExistsResult.passed) { for (const r of results) { ui.check(r.passed, r.message, r.skipped); - if (!r.passed) hasFailed = true; + if (!r.passed) { + hasFailed = true; + } } printSummary(results, startTime); process.exitCode = 1; @@ -494,29 +513,31 @@ export async function runDoctor(): Promise { } // Check 2: config.yml is valid - const configValidResult = await checkConfigValid(cwd); + const configValidResult = await checkConfigValid(effectiveCwd); results.push(configValidResult); // Check 3: Rule files are valid YAML - const rulesValidResult = await checkRulesValid(cwd); + const rulesValidResult = await checkRulesValid(effectiveCwd); results.push(rulesValidResult); if (!configValidResult.passed) { for (const r of results) { ui.check(r.passed, r.message, r.skipped); - if (!r.passed) hasFailed = true; + if (!r.passed) { + hasFailed = true; + } } printSummary(results, startTime); process.exitCode = 1; return; } - const config = await readConfig(cwd); + const config = await readConfig(effectiveCwd); // Load rules for remaining checks let rules: Rule[] = []; try { - rules = await readRules(cwd); + rules = await readRules(effectiveCwd); } catch { // readRules may fail if rules dir is missing; that's ok } @@ -534,43 +555,45 @@ export async function runDoctor(): Promise { results.push(bridgeResult); // Check 7: Symlinks valid (conditional on mode) - const symlinkResult = await checkSymlinks(cwd, config); + const symlinkResult = await checkSymlinks(effectiveCwd, config); results.push(symlinkResult); // Check 8: Pulled files exist - const pulledResult = await checkPulledFilesExist(cwd, config.pulled); + const pulledResult = await checkPulledFilesExist(effectiveCwd, config.pulled); results.push(pulledResult); // Check 9: Asset files exist - const assetResult = await checkAssetFilesExist(cwd, config.assets); + const assetResult = await checkAssetFilesExist(effectiveCwd, config.assets); results.push(assetResult); // Check 10: Hash sync (conditional on compiled files existing) - const hashResult = await checkHashSync(cwd, rules); + const hashResult = await checkHashSync(effectiveCwd, rules); results.push(hashResult); // Check 11: Canonical output exists (skip if no rules) if (rules.length > 0) { - const canonicalExistsResult = await checkCanonicalExists(cwd); + const canonicalExistsResult = await checkCanonicalExists(effectiveCwd); results.push(canonicalExistsResult); // Check 12: Canonical and native outputs are synchronized - const canonicalSyncResult = await checkCanonicalSync(cwd, rules, config); + const canonicalSyncResult = await checkCanonicalSync(effectiveCwd, rules, config); results.push(canonicalSyncResult); } // Check 13: Legacy migration has no pending files - const legacyResult = await checkLegacyMigration(cwd); + const legacyResult = await checkLegacyMigration(effectiveCwd); results.push(legacyResult); // Check 14: Native files have valid frontmatter for their editor - const frontmatterResult = await checkNativeFrontmatter(cwd, config); + const frontmatterResult = await checkNativeFrontmatter(effectiveCwd, config); results.push(frontmatterResult); // Output for (const r of results) { ui.check(r.passed, r.message, r.skipped); - if (!r.passed) hasFailed = true; + if (!r.passed) { + hasFailed = true; + } } printSummary(results, startTime); diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index c9f102c..e183657 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -1,4 +1,3 @@ -import { join } from 'node:path'; import type { Command } from 'commander'; import pc from 'picocolors'; import { readConfig, readRules } from '../core/parser.js'; @@ -10,7 +9,7 @@ import { geminiBridge } from '../bridges/gemini.js'; import { windsurfBridge } from '../bridges/windsurf.js'; import { copilotBridge } from '../bridges/copilot.js'; import { filterRules, groupByScope } from '../core/helpers.js'; -import { fileExists } from '../utils/fs.js'; +import { resolveContext } from '../core/resolve-context.js'; import * as ui from '../utils/ui.js'; import { ICONS } from '../utils/ui.js'; @@ -57,14 +56,15 @@ function formatSeparator(toolId: string): string { } async function runExplain(options: ExplainOptions): Promise { - const cwd = process.cwd(); + const resolved = await resolveContext(process.cwd()); - if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { - ui.error('.dwf/config.yml not found', 'Run devw init to initialize the project'); + if (!resolved) { + ui.error('No devw configuration found.', 'Run "devw init" to set up a project or global configuration.'); process.exitCode = 1; return; } + const cwd = resolved.configRoot; const config = await readConfig(cwd); const rules = await readRules(cwd); diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 6d083f4..0ebf8f0 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -1,8 +1,7 @@ -import { join } from 'node:path'; import type { Command } from 'commander'; import pc from 'picocolors'; import { readConfig, readRules } from '../core/parser.js'; -import { fileExists } from '../utils/fs.js'; +import { resolveContext } from '../core/resolve-context.js'; import { claudeBridge } from '../bridges/claude.js'; import { cursorBridge } from '../bridges/cursor.js'; import { geminiBridge } from '../bridges/gemini.js'; @@ -16,18 +15,21 @@ import { ICONS } from '../utils/ui.js'; const BRIDGES: Bridge[] = [claudeBridge, cursorBridge, geminiBridge, windsurfBridge, copilotBridge]; -async function ensureConfig(cwd: string): Promise { - if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { - ui.error('.dwf/config.yml not found', 'Run devw init to initialize the project'); +async function ensureConfig(): Promise { + const resolved = await resolveContext(process.cwd()); + if (!resolved) { + ui.error('No devw configuration found.', 'Run "devw init" to set up a project or global configuration.'); process.exitCode = 1; - return false; + return null; } - return true; + return resolved.configRoot; } async function listRules(): Promise { - const cwd = process.cwd(); - if (!(await ensureConfig(cwd))) return; + const cwd = await ensureConfig(); + if (!cwd) { + return; + } let rules; try { @@ -67,8 +69,10 @@ async function listBlocks(): Promise { } async function listTools(): Promise { - const cwd = process.cwd(); - if (!(await ensureConfig(cwd))) return; + const cwd = await ensureConfig(); + if (!cwd) { + return; + } const config = await readConfig(cwd); let activeScopeCount = 0; @@ -119,8 +123,10 @@ function getAssetOutputHint(type: string, name: string): string { } async function listAssets(typeFilter?: string): Promise { - const cwd = process.cwd(); - if (!(await ensureConfig(cwd))) return; + const cwd = await ensureConfig(); + if (!cwd) { + return; + } const config = await readConfig(cwd); diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index 04e2b52..ecaf99a 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -4,6 +4,7 @@ import type { Command } from 'commander'; import { parse, stringify } from 'yaml'; import { readConfig } from '../core/parser.js'; import { fileExists } from '../utils/fs.js'; +import { resolveContext } from '../core/resolve-context.js'; import { isAssetType, removeAsset } from '../core/assets.js'; import { validateInput } from './add.js'; import { multiselectPrompt, confirmPrompt, introPrompt, outroPrompt, isInteractiveSession } from '../utils/prompt.js'; @@ -52,18 +53,24 @@ async function removeRule(cwd: string, path: string): Promise { } export async function runRemove(ruleArg: string | undefined): Promise { - const cwd = process.cwd(); - if (isInteractiveSession()) { introPrompt('Remove rules or assets'); } - if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { - ui.error('.dwf/config.yml not found', 'Run devw init to initialize the project'); + const resolved = await resolveContext(process.cwd()); + + if (!resolved) { + ui.error('No devw configuration found.', 'Run "devw init" to set up a project or global configuration.'); process.exitCode = 1; return; } + const cwd = resolved.configRoot; + + if (resolved.globalMode) { + ui.info('Removing from global config (~/.dwf)'); + } + const config = await readConfig(cwd); if (!ruleArg) { diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts index 9b24866..940d75e 100644 --- a/packages/cli/src/commands/watch.ts +++ b/packages/cli/src/commands/watch.ts @@ -1,10 +1,9 @@ -import { join } from 'node:path'; import type { Command } from 'commander'; import pc from 'picocolors'; import chokidar from 'chokidar'; import { executePipeline } from './compile.js'; import type { CompileResult } from './compile.js'; -import { fileExists } from '../utils/fs.js'; +import { resolveContext } from '../core/resolve-context.js'; import * as ui from '../utils/ui.js'; import { ICONS } from '../utils/ui.js'; @@ -35,15 +34,21 @@ function printWaiting(withHint = false): void { } async function runWatch(options: WatchOptions): Promise { - const cwd = process.cwd(); - const dwfDir = join(cwd, '.dwf'); + const resolved = await resolveContext(process.cwd()); - if (!(await fileExists(join(dwfDir, 'config.yml')))) { - ui.error('.dwf/ not found', 'Run devw init to initialize the project'); + if (!resolved) { + ui.error('No devw configuration found.', 'Run "devw init" to set up a project or global configuration.'); process.exitCode = 1; return; } + const cwd = resolved.configRoot; + const dwfDir = resolved.dwfDir; + + if (resolved.globalMode) { + ui.info('Watching global config (~/.dwf)'); + } + const watcher = chokidar.watch(['**/*.yml', '**/*.yaml', 'assets/**/*.md', 'assets/**/*.json'], { cwd: dwfDir, ignoreInitial: true, diff --git a/packages/cli/src/core/resolve-context.ts b/packages/cli/src/core/resolve-context.ts new file mode 100644 index 0000000..6be9a5b --- /dev/null +++ b/packages/cli/src/core/resolve-context.ts @@ -0,0 +1,35 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { fileExists } from '../utils/fs.js'; + +export interface ResolvedContext { + configRoot: string; + outputRoot: string; + globalMode: boolean; + dwfDir: string; +} + +export async function resolveContext(cwd: string): Promise { + const localConfig = join(cwd, '.dwf', 'config.yml'); + if (await fileExists(localConfig)) { + return { + configRoot: cwd, + outputRoot: cwd, + globalMode: false, + dwfDir: join(cwd, '.dwf'), + }; + } + + const home = homedir(); + const globalConfig = join(home, '.dwf', 'config.yml'); + if (await fileExists(globalConfig)) { + return { + configRoot: home, + outputRoot: home, + globalMode: true, + dwfDir: join(home, '.dwf'), + }; + } + + return null; +} diff --git a/packages/cli/tests/commands/explain.test.ts b/packages/cli/tests/commands/explain.test.ts index 421e570..3da0c5c 100644 --- a/packages/cli/tests/commands/explain.test.ts +++ b/packages/cli/tests/commands/explain.test.ts @@ -135,7 +135,7 @@ describe('devw explain', () => { const result = await run(['explain'], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('config.yml not found')); + assert.ok(result.stderr.includes('No devw configuration found')); }); it('errors when --tool is not configured', async () => { diff --git a/packages/cli/tests/commands/watch.test.ts b/packages/cli/tests/commands/watch.test.ts index b1a1b13..bc816bc 100644 --- a/packages/cli/tests/commands/watch.test.ts +++ b/packages/cli/tests/commands/watch.test.ts @@ -145,8 +145,8 @@ describe('devw watch', () => { } 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 ?? ''}` + (e.stderr ?? '').includes('No devw configuration found') || (e.stdout ?? '').includes('No devw configuration found'), + `Expected "No devw configuration found" in output. stderr: ${e.stderr ?? ''}, stdout: ${e.stdout ?? ''}` ); assert.equal(e.code, 1); } diff --git a/packages/cli/tests/core/resolve-context.test.ts b/packages/cli/tests/core/resolve-context.test.ts new file mode 100644 index 0000000..7ebfa82 --- /dev/null +++ b/packages/cli/tests/core/resolve-context.test.ts @@ -0,0 +1,85 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveContext } from '../../src/core/resolve-context.js'; + +describe('resolveContext', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'resolve-context-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('returns local context when .dwf/config.yml exists in cwd', async () => { + const dwfDir = join(tempDir, '.dwf'); + await mkdir(dwfDir, { recursive: true }); + await writeFile(join(dwfDir, 'config.yml'), 'version: "0.1"', 'utf-8'); + + const result = await resolveContext(tempDir); + + assert.ok(result); + assert.equal(result.configRoot, tempDir); + assert.equal(result.outputRoot, tempDir); + assert.equal(result.globalMode, false); + assert.equal(result.dwfDir, dwfDir); + }); + + it('returns null when neither local nor global config exists', async () => { + // tempDir has no .dwf/config.yml, and we can't control homedir() easily, + // but if neither exists, we expect null. Since the user's home may or may + // not have ~/.dwf, we test with a cwd that definitely has no config. + // The global fallback depends on the actual home directory, so this test + // verifies the local-not-found path. A more isolated test would mock homedir. + const result = await resolveContext(tempDir); + + // If the user's home has ~/.dwf/config.yml, this won't be null. + // We accept that — the important assertion is that local is checked first. + if (result === null) { + assert.equal(result, null); + } else { + // Global fallback activated — verify it's global mode + assert.equal(result.globalMode, true); + } + }); + + it('prefers local config over global when both exist', async () => { + // Create local config + const dwfDir = join(tempDir, '.dwf'); + await mkdir(dwfDir, { recursive: true }); + await writeFile(join(dwfDir, 'config.yml'), 'version: "0.1"', 'utf-8'); + + const result = await resolveContext(tempDir); + + assert.ok(result); + assert.equal(result.configRoot, tempDir); + assert.equal(result.globalMode, false); + }); + + it('returns correct dwfDir path for local config', async () => { + const dwfDir = join(tempDir, '.dwf'); + await mkdir(dwfDir, { recursive: true }); + await writeFile(join(dwfDir, 'config.yml'), 'version: "0.1"', 'utf-8'); + + const result = await resolveContext(tempDir); + + assert.ok(result); + assert.equal(result.dwfDir, join(tempDir, '.dwf')); + }); + + it('returns null for a directory with empty .dwf (no config.yml)', async () => { + await mkdir(join(tempDir, '.dwf'), { recursive: true }); + + const result = await resolveContext(tempDir); + + // Same caveat as above — global might exist + if (result !== null) { + assert.equal(result.globalMode, true); + } + }); +}); diff --git a/packages/cli/tests/ui/output.test.ts b/packages/cli/tests/ui/output.test.ts index 470c1b6..1fd0455 100644 --- a/packages/cli/tests/ui/output.test.ts +++ b/packages/cli/tests/ui/output.test.ts @@ -296,7 +296,7 @@ describe('output format: error messages', () => { assert.equal(result.exitCode, 1); assert.ok(result.stderr.includes('\u2717'), 'should have error icon'); - assert.ok(result.stderr.includes('config.yml not found'), 'should show error message'); + assert.ok(result.stderr.includes('No devw configuration found'), 'should show error message'); assert.ok(result.stderr.includes('devw init'), 'should show hint'); }); diff --git a/packages/cli/tests/utils/banner.test.ts b/packages/cli/tests/utils/banner.test.ts index a251068..bb0a927 100644 --- a/packages/cli/tests/utils/banner.test.ts +++ b/packages/cli/tests/utils/banner.test.ts @@ -35,12 +35,12 @@ describe('renderBanner', () => { }); const expected = [ - '\u001b[38;5;45m██████╗ ███████╗██╗ ██╗██╗ ██╗\u001b[0m', - '\u001b[38;5;76m██╔══██╗██╔════╝██║ ██║██║ ██║\u001b[0m', - '\u001b[38;5;107m██║ ██║█████╗ ██║ ██║██║ █╗ ██║\u001b[0m', - '\u001b[38;5;139m██║ ██║██╔══╝ ╚██╗ ██╔╝██║███╗██║\u001b[0m', - '\u001b[38;5;170m██████╔╝███████╗ ╚████╔╝ ╚███╔███╔╝\u001b[0m', - '\u001b[38;5;201m╚═════╝ ╚══════╝ ╚═══╝ ╚══╝╚══╝\u001b[0m', + '\u001b[38;5;252m██████╗ ███████╗██╗ ██╗██╗ ██╗\u001b[0m', + '\u001b[38;5;250m██╔══██╗██╔════╝██║ ██║██║ ██║\u001b[0m', + '\u001b[38;5;247m██║ ██║█████╗ ██║ ██║██║ █╗ ██║\u001b[0m', + '\u001b[38;5;245m██║ ██║██╔══╝ ╚██╗ ██╔╝██║███╗██║\u001b[0m', + '\u001b[38;5;242m██████╔╝███████╗ ╚████╔╝ ╚███╔███╔╝\u001b[0m', + '\u001b[38;5;240m╚═════╝ ╚══════╝ ╚═══╝ ╚══╝╚══╝\u001b[0m', ].join('\n'); assert.equal(renderBanner(), expected);