Skip to content

Commit

Permalink
TypeScript support, allow missing index routes, and more (#31)
Browse files Browse the repository at this point in the history
  • Loading branch information
karaggeorge authored Aug 9, 2020
1 parent 0b883df commit a35417b
Show file tree
Hide file tree
Showing 18 changed files with 336 additions and 77 deletions.
132 changes: 88 additions & 44 deletions boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,50 +4,16 @@ const camelcaseKeys = require('camelcase-keys');
const decamelize = require('decamelize');
const yargs = require('yargs');

const getCommandsMessage = command => {
const usageString = [...command.usage, command.name].join(' ');
return `Commands:\n${command.subCommands.map(subCommand => ` ${usageString} ${subCommand.name}`).join('\n')}\n`;
};

module.exports = (dirname, React, Ink, commands) => {
const addCommand = (command, yargs) => {
// Yargs expects a special format for positional args when defining a command
// in order to correctly parse them, e.g. "<first-arg> <second-arg>"
const positionalArgs = command.args
.filter(arg => arg.positional)
.map(arg => `<${decamelize(arg.key, '-')}>`)
.join(' ');

const name = command.name === 'index' ? '*' : command.name;
const yargsName = positionalArgs.length > 0 ? `${name} ${positionalArgs}` : name;
const description = command.description || `${name} command`;

// Don't need to add a description as it'll be handled by the * selector in the builder
// eslint-disable-next-line no-use-before-define
yargs.command(yargsName, description, builder.bind(null, command), handler.bind(null, command));
};

const builder = (command, yargs) => {
for (const arg of command.args) {
// `inputArgs` is a reserved prop by Pastel and doesn't require a definition in yargs
if (arg.key === 'inputArgs') {
continue;
}

if (arg.positional) {
yargs.positional(decamelize(arg.key, '-'), {
type: arg.type,
description: arg.description,
default: arg.defaultValue
});
} else {
yargs.option(decamelize(arg.key, '-'), {
type: arg.type,
description: arg.description,
default: arg.defaultValue,
demandOption: arg.isRequired,
alias: arg.aliases.map(alias => decamelize(alias, '-'))
});
}
}

for (const subCommand of command.subCommands) {
addCommand(subCommand, yargs);
}
yargs.command(command.name, '', builder.bind(null, command), () => yargs.showHelp());
};

const handler = (command, argv) => {
Expand All @@ -64,9 +30,87 @@ module.exports = (dirname, React, Ink, commands) => {
});
};

for (const command of commands) {
addCommand(command, yargs);
}
const builder = (command, yargs) => {
const {
positionalArgs = [],
args,
description,
name,
subCommands,
isDefaultIndex,
usage
} = command;

for (const subCommand of subCommands) {
addCommand({
...subCommand,
usage: [...usage, name]
}, yargs);
}

// If there is no index defined, yargs will just list the sub-commands
if (isDefaultIndex) {
return;
}

const hasPositionalArgs = positionalArgs.length > 0;
const positionalArgsString = positionalArgs.map(key => {
const {isRequired, type, aliases} = args.find(arg => arg.key === key);
const [startTag, endTag] = isRequired ? ['<', '>'] : ['[', type === 'array' ? '..]' : ']'];
const argsNames = [key, ...aliases.slice(0, 1)].map(name => decamelize(name, '-')).join('|');

return `${startTag}${argsNames}${endTag}`;
}).join(' ');

const yargsName = hasPositionalArgs ? `* ${positionalArgsString}` : '*';
const commandDescription = description || `${name} command`;

yargs.command(yargsName, commandDescription, scopedYargs => {
for (const arg of command.args) {
// `inputArgs` is a reserved prop by Pastel and doesn't require a definition in yargs
if (arg.key === 'inputArgs') {
continue;
}

if (arg.positional) {
scopedYargs.positional(decamelize(arg.key, '-'), {
type: arg.type,
description: arg.description,
default: arg.defaultValue,
// This only keeps one for some reason for positional arguments
// The slice ensures we keep the same as the one we add in the command
alias: arg.aliases.slice(0, 1).map(alias => decamelize(alias, '-'))
});
} else {
scopedYargs.option(decamelize(arg.key, '-'), {
type: arg.type,
description: arg.description,
default: arg.defaultValue,
demandOption: arg.isRequired,
alias: arg.aliases.map(alias => decamelize(alias, '-'))
});
}
}

// If the index command takes no arguments and there are available sub-commands,
// list the sub-commands in the help message.
if (!hasPositionalArgs && subCommands.length > 0) {
const usageMessage = getCommandsMessage(command);
scopedYargs.demandCommand(0, 0, usageMessage, usageMessage);
}
}, handler.bind(null, command));
};

yargs.command(
'*', '',
yargs => {
const usage = [path.basename(yargs.$0)];
for (const command of commands) {
addCommand({...command, usage}, yargs);
}
},
() => yargs.showHelp()
);

yargs.parse();
};
22 changes: 20 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,28 @@ class Pastel extends EventEmitter {
this.commandsPath = path.join(appPath, 'commands');
this.buildPath = path.join(appPath, 'build');
this.cachePath = path.join(appPath, 'node_modules', '.cache', 'parcel');
this.tsConfigPath = path.join(appPath, 'tsconfig.json');

this.testMode = testMode;
}

checkOrCreateTsConfig() {
if (!fs.existsSync(this.tsConfigPath)) {
fs.writeFileSync(this.tsConfigPath, JSON.stringify({compilerOptions: {jsx: 'react'}}, null, 2));
console.log('We detected TypeScript in your project and created a `tsconfig.json` file for you.');
}
}

getEntryPointPaths(commands) {
const paths = getEntrypointPaths(this.commandsPath, commands);

if (paths.some(filePath => path.extname(filePath) === '.tsx')) {
this.checkOrCreateTsConfig();
}

return paths;
}

async createBuildDir() {
// `del` refuses to delete "build" directory when running Pastel's tests,
// so we use `force` option to override this behavior in test mode
Expand Down Expand Up @@ -65,7 +83,7 @@ class Pastel extends EventEmitter {
await this.createBuildDir();
await this.saveEntrypoint();
const commands = await this.scanCommands();
const bundler = this.createBundler(getEntrypointPaths(this.commandsPath, commands), {watch: false});
const bundler = this.createBundler(this.getEntryPointPaths(commands), {watch: false});

return bundler.bundle();
}
Expand Down Expand Up @@ -107,7 +125,7 @@ class Pastel extends EventEmitter {

const commands = await this.scanCommands();

bundler = this.createBundler(getEntrypointPaths(this.commandsPath, commands), {watch: true});
bundler = this.createBundler(this.getEntryPointPaths(commands), {watch: true});
bundler.on('buildStart', onStart);

bundler.on('bundled', handleAsyncErrors(async () => {
Expand Down
2 changes: 1 addition & 1 deletion lib/get-entrypoint-paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const getEntrypointPaths = (commandsPath, commands) => {
const entrypointPaths = [];

for (const command of commands) {
entrypointPaths.push(path.join(commandsPath, command.path));
entrypointPaths.push(path.join(commandsPath, command.path || ''));
entrypointPaths.push(...getEntrypointPaths(commandsPath, command.subCommands));
}

Expand Down
36 changes: 35 additions & 1 deletion lib/parse-command.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,9 @@ module.exports = async filePath => {
}
});

return {
const command = {
description,
positionalArgs: positionalArgs || [],
args: args.map(arg => {
return {
...arg,
Expand All @@ -302,4 +303,37 @@ module.exports = async filePath => {
};
})
};

const argsMap = new Map(
command.args.map(arg => [arg.key, arg])
);

if (positionalArgs && positionalArgs.length > 0) {
const missingArg = positionalArgs.find(key => !argsMap.has(key));

if (missingArg) {
throw new Error(`Positional argument \`${missingArg}\` is not declared in \`propTypes\` in \`${componentName}\``);
}

const firstOptionalArg = positionalArgs.findIndex(key => !argsMap.get(key).isRequired);

if (firstOptionalArg > -1 && positionalArgs.some((key, index) => index > firstOptionalArg && argsMap.get(key).isRequired)) {
throw new Error(`Optional positional arguments need to come after required positional arguments in \`${componentName}\``);
}

const variadicArg = positionalArgs.findIndex(key => argsMap.get(key).type === 'array');

if (variadicArg > -1 && variadicArg !== positionalArgs.length - 1) {
throw new Error(`Variadic positional argument must be at the end in \`${componentName}\``);
}

for (const key of positionalArgs) {
const {aliases} = argsMap.get(key);
if (aliases.length > 1) {
console.warn(`Positional arguments can only have up to one alias. The rest will be ignored for \`${key}\` in \`${componentName}\``);
}
}
}

return command;
};
25 changes: 14 additions & 11 deletions lib/read-commands.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,46 @@
'use strict';
const {promisify} = require('util');
const {join, basename, relative} = require('path');
const {join, relative, extname, parse} = require('path');
const fs = require('fs');
const parseCommand = require('./parse-command');

const stat = promisify(fs.stat);

const isIndexCommand = command => basename(basename(command.path, '.js'), '.tsx') === 'index';

// `dirPath` is a path in source `commands` folder
// `buildDirPath` is a path to the same folder, but in `build` folder
const readCommands = async (commandsPath, dirPath) => {
const paths = fs.readdirSync(dirPath);
const commands = [];

const promises = paths.filter(path => path.endsWith('.js') || path.endsWith('.tsx')).map(async path => {
const promises = paths.map(async path => {
// Since `readdir` returns relative paths, we need to transform them to absolute paths
const fullPath = join(dirPath, path);
const stats = await stat(fullPath);

if (stats.isDirectory()) {
const subCommands = await readCommands(commandsPath, fullPath);
const indexCommand = subCommands.find(isIndexCommand);
const indexCommand = subCommands.find(command => command.isIndex) || {
isDefaultIndex: true
};

commands.push({
...indexCommand,
isIndex: false,
name: path,
subCommands: subCommands.filter(command => !isIndexCommand(command))
subCommands: subCommands.filter(command => !command.isIndex)
});
}

if (stats.isFile()) {
const {description, args} = await parseCommand(fullPath);
if (stats.isFile() && ['.js', '.tsx'].includes(extname(fullPath))) {
const command = await parseCommand(fullPath);
const {name} = parse(fullPath);
const isIndex = name === 'index';

commands.push({
...command,
isIndex,
name: isIndex ? '*' : name,
path: relative(commandsPath, fullPath),
name: basename(basename(fullPath, '.js'), '.tsx'),
description,
args,
subCommands: []
});
}
Expand Down
45 changes: 45 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,51 @@ $ my-cli Jane Hopper
First argument is "Jane" and second is "Hopper"
```

The order of the fields in `positionalArgs` will be respected. Optional arguments need to appear after required ones.
If you want to collect an arbitrary amount of arguments you can define a variadic argument by giving it the array type.
Variadic arguments need to always be last and will capture all the remaining arguments.

```jsx
import React from 'react';
import PropTypes from 'prop-types';
import {Text} from 'ink';

const DownloadCommand = ({urls}) => (
<Text>
Downloading {urls.length} urls
</Text>
);

DownloadCommand.propTypes = {
urls: PropTypes.array
};

DownloadCommand.positionalArgs = ['urls'];

export default DownloadCommand;
```

```bash
$ my-cli download https://some/url https://some/other/url
Downloading 2 urls
```

Positional arguments also support aliases, but only one per argument. The rest will be ignored.

### TypeScript

Pastel supports TypeScript by simply renaming a command file and giving it the `.tsx` extension. A `tsconfig.json` will be generated for you.

If you want to define your own, make sure it contains the following:

```json
{
"compilerOptions": {
"jsx": "react"
}
}
```

### Distribution

Since Pastel compiles your application, the final source code of your CLI is generated in the `build` folder.
Expand Down
Loading

0 comments on commit a35417b

Please sign in to comment.