Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/pair-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
})
})
}
Expand Down
78 changes: 78 additions & 0 deletions apps/pair-cli/src/commands/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions apps/pair-cli/src/commands/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface DispatchContext {
httpClient?: HttpClientService
cliVersion?: string
baseTarget?: string
config?: string
}

async function dispatchWithExitCode(handler: () => Promise<number>): Promise<void> {
Expand Down Expand Up @@ -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 }),
}
}
93 changes: 93 additions & 0 deletions apps/pair-cli/src/commands/install/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createTestFs>

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'

Expand Down
1 change: 1 addition & 0 deletions apps/pair-cli/src/commands/install/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ interface ParseInstallOptions {
kb?: boolean
skipVerify?: boolean
listTargets?: boolean
config?: string
}

function buildOptionalFields(target?: string, skipVerify?: boolean) {
Expand Down
102 changes: 102 additions & 0 deletions apps/pair-cli/src/commands/update/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
1 change: 1 addition & 0 deletions apps/pair-cli/src/commands/update/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ interface ParseUpdateOptions {
source?: string
offline?: boolean
kb?: boolean
config?: string
}

function resolveSourceConfig(
Expand Down
Loading