yargs-file-commands 0.0.1

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 ADDED
@@ -0,0 +1,9 @@
1
+ # MIT LICENSE
2
+
3
+ Copyright 2024, Ben Houston
4
+
5
+ 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:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ 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.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # Yargs File Commands
2
+
3
+ [![NPM Package][npm]][npm-url]
4
+ [![Build Size][build-size]][build-size-url]
5
+ [![NPM Downloads][npm-downloads]][npmtrends-url]
6
+
7
+ This Yargs helper function lets you define all your commands as individual files and their file names and directory structure defines via implication your nested command structure.
8
+
9
+ Supports both JavaScript and TypeScript (on Node 22+.)
10
+
11
+ ## Installation
12
+
13
+ _NOTE: This is an ESM-only package._
14
+
15
+ ```sh
16
+ npm install yargs-file-commands
17
+ ```
18
+
19
+ ## Example
20
+
21
+ ```ts
22
+ import { createRequire } from 'module';
23
+ import path from 'path';
24
+ import yargs from 'yargs';
25
+ import { fileCommands } from 'yargs-file-commands';
26
+
27
+ const require = createRequire(import.meta.url);
28
+
29
+ export const main = async () => {
30
+ const rootCommandDir = path.join(
31
+ import.meta.url.replace('file://', ''),
32
+ '../commands'
33
+ );
34
+
35
+ return yargs(process.argv)
36
+ .command(await fileCommands({ rootDirs: [rootCommandDir] }))
37
+ .help().argv;
38
+ };
39
+ ```
40
+
41
+ You can use any combination of file names and directories. We support either [NextJS](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes) or [Remix](https://remix.run/docs/en/main/file-conventions/routes) conventions for interpreting filenames and directories.
42
+
43
+ ```
44
+ /commands
45
+ ├── db
46
+ │ ├── migration
47
+ │ │ └── command.ts // the "db migration" command
48
+ │ └── health.ts // the "db health" command
49
+ └── studio.start.ts // the "studio start" command
50
+ ```
51
+
52
+ Inside each route handler file, you make the default export the route handler. Here is a simple example:
53
+
54
+ ```ts
55
+ // commands/studio.start.ts
56
+
57
+ import type { ArgumentsCamelCase, Argv } from 'yargs';
58
+ import type { BaseOptions } from '../options.js';
59
+
60
+ export interface Options extends BaseOptions {
61
+ port?: number;
62
+ }
63
+
64
+ export const describe = 'Studio web interface';
65
+
66
+ export const builder = (args: Argv): Argv<Options> => {
67
+ const result = args.option('port', {
68
+ alias: 'p',
69
+ type: 'number',
70
+ describe: 'Port to listen on'
71
+ });
72
+ return result;
73
+ };
74
+
75
+ export const handler = async (args: ArgumentsCamelCase<Options>) => {
76
+ const config = await getConfig();
77
+ // Implementation
78
+ };
79
+ ```
80
+
81
+ The above will result in these commands being registered:
82
+
83
+ ```
84
+ db migration
85
+ db health
86
+ studio start
87
+ ```
88
+
89
+ ## Options
90
+
91
+ The "fileCommands" method takes the following options:
92
+
93
+ **routesDirs**
94
+
95
+ - An array of directories where the routes are located relative to the build root folder.
96
+ - Required
97
+
98
+ **extensions**
99
+
100
+ - An array of file extensions for the route files. Files without matching extensions are ignored
101
+ - Default: `[".js", ".ts"]`
102
+
103
+ ** ignorePatterns?: RegExp[];
104
+ **
105
+
106
+ - An array of regexs which if matched against a filename or directory, lead it to being ignored/skipped over.
107
+ - Default: `[ /^[\.|_].*/, /\.(test|spec)\.[jt]s$/, /__(test|spec)__/, /\.d\.ts$/ ]`
108
+
109
+ **logLevel**
110
+
111
+ - The verbosity level for the plugin, either `debug` or `info`
112
+ - Default: `"info"`
113
+
114
+ ## Plugin Development (for Contributors only)
115
+
116
+ If you want to contribute, just check out [this git project](https://github.com/bhouston/yargs-file-commands) and run the following commands to get going:
117
+
118
+ ```sh
119
+ # install dependencies
120
+ npm install
121
+
122
+ # build everything
123
+ npm run build
124
+
125
+ # prettify
126
+ npm run format
127
+
128
+ # eslint
129
+ npm run lint
130
+
131
+ # build and run tests
132
+ npm run test
133
+
134
+ # clean everything, should be like doing a fresh git checkout of the repo.
135
+ npm run clean
136
+
137
+ # publish the npm package
138
+ npm run publish
139
+
140
+ # run example cli
141
+ npx example-cli
142
+ ```
@@ -0,0 +1 @@
1
+ export * from './lib/fileCommands.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './lib/fileCommands.js';
@@ -0,0 +1,6 @@
1
+ import type { CommandModule } from 'yargs';
2
+ export interface Command {
3
+ fullPath: string;
4
+ segments: string[];
5
+ commandModule: CommandModule;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { CommandModule } from 'yargs';
2
+ import type { Command } from './Command';
3
+ type CommandTreeNode = {
4
+ segmentName: string;
5
+ } & ({
6
+ type: 'internal';
7
+ children: CommandTreeNode[];
8
+ } | {
9
+ type: 'leaf';
10
+ command: Command;
11
+ });
12
+ export declare const buildSegmentTree: (commands: Command[]) => CommandTreeNode[];
13
+ export declare const createCommand: (treeNode: CommandTreeNode) => CommandModule;
14
+ export {};
@@ -0,0 +1,65 @@
1
+ export const buildSegmentTree = (commands) => {
2
+ const rootTreeNodes = [];
3
+ for (const command of commands) {
4
+ insertIntoTree(rootTreeNodes, command, 0);
5
+ }
6
+ return rootTreeNodes;
7
+ };
8
+ function insertIntoTree(treeNodes, command, depth) {
9
+ // If we've processed all segments, we shouldn't be here
10
+ if (depth >= command.segments.length) {
11
+ return;
12
+ }
13
+ const currentSegmentName = command.segments[depth];
14
+ let currentSegment = treeNodes.find((s) => s.segmentName === currentSegmentName);
15
+ // If this is the last segment, create a leaf node
16
+ if (depth === command.segments.length - 1) {
17
+ if (currentSegment == null) {
18
+ treeNodes.push({
19
+ type: 'leaf',
20
+ segmentName: currentSegmentName,
21
+ command
22
+ });
23
+ }
24
+ else if (currentSegment.type === 'internal') {
25
+ throw new Error(`Conflict: ${currentSegmentName} is both a directory and a command`);
26
+ }
27
+ return;
28
+ }
29
+ // Creating or ensuring we have an internal node
30
+ if (currentSegment == null) {
31
+ currentSegment = {
32
+ type: 'internal',
33
+ segmentName: currentSegmentName,
34
+ children: []
35
+ };
36
+ treeNodes.push(currentSegment);
37
+ }
38
+ else if (currentSegment.type === 'leaf') {
39
+ throw new Error(`Conflict: ${currentSegmentName} is both a directory and a command`);
40
+ }
41
+ // Recurse into children
42
+ insertIntoTree(currentSegment.children, command, depth + 1);
43
+ }
44
+ export const createCommand = (treeNode) => {
45
+ if (treeNode.type === 'leaf') {
46
+ return treeNode.command.commandModule;
47
+ }
48
+ const name = treeNode.segmentName;
49
+ // For internal nodes, create a command that registers all children
50
+ const command = {
51
+ command: name,
52
+ describe: `${name} commands`,
53
+ builder: (yargs) => {
54
+ // Register all child segments as subcommands
55
+ yargs.command(treeNode.children.map((child) => createCommand(child)));
56
+ // Demand a subcommand unless we're at the root
57
+ yargs.demandCommand(1, `You must specify a ${name} subcommand`);
58
+ return yargs;
59
+ },
60
+ handler: async () => {
61
+ // Internal nodes don't need handlers as they'll demand subcommands
62
+ }
63
+ };
64
+ return command;
65
+ };
@@ -0,0 +1,8 @@
1
+ export type FileCommandsOptions = {
2
+ rootDirs: string[];
3
+ extensions?: string[];
4
+ ignorePatterns?: RegExp[];
5
+ logLevel?: 'info' | 'debug';
6
+ };
7
+ export declare const DefaultFileCommandsOptions: Partial<FileCommandsOptions>;
8
+ export declare const fileCommands: (options: FileCommandsOptions) => Promise<import("yargs").CommandModule<{}, {}>[]>;
@@ -0,0 +1,33 @@
1
+ import { buildSegmentTree, createCommand } from './buildSegmentTree.js';
2
+ import { importCommandFromFile } from './importCommand.js';
3
+ import { scanDirectory } from './scanDirectory.js';
4
+ import { segmentPath } from './segmentPath.js';
5
+ export const DefaultFileCommandsOptions = {
6
+ extensions: ['.js', '.ts'],
7
+ ignorePatterns: [
8
+ /^[\.|_].*/,
9
+ /\.(test|spec)\.[jt]s$/,
10
+ /__(test|spec)__/,
11
+ /\.d\.ts$/
12
+ ],
13
+ logLevel: 'info'
14
+ };
15
+ export const fileCommands = async (options) => {
16
+ const fullOptions = { ...DefaultFileCommandsOptions, ...options };
17
+ const commands = [];
18
+ await Promise.all(options.rootDirs.map(async (rootCommandDir) => {
19
+ const filePaths = await scanDirectory(rootCommandDir, fullOptions);
20
+ const rootDirCommands = await Promise.all(filePaths.map(async (filePath) => {
21
+ const segments = segmentPath(filePath, rootCommandDir);
22
+ return {
23
+ fullPath: filePath,
24
+ segments: segmentPath(filePath, rootCommandDir),
25
+ commandModule: await importCommandFromFile(filePath, segments[segments.length - 1])
26
+ };
27
+ }));
28
+ commands.push(...rootDirCommands);
29
+ }));
30
+ const commandRootNodes = buildSegmentTree(commands);
31
+ const rootCommands = commandRootNodes.map((node) => createCommand(node));
32
+ return rootCommands;
33
+ };
@@ -0,0 +1,18 @@
1
+ import { type ArgumentsCamelCase, type CommandBuilder, type CommandModule } from 'yargs';
2
+ export type CommandAlias = readonly string[] | string | undefined;
3
+ export type CommandName = readonly string[] | string | undefined;
4
+ export type CommandDeprecated = boolean | string | undefined;
5
+ export type CommandDescribe = string | false | undefined;
6
+ export type CommandHandler = (args: ArgumentsCamelCase<any>) => void | Promise<any>;
7
+ export interface FileCommandsParams {
8
+ rootDir: string;
9
+ }
10
+ export interface CommandImportModule {
11
+ alias?: CommandAlias;
12
+ builder?: CommandBuilder;
13
+ command?: CommandName;
14
+ deprecated?: CommandDeprecated;
15
+ describe?: CommandDescribe;
16
+ handler?: CommandHandler;
17
+ }
18
+ export declare const importCommandFromFile: (filePath: string, name: string) => Promise<CommandModule<{}, {}>>;
@@ -0,0 +1,15 @@
1
+ import {} from 'yargs';
2
+ export const importCommandFromFile = async (filePath, name) => {
3
+ const handlerModule = (await import(filePath));
4
+ return {
5
+ command: name,
6
+ describe: handlerModule.describe,
7
+ alias: handlerModule.alias,
8
+ builder: handlerModule.builder,
9
+ deprecated: handlerModule.deprecated,
10
+ handler: handlerModule.handler ??
11
+ (async (args) => {
12
+ // null implementation
13
+ })
14
+ };
15
+ };
@@ -0,0 +1,4 @@
1
+ export interface ScanDirectoryOptions {
2
+ ignorePatterns?: RegExp[];
3
+ }
4
+ export declare const scanDirectory: (dirPath: string, options?: ScanDirectoryOptions) => Promise<string[]>;
@@ -0,0 +1,31 @@
1
+ import { readdir, stat } from 'fs/promises';
2
+ import { join } from 'path';
3
+ export const scanDirectory = async (dirPath, options = {}) => {
4
+ const { ignorePatterns = [] } = options;
5
+ // Check if path should be ignored
6
+ const shouldIgnore = ignorePatterns.some((pattern) => pattern.test(dirPath));
7
+ if (shouldIgnore) {
8
+ return [];
9
+ }
10
+ try {
11
+ const entries = await readdir(dirPath);
12
+ const nestedFilesPromises = entries.map(async (entry) => {
13
+ // apply ignore pattern and early return if matched
14
+ const shouldIgnore = ignorePatterns.some((pattern) => pattern.test(entry));
15
+ if (shouldIgnore) {
16
+ return [];
17
+ }
18
+ const fullPath = join(dirPath, entry);
19
+ const stats = await stat(fullPath);
20
+ if (stats.isDirectory()) {
21
+ return scanDirectory(fullPath, options);
22
+ }
23
+ return [fullPath];
24
+ });
25
+ const nestedFiles = await Promise.all(nestedFilesPromises);
26
+ return nestedFiles.flat();
27
+ }
28
+ catch (error) {
29
+ throw new Error(`Failed to scan directory ${dirPath}: ${error}`);
30
+ }
31
+ };
@@ -0,0 +1 @@
1
+ export declare const segmentPath: (fullPath: string, baseDir: string) => string[];
@@ -0,0 +1,15 @@
1
+ export const segmentPath = (fullPath, baseDir) => {
2
+ // Remove base directory and normalize slashes
3
+ const relativePath = fullPath.replace(baseDir, '').replace(/^[/\\]+/, '');
4
+ // Split into path segments and filename
5
+ const allSegments = relativePath.split(/[/\\]/);
6
+ // Process all segments including filename (without extension)
7
+ const processedSegments = allSegments
8
+ // Remove extension from the last segment (filename)
9
+ .map((segment, index, array) => index === array.length - 1 ? segment.replace(/\.[^/.]+$/, '') : segment)
10
+ // Split segments containing periods
11
+ .flatMap((segment) => segment.split('.'))
12
+ // Filter out empty segments and 'command'
13
+ .filter((segment) => segment !== '' && segment !== 'command');
14
+ return processedSegments;
15
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "yargs-file-commands",
3
+ "description": "A yargs helper function that lets you define your commands structure via directory and file naming conventions.",
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "source": "./src/index.ts",
9
+ "license": "MIT",
10
+ "author": "Ben Houston <neuralsoft@gmail.com> (https://benhouston3d.com)",
11
+ "keywords": [
12
+ "yargs",
13
+ "file-router",
14
+ "files",
15
+ "directory",
16
+ "automatic",
17
+ "commandDir",
18
+ "helper",
19
+ "nodejs",
20
+ "typescript"
21
+ ],
22
+ "homepage": "https://github.com/bhouston/yargs-file-commands#readme",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/bhouston/yargs-file-commands"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/bhouston/yargs-file-commands/issues"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "package.json",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "peerDependencies": {
37
+ "yargs": "^17"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.10.0",
41
+ "@types/yargs": "^17.0.33"
42
+ },
43
+ "publishConfig": {
44
+ "registry": "https://registry.npmjs.org/",
45
+ "access": "public"
46
+ },
47
+ "engines": {
48
+ "node": ">=20.0.0"
49
+ }
50
+ }