mixcli 3.0.1 → 3.0.3

Sign up to get free protection for your applications and to get access to all the features.
package/src/command.ts ADDED
@@ -0,0 +1,451 @@
1
+ import { Command, Option } from "commander";
2
+ import prompts, { PromptObject } from "prompts";
3
+ import { MixOption, type MixedOptionParams } from "./option";
4
+ import { addBuiltInOptions, isEnablePrompts, outputDebug } from "./utils";
5
+ import path from "node:path";
6
+ import fs from "node:fs";
7
+ import type { AsyncFunction } from "flex-tools";
8
+
9
+ export type ICommandHookListener = ({
10
+ args,
11
+ options,
12
+ command,
13
+ }: {
14
+ args: any[];
15
+ options: Record<string, any>;
16
+ command: MixCommand;
17
+ }) => void | Promise<void>;
18
+
19
+ export type BeforeCommandHookListener = ({
20
+ args,
21
+ options,
22
+ command,
23
+ }: {
24
+ args: any[];
25
+ options: Record<string, any>;
26
+ command: MixCommand;
27
+ }) => void | Promise<void>;
28
+ export type AfterCommandHookListener = ({
29
+ value,
30
+ args,
31
+ options,
32
+ command,
33
+ }: {
34
+ value: any;
35
+ args: any[];
36
+ options: Record<string, any>;
37
+ command: MixCommand;
38
+ }) => void | Promise<void>;
39
+
40
+ export interface ActionOptions {
41
+ id: string;
42
+ at: "replace" | "before" | "after" | "preappend" | "append" | number;
43
+ // 函数签名类型,即采用原始的commander的action函数签名,还是mixcli的action函数签名
44
+ enhance: boolean;
45
+ }
46
+
47
+ export interface ActionRegistry extends Omit<ActionOptions, "at"> {
48
+ fn: Function;
49
+ }
50
+
51
+ // 原始的Action动作函数
52
+ export type OriginalAction = (...args: any[]) => void | Promise<void>;
53
+ // 增强的Action函数签名
54
+ export type EnhanceAction = ({
55
+ args,
56
+ options,
57
+ value,
58
+ command,
59
+ }: {
60
+ args: any[];
61
+ options: Record<string, any>;
62
+ value: any;
63
+ command: MixCommand;
64
+ }) => void | Promise<any>;
65
+
66
+ // 执行action的返回结果
67
+ export const BREAK = Symbol("BREAK_ACTION"); // 中止后续的action执行
68
+
69
+ export class MixCommand extends Command {
70
+ private _beforeHooks: [BeforeCommandHookListener, boolean][] = [];
71
+ private _afterHooks: [AfterCommandHookListener, boolean][] = [];
72
+ private _customPrompts: PromptObject[] = [];
73
+ private _optionValues: Record<string, any> = {}; // 命令行输入的选项值
74
+ private _actions: ActionRegistry[] = []; // 允许多个action
75
+ private _enable_prompts: boolean = true; // 是否启用交互提示
76
+ constructor(name?: string) {
77
+ super(name);
78
+ const self = this;
79
+ if (!this.isRoot) addBuiltInOptions(this);
80
+ this.hook("preAction", async function (this: any) {
81
+ self._optionValues = self.getOptionValues(this.hookedCommand);
82
+ try {
83
+ // @ts-ignore
84
+ await self.preActionHook.apply(self, arguments);
85
+ } catch {}
86
+ });
87
+ }
88
+ /**
89
+ * 是否是根命令
90
+ */
91
+ get isRoot() {
92
+ return !!!this.parent;
93
+ }
94
+ get actions() {
95
+ return this._actions;
96
+ }
97
+ get beforeHooks() {
98
+ return this._beforeHooks;
99
+ }
100
+ get afterHooks() {
101
+ return this._afterHooks;
102
+ }
103
+ get fullname() {
104
+ let names = [this.name()];
105
+ let parent = this.parent;
106
+ while (parent) {
107
+ if (parent.name() !== "root") {
108
+ names.unshift(parent.name());
109
+ }
110
+ parent = parent.parent;
111
+ }
112
+ return names.join(".");
113
+ }
114
+
115
+ /**
116
+ * 返回根命令
117
+ */
118
+ root() {
119
+ let root: MixCommand | null | undefined = this;
120
+ while (root && root.parent != null) {
121
+ root = root.parent as unknown as MixCommand;
122
+ }
123
+ return root;
124
+ }
125
+ action(fn: EnhanceAction, options: ActionOptions): this;
126
+ action(fn: OriginalAction): this;
127
+ action(fn: OriginalAction): this {
128
+ const actionFunc = arguments[0];
129
+ if (arguments.length == 1 && typeof actionFunc == "function") {
130
+ // 原始方式
131
+ this._actions.push({
132
+ id: Math.random().toString(36).substring(2),
133
+ enhance: false,
134
+ fn: actionFunc,
135
+ });
136
+ } else if (
137
+ arguments.length == 2 &&
138
+ typeof actionFunc == "function" &&
139
+ typeof arguments[1] == "object"
140
+ ) {
141
+ // 增强模式
142
+ const actionFn = arguments[0];
143
+ const actionOpts: ActionOptions = Object.assign({ at: "append" }, arguments[1]);
144
+ if (actionOpts.at == "replace") this._actions = [];
145
+ const actionItem = {
146
+ id: actionOpts.id || Math.random().toString(36).substring(2),
147
+ enhance: actionOpts.enhance == undefined ? true : actionOpts.enhance,
148
+ fn: actionFn,
149
+ } as const;
150
+ if (typeof actionOpts.at == "number") {
151
+ this._actions.splice(Number(actionOpts.at), 0, actionItem);
152
+ } else if (["append", "before"].includes(actionOpts.at)) {
153
+ this._actions.push(actionItem);
154
+ } else if (["preappend", "after"].includes(actionOpts.at)) {
155
+ this._actions.splice(0, 0, actionItem);
156
+ } else {
157
+ this._actions.push(actionItem);
158
+ }
159
+ } else {
160
+ console.log("[mixcli] action params error");
161
+ }
162
+ return super.action(this.getWrapperedAction());
163
+ }
164
+
165
+ /**
166
+ * 读取命令配置值,包括父命令提供的配置选项
167
+ * @param command
168
+ */
169
+ private getOptionValues(command: Command) {
170
+ let opts = {};
171
+ let parent: Command | null = command;
172
+ while (parent) {
173
+ Object.assign(opts, (parent as MixCommand)._optionValues);
174
+ parent = parent.parent;
175
+ }
176
+ return opts;
177
+ }
178
+ /**
179
+ * 本函数在运行时子类进行action生成该命令的action
180
+ */
181
+ private getWrapperedAction() {
182
+ return this.wrapperWorkDirsAction(this.wrapperChainActions());
183
+ }
184
+
185
+ /**
186
+ * 向上查找所有祖先命令
187
+ */
188
+ private getAncestorCommands(): MixCommand[] {
189
+ let cmds: MixCommand[] = [];
190
+ let cmd: MixCommand | null = this;
191
+ while (cmd) {
192
+ cmd = cmd.parent as MixCommand;
193
+ if (cmd) {
194
+ cmds.push(cmd);
195
+ }
196
+ }
197
+ return cmds;
198
+ }
199
+ /***
200
+ * 将所有actions包装成一个链式调用的函数
201
+ */
202
+ private wrapperChainActions() {
203
+ const self = this;
204
+ return async function (this: any) {
205
+ const args = Array.from(arguments); // 原始输入的参数
206
+ let preValue: any; // 保存上一个action的返回值
207
+ //解析参数, 0-1个参数为options,最后一个参数为command
208
+ let actionOpts: Record<string, any> = {},
209
+ actionArgs: any[] = [],
210
+ cmd: any;
211
+ if (args.length >= 2) {
212
+ cmd = args[args.length - 1]; // 最后一个command
213
+ actionOpts = args[args.length - 2];
214
+ actionArgs = args.slice(0, args.length - 2);
215
+ }
216
+ await self.executeBeforeHooks({ args: actionArgs, options: actionOpts, command: cmd });
217
+ try {
218
+ for (let action of self._actions) {
219
+ try {
220
+ if (action.enhance) {
221
+ // 增强模式
222
+ outputDebug("执行<{}>: args={}, options={}", () => [
223
+ self.name(),
224
+ actionArgs,
225
+ actionOpts,
226
+ ]);
227
+ preValue = await action.fn.call(this, {
228
+ command: cmd,
229
+ value: preValue,
230
+ args: actionArgs,
231
+ options: actionOpts,
232
+ });
233
+ } else {
234
+ // 原始模式
235
+ preValue = await action.fn.apply(this, args);
236
+ }
237
+ if (preValue === BREAK) break;
238
+ } catch (e) {
239
+ outputDebug("命令{}的Action({})执行出错:{}", [self.name, action.id, e]);
240
+ throw e;
241
+ }
242
+ }
243
+ } finally {
244
+ await self.executeAfterHooks({
245
+ value: preValue,
246
+ args: actionArgs,
247
+ options: actionOpts,
248
+ command: cmd,
249
+ });
250
+ }
251
+ };
252
+ }
253
+ /**
254
+ * 当传入--work-dirs时用来处理工作目录
255
+ */
256
+ private wrapperWorkDirsAction(fn: AsyncFunction) {
257
+ const self = this;
258
+ return async function (this: any) {
259
+ let workDirs = self._optionValues.workDirs;
260
+ // 未指定工作目录参数
261
+ if (!workDirs) {
262
+ return await fn.apply(this, Array.from(arguments));
263
+ }
264
+ if (!Array.isArray(workDirs)) workDirs = workDirs.split(",");
265
+ workDirs = workDirs.reduce((dirs: any[], dir: string) => {
266
+ if (typeof dir == "string") dirs.push(...dir.split(","));
267
+ return dirs;
268
+ }, []);
269
+ for (let workDir of workDirs) {
270
+ const cwd = process.cwd();
271
+ try {
272
+ if (!path.isAbsolute(workDir)) workDir = path.join(cwd, workDir);
273
+ if (fs.existsSync(workDir) && fs.statSync(workDir).isDirectory()) {
274
+ outputDebug("切换到工作目录:{}", workDir);
275
+ process.chdir(workDir); // 切换
276
+ await fn.apply(this, Array.from(arguments));
277
+ } else {
278
+ outputDebug("无效的工作目录:{}", workDir);
279
+ }
280
+ } catch (e) {
281
+ throw e;
282
+ } finally {
283
+ process.chdir(cwd);
284
+ }
285
+ }
286
+ };
287
+ }
288
+ getOption(name: string): MixOption {
289
+ return this.options.find((option) => option.name() == name) as unknown as MixOption;
290
+ }
291
+ /**
292
+ * 添加一个Before钩子
293
+ * @param listener
294
+ * @param scope =false时代表只在本命令执行,=true时代表在本命令及其子命令执行
295
+ * @returns
296
+ */
297
+ before(listener: BeforeCommandHookListener, scope: boolean = true) {
298
+ this._beforeHooks.push([listener, scope]);
299
+ return this;
300
+ }
301
+ private async executeBeforeHooks(args: any) {
302
+ const hooks: [BeforeCommandHookListener, boolean, MixCommand][] = this.beforeHooks.map(
303
+ ([hook, scope]) => [hook, scope, this]
304
+ );
305
+ this.getAncestorCommands().forEach((cmd: MixCommand) => {
306
+ hooks.unshift(
307
+ ...cmd.beforeHooks.map(([hook, scope]) => {
308
+ return [hook, scope, cmd] as [BeforeCommandHookListener, boolean, MixCommand];
309
+ })
310
+ );
311
+ });
312
+ for (let [hook, scope, cmd] of hooks) {
313
+ if (!scope) continue;
314
+ await hook.call(cmd, args);
315
+ }
316
+ }
317
+ /**
318
+ * 添加一个After钩子
319
+ * @param listener
320
+ * @param scope =false时代表只在本命令执行,=true时代表在本命令及其子命令执行
321
+ * @returns
322
+ */
323
+ after(listener: AfterCommandHookListener, scope: boolean = true) {
324
+ this._afterHooks.push([listener, scope]);
325
+ return this;
326
+ }
327
+ private async executeAfterHooks(args: any) {
328
+ const hooks: [AfterCommandHookListener, boolean, MixCommand][] = this.afterHooks.map(
329
+ ([hook, scope]) => [hook, scope, this]
330
+ );
331
+ this.getAncestorCommands().forEach((cmd: MixCommand) => {
332
+ hooks.push(
333
+ ...cmd.afterHooks.map(([hook, scope]) => {
334
+ return [hook, scope, cmd] as [BeforeCommandHookListener, boolean, MixCommand];
335
+ })
336
+ );
337
+ });
338
+ for (let [hook, scope, cmd] of hooks) {
339
+ if (!scope) continue; //=false时不执行
340
+ await hook.call(cmd, args);
341
+ }
342
+ }
343
+ private async preActionHook(thisCommand: Command, actionCommand: Command) {
344
+ if (this.isEnablePrompts()) {
345
+ // 自动生成提示
346
+ const questions: PromptObject[] = [
347
+ ...this.generateAutoPrompts(),
348
+ ...this._customPrompts,
349
+ ];
350
+ // 用户提示
351
+ if (questions.length > 0) {
352
+ const results = await prompts(questions);
353
+ Object.entries(results).forEach(([key, value]) => {
354
+ thisCommand.setOptionValue(key, value);
355
+ });
356
+ }
357
+ }
358
+ }
359
+
360
+ private isEnablePrompts() {
361
+ if (isEnablePrompts() === false) {
362
+ // 命令行参数禁用了提示,优先级最高
363
+ return false;
364
+ } else {
365
+ return this._enable_prompts;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * 生成选项自动提示
371
+ *
372
+ * @remarks
373
+ * FlexCli要求所有未提供默认值的Option自动生成提示
374
+ *
375
+ * - 未提供默认值,并且是必选的参数Option
376
+ * - 指定了choices但未提供有效值的Option
377
+ *
378
+ */
379
+ private generateAutoPrompts(): PromptObject[] {
380
+ const options = this.options as unknown as MixOption[];
381
+ const optionPromports = options
382
+ .filter((option) => !option.hidden && option instanceof MixOption)
383
+ .map((option) => option.getPrompt(this._optionValues[option.name()]))
384
+ .filter((prompt) => prompt) as PromptObject[];
385
+ outputDebug("命令<{}>自动生成{}个选项提示:{}", [
386
+ this.name(),
387
+ optionPromports.length,
388
+ optionPromports.map((prompt) => `${prompt.name}(${prompt.type})`).join(","),
389
+ ]);
390
+ return optionPromports;
391
+ }
392
+ option(flags: string, description?: string | undefined, defaultValue?: any): this;
393
+ option(flags: string, description?: string | undefined, options?: MixedOptionParams): this {
394
+ // @ts-ignore
395
+ const option = new MixOption(...arguments);
396
+ if (option.required && !this.isEnablePrompts()) option.mandatory = true;
397
+ return this.addOption(option as unknown as Option);
398
+ }
399
+
400
+ /**
401
+ * 添加提示
402
+ *
403
+ * @remarks
404
+ *
405
+ * 添加一些自定义提示
406
+ *
407
+ *
408
+ * @param questions
409
+ * @param show 是否显示提示信息,auto表示只有在用户没有提供option的值时才显示提示信息,always表示总是显示提示信息,never表示不显示提示信息
410
+ * @returns
411
+ */
412
+ prompt(questions: PromptObject | PromptObject[]) {
413
+ this._customPrompts.push(...(Array.isArray(questions) ? questions : [questions]));
414
+ return this;
415
+ }
416
+ /**
417
+ *
418
+ * 选择命令并执行
419
+ *
420
+ * @remorks
421
+ *
422
+ * 当命令具有多个子命令时,并且没有提供默认子命令时,提示用户选择一个子命令
423
+ *
424
+ */
425
+ async selectCommands() {
426
+ const choices = this.commands.map((command) => ({
427
+ title: `${command.description()}(${command.name()})`,
428
+ value: command.name(),
429
+ }));
430
+ const result = await prompts({
431
+ type: "select",
432
+ name: "command",
433
+ message: "请选择命令:",
434
+ choices,
435
+ });
436
+ // 重新解析命令行参数标志,
437
+ const command = this.commands.find((command) => command.name() === result.command);
438
+ await command?.parseAsync([result.command], { from: "user" });
439
+ }
440
+ /**
441
+ * 禁用/启用所有提示
442
+ */
443
+ disablePrompts() {
444
+ this._enable_prompts = false;
445
+ return this;
446
+ }
447
+ enablePrompts() {
448
+ this._enable_prompts = true;
449
+ return this;
450
+ }
451
+ }
package/src/finder.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { getPackageRootPath } from 'flex-tools';
2
+ import type { MixCli } from './cli';
3
+ import { globSync } from 'glob'
4
+ import { MixCliCommand } from './cli';
5
+ import { isDebug, outputDebug } from './utils';
6
+ import fs from "node:fs"
7
+ import path from "node:path"
8
+ import { getPackageJson } from "flex-tools/package/getPackageJson"
9
+ import { fileURLToPath } from 'node:url';
10
+ import { createRequire } from 'node:module';
11
+
12
+
13
+
14
+ /**
15
+ *
16
+ * 在当前工程中查找符合FlexCli.prefix约定的命令
17
+ *
18
+ * - 读取当前包的package.json
19
+ * - 找出所有以cli.prefix开头的依赖
20
+ * - 加载这些依赖的目录下的匹配cli.pattern的命令
21
+ * - 加载加载这样命令
22
+ *
23
+ */
24
+
25
+
26
+ export function getMatchedDependencies(this:MixCli,entry:string):string[]{
27
+ const pacakgeMacher = this.options.include
28
+ if(!(pacakgeMacher instanceof RegExp)) return []
29
+
30
+ // 找出当前包的所有依赖
31
+ const { dependencies={},devDependencies={},peerDependencies={},optionalDependencies={},bundleDependencies={} } = getPackageJson(entry)
32
+ const packageNames = [
33
+ ...Object.keys(dependencies),
34
+ ...Object.keys(devDependencies),
35
+ ...Object.keys(peerDependencies),
36
+ ...Object.keys(optionalDependencies),
37
+ ...Object.keys(bundleDependencies)
38
+ ]
39
+ return packageNames.filter(name=>name!=="@voerka/cli" && pacakgeMacher.test(name))
40
+ }
41
+
42
+ function isMatched(str:string,reg?:string | RegExp | string[] | RegExp[]):boolean{
43
+ // let regexps:RegExp[]=[]
44
+ const regexps = reg ? (Array.isArray(reg) ? reg : [reg]) : []
45
+ return regexps.some(regexp=>{
46
+ if(typeof regexp === "string"){
47
+ return (new RegExp(regexp)).test(str)
48
+ }else if(regexp instanceof RegExp){
49
+ return regexp.test(str)
50
+ }else{
51
+ return false
52
+ }
53
+ })
54
+ }
55
+
56
+ export function findCliPaths(this:MixCli,packageName?:string ,entry?:string):string[]{
57
+ const includeMacher = this.options.include
58
+ const excludeMacher = this.options.exclude
59
+ if(!includeMacher) return []
60
+ const packageRoot = getPackageRootPath(entry || process.cwd())
61
+ const packagePath = packageName ? path.dirname(require.resolve(packageName,{paths:[packageRoot as string]})) : packageName!
62
+
63
+ // 找出当前包的所有依赖
64
+ const packageNames = getMatchedDependencies.call(this,packagePath)
65
+
66
+ const cliDirs:string[]=[]
67
+
68
+ if(entry!==undefined) cliDirs.push(path.join(packagePath,this.options.cliDir))
69
+ packageNames.filter(name=>{
70
+ return isMatched(name,includeMacher) && !isMatched(name,excludeMacher)
71
+ })
72
+ .forEach(name=>{
73
+ outputDebug("匹配包:{}",`${packageName ? name+" <- "+packageName : name}`)
74
+ try{
75
+ const packageEntry = path.dirname(require.resolve(name,{paths:packagePath ? [packagePath] : [process.cwd()]}))
76
+ const packageCliDir =path.join(packageEntry,this.options.cliDir!)
77
+ // 查找当前包的所属工程的依赖
78
+ let dependencies = getMatchedDependencies.call(this,packageEntry)
79
+ cliDirs.push(...dependencies.reduce<string[]>((result,dependencie)=>{
80
+ outputDebug("匹配包:{}",`${dependencie} <- ${name}`)
81
+ result.push(...findCliPaths.call(this,dependencie,packageEntry))
82
+ return result
83
+ },[]))
84
+ if(fs.existsSync(packageCliDir)){
85
+ cliDirs.push(packageCliDir)
86
+ }
87
+ }catch(e:any){
88
+ outputDebug("解析包<{}>路径出错:{}",[name,e.stack])
89
+ }
90
+ })
91
+ // 由于一些包可能存在循环依赖,所以需要去重
92
+ return [...new Set(cliDirs)]
93
+ }
94
+
95
+
96
+ function showError(e:any){
97
+ if(isDebug()){
98
+ outputDebug("导入命令<>出错:{}",e.stack)
99
+ }else{
100
+ console.error(e)
101
+ }
102
+
103
+ }
104
+
105
+
106
+ async function importModule(file:string){
107
+ let module
108
+ try{
109
+ module = require(file)
110
+ }catch(e:any){
111
+ try{
112
+ const cmd = await import(`file://${file}`)
113
+ module = cmd.default
114
+ }catch(e:any){
115
+ throw e
116
+ }
117
+ }
118
+ return module
119
+ }
120
+
121
+ /**
122
+ *
123
+ * 扫描当前工程中所有符合条件的命令
124
+ *
125
+ * @param cli
126
+ *
127
+ */
128
+ export async function findCommands(cli:MixCli){
129
+ const cliDirs = findCliPaths.call(cli)
130
+ const commands:MixCliCommand[] = []
131
+ const files = [] as string[]
132
+ cliDirs.forEach(dir=>{
133
+ globSync("*",{
134
+ cwd:dir,
135
+ absolute :true
136
+ }).forEach((file:string)=>{
137
+ const ext = path.extname(file).toLowerCase()
138
+ if([".js",".cjs",".mjs"].includes(ext)){
139
+ files.push(file)
140
+ }else if(fs.statSync(file).isDirectory()){
141
+ files.push(path.join(file,"index.js"))
142
+ files.push(path.join(file,"index.cjs"))
143
+ files.push(path.join(file,"index.mjs"))
144
+ }
145
+ })
146
+ })
147
+ for(let file of files){
148
+ if(!fs.existsSync(file)) continue
149
+ try{
150
+ outputDebug("导入命令:{}",file)
151
+ if(file.endsWith(".cjs") || file.endsWith(".js")){
152
+ commands.push(await importModule(file))
153
+ }else if(file.endsWith(".mjs")){
154
+ const cmd = await import(`file://${file}`)
155
+ commands.push(cmd.default)
156
+ }
157
+ }catch(e:any){
158
+ outputDebug(e)
159
+ }
160
+ }
161
+ return commands
162
+ }
163
+
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./cli"
2
+ export * from "./utils"
3
+ export * from "./command"
4
+ export * from "./option"