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
5 changes: 5 additions & 0 deletions .changeset/fix-convert-without-package-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codama/cli": patch
---

Make `codama convert` work without a `package.json`. The CLI resolves an available `@codama/nodes-from-anchor` adapter directly. If the adapter is missing, it suggests an `npx -p` command instead of creating a `package.json`; projects with a `package.json` keep the existing install prompt.
2 changes: 1 addition & 1 deletion packages/cli/src/commands/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function setConvertCommand(program: Command): void {
async function doConvert(idlPath: string, outPath: string) {
const idl = await readJson(idlPath);

const node = await getRootNodeFromIdl(idl);
const node = await getRootNodeFromIdl(idl, { npxCommandArgs: ['convert', idlPath, outPath] });

await writeFile(outPath, JSON.stringify(node, null, 2));

Expand Down
56 changes: 49 additions & 7 deletions packages/cli/src/utils/nodes.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,72 @@
import type { RootNode } from '@codama/nodes';
import pico from 'picocolors';

import { CliError } from './errors';
import { importModuleItem } from './import';
import { installMissingDependencies } from './packageInstall';
import { tryGetPackageJson } from './packageJson';

export async function getRootNodeFromIdl(idl: unknown): Promise<RootNode> {
type RootNodeFromAnchor = (idl: unknown) => RootNode;
export type GetRootNodeFromIdlOptions = { npxCommandArgs?: string[] };

const NODES_FROM_ANCHOR = { from: '@codama/nodes-from-anchor', item: 'rootNodeFromAnchor' } as const;

export async function getRootNodeFromIdl(idl: unknown, options: GetRootNodeFromIdlOptions = {}): Promise<RootNode> {
if (typeof idl !== 'object' || idl === null) {
throw new CliError('Unexpected IDL content. Expected an object, got ' + typeof idl);
}
if (isRootNode(idl)) {
return idl;
}

const rootNodeFromAnchor =
(await tryImportRootNodeFromAnchor()) ?? (await resolveRootNodeFromAnchor(options.npxCommandArgs));
return rootNodeFromAnchor(idl);
}

async function resolveRootNodeFromAnchor(npxCommandArgs: string[] = ['[...]']): Promise<RootNodeFromAnchor> {
if (!(await tryGetPackageJson())) {
throw new CliError('Anchor IDL support is not available.', [
`${pico.bold('Missing dependency')}: ${NODES_FROM_ANCHOR.from}`,
'No package.json was found, so Codama did not install dependencies in this directory.',
`${pico.bold('Re-run with')}: ${pico.yellow(getNpxCommand(npxCommandArgs))}`,
]);
}

return await installRootNodeFromAnchor();
}

function getNpxCommand(commandArgs: string[]): string {
return ['npx', '-p', 'codama', '-p', NODES_FROM_ANCHOR.from, 'codama', ...commandArgs].join(' ');
}

async function installRootNodeFromAnchor(): Promise<RootNodeFromAnchor> {
const hasNodesFromAnchor = await installMissingDependencies(
'Anchor IDL detected. Additional dependencies are required to process Anchor IDLs.',
['@codama/nodes-from-anchor'],
[NODES_FROM_ANCHOR.from],
);
if (!hasNodesFromAnchor) {
throw new CliError('Cannot proceed without Anchor IDL support.');
}

const rootNodeFromAnchor = await importModuleItem<(idl: unknown) => RootNode>({
from: '@codama/nodes-from-anchor',
item: 'rootNodeFromAnchor',
});
return rootNodeFromAnchor(idl);
return await importModuleItem<RootNodeFromAnchor>(NODES_FROM_ANCHOR);
}

async function tryImportRootNodeFromAnchor(): Promise<RootNodeFromAnchor | undefined> {
try {
return await importModuleItem<RootNodeFromAnchor>(NODES_FROM_ANCHOR);
} catch (error) {
if (isMissingModuleError(error)) return undefined;
throw error;
}
}

function isMissingModuleError(error: unknown): boolean {
for (let current: unknown = error; current != null; current = (current as { cause?: unknown }).cause) {
const { code } = current as { code?: unknown };
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') return true;
}
return false;
}

export function isRootNode(value: unknown): value is RootNode {
Expand Down
30 changes: 24 additions & 6 deletions packages/cli/src/utils/packageJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,37 @@ type PackageJson = {
};

let packageJson: PackageJson | undefined;
export async function getPackageJson(): Promise<PackageJson> {
if (!packageJson) {
let packageJsonChecked = false;

/**
* Reads the local `package.json` if one exists. Returns `undefined` when none is found so callers
* can degrade gracefully (e.g. when running outside a Node project or via `npx`).
*/
export async function tryGetPackageJson(): Promise<PackageJson | undefined> {
Comment thread
senzenn marked this conversation as resolved.
if (!packageJsonChecked) {
const packageJsonPath = resolveRelativePath('package.json');
if (!(await canRead(packageJsonPath))) {
throw new CliError('Cannot read package.json.', [`${pico.bold('Path')}: ${packageJsonPath}`]);
if (await canRead(packageJsonPath)) {
packageJson = await readJson<PackageJson>(packageJsonPath);
}
packageJson = await readJson<PackageJson>(packageJsonPath);
packageJsonChecked = true;
Comment thread
lorisleiva marked this conversation as resolved.
}
return packageJson;
}

/** Reads the local `package.json`, throwing when none exists. */
export async function getPackageJson(): Promise<PackageJson> {
const json = await tryGetPackageJson();
if (!json) {
const packageJsonPath = resolveRelativePath('package.json');
throw new CliError('Cannot read package.json.', [`${pico.bold('Path')}: ${packageJsonPath}`]);
}
return json;
}

/** Lists declared dependencies, returning `[]` outside a Node project (no `package.json`). */
export async function getPackageJsonDependencies(options: { includeDev?: boolean } = {}): Promise<string[]> {
const packageJson = await getPackageJson();
const packageJson = await tryGetPackageJson();
Comment thread
lorisleiva marked this conversation as resolved.
if (!packageJson) return [];
return [
...(packageJson.dependencies ? Object.keys(packageJson.dependencies) : []),
...(options.includeDev && packageJson.devDependencies ? Object.keys(packageJson.devDependencies) : []),
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/utils/packageManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createChildCommand, spawnChildCommand } from './childCommands';
import { canRead, resolveRelativePath } from './fs';
import { getPackageJson } from './packageJson';
import { tryGetPackageJson } from './packageJson';

type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn';

Expand Down Expand Up @@ -28,8 +28,8 @@ async function detectPackageManager(): Promise<PackageManager> {
}

async function detectPackageManagerFromPackageJson(): Promise<PackageManager | undefined> {
const packageJson = await getPackageJson();
if (!packageJson.packageManager) return undefined;
const packageJson = await tryGetPackageJson();
if (!packageJson?.packageManager) return undefined;
if (packageJson.packageManager.startsWith('npm@')) return 'npm';
if (packageJson.packageManager.startsWith('pnpm@')) return 'pnpm';
if (packageJson.packageManager.startsWith('yarn@')) return 'yarn';
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/test/nodes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { RootNode } from '@codama/nodes';
import pico from 'picocolors';
import { beforeEach, describe, expect, test, vi } from 'vitest';

import { CliError } from '../src/utils/errors';
import { importModuleItem } from '../src/utils/import';
import { getRootNodeFromIdl } from '../src/utils/nodes';
import { installMissingDependencies } from '../src/utils/packageInstall';
import { tryGetPackageJson } from '../src/utils/packageJson';

vi.mock('../src/utils/import', () => ({ importModuleItem: vi.fn() }));
vi.mock('../src/utils/packageInstall', () => ({ installMissingDependencies: vi.fn() }));
vi.mock('../src/utils/packageJson', () => ({ tryGetPackageJson: vi.fn() }));

const importModuleItemMock = vi.mocked(importModuleItem);
const installMissingDependenciesMock = vi.mocked(installMissingDependencies);
const tryGetPackageJsonMock = vi.mocked(tryGetPackageJson);
const anchorIdl = { instructions: [], metadata: { spec: '0.1.0' } };
const rootNode = { kind: 'rootNode', standard: 'codama' } as RootNode;

describe('getRootNodeFromIdl', () => {
beforeEach(() => {
vi.resetAllMocks();
});

test('uses an already resolvable Anchor adapter without checking package.json', async () => {
const rootNodeFromAnchor = vi.fn().mockReturnValue(rootNode);
importModuleItemMock.mockResolvedValue(rootNodeFromAnchor);

await expect(getRootNodeFromIdl(anchorIdl)).resolves.toBe(rootNode);
expect(tryGetPackageJsonMock).not.toHaveBeenCalled();
expect(installMissingDependenciesMock).not.toHaveBeenCalled();
});

test('suggests npx without installing when package.json and the adapter are missing', async () => {
importModuleItemMock.mockRejectedValue(createMissingModuleError('@codama/nodes-from-anchor'));
tryGetPackageJsonMock.mockResolvedValue(undefined);

const error = await getRootNodeFromIdl(anchorIdl, {
npxCommandArgs: ['convert', 'anchor.json', 'codama.json'],
}).catch((cause: unknown) => cause);

expect(error).toBeInstanceOf(CliError);
expect((error as CliError).message).toBe('Anchor IDL support is not available.');
expect((error as CliError).items).toEqual([
`${pico.bold('Missing dependency')}: @codama/nodes-from-anchor`,
'No package.json was found, so Codama did not install dependencies in this directory.',
`${pico.bold('Re-run with')}: ${pico.yellow(
'npx -p codama -p @codama/nodes-from-anchor codama convert anchor.json codama.json',
)}`,
]);
expect(installMissingDependenciesMock).not.toHaveBeenCalled();
});

test('keeps the install flow when package.json exists', async () => {
const rootNodeFromAnchor = vi.fn().mockReturnValue(rootNode);
importModuleItemMock
.mockRejectedValueOnce(createMissingModuleError('@codama/nodes-from-anchor'))
.mockResolvedValueOnce(rootNodeFromAnchor);
tryGetPackageJsonMock.mockResolvedValue({ name: 'example' });
installMissingDependenciesMock.mockResolvedValue(true);

await expect(getRootNodeFromIdl(anchorIdl)).resolves.toBe(rootNode);
expect(installMissingDependenciesMock).toHaveBeenCalledWith(
'Anchor IDL detected. Additional dependencies are required to process Anchor IDLs.',
['@codama/nodes-from-anchor'],
);
});

test('surfaces adapter load failures instead of treating them as a missing adapter', async () => {
const loadError = new SyntaxError('Unexpected token');
importModuleItemMock.mockRejectedValue(loadError);

await expect(getRootNodeFromIdl(anchorIdl)).rejects.toBe(loadError);
expect(tryGetPackageJsonMock).not.toHaveBeenCalled();
expect(installMissingDependenciesMock).not.toHaveBeenCalled();
});

test('treats any missing-module error as a missing adapter', async () => {
importModuleItemMock.mockRejectedValue(createMissingModuleError('missing-transitive-package'));
tryGetPackageJsonMock.mockResolvedValue(undefined);

const error = await getRootNodeFromIdl(anchorIdl).catch((cause: unknown) => cause);

expect(error).toBeInstanceOf(CliError);
expect((error as CliError).message).toBe('Anchor IDL support is not available.');
expect(installMissingDependenciesMock).not.toHaveBeenCalled();
});
});

function createMissingModuleError(moduleName: string): CliError {
const cause = Object.assign(new Error(`Cannot find package '${moduleName}'`), { code: 'ERR_MODULE_NOT_FOUND' });
return new CliError('Failed to load module.', [], { cause });
}
23 changes: 23 additions & 0 deletions packages/cli/test/packageJson.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { beforeEach, expect, test, vi } from 'vitest';

import { canRead, resolveRelativePath } from '../src/utils/fs';

vi.mock('../src/utils/fs', () => ({
canRead: vi.fn(),
readJson: vi.fn(),
resolveRelativePath: vi.fn(),
}));

beforeEach(() => {
vi.resetModules();
vi.mocked(canRead).mockReset();
vi.mocked(resolveRelativePath).mockReturnValue('/tmp/codama-no-package-json/package.json');
});

test('returns undefined and no dependencies when package.json is missing', async () => {
vi.mocked(canRead).mockResolvedValue(false);
const { getPackageJsonDependencies, tryGetPackageJson } = await import('../src/utils/packageJson');

await expect(tryGetPackageJson()).resolves.toBeUndefined();
await expect(getPackageJsonDependencies()).resolves.toEqual([]);
});
Loading