Skip to content

Commit 7fcb4c3

Browse files
committed
0.0.4
1 parent bc6b644 commit 7fcb4c3

29 files changed

Lines changed: 1242 additions & 4 deletions

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
"private": true,
44
"type": "module",
55
"scripts": {
6-
"build": "vp run cli-to-js#build",
6+
"build": "pnpm -r build",
77
"fmt": "vp fmt",
88
"lint": "vp lint",
99
"check": "vp check",
10-
"test": "vp run cli-to-js#test",
11-
"typecheck": "vp run cli-to-js#typecheck",
10+
"test": "pnpm -r test",
11+
"typecheck": "pnpm -r typecheck",
1212
"changeset": "changeset",
1313
"version": "changeset version",
1414
"release": "pnpm build && changeset publish"

packages/cli-to-js/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# cli-to-js
22

3+
## 0.0.4
4+
5+
### Patch Changes
6+
7+
- fix
8+
39
## 0.0.3
410

511
### Patch Changes

packages/cli-to-js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cli-to-js",
3-
"version": "0.0.3",
3+
"version": "0.0.4",
44
"description": "Turn any CLI tool into a Node.js API. Reverse commander.",
55
"keywords": [
66
"api",

packages/js-to-cli/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# js-to-cli
2+
3+
## 0.0.2
4+
5+
### Patch Changes
6+
7+
- fix

packages/js-to-cli/package.json

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"name": "js-to-cli",
3+
"version": "0.0.2",
4+
"description": "Turn any Node.js module into a Commander CLI. Inverse of cli-to-js.",
5+
"keywords": [
6+
"cli",
7+
"commander",
8+
"module",
9+
"node",
10+
"runtime",
11+
"wrapper"
12+
],
13+
"homepage": "https://github.com/aidenybai/cli-to-js",
14+
"bugs": {
15+
"url": "https://github.com/aidenybai/cli-to-js/issues"
16+
},
17+
"license": "MIT",
18+
"author": {
19+
"name": "Aiden Bai",
20+
"email": "aiden@million.dev"
21+
},
22+
"repository": {
23+
"type": "git",
24+
"url": "git+https://github.com/aidenybai/cli-to-js.git"
25+
},
26+
"bin": {
27+
"js-to-cli": "./dist/cli.mjs"
28+
},
29+
"files": [
30+
"dist",
31+
"README.md"
32+
],
33+
"type": "module",
34+
"main": "./dist/index.mjs",
35+
"exports": {
36+
".": {
37+
"types": "./dist/index.d.mts",
38+
"import": "./dist/index.mjs"
39+
},
40+
"./package.json": "./package.json"
41+
},
42+
"publishConfig": {
43+
"access": "public"
44+
},
45+
"scripts": {
46+
"build": "vp pack",
47+
"dev": "vp pack --watch",
48+
"test": "vp test run",
49+
"typecheck": "tsc --noEmit"
50+
},
51+
"dependencies": {
52+
"commander": "^14.0.3"
53+
},
54+
"engines": {
55+
"node": ">=22"
56+
}
57+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { Command } from "commander";
2+
import { basename } from "node:path";
3+
import { DEFAULT_FAILURE_EXIT_CODE } from "./constants.js";
4+
import { loadModule } from "./load-module.js";
5+
import {
6+
parseFunctionSignature,
7+
type ParsedFunctionSignature,
8+
type ParsedOptionField,
9+
type ParsedParameter,
10+
} from "./parse-function.js";
11+
import { camelToKebab } from "./utils/camel-to-kebab.js";
12+
import { formatResult } from "./utils/format-result.js";
13+
import { inferOptionType } from "./utils/infer-option-type.js";
14+
15+
export interface BuildCliOptions {
16+
programName?: string;
17+
}
18+
19+
const collectArrayValue = (value: string, previous: string[]): string[] => [...previous, value];
20+
21+
const applyOptionField = (subcommand: Command, field: ParsedOptionField): void => {
22+
const kebabName = camelToKebab(field.name);
23+
const inferred = inferOptionType(field.defaultLiteral);
24+
25+
switch (inferred.commanderType) {
26+
case "boolean":
27+
subcommand.option(`--${kebabName}`, "");
28+
return;
29+
case "negated-boolean":
30+
subcommand.option(`--no-${kebabName}`, "");
31+
return;
32+
case "number":
33+
subcommand.option(`--${kebabName} <number>`, "", parseFloat, inferred.defaultValue);
34+
return;
35+
case "array": {
36+
const emptyArrayDefault: string[] = [];
37+
subcommand.option(`--${kebabName} <value>`, "", collectArrayValue, emptyArrayDefault);
38+
return;
39+
}
40+
case "required-string":
41+
subcommand.requiredOption(`--${kebabName} <value>`, "");
42+
return;
43+
case "string":
44+
if (inferred.defaultValue !== undefined) {
45+
subcommand.option(`--${kebabName} <value>`, "", inferred.defaultValue);
46+
return;
47+
}
48+
subcommand.option(`--${kebabName} <value>`, "");
49+
return;
50+
}
51+
};
52+
53+
const applyParameter = (subcommand: Command, parameter: ParsedParameter): void => {
54+
if (parameter.kind === "primitive") {
55+
const argumentSpec = parameter.hasDefault
56+
? `[${camelToKebab(parameter.name)}]`
57+
: `<${camelToKebab(parameter.name)}>`;
58+
subcommand.argument(argumentSpec);
59+
return;
60+
}
61+
62+
if (parameter.kind === "rest") {
63+
subcommand.argument(`[${camelToKebab(parameter.name)}...]`);
64+
return;
65+
}
66+
67+
for (const field of parameter.optionFields ?? []) {
68+
applyOptionField(subcommand, field);
69+
}
70+
};
71+
72+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
73+
typeof value === "object" && value !== null;
74+
75+
const assembleCallArgs = (
76+
signature: ParsedFunctionSignature,
77+
commanderArgs: unknown[],
78+
): unknown[] => {
79+
const positionalParameters = signature.parameters.filter(
80+
(parameter) => parameter.kind === "primitive" || parameter.kind === "rest",
81+
);
82+
const positionalValues = commanderArgs.slice(0, positionalParameters.length);
83+
const optionsSlot = commanderArgs[positionalParameters.length];
84+
const rawOptions: Record<string, unknown> = isPlainObject(optionsSlot) ? optionsSlot : {};
85+
86+
const callArgs: unknown[] = [];
87+
let positionalCursor = 0;
88+
89+
for (const parameter of signature.parameters) {
90+
if (parameter.kind === "primitive") {
91+
callArgs.push(positionalValues[positionalCursor]);
92+
positionalCursor++;
93+
continue;
94+
}
95+
96+
if (parameter.kind === "rest") {
97+
const restValue = positionalValues[positionalCursor];
98+
positionalCursor++;
99+
if (Array.isArray(restValue)) {
100+
callArgs.push(...restValue);
101+
} else if (restValue !== undefined) {
102+
callArgs.push(restValue);
103+
}
104+
continue;
105+
}
106+
107+
const optionsObject: Record<string, unknown> = {};
108+
for (const field of parameter.optionFields ?? []) {
109+
if (field.name in rawOptions) {
110+
optionsObject[field.name] = rawOptions[field.name];
111+
}
112+
}
113+
callArgs.push(optionsObject);
114+
}
115+
116+
return callArgs;
117+
};
118+
119+
const buildActionHandler =
120+
(fn: (...args: unknown[]) => unknown, signature: ParsedFunctionSignature) =>
121+
async (...commanderArgs: unknown[]): Promise<void> => {
122+
try {
123+
const callArgs = assembleCallArgs(signature, commanderArgs);
124+
const result = await fn(...callArgs);
125+
const formatted = formatResult(result);
126+
if (formatted !== null) process.stdout.write(formatted + "\n");
127+
} catch (error) {
128+
const message = error instanceof Error ? error.message : String(error);
129+
process.stderr.write(message + "\n");
130+
process.exit(DEFAULT_FAILURE_EXIT_CODE);
131+
}
132+
};
133+
134+
export const convertJsToCli = async (
135+
modulePath: string,
136+
options: BuildCliOptions = {},
137+
): Promise<Command> => {
138+
const loaded = await loadModule(modulePath);
139+
140+
const program = new Command()
141+
.name(options.programName ?? basename(loaded.absolutePath))
142+
.description(`CLI generated from ${loaded.modulePath}`);
143+
144+
for (const functionExport of loaded.functionExports) {
145+
const signature = parseFunctionSignature(functionExport.fn, functionExport.exportName);
146+
const subcommand = program.command(functionExport.commandName);
147+
for (const parameter of signature.parameters) {
148+
applyParameter(subcommand, parameter);
149+
}
150+
subcommand.action(buildActionHandler(functionExport.fn, signature));
151+
}
152+
153+
return program;
154+
};

packages/js-to-cli/src/cli.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env node
2+
3+
import { convertJsToCli } from "./build-cli.js";
4+
import { DEFAULT_FAILURE_EXIT_CODE } from "./constants.js";
5+
6+
const HELP_TEXT =
7+
"Usage: js-to-cli <module-path> <subcommand> [args...]\n" +
8+
"\n" +
9+
"Loads the given JS/TS module and exposes its exported functions as subcommands.\n" +
10+
"Each function becomes a subcommand. Primitive parameters become positional args;\n" +
11+
"a trailing destructured options object becomes --flags.\n";
12+
13+
const main = async (): Promise<void> => {
14+
const argv = process.argv.slice(2);
15+
16+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
17+
process.stdout.write(HELP_TEXT);
18+
process.exit(0);
19+
}
20+
21+
const modulePath = argv[0];
22+
const remainingArgv = argv.slice(1);
23+
24+
try {
25+
const program = await convertJsToCli(modulePath);
26+
await program.parseAsync(remainingArgv, { from: "user" });
27+
} catch (error) {
28+
const message = error instanceof Error ? error.message : String(error);
29+
process.stderr.write(`js-to-cli: ${message}\n`);
30+
process.exit(DEFAULT_FAILURE_EXIT_CODE);
31+
}
32+
};
33+
34+
main();
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const JSON_INDENT_SPACES = 2;
2+
export const DEFAULT_FAILURE_EXIT_CODE = 1;

packages/js-to-cli/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export { convertJsToCli } from "./build-cli.js";
2+
export type { BuildCliOptions } from "./build-cli.js";
3+
export { loadModule } from "./load-module.js";
4+
export type { LoadedFunctionExport, LoadedModule } from "./load-module.js";
5+
export { parseFunctionSignature } from "./parse-function.js";
6+
export type {
7+
ParsedFunctionSignature,
8+
ParsedOptionField,
9+
ParsedParameter,
10+
} from "./parse-function.js";
11+
export { inferOptionType } from "./utils/infer-option-type.js";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { resolve, extname } from "node:path";
2+
import { pathToFileURL } from "node:url";
3+
import { camelToKebab } from "./utils/camel-to-kebab.js";
4+
5+
export interface LoadedFunctionExport {
6+
exportName: string;
7+
commandName: string;
8+
fn: (...args: unknown[]) => unknown;
9+
}
10+
11+
export interface LoadedModule {
12+
modulePath: string;
13+
absolutePath: string;
14+
namespace: Record<string, unknown>;
15+
functionExports: LoadedFunctionExport[];
16+
}
17+
18+
const SUPPORTED_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
19+
const RESERVED_COMMAND_NAMES = new Set(["help", "version"]);
20+
21+
const isCallableValue = (value: unknown): value is (...args: unknown[]) => unknown =>
22+
typeof value === "function";
23+
24+
const isClassConstructor = (value: (...args: unknown[]) => unknown): boolean =>
25+
/^\s*class\b/.test(Function.prototype.toString.call(value));
26+
27+
export const loadModule = async (modulePath: string): Promise<LoadedModule> => {
28+
const absolutePath = resolve(process.cwd(), modulePath);
29+
const extension = extname(absolutePath).toLowerCase();
30+
31+
if (!SUPPORTED_EXTENSIONS.has(extension)) {
32+
throw new Error(`unsupported module extension: "${extension}"`);
33+
}
34+
35+
const moduleHref = pathToFileURL(absolutePath).href;
36+
const importedNamespace: Record<string, unknown> = await import(moduleHref);
37+
38+
const functionExports: LoadedFunctionExport[] = [];
39+
for (const [exportName, exportedValue] of Object.entries(importedNamespace)) {
40+
if (exportName === "__esModule") continue;
41+
if (!isCallableValue(exportedValue)) continue;
42+
if (isClassConstructor(exportedValue)) continue;
43+
44+
const commandName = exportName === "default" ? "default" : camelToKebab(exportName);
45+
46+
if (RESERVED_COMMAND_NAMES.has(commandName)) {
47+
process.stderr.write(
48+
`js-to-cli: skipping export "${exportName}" — collides with reserved command name\n`,
49+
);
50+
continue;
51+
}
52+
53+
functionExports.push({ exportName, commandName, fn: exportedValue });
54+
}
55+
56+
if (functionExports.length === 0) {
57+
throw new Error(`no exported functions found in ${modulePath}`);
58+
}
59+
60+
return { modulePath, absolutePath, namespace: importedNamespace, functionExports };
61+
};

0 commit comments

Comments
 (0)