Browse Source

Add Codama CLI (#425)

This PR adds a brand new `@codama/cli` package which is then imported (**not** re-exported) by the main `codama` library in order to use the `codama` keyword as CLI binary.

The CLI currently supports the following two commands:
- `codama init`: which initialises a Codama configuration file by using prompts and selected script presets.
- `codama run`: which runs any script defined in your configuration file.

You can learn more about the CLI commands and the Codama config file from the `@codama/cli` README copy/pasted below:

---


This package provides a CLI for the Codama library that can be used to run scripts on Codama IDLs.

Note that, whilst the CLI code is located in the `@codama/cli` package, the CLI binary is directly provided by the main `codama` library.

## Getting started

To get started with Codama, simply install `codama` to your project and run the `init` command like so:

```sh
pnpm install codama
codama init
```

You will be prompted for the path of your IDL and asked to select any script presets you would like to use.

## `codama run`

Once you have your codama config file, you can run your Codama scripts using the `codama run` command as follows:

```sh
codama run         # Only runs your before visitors.
codama run js rust # Runs your before visitors followed by the `js` and `rust` scripts.
codama run --all   # Runs your before visitors followed by all your scripts.
```

## The configuration file

The codama config 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.
- `before` (array): An array of visitors that will run before every script.
- `scripts` (object): An object defining the available Codama scripts. The keys identify the scripts and the values are arrays of visitors that make up the script.

Whether it is in the `before` array or in the `scripts` values, when defining a visitor you may either provide:

- an object with the following fields:
    - `from` (string): The import path to the visitor.
    - `args` (array): An array of arguments to pass to the visitor.
- a string: The import path to the visitor. This is equivalent to providing an object with a `from` field and an empty `args` array.

Visitor import paths can either be local paths (pointing to JavaScript files exporting visitors) or npm package names. By default, the `default` export will be used but you may specify a nammed export by appending a `#` followed by the export name. When resolved, the imported element inside the module should either be a `Visitor<any, 'rootNode'>` or a function that returns a `Visitor<any, 'rootNode'>` given the arguments provided. Here are some examples of valid visitor import paths:

```js
'./my-visitor.js'; // Relative local path to a visitor module.
'/Users/me/my-visitor.js'; // Absolute local path to a visitor module.
'some-library'; // npm package name.
'@acme/some-library'; // Scoped npm package name.
'./my-visitor.js#myExport'; // Named export from a local path.
'@acme/some-library#myExport'; // Named export from an npm package.
```

Here is an example of what a Codama configuration file might look like:

```json
{
    "idl": "path/to/idl",
    "before": [
        "./my-before-visitor.js",
        { "from": "some-library#removeTypes", "args": [["internalFoo", "internalBar"]] }
    ],
    "scripts": {
        "js": [
            {
                "from": "@codama/renderers-js",
                "args": ["clients/js/src/generated"]
            }
        ]
    }
}
```

Note that you can use the `--js` flag to generate a `.js` configuration file when running the `init` command.
Loris Leiva 9 months ago
parent
commit
7bb6920d58
42 changed files with 990 additions and 9 deletions
  1. 8 0
      .changeset/little-spiders-exercise.md
  2. 6 0
      .changeset/poor-crabs-act.md
  3. 1 1
      eslint.config.mjs
  4. 1 0
      packages/cli/.gitignore
  5. 5 0
      packages/cli/.prettierignore
  6. 22 0
      packages/cli/LICENSE
  7. 81 0
      packages/cli/README.md
  8. 66 0
      packages/cli/package.json
  9. 2 0
      packages/cli/src/commands/index.ts
  10. 145 0
      packages/cli/src/commands/init.ts
  11. 85 0
      packages/cli/src/commands/run.ts
  12. 48 0
      packages/cli/src/config.ts
  13. 2 0
      packages/cli/src/index.ts
  14. 94 0
      packages/cli/src/parsedConfig.ts
  15. 36 0
      packages/cli/src/program.ts
  16. 14 0
      packages/cli/src/programOptions.ts
  17. 6 0
      packages/cli/src/types/global.d.ts
  18. 42 0
      packages/cli/src/utils/fs.ts
  19. 71 0
      packages/cli/src/utils/import.ts
  20. 6 0
      packages/cli/src/utils/index.ts
  21. 27 0
      packages/cli/src/utils/logs.ts
  22. 21 0
      packages/cli/src/utils/nodes.ts
  23. 3 0
      packages/cli/src/utils/promises.ts
  24. 55 0
      packages/cli/src/utils/visitors.ts
  25. 7 0
      packages/cli/test/index.test.ts
  26. 10 0
      packages/cli/tsconfig.declarations.json
  27. 9 0
      packages/cli/tsconfig.json
  28. 7 0
      packages/library/bin/cli.mjs
  29. 6 1
      packages/library/package.json
  30. 15 0
      packages/library/src/cli/index.ts
  31. 1 1
      packages/library/src/codama.ts
  32. 4 1
      packages/library/tsconfig.json
  33. 1 1
      packages/renderers-js-umi/e2e/system/idl.json
  34. 2 0
      packages/renderers-js-umi/src/index.ts
  35. 1 1
      packages/renderers-js/e2e/system/idl.json
  36. 1 1
      packages/renderers-js/e2e/token/idl.json
  37. 2 0
      packages/renderers-js/src/index.ts
  38. 1 1
      packages/renderers-rust/e2e/system/idl.json
  39. 2 0
      packages/renderers-rust/src/index.ts
  40. 1 1
      packages/renderers-vixen-parser/e2e/system_parser/idl.json
  41. 2 0
      packages/renderers-vixen-parser/src/index.ts
  42. 71 0
      pnpm-lock.yaml

+ 8 - 0
.changeset/little-spiders-exercise.md

@@ -0,0 +1,8 @@
+---
+'@codama/renderers-vixen-parser': patch
+'@codama/renderers-js-umi': patch
+'@codama/renderers-rust': patch
+'@codama/renderers-js': patch
+---
+
+Export `renderVisitor` function of all renderers packages as `default` export.

+ 6 - 0
.changeset/poor-crabs-act.md

@@ -0,0 +1,6 @@
+---
+'codama': patch
+'@codama/cli': patch
+---
+
+Add new Codama CLI

+ 1 - 1
eslint.config.mjs

@@ -8,7 +8,7 @@ export default tseslint.config([
         extends: [solanaConfig],
     },
     {
-        files: ['packages/nodes/**', 'packages/node-types/**'],
+        files: ['packages/cli/**', 'packages/nodes/**', 'packages/node-types/**'],
         rules: {
             'sort-keys-fix/sort-keys-fix': 'off',
             'typescript-sort-keys/interface': 'off',

+ 1 - 0
packages/cli/.gitignore

@@ -0,0 +1 @@
+dist/

+ 5 - 0
packages/cli/.prettierignore

@@ -0,0 +1,5 @@
+dist/
+e2e/
+test-ledger/
+target/
+CHANGELOG.md

+ 22 - 0
packages/cli/LICENSE

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

+ 81 - 0
packages/cli/README.md

@@ -0,0 +1,81 @@
+# Codama ➤ CLI
+
+[![npm][npm-image]][npm-url]
+[![npm-downloads][npm-downloads-image]][npm-url]
+
+[npm-downloads-image]: https://img.shields.io/npm/dm/@codama/cli.svg?style=flat
+[npm-image]: https://img.shields.io/npm/v/@codama/cli.svg?style=flat&label=%40codama%2Fcli
+[npm-url]: https://www.npmjs.com/package/@codama/cli
+
+This package provides a CLI for the Codama library that can be used to run scripts on Codama IDLs.
+
+Note that, whilst the CLI code is located in the `@codama/cli` package, the CLI binary is directly provided by the main `codama` library.
+
+## Getting started
+
+To get started with Codama, simply install `codama` to your project and run the `init` command like so:
+
+```sh
+pnpm install codama
+codama init
+```
+
+You will be prompted for the path of your IDL and asked to select any script presets you would like to use.
+
+## `codama run`
+
+Once you have your codama config file, you can run your Codama scripts using the `codama run` command as follows:
+
+```sh
+codama run         # Only runs your before visitors.
+codama run js rust # Runs your before visitors followed by the `js` and `rust` scripts.
+codama run --all   # Runs your before visitors followed by all your scripts.
+```
+
+## The configuration file
+
+The codama config 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.
+- `before` (array): An array of visitors that will run before every script.
+- `scripts` (object): An object defining the available Codama scripts. The keys identify the scripts and the values are arrays of visitors that make up the script.
+
+Whether it is in the `before` array or in the `scripts` values, when defining a visitor you may either provide:
+
+- an object with the following fields:
+    - `from` (string): The import path to the visitor.
+    - `args` (array): An array of arguments to pass to the visitor.
+- a string: The import path to the visitor. This is equivalent to providing an object with a `from` field and an empty `args` array.
+
+Visitor import paths can either be local paths (pointing to JavaScript files exporting visitors) or npm package names. By default, the `default` export will be used but you may specify a named export by appending a `#` followed by the export name. When resolved, the imported element inside the module should either be a `Visitor<any, 'rootNode'>` or a function that returns a `Visitor<any, 'rootNode'>` given the arguments provided. Here are some examples of valid visitor import paths:
+
+```js
+'./my-visitor.js'; // Relative local path to a visitor module.
+'/Users/me/my-visitor.js'; // Absolute local path to a visitor module.
+'some-library'; // npm package name.
+'@acme/some-library'; // Scoped npm package name.
+'./my-visitor.js#myExport'; // Named export from a local path.
+'@acme/some-library#myExport'; // Named export from an npm package.
+```
+
+Here is an example of what a Codama configuration file might look like:
+
+```json
+{
+    "idl": "path/to/idl",
+    "before": [
+        "./my-before-visitor.js",
+        { "from": "some-library#removeTypes", "args": [["internalFoo", "internalBar"]] }
+    ],
+    "scripts": {
+        "js": [
+            {
+                "from": "@codama/renderers-js",
+                "args": ["clients/js/src/generated"]
+            }
+        ]
+    }
+}
+```
+
+Note that you can use the `--js` flag to generate a `.js` configuration file when running the `init` command.

+ 66 - 0
packages/cli/package.json

@@ -0,0 +1,66 @@
+{
+    "name": "@codama/cli",
+    "version": "1.0.0",
+    "description": "The package that provides a CLI for the Codama standard",
+    "exports": {
+        "types": "./dist/types/index.d.ts",
+        "node": {
+            "import": "./dist/index.node.mjs",
+            "require": "./dist/index.node.cjs"
+        }
+    },
+    "main": "./dist/index.node.cjs",
+    "module": "./dist/index.node.mjs",
+    "types": "./dist/types/index.d.ts",
+    "type": "commonjs",
+    "files": [
+        "./dist/types",
+        "./dist/index.*"
+    ],
+    "sideEffects": false,
+    "keywords": [
+        "codama",
+        "standard",
+        "cli"
+    ],
+    "scripts": {
+        "build": "rimraf dist && pnpm build:src && pnpm build:types",
+        "build:src": "zx ../../node_modules/@codama/internals/scripts/build-src.mjs node",
+        "build:types": "zx ../../node_modules/@codama/internals/scripts/build-types.mjs",
+        "dev": "zx ../../node_modules/@codama/internals/scripts/test-unit.mjs node --watch",
+        "lint": "zx ../../node_modules/@codama/internals/scripts/lint.mjs",
+        "lint:fix": "zx ../../node_modules/@codama/internals/scripts/lint.mjs --fix",
+        "test": "pnpm test:types && pnpm test:treeshakability && pnpm test:node",
+        "test:node": "zx ../../node_modules/@codama/internals/scripts/test-unit.mjs node",
+        "test:treeshakability": "zx ../../node_modules/@codama/internals/scripts/test-treeshakability.mjs",
+        "test:types": "zx ../../node_modules/@codama/internals/scripts/test-types.mjs"
+    },
+    "dependencies": {
+        "@codama/nodes": "workspace:*",
+        "@codama/nodes-from-anchor": "workspace:*",
+        "@codama/renderers": "workspace:*",
+        "@codama/renderers-js": "workspace:*",
+        "@codama/renderers-js-umi": "workspace:*",
+        "@codama/renderers-rust": "workspace:*",
+        "@codama/visitors": "workspace:*",
+        "@codama/visitors-core": "workspace:*",
+        "chalk": "^5.4.1",
+        "commander": "^13.1.0",
+        "prompts": "^2.4.2"
+    },
+    "devDependencies": {
+        "@types/prompts": "^2.4.9"
+    },
+    "license": "MIT",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/codama-idl/codama"
+    },
+    "bugs": {
+        "url": "http://github.com/codama-idl/codama/issues"
+    },
+    "browserslist": [
+        "supports bigint and not dead",
+        "maintained node versions"
+    ]
+}

+ 2 - 0
packages/cli/src/commands/index.ts

@@ -0,0 +1,2 @@
+export * from './init';
+export * from './run';

+ 145 - 0
packages/cli/src/commands/init.ts

@@ -0,0 +1,145 @@
+import { Command } from 'commander';
+import prompts, { PromptType } from 'prompts';
+
+import { Config, ScriptConfig, ScriptName } from '../config';
+import { canRead, logBanner, logSuccess, resolveRelativePath, writeFile } from '../utils';
+
+export function setInitCommand(program: Command): void {
+    program
+        .command('init')
+        .argument('[output]', 'Optional path used to output the configuration file')
+        .option('-d, --default', 'Bypass prompts and select all defaults options')
+        .option('--js', 'Forces the output to be a JavaScript file')
+        .action(doInit);
+}
+
+type InitOptions = {
+    default?: boolean;
+    js?: boolean;
+};
+
+async function doInit(explicitOutput: string | undefined, options: InitOptions) {
+    const output = getOutputPath(explicitOutput, options);
+    const useJsFile = options.js || output.endsWith('.js');
+    if (await canRead(output)) {
+        throw new Error(`Configuration file already exists at "${output}".`);
+    }
+
+    logBanner();
+    const result = await getPromptResult(options);
+    const content = getContentFromPromptResult(result, useJsFile);
+    await writeFile(output, content);
+    logSuccess(`Configuration file created at "${output}".`);
+}
+
+function getOutputPath(explicitOutput: string | undefined, options: Pick<InitOptions, 'js'>): string {
+    if (explicitOutput) {
+        return resolveRelativePath(explicitOutput);
+    }
+    return resolveRelativePath(options.js ? 'codama.js' : 'codama.json');
+}
+
+type PromptResult = {
+    idlPath: string;
+    jsPath?: string;
+    rustCrate?: string;
+    rustPath?: string;
+    scripts: string[];
+};
+
+async function getPromptResult(options: Pick<InitOptions, 'default'>): Promise<PromptResult> {
+    const defaults = getDefaultPromptResult();
+    if (options.default) {
+        return defaults;
+    }
+
+    const hasScript =
+        (script: string, type: PromptType = 'text') =>
+        (_: unknown, values: { scripts: string[] }) =>
+            values.scripts.includes(script) ? type : null;
+    const result: PromptResult = await prompts(
+        [
+            {
+                initial: defaults.idlPath,
+                message: 'Where is your IDL located? (Supports Codama and Anchor IDLs).',
+                name: 'idlPath',
+                type: 'text',
+            },
+            {
+                choices: [
+                    { selected: true, title: 'Generate JavaScript client', value: 'js' },
+                    { selected: true, title: 'Generate Rust client', value: 'rust' },
+                ],
+                instructions: '[space] to toggle / [a] to toggle all / [enter] to submit',
+                message: 'Which script preset would you like to use?',
+                name: 'scripts',
+                type: 'multiselect',
+            },
+            {
+                initial: defaults.jsPath,
+                message: '[js] Where should the JavaScript code be generated?',
+                name: 'jsPath',
+                type: hasScript('js'),
+            },
+            {
+                initial: defaults.rustCrate,
+                message: '[rust] Where is the Rust client crate located?',
+                name: 'rustCrate',
+                type: hasScript('rust'),
+            },
+            {
+                initial: (prev: string) => `${prev}/src/generated`,
+                message: '[rust] Where should the Rust code be generated?',
+                name: 'rustPath',
+                type: hasScript('rust'),
+            },
+        ],
+        {
+            onCancel: () => {
+                throw new Error('Operation cancelled.');
+            },
+        },
+    );
+
+    return result;
+}
+
+function getDefaultPromptResult(): PromptResult {
+    return {
+        idlPath: 'program/idl.json',
+        jsPath: 'clients/js/src/generated',
+        rustCrate: 'clients/rust',
+        rustPath: 'clients/rust/src/generated',
+        scripts: ['js', 'rust'],
+    };
+}
+
+function getContentFromPromptResult(result: PromptResult, useJsFile: boolean): string {
+    const scripts: Record<ScriptName, ScriptConfig> = {};
+    if (result.scripts.includes('js')) {
+        scripts.js = {
+            from: '@codama/renderers-js',
+            args: [result.jsPath],
+        };
+    }
+    if (result.scripts.includes('rust')) {
+        scripts.rust = {
+            from: '@codama/renderers-rust',
+            args: [result.rustPath, { crateFolder: result.rustCrate, formatCode: true }],
+        };
+    }
+    const content: Config = { idl: result.idlPath, before: [], scripts };
+
+    if (!useJsFile) {
+        return JSON.stringify(content, null, 4);
+    }
+
+    return (
+        'export default ' +
+        JSON.stringify(content, null, 4)
+            // Remove quotes around property names
+            .replace(/"([^"]+)":/g, '$1:')
+            // Convert double-quoted strings to single quotes
+            .replace(/"([^"]*)"/g, "'$1'")
+    );
+}

+ 85 - 0
packages/cli/src/commands/run.ts

@@ -0,0 +1,85 @@
+import type { RootNode } from '@codama/nodes';
+import { visit, type Visitor } from '@codama/visitors-core';
+import { Command } from 'commander';
+
+import { ScriptName } from '../config';
+import { getParsedConfigFromCommand, ParsedConfig } from '../parsedConfig';
+import { getRootNodeVisitors, logInfo, logSuccess, logWarning } from '../utils';
+
+export function setRunCommand(program: Command): void {
+    program
+        .command('run')
+        .argument('[scripts...]', 'The scripts to execute')
+        .option('-a, --all', 'Run all scripts in the config file')
+        .action(doRun);
+}
+
+type RunOptions = {
+    all?: boolean;
+};
+
+async function doRun(explicitScripts: string[], { all }: RunOptions, cmd: Command) {
+    if (all && explicitScripts.length > 0) {
+        logWarning(`CLI arguments "${explicitScripts.join(' ')}" are ignored because the "--all" option is set.`);
+    }
+    const parsedConfig = await getParsedConfigFromCommand(cmd);
+    const scripts = all ? Object.keys(parsedConfig.scripts) : explicitScripts;
+    const plans = await getPlans(parsedConfig, scripts);
+    runPlans(plans, parsedConfig.rootNode);
+}
+
+type RunPlan = {
+    script: ScriptName | null;
+    visitors: Visitor<RootNode, 'rootNode'>[];
+};
+
+async function getPlans(
+    parsedConfig: Pick<ParsedConfig, 'before' | 'configPath' | 'scripts'>,
+    scripts: ScriptName[],
+): Promise<RunPlan[]> {
+    const plans: RunPlan[] = [];
+    if (scripts.length === 0 && parsedConfig.before.length === 0) {
+        throw new Error('There are no scripts or before visitors to run.');
+    }
+
+    const missingScripts = scripts.filter(script => !parsedConfig.scripts[script]);
+    if (missingScripts.length > 0) {
+        const scriptPluralized = missingScripts.length === 1 ? 'Script' : 'Scripts';
+        const missingScriptsIdentifier = `${scriptPluralized} "${missingScripts.join(', ')}"`;
+        const message = parsedConfig.configPath
+            ? `${missingScriptsIdentifier} not found in config file "${parsedConfig.configPath}"`
+            : `${missingScriptsIdentifier} not found because no config file was found`;
+        throw new Error(message);
+    }
+
+    if (parsedConfig.before.length > 0) {
+        plans.push({ script: null, visitors: await getRootNodeVisitors(parsedConfig.before) });
+    }
+
+    for (const script of scripts) {
+        plans.push({ script, visitors: await getRootNodeVisitors(parsedConfig.scripts[script]) });
+    }
+
+    return plans;
+}
+
+function runPlans(plans: RunPlan[], rootNode: RootNode): void {
+    for (const plan of plans) {
+        const result = runPlan(plan, rootNode);
+        if (!plan.script) {
+            rootNode = result;
+        }
+    }
+}
+
+function runPlan(plan: RunPlan, rootNode: RootNode): RootNode {
+    const visitorLength = plan.visitors.length;
+    const visitorPluralized = visitorLength === 1 ? 'visitor' : 'visitors';
+    const identifier = plan.script
+        ? `script "${plan.script}" with ${visitorLength} ${visitorPluralized}`
+        : `${visitorLength} before ${visitorPluralized}`;
+    logInfo(`Running ${identifier}...`);
+    const newRoot = plan.visitors.reduce(visit, rootNode);
+    logSuccess(`Executed ${identifier}!`);
+    return newRoot;
+}

+ 48 - 0
packages/cli/src/config.ts

@@ -0,0 +1,48 @@
+import path from 'node:path';
+
+import { ProgramOptions } from './programOptions';
+import { canRead, importModuleItem, logWarning } from './utils';
+
+export type Config = Readonly<{
+    idl?: string;
+    scripts?: ScriptsConfig;
+    before?: readonly VisitorConfig[];
+}>;
+
+export type ScriptName = string;
+export type ScriptConfig = VisitorConfig | readonly VisitorConfig[];
+export type ScriptsConfig = Readonly<Record<ScriptName, ScriptConfig>>;
+
+export type VisitorPath = string;
+export type VisitorConfig<T extends readonly unknown[] = readonly unknown[]> = VisitorConfigObject<T> | VisitorPath;
+export type VisitorConfigObject<T extends readonly unknown[] = readonly unknown[]> = Readonly<{
+    args?: T;
+    from: VisitorPath;
+}>;
+
+export async function getConfig(options: Pick<ProgramOptions, 'config'>): Promise<[Config, string | null]> {
+    const configPath = options.config != null ? path.resolve(options.config) : await getDefaultConfigPath();
+
+    if (!configPath) {
+        logWarning('No config file found. Using empty configs. Make sure you provide the `--idl` option.');
+        return [{}, configPath];
+    }
+
+    const configFile = await importModuleItem('config file', configPath);
+    if (!configFile || typeof configFile !== 'object') {
+        throw new Error(`Invalid config file at "${configPath}"`);
+    }
+
+    return [configFile, configPath];
+}
+
+async function getDefaultConfigPath(): Promise<string | null> {
+    const candidatePaths = ['codama.js', 'codama.mjs', 'codama.cjs', 'codama.json'];
+    for (const candidatePath of candidatePaths) {
+        const resolvedPath = path.resolve(process.cwd(), candidatePath);
+        if (await canRead(resolvedPath)) {
+            return resolvedPath;
+        }
+    }
+    return null;
+}

+ 2 - 0
packages/cli/src/index.ts

@@ -0,0 +1,2 @@
+export * from './program';
+export * from './utils/logs';

+ 94 - 0
packages/cli/src/parsedConfig.ts

@@ -0,0 +1,94 @@
+import type { RootNode } from '@codama/nodes';
+import { Command } from 'commander';
+
+import { Config, getConfig, ScriptName, ScriptsConfig, VisitorConfig, VisitorPath } from './config';
+import { ProgramOptions } from './programOptions';
+import {
+    getRootNodeFromIdl,
+    importModuleItem,
+    isLocalModulePath,
+    resolveConfigPath,
+    resolveRelativePath,
+} from './utils';
+
+export type ParsedConfig = Readonly<{
+    configPath: string | null;
+    idlContent: unknown;
+    idlPath: string;
+    rootNode: RootNode;
+    scripts: ParsedScriptsConfig;
+    before: readonly ParsedVisitorConfig[];
+}>;
+
+export type ParsedScriptsConfig = Readonly<Record<ScriptName, readonly ParsedVisitorConfig[]>>;
+export type ParsedVisitorConfig<T extends readonly unknown[] = readonly unknown[]> = Readonly<{
+    args: T;
+    index: number;
+    item: string | undefined;
+    path: VisitorPath;
+    script: ScriptName | null;
+}>;
+
+export async function getParsedConfigFromCommand(cmd: Command): Promise<ParsedConfig> {
+    return await getParsedConfig(cmd.optsWithGlobals() as ProgramOptions);
+}
+
+export async function getParsedConfig(options: Pick<ProgramOptions, 'config' | 'idl'>): Promise<ParsedConfig> {
+    const [config, configPath] = await getConfig(options);
+    return await parseConfig(config, configPath, options);
+}
+
+async function parseConfig(
+    config: Config,
+    configPath: string | null,
+    options: Pick<ProgramOptions, 'idl'>,
+): Promise<ParsedConfig> {
+    const idlPath = parseIdlPath(config, configPath, options);
+    const idlContent = await importModuleItem('IDL', idlPath);
+    const rootNode = getRootNodeFromIdl(idlContent);
+    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 };
+}
+
+function parseIdlPath(
+    config: Pick<Config, 'idl'>,
+    configPath: string | null,
+    options: Pick<ProgramOptions, 'idl'>,
+): string {
+    if (options.idl) {
+        return resolveRelativePath(options.idl);
+    }
+    if (config.idl) {
+        return resolveConfigPath(config.idl, configPath);
+    }
+    throw new Error('No IDL identified. Please provide the `--idl` option or set it in the config file.');
+}
+
+function parseScripts(scripts: ScriptsConfig, configPath: string | null): ParsedScriptsConfig {
+    const entryPromises = Object.entries(scripts).map(([name, scriptConfig]) => {
+        const visitors: readonly VisitorConfig[] = Array.isArray(scriptConfig) ? scriptConfig : [scriptConfig];
+        return [name, visitors.map((v, i) => parseVisitorConfig(v, configPath, i, name))] as const;
+    });
+    return Object.fromEntries(entryPromises);
+}
+
+function parseVisitorConfig<T extends readonly unknown[]>(
+    visitorConfig: VisitorConfig<T>,
+    configPath: string | null,
+    index: number,
+    script: ScriptName | null,
+): ParsedVisitorConfig<T> {
+    const emptyArgs = [] as readonly unknown[] as T;
+    const visitorPath = typeof visitorConfig === 'string' ? visitorConfig : visitorConfig.from;
+    const visitorArgs = typeof visitorConfig === 'string' ? emptyArgs : (visitorConfig.args ?? emptyArgs);
+    const [path, item] = resolveVisitorPath(visitorPath, configPath);
+    return { args: visitorArgs, index, item, path, script };
+}
+
+function resolveVisitorPath(visitorPath: string, configPath: string | null): readonly [string, string | undefined] {
+    const [modulePath, itemName] = visitorPath.split('#') as [string, string | undefined];
+    const resolveModulePath = isLocalModulePath(modulePath) ? resolveConfigPath(modulePath, configPath) : modulePath;
+    return [resolveModulePath, itemName];
+}

+ 36 - 0
packages/cli/src/program.ts

@@ -0,0 +1,36 @@
+import { Command, createCommand } from 'commander';
+
+import { setInitCommand, setRunCommand } from './commands';
+import { setProgramOptions } from './programOptions';
+
+export async function codama(args: string[], opts?: { suppressOutput?: boolean }): Promise<void> {
+    await createProgram({
+        exitOverride: true,
+        suppressOutput: opts?.suppressOutput,
+    }).parseAsync(args, { from: 'user' });
+}
+
+export function createProgram(internalOptions?: { exitOverride?: boolean; suppressOutput?: boolean }): Command {
+    const program = createCommand()
+        .version(__VERSION__)
+        .allowExcessArguments(false)
+        .configureHelp({ showGlobalOptions: true, sortOptions: true, sortSubcommands: true });
+
+    // Set program options and commands.
+    setProgramOptions(program);
+    setInitCommand(program);
+    setRunCommand(program);
+
+    // Internal options.
+    if (internalOptions?.exitOverride) {
+        program.exitOverride();
+    }
+    if (internalOptions?.suppressOutput) {
+        program.configureOutput({
+            writeErr: () => {},
+            writeOut: () => {},
+        });
+    }
+
+    return program;
+}

+ 14 - 0
packages/cli/src/programOptions.ts

@@ -0,0 +1,14 @@
+import { Command } from 'commander';
+
+export type ProgramOptions = Readonly<{
+    config?: string;
+    debug?: boolean;
+    idl?: string;
+}>;
+
+export function setProgramOptions(program: Command): void {
+    program
+        .option('--debug', 'include debugging information, such as stack dump')
+        .option('-i, --idl <path>', 'The path to the IDL to use.')
+        .option('-c, --config <path>', 'The path to the Codama configuration file. Defaults to `codama.(js|json)`.');
+}

+ 6 - 0
packages/cli/src/types/global.d.ts

@@ -0,0 +1,6 @@
+declare const __BROWSER__: boolean;
+declare const __ESM__: boolean;
+declare const __NODEJS__: boolean;
+declare const __REACTNATIVE__: boolean;
+declare const __TEST__: boolean;
+declare const __VERSION__: string;

+ 42 - 0
packages/cli/src/utils/fs.ts

@@ -0,0 +1,42 @@
+import { R_OK, W_OK } from 'node:constants';
+import fs, { PathLike } from 'node:fs';
+import path from 'node:path';
+
+export function resolveRelativePath(childPath: string, relativeDirectory: string | null = null) {
+    return path.resolve(relativeDirectory ?? process.cwd(), childPath);
+}
+
+export function resolveConfigPath(childPath: string, configPath: string | null) {
+    const configDir = configPath ? path.dirname(configPath) : null;
+    return resolveRelativePath(childPath, configDir);
+}
+
+export function isLocalModulePath(modulePath: string) {
+    return modulePath.startsWith('.') || modulePath.startsWith('/');
+}
+
+export async function writeFile(filePath: string, content: string) {
+    const directory = path.dirname(filePath);
+    if (!(await canWrite(directory))) {
+        await fs.promises.mkdir(directory, { recursive: true });
+    }
+    await fs.promises.writeFile(filePath, content);
+}
+
+export async function canRead(p: PathLike) {
+    try {
+        await fs.promises.access(p, R_OK);
+        return true;
+    } catch {
+        return false;
+    }
+}
+
+export async function canWrite(p: PathLike) {
+    try {
+        await fs.promises.access(p, W_OK);
+        return true;
+    } catch {
+        return false;
+    }
+}

+ 71 - 0
packages/cli/src/utils/import.ts

@@ -0,0 +1,71 @@
+import { createRequire } from 'node:module';
+
+import { canRead, isLocalModulePath, resolveRelativePath } from './fs';
+
+export async function importModuleItem<T = unknown>(
+    identifier: string,
+    modulePath: string,
+    itemName: string = 'default',
+): Promise<T> {
+    const module = await importModule(identifier, modulePath);
+    const item = pickModuleItem(module, itemName) as T | undefined;
+    if (item === undefined) {
+        throw new Error(`Failed to import "${itemName}" from ${identifier} at "${modulePath}".`);
+    }
+    return item;
+}
+
+type ModuleDefinition = Partial<Record<string, unknown>> & {
+    __esModule?: boolean;
+    default?: Partial<Record<string, unknown>> & { default?: Partial<Record<string, unknown>> };
+};
+
+function pickModuleItem(module: ModuleDefinition, itemName: string): unknown {
+    if (itemName === 'default') {
+        return module.default?.default ?? module.default ?? module;
+    }
+    return module[itemName] ?? module.default?.[itemName] ?? module.default?.default?.[itemName];
+}
+
+async function importModule<T extends object>(identifier: string, modulePath: string): Promise<T> {
+    if (isLocalModulePath(modulePath)) {
+        return await importLocalModule(identifier, modulePath);
+    }
+
+    try {
+        return await importExternalUserModule(identifier, modulePath);
+    } catch {
+        return await importExternalModule(identifier, modulePath);
+    }
+}
+
+async function importLocalModule<T extends object>(identifier: string, modulePath: string): Promise<T> {
+    if (!(await canRead(modulePath))) {
+        throw new Error(`Cannot access ${identifier} at "${modulePath}"`);
+    }
+
+    const dotIndex = modulePath.lastIndexOf('.');
+    const extension = dotIndex === -1 ? undefined : modulePath.slice(dotIndex);
+    const modulePromise = extension === '.json' ? import(modulePath, { with: { type: 'json' } }) : import(modulePath);
+
+    try {
+        return (await modulePromise) as unknown as T;
+    } catch (error) {
+        throw new Error(`Failed to import ${identifier} at "${modulePath}" as a local module`, { cause: error });
+    }
+}
+
+async function importExternalModule<T extends object>(identifier: string, modulePath: string): Promise<T> {
+    try {
+        return (await import(modulePath)) as unknown as T;
+    } catch (error) {
+        throw new Error(`Failed to import ${identifier} at "${modulePath}" as a module`, { cause: error });
+    }
+}
+
+async function importExternalUserModule<T extends object>(identifier: string, modulePath: string): Promise<T> {
+    const userPackageJsonPath = resolveRelativePath('package.json');
+    const userRequire = createRequire(userPackageJsonPath);
+    const userModulePath = userRequire.resolve(modulePath);
+    return await importExternalModule<T>(identifier, userModulePath);
+}

+ 6 - 0
packages/cli/src/utils/index.ts

@@ -0,0 +1,6 @@
+export * from './fs';
+export * from './import';
+export * from './logs';
+export * from './nodes';
+export * from './promises';
+export * from './visitors';

+ 27 - 0
packages/cli/src/utils/logs.ts

@@ -0,0 +1,27 @@
+import chalk from 'chalk';
+
+export function logSuccess(...args: unknown[]): void {
+    console.log(chalk.green('[Success]'), ...args);
+}
+
+export function logInfo(...args: unknown[]): void {
+    console.log(chalk.blueBright('[Info]'), ...args);
+}
+
+export function logWarning(...args: unknown[]): void {
+    console.log(chalk.yellow('[Warning]'), ...args);
+}
+
+export function logError(...args: unknown[]): void {
+    console.log(chalk.red('[Error]'), ...args);
+}
+
+export function logBanner(): void {
+    console.log(getBanner());
+}
+
+function getBanner(): string {
+    const textBanner = 'Welcome to Codama!';
+    const gradientBanner = chalk.bold(chalk.hex('#e7ab61')(textBanner));
+    return process.stdout.isTTY && process.stdout.getColorDepth() > 8 ? gradientBanner : textBanner;
+}

+ 21 - 0
packages/cli/src/utils/nodes.ts

@@ -0,0 +1,21 @@
+import type { RootNode } from '@codama/nodes';
+import { type AnchorIdl, rootNodeFromAnchor } from '@codama/nodes-from-anchor';
+
+export function getRootNodeFromIdl(idl: unknown): RootNode {
+    if (typeof idl !== 'object' || idl === null) {
+        throw new Error('Unexpected IDL content. Expected an object, got ' + typeof idl);
+    }
+    if (isRootNode(idl)) {
+        return idl;
+    }
+    return rootNodeFromAnchor(idl as AnchorIdl);
+}
+
+export function isRootNode(value: unknown): value is RootNode {
+    return (
+        typeof value === 'object' &&
+        value !== null &&
+        (value as { standard?: string }).standard === 'codama' &&
+        (value as { kind?: string }).kind === 'rootNode'
+    );
+}

+ 3 - 0
packages/cli/src/utils/promises.ts

@@ -0,0 +1,3 @@
+export function promisify<T>(value: Promise<T> | T): Promise<T> {
+    return Promise.resolve(value);
+}

+ 55 - 0
packages/cli/src/utils/visitors.ts

@@ -0,0 +1,55 @@
+import type { RootNode } from '@codama/nodes';
+import { rootNodeVisitor, visit, type Visitor } from '@codama/visitors-core';
+
+import { ParsedVisitorConfig } from '../parsedConfig';
+import { importModuleItem } from './import';
+import { isRootNode } from './nodes';
+import { promisify } from './promises';
+
+export async function getRootNodeVisitors(
+    visitors: readonly ParsedVisitorConfig[],
+): Promise<Visitor<RootNode, 'rootNode'>[]> {
+    return await Promise.all(visitors.map(getRootNodeVisitor));
+}
+
+async function getRootNodeVisitor(visitorConfig: ParsedVisitorConfig): Promise<Visitor<RootNode, 'rootNode'>> {
+    const { args, item, path } = visitorConfig;
+    const identifier = getVisitorIdentifier(visitorConfig);
+    const moduleItem = await importModuleItem(identifier, path, item);
+    const visitor = await getVisitorFromModuleItem(identifier, moduleItem, args);
+    return rootNodeVisitor(root => {
+        const result = visit(root, visitor);
+        return isRootNode(result) ? result : root;
+    });
+}
+
+type UnknownFunction = (...args: readonly unknown[]) => unknown;
+async function getVisitorFromModuleItem(
+    identifier: string,
+    moduleItem: unknown,
+    args: readonly unknown[],
+): Promise<Visitor<unknown, 'rootNode'>> {
+    if (isRootNodeVisitor(moduleItem)) {
+        return moduleItem;
+    }
+    if (typeof moduleItem === 'function') {
+        const result = await promisify((moduleItem as UnknownFunction)(...args));
+        if (isRootNodeVisitor(result)) {
+            return result;
+        }
+    }
+    throw new Error(`Invalid ${identifier}. Expected a visitor or a function returning a visitor.`);
+}
+
+function isRootNodeVisitor(value: unknown): value is Visitor<unknown, 'rootNode'> {
+    return !!value && typeof value === 'object' && 'visitRoot' in value;
+}
+
+function getVisitorIdentifier(visitorConfig: ParsedVisitorConfig): string {
+    const { index, item, path, script } = visitorConfig;
+    const pathWithItem = item ? `${path}#${item}` : path;
+    let identifier = `visitor of index #${index}`;
+    identifier += script ? ` in script "${script}"` : '';
+    identifier += ` (at path "${pathWithItem}")`;
+    return identifier;
+}

+ 7 - 0
packages/cli/test/index.test.ts

@@ -0,0 +1,7 @@
+import { expect, test } from 'vitest';
+
+import { createProgram } from '../src';
+
+test('it exports a function to create a CLI program', () => {
+    expect(typeof createProgram).toBe('function');
+});

+ 10 - 0
packages/cli/tsconfig.declarations.json

@@ -0,0 +1,10 @@
+{
+    "compilerOptions": {
+        "declaration": true,
+        "declarationMap": true,
+        "emitDeclarationOnly": true,
+        "outDir": "./dist/types"
+    },
+    "extends": "./tsconfig.json",
+    "include": ["src/index.ts", "src/types"]
+}

+ 9 - 0
packages/cli/tsconfig.json

@@ -0,0 +1,9 @@
+{
+    "$schema": "https://json.schemastore.org/tsconfig",
+    "compilerOptions": {
+        "module": "ESNext"
+    },
+    "display": "codama",
+    "extends": "../internals/tsconfig.base.json",
+    "include": ["src", "test"]
+}

+ 7 - 0
packages/library/bin/cli.mjs

@@ -0,0 +1,7 @@
+#!/usr/bin/env -S node
+
+import process from 'node:process';
+
+import { run } from '../dist/cli.mjs';
+
+run(process.argv);

+ 6 - 1
packages/library/package.json

@@ -26,8 +26,11 @@
     "react-native": "./dist/index.react-native.mjs",
     "types": "./dist/types/index.d.ts",
     "type": "commonjs",
+    "bin": "./bin/cli.mjs",
     "files": [
+        "./bin",
         "./dist/types",
+        "./dist/cli.*",
         "./dist/index.*"
     ],
     "sideEffects": false,
@@ -39,8 +42,9 @@
         "code generation"
     ],
     "scripts": {
-        "build": "rimraf dist && pnpm build:src && pnpm build:types",
+        "build": "rimraf dist && pnpm build:src && pnpm build:cli && pnpm build:types",
         "build:src": "zx ../../node_modules/@codama/internals/scripts/build-src.mjs library",
+        "build:cli": "zx ../../node_modules/@codama/internals/scripts/build-src.mjs cli",
         "build:types": "zx ../../node_modules/@codama/internals/scripts/build-types.mjs",
         "dev": "zx ../../node_modules/@codama/internals/scripts/test-unit.mjs node --watch",
         "lint": "zx ../../node_modules/@codama/internals/scripts/lint.mjs",
@@ -53,6 +57,7 @@
         "test:types": "zx ../../node_modules/@codama/internals/scripts/test-types.mjs"
     },
     "dependencies": {
+        "@codama/cli": "workspace:*",
         "@codama/errors": "workspace:*",
         "@codama/nodes": "workspace:*",
         "@codama/validators": "workspace:*",

+ 15 - 0
packages/library/src/cli/index.ts

@@ -0,0 +1,15 @@
+import { createProgram, logError } from '@codama/cli';
+
+const program = createProgram();
+
+export async function run(argv: readonly string[]) {
+    try {
+        await program.parseAsync(argv);
+    } catch (err) {
+        if (program.opts().debug) {
+            logError(`${(err as { stack: string }).stack}`);
+        }
+        logError((err as { message: string }).message);
+        process.exitCode = 1;
+    }
+}

+ 1 - 1
packages/library/src/codama.ts

@@ -39,7 +39,7 @@ export function createFromJson(json: string): Codama {
     return createFromRoot(JSON.parse(json) as RootNode);
 }
 
-function validateCodamaVersion(rootVersion: CodamaVersion): void {
+export function validateCodamaVersion(rootVersion: CodamaVersion): void {
     const codamaVersion = __VERSION__;
     if (rootVersion === codamaVersion) return;
     const [rootMajor, rootMinor] = rootVersion.split('.').map(Number);

+ 4 - 1
packages/library/tsconfig.json

@@ -1,6 +1,9 @@
 {
     "$schema": "https://json.schemastore.org/tsconfig",
-    "compilerOptions": { "lib": [] },
+    "compilerOptions": {
+        "lib": ["DOM", "ES2015", "ES2022.Error"],
+        "resolveJsonModule": true
+    },
     "display": "codama",
     "extends": "../internals/tsconfig.base.json",
     "include": ["src", "test"]

+ 1 - 1
packages/renderers-js-umi/e2e/system/idl.json

@@ -1050,5 +1050,5 @@
   },
   "additionalPrograms": [],
   "standard": "codama",
-  "version": "0.20.0"
+  "version": "1.0.0"
 }

+ 2 - 0
packages/renderers-js-umi/src/index.ts

@@ -2,3 +2,5 @@ export * from './ImportMap';
 export * from './getRenderMapVisitor';
 export * from './getTypeManifestVisitor';
 export * from './renderVisitor';
+
+export { renderVisitor as default } from './renderVisitor';

+ 1 - 1
packages/renderers-js/e2e/system/idl.json

@@ -1056,5 +1056,5 @@
   },
   "additionalPrograms": [],
   "standard": "codama",
-  "version": "0.20.0"
+  "version": "1.0.0"
 }

+ 1 - 1
packages/renderers-js/e2e/token/idl.json

@@ -2856,5 +2856,5 @@
     }
   ],
   "standard": "codama",
-  "version": "0.20.0"
+  "version": "1.0.0"
 }

+ 2 - 0
packages/renderers-js/src/index.ts

@@ -4,3 +4,5 @@ export * from './getRenderMapVisitor';
 export * from './getTypeManifestVisitor';
 export * from './nameTransformers';
 export * from './renderVisitor';
+
+export { renderVisitor as default } from './renderVisitor';

+ 1 - 1
packages/renderers-rust/e2e/system/idl.json

@@ -1038,5 +1038,5 @@
     },
     "additionalPrograms": [],
     "standard": "codama",
-    "version": "0.20.0"
+    "version": "1.0.0"
 }

+ 2 - 0
packages/renderers-rust/src/index.ts

@@ -2,3 +2,5 @@ export * from './ImportMap';
 export * from './getRenderMapVisitor';
 export * from './getTypeManifestVisitor';
 export * from './renderVisitor';
+
+export { renderVisitor as default } from './renderVisitor';

+ 1 - 1
packages/renderers-vixen-parser/e2e/system_parser/idl.json

@@ -1038,5 +1038,5 @@
     },
     "additionalPrograms": [],
     "standard": "codama",
-    "version": "0.20.0"
+    "version": "1.0.0"
 }

+ 2 - 0
packages/renderers-vixen-parser/src/index.ts

@@ -2,3 +2,5 @@ export * from './ImportMap';
 export * from './getRenderMapVisitor';
 export * from './getTypeManifestVisitor';
 export * from './renderVisitor';
+
+export { renderVisitor as default } from './renderVisitor';

+ 71 - 0
pnpm-lock.yaml

@@ -78,6 +78,46 @@ importers:
         specifier: ^8.3.1
         version: 8.3.1
 
+  packages/cli:
+    dependencies:
+      '@codama/nodes':
+        specifier: workspace:*
+        version: link:../nodes
+      '@codama/nodes-from-anchor':
+        specifier: workspace:*
+        version: link:../nodes-from-anchor
+      '@codama/renderers':
+        specifier: workspace:*
+        version: link:../renderers
+      '@codama/renderers-js':
+        specifier: workspace:*
+        version: link:../renderers-js
+      '@codama/renderers-js-umi':
+        specifier: workspace:*
+        version: link:../renderers-js-umi
+      '@codama/renderers-rust':
+        specifier: workspace:*
+        version: link:../renderers-rust
+      '@codama/visitors':
+        specifier: workspace:*
+        version: link:../visitors
+      '@codama/visitors-core':
+        specifier: workspace:*
+        version: link:../visitors-core
+      chalk:
+        specifier: ^5.4.1
+        version: 5.4.1
+      commander:
+        specifier: ^13.1.0
+        version: 13.1.0
+      prompts:
+        specifier: ^2.4.2
+        version: 2.4.2
+    devDependencies:
+      '@types/prompts':
+        specifier: ^2.4.9
+        version: 2.4.9
+
   packages/dynamic-codecs:
     dependencies:
       '@codama/errors':
@@ -135,6 +175,9 @@ importers:
 
   packages/library:
     dependencies:
+      '@codama/cli':
+        specifier: workspace:*
+        version: link:../cli
       '@codama/errors':
         specifier: workspace:*
         version: link:../errors
@@ -1202,6 +1245,9 @@ packages:
   '@types/nunjucks@3.2.6':
     resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==}
 
+  '@types/prompts@2.4.9':
+    resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==}
+
   '@types/semver@7.5.8':
     resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
 
@@ -2361,6 +2407,10 @@ packages:
   keyv@4.5.4:
     resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
 
+  kleur@3.0.3:
+    resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
+    engines: {node: '>=6'}
+
   leven@3.1.0:
     resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
     engines: {node: '>=6'}
@@ -2680,6 +2730,10 @@ packages:
     resolution: {integrity: sha512-xkeffkZoqQmRrcNewpOsUCKNOl+CkPqjt3Ld749uz1S7/O7GuPNPv2fZk3v/1U/FE8/B4Zz0llVL80MKON1tOQ==}
     engines: {node: ^16.10.0 || ^18.12.0 || >=20.0.0}
 
+  prompts@2.4.2:
+    resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
+    engines: {node: '>= 6'}
+
   punycode@2.3.1:
     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
     engines: {node: '>=6'}
@@ -2793,6 +2847,9 @@ packages:
     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     engines: {node: '>=14'}
 
+  sisteransi@1.0.5:
+    resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+
   slash@3.0.0:
     resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
     engines: {node: '>=8'}
@@ -4206,6 +4263,11 @@ snapshots:
 
   '@types/nunjucks@3.2.6': {}
 
+  '@types/prompts@2.4.9':
+    dependencies:
+      '@types/node': 22.12.0
+      kleur: 3.0.3
+
   '@types/semver@7.5.8': {}
 
   '@types/stack-utils@2.0.3': {}
@@ -5668,6 +5730,8 @@ snapshots:
     dependencies:
       json-buffer: 3.0.1
 
+  kleur@3.0.3: {}
+
   leven@3.1.0: {}
 
   levn@0.4.1:
@@ -5912,6 +5976,11 @@ snapshots:
       ansi-styles: 5.2.0
       react-is: 18.3.1
 
+  prompts@2.4.2:
+    dependencies:
+      kleur: 3.0.3
+      sisteransi: 1.0.5
+
   punycode@2.3.1: {}
 
   pure-rand@6.1.0: {}
@@ -6023,6 +6092,8 @@ snapshots:
 
   signal-exit@4.1.0: {}
 
+  sisteransi@1.0.5: {}
+
   slash@3.0.0: {}
 
   source-map-js@1.2.1: {}