diff --git a/Readme.md b/Readme.md index 4cc640b94..85b7e02af 100644 --- a/Readme.md +++ b/Readme.md @@ -32,7 +32,8 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md) - [Custom help](#custom-help) - [Display help after errors](#display-help-after-errors) - [Display help from code](#display-help-from-code) - - [.usage and .name](#usage-and-name) + - [.name](#name) + - [.usage](#usage) - [.helpOption(flags, description)](#helpoptionflags-description) - [.addHelpCommand()](#addhelpcommand) - [More configuration](#more-configuration-2) @@ -66,7 +67,6 @@ This is used in the examples in this README for brevity. ```js const { program } = require('commander'); -program.version('0.0.1'); ``` For larger programs which may use commander in multiple ways, including unit testing, it is better to create a local Command object to use. @@ -74,7 +74,6 @@ For larger programs which may use commander in multiple ways, including unit tes ```js const { Command } = require('commander'); const program = new Command(); -program.version('0.0.1'); ``` For named imports in ECMAScript modules, import from `commander/esm.mjs`. @@ -93,7 +92,6 @@ import { Command } from 'commander'; const program = new Command(); ``` - ## Options Options are defined with the `.option()` method, also serving as documentation for the options. Each option can have a short flag (single character) and a long name, separated by a comma or space or vertical bar ('|'). @@ -550,8 +548,9 @@ pass more arguments than declared, but you can make this an error with `.allowEx ### Stand-alone executable (sub)commands When `.command()` is invoked with a description argument, this tells Commander that you're going to use stand-alone executables for subcommands. -Commander will search the executables in the directory of the entry script (like `./examples/pm`) with the name `program-subcommand`, like `pm-install`, `pm-search`. -You can specify a custom name with the `executableFile` configuration option. +Commander will search the files in the directory of the entry script for a file with the name combination `command-subcommand`, like `pm-install` or `pm-search` in the example below. The search includes trying common file extensions, like `.js`. +You may specify a custom name (and path) with the `executableFile` configuration option. +You may specify a custom search directory for subcommands with `.executableDir()`. You handle the options for an executable (sub)command in the executable, and don't declare them at the top-level. @@ -559,6 +558,7 @@ Example file: [pm](./examples/pm) ```js program + .name('pm') .version('0.1.0') .command('install [name]', 'install one or more packages') .command('search [query]', 'search with optional query') @@ -696,10 +696,25 @@ error: unknown option '--unknown' `.helpInformation()`: get the built-in command help information as a string for processing or displaying yourself. -### .usage and .name +### .name -These allow you to customise the usage description in the first line of the help. The name is otherwise -deduced from the (full) program arguments. Given: +The command name appears in the help, and is also used for locating stand-alone executable subcommands. + +You may specify the program name using `.name()` or in the Command constructor. For the program, Commander will +fallback to using the script name from the full arguments passed into `.parse()`. However, the script name varies +depending on how your program is launched so you may wish to specify it explicitly. + +```js +program.name('pizza'); +const pm = new Command('pm'); +``` + +Subcommands get a name when specified using `.command()`. If you create the subcommand yourself to use with `.addCommand()`, +then set the name using `.name()` or in the Command constructor. + +### .usage + +This allows you to customise the usage description in the first line of the help. Given: ```js program @@ -715,7 +730,7 @@ Usage: my-command [global options] command ### .helpOption(flags, description) -By default every command has a help option. Override the default help flags and description. Pass false to disable the built-in help option. +By default every command has a help option. You may change the default help flags and description. Pass false to disable the built-in help option. ```js program @@ -903,7 +918,6 @@ You can modify this behaviour for custom applications. In addition, you can modi Example file: [configure-output.js](./examples/configure-output.js) - ```js function errorColor(str) { // Add ANSI escape codes to display text in red. @@ -960,6 +974,7 @@ const { Command } = require('commander'); const program = new Command(); program + .name('deploy') .version('0.0.1') .option('-c, --config ', 'set config path', './deploy.conf'); diff --git a/Readme_zh-CN.md b/Readme_zh-CN.md index 6ab915f71..78cf4266c 100644 --- a/Readme_zh-CN.md +++ b/Readme_zh-CN.md @@ -65,7 +65,6 @@ npm install commander ```js const { program } = require('commander'); -program.version('0.0.1'); ``` 如果程序较为复杂,用户需要以多种方式来使用 Commander,如单元测试等。创建本地`Command`对象是一种更好的方式: @@ -73,7 +72,6 @@ program.version('0.0.1'); ```js const { Command } = require('commander'); const program = new Command(); -program.version('0.0.1'); ``` 要在 ECMAScript 模块中使用命名导入,可从`commander/esm.mjs`中导入。 @@ -270,6 +268,7 @@ $ collect --letter -n 1 -n 2 3 -- operand Options: { number: [ '1', '2', '3' ], letter: true } Remaining arguments: [ 'operand' ] ``` + 关于可能有歧义的用例,请见[可变参数的选项](./docs/zh-CN/%E5%8F%AF%E5%8F%98%E5%8F%82%E6%95%B0%E7%9A%84%E9%80%89%E9%A1%B9.md)。 ### 版本选项 @@ -534,6 +533,7 @@ Commander 将会尝试在入口脚本(例如`./examples/pm`)的目录中搜 ```js program + .name('pm') .version('0.1.0') .command('install [name]', 'install one or more packages') .command('search [query]', 'search with optional query') @@ -708,6 +708,7 @@ program.addHelpCommand('assist [command]', 'show assistance'); 内建帮助信息通过`Help`类进行格式化。如有需要,可以使用`.configureHelp()`来更改其数据属性和方法,或使用`.createHelp()`来创建子类,从而配置`Help`类的行为。 数据属性包括: + - `helpWidth`:指明帮助信息的宽度。可在单元测试中使用。 - `sortSubcommands`:以字母序排列子命令 - `sortOptions`:以字母序排列选项 @@ -862,7 +863,6 @@ Commander 默认用作命令行应用,其输出写入 stdout 和 stderr。 示例代码:[configure-output.js](./examples/configure-output.js) - ```js function errorColor(str) { // 添加 ANSI 转义字符,以将文本输出为红色 @@ -919,6 +919,7 @@ const { Command } = require('commander'); const program = new Command(); program + .name('deploy') .version('0.0.1') .option('-c, --config ', 'set config path', './deploy.conf'); diff --git a/examples/deploy b/examples/deploy index 9c769dfd0..d24b4b6ab 100755 --- a/examples/deploy +++ b/examples/deploy @@ -5,6 +5,7 @@ const { Command } = require('../'); // include commander in git clone of command const program = new Command(); program + .name('deploy') .version('0.0.1') .option('-c, --config ', 'set config path', './deploy.conf'); diff --git a/examples/pm b/examples/pm index fa838968e..262c36db8 100755 --- a/examples/pm +++ b/examples/pm @@ -5,6 +5,7 @@ const { Command } = require('../'); // include commander in git clone of command const program = new Command(); program + .name('pm') .version('0.0.1') .description('Fake package manager') .command('install [name]', 'install one or more packages').alias('i') diff --git a/examples/pm-install b/examples/pm-install index f4e6e9cfb..2648bd61c 100755 --- a/examples/pm-install +++ b/examples/pm-install @@ -17,7 +17,7 @@ if (!pkgs.length) { } console.log(); -if (program.force) console.log(' force: install'); +if (program.opts().force) console.log(' force: install'); pkgs.forEach(function(pkg) { console.log(' install : %s', pkg); }); diff --git a/lib/command.js b/lib/command.js index 4d080b172..fa7b9d535 100644 --- a/lib/command.js +++ b/lib/command.js @@ -39,6 +39,7 @@ class Command extends EventEmitter { this._actionHandler = null; this._executableHandler = false; this._executableFile = null; // custom name for executable + this._executableDir = null; // custom search directory for subcommands this._defaultCommandName = null; this._exitCallback = null; this._aliases = []; @@ -243,19 +244,10 @@ class Command extends EventEmitter { */ addCommand(cmd, opts) { - if (!cmd._name) throw new Error('Command passed to .addCommand() must have a name'); - - // To keep things simple, block automatic name generation for deeply nested executables. - // Fail fast and detect when adding rather than later when parsing. - function checkExplicitNames(commandArray) { - commandArray.forEach((cmd) => { - if (cmd._executableHandler && !cmd._executableFile) { - throw new Error(`Must specify executableFile for deeply nested executable: ${cmd.name()}`); - } - checkExplicitNames(cmd.commands); - }); + if (!cmd._name) { + throw new Error(`Command passed to .addCommand() must have a name +- specify the name in Command constructor or using .name()`); } - checkExplicitNames(cmd.commands); opts = opts || {}; if (opts.isDefault) this._defaultCommandName = cmd._name; @@ -768,8 +760,8 @@ Expecting one of '${allowedValues.join("', '")}'`); }; /** - * Get user arguments implied or explicit arguments. - * Side-effects: set _scriptPath if args included application, and use that to set implicit command name. + * Get user arguments from implied or explicit arguments. + * Side-effects: set _scriptPath if args included script. Used for default program name, and subcommand searches. * * @api private */ @@ -813,12 +805,10 @@ Expecting one of '${allowedValues.join("', '")}'`); default: throw new Error(`unexpected parse option { from: '${parseOptions.from}' }`); } - if (!this._scriptPath && require.main) { - this._scriptPath = require.main.filename; - } - // Guess name, used in usage in help. - this._name = this._name || (this._scriptPath && path.basename(this._scriptPath, path.extname(this._scriptPath))); + // Find default name for program from arguments. + if (!this._name && this._scriptPath) this.nameFromFilename(this._scriptPath); + this._name = this._name || 'program'; return userArgs; } @@ -884,71 +874,82 @@ Expecting one of '${allowedValues.join("', '")}'`); let launchWithNode = false; // Use node for source targets so do not need to get permissions correct, and on Windows. const sourceExt = ['.js', '.ts', '.tsx', '.mjs', '.cjs']; - // Not checking for help first. Unlikely to have mandatory and executable, and can't robustly test for help flags in external command. - this._checkForMissingMandatoryOptions(); + function findFile(baseDir, baseName) { + // Look for specified file + const localBin = path.resolve(baseDir, baseName); + if (fs.existsSync(localBin)) return localBin; - // Want the entry script as the reference for command name and directory for searching for other files. - let scriptPath = this._scriptPath; - // Fallback in case not set, due to how Command created or called. - if (!scriptPath && require.main) { - scriptPath = require.main.filename; - } + // Stop looking if candidate already has an expected extension. + if (sourceExt.includes(path.extname(baseName))) return undefined; - let baseDir; - try { - const resolvedLink = fs.realpathSync(scriptPath); - baseDir = path.dirname(resolvedLink); - } catch (e) { - baseDir = '.'; // dummy, probably not going to find executable! + // Try all the extensions. + const foundExt = sourceExt.find(ext => fs.existsSync(`${localBin}${ext}`)); + if (foundExt) return `${localBin}${foundExt}`; + + return undefined; } - // name of the subcommand, like `pm-install` - let bin = path.basename(scriptPath, path.extname(scriptPath)) + '-' + subcommand._name; - if (subcommand._executableFile) { - bin = subcommand._executableFile; + // Not checking for help first. Unlikely to have mandatory and executable, and can't robustly test for help flags in external command. + this._checkForMissingMandatoryOptions(); + + // executableFile and executableDir might be full path, or just a name + let executableFile = subcommand._executableFile || `${this._name}-${subcommand._name}`; + let executableDir = this._executableDir || ''; + if (this._scriptPath) { + let resolvedScriptPath; // resolve possible symlink for installed npm binary + try { + resolvedScriptPath = fs.realpathSync(this._scriptPath); + } catch (err) { + resolvedScriptPath = this._scriptPath; + } + executableDir = path.resolve(path.dirname(resolvedScriptPath), executableDir); } - const localBin = path.join(baseDir, bin); - if (fs.existsSync(localBin)) { - // prefer local `./` to bin in the $PATH - bin = localBin; - } else { - // Look for source files. - sourceExt.forEach((ext) => { - if (fs.existsSync(`${localBin}${ext}`)) { - bin = `${localBin}${ext}`; + // Look for a local file in preference to a command in PATH. + if (executableDir) { + let localFile = findFile(executableDir, executableFile); + + // Legacy search using prefix of script name instead of command name + if (!localFile && !subcommand._executableFile && this._scriptPath) { + const legacyName = path.basename(this._scriptPath, path.extname(this._scriptPath)); + if (legacyName !== this._name) { + localFile = findFile(executableDir, `${legacyName}-${subcommand._name}`); } - }); + } + executableFile = localFile || executableFile; } - launchWithNode = sourceExt.includes(path.extname(bin)); + + launchWithNode = sourceExt.includes(path.extname(executableFile)); let proc; if (process.platform !== 'win32') { if (launchWithNode) { - args.unshift(bin); + args.unshift(executableFile); // add executable arguments to spawn args = incrementNodeInspectorPort(process.execArgv).concat(args); proc = childProcess.spawn(process.argv[0], args, { stdio: 'inherit' }); } else { - proc = childProcess.spawn(bin, args, { stdio: 'inherit' }); + proc = childProcess.spawn(executableFile, args, { stdio: 'inherit' }); } } else { - args.unshift(bin); + args.unshift(executableFile); // add executable arguments to spawn args = incrementNodeInspectorPort(process.execArgv).concat(args); proc = childProcess.spawn(process.execPath, args, { stdio: 'inherit' }); } - const signals = ['SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGINT', 'SIGHUP']; - signals.forEach((signal) => { - // @ts-ignore - process.on(signal, () => { - if (proc.killed === false && proc.exitCode === null) { - proc.kill(signal); - } + if (!proc.killed) { // testing mainly to avoid leak warnings during unit tests with mocked spawn + const signals = ['SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGINT', 'SIGHUP']; + signals.forEach((signal) => { + // @ts-ignore + process.on(signal, () => { + if (proc.killed === false && proc.exitCode === null) { + proc.kill(signal); + } + }); }); - }); + } // By default terminate process when spawned process terminates. // Suppressing the exit if exitCallback defined is a bit messy and of limited use, but does allow process to stay running! @@ -963,13 +964,17 @@ Expecting one of '${allowedValues.join("', '")}'`); proc.on('error', (err) => { // @ts-ignore if (err.code === 'ENOENT') { - const executableMissing = `'${bin}' does not exist + const executableDirMessage = executableDir + ? `searched for local subcommand relative to directory '${executableDir}'` + : 'no directory for search for local subcommand, use .executableDir() to supply a custom directory'; + const executableMissing = `'${executableFile}' does not exist - if '${subcommand._name}' is not meant to be an executable command, remove description parameter from '.command()' and use '.description()' instead - - if the default executable name is not suitable, use the executableFile option to supply a custom name`; + - if the default executable name is not suitable, use the executableFile option to supply a custom name or path + - ${executableDirMessage}`; throw new Error(executableMissing); // @ts-ignore } else if (err.code === 'EACCES') { - throw new Error(`'${bin}' not executable`); + throw new Error(`'${executableFile}' not executable`); } if (!exitCallback) { process.exit(1); @@ -1601,7 +1606,7 @@ Expecting one of '${allowedValues.join("', '")}'`); }; /** - * Get or set the name of the command + * Get or set the name of the command. * * @param {string} [str] * @return {string|Command} @@ -1613,6 +1618,43 @@ Expecting one of '${allowedValues.join("', '")}'`); return this; }; + /** + * Set the name of the command from script filename, such as process.argv[1], + * or require.main.filename, or __filename. + * + * (Used internally and public although not documented in README.) + * + * @example + * program.nameFromFilename(require.main.filename); + * + * @param {string} filename + * @return {Command} + */ + + nameFromFilename(filename) { + this._name = path.basename(filename, path.extname(filename)); + + return this; + } + + /** + * Get or set the directory for searching for executable subcommands of this command. + * + * @example + * program.executableDir(__dirname); + * // or + * program.executableDir('subcommands'); + * + * @param {string} [path] + * @return {string|Command} + */ + + executableDir(path) { + if (path === undefined) return this._executableDir; + this._executableDir = path; + return this; + }; + /** * Return program help documentation. * diff --git a/tests/command.addCommand.test.js b/tests/command.addCommand.test.js index b20324c38..f8f86b682 100644 --- a/tests/command.addCommand.test.js +++ b/tests/command.addCommand.test.js @@ -51,15 +51,6 @@ test('when command without name passed to .addCommand then throw', () => { }).toThrow(); }); -test('when executable command without custom executableFile passed to .addCommand then throw', () => { - const program = new commander.Command(); - const cmd = new commander.Command('sub'); - cmd.command('exec', 'exec description'); - expect(() => { - program.addCommand(cmd); - }).toThrow(); -}); - test('when executable command with custom executableFile passed to .addCommand then ok', () => { const program = new commander.Command(); const cmd = new commander.Command('sub'); diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index e80c2b292..9c25a3db4 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -190,4 +190,10 @@ describe('Command methods that should return this for chaining', () => { const result = cmd.copyInheritedSettings(program); expect(result).toBe(cmd); }); + + test('when set .nameFromFilename() then returns this', () => { + const program = new Command(); + const result = program.nameFromFilename('name'); + expect(result).toBe(program); + }); }); diff --git a/tests/command.executableSubcommand.lookup.test.js b/tests/command.executableSubcommand.lookup.test.js index 419d0a537..7ff011c89 100644 --- a/tests/command.executableSubcommand.lookup.test.js +++ b/tests/command.executableSubcommand.lookup.test.js @@ -4,8 +4,10 @@ const util = require('util'); const execFileAsync = util.promisify(childProcess.execFile); // Calling node explicitly so pm works without file suffix cross-platform. +// This file does end-to-end tests actually spawning program. +// See also command.executableSubcommand.search.test.js -// Get false positives due to use of testOrSkipOnWindows +// Suppress false positive warnings due to use of testOrSkipOnWindows /* eslint-disable jest/no-standalone-expect */ const testOrSkipOnWindows = (process.platform === 'win32') ? test.skip : test; diff --git a/tests/command.executableSubcommand.search.test.js b/tests/command.executableSubcommand.search.test.js new file mode 100644 index 000000000..74180560e --- /dev/null +++ b/tests/command.executableSubcommand.search.test.js @@ -0,0 +1,269 @@ +const childProcess = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const commander = require('../'); + +// This file does in-process mocking. Bit clumsy but faster and less external clutter than using fixtures. +// See also command.executableSubcommand.lookup.test.js for tests using fixtures. + +const gLocalDirectory = path.resolve(__dirname, 'fixtures'); // Real directory, although not actually searching for files in it. + +function extractMockSpawnArgs(mock) { + expect(mock).toHaveBeenCalled(); + // non-Win, launchWithNode: childProcess.spawn(process.argv[0], args, { stdio: 'inherit' }); + // Win always: childProcess.spawn(process.execPath, args, { stdio: 'inherit' }); + return mock.mock.calls[0][1]; +} + +function extractMockSpawnCommand(mock) { + expect(mock).toHaveBeenCalled(); + // child_process.spawn(command[, args][, options]) + return mock.mock.calls[0][0]; +} + +const describeOrSkipOnWindows = (process.platform === 'win32') ? describe.skip : describe; + +describe('search for subcommand', () => { + let spawnSpy; + let existsSpy; + + beforeAll(() => { + spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(() => { + return { + on: () => {}, + killed: true + }; + }); + }); + + beforeEach(() => { + existsSpy = jest.spyOn(fs, 'existsSync'); + }); + + afterEach(() => { + spawnSpy.mockClear(); + existsSpy.mockRestore(); + }); + + afterAll(() => { + spawnSpy.mockRestore(); + }); + + describe('whether perform search for local files', () => { + beforeEach(() => { + existsSpy.mockImplementation(() => false); + }); + + test('when no script arg or executableDir then no search for local file', () => { + const program = new commander.Command(); + program.name('pm'); + program.command('sub', 'executable description'); + program.parse(['sub'], { from: 'user' }); + + expect(existsSpy).not.toHaveBeenCalled(); + }); + + test('when script arg then search for local files', () => { + const program = new commander.Command(); + program.name('pm'); + program.command('sub', 'executable description'); + program.parse(['node', 'script-name', 'sub']); + + expect(existsSpy).toHaveBeenCalled(); + }); + + test('when executableDir then search for local files)', () => { + const program = new commander.Command(); + program.name('pm'); + program.executableDir(__dirname); + program.command('sub', 'executable description'); + program.parse(['sub'], { from: 'user' }); + + expect(existsSpy).toHaveBeenCalled(); + }); + }); + + // We always use node on Windows, and don't spawn executable as the command (which may be a feature or a shortcoming!?). + describeOrSkipOnWindows('subcommand command name with no matching local file (non-Windows)', () => { + beforeEach(() => { + existsSpy.mockImplementation(() => false); + }); + + test('when named pm and no script arg or executableDir then spawn pm-sub as command', () => { + const program = new commander.Command(); + program.name('pm'); + program.command('sub', 'executable description'); + program.parse(['sub'], { from: 'user' }); + + expect(extractMockSpawnCommand(spawnSpy)).toEqual('pm-sub'); + }); + + test('when named pm and script arg then still spawn pm-sub as command', () => { + const program = new commander.Command(); + program.name('pm'); + program.command('sub', 'executable description'); + program.parse(['node', 'script-name', 'sub']); + + expect(extractMockSpawnCommand(spawnSpy)).toEqual('pm-sub'); + }); + + test('when no name and script arg then spawn script-sub as command', () => { + const program = new commander.Command(); + program.command('sub', 'executable description'); + program.parse(['node', 'script.js', 'sub']); + + expect(extractMockSpawnCommand(spawnSpy)).toEqual('script-sub'); + }); + + test('when named pm and script arg and executableFile then spawn executableFile as command', () => { + const program = new commander.Command(); + program.command('sub', 'executable description', { executableFile: 'myExecutable' }); + program.parse(['node', 'script.js', 'sub']); + + expect(extractMockSpawnCommand(spawnSpy)).toEqual('myExecutable'); + }); + }); + + describe('subcommand command name with matching local file', () => { + test('when construct with name pm and script arg then spawn local pm-sub.js', () => { + const program = new commander.Command('pm'); + program.command('sub', 'executable description'); + + const localPath = path.resolve(gLocalDirectory, 'pm-sub.js'); + existsSpy.mockImplementation((path) => path === localPath); + program.parse(['node', path.resolve(gLocalDirectory, 'script.js'), 'sub']); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([localPath]); + }); + + test('when name pm and script arg then spawn local pm-sub.js', () => { + const program = new commander.Command(); + program.name('pm'); + program.command('sub', 'executable description'); + + const localPath = path.resolve(gLocalDirectory, 'pm-sub.js'); + existsSpy.mockImplementation((path) => path === localPath); + program.parse(['node', path.resolve(gLocalDirectory, 'script.js'), 'sub']); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([localPath]); + }); + + test('when script arg then spawn local script-sub.js', () => { + const program = new commander.Command(); + program.command('sub', 'executable description'); + + const localPath = path.resolve(gLocalDirectory, 'script-sub.js'); + existsSpy.mockImplementation((path) => path === localPath); + program.parse(['node', path.resolve(gLocalDirectory, 'script.js'), 'sub']); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([localPath]); + }); + + test('when name pm and script arg and only script-sub.js then fallback to spawn local script-sub.js', () => { + const program = new commander.Command(); + program.name('pm'); + program.command('sub', 'executable description'); + + // Fallback for compatibility with Commander <= v8 + const localPath = path.resolve(gLocalDirectory, 'script-sub.js'); + existsSpy.mockImplementation((path) => path === localPath); + program.parse(['node', path.resolve(gLocalDirectory, 'script.js'), 'sub']); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([localPath]); + }); + + test('when name pm and executableDir then spawn local pm-sub.js', () => { + const program = new commander.Command(); + program.name('pm'); + program.command('sub', 'executable description'); + + const execDir = path.resolve(gLocalDirectory, 'exec-dir'); + program.executableDir(execDir); + const localPath = path.resolve(execDir, 'pm-sub.js'); + existsSpy.mockImplementation((path) => path === localPath); + program.parse(['sub'], { from: 'user' }); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([localPath]); + }); + + test('when script arg and relative executableDir then spawn relative script-sub.js', () => { + const program = new commander.Command(); + program.command('sub', 'executable description'); + + const execDir = 'exec-dir'; + program.executableDir(execDir); + const localPath = path.resolve(gLocalDirectory, execDir, 'script-sub.js'); + existsSpy.mockImplementation((path) => path === localPath); + program.parse(['node', path.resolve(gLocalDirectory, 'script'), 'sub']); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([localPath]); + }); + + test('when script arg and absolute executableDir then spawn absolute script-sub.js', () => { + const program = new commander.Command(); + program.command('sub', 'executable description'); + + const execDir = path.resolve(gLocalDirectory, 'exec-dir'); + program.executableDir(execDir); + const localPath = path.resolve(execDir, 'script-sub.js'); + existsSpy.mockImplementation((path) => path === localPath); + program.parse(['node', path.resolve(gLocalDirectory, 'script-Dir', 'script'), 'sub']); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([localPath]); + }); + + test('when script arg is link and and link-sub relative to link target then spawn local link-sub', () => { + const program = new commander.Command(); + program.command('sub', 'executable description'); + + const linkPath = path.resolve(gLocalDirectory, 'link', 'link'); + const scriptPath = path.resolve(gLocalDirectory, 'script', 'script.js'); + const scriptSubPath = path.resolve(gLocalDirectory, 'script', 'link-sub.js'); + const realPathSyncSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((path) => { + return path === linkPath ? scriptPath : linkPath; + }); + existsSpy.mockImplementation((path) => path === scriptSubPath); + program.parse(['node', linkPath, 'sub']); + realPathSyncSpy.mockRestore(); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([scriptSubPath]); + }); + + test('when name pm and script arg and relative executableFile then spawn local exec.js', () => { + const program = new commander.Command('pm'); + const localPath = path.join('relative', 'exec.js'); + const absolutePath = path.resolve(gLocalDirectory, localPath); + program.command('sub', 'executable description', { executableFile: localPath }); + + existsSpy.mockImplementation((path) => path === absolutePath); + program.parse(['node', path.resolve(gLocalDirectory, 'script.js'), 'sub']); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([absolutePath]); + }); + + test('when name pm and script arg and absolute executableFile then spawn local exec.js', () => { + const program = new commander.Command('pm'); + const localPath = path.resolve(gLocalDirectory, 'absolute', 'exec.js'); + program.command('sub', 'executable description', { executableFile: localPath }); + + existsSpy.mockImplementation((path) => path === localPath); + program.parse(['node', path.resolve(gLocalDirectory, 'script.js'), 'sub']); + + expect(extractMockSpawnArgs(spawnSpy)).toEqual([localPath]); + }); + }); + + describe('search for local file', () => { + test('when script arg then search for local script-sub.js, .ts, .tsx, .mpjs, .cjs', () => { + existsSpy.mockImplementation((path) => false); + const program = new commander.Command(); + program.command('sub', 'executable description'); + const scriptPath = path.resolve(gLocalDirectory, 'script'); + program.parse(['node', scriptPath, 'sub']); + const sourceExt = ['.js', '.ts', '.tsx', '.mjs', '.cjs']; + sourceExt.forEach((ext) => { + expect(existsSpy).toHaveBeenCalledWith(path.resolve(gLocalDirectory, `script-sub${ext}`)); + }); + }); + }); +}); diff --git a/tests/command.name.test.js b/tests/command.name.test.js index 290e32ffc..f0d39a0e1 100644 --- a/tests/command.name.test.js +++ b/tests/command.name.test.js @@ -1,5 +1,11 @@ +const path = require('path'); const commander = require('../'); +test('when construct with name then name is set', () => { + const program = new commander.Command('foo'); + expect(program.name()).toBe('foo'); +}); + test('when set program name and parse then name is as assigned', () => { const program = new commander.Command(); program.name('custom'); @@ -7,10 +13,16 @@ test('when set program name and parse then name is as assigned', () => { expect(program.name()).toBe('custom'); }); -test('when program name not set and parse then name is found from arguments', () => { +test('when program name not set and parse with script argument then plain name is found from script name', () => { const program = new commander.Command(); - program.parse(['node', 'test']); - expect(program.name()).toBe('test'); + program.parse(['node', path.resolve(process.cwd(), 'script.js')], { from: 'node' }); + expect(program.name()).toBe('script'); +}); + +test('when command name not set and no script argument in parse then name is program', () => { + const program = new commander.Command(); + program.parse([], { from: 'user' }); + expect(program.name()).toBe('program'); }); test('when add command then command is named', () => { @@ -26,3 +38,15 @@ test('when set program name then name appears in help', () => { const helpInformation = program.helpInformation(); expect(helpInformation).toMatch(/^Usage: custom-name/); }); + +test('when pass path to nameFromFilename then name is plain name', () => { + const program = new commander.Command(); + program.nameFromFilename(path.resolve(process.cwd(), 'foo.js')); + expect(program.name()).toBe('foo'); +}); + +test('when pass __filename to nameFromFilename then name is plain name of this file', () => { + const program = new commander.Command(); + program.nameFromFilename(__filename); + expect(program.name()).toBe('command.name.test'); +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index 2889e30e0..d6e9f5b5b 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -691,6 +691,39 @@ export class Command { name(): string; /** + * Set the name of the command from script filename, such as process.argv[1], + * or require.main.filename, or __filename. + * + * (Used internally and public although not documented in README.) + * + * @example + * ```ts + * program.nameFromFilename(require.main.filename); + * ``` + * + * @returns `this` command for chaining + */ + nameFromFilename(filename: string): this; + + /** + * Set the directory for searching for executable subcommands of this command. + * + * @example + * ```ts + * program.executableDir(__dirname); + * // or + * program.executableDir('subcommands'); + * ``` + * + * @returns `this` command for chaining + */ + executableDir(path: string): this; + /** + * Get the executable search directory. + */ + executableDir(): string; + + /** * Output help information for this command. * * Outputs built-in help, and custom text added using `.addHelpText()`. diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 237fb37ee..59a8c9d17 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -228,6 +228,13 @@ expectType(program.usage()); expectType(program.name('my-name')); expectType(program.name()); +// nameFromFilename +expectType(program.nameFromFilename(__filename)); + +// executableDir +expectType(program.executableDir(__dirname)); +expectType(program.executableDir()); + // outputHelp expectType(program.outputHelp()); expectType(program.outputHelp((str: string) => { return str; }));