js-to-cli 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/build-cli-Bl-vRrRB.mjs +422 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +23 -0
- package/dist/index.d.mts +67 -0
- package/dist/index.mjs +2 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Million Software, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { basename, extname, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
//#region src/utils/camel-to-kebab.ts
|
|
5
|
+
const camelToKebab = (input) => input.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").toLowerCase();
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/load-module.ts
|
|
8
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
9
|
+
".js",
|
|
10
|
+
".mjs",
|
|
11
|
+
".cjs",
|
|
12
|
+
".ts",
|
|
13
|
+
".mts",
|
|
14
|
+
".cts"
|
|
15
|
+
]);
|
|
16
|
+
const RESERVED_COMMAND_NAMES = new Set(["help", "version"]);
|
|
17
|
+
const isCallableValue = (value) => typeof value === "function";
|
|
18
|
+
const isClassConstructor = (value) => /^\s*class\b/.test(Function.prototype.toString.call(value));
|
|
19
|
+
const loadModule = async (modulePath) => {
|
|
20
|
+
const absolutePath = resolve(process.cwd(), modulePath);
|
|
21
|
+
const extension = extname(absolutePath).toLowerCase();
|
|
22
|
+
if (!SUPPORTED_EXTENSIONS.has(extension)) throw new Error(`unsupported module extension: "${extension}"`);
|
|
23
|
+
const importedNamespace = await import(pathToFileURL(absolutePath).href);
|
|
24
|
+
const functionExports = [];
|
|
25
|
+
for (const [exportName, exportedValue] of Object.entries(importedNamespace)) {
|
|
26
|
+
if (exportName === "__esModule") continue;
|
|
27
|
+
if (!isCallableValue(exportedValue)) continue;
|
|
28
|
+
if (isClassConstructor(exportedValue)) continue;
|
|
29
|
+
const commandName = exportName === "default" ? "default" : camelToKebab(exportName);
|
|
30
|
+
if (RESERVED_COMMAND_NAMES.has(commandName)) {
|
|
31
|
+
process.stderr.write(`js-to-cli: skipping export "${exportName}" — collides with reserved command name\n`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
functionExports.push({
|
|
35
|
+
exportName,
|
|
36
|
+
commandName,
|
|
37
|
+
fn: exportedValue
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (functionExports.length === 0) throw new Error(`no exported functions found in ${modulePath}`);
|
|
41
|
+
return {
|
|
42
|
+
modulePath,
|
|
43
|
+
absolutePath,
|
|
44
|
+
namespace: importedNamespace,
|
|
45
|
+
functionExports
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/utils/split-top-level.ts
|
|
50
|
+
const splitTopLevel = (source, separator) => {
|
|
51
|
+
const segments = [];
|
|
52
|
+
let depth = 0;
|
|
53
|
+
let stringQuote = null;
|
|
54
|
+
let segmentStart = 0;
|
|
55
|
+
for (let index = 0; index < source.length; index++) {
|
|
56
|
+
const character = source[index];
|
|
57
|
+
if (stringQuote !== null) {
|
|
58
|
+
if (character === "\\") {
|
|
59
|
+
index++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (character === stringQuote) stringQuote = null;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
66
|
+
stringQuote = character;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (character === "(" || character === "[" || character === "{") {
|
|
70
|
+
depth++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (character === ")" || character === "]" || character === "}") {
|
|
74
|
+
depth--;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (depth === 0 && source.startsWith(separator, index)) {
|
|
78
|
+
segments.push(source.slice(segmentStart, index));
|
|
79
|
+
segmentStart = index + separator.length;
|
|
80
|
+
index += separator.length - 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
segments.push(source.slice(segmentStart));
|
|
84
|
+
return segments;
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/utils/strip-comments.ts
|
|
88
|
+
const stripComments = (source) => {
|
|
89
|
+
let output = "";
|
|
90
|
+
let stringQuote = null;
|
|
91
|
+
let index = 0;
|
|
92
|
+
while (index < source.length) {
|
|
93
|
+
const character = source[index];
|
|
94
|
+
if (stringQuote !== null) {
|
|
95
|
+
output += character;
|
|
96
|
+
if (character === "\\" && index + 1 < source.length) {
|
|
97
|
+
output += source[index + 1];
|
|
98
|
+
index += 2;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (character === stringQuote) stringQuote = null;
|
|
102
|
+
index++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
106
|
+
stringQuote = character;
|
|
107
|
+
output += character;
|
|
108
|
+
index++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (character === "/" && source[index + 1] === "/") {
|
|
112
|
+
const newlineIndex = source.indexOf("\n", index + 2);
|
|
113
|
+
if (newlineIndex === -1) index = source.length;
|
|
114
|
+
else index = newlineIndex;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (character === "/" && source[index + 1] === "*") {
|
|
118
|
+
const closingIndex = source.indexOf("*/", index + 2);
|
|
119
|
+
if (closingIndex === -1) index = source.length;
|
|
120
|
+
else index = closingIndex + 2;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
output += character;
|
|
124
|
+
index++;
|
|
125
|
+
}
|
|
126
|
+
return output;
|
|
127
|
+
};
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/parse-function.ts
|
|
130
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_$][\w$]*$/;
|
|
131
|
+
const findMatchingClose = (source, openIndex) => {
|
|
132
|
+
const openChar = source[openIndex];
|
|
133
|
+
const closeChar = openChar === "(" ? ")" : openChar === "{" ? "}" : "]";
|
|
134
|
+
let depth = 0;
|
|
135
|
+
let stringQuote = null;
|
|
136
|
+
for (let index = openIndex; index < source.length; index++) {
|
|
137
|
+
const character = source[index];
|
|
138
|
+
if (stringQuote !== null) {
|
|
139
|
+
if (character === "\\") {
|
|
140
|
+
index++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (character === stringQuote) stringQuote = null;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
147
|
+
stringQuote = character;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (character === openChar) depth++;
|
|
151
|
+
else if (character === closeChar) {
|
|
152
|
+
depth--;
|
|
153
|
+
if (depth === 0) return index;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return -1;
|
|
157
|
+
};
|
|
158
|
+
const findTopLevelDefaultEquals = (source) => {
|
|
159
|
+
let depth = 0;
|
|
160
|
+
let stringQuote = null;
|
|
161
|
+
for (let index = 0; index < source.length; index++) {
|
|
162
|
+
const character = source[index];
|
|
163
|
+
if (stringQuote !== null) {
|
|
164
|
+
if (character === "\\") {
|
|
165
|
+
index++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (character === stringQuote) stringQuote = null;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
172
|
+
stringQuote = character;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (character === "(" || character === "[" || character === "{") {
|
|
176
|
+
depth++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (character === ")" || character === "]" || character === "}") {
|
|
180
|
+
depth--;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (depth === 0 && character === "=") {
|
|
184
|
+
const nextCharacter = source[index + 1];
|
|
185
|
+
const previousCharacter = source[index - 1];
|
|
186
|
+
if (nextCharacter === "=" || nextCharacter === ">") continue;
|
|
187
|
+
if (previousCharacter === "=" || previousCharacter === "<" || previousCharacter === ">" || previousCharacter === "!") continue;
|
|
188
|
+
return index;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return -1;
|
|
192
|
+
};
|
|
193
|
+
const splitNameAndDefault = (source) => {
|
|
194
|
+
const equalsIndex = findTopLevelDefaultEquals(source);
|
|
195
|
+
if (equalsIndex === -1) return {
|
|
196
|
+
beforeEquals: source.trim(),
|
|
197
|
+
defaultLiteral: null
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
beforeEquals: source.slice(0, equalsIndex).trim(),
|
|
201
|
+
defaultLiteral: source.slice(equalsIndex + 1).trim()
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const parseOptionFields = (innerSource) => {
|
|
205
|
+
const fields = [];
|
|
206
|
+
for (const rawSlice of splitTopLevel(innerSource, ",")) {
|
|
207
|
+
const slice = rawSlice.trim();
|
|
208
|
+
if (slice === "") continue;
|
|
209
|
+
const { beforeEquals, defaultLiteral } = splitNameAndDefault(slice);
|
|
210
|
+
let propertyName = beforeEquals;
|
|
211
|
+
const colonIndex = beforeEquals.indexOf(":");
|
|
212
|
+
if (colonIndex !== -1) propertyName = beforeEquals.slice(0, colonIndex).trim();
|
|
213
|
+
if (!IDENTIFIER_PATTERN.test(propertyName)) throw new Error(`unsupported destructured option field: "${slice}"`);
|
|
214
|
+
fields.push({
|
|
215
|
+
name: propertyName,
|
|
216
|
+
hasDefault: defaultLiteral !== null,
|
|
217
|
+
defaultLiteral
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return fields;
|
|
221
|
+
};
|
|
222
|
+
const parseParameter = (rawSlice, exportedName) => {
|
|
223
|
+
const slice = rawSlice.trim();
|
|
224
|
+
if (slice.startsWith("...")) {
|
|
225
|
+
const restName = slice.slice(3).trim();
|
|
226
|
+
if (!IDENTIFIER_PATTERN.test(restName)) throw new Error(`unsupported rest parameter "${slice}" in "${exportedName}"`);
|
|
227
|
+
return {
|
|
228
|
+
name: restName,
|
|
229
|
+
kind: "rest",
|
|
230
|
+
hasDefault: false,
|
|
231
|
+
defaultLiteral: null,
|
|
232
|
+
optionFields: null
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (slice.startsWith("{")) {
|
|
236
|
+
const closingBraceIndex = findMatchingClose(slice, 0);
|
|
237
|
+
if (closingBraceIndex === -1) throw new Error(`could not parse destructured options parameter in "${exportedName}"`);
|
|
238
|
+
const innerSource = slice.slice(1, closingBraceIndex);
|
|
239
|
+
const afterBrace = slice.slice(closingBraceIndex + 1).trim();
|
|
240
|
+
const hasDefault = afterBrace.startsWith("=");
|
|
241
|
+
return {
|
|
242
|
+
name: "options",
|
|
243
|
+
kind: "options",
|
|
244
|
+
hasDefault,
|
|
245
|
+
defaultLiteral: hasDefault ? afterBrace.slice(1).trim() : null,
|
|
246
|
+
optionFields: parseOptionFields(innerSource)
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (slice.startsWith("[")) throw new Error(`unsupported parameter pattern "${slice}" in "${exportedName}" (array destructuring not supported)`);
|
|
250
|
+
const { beforeEquals, defaultLiteral } = splitNameAndDefault(slice);
|
|
251
|
+
if (!IDENTIFIER_PATTERN.test(beforeEquals)) throw new Error(`unsupported parameter pattern "${slice}" in "${exportedName}"`);
|
|
252
|
+
return {
|
|
253
|
+
name: beforeEquals,
|
|
254
|
+
kind: "primitive",
|
|
255
|
+
hasDefault: defaultLiteral !== null,
|
|
256
|
+
defaultLiteral,
|
|
257
|
+
optionFields: null
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
const extractParameterListSource = (source) => {
|
|
261
|
+
let cursor = 0;
|
|
262
|
+
while (cursor < source.length && /\s/.test(source[cursor])) cursor++;
|
|
263
|
+
if (source.startsWith("function", cursor)) {
|
|
264
|
+
cursor += 8;
|
|
265
|
+
while (cursor < source.length && source[cursor] !== "(") cursor++;
|
|
266
|
+
}
|
|
267
|
+
while (cursor < source.length && /\s/.test(source[cursor])) cursor++;
|
|
268
|
+
if (source[cursor] !== "(") {
|
|
269
|
+
const arrowIndex = source.indexOf("=>", cursor);
|
|
270
|
+
if (arrowIndex === -1) throw new Error(`could not parse function source: ${source}`);
|
|
271
|
+
return source.slice(cursor, arrowIndex).trim();
|
|
272
|
+
}
|
|
273
|
+
const closingIndex = findMatchingClose(source, cursor);
|
|
274
|
+
if (closingIndex === -1) throw new Error(`could not find matching paren in function source: ${source}`);
|
|
275
|
+
return source.slice(cursor + 1, closingIndex);
|
|
276
|
+
};
|
|
277
|
+
const parseFunctionSignature = (fn, exportedName) => {
|
|
278
|
+
const strippedSource = stripComments(fn.toString()).trim();
|
|
279
|
+
const isAsync = /^async\b/.test(strippedSource);
|
|
280
|
+
const parameterListSource = extractParameterListSource(isAsync ? strippedSource.slice(5).trimStart() : strippedSource);
|
|
281
|
+
if (parameterListSource.trim() === "") return {
|
|
282
|
+
name: exportedName,
|
|
283
|
+
isAsync,
|
|
284
|
+
parameters: []
|
|
285
|
+
};
|
|
286
|
+
const parameters = splitTopLevel(parameterListSource, ",").map((slice) => slice.trim()).filter((slice) => slice !== "").map((slice) => parseParameter(slice, exportedName));
|
|
287
|
+
for (let index = 0; index < parameters.length; index++) {
|
|
288
|
+
const parameter = parameters[index];
|
|
289
|
+
const isLast = index === parameters.length - 1;
|
|
290
|
+
if ((parameter.kind === "options" || parameter.kind === "rest") && !isLast) throw new Error(`${parameter.kind} parameter "${parameter.name}" must be last in "${exportedName}"`);
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
name: exportedName,
|
|
294
|
+
isAsync,
|
|
295
|
+
parameters
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/utils/format-result.ts
|
|
300
|
+
const formatResult = (value) => {
|
|
301
|
+
if (value === void 0) return null;
|
|
302
|
+
if (value === null) return "null";
|
|
303
|
+
if (typeof value === "string") return value;
|
|
304
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
305
|
+
return JSON.stringify(value, null, 2);
|
|
306
|
+
};
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/utils/infer-option-type.ts
|
|
309
|
+
const NUMBER_LITERAL_PATTERN = /^-?\d+(?:\.\d+)?$/;
|
|
310
|
+
const inferOptionType = (defaultLiteral) => {
|
|
311
|
+
if (defaultLiteral === null) return { commanderType: "required-string" };
|
|
312
|
+
const trimmedLiteral = defaultLiteral.trim();
|
|
313
|
+
if (trimmedLiteral === "false") return { commanderType: "boolean" };
|
|
314
|
+
if (trimmedLiteral === "true") return { commanderType: "negated-boolean" };
|
|
315
|
+
if (NUMBER_LITERAL_PATTERN.test(trimmedLiteral)) return {
|
|
316
|
+
commanderType: "number",
|
|
317
|
+
defaultValue: parseFloat(trimmedLiteral)
|
|
318
|
+
};
|
|
319
|
+
if (trimmedLiteral.startsWith("[")) return { commanderType: "array" };
|
|
320
|
+
const firstCharacter = trimmedLiteral[0];
|
|
321
|
+
if (firstCharacter === "\"" || firstCharacter === "'" || firstCharacter === "`") return {
|
|
322
|
+
commanderType: "string",
|
|
323
|
+
defaultValue: trimmedLiteral[trimmedLiteral.length - 1] === firstCharacter ? trimmedLiteral.slice(1, -1) : trimmedLiteral.slice(1)
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
commanderType: "string",
|
|
327
|
+
defaultValue: void 0
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
//#endregion
|
|
331
|
+
//#region src/build-cli.ts
|
|
332
|
+
const collectArrayValue = (value, previous) => [...previous, value];
|
|
333
|
+
const applyOptionField = (subcommand, field) => {
|
|
334
|
+
const kebabName = camelToKebab(field.name);
|
|
335
|
+
const inferred = inferOptionType(field.defaultLiteral);
|
|
336
|
+
switch (inferred.commanderType) {
|
|
337
|
+
case "boolean":
|
|
338
|
+
subcommand.option(`--${kebabName}`, "");
|
|
339
|
+
return;
|
|
340
|
+
case "negated-boolean":
|
|
341
|
+
subcommand.option(`--no-${kebabName}`, "");
|
|
342
|
+
return;
|
|
343
|
+
case "number":
|
|
344
|
+
subcommand.option(`--${kebabName} <number>`, "", parseFloat, inferred.defaultValue);
|
|
345
|
+
return;
|
|
346
|
+
case "array":
|
|
347
|
+
subcommand.option(`--${kebabName} <value>`, "", collectArrayValue, []);
|
|
348
|
+
return;
|
|
349
|
+
case "required-string":
|
|
350
|
+
subcommand.requiredOption(`--${kebabName} <value>`, "");
|
|
351
|
+
return;
|
|
352
|
+
case "string":
|
|
353
|
+
if (inferred.defaultValue !== void 0) {
|
|
354
|
+
subcommand.option(`--${kebabName} <value>`, "", inferred.defaultValue);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
subcommand.option(`--${kebabName} <value>`, "");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
const applyParameter = (subcommand, parameter) => {
|
|
362
|
+
if (parameter.kind === "primitive") {
|
|
363
|
+
const argumentSpec = parameter.hasDefault ? `[${camelToKebab(parameter.name)}]` : `<${camelToKebab(parameter.name)}>`;
|
|
364
|
+
subcommand.argument(argumentSpec);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (parameter.kind === "rest") {
|
|
368
|
+
subcommand.argument(`[${camelToKebab(parameter.name)}...]`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
for (const field of parameter.optionFields ?? []) applyOptionField(subcommand, field);
|
|
372
|
+
};
|
|
373
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null;
|
|
374
|
+
const assembleCallArgs = (signature, commanderArgs) => {
|
|
375
|
+
const positionalParameters = signature.parameters.filter((parameter) => parameter.kind === "primitive" || parameter.kind === "rest");
|
|
376
|
+
const positionalValues = commanderArgs.slice(0, positionalParameters.length);
|
|
377
|
+
const optionsSlot = commanderArgs[positionalParameters.length];
|
|
378
|
+
const rawOptions = isPlainObject(optionsSlot) ? optionsSlot : {};
|
|
379
|
+
const callArgs = [];
|
|
380
|
+
let positionalCursor = 0;
|
|
381
|
+
for (const parameter of signature.parameters) {
|
|
382
|
+
if (parameter.kind === "primitive") {
|
|
383
|
+
callArgs.push(positionalValues[positionalCursor]);
|
|
384
|
+
positionalCursor++;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (parameter.kind === "rest") {
|
|
388
|
+
const restValue = positionalValues[positionalCursor];
|
|
389
|
+
positionalCursor++;
|
|
390
|
+
if (Array.isArray(restValue)) callArgs.push(...restValue);
|
|
391
|
+
else if (restValue !== void 0) callArgs.push(restValue);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const optionsObject = {};
|
|
395
|
+
for (const field of parameter.optionFields ?? []) if (field.name in rawOptions) optionsObject[field.name] = rawOptions[field.name];
|
|
396
|
+
callArgs.push(optionsObject);
|
|
397
|
+
}
|
|
398
|
+
return callArgs;
|
|
399
|
+
};
|
|
400
|
+
const buildActionHandler = (fn, signature) => async (...commanderArgs) => {
|
|
401
|
+
try {
|
|
402
|
+
const formatted = formatResult(await fn(...assembleCallArgs(signature, commanderArgs)));
|
|
403
|
+
if (formatted !== null) process.stdout.write(formatted + "\n");
|
|
404
|
+
} catch (error) {
|
|
405
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
406
|
+
process.stderr.write(message + "\n");
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const convertJsToCli = async (modulePath, options = {}) => {
|
|
411
|
+
const loaded = await loadModule(modulePath);
|
|
412
|
+
const program = new Command().name(options.programName ?? basename(loaded.absolutePath)).description(`CLI generated from ${loaded.modulePath}`);
|
|
413
|
+
for (const functionExport of loaded.functionExports) {
|
|
414
|
+
const signature = parseFunctionSignature(functionExport.fn, functionExport.exportName);
|
|
415
|
+
const subcommand = program.command(functionExport.commandName);
|
|
416
|
+
for (const parameter of signature.parameters) applyParameter(subcommand, parameter);
|
|
417
|
+
subcommand.action(buildActionHandler(functionExport.fn, signature));
|
|
418
|
+
}
|
|
419
|
+
return program;
|
|
420
|
+
};
|
|
421
|
+
//#endregion
|
|
422
|
+
export { loadModule as i, inferOptionType as n, parseFunctionSignature as r, convertJsToCli as t };
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as convertJsToCli } from "./build-cli-Bl-vRrRB.mjs";
|
|
3
|
+
//#region src/cli.ts
|
|
4
|
+
const HELP_TEXT = "Usage: js-to-cli <module-path> <subcommand> [args...]\n\nLoads the given JS/TS module and exposes its exported functions as subcommands.\nEach function becomes a subcommand. Primitive parameters become positional args;\na trailing destructured options object becomes --flags.\n";
|
|
5
|
+
const main = async () => {
|
|
6
|
+
const argv = process.argv.slice(2);
|
|
7
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
8
|
+
process.stdout.write(HELP_TEXT);
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
const modulePath = argv[0];
|
|
12
|
+
const remainingArgv = argv.slice(1);
|
|
13
|
+
try {
|
|
14
|
+
await (await convertJsToCli(modulePath)).parseAsync(remainingArgv, { from: "user" });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
process.stderr.write(`js-to-cli: ${message}\n`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
main();
|
|
22
|
+
//#endregion
|
|
23
|
+
export {};
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
//#region src/build-cli.d.ts
|
|
4
|
+
interface BuildCliOptions {
|
|
5
|
+
programName?: string;
|
|
6
|
+
}
|
|
7
|
+
declare const convertJsToCli: (modulePath: string, options?: BuildCliOptions) => Promise<Command>;
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/load-module.d.ts
|
|
10
|
+
interface LoadedFunctionExport {
|
|
11
|
+
exportName: string;
|
|
12
|
+
commandName: string;
|
|
13
|
+
fn: (...args: unknown[]) => unknown;
|
|
14
|
+
}
|
|
15
|
+
interface LoadedModule {
|
|
16
|
+
modulePath: string;
|
|
17
|
+
absolutePath: string;
|
|
18
|
+
namespace: Record<string, unknown>;
|
|
19
|
+
functionExports: LoadedFunctionExport[];
|
|
20
|
+
}
|
|
21
|
+
declare const loadModule: (modulePath: string) => Promise<LoadedModule>;
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/parse-function.d.ts
|
|
24
|
+
interface ParsedOptionField {
|
|
25
|
+
name: string;
|
|
26
|
+
hasDefault: boolean;
|
|
27
|
+
defaultLiteral: string | null;
|
|
28
|
+
}
|
|
29
|
+
interface ParsedParameter {
|
|
30
|
+
name: string;
|
|
31
|
+
kind: "primitive" | "options" | "rest";
|
|
32
|
+
hasDefault: boolean;
|
|
33
|
+
defaultLiteral: string | null;
|
|
34
|
+
optionFields: ParsedOptionField[] | null;
|
|
35
|
+
}
|
|
36
|
+
interface ParsedFunctionSignature {
|
|
37
|
+
name: string;
|
|
38
|
+
isAsync: boolean;
|
|
39
|
+
parameters: ParsedParameter[];
|
|
40
|
+
}
|
|
41
|
+
declare const parseFunctionSignature: (fn: Function, exportedName: string) => ParsedFunctionSignature;
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/utils/infer-option-type.d.ts
|
|
44
|
+
interface BooleanOption {
|
|
45
|
+
commanderType: "boolean";
|
|
46
|
+
}
|
|
47
|
+
interface NegatedBooleanOption {
|
|
48
|
+
commanderType: "negated-boolean";
|
|
49
|
+
}
|
|
50
|
+
interface NumberOption {
|
|
51
|
+
commanderType: "number";
|
|
52
|
+
defaultValue: number;
|
|
53
|
+
}
|
|
54
|
+
interface ArrayOption {
|
|
55
|
+
commanderType: "array";
|
|
56
|
+
}
|
|
57
|
+
interface StringOption {
|
|
58
|
+
commanderType: "string";
|
|
59
|
+
defaultValue: string | undefined;
|
|
60
|
+
}
|
|
61
|
+
interface RequiredStringOption {
|
|
62
|
+
commanderType: "required-string";
|
|
63
|
+
}
|
|
64
|
+
type InferredOptionType = BooleanOption | NegatedBooleanOption | NumberOption | ArrayOption | StringOption | RequiredStringOption;
|
|
65
|
+
declare const inferOptionType: (defaultLiteral: string | null) => InferredOptionType;
|
|
66
|
+
//#endregion
|
|
67
|
+
export { type BuildCliOptions, type LoadedFunctionExport, type LoadedModule, type ParsedFunctionSignature, type ParsedOptionField, type ParsedParameter, convertJsToCli, inferOptionType, loadModule, parseFunctionSignature };
|
package/dist/index.mjs
ADDED
package/package.json
ADDED
|
@@ -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
|
+
"dependencies": {
|
|
46
|
+
"commander": "^14.0.3"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "vp pack",
|
|
53
|
+
"dev": "vp pack --watch",
|
|
54
|
+
"test": "vp test run",
|
|
55
|
+
"typecheck": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|