Skip to content

Commit fc24b7a

Browse files
committed
fix: problems due to new version; feat: new cli utility
1 parent ee5b737 commit fc24b7a

19 files changed

+806
-250
lines changed

cli/constants.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const root_command = Symbol("root");
2+
const skip_command = Symbol("skip");
3+
4+
const reservedOptions = ["version", "help"];
5+
6+
module.exports = {
7+
root_command,
8+
skip_command,
9+
reservedOptions,
10+
};

cli/execute.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
const _ = require("lodash");
2+
const { root_command, skip_command } = require("./constants");
3+
const { parseArgs } = require("./parse-args");
4+
const didYouMean = require("didyoumean");
5+
6+
didYouMean.threshold = 0.5;
7+
8+
const execute = (params, commands, instance) => {
9+
const args = parseArgs(params.args, params.from);
10+
11+
return new Promise((resolve, reject) => {
12+
const { command, usageOptions, error } = processArgs(commands, args);
13+
14+
if (error) {
15+
reject(new Error(error));
16+
}
17+
18+
if (!usageOptions.length && command.name === root_command) {
19+
usageOptions.push(command.options.find((option) => option.flags.name === "help"));
20+
}
21+
22+
const operationOptions = usageOptions.filter((option) => option.operation);
23+
if (operationOptions.length) {
24+
operationOptions[0].operation();
25+
resolve({
26+
command: skip_command,
27+
options: {},
28+
});
29+
return;
30+
} else {
31+
let error = "";
32+
33+
const processUserOptionData = (data, option) => {
34+
if (!data.length && !option.flags.value) {
35+
return !option.flags.isNoFlag;
36+
}
37+
if (option.flags.value) {
38+
if (option.flags.value.variadic) {
39+
return data.reduce((acc, d) => {
40+
acc.push(...d.split(",").map(option.flags.value.formatter));
41+
return acc;
42+
}, []);
43+
} else {
44+
return option.flags.value.formatter(data[0] || option.default);
45+
}
46+
}
47+
48+
return option.default;
49+
};
50+
51+
const parsedOptionsObject = command.options.reduce((acc, option) => {
52+
if (error) return acc;
53+
54+
const userOption = usageOptions.find((o) => o.flags.name === option.flags.name);
55+
56+
if (!userOption && option.required) {
57+
error = `required option '${option.flags.raw}' not specified`;
58+
return acc;
59+
}
60+
61+
if (userOption) {
62+
acc[option.flags.name] = processUserOptionData(userOption.$data, option);
63+
} else {
64+
acc[option.flags.name] = option.default;
65+
}
66+
67+
return acc;
68+
}, {});
69+
70+
if (error) {
71+
reject(new Error(error));
72+
} else {
73+
resolve({
74+
command: command.name === root_command ? null : command.name,
75+
options: parsedOptionsObject,
76+
});
77+
}
78+
}
79+
});
80+
};
81+
82+
const processArgs = (commands, args) => {
83+
let command = null;
84+
let usageOptions = [];
85+
let walkingOption = null;
86+
let error = "";
87+
88+
let allFlagKeys = [];
89+
90+
_.forEach(args, (arg, i) => {
91+
if (error) return;
92+
93+
if (i === 0) {
94+
command = commands[arg] || commands[root_command];
95+
allFlagKeys = command.options.reduce((acc, option) => [...acc, ...option.flags.keys], []);
96+
}
97+
if (arg.startsWith("-")) {
98+
const option = command.options.find((option) => option.flags.keys.includes(arg));
99+
100+
if (!option) {
101+
const tip = didYouMean(arg, allFlagKeys);
102+
error = `unknown option ${arg}${tip ? `\n(Did you mean ${tip} ?)` : ""}`;
103+
}
104+
105+
if (option) {
106+
if (walkingOption && walkingOption.flags.name === option.flags.name) {
107+
return;
108+
}
109+
const existedOption = usageOptions.find((o) => o.flags.name === option.flags.name);
110+
if (existedOption) {
111+
walkingOption = existedOption;
112+
} else {
113+
walkingOption = {
114+
...option,
115+
$data: [],
116+
};
117+
usageOptions.push(walkingOption);
118+
}
119+
}
120+
121+
return;
122+
}
123+
124+
if (walkingOption) {
125+
walkingOption.$data.push(arg);
126+
}
127+
});
128+
command = command || commands[root_command];
129+
130+
return {
131+
command,
132+
usageOptions,
133+
error,
134+
};
135+
};
136+
137+
module.exports = {
138+
execute,
139+
};

cli/index.d.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { skip_command } from "./constants";
2+
3+
type CliStructOption = {
4+
flags?: string;
5+
description?: string;
6+
default?: unknown;
7+
};
8+
9+
type CliStruct = {
10+
inherited?: string | null;
11+
name?: string;
12+
alias?: string;
13+
version?: string;
14+
description?: string;
15+
options: CliStructOption[];
16+
commands?: CliStruct[];
17+
};
18+
19+
type ExecuteOptions = {
20+
args: string[];
21+
};
22+
23+
type ExecuteOutput = {
24+
command: null | string;
25+
options: Record<string, any>;
26+
};
27+
28+
type CliInstance = {
29+
addCommand: (struct: CliStruct) => CliInstance;
30+
execute: (options: ExecuteOptions) => Promise<ExecuteOutput>;
31+
};
32+
33+
type Cli = <S extends Omit<CliStruct, "inherited">>(struct: S) => CliInstance;
34+
35+
export declare const cli: Cli;

cli/index.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const _ = require("lodash");
2+
const { reservedOptions, root_command } = require("./constants");
3+
const { processOption } = require("./process-option");
4+
const { execute } = require("./execute");
5+
const { displayHelp } = require("./operations/display-help");
6+
const { displayVersion } = require("./operations/display-version");
7+
8+
const cli = (input) => {
9+
const commands = {};
10+
11+
const addCommand = (command) => {
12+
commands[command.name] = {
13+
name: command.name,
14+
description: `${command.description || ""}`,
15+
options: _.compact(_.map(command.options, processOption)),
16+
};
17+
18+
return instance;
19+
};
20+
21+
const instance = {
22+
commands,
23+
input,
24+
addCommand,
25+
execute: (params) => execute(params, commands, instance),
26+
};
27+
28+
addCommand({
29+
name: root_command,
30+
options: [],
31+
});
32+
33+
_.forEach(input.options, (option) => {
34+
const processed = processOption(option);
35+
36+
if (!processed) return;
37+
38+
if (reservedOptions.includes(processed.name)) {
39+
console.warn("reserved option", processed.name);
40+
return;
41+
}
42+
43+
commands[root_command].options.push(processed);
44+
});
45+
46+
commands[root_command].options.unshift(
47+
processOption({
48+
flags: "-v, --version",
49+
description: "output the current version",
50+
operation: () => displayVersion(instance),
51+
}),
52+
);
53+
54+
commands[root_command].options.push(
55+
processOption({
56+
flags: "-h, --help",
57+
description: "display help for command",
58+
operation: () => displayHelp(commands, instance),
59+
}),
60+
);
61+
62+
_.forEach(input.commands, addCommand);
63+
64+
return instance;
65+
};
66+
67+
module.exports = {
68+
cli,
69+
};

cli/operations/display-help.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
const _ = require("lodash");
2+
const { root_command } = require("../constants");
3+
4+
const displayHelp = (commands, instance) => {
5+
const generateOptionsOutput = (options) =>
6+
options.reduce(
7+
(acc, option) => {
8+
const flags = `${option.flags.keys.join(", ")}${option.flags.value?.raw ? ` ${option.flags.value?.raw}` : ""}`;
9+
const description = `${option.description || ""}${
10+
option.default === undefined || (option.flags.isNoFlag && option.default === true)
11+
? ""
12+
: ` (default: ${typeof option.default === "string" ? `"${option.default}"` : option.default})`
13+
}`;
14+
15+
if (flags.length > acc.maxLength) {
16+
acc.maxLength = flags.length;
17+
}
18+
19+
acc.options.push({
20+
flags,
21+
description,
22+
});
23+
return acc;
24+
},
25+
{
26+
options: [],
27+
maxLength: 0,
28+
},
29+
);
30+
31+
const { options, maxLength: maxOptionLength } = generateOptionsOutput(commands[root_command].options);
32+
33+
const { commands: commandLabels, maxLength: maxCommandLength } = _.filter(
34+
commands,
35+
(command) => command.name !== root_command,
36+
).reduce(
37+
(acc, command) => {
38+
const options = generateOptionsOutput(command.options);
39+
const name = `${command.name}${options.length ? " [options]" : ""}`;
40+
const description = command.description;
41+
42+
const maxLength = Math.max(name.length, options.maxLength);
43+
if (maxLength > acc.maxLength) {
44+
acc.maxLength = maxLength;
45+
}
46+
47+
acc.commands.push({
48+
description,
49+
name,
50+
options,
51+
});
52+
return acc;
53+
},
54+
{
55+
commands: [],
56+
maxLength: maxOptionLength,
57+
},
58+
);
59+
60+
const generateOptionsTextOutput = (options, maxLength, spaces) =>
61+
options
62+
.map((option) => {
63+
const spacesText = Array(spaces).fill(" ").join("");
64+
const leftStr = `${spacesText}${option.flags.padEnd(maxLength, " ")} `;
65+
const leftStrFiller = Array(leftStr.length).fill(" ").join("");
66+
const descriptionLines = option.description.split("\n");
67+
68+
return (
69+
leftStr +
70+
descriptionLines
71+
.map((line, i) => {
72+
if (i === 0) {
73+
return line;
74+
}
75+
76+
return `\n${leftStrFiller}${line}`;
77+
})
78+
.join("")
79+
);
80+
})
81+
.join("\n");
82+
83+
const optionsOutput = generateOptionsTextOutput(options, maxOptionLength, 2);
84+
85+
const commandsOutput = commandLabels
86+
.map((commandLabel) => {
87+
const leftStr = ` ${commandLabel.name.padEnd(maxCommandLength, " ")} `;
88+
const leftStrFiller = Array(leftStr.length).fill(" ").join("");
89+
const descriptionLines = commandLabel.description.split("\n");
90+
const optionsTextOutput = generateOptionsTextOutput(commandLabel.options.options, maxCommandLength, 4);
91+
92+
return (
93+
leftStr +
94+
descriptionLines
95+
.map((line, i) => {
96+
if (i === 0) {
97+
return line;
98+
}
99+
100+
return `\n${leftStrFiller}${line}`;
101+
})
102+
.join("") +
103+
(optionsTextOutput.length ? `\n${optionsTextOutput}` : "")
104+
);
105+
})
106+
.join("\n");
107+
108+
const outputTest = [
109+
optionsOutput &&
110+
`Options:
111+
${optionsOutput}`,
112+
commandsOutput &&
113+
`Commands:
114+
${commandsOutput}`,
115+
]
116+
.filter(Boolean)
117+
.join("\n\n");
118+
119+
console.log(`Usage: ${[instance.input.name, instance.input.alias].filter(Boolean).join("|")}${
120+
optionsOutput ? " [options]" : ""
121+
}${commandsOutput ? " [command]" : ""}
122+
${
123+
instance.input.description &&
124+
`
125+
${instance.input.description}`
126+
}
127+
128+
${outputTest}`);
129+
};
130+
131+
module.exports = {
132+
displayHelp,
133+
};

cli/operations/display-version.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const displayVersion = (instance) => {
2+
console.log(instance.input.version);
3+
};
4+
5+
module.exports = { displayVersion };

0 commit comments

Comments
 (0)