Skip to content

Commit a84da6f

Browse files
committed
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.
1 parent 22f6dcd commit a84da6f

16 files changed

Lines changed: 719 additions & 21 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,18 @@ module.exports = {
292292
293293
For plugin configuration options, see the [`@domscribe/transform` README](./packages/domscribe-transform/README.md).
294294

295+
#### Monorepos
296+
297+
If your frontend app is in a subdirectory (e.g. `apps/web`), pass `--app-root` during init:
298+
299+
```bash
300+
npx domscribe init --app-root apps/web
301+
```
302+
303+
Or run `npx domscribe init` and follow the prompts — the wizard asks if you're in a monorepo.
304+
305+
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.
306+
295307
### Agent-Side — Connect Your Coding Agent
296308

297309
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.

packages/domscribe-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
// Export all types
77
export * from './lib/types/annotation.js';
8+
export * from './lib/types/config.js';
89
export * from './lib/types/manifest.js';
910
export * from './lib/types/nullable.js';
1011

packages/domscribe-core/src/lib/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export const PATHS = {
119119
AGENT_INSTRUCTIONS: '.domscribe/agent-instructions',
120120

121121
// Config files
122+
CONFIG_JSON_FILE: 'domscribe.config.json',
122123
CONFIG_FILE: 'domscribe.config.ts',
123124
CONFIG_JS_FILE: 'domscribe.config.js',
124125
VALIDATION_RECIPE: 'domscribe.validation.yaml',
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Domscribe project configuration schema.
3+
* @module @domscribe/core/types/config
4+
*/
5+
import { z } from 'zod';
6+
7+
/**
8+
* Schema for `domscribe.config.json`.
9+
*
10+
* @remarks
11+
* Used in monorepo setups where the coding agent starts at the repo root
12+
* but the frontend app lives in a subdirectory. The config file sits at
13+
* the repo root and points to the app root where `.domscribe/` is located.
14+
*/
15+
export const DomscribeConfigSchema = z.object({
16+
appRoot: z
17+
.string()
18+
.describe(
19+
'Relative path from the config file to the frontend app root directory',
20+
),
21+
});
22+
23+
export type DomscribeConfig = z.infer<typeof DomscribeConfigSchema>;

packages/domscribe-relay/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ domscribe mcp # Run as MCP server via stdio
2424

2525
For use in agent MCP configuration, the standalone `domscribe-mcp` binary runs the MCP server directly over stdio without the HTTP/WebSocket relay.
2626

27+
**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 <path>` to generate the config, or let the interactive wizard detect it.
28+
2729
## Annotation Lifecycle
2830

2931
A developer clicks an element in the running app, types an instruction, and submits it. The annotation moves through the following states:

packages/domscribe-relay/src/cli/commands/init.command.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface InitCommandOptions {
1414
agent?: string;
1515
framework?: string;
1616
pm?: string;
17+
appRoot?: string;
1718
}
1819

1920
export const InitCommand = new Command('init')
@@ -26,6 +27,7 @@ export const InitCommand = new Command('init')
2627
`Framework + bundler (${FRAMEWORK_IDS.join(', ')})`,
2728
)
2829
.option('--pm <name>', `Package manager (${PACKAGE_MANAGER_IDS.join(', ')})`)
30+
.option('--app-root <path>', 'Path to frontend app root (for monorepos)')
2931
.action(async (options: InitCommandOptions) => {
3032
try {
3133
if (options.agent && !AGENT_IDS.includes(options.agent as never)) {
@@ -58,6 +60,7 @@ export const InitCommand = new Command('init')
5860
agent: options.agent as InitOptions['agent'],
5961
framework: options.framework as InitOptions['framework'],
6062
pm: options.pm as InitOptions['pm'],
63+
appRoot: options.appRoot,
6164
};
6265

6366
await runInitWizard(initOptions);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
3+
import { findConfigFile, loadAppRoot } from './config-loader.js';
4+
5+
vi.mock('node:fs', () => ({
6+
existsSync: vi.fn(),
7+
readFileSync: vi.fn(),
8+
}));
9+
10+
describe('findConfigFile', () => {
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
});
14+
15+
it('should return JSON config path when it exists', () => {
16+
// Arrange
17+
vi.mocked(existsSync).mockImplementation(
18+
(p) => String(p) === '/project/domscribe.config.json',
19+
);
20+
21+
// Act
22+
const result = findConfigFile('/project');
23+
24+
// Assert
25+
expect(result).toBe('/project/domscribe.config.json');
26+
});
27+
28+
it('should prefer JSON over JS and TS', () => {
29+
// Arrange
30+
vi.mocked(existsSync).mockReturnValue(true);
31+
32+
// Act
33+
const result = findConfigFile('/project');
34+
35+
// Assert
36+
expect(result).toBe('/project/domscribe.config.json');
37+
});
38+
39+
it('should fall back to JS when JSON does not exist', () => {
40+
// Arrange
41+
vi.mocked(existsSync).mockImplementation(
42+
(p) => String(p) === '/project/domscribe.config.js',
43+
);
44+
45+
// Act
46+
const result = findConfigFile('/project');
47+
48+
// Assert
49+
expect(result).toBe('/project/domscribe.config.js');
50+
});
51+
52+
it('should return undefined when no config file exists', () => {
53+
// Arrange
54+
vi.mocked(existsSync).mockReturnValue(false);
55+
56+
// Act
57+
const result = findConfigFile('/project');
58+
59+
// Assert
60+
expect(result).toBeUndefined();
61+
});
62+
});
63+
64+
describe('loadAppRoot', () => {
65+
beforeEach(() => {
66+
vi.clearAllMocks();
67+
});
68+
69+
it('should parse valid JSON and resolve appRoot', () => {
70+
// Arrange
71+
vi.mocked(readFileSync).mockReturnValue(
72+
JSON.stringify({ appRoot: './apps/web' }),
73+
);
74+
75+
// Act
76+
const result = loadAppRoot('/monorepo/domscribe.config.json');
77+
78+
// Assert
79+
expect(result).toBe('/monorepo/apps/web');
80+
});
81+
82+
it('should resolve relative paths with parent traversal', () => {
83+
// Arrange
84+
vi.mocked(readFileSync).mockReturnValue(
85+
JSON.stringify({ appRoot: '../frontend' }),
86+
);
87+
88+
// Act
89+
const result = loadAppRoot('/monorepo/config/domscribe.config.json');
90+
91+
// Assert
92+
expect(result).toBe('/monorepo/frontend');
93+
});
94+
95+
it('should throw on missing appRoot field', () => {
96+
// Arrange
97+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({}));
98+
99+
// Act & Assert
100+
expect(() => loadAppRoot('/project/domscribe.config.json')).toThrow();
101+
});
102+
103+
it('should throw on invalid JSON', () => {
104+
// Arrange
105+
vi.mocked(readFileSync).mockReturnValue('not json');
106+
107+
// Act & Assert
108+
expect(() => loadAppRoot('/project/domscribe.config.json')).toThrow();
109+
});
110+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Domscribe config file discovery and loading.
3+
* @module @domscribe/relay/cli/config-loader
4+
*/
5+
import { existsSync, readFileSync } from 'node:fs';
6+
import path from 'node:path';
7+
8+
import { DomscribeConfigSchema, PATHS } from '@domscribe/core';
9+
10+
/**
11+
* Config filenames to scan, in priority order.
12+
* JSON is preferred; `.js` and `.ts` are reserved for future use.
13+
*/
14+
const CONFIG_FILENAMES = [
15+
PATHS.CONFIG_JSON_FILE,
16+
PATHS.CONFIG_JS_FILE,
17+
PATHS.CONFIG_FILE,
18+
] as const;
19+
20+
/**
21+
* Find a Domscribe config file in the given directory.
22+
* Returns the absolute path to the first match, or `undefined`.
23+
*/
24+
export function findConfigFile(dir: string): string | undefined {
25+
for (const name of CONFIG_FILENAMES) {
26+
const filePath = path.join(dir, name);
27+
if (existsSync(filePath)) return filePath;
28+
}
29+
return undefined;
30+
}
31+
32+
/**
33+
* Read a `domscribe.config.json` and resolve the `appRoot` to an absolute path
34+
* relative to the config file's directory.
35+
*/
36+
export function loadAppRoot(configPath: string): string {
37+
const raw = readFileSync(configPath, 'utf-8');
38+
const parsed = JSON.parse(raw) as unknown;
39+
const config = DomscribeConfigSchema.parse(parsed);
40+
return path.resolve(path.dirname(configPath), config.appRoot);
41+
}

packages/domscribe-relay/src/cli/init/framework-step.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ export async function runFrameworkStep(
142142
return;
143143
}
144144

145-
// Detect and confirm package manager
146-
const detected = detectPackageManager(cwd);
145+
// Detect package manager from the repo root (lockfiles live there, not in app subdirs)
146+
const detected = detectPackageManager(process.cwd());
147147
const pm = await confirmPackageManager(detected, options);
148148

149149
const installCmd = buildInstallCommand(pm, framework.package);

packages/domscribe-relay/src/cli/init/init-wizard.spec.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ vi.mock('./agent-step.js', () => ({
1212
runAgentStep: vi.fn().mockResolvedValue(undefined),
1313
}));
1414

15+
vi.mock('./monorepo-step.js', () => ({
16+
runMonorepoStep: vi.fn().mockResolvedValue({ appRoot: '/resolved/app/root' }),
17+
}));
18+
1519
vi.mock('./framework-step.js', () => ({
1620
runFrameworkStep: vi.fn().mockResolvedValue(undefined),
1721
}));
@@ -41,15 +45,20 @@ describe('runInitWizard', () => {
4145
);
4246
});
4347

44-
it('should call steps in order: agent → framework → gitignore', async () => {
48+
it('should call steps in order: agent → monorepo → framework → gitignore', async () => {
4549
// Arrange
4650
const callOrder: string[] = [];
4751
const { runAgentStep } = await import('./agent-step.js');
52+
const { runMonorepoStep } = await import('./monorepo-step.js');
4853
const { runFrameworkStep } = await import('./framework-step.js');
4954
const { runGitignoreStep } = await import('./gitignore-step.js');
5055
vi.mocked(runAgentStep).mockImplementation(async () => {
5156
callOrder.push('agent');
5257
});
58+
vi.mocked(runMonorepoStep).mockImplementation(async () => {
59+
callOrder.push('monorepo');
60+
return { appRoot: process.cwd() };
61+
});
5362
vi.mocked(runFrameworkStep).mockImplementation(async () => {
5463
callOrder.push('framework');
5564
});
@@ -61,28 +70,62 @@ describe('runInitWizard', () => {
6170
await runInitWizard(baseOptions);
6271

6372
// Assert
64-
expect(callOrder).toEqual(['agent', 'framework', 'gitignore']);
73+
expect(callOrder).toEqual(['agent', 'monorepo', 'framework', 'gitignore']);
6574
});
6675

67-
it('should pass options through to both steps', async () => {
76+
it('should pass appRoot from monorepo step to framework step', async () => {
77+
// Arrange
78+
const { runMonorepoStep } = await import('./monorepo-step.js');
79+
const { runFrameworkStep } = await import('./framework-step.js');
80+
vi.mocked(runMonorepoStep).mockResolvedValue({
81+
appRoot: '/monorepo/apps/web',
82+
});
83+
84+
// Act
85+
await runInitWizard(baseOptions);
86+
87+
// Assert
88+
expect(runFrameworkStep).toHaveBeenCalledWith(
89+
baseOptions,
90+
'/monorepo/apps/web',
91+
);
92+
});
93+
94+
it('should pass cwd (not appRoot) to gitignore step', async () => {
95+
// Arrange
96+
const { runMonorepoStep } = await import('./monorepo-step.js');
97+
const { runGitignoreStep } = await import('./gitignore-step.js');
98+
vi.mocked(runMonorepoStep).mockResolvedValue({
99+
appRoot: '/monorepo/apps/web',
100+
});
101+
102+
// Act
103+
await runInitWizard(baseOptions);
104+
105+
// Assert
106+
expect(runGitignoreStep).toHaveBeenCalledWith(baseOptions, process.cwd());
107+
});
108+
109+
it('should pass options through to all steps', async () => {
68110
// Arrange
69111
const options: InitOptions = {
70112
force: true,
71113
dryRun: true,
72114
agent: 'claude-code',
73115
framework: 'next',
74116
pm: 'pnpm',
117+
appRoot: 'apps/web',
75118
};
76119
const { runAgentStep } = await import('./agent-step.js');
77-
const { runFrameworkStep } = await import('./framework-step.js');
120+
const { runMonorepoStep } = await import('./monorepo-step.js');
78121
const { runGitignoreStep } = await import('./gitignore-step.js');
79122

80123
// Act
81124
await runInitWizard(options);
82125

83126
// Assert
84127
expect(runAgentStep).toHaveBeenCalledWith(options);
85-
expect(runFrameworkStep).toHaveBeenCalledWith(options, process.cwd());
128+
expect(runMonorepoStep).toHaveBeenCalledWith(options, process.cwd());
86129
expect(runGitignoreStep).toHaveBeenCalledWith(options, process.cwd());
87130
});
88131
});

0 commit comments

Comments
 (0)