From a6abb74edb12cc85d1ac8e00c4b5ac925d1474ca Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 19:34:22 +0200 Subject: [PATCH 1/3] [T-2] test: failing tests for config forwarding bug - dispatcher.test.ts: test.fails for update + install config forwarding - update/handler.test.ts: custom config, backward compat, missing config - install/handler.test.ts: custom config, backward compat, missing config Refs: #186 --- apps/pair-cli/src/commands/dispatcher.test.ts | 87 +++++++++++++++ .../src/commands/install/handler.test.ts | 93 ++++++++++++++++ .../src/commands/update/handler.test.ts | 102 ++++++++++++++++++ 3 files changed, 282 insertions(+) diff --git a/apps/pair-cli/src/commands/dispatcher.test.ts b/apps/pair-cli/src/commands/dispatcher.test.ts index 5c06e1f4..57597c67 100644 --- a/apps/pair-cli/src/commands/dispatcher.test.ts +++ b/apps/pair-cli/src/commands/dispatcher.test.ts @@ -11,6 +11,93 @@ import type { } from './index' import type { KbInfoCommandConfig } from './kb-info/parser' +/** + * #186: config forwarding from dispatch context to handlers + * + * The dispatcher's resolveOptions strips the `config` field, so handlers + * never receive the custom config path. These tests use `test.fails` to + * document the bug — they pass (as "expected failures") before the fix + * and must be changed to `test` after T-1 wires config through. + */ +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.fails('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, + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await dispatchCommand(config, fs, { config: `${cwd}/custom.json` } as any) + + // Bug: config not forwarded → handler uses base config → updates dest, not custom-dest + expect(await fs.readFile(`${cwd}/custom-dest/file.txt`)).toBe('content') + }) + + test.fails( + 'forwards config to install handler — output uses custom registry target', + async () => { + const config: InstallCommandConfig = { + command: 'install', + resolution: 'default', + kb: true, + offline: false, + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await dispatchCommand(config, fs, { config: `${cwd}/custom.json` } as any) + + // Bug: config not forwarded → handler uses base config → installs to dest, not custom-dest + 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/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/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 * From 5714dc8cce9bd35ee87a17492ed4b53f9c165833 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 19:36:03 +0200 Subject: [PATCH 2/3] [T-1] fix: wire config through dispatch path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DispatchContext: add config?: string - resolveOptions: spread config to handler options - cli.ts: pass normalizedOptions.config to dispatch context - dispatcher tests: test.fails → test (bug fixed) Refs: #186 --- apps/pair-cli/src/cli.ts | 2 + apps/pair-cli/src/commands/dispatcher.test.ts | 41 ++++++++----------- apps/pair-cli/src/commands/dispatcher.ts | 2 + 3 files changed, 20 insertions(+), 25 deletions(-) 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 57597c67..e29bd83f 100644 --- a/apps/pair-cli/src/commands/dispatcher.test.ts +++ b/apps/pair-cli/src/commands/dispatcher.test.ts @@ -14,10 +14,8 @@ import type { KbInfoCommandConfig } from './kb-info/parser' /** * #186: config forwarding from dispatch context to handlers * - * The dispatcher's resolveOptions strips the `config` field, so handlers - * never receive the custom config path. These tests use `test.fails` to - * document the bug — they pass (as "expected failures") before the fix - * and must be changed to `test` after T-1 wires config through. + * 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 @@ -58,7 +56,7 @@ describe('#186 — config forwarding through dispatch context', () => { vi.restoreAllMocks() }) - test.fails('forwards config to update handler — output uses custom registry target', async () => { + 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') @@ -72,30 +70,23 @@ describe('#186 — config forwarding through dispatch context', () => { offline: false, } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await dispatchCommand(config, fs, { config: `${cwd}/custom.json` } as any) + await dispatchCommand(config, fs, { config: `${cwd}/custom.json` }) - // Bug: config not forwarded → handler uses base config → updates dest, not custom-dest expect(await fs.readFile(`${cwd}/custom-dest/file.txt`)).toBe('content') }) - test.fails( - 'forwards config to install handler — output uses custom registry target', - async () => { - const config: InstallCommandConfig = { - command: 'install', - resolution: 'default', - kb: true, - offline: false, - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await dispatchCommand(config, fs, { config: `${cwd}/custom.json` } as any) - - // Bug: config not forwarded → handler uses base config → installs to dest, not custom-dest - expect(await fs.exists(`${cwd}/custom-dest/file.txt`)).toBe(true) - }, - ) + 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', () => { 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 }), } } From f8fa574f4f05195161569cdf0c496c37b353bae0 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 19:37:00 +0200 Subject: [PATCH 3/3] [T-3] fix: add config to ParseUpdateOptions and ParseInstallOptions - update/parser.ts: add config?: string to ParseUpdateOptions - install/parser.ts: add config?: string to ParseInstallOptions Refs: #186 --- apps/pair-cli/src/commands/install/parser.ts | 1 + apps/pair-cli/src/commands/update/parser.ts | 1 + 2 files changed, 2 insertions(+) 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/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(