From 8eab0871f62bb35faf939f1c43468f04aecc6ea5 Mon Sep 17 00:00:00 2001 From: Kaushik Gnanaskandan Date: Thu, 26 Mar 2026 11:02:24 -0700 Subject: [PATCH 1/2] feat(relay): improve init command UX Fix frozen spinner during package install by replacing spawnSync with async spawn, keeping the event loop free so the clack spinner animates. Add gitignore step to the init wizard that ensures .domscribe is listed in the project's .gitignore (creates the file if needed, appends if missing, no-ops if already present). --- .../src/cli/init/framework-step.spec.ts | 44 ++++-- .../src/cli/init/framework-step.ts | 41 ++++- .../src/cli/init/gitignore-step.spec.ts | 141 ++++++++++++++++++ .../src/cli/init/gitignore-step.ts | 70 +++++++++ .../src/cli/init/init-wizard.spec.ts | 14 +- .../src/cli/init/init-wizard.ts | 4 +- 6 files changed, 293 insertions(+), 21 deletions(-) create mode 100644 packages/domscribe-relay/src/cli/init/gitignore-step.spec.ts create mode 100644 packages/domscribe-relay/src/cli/init/gitignore-step.ts diff --git a/packages/domscribe-relay/src/cli/init/framework-step.spec.ts b/packages/domscribe-relay/src/cli/init/framework-step.spec.ts index 836fb3c..fae4faa 100644 --- a/packages/domscribe-relay/src/cli/init/framework-step.spec.ts +++ b/packages/domscribe-relay/src/cli/init/framework-step.spec.ts @@ -1,12 +1,31 @@ -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import * as clack from '@clack/prompts'; import { runFrameworkStep } from './framework-step.js'; import type { InitOptions } from './types.js'; +/** + * Create a fake ChildProcess that emits 'close' on the next tick. + */ +function createFakeChild(exitCode = 0, stderrData = ''): EventEmitter { + const child = new EventEmitter(); + const stderr = new EventEmitter(); + (child as unknown as Record).stderr = stderr; + + process.nextTick(() => { + if (stderrData) { + stderr.emit('data', Buffer.from(stderrData)); + } + child.emit('close', exitCode); + }); + + return child; +} + vi.mock('node:child_process', () => ({ - spawnSync: vi.fn().mockReturnValue({ status: 0 }), + spawn: vi.fn(() => createFakeChild(0)), })); vi.mock('@clack/prompts', () => ({ @@ -40,9 +59,9 @@ describe('runFrameworkStep', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(clack.isCancel).mockReturnValue(false); - vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType< - typeof spawnSync - >); + vi.mocked(spawn).mockImplementation( + () => createFakeChild(0) as ReturnType, + ); }); describe('interactive mode', () => { @@ -109,7 +128,7 @@ describe('runFrameworkStep', () => { // Assert expect(clack.select).not.toHaveBeenCalled(); - expect(spawnSync).toHaveBeenCalledWith( + expect(spawn).toHaveBeenCalledWith( 'pnpm', ['add', '-D', '@domscribe/nuxt'], expect.objectContaining({ cwd: '/project' }), @@ -128,7 +147,7 @@ describe('runFrameworkStep', () => { await runFrameworkStep(baseOptions, '/project'); // Assert - expect(spawnSync).toHaveBeenCalledWith( + expect(spawn).toHaveBeenCalledWith( 'npm', ['install', '-D', '@domscribe/next'], expect.objectContaining({ cwd: '/project' }), @@ -145,7 +164,7 @@ describe('runFrameworkStep', () => { await runFrameworkStep(baseOptions, '/project'); // Assert - expect(spawnSync).toHaveBeenCalledWith( + expect(spawn).toHaveBeenCalledWith( 'pnpm', ['add', '-D', '@domscribe/react'], expect.objectContaining({ cwd: '/project' }), @@ -157,10 +176,9 @@ describe('runFrameworkStep', () => { vi.mocked(clack.select) .mockResolvedValueOnce('nuxt') .mockResolvedValueOnce('npm'); - vi.mocked(spawnSync).mockReturnValue({ - status: 1, - stderr: Buffer.from('ERR'), - } as unknown as ReturnType); + vi.mocked(spawn).mockImplementation( + () => createFakeChild(1, 'ERR') as ReturnType, + ); // Act await runFrameworkStep(baseOptions, '/project'); @@ -224,7 +242,7 @@ describe('runFrameworkStep', () => { await runFrameworkStep(options, '/project'); // Assert - expect(spawnSync).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); expect(clack.log.info).toHaveBeenCalledWith( expect.stringContaining('npm install -D @domscribe/nuxt'), ); diff --git a/packages/domscribe-relay/src/cli/init/framework-step.ts b/packages/domscribe-relay/src/cli/init/framework-step.ts index d62a638..f4e616a 100644 --- a/packages/domscribe-relay/src/cli/init/framework-step.ts +++ b/packages/domscribe-relay/src/cli/init/framework-step.ts @@ -2,7 +2,7 @@ * Framework selection, package installation, and config snippet step. * @module @domscribe/relay/cli/init/framework-step */ -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import * as clack from '@clack/prompts'; import { highlight } from 'cli-highlight'; @@ -49,6 +49,38 @@ function buildInstallCommand(pm: PackageManagerId, pkg: string): string { return `${pmConfig?.installCmd ?? 'npm install -D'} ${pkg}`; } +/** + * Run a package install command asynchronously, collecting stderr. + * Using async spawn (not spawnSync) keeps the event loop free so the + * clack spinner can animate during the install. + */ +function runInstall( + bin: string, + args: string[], + cwd: string, +): Promise<{ exitCode: number; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(bin, args, { + stdio: ['ignore', 'ignore', 'pipe'], + cwd, + }); + const chunks: Buffer[] = []; + + child.stderr.on('data', (chunk: Buffer) => chunks.push(chunk)); + + child.on('close', (code) => { + resolve({ + exitCode: code ?? 1, + stderr: Buffer.concat(chunks).toString().trim(), + }); + }); + + child.on('error', (err) => { + resolve({ exitCode: 1, stderr: err.message }); + }); + }); +} + /** * Prompt the user to confirm or override the detected package manager. */ @@ -122,16 +154,15 @@ export async function runFrameworkStep( return; } - // Run the install + // Run the install asynchronously so the spinner can animate const spinner = clack.spinner(); spinner.start(`Installing ${framework.package}...`); const [bin, ...args] = installCmd.split(' '); - const result = spawnSync(bin, args, { stdio: 'pipe', cwd }); + const { exitCode, stderr } = await runInstall(bin, args, cwd); - if (result.status !== 0) { + if (exitCode !== 0) { spinner.stop(`Failed to install ${framework.package}.`); - const stderr = result.stderr?.toString().trim(); if (stderr) { clack.log.warn(stderr); } diff --git a/packages/domscribe-relay/src/cli/init/gitignore-step.spec.ts b/packages/domscribe-relay/src/cli/init/gitignore-step.spec.ts new file mode 100644 index 0000000..4f59f3e --- /dev/null +++ b/packages/domscribe-relay/src/cli/init/gitignore-step.spec.ts @@ -0,0 +1,141 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +import * as clack from '@clack/prompts'; + +import { runGitignoreStep } from './gitignore-step.js'; +import type { InitOptions } from './types.js'; + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('@clack/prompts', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + }, +})); + +const baseOptions: InitOptions = { force: false, dryRun: false }; + +describe('runGitignoreStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should create .gitignore when it does not exist', () => { + // Arrange + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error('ENOENT'); + }); + + // Act + runGitignoreStep(baseOptions, '/project'); + + // Assert + expect(writeFileSync).toHaveBeenCalledWith( + '/project/.gitignore', + '# Domscribe artifacts\n.domscribe\n', + 'utf-8', + ); + expect(clack.log.success).toHaveBeenCalledWith( + 'Created .domscribe to .gitignore', + ); + }); + + it('should append to existing .gitignore that lacks the entry', () => { + // Arrange + vi.mocked(readFileSync).mockReturnValue('node_modules\ndist\n'); + + // Act + runGitignoreStep(baseOptions, '/project'); + + // Assert + expect(writeFileSync).toHaveBeenCalledWith( + '/project/.gitignore', + 'node_modules\ndist\n\n# Domscribe artifacts\n.domscribe\n', + 'utf-8', + ); + expect(clack.log.success).toHaveBeenCalledWith( + 'Added .domscribe to .gitignore', + ); + }); + + it('should add a newline separator when file does not end with one', () => { + // Arrange + vi.mocked(readFileSync).mockReturnValue('node_modules'); + + // Act + runGitignoreStep(baseOptions, '/project'); + + // Assert + expect(writeFileSync).toHaveBeenCalledWith( + '/project/.gitignore', + 'node_modules\n\n# Domscribe artifacts\n.domscribe\n', + 'utf-8', + ); + }); + + it('should skip when .domscribe is already present', () => { + // Arrange + vi.mocked(readFileSync).mockReturnValue('node_modules\n.domscribe\n'); + + // Act + runGitignoreStep(baseOptions, '/project'); + + // Assert + expect(writeFileSync).not.toHaveBeenCalled(); + expect(clack.log.info).toHaveBeenCalledWith( + '.gitignore already contains .domscribe', + ); + }); + + it('should skip when .domscribe/ (with trailing slash) is present', () => { + // Arrange + vi.mocked(readFileSync).mockReturnValue('.domscribe/\n'); + + // Act + runGitignoreStep(baseOptions, '/project'); + + // Assert + expect(writeFileSync).not.toHaveBeenCalled(); + expect(clack.log.info).toHaveBeenCalledWith( + '.gitignore already contains .domscribe', + ); + }); + + describe('dry-run', () => { + const dryRunOptions: InitOptions = { ...baseOptions, dryRun: true }; + + it('should log without writing when file does not exist', () => { + // Arrange + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error('ENOENT'); + }); + + // Act + runGitignoreStep(dryRunOptions, '/project'); + + // Assert + expect(writeFileSync).not.toHaveBeenCalled(); + expect(clack.log.info).toHaveBeenCalledWith( + 'Would create .gitignore with .domscribe', + ); + }); + + it('should log without writing when file exists but lacks entry', () => { + // Arrange + vi.mocked(readFileSync).mockReturnValue('node_modules\n'); + + // Act + runGitignoreStep(dryRunOptions, '/project'); + + // Assert + expect(writeFileSync).not.toHaveBeenCalled(); + expect(clack.log.info).toHaveBeenCalledWith( + 'Would append to .gitignore with .domscribe', + ); + }); + }); +}); diff --git a/packages/domscribe-relay/src/cli/init/gitignore-step.ts b/packages/domscribe-relay/src/cli/init/gitignore-step.ts new file mode 100644 index 0000000..61e6a2f --- /dev/null +++ b/packages/domscribe-relay/src/cli/init/gitignore-step.ts @@ -0,0 +1,70 @@ +/** + * Ensure `.domscribe` is listed in the project's `.gitignore`. + * @module @domscribe/relay/cli/init/gitignore-step + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import * as clack from '@clack/prompts'; + +import type { InitOptions } from './types.js'; + +const GITIGNORE = '.gitignore'; +const ENTRY = '.domscribe'; + +/** + * Check whether a gitignore file already contains a `.domscribe` entry. + * + * @remarks + * Matches `.domscribe` or `.domscribe/` as a standalone line, + * ignoring leading whitespace and trailing slashes. + */ +function hasEntry(content: string): boolean { + return content + .split('\n') + .some((line) => line.trim().replace(/\/$/, '') === ENTRY); +} + +/** + * Ensure `.domscribe` is present in the project's `.gitignore`. + * + * - If the file doesn't exist, creates it with the entry. + * - If the file exists but lacks the entry, appends it. + * - If the entry already exists, does nothing. + */ +export function runGitignoreStep(options: InitOptions, cwd: string): void { + const filePath = join(cwd, GITIGNORE); + + let existing = ''; + let fileExists = false; + + try { + existing = readFileSync(filePath, 'utf-8'); + fileExists = true; + } catch { + // File doesn't exist — we'll create it. + } + + if (fileExists && hasEntry(existing)) { + clack.log.info(`${GITIGNORE} already contains ${ENTRY}`); + return; + } + + if (options.dryRun) { + const verb = fileExists ? 'Append to' : 'Create'; + clack.log.info(`Would ${verb.toLowerCase()} ${GITIGNORE} with ${ENTRY}`); + return; + } + + const block = `\n# Domscribe artifacts\n${ENTRY}\n`; + + if (fileExists) { + const separator = existing.endsWith('\n') ? '' : '\n'; + writeFileSync(filePath, existing + separator + block, 'utf-8'); + } else { + writeFileSync(filePath, block.trimStart(), 'utf-8'); + } + + const verb = fileExists ? 'Added' : 'Created'; + clack.log.success(`${verb} ${ENTRY} to ${GITIGNORE}`); +} diff --git a/packages/domscribe-relay/src/cli/init/init-wizard.spec.ts b/packages/domscribe-relay/src/cli/init/init-wizard.spec.ts index 650b5fb..7cde388 100644 --- a/packages/domscribe-relay/src/cli/init/init-wizard.spec.ts +++ b/packages/domscribe-relay/src/cli/init/init-wizard.spec.ts @@ -16,6 +16,10 @@ vi.mock('./framework-step.js', () => ({ runFrameworkStep: vi.fn().mockResolvedValue(undefined), })); +vi.mock('./gitignore-step.js', () => ({ + runGitignoreStep: vi.fn(), +})); + const baseOptions: InitOptions = { force: false, dryRun: false, @@ -37,23 +41,27 @@ describe('runInitWizard', () => { ); }); - it('should call agent step then framework step in order', async () => { + it('should call steps in order: agent → framework → gitignore', async () => { // Arrange const callOrder: string[] = []; const { runAgentStep } = await import('./agent-step.js'); const { runFrameworkStep } = await import('./framework-step.js'); + const { runGitignoreStep } = await import('./gitignore-step.js'); vi.mocked(runAgentStep).mockImplementation(async () => { callOrder.push('agent'); }); vi.mocked(runFrameworkStep).mockImplementation(async () => { callOrder.push('framework'); }); + vi.mocked(runGitignoreStep).mockImplementation(() => { + callOrder.push('gitignore'); + }); // Act await runInitWizard(baseOptions); // Assert - expect(callOrder).toEqual(['agent', 'framework']); + expect(callOrder).toEqual(['agent', 'framework', 'gitignore']); }); it('should pass options through to both steps', async () => { @@ -67,6 +75,7 @@ describe('runInitWizard', () => { }; const { runAgentStep } = await import('./agent-step.js'); const { runFrameworkStep } = await import('./framework-step.js'); + const { runGitignoreStep } = await import('./gitignore-step.js'); // Act await runInitWizard(options); @@ -74,5 +83,6 @@ describe('runInitWizard', () => { // Assert expect(runAgentStep).toHaveBeenCalledWith(options); expect(runFrameworkStep).toHaveBeenCalledWith(options, process.cwd()); + expect(runGitignoreStep).toHaveBeenCalledWith(options, process.cwd()); }); }); diff --git a/packages/domscribe-relay/src/cli/init/init-wizard.ts b/packages/domscribe-relay/src/cli/init/init-wizard.ts index 74136a0..c450056 100644 --- a/packages/domscribe-relay/src/cli/init/init-wizard.ts +++ b/packages/domscribe-relay/src/cli/init/init-wizard.ts @@ -6,10 +6,11 @@ import * as clack from '@clack/prompts'; import { runAgentStep } from './agent-step.js'; import { runFrameworkStep } from './framework-step.js'; +import { runGitignoreStep } from './gitignore-step.js'; import type { InitOptions } from './types.js'; /** - * Run the full init wizard: agent selection → framework selection. + * Run the full init wizard: agent → framework → project setup. * * @remarks * The `.domscribe/` directory is NOT created here — it is created @@ -21,6 +22,7 @@ export async function runInitWizard(options: InitOptions): Promise { await runAgentStep(options); await runFrameworkStep(options, process.cwd()); + runGitignoreStep(options, process.cwd()); clack.outro( 'Add the config above to your project, then start your dev server — Domscribe will take care of the rest.', From cb087fd949e65f9c75c0cb86672f510d9c1be762 Mon Sep 17 00:00:00 2001 From: Kaushik Gnanaskandan Date: Thu, 26 Mar 2026 12:34:25 -0700 Subject: [PATCH 2/2] feat(relay,core): add monorepo support to init command and workspace discovery Introduce domscribe.config.json for monorepo setups where the frontend app lives in a subdirectory (e.g. apps/web). The init wizard now asks if the project is a monorepo and writes a config file at the repo root pointing to the app root. Updated getWorkspaceRoot() discovery chain to check for config files in addition to .domscribe/ directories, so all CLI commands (serve, stop, status) and MCP connections resolve the app root automatically. Also fixes package manager detection in monorepos by checking lockfiles at the repo root rather than the app subdirectory. --- README.md | 12 + packages/domscribe-core/src/index.ts | 1 + .../domscribe-core/src/lib/constants/index.ts | 1 + .../domscribe-core/src/lib/types/config.ts | 23 ++ packages/domscribe-relay/README.md | 2 + .../src/cli/commands/init.command.ts | 3 + .../src/cli/config-loader.spec.ts | 110 ++++++++ .../domscribe-relay/src/cli/config-loader.ts | 41 +++ .../src/cli/init/framework-step.ts | 4 +- .../src/cli/init/init-wizard.spec.ts | 53 +++- .../src/cli/init/init-wizard.ts | 8 +- .../src/cli/init/monorepo-step.spec.ts | 240 ++++++++++++++++++ .../src/cli/init/monorepo-step.ts | 114 +++++++++ .../domscribe-relay/src/cli/init/types.ts | 1 + .../domscribe-relay/src/cli/utils.spec.ts | 70 ++++- packages/domscribe-relay/src/cli/utils.ts | 57 ++++- 16 files changed, 719 insertions(+), 21 deletions(-) create mode 100644 packages/domscribe-core/src/lib/types/config.ts create mode 100644 packages/domscribe-relay/src/cli/config-loader.spec.ts create mode 100644 packages/domscribe-relay/src/cli/config-loader.ts create mode 100644 packages/domscribe-relay/src/cli/init/monorepo-step.spec.ts create mode 100644 packages/domscribe-relay/src/cli/init/monorepo-step.ts diff --git a/README.md b/README.md index 2171120..7503e54 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,18 @@ module.exports = { For plugin configuration options, see the [`@domscribe/transform` README](./packages/domscribe-transform/README.md). +#### Monorepos + +If your frontend app is in a subdirectory (e.g. `apps/web`), pass `--app-root` during init: + +```bash +npx domscribe init --app-root apps/web +``` + +Or run `npx domscribe init` and follow the prompts — the wizard asks if you're in a monorepo. + +This creates a `domscribe.config.json` at your repo root that tells all Domscribe tools where your app lives. CLI commands (`serve`, `stop`, `status`) and agent MCP connections automatically resolve the app root from this config — no extra flags needed. + ### Agent-Side — Connect Your Coding Agent Domscribe exposes 12 tools and 4 prompts via MCP. Agent plugins bundle the MCP config and a skill file that teaches the agent how to use the tools effectively. diff --git a/packages/domscribe-core/src/index.ts b/packages/domscribe-core/src/index.ts index ac572d5..a036ebc 100644 --- a/packages/domscribe-core/src/index.ts +++ b/packages/domscribe-core/src/index.ts @@ -5,6 +5,7 @@ // Export all types export * from './lib/types/annotation.js'; +export * from './lib/types/config.js'; export * from './lib/types/manifest.js'; export * from './lib/types/nullable.js'; diff --git a/packages/domscribe-core/src/lib/constants/index.ts b/packages/domscribe-core/src/lib/constants/index.ts index a6c28bf..1535b1c 100644 --- a/packages/domscribe-core/src/lib/constants/index.ts +++ b/packages/domscribe-core/src/lib/constants/index.ts @@ -119,6 +119,7 @@ export const PATHS = { AGENT_INSTRUCTIONS: '.domscribe/agent-instructions', // Config files + CONFIG_JSON_FILE: 'domscribe.config.json', CONFIG_FILE: 'domscribe.config.ts', CONFIG_JS_FILE: 'domscribe.config.js', VALIDATION_RECIPE: 'domscribe.validation.yaml', diff --git a/packages/domscribe-core/src/lib/types/config.ts b/packages/domscribe-core/src/lib/types/config.ts new file mode 100644 index 0000000..ed2ac63 --- /dev/null +++ b/packages/domscribe-core/src/lib/types/config.ts @@ -0,0 +1,23 @@ +/** + * Domscribe project configuration schema. + * @module @domscribe/core/types/config + */ +import { z } from 'zod'; + +/** + * Schema for `domscribe.config.json`. + * + * @remarks + * Used in monorepo setups where the coding agent starts at the repo root + * but the frontend app lives in a subdirectory. The config file sits at + * the repo root and points to the app root where `.domscribe/` is located. + */ +export const DomscribeConfigSchema = z.object({ + appRoot: z + .string() + .describe( + 'Relative path from the config file to the frontend app root directory', + ), +}); + +export type DomscribeConfig = z.infer; diff --git a/packages/domscribe-relay/README.md b/packages/domscribe-relay/README.md index 2489d85..0bbddda 100644 --- a/packages/domscribe-relay/README.md +++ b/packages/domscribe-relay/README.md @@ -24,6 +24,8 @@ domscribe mcp # Run as MCP server via stdio For use in agent MCP configuration, the standalone `domscribe-mcp` binary runs the MCP server directly over stdio without the HTTP/WebSocket relay. +**Monorepo support:** All commands automatically resolve the app root from a `domscribe.config.json` file when run from a monorepo root. Run `domscribe init --app-root ` to generate the config, or let the interactive wizard detect it. + ## Annotation Lifecycle A developer clicks an element in the running app, types an instruction, and submits it. The annotation moves through the following states: diff --git a/packages/domscribe-relay/src/cli/commands/init.command.ts b/packages/domscribe-relay/src/cli/commands/init.command.ts index 416ab0a..afd70a5 100644 --- a/packages/domscribe-relay/src/cli/commands/init.command.ts +++ b/packages/domscribe-relay/src/cli/commands/init.command.ts @@ -14,6 +14,7 @@ interface InitCommandOptions { agent?: string; framework?: string; pm?: string; + appRoot?: string; } export const InitCommand = new Command('init') @@ -26,6 +27,7 @@ export const InitCommand = new Command('init') `Framework + bundler (${FRAMEWORK_IDS.join(', ')})`, ) .option('--pm ', `Package manager (${PACKAGE_MANAGER_IDS.join(', ')})`) + .option('--app-root ', 'Path to frontend app root (for monorepos)') .action(async (options: InitCommandOptions) => { try { if (options.agent && !AGENT_IDS.includes(options.agent as never)) { @@ -58,6 +60,7 @@ export const InitCommand = new Command('init') agent: options.agent as InitOptions['agent'], framework: options.framework as InitOptions['framework'], pm: options.pm as InitOptions['pm'], + appRoot: options.appRoot, }; await runInitWizard(initOptions); diff --git a/packages/domscribe-relay/src/cli/config-loader.spec.ts b/packages/domscribe-relay/src/cli/config-loader.spec.ts new file mode 100644 index 0000000..109a6f9 --- /dev/null +++ b/packages/domscribe-relay/src/cli/config-loader.spec.ts @@ -0,0 +1,110 @@ +import { existsSync, readFileSync } from 'node:fs'; + +import { findConfigFile, loadAppRoot } from './config-loader.js'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +describe('findConfigFile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return JSON config path when it exists', () => { + // Arrange + vi.mocked(existsSync).mockImplementation( + (p) => String(p) === '/project/domscribe.config.json', + ); + + // Act + const result = findConfigFile('/project'); + + // Assert + expect(result).toBe('/project/domscribe.config.json'); + }); + + it('should prefer JSON over JS and TS', () => { + // Arrange + vi.mocked(existsSync).mockReturnValue(true); + + // Act + const result = findConfigFile('/project'); + + // Assert + expect(result).toBe('/project/domscribe.config.json'); + }); + + it('should fall back to JS when JSON does not exist', () => { + // Arrange + vi.mocked(existsSync).mockImplementation( + (p) => String(p) === '/project/domscribe.config.js', + ); + + // Act + const result = findConfigFile('/project'); + + // Assert + expect(result).toBe('/project/domscribe.config.js'); + }); + + it('should return undefined when no config file exists', () => { + // Arrange + vi.mocked(existsSync).mockReturnValue(false); + + // Act + const result = findConfigFile('/project'); + + // Assert + expect(result).toBeUndefined(); + }); +}); + +describe('loadAppRoot', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should parse valid JSON and resolve appRoot', () => { + // Arrange + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ appRoot: './apps/web' }), + ); + + // Act + const result = loadAppRoot('/monorepo/domscribe.config.json'); + + // Assert + expect(result).toBe('/monorepo/apps/web'); + }); + + it('should resolve relative paths with parent traversal', () => { + // Arrange + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ appRoot: '../frontend' }), + ); + + // Act + const result = loadAppRoot('/monorepo/config/domscribe.config.json'); + + // Assert + expect(result).toBe('/monorepo/frontend'); + }); + + it('should throw on missing appRoot field', () => { + // Arrange + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({})); + + // Act & Assert + expect(() => loadAppRoot('/project/domscribe.config.json')).toThrow(); + }); + + it('should throw on invalid JSON', () => { + // Arrange + vi.mocked(readFileSync).mockReturnValue('not json'); + + // Act & Assert + expect(() => loadAppRoot('/project/domscribe.config.json')).toThrow(); + }); +}); diff --git a/packages/domscribe-relay/src/cli/config-loader.ts b/packages/domscribe-relay/src/cli/config-loader.ts new file mode 100644 index 0000000..586c3ae --- /dev/null +++ b/packages/domscribe-relay/src/cli/config-loader.ts @@ -0,0 +1,41 @@ +/** + * Domscribe config file discovery and loading. + * @module @domscribe/relay/cli/config-loader + */ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { DomscribeConfigSchema, PATHS } from '@domscribe/core'; + +/** + * Config filenames to scan, in priority order. + * JSON is preferred; `.js` and `.ts` are reserved for future use. + */ +const CONFIG_FILENAMES = [ + PATHS.CONFIG_JSON_FILE, + PATHS.CONFIG_JS_FILE, + PATHS.CONFIG_FILE, +] as const; + +/** + * Find a Domscribe config file in the given directory. + * Returns the absolute path to the first match, or `undefined`. + */ +export function findConfigFile(dir: string): string | undefined { + for (const name of CONFIG_FILENAMES) { + const filePath = path.join(dir, name); + if (existsSync(filePath)) return filePath; + } + return undefined; +} + +/** + * Read a `domscribe.config.json` and resolve the `appRoot` to an absolute path + * relative to the config file's directory. + */ +export function loadAppRoot(configPath: string): string { + const raw = readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + const config = DomscribeConfigSchema.parse(parsed); + return path.resolve(path.dirname(configPath), config.appRoot); +} diff --git a/packages/domscribe-relay/src/cli/init/framework-step.ts b/packages/domscribe-relay/src/cli/init/framework-step.ts index f4e616a..b0040de 100644 --- a/packages/domscribe-relay/src/cli/init/framework-step.ts +++ b/packages/domscribe-relay/src/cli/init/framework-step.ts @@ -142,8 +142,8 @@ export async function runFrameworkStep( return; } - // Detect and confirm package manager - const detected = detectPackageManager(cwd); + // Detect package manager from the repo root (lockfiles live there, not in app subdirs) + const detected = detectPackageManager(process.cwd()); const pm = await confirmPackageManager(detected, options); const installCmd = buildInstallCommand(pm, framework.package); diff --git a/packages/domscribe-relay/src/cli/init/init-wizard.spec.ts b/packages/domscribe-relay/src/cli/init/init-wizard.spec.ts index 7cde388..f4384c5 100644 --- a/packages/domscribe-relay/src/cli/init/init-wizard.spec.ts +++ b/packages/domscribe-relay/src/cli/init/init-wizard.spec.ts @@ -12,6 +12,10 @@ vi.mock('./agent-step.js', () => ({ runAgentStep: vi.fn().mockResolvedValue(undefined), })); +vi.mock('./monorepo-step.js', () => ({ + runMonorepoStep: vi.fn().mockResolvedValue({ appRoot: '/resolved/app/root' }), +})); + vi.mock('./framework-step.js', () => ({ runFrameworkStep: vi.fn().mockResolvedValue(undefined), })); @@ -41,15 +45,20 @@ describe('runInitWizard', () => { ); }); - it('should call steps in order: agent → framework → gitignore', async () => { + it('should call steps in order: agent → monorepo → framework → gitignore', async () => { // Arrange const callOrder: string[] = []; const { runAgentStep } = await import('./agent-step.js'); + const { runMonorepoStep } = await import('./monorepo-step.js'); const { runFrameworkStep } = await import('./framework-step.js'); const { runGitignoreStep } = await import('./gitignore-step.js'); vi.mocked(runAgentStep).mockImplementation(async () => { callOrder.push('agent'); }); + vi.mocked(runMonorepoStep).mockImplementation(async () => { + callOrder.push('monorepo'); + return { appRoot: process.cwd() }; + }); vi.mocked(runFrameworkStep).mockImplementation(async () => { callOrder.push('framework'); }); @@ -61,10 +70,43 @@ describe('runInitWizard', () => { await runInitWizard(baseOptions); // Assert - expect(callOrder).toEqual(['agent', 'framework', 'gitignore']); + expect(callOrder).toEqual(['agent', 'monorepo', 'framework', 'gitignore']); }); - it('should pass options through to both steps', async () => { + it('should pass appRoot from monorepo step to framework step', async () => { + // Arrange + const { runMonorepoStep } = await import('./monorepo-step.js'); + const { runFrameworkStep } = await import('./framework-step.js'); + vi.mocked(runMonorepoStep).mockResolvedValue({ + appRoot: '/monorepo/apps/web', + }); + + // Act + await runInitWizard(baseOptions); + + // Assert + expect(runFrameworkStep).toHaveBeenCalledWith( + baseOptions, + '/monorepo/apps/web', + ); + }); + + it('should pass cwd (not appRoot) to gitignore step', async () => { + // Arrange + const { runMonorepoStep } = await import('./monorepo-step.js'); + const { runGitignoreStep } = await import('./gitignore-step.js'); + vi.mocked(runMonorepoStep).mockResolvedValue({ + appRoot: '/monorepo/apps/web', + }); + + // Act + await runInitWizard(baseOptions); + + // Assert + expect(runGitignoreStep).toHaveBeenCalledWith(baseOptions, process.cwd()); + }); + + it('should pass options through to all steps', async () => { // Arrange const options: InitOptions = { force: true, @@ -72,9 +114,10 @@ describe('runInitWizard', () => { agent: 'claude-code', framework: 'next', pm: 'pnpm', + appRoot: 'apps/web', }; const { runAgentStep } = await import('./agent-step.js'); - const { runFrameworkStep } = await import('./framework-step.js'); + const { runMonorepoStep } = await import('./monorepo-step.js'); const { runGitignoreStep } = await import('./gitignore-step.js'); // Act @@ -82,7 +125,7 @@ describe('runInitWizard', () => { // Assert expect(runAgentStep).toHaveBeenCalledWith(options); - expect(runFrameworkStep).toHaveBeenCalledWith(options, process.cwd()); + expect(runMonorepoStep).toHaveBeenCalledWith(options, process.cwd()); expect(runGitignoreStep).toHaveBeenCalledWith(options, process.cwd()); }); }); diff --git a/packages/domscribe-relay/src/cli/init/init-wizard.ts b/packages/domscribe-relay/src/cli/init/init-wizard.ts index c450056..c65c294 100644 --- a/packages/domscribe-relay/src/cli/init/init-wizard.ts +++ b/packages/domscribe-relay/src/cli/init/init-wizard.ts @@ -7,10 +7,11 @@ import * as clack from '@clack/prompts'; import { runAgentStep } from './agent-step.js'; import { runFrameworkStep } from './framework-step.js'; import { runGitignoreStep } from './gitignore-step.js'; +import { runMonorepoStep } from './monorepo-step.js'; import type { InitOptions } from './types.js'; /** - * Run the full init wizard: agent → framework → project setup. + * Run the full init wizard: agent → monorepo → framework → project setup. * * @remarks * The `.domscribe/` directory is NOT created here — it is created @@ -21,7 +22,10 @@ export async function runInitWizard(options: InitOptions): Promise { clack.intro('Domscribe Setup'); await runAgentStep(options); - await runFrameworkStep(options, process.cwd()); + + const { appRoot } = await runMonorepoStep(options, process.cwd()); + + await runFrameworkStep(options, appRoot); runGitignoreStep(options, process.cwd()); clack.outro( diff --git a/packages/domscribe-relay/src/cli/init/monorepo-step.spec.ts b/packages/domscribe-relay/src/cli/init/monorepo-step.spec.ts new file mode 100644 index 0000000..43ab0f7 --- /dev/null +++ b/packages/domscribe-relay/src/cli/init/monorepo-step.spec.ts @@ -0,0 +1,240 @@ +import { existsSync, writeFileSync } from 'node:fs'; + +import * as clack from '@clack/prompts'; + +import { runMonorepoStep } from './monorepo-step.js'; +import type { InitOptions } from './types.js'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + writeFileSync: vi.fn(), +})); + +vi.mock('@clack/prompts', () => ({ + confirm: vi.fn(), + text: vi.fn(), + isCancel: vi.fn().mockReturnValue(false), + cancel: vi.fn(), + log: { + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../config-loader.js', () => ({ + findConfigFile: vi.fn(), + loadAppRoot: vi.fn(), +})); + +const { findConfigFile, loadAppRoot } = await import('../config-loader.js'); + +const baseOptions: InitOptions = { force: false, dryRun: false }; + +describe('runMonorepoStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(findConfigFile).mockReturnValue(undefined); + }); + + describe('non-interactive (--app-root flag)', () => { + it('should write config and return resolved appRoot', async () => { + // Arrange — app dir exists, config file does not + vi.mocked(existsSync).mockImplementation((p) => { + const s = String(p); + return s === '/monorepo/apps/web'; // app root exists, config does not + }); + const options: InitOptions = { ...baseOptions, appRoot: 'apps/web' }; + + // Act + const result = await runMonorepoStep(options, '/monorepo'); + + // Assert + expect(result.appRoot).toBe('/monorepo/apps/web'); + expect(writeFileSync).toHaveBeenCalledWith( + '/monorepo/domscribe.config.json', + expect.stringContaining('"appRoot": "apps/web"'), + 'utf-8', + ); + }); + + it('should exit when app root directory does not exist', async () => { + // Arrange + vi.mocked(existsSync).mockReturnValue(false); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const options: InitOptions = { ...baseOptions, appRoot: 'apps/missing' }; + + // Act + await runMonorepoStep(options, '/monorepo'); + + // Assert + expect(clack.log.error).toHaveBeenCalledWith( + expect.stringContaining('Directory not found'), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('existing config', () => { + it('should reuse existing config when --force is not set', async () => { + // Arrange + vi.mocked(findConfigFile).mockReturnValue( + '/monorepo/domscribe.config.json', + ); + vi.mocked(loadAppRoot).mockReturnValue('/monorepo/apps/web'); + + // Act + const result = await runMonorepoStep(baseOptions, '/monorepo'); + + // Assert + expect(result.appRoot).toBe('/monorepo/apps/web'); + expect(clack.confirm).not.toHaveBeenCalled(); + expect(writeFileSync).not.toHaveBeenCalled(); + }); + + it('should prompt when --force is set despite existing config', async () => { + // Arrange + vi.mocked(findConfigFile).mockReturnValue( + '/monorepo/domscribe.config.json', + ); + vi.mocked(clack.confirm).mockResolvedValue(false); + const options: InitOptions = { ...baseOptions, force: true }; + + // Act + const result = await runMonorepoStep(options, '/monorepo'); + + // Assert + expect(clack.confirm).toHaveBeenCalled(); + expect(result.appRoot).toBe('/monorepo'); + }); + }); + + describe('interactive mode', () => { + it('should return cwd when user says not a monorepo', async () => { + // Arrange + vi.mocked(clack.confirm).mockResolvedValue(false); + + // Act + const result = await runMonorepoStep(baseOptions, '/project'); + + // Assert + expect(result.appRoot).toBe('/project'); + expect(writeFileSync).not.toHaveBeenCalled(); + }); + + it('should write config and return appRoot when user provides path', async () => { + // Arrange — app dir exists, config file does not + vi.mocked(existsSync).mockImplementation((p) => { + const s = String(p); + return s === '/monorepo/apps/web'; + }); + vi.mocked(clack.confirm).mockResolvedValue(true); + vi.mocked(clack.text).mockResolvedValue('apps/web'); + + // Act + const result = await runMonorepoStep(baseOptions, '/monorepo'); + + // Assert + expect(result.appRoot).toBe('/monorepo/apps/web'); + expect(writeFileSync).toHaveBeenCalledWith( + '/monorepo/domscribe.config.json', + expect.stringContaining('"appRoot": "apps/web"'), + 'utf-8', + ); + }); + + it('should exit on monorepo prompt cancel', async () => { + // Arrange + vi.mocked(clack.confirm).mockResolvedValue(Symbol('cancel')); + vi.mocked(clack.isCancel).mockReturnValueOnce(true); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + // Act + await runMonorepoStep(baseOptions, '/project'); + + // Assert + expect(clack.cancel).toHaveBeenCalledWith('Setup cancelled.'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('should exit on app root prompt cancel', async () => { + // Arrange + vi.mocked(clack.confirm).mockResolvedValue(true); + vi.mocked(clack.text).mockResolvedValue(Symbol('cancel')); + vi.mocked(clack.isCancel) + .mockReturnValueOnce(false) // confirm check + .mockReturnValueOnce(true); // text check + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + // Act + await runMonorepoStep(baseOptions, '/project'); + + // Assert + expect(clack.cancel).toHaveBeenCalledWith('Setup cancelled.'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + }); + + describe('dry-run', () => { + it('should log config content without writing', async () => { + // Arrange — no config file on disk + vi.mocked(existsSync).mockImplementation((p) => { + const s = String(p); + return s === '/monorepo/apps/web'; // only app dir exists + }); + vi.mocked(clack.confirm).mockResolvedValue(true); + vi.mocked(clack.text).mockResolvedValue('apps/web'); + const options: InitOptions = { ...baseOptions, dryRun: true }; + + // Act + const result = await runMonorepoStep(options, '/monorepo'); + + // Assert + expect(result.appRoot).toBe('/monorepo/apps/web'); + expect(writeFileSync).not.toHaveBeenCalled(); + expect(clack.log.info).toHaveBeenCalledWith( + expect.stringContaining('Would write'), + ); + }); + }); + + describe('idempotency', () => { + it('should skip writing when config exists and --force is not set', async () => { + // Arrange — config exists on disk, --app-root provided + vi.mocked(existsSync).mockReturnValue(true); + const options: InitOptions = { ...baseOptions, appRoot: 'apps/web' }; + + // Act + await runMonorepoStep(options, '/monorepo'); + + // Assert + expect(writeFileSync).not.toHaveBeenCalled(); + expect(clack.log.info).toHaveBeenCalledWith( + expect.stringContaining('Config already exists'), + ); + }); + + it('should overwrite when --force is set', async () => { + // Arrange + vi.mocked(existsSync).mockReturnValue(true); + const options: InitOptions = { + ...baseOptions, + force: true, + appRoot: 'apps/web', + }; + + // Act + await runMonorepoStep(options, '/monorepo'); + + // Assert + expect(writeFileSync).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/domscribe-relay/src/cli/init/monorepo-step.ts b/packages/domscribe-relay/src/cli/init/monorepo-step.ts new file mode 100644 index 0000000..7dda1ee --- /dev/null +++ b/packages/domscribe-relay/src/cli/init/monorepo-step.ts @@ -0,0 +1,114 @@ +/** + * Monorepo detection and config file creation step. + * @module @domscribe/relay/cli/init/monorepo-step + */ +import { existsSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import * as clack from '@clack/prompts'; +import { PATHS } from '@domscribe/core'; + +import { findConfigFile, loadAppRoot } from '../config-loader.js'; +import type { InitOptions } from './types.js'; + +/** + * Result of the monorepo step. + */ +export interface MonorepoResult { + /** Resolved absolute path to the app root (equals cwd for single-repo). */ + readonly appRoot: string; +} + +/** + * Write `domscribe.config.json` at the given directory. + * Idempotent: skips if file exists and `--force` is not set. + */ +function writeConfigIfNeeded( + cwd: string, + appRoot: string, + options: InitOptions, +): void { + const configPath = path.join(cwd, PATHS.CONFIG_JSON_FILE); + + if (existsSync(configPath) && !options.force) { + clack.log.info(`Config already exists: ${PATHS.CONFIG_JSON_FILE}`); + return; + } + + const content = JSON.stringify({ appRoot }, null, 2) + '\n'; + + if (options.dryRun) { + clack.log.info(`Would write ${PATHS.CONFIG_JSON_FILE}:\n${content.trim()}`); + return; + } + + writeFileSync(configPath, content, 'utf-8'); + clack.log.success(`Created ${PATHS.CONFIG_JSON_FILE}`); +} + +/** + * Run the monorepo detection and config creation step. + * + * @returns The resolved app root — equals `cwd` for single-repo projects, + * or the subdirectory path for monorepos. + */ +export async function runMonorepoStep( + options: InitOptions, + cwd: string, +): Promise { + // Non-interactive: --app-root flag provided + if (options.appRoot) { + const resolved = path.resolve(cwd, options.appRoot); + + if (!existsSync(resolved)) { + clack.log.error(`Directory not found: ${resolved}`); + return process.exit(1) as never; + } + + writeConfigIfNeeded(cwd, options.appRoot, options); + return { appRoot: resolved }; + } + + // Config already exists — reuse it unless --force + const existingConfig = findConfigFile(cwd); + if (existingConfig && !options.force) { + const appRoot = loadAppRoot(existingConfig); + clack.log.info(`Using existing config: ${path.basename(existingConfig)}`); + return { appRoot }; + } + + // Interactive prompt + const isMonorepo = await clack.confirm({ + message: 'Is this a monorepo?', + initialValue: false, + }); + + if (clack.isCancel(isMonorepo)) { + clack.cancel('Setup cancelled.'); + return process.exit(0) as never; + } + + if (!isMonorepo) { + return { appRoot: cwd }; + } + + const appRootInput = await clack.text({ + message: 'Where is your frontend app? (relative path)', + placeholder: 'apps/web', + validate: (value = '') => { + if (!value.trim()) return 'Path is required'; + const abs = path.resolve(cwd, value); + if (!existsSync(abs)) return `Directory not found: ${abs}`; + return undefined; + }, + }); + + if (clack.isCancel(appRootInput)) { + clack.cancel('Setup cancelled.'); + return process.exit(0) as never; + } + + const resolved = path.resolve(cwd, appRootInput); + writeConfigIfNeeded(cwd, appRootInput, options); + return { appRoot: resolved }; +} diff --git a/packages/domscribe-relay/src/cli/init/types.ts b/packages/domscribe-relay/src/cli/init/types.ts index dc8d4c4..91d5e82 100644 --- a/packages/domscribe-relay/src/cli/init/types.ts +++ b/packages/domscribe-relay/src/cli/init/types.ts @@ -70,6 +70,7 @@ export interface InitOptions { readonly agent?: AgentId; readonly framework?: FrameworkId; readonly pm?: PackageManagerId; + readonly appRoot?: string; } const MCP_CONFIG = `{ diff --git a/packages/domscribe-relay/src/cli/utils.spec.ts b/packages/domscribe-relay/src/cli/utils.spec.ts index 9885cfe..e9240ad 100644 --- a/packages/domscribe-relay/src/cli/utils.spec.ts +++ b/packages/domscribe-relay/src/cli/utils.spec.ts @@ -1,4 +1,5 @@ import { existsSync, statSync } from 'node:fs'; + import { getWorkspaceRoot } from './utils.js'; vi.mock('node:fs', () => ({ @@ -6,9 +7,17 @@ vi.mock('node:fs', () => ({ statSync: vi.fn().mockReturnValue({ isDirectory: () => true }), })); +vi.mock('./config-loader.js', () => ({ + findConfigFile: vi.fn(), + loadAppRoot: vi.fn(), +})); + +// Import after mocking so we get the mocked versions +const { findConfigFile, loadAppRoot } = await import('./config-loader.js'); + describe('getWorkspaceRoot', () => { - afterEach(() => { - vi.restoreAllMocks(); + beforeEach(() => { + vi.clearAllMocks(); }); it('should return cwd when .domscribe exists there', () => { @@ -22,13 +31,43 @@ describe('getWorkspaceRoot', () => { expect(result).toBe(process.cwd()); }); + it('should resolve appRoot from config at cwd when .domscribe is absent', () => { + // Arrange + vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(findConfigFile).mockReturnValueOnce( + '/monorepo/domscribe.config.json', + ); + vi.mocked(loadAppRoot).mockReturnValueOnce('/monorepo/apps/web'); + + // Act + const result = getWorkspaceRoot(); + + // Assert + expect(result).toBe('/monorepo/apps/web'); + }); + + it('should prefer .domscribe at cwd over config file', () => { + // Arrange — .domscribe exists at cwd + vi.mocked(existsSync).mockImplementation((p) => + String(p).endsWith('.domscribe'), + ); + + // Act + const result = getWorkspaceRoot(); + + // Assert + expect(result).toBe(process.cwd()); + // Config loader should not be consulted when .domscribe is found directly + expect(loadAppRoot).not.toHaveBeenCalled(); + }); + it('should walk up and find .domscribe in parent directory', () => { // Arrange vi.mocked(existsSync).mockImplementation((p) => { const str = String(p); - // Only .domscribe in root '/' matches return str === '/.domscribe'; }); + vi.mocked(findConfigFile).mockReturnValue(undefined); vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, } as ReturnType); @@ -40,9 +79,32 @@ describe('getWorkspaceRoot', () => { expect(result).toBe('/'); }); - it('should return undefined when no .domscribe is found', () => { + it('should walk up and find config in parent directory', () => { + // Arrange + vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => true, + } as ReturnType); + + // findConfigFile: no config at cwd, then found at root + vi.mocked(findConfigFile) + .mockReturnValueOnce(undefined) // step 2: cwd + .mockImplementation((dir) => + dir === '/' ? '/domscribe.config.json' : undefined, + ); + vi.mocked(loadAppRoot).mockReturnValue('/apps/web'); + + // Act + const result = getWorkspaceRoot(); + + // Assert + expect(result).toBe('/apps/web'); + }); + + it('should return undefined when no .domscribe or config is found', () => { // Arrange vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(findConfigFile).mockReturnValue(undefined); // Act const result = getWorkspaceRoot(); diff --git a/packages/domscribe-relay/src/cli/utils.ts b/packages/domscribe-relay/src/cli/utils.ts index cecef36..e38f7a0 100644 --- a/packages/domscribe-relay/src/cli/utils.ts +++ b/packages/domscribe-relay/src/cli/utils.ts @@ -5,20 +5,41 @@ import { existsSync, statSync } from 'node:fs'; import path from 'node:path'; +import { PATHS } from '@domscribe/core'; + +import { findConfigFile, loadAppRoot } from './config-loader.js'; + /** - * Locate the workspace root by walking up from cwd looking for a `.domscribe` directory. + * Locate the workspace root (the directory containing `.domscribe/`). + * + * @remarks + * Discovery chain: + * 1. `.domscribe/` at cwd — single-repo fast path + * 2. `domscribe.config.*` at cwd — monorepo: resolve appRoot from config + * 3. Walk up for `.domscribe/` — nested working directory + * 4. Walk up for `domscribe.config.*` — nested working directory in monorepo + * 5. Nothing found — returns `undefined` (dormant mode) */ export function getWorkspaceRoot(): string | undefined { - // Check if .domscribe directory exists const cwd = process.cwd(); - const domscribeDir = path.join(cwd, '.domscribe'); - if (existsSync(domscribeDir)) { + // 1. .domscribe at cwd (single-repo fast path) + if (existsSync(path.join(cwd, PATHS.DOMSCRIBE_DIR))) { return cwd; } - // Walk up to find .domscribe directory - return walkUpToFindDomscribe(cwd); + // 2. Config file at cwd (monorepo: config at repo root) + const configAtCwd = findConfigFile(cwd); + if (configAtCwd) { + return loadAppRoot(configAtCwd); + } + + // 3. Walk up for .domscribe + const fromDomscribe = walkUpToFindDomscribe(cwd); + if (fromDomscribe) return fromDomscribe; + + // 4. Walk up for config file + return walkUpToFindConfig(cwd); } function walkUpToFindDomscribe(startPath: string): string | undefined { @@ -30,13 +51,33 @@ function walkUpToFindDomscribe(startPath: string): string | undefined { } while (true) { - if (existsSync(path.join(dir, '.domscribe'))) { + if (existsSync(path.join(dir, PATHS.DOMSCRIBE_DIR))) { return dir; } const parent = path.dirname(dir); if (parent === dir) { - // Hit filesystem root + return; + } + dir = parent; + } +} + +function walkUpToFindConfig(startPath: string): string | undefined { + let dir = path.resolve(startPath); + + if (!statSync(dir).isDirectory()) { + dir = path.dirname(dir); + } + + while (true) { + const configPath = findConfigFile(dir); + if (configPath) { + return loadAppRoot(configPath); + } + + const parent = path.dirname(dir); + if (parent === dir) { return; } dir = parent;