Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/plutohan-additional-idls.md
Original file line number Diff line number Diff line change
@@ -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`
1 change: 1 addition & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
26 changes: 24 additions & 2 deletions packages/cli/src/parsedConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RootNode } from '@codama/nodes';
import { getAllPrograms, type RootNode } from '@codama/nodes';
import { Command } from 'commander';

import { Config, getConfig, ScriptName, ScriptsConfig, VisitorConfig, VisitorPath } from './config';
Expand Down Expand Up @@ -46,13 +46,35 @@ async function parseConfig(
): Promise<ParsedConfig> {
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<RootNode> {
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));
}

// Preserve the main root node's other fields (e.g. `standard`, `version`).
return { ...mainRootNode, additionalPrograms };
}

function parseIdlPath(
config: Pick<Config, 'idl'>,
configPath: string | null,
Expand Down
68 changes: 68 additions & 0 deletions packages/cli/test/parsedConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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'), version: '9.9.9' } as RootNode)
.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']);

@mikhd mikhd Jul 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would double check that version and other fields are preserved after additionalPrograms merge.
Thank you!

// 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 () => {
getConfigMock.mockResolvedValue([{ idl: 'main.json' }, '/root/codama.json']);
getRootNodeFromIdlMock.mockResolvedValue(rootNodeWith('main'));

const parsed = await getParsedConfig({});

expect(parsed.rootNode.additionalPrograms).toEqual([]);
});