From 7acb8132ba6c6af28e1d55e1aff755ff144e4ec9 Mon Sep 17 00:00:00 2001 From: Pluto Han Date: Thu, 2 Jul 2026 11:09:03 +0400 Subject: [PATCH 1/2] feat(cli): add additionalIdls config option --- .changeset/plutohan-additional-idls.md | 5 ++ packages/cli/README.md | 1 + packages/cli/src/config.ts | 1 + packages/cli/src/parsedConfig.ts | 25 +++++++++- packages/cli/test/parsedConfig.test.ts | 65 ++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 .changeset/plutohan-additional-idls.md create mode 100644 packages/cli/test/parsedConfig.test.ts diff --git a/.changeset/plutohan-additional-idls.md b/.changeset/plutohan-additional-idls.md new file mode 100644 index 000000000..662e5db71 --- /dev/null +++ b/.changeset/plutohan-additional-idls.md @@ -0,0 +1,5 @@ +--- +'@codama/cli': minor +--- + +Add an `additionalIdls` config option to generate clients for multiple programs in an Anchor workspace from a single `codama.json` diff --git a/packages/cli/README.md b/packages/cli/README.md index f7b8f4b1a..189015dad 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -71,6 +71,7 @@ Here are the available options for the `run` command: The Codama configuration file defines an object containing the following fields: - `idl` (string): The path to the IDL file. This can be a Codama IDL or an Anchor IDL which will be automatically converted to a Codama IDL. +- `additionalIdls` (array): An array of paths to additional Anchor IDLs. Their programs are included alongside the main IDL's program (as `additionalPrograms`), so a single config can generate clients for multiple programs in an Anchor workspace. ```json { "idl": "path/to/idl" diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 9328310c3..f19920045 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -6,6 +6,7 @@ import { ProgramOptions } from './programOptions'; import { canRead, CliError, importModuleItem, logWarning } from './utils'; export type Config = Readonly<{ + additionalIdls?: readonly string[]; idl?: string; scripts?: ScriptsConfig; before?: readonly VisitorConfig[]; diff --git a/packages/cli/src/parsedConfig.ts b/packages/cli/src/parsedConfig.ts index 4c18dcfdc..c36023389 100644 --- a/packages/cli/src/parsedConfig.ts +++ b/packages/cli/src/parsedConfig.ts @@ -1,4 +1,4 @@ -import type { RootNode } from '@codama/nodes'; +import { getAllPrograms, type RootNode,rootNode } from '@codama/nodes'; import { Command } from 'commander'; import { Config, getConfig, ScriptName, ScriptsConfig, VisitorConfig, VisitorPath } from './config'; @@ -46,13 +46,34 @@ async function parseConfig( ): Promise { const idlPath = parseIdlPath(config, configPath, options); const idlContent = await importModuleItem({ identifier: 'IDL', from: idlPath }); - const rootNode = await getRootNodeFromIdl(idlContent); + const mainRootNode = await getRootNodeFromIdl(idlContent); + const rootNode = await mergeAdditionalIdls(mainRootNode, config.additionalIdls ?? [], configPath); const scripts = parseScripts(config.scripts ?? {}, configPath); const visitors = (config.before ?? []).map((v, i) => parseVisitorConfig(v, configPath, i, null)); return { configPath, idlContent, idlPath, rootNode, scripts, before: visitors }; } +async function mergeAdditionalIdls( + mainRootNode: RootNode, + additionalIdls: readonly string[], + configPath: string | null, +): Promise { + if (additionalIdls.length === 0) { + return mainRootNode; + } + + const additionalPrograms = [...mainRootNode.additionalPrograms]; + for (const additionalIdl of additionalIdls) { + const additionalIdlPath = resolveConfigPath(additionalIdl, configPath); + const additionalIdlContent = await importModuleItem({ identifier: 'additional IDL', from: additionalIdlPath }); + const additionalRootNode = await getRootNodeFromIdl(additionalIdlContent); + additionalPrograms.push(...getAllPrograms(additionalRootNode)); + } + + return rootNode(mainRootNode.program, additionalPrograms); +} + function parseIdlPath( config: Pick, configPath: string | null, diff --git a/packages/cli/test/parsedConfig.test.ts b/packages/cli/test/parsedConfig.test.ts new file mode 100644 index 000000000..f81147485 --- /dev/null +++ b/packages/cli/test/parsedConfig.test.ts @@ -0,0 +1,65 @@ +import type { ProgramNode, RootNode } from '@codama/nodes'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { getConfig } from '../src/config'; +import { getParsedConfig } from '../src/parsedConfig'; +import { importModuleItem } from '../src/utils/import'; +import { getRootNodeFromIdl } from '../src/utils/nodes'; + +vi.mock('../src/config', () => ({ getConfig: vi.fn() })); +vi.mock('../src/utils/import', () => ({ importModuleItem: vi.fn() })); +vi.mock('../src/utils/nodes', () => ({ getRootNodeFromIdl: vi.fn() })); + +const getConfigMock = vi.mocked(getConfig); +const importModuleItemMock = vi.mocked(importModuleItem); +const getRootNodeFromIdlMock = vi.mocked(getRootNodeFromIdl); + +function programNode(name: string): ProgramNode { + return { + accounts: [], + constants: [], + definedTypes: [], + errors: [], + instructions: [], + kind: 'programNode', + name, + pdas: [], + } as unknown as ProgramNode; +} + +function rootNodeWith(name: string): RootNode { + return { + additionalPrograms: [], + kind: 'rootNode', + program: programNode(name), + standard: 'codama', + version: '1.0.0', + } as unknown as RootNode; +} + +beforeEach(() => { + vi.resetAllMocks(); + importModuleItemMock.mockResolvedValue({}); +}); + +test('merges additionalIdls programs into the root node additionalPrograms', async () => { + getConfigMock.mockResolvedValue([{ additionalIdls: ['a.json', 'b.json'], idl: 'main.json' }, '/root/codama.json']); + getRootNodeFromIdlMock + .mockResolvedValueOnce(rootNodeWith('main')) + .mockResolvedValueOnce(rootNodeWith('a')) + .mockResolvedValueOnce(rootNodeWith('b')); + + const parsed = await getParsedConfig({}); + + expect(parsed.rootNode.program.name).toBe('main'); + expect(parsed.rootNode.additionalPrograms.map(p => p.name)).toEqual(['a', 'b']); +}); + +test('leaves the root node unchanged when no additionalIdls are provided', async () => { + getConfigMock.mockResolvedValue([{ idl: 'main.json' }, '/root/codama.json']); + getRootNodeFromIdlMock.mockResolvedValue(rootNodeWith('main')); + + const parsed = await getParsedConfig({}); + + expect(parsed.rootNode.additionalPrograms).toEqual([]); +}); From c083e6196230f249dbfa3ea19dcad22e0e22d33c Mon Sep 17 00:00:00 2001 From: Pluto Han Date: Mon, 6 Jul 2026 23:12:23 +0400 Subject: [PATCH 2/2] Preserve root node fields when merging additional IDLs --- packages/cli/src/parsedConfig.ts | 5 +++-- packages/cli/test/parsedConfig.test.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/parsedConfig.ts b/packages/cli/src/parsedConfig.ts index c36023389..454936adb 100644 --- a/packages/cli/src/parsedConfig.ts +++ b/packages/cli/src/parsedConfig.ts @@ -1,4 +1,4 @@ -import { getAllPrograms, type RootNode,rootNode } from '@codama/nodes'; +import { getAllPrograms, type RootNode } from '@codama/nodes'; import { Command } from 'commander'; import { Config, getConfig, ScriptName, ScriptsConfig, VisitorConfig, VisitorPath } from './config'; @@ -71,7 +71,8 @@ async function mergeAdditionalIdls( additionalPrograms.push(...getAllPrograms(additionalRootNode)); } - return rootNode(mainRootNode.program, additionalPrograms); + // Preserve the main root node's other fields (e.g. `standard`, `version`). + return { ...mainRootNode, additionalPrograms }; } function parseIdlPath( diff --git a/packages/cli/test/parsedConfig.test.ts b/packages/cli/test/parsedConfig.test.ts index f81147485..4597d4f76 100644 --- a/packages/cli/test/parsedConfig.test.ts +++ b/packages/cli/test/parsedConfig.test.ts @@ -45,7 +45,7 @@ beforeEach(() => { test('merges additionalIdls programs into the root node additionalPrograms', async () => { getConfigMock.mockResolvedValue([{ additionalIdls: ['a.json', 'b.json'], idl: 'main.json' }, '/root/codama.json']); getRootNodeFromIdlMock - .mockResolvedValueOnce(rootNodeWith('main')) + .mockResolvedValueOnce({ ...rootNodeWith('main'), version: '9.9.9' } as RootNode) .mockResolvedValueOnce(rootNodeWith('a')) .mockResolvedValueOnce(rootNodeWith('b')); @@ -53,6 +53,9 @@ test('merges additionalIdls programs into the root node additionalPrograms', asy expect(parsed.rootNode.program.name).toBe('main'); expect(parsed.rootNode.additionalPrograms.map(p => p.name)).toEqual(['a', 'b']); + // The main root node's other fields are preserved by the merge. + expect(parsed.rootNode.standard).toBe('codama'); + expect(parsed.rootNode.version).toBe('9.9.9'); }); test('leaves the root node unchanged when no additionalIdls are provided', async () => {