Skip to content

Commit cc36162

Browse files
committed
Add Codama CLI
1 parent 73e21de commit cc36162

File tree

39 files changed

+917
-8
lines changed

39 files changed

+917
-8
lines changed

packages/cli/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/

packages/cli/.prettierignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dist/
2+
e2e/
3+
test-ledger/
4+
target/
5+
CHANGELOG.md

packages/cli/LICENSE

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Codama
4+
5+
Permission is hereby granted, free of charge, to any person obtaining
6+
a copy of this software and associated documentation files (the
7+
"Software"), to deal in the Software without restriction, including
8+
without limitation the rights to use, copy, modify, merge, publish,
9+
distribute, sublicense, and/or sell copies of the Software, and to
10+
permit persons to whom the Software is furnished to do so, subject to
11+
the following conditions:
12+
13+
The above copyright notice and this permission notice shall be
14+
included in all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

packages/cli/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Codama ➤ CLI
2+
3+
[![npm][npm-image]][npm-url]
4+
[![npm-downloads][npm-downloads-image]][npm-url]
5+
6+
[npm-downloads-image]: https://img.shields.io/npm/dm/@codama/cli.svg?style=flat
7+
[npm-image]: https://img.shields.io/npm/v/@codama/cli.svg?style=flat&label=%40codama%2Fcli
8+
[npm-url]: https://www.npmjs.com/package/@codama/cli
9+
10+
This package provides a CLI for the Codama library that can be used to execute visitors on Codama IDLs.
11+
12+
## Installation
13+
14+
```sh
15+
pnpm install codama
16+
```
17+
18+
## Usage
19+
20+
TODO

packages/cli/package.json

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"name": "@codama/cli",
3+
"version": "1.0.0",
4+
"description": "The package that provides a CLI for the Codama standard",
5+
"exports": {
6+
"types": "./dist/types/index.d.ts",
7+
"node": {
8+
"import": "./dist/index.node.mjs",
9+
"require": "./dist/index.node.cjs"
10+
}
11+
},
12+
"main": "./dist/index.node.cjs",
13+
"module": "./dist/index.node.mjs",
14+
"types": "./dist/types/index.d.ts",
15+
"type": "commonjs",
16+
"files": [
17+
"./dist/types",
18+
"./dist/index.*"
19+
],
20+
"sideEffects": false,
21+
"keywords": [
22+
"codama",
23+
"standard",
24+
"cli"
25+
],
26+
"scripts": {
27+
"build": "rimraf dist && pnpm build:src && pnpm build:types",
28+
"build:src": "zx ../../node_modules/@codama/internals/scripts/build-src.mjs node",
29+
"build:types": "zx ../../node_modules/@codama/internals/scripts/build-types.mjs",
30+
"dev": "zx ../../node_modules/@codama/internals/scripts/test-unit.mjs node --watch",
31+
"lint": "zx ../../node_modules/@codama/internals/scripts/lint.mjs",
32+
"lint:fix": "zx ../../node_modules/@codama/internals/scripts/lint.mjs --fix",
33+
"test": "pnpm test:types && pnpm test:treeshakability && pnpm test:node",
34+
"test:node": "zx ../../node_modules/@codama/internals/scripts/test-unit.mjs node",
35+
"test:treeshakability": "zx ../../node_modules/@codama/internals/scripts/test-treeshakability.mjs",
36+
"test:types": "zx ../../node_modules/@codama/internals/scripts/test-types.mjs"
37+
},
38+
"dependencies": {
39+
"@codama/nodes": "workspace:*",
40+
"@codama/nodes-from-anchor": "workspace:*",
41+
"@codama/renderers": "workspace:*",
42+
"@codama/renderers-js": "workspace:*",
43+
"@codama/renderers-js-umi": "workspace:*",
44+
"@codama/renderers-rust": "workspace:*",
45+
"@codama/visitors-core": "workspace:*",
46+
"chalk": "^5.4.1",
47+
"commander": "^13.1.0",
48+
"prompts": "^2.4.2"
49+
},
50+
"devDependencies": {
51+
"@types/prompts": "^2.4.9"
52+
},
53+
"license": "MIT",
54+
"repository": {
55+
"type": "git",
56+
"url": "https://github.com/codama-idl/codama"
57+
},
58+
"bugs": {
59+
"url": "http://github.com/codama-idl/codama/issues"
60+
},
61+
"browserslist": [
62+
"supports bigint and not dead",
63+
"maintained node versions"
64+
]
65+
}

packages/cli/src/commands/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './init';
2+
export * from './run';

packages/cli/src/commands/init.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Command } from 'commander';
2+
import prompts, { PromptType } from 'prompts';
3+
4+
import { canRead, logBanner, logSuccess, resolveRelativePath, writeFile } from '../utils';
5+
6+
export function setInitCommand(program: Command): void {
7+
program
8+
.command('init')
9+
.argument('[output]', 'Optional path used to output the configuration file')
10+
.option('-d, --default', 'Bypass prompts and select all defaults options')
11+
.option('--js', 'Forces the output to be a JavaScript file')
12+
.action(doInit);
13+
}
14+
15+
type InitOptions = {
16+
default?: boolean;
17+
js?: boolean;
18+
};
19+
20+
async function doInit(explicitOutput: string | undefined, options: InitOptions) {
21+
const output = getOutputPath(explicitOutput, options);
22+
const useJsFile = options.js || output.endsWith('.js');
23+
if (await canRead(output)) {
24+
throw new Error(`Configuration file already exists at "${output}".`);
25+
}
26+
27+
logBanner();
28+
const result = await getPromptResult(options);
29+
const content = getContentFromPromptResult(result, useJsFile);
30+
await writeFile(output, content);
31+
logSuccess(`Configuration file created at "${output}".`);
32+
}
33+
34+
function getOutputPath(explicitOutput: string | undefined, options: Pick<InitOptions, 'js'>): string {
35+
if (explicitOutput) {
36+
return resolveRelativePath(explicitOutput);
37+
}
38+
return resolveRelativePath(options.js ? 'codama.js' : 'codama.json');
39+
}
40+
41+
type PromptResult = {
42+
idlPath: string;
43+
jsPath?: string;
44+
rustCrate?: string;
45+
rustPath?: string;
46+
scripts: string[];
47+
};
48+
49+
async function getPromptResult(options: Pick<InitOptions, 'default'>): Promise<PromptResult> {
50+
const defaults = getDefaultPromptResult();
51+
if (options.default) {
52+
return defaults;
53+
}
54+
55+
const hasScript =
56+
(script: string, type: PromptType = 'text') =>
57+
(_: unknown, values: { scripts: string[] }) =>
58+
values.scripts.includes(script) ? type : null;
59+
const result: PromptResult = await prompts(
60+
[
61+
{
62+
initial: defaults.idlPath,
63+
message: 'Where is your IDL located? (Supports Codama and Anchor IDLs).',
64+
name: 'idlPath',
65+
type: 'text',
66+
},
67+
{
68+
choices: [
69+
{ selected: true, title: 'Generate JavaScript client', value: 'js' },
70+
{ selected: true, title: 'Generate Rust client', value: 'rust' },
71+
],
72+
instructions: '[space] to toggle / [a] to toggle all / [enter] to submit',
73+
message: 'Which script preset would you like to use?',
74+
name: 'scripts',
75+
type: 'multiselect',
76+
},
77+
{
78+
initial: defaults.jsPath,
79+
message: '[js] Where should the JavaScript code be generated?',
80+
name: 'jsPath',
81+
type: hasScript('js'),
82+
},
83+
{
84+
initial: defaults.rustCrate,
85+
message: '[rust] Where is the Rust client crate located?',
86+
name: 'rustCrate',
87+
type: hasScript('rust'),
88+
},
89+
{
90+
initial: (prev: string) => `${prev}/src/generated`,
91+
message: '[rust] Where should the Rust code be generated?',
92+
name: 'rustPath',
93+
type: hasScript('rust'),
94+
},
95+
],
96+
{
97+
onCancel: () => {
98+
throw new Error('Operation cancelled.');
99+
},
100+
},
101+
);
102+
103+
return result;
104+
}
105+
106+
function getDefaultPromptResult(): PromptResult {
107+
return {
108+
idlPath: 'program/idl.json',
109+
jsPath: 'clients/js/src/generated',
110+
rustCrate: 'clients/rust',
111+
rustPath: 'clients/rust/src/generated',
112+
scripts: ['js', 'rust'],
113+
};
114+
}
115+
116+
function getContentFromPromptResult(result: PromptResult, useJsFile: boolean): string {
117+
const scripts: Record<string, unknown> = {};
118+
if (result.scripts.includes('js')) {
119+
scripts.js = {
120+
path: '@codama/renderers-js',
121+
// eslint-disable-next-line sort-keys-fix/sort-keys-fix
122+
args: [result.jsPath],
123+
};
124+
}
125+
if (result.scripts.includes('rust')) {
126+
scripts.rust = {
127+
path: '@codama/renderers-rust',
128+
// eslint-disable-next-line sort-keys-fix/sort-keys-fix
129+
args: [result.rustPath, { crateFolder: result.rustCrate, formatCode: true }],
130+
};
131+
}
132+
const content = {
133+
idl: result.idlPath,
134+
visitors: [],
135+
// eslint-disable-next-line sort-keys-fix/sort-keys-fix
136+
scripts,
137+
};
138+
139+
if (!useJsFile) {
140+
return JSON.stringify(content, null, 4);
141+
}
142+
143+
return (
144+
'export default ' +
145+
JSON.stringify(content, null, 4)
146+
// Remove quotes around property names
147+
.replace(/"([^"]+)":/g, '$1:')
148+
// Convert double-quoted strings to single quotes
149+
.replace(/"([^"]*)"/g, "'$1'")
150+
);
151+
}

packages/cli/src/commands/run.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { RootNode } from '@codama/nodes';
2+
import { visit, type Visitor } from '@codama/visitors-core';
3+
import { Command } from 'commander';
4+
5+
import { ScriptName } from '../config';
6+
import { getParsedConfigFromCommand, ParsedConfig } from '../parsedConfig';
7+
import { getRootNodeVisitors, logInfo, logSuccess, logWarning } from '../utils';
8+
9+
export function setRunCommand(program: Command): void {
10+
program
11+
.command('run')
12+
.argument('[scripts...]', 'The scripts to execute')
13+
.option('-a, --all', 'Run all scripts in the config file')
14+
.action(doRun);
15+
}
16+
17+
type RunOptions = {
18+
all?: boolean;
19+
};
20+
21+
async function doRun(explicitScripts: string[], { all }: RunOptions, cmd: Command) {
22+
if (all && explicitScripts.length > 0) {
23+
logWarning(`CLI arguments "${explicitScripts.join(' ')}" are ignored because the "--all" option is set.`);
24+
}
25+
const parsedConfig = await getParsedConfigFromCommand(cmd);
26+
const scripts = all ? Object.keys(parsedConfig.scripts) : explicitScripts;
27+
const plans = await getPlans(parsedConfig, scripts);
28+
logInfo({ parsedConfig, plans });
29+
runPlans(plans, parsedConfig.rootNode);
30+
}
31+
32+
type RunPlan = {
33+
script: ScriptName | null;
34+
visitors: Visitor<RootNode, 'rootNode'>[];
35+
};
36+
37+
async function getPlans(
38+
parsedConfig: Pick<ParsedConfig, 'configPath' | 'scripts' | 'visitors'>,
39+
scripts: ScriptName[],
40+
): Promise<RunPlan[]> {
41+
const plans: RunPlan[] = [];
42+
if (scripts.length === 0 && parsedConfig.visitors.length === 0) {
43+
throw new Error('There are no scripts or visitors to run.');
44+
}
45+
46+
const missingScripts = scripts.filter(script => !parsedConfig.scripts[script]);
47+
if (missingScripts.length > 0) {
48+
const scriptPluralized = missingScripts.length === 1 ? 'Script' : 'Scripts';
49+
const missingScriptsIdentifier = `${scriptPluralized} "${missingScripts.join(', ')}"`;
50+
const message = parsedConfig.configPath
51+
? `${missingScriptsIdentifier} not found in config file "${parsedConfig.configPath}"`
52+
: `${missingScriptsIdentifier} not found because no config file was found`;
53+
throw new Error(message);
54+
}
55+
56+
if (parsedConfig.visitors.length > 0) {
57+
plans.push({ script: null, visitors: await getRootNodeVisitors(parsedConfig.visitors) });
58+
}
59+
60+
for (const script of scripts) {
61+
plans.push({ script, visitors: await getRootNodeVisitors(parsedConfig.scripts[script]) });
62+
}
63+
64+
return plans;
65+
}
66+
67+
function runPlans(plans: RunPlan[], rootNode: RootNode): void {
68+
for (const plan of plans) {
69+
const result = runPlan(plan, rootNode);
70+
if (!plan.script) {
71+
rootNode = result;
72+
}
73+
}
74+
}
75+
76+
function runPlan(plan: RunPlan, rootNode: RootNode): RootNode {
77+
const visitorLength = plan.visitors.length;
78+
const visitorPluralized = visitorLength === 1 ? 'visitor' : 'visitors';
79+
const identifier = plan.script
80+
? `script "${plan.script}" with ${visitorLength} ${visitorPluralized}`
81+
: `${visitorLength} global ${visitorPluralized}`;
82+
logInfo(`Running ${identifier}...`);
83+
const newRoot = plan.visitors.reduce(visit, rootNode);
84+
logSuccess(`Executed ${identifier}!`);
85+
return newRoot;
86+
}

0 commit comments

Comments
 (0)