diff --git a/src/config/config-resolver.test.ts b/src/config/config-resolver.test.ts index e6d0ff773..05acf6ece 100644 --- a/src/config/config-resolver.test.ts +++ b/src/config/config-resolver.test.ts @@ -226,6 +226,32 @@ describe("config-resolver", () => { expect(config.getOutputRoots()).toContain(resolve("./app2")); expect(config.getOutputRoots()).toContain(resolve("./app3")); }); + + it("should resolve outputRoots configured per target", async () => { + const configContent = JSON.stringify({ + targets: ["copilot", "claudecode"], + outputRoots: { + copilot: "./build/copilot", + claudecode: ["./build/claudecode", "./build/claude-extra"], + }, + }); + await writeFileContent(join(testDir, "rulesync.jsonc"), configContent); + + const config = await ConfigResolver.resolve({ + configPath: join(testDir, "rulesync.jsonc"), + }); + + expect(config.getOutputRoots("copilot")).toEqual([resolve("./build/copilot")]); + expect(config.getOutputRoots("claudecode")).toEqual([ + resolve("./build/claudecode"), + resolve("./build/claude-extra"), + ]); + expect(config.getOutputRoots()).toEqual([ + resolve("./build/copilot"), + resolve("./build/claudecode"), + resolve("./build/claude-extra"), + ]); + }); }); describe("local configuration (rulesync.local.jsonc)", () => { diff --git a/src/config/config-resolver.ts b/src/config/config-resolver.ts index ef2f413eb..3ec122b1d 100644 --- a/src/config/config-resolver.ts +++ b/src/config/config-resolver.ts @@ -30,6 +30,7 @@ import { PartialConfigParams, RequiredConfigParams, } from "./config.js"; +import type { OutputRoots } from "./config.js"; /** * CLI-resolvable params exclude `sources` — sources are config-file-only. @@ -372,9 +373,9 @@ function getOutputRootsInLightOfGlobal({ outputRoots, global, }: { - outputRoots: string[]; + outputRoots: OutputRoots; global: boolean; -}): string[] { +}): OutputRoots { if (global) { // When global is true, the base directory is always the home directory return [getHomeDirectory()]; @@ -383,11 +384,26 @@ function getOutputRootsInLightOfGlobal({ // Validate the *raw* user input first so traversal patterns like // `/foo/../bar` cannot slip through `resolve()`'s normalization. Then // resolve to absolute for downstream consumers. - outputRoots.forEach((outputRoot) => { - validateOutputRoot(outputRoot); - }); + if (Array.isArray(outputRoots)) { + outputRoots.forEach((outputRoot) => { + validateOutputRoot(outputRoot); + }); + + return outputRoots.map((outputRoot) => resolve(outputRoot)); + } + + const resolvedOutputRoots: OutputRoots = {}; + for (const [target, targetOutputRoots] of Object.entries(outputRoots)) { + const roots = Array.isArray(targetOutputRoots) ? targetOutputRoots : [targetOutputRoots]; + roots.forEach((outputRoot) => { + validateOutputRoot(outputRoot); + }); + resolvedOutputRoots[target as ToolTarget] = Array.isArray(targetOutputRoots) + ? roots.map((outputRoot) => resolve(outputRoot)) + : resolve(targetOutputRoots); + } - return outputRoots.map((outputRoot) => resolve(outputRoot)); + return resolvedOutputRoots; } function extractConfigFileTargets( diff --git a/src/config/config.ts b/src/config/config.ts index 17a4dc225..415e9aa50 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -58,8 +58,11 @@ const SourceEntrySchema = z.object({ }); export type SourceEntry = z.infer; -const ConfigParamsSchema = z.object({ - outputRoots: z.array(z.string()), +export const ConfigParamsSchema = z.object({ + outputRoots: z.union([ + z.array(z.string()), + z.record(z.string(), z.union([z.string(), z.array(z.string())])), + ]), targets: RulesyncConfigTargetsSchema, features: RulesyncFeaturesSchema, verbose: z.boolean(), @@ -99,7 +102,9 @@ const ConfigParamsSchema = z.object({ // `assertTargetsOrFeaturesProvided`. Programmatic callers constructing // `Config` directly must respect these invariants. type InferredConfigParams = z.infer; +export type OutputRoots = string[] | Partial>; export type ConfigParams = Omit & { + outputRoots: OutputRoots; targets?: RulesyncConfigTargets; features?: RulesyncFeatures; configFileTargets?: ToolTarget[]; @@ -108,6 +113,7 @@ export type ConfigParams = Omit & const PartialConfigParamsSchema = z.partial(ConfigParamsSchema); type InferredPartialConfigParams = z.infer; export type PartialConfigParams = Omit & { + outputRoots?: OutputRoots; targets?: RulesyncConfigTargets; features?: RulesyncFeatures; }; @@ -119,6 +125,7 @@ export const ConfigFileSchema = z.object({ }); type InferredConfigFile = z.infer; export type ConfigFile = Omit & { + outputRoots?: OutputRoots; targets?: RulesyncConfigTargets; features?: RulesyncFeatures; }; @@ -126,6 +133,7 @@ export type ConfigFile = Omit & { const RequiredConfigParamsSchema = z.required(ConfigParamsSchema); type InferredRequiredConfigParams = z.infer; export type RequiredConfigParams = Omit & { + outputRoots: OutputRoots; targets?: RulesyncConfigTargets; features?: RulesyncFeatures; }; @@ -207,7 +215,7 @@ const assertTargetsOrFeaturesProvided = ({ }; export class Config { - private readonly outputRoots: string[]; + private readonly outputRoots: OutputRoots; private readonly targets: RulesyncConfigTargets; private readonly features: RulesyncFeatures; /** @@ -274,6 +282,7 @@ export class Config { // Reject unknown keys in the object form of `targets`. Array-form values // are already validated at the Zod schema level. this.validateObjectFormTargetKeys(resolvedTargets); + this.validateObjectFormOutputRootKeys(outputRoots); // Validate conflicting targets (accepts array and object forms) this.validateConflictingTargets(resolvedTargets); @@ -346,6 +355,18 @@ export class Config { } } + private validateObjectFormOutputRootKeys(outputRoots: OutputRoots): void { + if (Array.isArray(outputRoots)) return; + const validTargets = new Set(ALL_TOOL_TARGETS); + for (const key of Object.keys(outputRoots)) { + if (!validTargets.has(key)) { + throw new Error( + `Unknown outputRoots target '${key}'. Valid targets: ${ALL_TOOL_TARGETS.join(", ")}.`, + ); + } + } + } + private validateConflictingTargets(targets: RulesyncConfigTargets): void { // Wildcard (*) doesn't include legacy targets, so conflicts can only // occur when both sides of a conflicting pair are explicitly present. @@ -366,8 +387,25 @@ export class Config { } } - public getOutputRoots(): string[] { - return this.outputRoots; + public getOutputRoots(): string[]; + public getOutputRoots(target: ToolTarget): string[]; + public getOutputRoots(target?: ToolTarget): string[] { + if (Array.isArray(this.outputRoots)) { + return this.outputRoots; + } + + if (target) { + const targetOutputRoots = this.outputRoots[target]; + if (targetOutputRoots === undefined) return []; + return Array.isArray(targetOutputRoots) ? targetOutputRoots : [targetOutputRoots]; + } + + const allRoots: string[] = []; + for (const value of Object.values(this.outputRoots)) { + if (value === undefined) continue; + allRoots.push(...(Array.isArray(value) ? value : [value])); + } + return [...new Set(allRoots)]; } /** diff --git a/src/lib/generate.ts b/src/lib/generate.ts index 72100c41f..1b0a8fb29 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -490,8 +490,8 @@ async function generateRulesCore(params: { }) : new Map(); - for (const outputRoot of config.getOutputRoots()) { - for (const toolTarget of toolTargets) { + for (const toolTarget of toolTargets) { + for (const outputRoot of config.getOutputRoots(toolTarget)) { // Check if rules feature is enabled for this specific target if (!config.getFeatures(toolTarget).includes("rules")) { continue; @@ -566,7 +566,7 @@ async function generateIgnoreCore(params: { continue; } - for (const outputRoot of config.getOutputRoots()) { + for (const outputRoot of config.getOutputRoots(toolTarget)) { try { const processor = new IgnoreProcessor({ // Pass `outputRoot` verbatim. The legacy @@ -619,8 +619,8 @@ async function generateMcpCore(params: { logger, }); - for (const outputRoot of config.getOutputRoots()) { - for (const toolTarget of toolTargets) { + for (const toolTarget of toolTargets) { + for (const outputRoot of config.getOutputRoots(toolTarget)) { // Check if mcp feature is enabled for this specific target if (!config.getFeatures(toolTarget).includes("mcp")) { continue; @@ -670,8 +670,8 @@ async function generateCommandsCore(params: { logger, }); - for (const outputRoot of config.getOutputRoots()) { - for (const toolTarget of toolTargets) { + for (const toolTarget of toolTargets) { + for (const outputRoot of config.getOutputRoots(toolTarget)) { // Check if commands feature is enabled for this specific target if (!config.getFeatures(toolTarget).includes("commands")) { continue; @@ -726,8 +726,8 @@ async function generateSubagentsCore(params: { logger, }); - for (const outputRoot of config.getOutputRoots()) { - for (const toolTarget of toolTargets) { + for (const toolTarget of toolTargets) { + for (const outputRoot of config.getOutputRoots(toolTarget)) { // Check if subagents feature is enabled for this specific target if (!config.getFeatures(toolTarget).includes("subagents")) { continue; @@ -778,8 +778,8 @@ async function generateSkillsCore(params: { logger, }); - for (const outputRoot of config.getOutputRoots()) { - for (const toolTarget of toolTargets) { + for (const toolTarget of toolTargets) { + for (const outputRoot of config.getOutputRoots(toolTarget)) { // Check if skills feature is enabled for this specific target if (!config.getFeatures(toolTarget).includes("skills")) { continue; @@ -838,8 +838,8 @@ async function generateHooksCore(params: { logger, }); - for (const outputRoot of config.getOutputRoots()) { - for (const toolTarget of toolTargets) { + for (const toolTarget of toolTargets) { + for (const outputRoot of config.getOutputRoots(toolTarget)) { // Check if hooks feature is enabled for this specific target if (!config.getFeatures(toolTarget).includes("hooks")) { continue; @@ -886,8 +886,8 @@ async function generatePermissionsCore(params: { const allPaths: string[] = []; let hasDiff = false; - for (const outputRoot of config.getOutputRoots()) { - for (const toolTarget of intersection(config.getTargets(), supportedPermissionsTargets)) { + for (const toolTarget of intersection(config.getTargets(), supportedPermissionsTargets)) { + for (const outputRoot of config.getOutputRoots(toolTarget)) { if (!config.getFeatures(toolTarget).includes("permissions")) { continue; }