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.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..b0040de 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. */ @@ -110,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); @@ -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..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,10 +12,18 @@ 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), })); +vi.mock('./gitignore-step.js', () => ({ + runGitignoreStep: vi.fn(), +})); + const baseOptions: InitOptions = { force: false, dryRun: false, @@ -37,26 +45,68 @@ describe('runInitWizard', () => { ); }); - it('should call agent step then framework step in order', 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'); }); + vi.mocked(runGitignoreStep).mockImplementation(() => { + callOrder.push('gitignore'); + }); // Act await runInitWizard(baseOptions); // Assert - expect(callOrder).toEqual(['agent', 'framework']); + 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, @@ -64,15 +114,18 @@ 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 await runInitWizard(options); // 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 74136a0..c65c294 100644 --- a/packages/domscribe-relay/src/cli/init/init-wizard.ts +++ b/packages/domscribe-relay/src/cli/init/init-wizard.ts @@ -6,10 +6,12 @@ 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 selection → framework selection. + * Run the full init wizard: agent → monorepo → framework → project setup. * * @remarks * The `.domscribe/` directory is NOT created here — it is created @@ -20,7 +22,11 @@ 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( 'Add the config above to your project, then start your dev server — Domscribe will take care of the rest.', 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;