diff --git a/apps/pair-cli/src/cli.ts b/apps/pair-cli/src/cli.ts index cea23470..1912e427 100644 --- a/apps/pair-cli/src/cli.ts +++ b/apps/pair-cli/src/cli.ts @@ -191,10 +191,12 @@ function registerCommandFromMetadata( const config = cmdConfig.parse(normalizedOptions, positionalArgs) const initCwd = process.env['INIT_CWD'] + const configPath = normalizedOptions['config'] as string | undefined await dispatchCommand(config, fsService, { httpClient, cliVersion: version, ...(initCwd && { baseTarget: initCwd }), + ...(configPath && { config: configPath }), }) }) } diff --git a/apps/pair-cli/src/commands/dispatcher.test.ts b/apps/pair-cli/src/commands/dispatcher.test.ts index 5c06e1f4..e29bd83f 100644 --- a/apps/pair-cli/src/commands/dispatcher.test.ts +++ b/apps/pair-cli/src/commands/dispatcher.test.ts @@ -11,6 +11,84 @@ import type { } from './index' import type { KbInfoCommandConfig } from './kb-info/parser' +/** + * #186: config forwarding from dispatch context to handlers + * + * Verifies that the dispatcher forwards the `config` field from the + * dispatch context to handler options for both update and install commands. + */ +describe('#186 — config forwarding through dispatch context', () => { + let fs: InMemoryFileSystemService + const cwd = '/project' + + beforeEach(() => { + fs = createTestFs( + { + asset_registries: { + reg: { + source: 'reg', + behavior: 'mirror', + targets: [{ path: 'dest', mode: 'canonical' }], + description: 'base target', + }, + }, + }, + { + [`${cwd}/package.json`]: JSON.stringify({ name: 'test', version: '0.1.0' }), + [`${cwd}/packages/knowledge-hub/package.json`]: JSON.stringify({ + name: '@pair/knowledge-hub', + }), + [`${cwd}/packages/knowledge-hub/dataset/reg/file.txt`]: 'content', + // Custom config overrides target path + [`${cwd}/custom.json`]: JSON.stringify({ + asset_registries: { + reg: { + source: 'reg', + behavior: 'mirror', + targets: [{ path: 'custom-dest', mode: 'canonical' }], + description: 'custom target', + }, + }, + }), + }, + cwd, + ) + vi.restoreAllMocks() + }) + + test('forwards config to update handler — output uses custom registry target', async () => { + // Pre-existing targets (update precondition) + await fs.mkdir(`${cwd}/dest`, { recursive: true }) + await fs.writeFile(`${cwd}/dest/file.txt`, 'old') + await fs.mkdir(`${cwd}/custom-dest`, { recursive: true }) + await fs.writeFile(`${cwd}/custom-dest/file.txt`, 'old') + + const config: UpdateCommandConfig = { + command: 'update', + resolution: 'default', + kb: true, + offline: false, + } + + await dispatchCommand(config, fs, { config: `${cwd}/custom.json` }) + + expect(await fs.readFile(`${cwd}/custom-dest/file.txt`)).toBe('content') + }) + + test('forwards config to install handler — output uses custom registry target', async () => { + const config: InstallCommandConfig = { + command: 'install', + resolution: 'default', + kb: true, + offline: false, + } + + await dispatchCommand(config, fs, { config: `${cwd}/custom.json` }) + + expect(await fs.exists(`${cwd}/custom-dest/file.txt`)).toBe(true) + }) +}) + describe('dispatchCommand() - real handlers integration', () => { let fs: InMemoryFileSystemService const cwd = '/project' diff --git a/apps/pair-cli/src/commands/dispatcher.ts b/apps/pair-cli/src/commands/dispatcher.ts index f7c7a3de..722323aa 100644 --- a/apps/pair-cli/src/commands/dispatcher.ts +++ b/apps/pair-cli/src/commands/dispatcher.ts @@ -6,6 +6,7 @@ interface DispatchContext { httpClient?: HttpClientService cliVersion?: string baseTarget?: string + config?: string } async function dispatchWithExitCode(handler: () => Promise): Promise { @@ -50,5 +51,6 @@ function resolveOptions(ctx: DispatchContext) { ...(ctx.httpClient && { httpClient: ctx.httpClient }), ...(ctx.cliVersion && { cliVersion: ctx.cliVersion }), ...(ctx.baseTarget && { baseTarget: ctx.baseTarget }), + ...(ctx.config && { config: ctx.config }), } } diff --git a/apps/pair-cli/src/commands/install/handler.test.ts b/apps/pair-cli/src/commands/install/handler.test.ts index caed407d..0666f933 100644 --- a/apps/pair-cli/src/commands/install/handler.test.ts +++ b/apps/pair-cli/src/commands/install/handler.test.ts @@ -4,6 +4,99 @@ import type { InstallCommandConfig } from './parser' import { createTestFs } from '#test-utils' import { InMemoryFileSystemService } from '@pair/content-ops' +/** + * #186: config override via options.config + * + * Verifies that handleInstallCommand uses a custom config when + * options.config is provided, and falls back to base config otherwise. + */ +describe('#186: config override via options.config', () => { + const cwd = '/test-project' + const datasetSrc = `${cwd}/packages/knowledge-hub/dataset` + + let fs: ReturnType + + beforeEach(() => { + fs = createTestFs( + { + asset_registries: { + github: { + source: 'github', + behavior: 'mirror', + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub registry', + }, + }, + }, + { + [`${cwd}/package.json`]: JSON.stringify({ name: 'test-pkg', version: '0.1.0' }), + [`${cwd}/packages/knowledge-hub/package.json`]: JSON.stringify({ + name: '@pair/knowledge-hub', + }), + [`${datasetSrc}/github/workflow.yml`]: 'content: val', + [`${datasetSrc}/custom-reg/data.md`]: '# Custom Install Data', + }, + cwd, + ) + }) + + test('uses custom config registries when options.config is provided', async () => { + const customConfig = { + asset_registries: { + 'custom-reg': { + source: 'custom-reg', + behavior: 'mirror', + targets: [{ path: '.custom-target', mode: 'canonical' }], + description: 'Custom registry', + }, + }, + } + await fs.writeFile(`${cwd}/custom-config.json`, JSON.stringify(customConfig)) + + const config: InstallCommandConfig = { + command: 'install', + resolution: 'default', + kb: true, + offline: false, + } + + await handleInstallCommand(config, fs, { + config: `${cwd}/custom-config.json`, + }) + + expect(await fs.exists(`${cwd}/.custom-target/data.md`)).toBe(true) + expect(await fs.readFile(`${cwd}/.custom-target/data.md`)).toBe('# Custom Install Data') + }) + + test('uses base config when options.config is not provided', async () => { + const config: InstallCommandConfig = { + command: 'install', + resolution: 'default', + kb: true, + offline: false, + } + + await handleInstallCommand(config, fs) + + expect(await fs.exists(`${cwd}/.github/workflow.yml`)).toBe(true) + }) + + test('throws when options.config points to non-existent file', async () => { + const config: InstallCommandConfig = { + command: 'install', + resolution: 'default', + kb: true, + offline: false, + } + + await expect( + handleInstallCommand(config, fs, { + config: `${cwd}/nonexistent.json`, + }), + ).rejects.toThrow(/Failed to load custom config/) + }) +}) + describe('handleInstallCommand - real services integration', () => { const cwd = '/test-project' diff --git a/apps/pair-cli/src/commands/install/parser.ts b/apps/pair-cli/src/commands/install/parser.ts index b16acefa..56222f4e 100644 --- a/apps/pair-cli/src/commands/install/parser.ts +++ b/apps/pair-cli/src/commands/install/parser.ts @@ -76,6 +76,7 @@ interface ParseInstallOptions { kb?: boolean skipVerify?: boolean listTargets?: boolean + config?: string } function buildOptionalFields(target?: string, skipVerify?: boolean) { diff --git a/apps/pair-cli/src/commands/update/handler.test.ts b/apps/pair-cli/src/commands/update/handler.test.ts index 8eafb106..a124820d 100644 --- a/apps/pair-cli/src/commands/update/handler.test.ts +++ b/apps/pair-cli/src/commands/update/handler.test.ts @@ -206,6 +206,108 @@ describe('handleUpdateCommand - integration with in-memory services', () => { }) }) +/** + * #186: config override via options.config + * + * Verifies that handleUpdateCommand uses a custom config when + * options.config is provided, and falls back to base config otherwise. + */ +describe('#186: config override via options.config', () => { + let fs: InMemoryFileSystemService + let httpClient: MockHttpClientService + + const cwd = '/project' + const datasetSrc = '/project/packages/knowledge-hub/dataset' + + beforeEach(() => { + fs = new InMemoryFileSystemService( + { + [`${cwd}/package.json`]: JSON.stringify({ name: 'test', version: '0.1.0' }), + [`${cwd}/packages/knowledge-hub/package.json`]: JSON.stringify({ + name: '@pair/knowledge-hub', + }), + [`${cwd}/config.json`]: JSON.stringify({ + asset_registries: { + 'test-registry': { + source: 'test-registry', + behavior: 'mirror', + targets: [{ path: '.pair/test-registry', mode: 'canonical' }], + description: 'Base registry', + }, + }, + }), + [`${datasetSrc}/test-registry/file.md`]: '# Base Content', + [`${datasetSrc}/custom-registry/data.md`]: '# Custom Data', + [`${cwd}/.pair/test-registry/file.md`]: '# Old Base', + }, + cwd, + cwd, + ) + httpClient = new MockHttpClientService() + }) + + test('uses custom config registries when options.config is provided', async () => { + const customConfig = { + asset_registries: { + 'custom-registry': { + source: 'custom-registry', + behavior: 'mirror', + targets: [{ path: '.pair/custom-target', mode: 'canonical' }], + description: 'Custom registry', + }, + }, + } + await fs.writeFile(`${cwd}/custom-config.json`, JSON.stringify(customConfig)) + // Pre-existing target (update precondition) + await fs.mkdir(`${cwd}/.pair/custom-target`, { recursive: true }) + await fs.writeFile(`${cwd}/.pair/custom-target/data.md`, '# Old Custom') + + const config: UpdateCommandConfig = { + command: 'update', + resolution: 'default', + kb: true, + offline: false, + } + + await handleUpdateCommand(config, fs, { + httpClient, + config: `${cwd}/custom-config.json`, + }) + + expect(await fs.readFile(`${cwd}/.pair/custom-target/data.md`)).toBe('# Custom Data') + }) + + test('uses base config when options.config is not provided', async () => { + const config: UpdateCommandConfig = { + command: 'update', + resolution: 'default', + kb: true, + offline: false, + } + + await handleUpdateCommand(config, fs, { httpClient }) + + expect(await fs.readFile(`${cwd}/.pair/test-registry/file.md`)).toBe('# Base Content') + }) + + test('throws when options.config points to non-existent file', async () => { + // Pre-existing target so we don't fail on the precondition check + const config: UpdateCommandConfig = { + command: 'update', + resolution: 'default', + kb: true, + offline: false, + } + + await expect( + handleUpdateCommand(config, fs, { + httpClient, + config: `${cwd}/nonexistent.json`, + }), + ).rejects.toThrow(/Failed to load custom config/) + }) +}) + /** * BUG 4: update precondition — targets must exist * diff --git a/apps/pair-cli/src/commands/update/parser.ts b/apps/pair-cli/src/commands/update/parser.ts index 5554a470..b709b893 100644 --- a/apps/pair-cli/src/commands/update/parser.ts +++ b/apps/pair-cli/src/commands/update/parser.ts @@ -61,6 +61,7 @@ interface ParseUpdateOptions { source?: string offline?: boolean kb?: boolean + config?: string } function resolveSourceConfig(