Giter VIP home page Giter VIP logo

pastel's Introduction

Pastel test

Next.js-like framework for CLIs made with Ink.

Features

  • Create files in commands folder to add commands.
  • Create folders in commands to add subcommands.
  • Define options and arguments via Zod.
  • Full type-safety of options and arguments thanks to Zod.
  • Auto-generated help message for commands, options and arguments.
  • Uses battle-tested Commander package under the hood.

Install

npm install pastel ink react zod

Geting started

Use create-pastel-app to quickly scaffold a Pastel app with TypeScript, linter and tests set up.

npm create pastel-app hello-world
hello-world
Manual setup

  1. Set up a new project.
mkdir hello-world
cd hello-world
npm init --yes
  1. Install Pastel and TypeScript.
npm install pastel
npm install --save-dev typescript @sindresorhus/tsconfig
  1. Create a tsconfig.json file to set up TypeScript.
{
	"extends": "@sindresorhus/tsconfig",
	"compilerOptions": {
		"moduleResolution": "node16",
		"module": "node16",
		"outDir": "build",
		"sourceMap": true,
		"tsx": "react"
	},
	"include": ["source"]
}
  1. Create a source folder for the source code.
mkdir source
  1. Create a source/cli.ts file with the following code, which will be CLI's entrypoint:
#!/usr/bin/env node
import Pastel from 'pastel';

const app = new Pastel({
	importMeta: import.meta,
});

await app.run();
  1. Create source/commands folder for defining CLI's commands.
mkdir source/commands
  1. Create an source/commands/index.tsx file for a default command, with the following code:
import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	name: zod.string().describe('Your name'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Index({options}: Props) {
	return <Text>Hello, {options.name}!</Text>;
}
  1. Build your CLI.
npx tsc
  1. Set up an executable file.

9.1. Add bin field to package.json, which points to the compiled version of source/cli.ts file.

	"bin": "build/cli.js"

9.2. Make your CLI available globally.

npm link --global
  1. Run your CLI.
hello-world --name=Jane
Hello, Jane!
hello-world --help
Usage: hello-world [options]

Options:
  --name         Your name
  -v, --version  Show version number
  -h, --help     Show help

Table of contents

Commands

Pastel treats every file in the commands folder as a command, where filename is a command's name (excluding the extension). Files are expected to export a React component, which will be rendered when command is executed.

You can also nest files in folders to create subcommands.

Here's an example, which defines login and logout commands:

commands/
	login.tsx
	logout.tsx

login.tsx

import React from 'react';
import {Text} from 'ink';

export default function Login() {
	return <Text>Logging in</Text>;
}

logout.tsx

import React from 'react';
import {Text} from 'ink';

export default function Logout() {
	return <Text>Logging out</Text>;
}

Given that your executable is named my-cli, you can execute these commands like so:

$ my-cli login
$ my-cli logout

Index commands

Files named index.tsx are index commands. They will be executed by default, when no other command isn't specified.

commands/
	index.tsx
	login.tsx
	logout.tsx

Running my-cli without a command name will execute index.tsx command.

$ my-cli

Index command is useful when you're building a single-purpose CLI, which has only one command. For example, np or fast-cli.

Default commands

Default commands are similar to index commands, because they too will be executed when an explicit command isn't specified. The difference is default commands still have a name, just like any other command, and they'll show up in the help message.

Default commands are useful for creating shortcuts to commands that are used most often.

Let's say there are 3 commands available: deploy, login and logout.

commands/
	deploy.tsx
	login.tsx
	logout.tsx

Each of them can be executed by typing their name.

$ my-cli deploy
$ my-cli login
$ my-cli logout

Chances are, deploy command is going to be used a lot more frequently than login and logout, so it makes sense to make deploy a default command in this CLI.

Export a variable named isDefault from the command file and set it to true to mark that command as a default one.

import React from 'react';
import {Text} from 'ink';

+ export const isDefault = true;

export default function Deploy() {
	return <Text>Deploying...</Text>;
}

Now, running my-cli or my-cli deploy will execute a deploy command.

$ my-cli

Vercel's CLI is a real-world example of this approach, where both vercel and vercel deploy trigger a new deploy of your project.

Subcommands

As your CLI grows and more commands are added, it makes sense to group the related commands together.

To do that, create nested folders in commands folder and put the relevant commands inside to create subcommands. Here's an example for a CLI that triggers deploys and manages domains for your project:

commands/
	deploy.tsx
	login.tsx
	logout.tsx
	domains/
		list.tsx
		add.tsx
		remove.tsx

Commands for managing domains would be executed like so:

$ my-cli domains list
$ my-cli domains add
$ my-cli domains remove

Subcommands can even be deeply nested within many folders.

Aliases

Commands can have an alias, which is usually a shorter alternative name for the same command. Power users prefer aliases instead of full names for commands they use often. For example, most users type npm i instead of npm install.

Any command in Pastel can assign an alias by exporting a variable named alias:

import React from 'react';
import {Text} from 'ink';

+ export const alias = 'i';

export default function Install() {
	return <Text>Installing something...</Text>;
}

Now the same install command can be executed by only typing i:

$ my-cli i

Options

Commands can define options to customize their default behavior or ask for some additional data to run properly. For example, a command that creates a new server might specify options for choosing a server's name, an operating system, memory size or a region where that server should be spin up.

Pastel uses Zod to define, parse and validate command options. Export a variable named options and set a Zod object schema. Pastel will parse that schema and automatically set these options up. When command is executed, option values are passed via options prop to your component.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	name: zod.string().describe('Server name'),
	os: zod.enum(['Ubuntu', 'Debian']).describe('Operating system'),
	memory: zod.number().describe('Memory size'),
	region: zod.enum(['waw', 'lhr', 'nyc']).describe('Region'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Deploy({options}: Props) {
	return (
		<Text>
			Deploying a server named "{options.name}" running {options.os} with memory
			size of {options.memory} MB in {options.region} region
		</Text>
	);
}

With options set up, here's an example deploy command:

$ my-cli deploy --name=Test --os=Ubuntu --memory=1024 --region=waw
Deploying a server named "Test" running Ubuntu with memory size of 1024 MB in waw region.

Help message is auto-generated for you as well.

$ my-cli deploy --help
Usage: my-cli deploy [options]

Options:
  --name         Server name
  --os           Operating system (choices: "Ubuntu", "Debian")
  --memory       Memory size
  --region       Region
  -v, --version  Show version number
  -h, --help     Show help

Types

Pastel only supports string, number, boolean, enum, array and set types for defining options.

String

Example that defines a --name string option:

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	name: zod.string().describe('Your name'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Name = {options.name}</Text>;
}
$ my-cli --name=Jane
Name = Jane

Number

Example that defines a --size number option:

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	age: zod.number().describe('Your age'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Age = {options.age}</Text>;
}
$ my-cli --age=28
Age = 28

Boolean

Example that defines a --compress number option:

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	compress: zod.boolean().describe('Compress output'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Compress = {String(options.compress)}</Text>;
}
$ my-cli --compress
Compress = true

Boolean options are special, because they can't be required and default to false, even if Zod schema doesn't use a default(false) function.

When boolean option defaults to true, it's treated as a negated option, which adds a no- prefix to its name.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	compress: zod.boolean().default(true).describe("Don't compress output"),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Compress = {String(options.compress)}</Text>;
}
$ my-cli --no-compress
Compress = false

Enum

Example that defines an --os enum option with a set of allowed values.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	os: zod.enum(['Ubuntu', 'Debian']).describe('Operating system'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Operating system = {options.os}</Text>;
}
$ my-cli --os=Ubuntu
Operating system = Ubuntu

$ my-cli --os=Debian
Operating system = Debian

$ my-cli --os=Windows
error: option '--os <os>' argument 'Windows' is invalid. Allowed choices are Ubuntu, Debian.

Array

Example that defines a --tag array option, which can be specified multiple times.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	tag: zod.array(zod.string()).describe('Tags'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Tags = {options.tags.join(', ')}</Text>;
}
$ my-cli --tag=App --tag=Production
Tags = App, Production

Array options can only include strings (zod.string), numbers (zod.number) or enums (zod.enum).

Set

Example that defines a --tag set option, which can be specified multiple times. It's similar to an array option, except duplicate values are removed, since the option's value is a Set instance.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	tag: zod.set(zod.string()).describe('Tags'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Tags = {[...options.tags].join(', ')}</Text>;
}
$ my-cli --tag=App --tag=Production --tag=Production
Tags = App, Production

Set options can only include strings (zod.string), numbers (zod.number) or enums (zod.enum).

Optional or required options

Pastel determines whether option is optional or required by parsing its Zod schema. Since Zod schemas are required by default, so are options in Pastel.

If an option isn't be required for a command to function properly, mark it as optional.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	os: zod.enum(['Ubuntu', 'Debian']).optional().describe('Operating system'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Operating system = {options.os ?? 'unspecified'}</Text>;
}
$ my-cli --os=Ubuntu
Operating system = Ubuntu

$ my-cli
Operating system = unspecified

Default value

Default value for an option can be set via a default function in Zod schema.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const options = zod.object({
	size: zod.number().default(1024).describe('Memory size'),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Memory = {options.size} MB</Text>;
}
$ my-cli
Memory size = 1024 MB

JSON representation of default value will be displayed in the help message.

$ my-cli --help
Usage: my-cli [options]

Options:
  --size  Memory size (default: 1024)

You can also customize it via defaultValueDescription option in option helper function.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';
import {option} from 'pastel';

export const options = zod.object({
	size: zod
		.number()
		.default(1024)
		.describe(
			option({
				description: 'Memory size',
				defaultValueDescription: '1 GB',
			}),
		),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Memory = {options.size} MB</Text>;
}
$ my-cli --help
Usage: my-cli [options]

Options:
  --size  Memory size (default: 1 GB)

Alias

Options can specify an alias, which is usually the first letter of an original option name.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';
import {option} from 'pastel';

export const options = zod.object({
	force: zod.boolean().describe(
		option({
			description: 'Force',
			alias: 'f',
		}),
	),
});

type Props = {
	options: zod.infer<typeof options>;
};

export default function Example({options}: Props) {
	return <Text>Force = {String(options.force)}</Text>;
}
$ my-cli --force
Force = true

$ my-cli -f
Force = true

Arguments

Arguments are similar to options, except they don't require a flag to specify them (e.g. --name) and they're always specified after command name and options. For example, mv requires 2 arguments, where first argument is a source path and second argument is a target path.

$ mv source.txt target.txt

A theoretical mv command in Pastel can define similar arguments like so:

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const args = zod.tuple([zod.string(), zod.string()]);

type Props = {
	args: zod.infer<typeof args>;
};

export default function Move({args}: Props) {
	return (
		<Text>
			Moving from {args[0]} to {args[1]}
		</Text>
	);
}
$ my-cli source.txt target.txt
Moving from source.txt to target.txt

This command defines two positional arguments, which means that argument's position matters for command's execution. This is why positional arguments are defined via zod.tuple in Zod, where a specific number of values is expected.

However, there are commands like rm, which can accept any number of arguments. To accomplish that in Pastel, use zod.array instead.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';

export const args = zod.array(zod.string());

type Props = {
	args: zod.infer<typeof args>;
};

export default function Remove({args}: Props) {
	return <Text>Removing {args.join(', ')}</Text>;
}
$ my-cli a.txt b.txt c.txt
Removing a.txt, b.txt, c.txt

Types

Pastel only supports string, number and enum types for defining arguments inside tuple or array.

String

Example that defines a string argument.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';
import {argument} from 'pastel';

export const args = zod.tuple([
	zod.string().describe(
		argument({
			name: 'name',
			description: 'Your name',
		}),
	),
]);

type Props = {
	args: zod.infer<typeof args>;
};

export default function Hello({args}: Props) {
	return <Text>Hello, {args[0]}</Text>;
}
$ my-cli Jane
Hello, Jane

Number

Example that defines a number argument.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';
import {argument} from 'pastel';

export const args = zod.tuple([
	zod.number().describe(
		argument({
			name: 'age',
			description: 'Age',
		}),
	),
]);

type Props = {
	args: zod.infer<typeof args>;
};

export default function Hello({args}: Props) {
	return <Text>Your age is {args[0]}</Text>;
}
$ my-cli 28
Your age is 28

Enum

Example that defines an enum argument.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';
import {argument} from 'pastel';

export const args = zod.tuple([
	zod.enum(['Ubuntu', 'Debian']).describe(
		argument({
			name: 'os',
			description: 'Operating system',
		}),
	),
]);

type Props = {
	args: zod.infer<typeof args>;
};

export default function Example({args}: Props) {
	return <Text>Operating system = {args[0]}</Text>;
}
$ my-cli Ubuntu
Operating system = Ubuntu

Default value

Default value for an argument can be via a default function in Zod schema.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';
import {argument} from 'pastel';

export const args = zod.tuple([
	zod
		.number()
		.default(1024)
		.describe(
			argument({
				name: 'number',
				description: 'Some number',
			}),
		),
]);

type Props = {
	args: zod.infer<typeof args>;
};

export default function Example({args}: Props) {
	return <Text>Some number = {args[0]}</Text>;
}
$ my-cli
Some number = 1024

JSON representation of default value will be displayed in the help message.

$ my-cli --help
Usage: my-cli [options] [number]

Arguments:
  number  Some number (default: 1024)

You can also customize it via defaultValueDescription option in option helper function.

import React from 'react';
import {Text} from 'ink';
import zod from 'zod';
import {argument} from 'pastel';

export const args = zod.tuple([
	zod
		.number()
		.default(1024)
		.describe(
			argument({
				name: 'number',
				description: 'Some number',
				defaultValueDescription: '1,204',
			}),
		),
]);

type Props = {
	args: zod.infer<typeof args>;
};

export default function Example({args}: Props) {
	return <Text>Some number = {args[0]}</Text>;
}
$ my-cli --help
Usage: my-cli [options] [number]

Arguments:
  number  Some number (default: 1,024)

Custom app

Similar to Next.js, Pastel wraps every command component with a component exported from commands/_app.tsx. If this file doesn't exist, Pastel uses a default app component, which does nothing but render your component with options and args props.

import React from 'react';
import type {AppProps} from 'pastel';

export default function App({Component, commandProps}: AppProps) {
	return <Component {...commandProps} />;
}

You can copy paste that code into commands/_app.tsx and add some logic that will be shared across all commands.

Custom program name

Pastel extracts a program name from the name field in the nearest package.json file. If it doesn't exist, a first argument in process.argv is used.

When the name of an executable doesn't match the name in package.json, it can be customized via a name option during app initialization.

import Pastel from 'pastel';

const app = new Pastel({
	name: 'custom-cli-name',
});

await app.run();

Custom description

Similar to program name, Pastel looks for a description in description field in the nearest package.json file. To customize it, use a description option when initializating Pastel.

import Pastel from 'pastel';

const app = new Pastel({
	description: 'Custom description',
});

await app.run();

Custom version

Similar to program name and description, Pastel looks for a version in version field in the nearest package.json file. If Pastel can't find it, version will be hidden in the help message and -v, --version options won't be available.

To customize it, use a version option during app initialization.

import Pastel from 'pastel';

const app = new Pastel({
	version: '1.0.0
});

await app.run()

API

Pastel(options)

Initializes a Pastel app.

options

Type: object

name

Type: string

Program name. Defaults to name in the nearest package.json or the name of the executable.

version

Type: string

Version. Defaults to version in the nearest package.json.

description

Type: string

Description. Defaults to description in the nearest package.json.

importMeta

Type: ImportMeta

Pass in import.meta. This is used to find the commands directory.

run(argv)

Parses the arguments and runs the app.

argv

Type: Array
Default: process.argv

Program arguments.

option(config)

Set additional metadata for an option. Must be used as an argument to describe function from Zod.

config

Type: object

description

Type: string

Description. If description is missing, option won't appear in the "Options" section of the help message.

defaultValueDescription

Type: string

Description of a default value.

valueDescription

Type: string

Description of a value. Replaces "value" in --flag <value> in the help message.

alias

Type: string

Alias. Usually a first letter of the full option name.

argument(config)

Set additional metadata for an argument. Must be used as an argument to describe function from Zod.

config

Type: object

name

Type: string
Default: 'arg'

Argument's name. Displayed in "Usage" part of the help message. Doesn't affect how argument is parsed.

description

Type: string

Description of an argument. If description is missing, argument won't appear in the "Arguments" section of the help message.

defaultValueDescription

Type: string

Description of a default value.

pastel's People

Contributors

karaggeorge avatar lr0pb avatar m-allanson avatar railly avatar renatorib avatar vadimdemedes avatar zimme avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

pastel's Issues

How to Remove Created CLI

I have created a cli but i need to remove using it or if i need to use the same name to other package/folder

Unclear documentation

I'm interested in setting up and using pastel for a project that I'm working on, however, I will need to be able to use multiple commands and I don't find that the README is very clear on how to achieve this.

I can see that by creating folders within the commands dir it will generate the extra commands, however, whenever I try to do this, it always seems to default to the command inside commands/index.js. For example, if I create a folder called init inside of commands and create a new file called index.js and run my-command init it still runs the default greeter command in commands/index.js.

I tried the npm run dev command, but it just says that it's watching the commands directory infinitely, so I don't if I need to open a new terminal and try any new commands. I've even checked the dependency graph to see if I can find any examples of people using multiple commands but I couldn't find any good examples.

Maybe I'm doing something silly, but if I'm not, I'd be happy to work on the documentation to try to make this clearer for other users

TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string.

npm run dev suddenly stopped working wiht the following error. Could only 'fix' it by re-cloning my repo and running npm install.

βœ– Build failed with the following error

TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received type undefined
    at validateString (internal/validators.js:107:11)
    at Object.join (path.js:1037:7)
    at getEntrypointPaths (/Users/ragnorcomerford/Work/Repos/atlas/node_modules/pastel/lib/get-entrypoint-paths.js:9:29)
    at Pastel.build (/Users/ragnorcomerford/Work/Repos/atlas/node_modules/pastel/index.js:68:38)
    at async module.exports (/Users/ragnorcomerford/Work/Repos/atlas/node_modules/pastel/commands/dev.js:35:3)

npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! [email protected] dev: `pastel dev`
npm ERR! Exit status 1
npm ERR! 
npm ERR! Failed at the [email protected] dev script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output abo

Feedback

Hi, you requested I open an issue here, so here it is.

  1. The distinction between aliases and short flags is unclear. How does using a short flag differ from, e.g.:
    Foo.aliases = {bar: ['b']}
    Short option chaining is a thing per POSIX standards (e.g., foo -abc is equivalent to foo -a -b -c), but unsure if this is supported by yargs-parser?
  2. The /// convention for descriptions is problematic because it must always be hard-coded. Users cannot dynamically create strings or use template literals. Probably best to just use a JS string. πŸ˜„ Dynamic descriptions may be unlikely, but unless there's a strong reason, introducing a domain-specific construct like /// is probably unnecessary.
  3. Pastel opens up the idea of reusable commands (but doesn't quite get there, IMO) by distributing them as individual components. In other words, you could in theory publish a single command to npm (which may (?) work on its own), and compose a new program from third-party commands. I don't think it's too much of a stretch to get there, but there'd need to be a way to:
    1. Import commands living in node_modules
    2. Rename commands (the "default" name could still be the filename, but should allow to be user-defined)
    3. Control which options at the top level are "global" and can be passed to subcommands (see yargs' global flag for options)
  4. Pastel shouldn't restrict what you can and can't do with yargs. To that end, it should provide an adapter which "passes through" to yargs. Another reason is that it will become difficult to maintain unique abstractions for the entire set of yargs' options, especially as yargs adds new features and breaks old ones. Along the same lines, PropTypes, while familiar to React users, may not be expressive enough to map directly to yargs' types.

I think both Ink and Pastel are cool ideas, and am always excited to see developers driving Node.js-on-the-command-line forward. Thanks for these projects!

Command unable to specify a "version" option.

Hello!

I would like one of my commands to have a version option so that users can look up a resource by an optional version ID.

I've set my options as such:

export const options = zod.object({
	owner: zod.string().describe('Model owner'),
	name: zod.string().describe('Model name'),
	version: zod.string().optional().describe('Model version'),
	silent: zod.boolean().optional().describe('Silence output'),
});

I'm noticing, however, that no matter what the user passes via --version, the underlying app version is returned and the command never runs.

I'm not sure whether this is unexpected behavior, or something I'm doing silly. Probably the latter :) Let me know if I can provide any more detail.

Executable does not run

Within the pastel app, I see there is an executable generated. Within the hello-world example, the executable is called hello-world-tui

Wow! That's cool.

When I run this executable on another computer and got the error:

...
/snapshot/hello-world/dist/cli.js:2
import Pastel from 'pastel';
^^^^^^

SyntaxError: Cannot use import statement outside a module
...

I checked and the package.json in my is pastel app is set to "type": "module"

This is the contents of my dist/cli.js , which I did not change from the start code:

#!/usr/bin/env node
import Pastel from 'pastel';
const app = new Pastel({
    importMeta: import.meta,
});
await app.run();

"Invariant Violation: Invalid hook call." when command run in project folder with React

Thank you for this great project. I ran into this problem: After writing a command, successfully testing in dev, publishing to npm, then uninstalling the dev version and installing the npm version, running the command failed with the React 'Invariant Violation: Invalid hook call.' This only happens when running the command in a project folder that has React installed. Running command elsewhere works as expected.

Running builded cli fails

After building or running in dev mode, execution of cli fails with an error

➜  test hello-person
/private/tmp/test/node_modules/yoga-layout-prebuilt/yoga-layout/build/Release/nbind.js:53
        throw ex;
        ^

Error: Cannot find module 'pasteljs/boot'
Require stack:
- /private/tmp/test/build/cli.js
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:623:15)
    at Function.Module._load (internal/modules/cjs/loader.js:527:27)
    at Module.require (internal/modules/cjs/loader.js:681:19)
    at require (internal/modules/cjs/helpers.js:16:16)
    at Object.<anonymous> (/private/tmp/test/build/cli.js:5:14)
    at Module._compile (internal/modules/cjs/loader.js:774:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:785:10)
    at Module.load (internal/modules/cjs/loader.js:641:32)
    at Function.Module._load (internal/modules/cjs/loader.js:556:12)
    at Function.Module.runMain (internal/modules/cjs/loader.js:837:10)

The name was changed here but it's still pasteljs inside the cli.

Support for ink 4 (ERR_REQUIRE_ESM error)

When creating a new Pastel app, running it for the first time gives an error "ERR_REQUIRE_ESM". The error is fixed by downgrading ink to version 3.

Reproduction:

  • mkdir komcli
  • cd komcli
  • npx create-pastel-app
  • npm run dev
  • Open new terminal and run komcli --help
❯ komcli --help
internal/modules/cjs/loader.js:1102
      throw new ERR_REQUIRE_ESM(filename, parentPath, packageJsonPath);
      ^

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: ~/komcli/node_modules/ink/build/index.js
require() of ES modules is not supported.
require() of ~/komcli/node_modules/ink/build/index.js from ~/komcli/build/cli.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename index.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from ~/komcli/node_modules/ink/package.json.

    at new NodeError (internal/errors.js:322:7)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1102:13)
    at Module.load (internal/modules/cjs/loader.js:950:32)
    at Function.Module._load (internal/modules/cjs/loader.js:790:12)
    at Module.require (internal/modules/cjs/loader.js:974:19)
    at require (internal/modules/cjs/helpers.js:101:18)
    at Object.<anonymous> (~/komcli/build/cli.js:4:13)
    at Module._compile (internal/modules/cjs/loader.js:1085:14)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
    at Module.load (internal/modules/cjs/loader.js:950:32) {
  code: 'ERR_REQUIRE_ESM'
}

Notice: other reproduction steps would be to create a new project with a package.json file looking like this: (it's what create-pastel-app does)

{
	"name": "komcli",
	"version": "0.0.0",
	"license": "MIT",
	"bin": "./build/cli.js",
	"engines": {
		"node": ">=8"
	},
	"scripts": {
		"dev": "pastel dev",
		"build": "pastel build",
		"prepare": "pastel build"
	},
	"files": [
		"build"
	],
	"dependencies": {
		"ink": "^4.0.0",
		"pastel": "^1.1.1",
		"prop-types": "^15.8.1",
		"react": "^18.2.0"
	},
	"devDependencies": {}
}

I believe that in order to support Ink 4, package.json should have type: "module" and Pastel should start bundling code in ESM format.

I'm willing to try and submit a PR for this

CLI blows up if `commands` folder has non-js files

Expected:
Pastel will either complain if a file that isn't a valid command js file is inside of the commands folder, or it will simply ignore that file.

Actual:
yarn dev and yarn build execute with no issue, but when you try to run your CLI, you get the following:

image

Steps to reproduce:

  1. Create a pastel project and commands folder with an index.js.
  2. Put a .jpg or .html or probably any non-JS file in the commands folder
  3. Run yarn dev
  4. Attempt to run your CLI.

This may seem like a no-brainer, but it threw me for a loop, and could probably benefit from clearer error handling.

How it happened:
I started a pastel project and was intrigued by the ink-image component. I pulled down an image and was having trouble getting it to work properly, so I threw the image into my commands folder while trying to figure out how to get it to work. This caused my CLI to stop working, and the error message wasn't clear, so it took me a minute to realize that it was because it didn't like the jpg being in the folder.

I can try to make time to fix this, just let me know what the intended behavior should be.

Unable to pass options to Ink render() command

It would be nice if there was a mechanism to configure options to be passed into the render() method called in boot.js. There are a couple options there which are useful when troubleshooting (and perhaps for more advanced use cases I don't have a need for).

It seems that it should be easy enough to include the props in the call, I just don't know what the best model for passing them in would be for this tool's design. (pastel.config.js - taking a cue from Next.js?)

I wouldn't mind working on it if I have an idea what solution you would find acceptable.

Expose whether the command is running in dev or prod

There's no easy way to tell whether the command is running in dev or not.

I could assume production production unless a certain environment variable exists, but that's not a great idea. It would require devs to set an environment variable (globally or when running the CLI on any machine) and if they forget, they might run something bad on a branch and it'll interact with production servers.

This is my current workaround; environment.js:

/*
	In production, commands are each bundled into a single file;
	e.g. /build/commands/index.js.
	That's why the following will be false in production
*/
const isDev = __filename.endsWith("environment.js");

export default Object.freeze({
	isDev
});

I use this in my commands/components to check if it's dev/prod
I suggest pastel sets process.env.NODE_ENV or something like that.

how to change build location

How to change build location of pastel ?
and how to grab app render object to clear the output ?
const {clear} = render();
clear();

Description of the base command is duplicated on help

The description of the whole application is a duplication of the first global command:
image

I was looking at yargs and all of pastel source in order to do a PR for this, but I couldn't figure what's the best strategy to fix this. My current train of thought was to do a custom option in boot.js in this line https://github.com/vadimdemedes/pastel/blob/master/boot.js#L17

The condition would be if name is index and no subCommand

However, I realized that this will goes inside yargs, so I went to yargs document. After that I couldn't find what is the method to set the description in yargs...

Include static files?

Is there a way to include static files in the generated build/ folder? I have a Go binary that I want to include in the distribution, which is run by the Pastel commands. I can sort of hack it to work by automating moving the file into the folder: "build": "pastel build && cp -f gocode build" β€” but this seems like not the best solution. When running pastel dev, this file is constantly overridden.

Option `alias` doesn't work in combination with `zod.default()`/`zod.optional()`

I'm trying to define an alias for an option, but it's not working if used with neither zod.default() nor zod.optional().

Try it out: https://codesandbox.io/p/devbox/infallible-jones-ys358t?file=/string-default-alias/source/commands/index.tsx:6,1

Run npm run build (maybe followed by npm link) and then da -h.

Works πŸ‘

export const options = zod.object({
	name: zod
		.string()
		.describe(option({alias: 'n', description: 'Name'})),
});

Outputs:

❭  sda -h       
Usage: sda [options]

Options:
  -n, --name <name>  Name
  -v, --version      Show version number
  -h, --help         Show help

Doesn't work πŸ‘Ž

export const options = zod.object({
	name: zod
		.string()
		.default('Stranger')
		.describe(option({alias: 'n', description: 'Name'})),
});

and

export const options = zod.object({
	name: zod
		.string()
		.optional()
		.describe(option({alias: 'n', description: 'Name'})),
});

Outputs:

❭  sda -h       
Usage: sda [options]

Options:
  --name [name]  Name (default: 1)
  -v, --version  Show version number
  -h, --help     Show help

(default only in the first case, obviously)

Use relative command paths in distributed CLIs

I just try to use pastel write a demo cli project. After I publish package and install&run it on my target server. It throw error below.

/Users/0004112/temp/node_modules/yoga-layout-prebuilt/yoga-layout/build/Release/nbind.js:53
        throw ex;
        ^

Error: Cannot find module '/Users/zhangqing/Documents/web/pastel-demo/build/commands/index.js'

I found the bug is located in the generated commands.json file , command.path is my dev machine path.

{
	"commands": [
		{
			"path": "/Users/zhangqing/Documents/web/pastel-demo/commands/index.js",
			"buildPath": "/Users/zhangqing/Documents/web/pastel-demo/build/commands/index.js",
			"name": "index",
			"description": "This is my command description",
			"args": [
				{
					"key": "name",
					"type": "string",
					"description": "This is \"name\" option description",
					"isRequired": true,
					"aliases": [
						"n"
					],
					"positional": false
				}
			],
			"subCommands": []
		},
		{
			"path": "/Users/zhangqing/Documents/web/pastel-demo/commands/posts/index.js",
			"buildPath": "/Users/zhangqing/Documents/web/pastel-demo/build/commands/posts/index.js",
			"name": "posts",
			"description": "",
			"args": [],
			"subCommands": []
		}
	]
}

I am new to pastel, I don't know whether is my mistake or it is a bug in pastel.
here is my demo package:
npm:https://www.npmjs.com/package/@mogul/hello-person-demo
git: https://github.com/JennerChen/try-pastel-person-demo
env:
nodejs: 8.12.0
pasteljs: 1.0.1

PropTypes functional type errors do not stop rendering

When using the functional form of propTypes to do some conditional prop checking, errors result in a warning printed but do not prevent rendering like when a .isRequired prop is missing.

As an example, I want the serviceName and newSchema props to be linked such that if you provide neither it is ok, but if you provide one of them, you MUST provide both. I tried the following:

Component.propTypes = {
  serviceName: function (props, propName) {
    if (props['newSchema'] !== undefined && props[propName] === undefined) {
      return new Error('Please provide a --service-name value!');
    }
  },
  newSchema: function (props, propName) {
    if (props['serviceName'] !== undefined && props[propName] === undefined) {
      return new Error('Please provide a --new-schema value!');
    }
  }
}

But this results only in a warning being displayed and does not prevent the command from running. The warning looks like this:

Warning: Failed prop type: Please provide a --new-schema value!
    in Check

I would expect this to act like a required prop being omitted, resulting in the help text for the command being displayed with a message about which required prop is missing.

multiple commands: descriptions not added to help output

When using multiple commands their description isn't added to the --help output. It would be nice if each command's description was added beside the command.

Sample Help Output 1

multiple commands

commands/
  commands/test1.js
  commands/test2.js

test1.js

import React from "react";
import PropTypes from "prop-types";
import { Text } from "ink";

/// test1 description
const HelloWorld = () => <Text>Hello World</Text>;

HelloWorld.propTypes = {
  /// prop1 description
  prop1: PropTypes.string
};

export default HelloWorld;

test2.js

import React from "react";
import PropTypes from "prop-types";
import { Text } from "ink";

/// test2 description
const HelloWorld = () => <Text>Hello World</Text>;

HelloWorld.propTypes = {
  /// prop1 description
  prop1: PropTypes.string
};

export default HelloWorld;

help output

cli.js

Commands:
  cli.js test1
  cli.js test2

Options:
  --help     Show help                                                 [boolean]
  --version  Show version number                                       [boolean]

Sample Help Output 2

multiple commands - with index

commands/
  commands/index.js
  commands/test1.js
  commands/test2.js

help output

cli.js

Commands:
  cli.js
  cli.js test1
  cli.js test2
  cli.js        index description                                      [default]

Options:
  --help     Show help                                                 [boolean]
  --version  Show version number                                       [boolean]

Is there an exit event / listener?

I ran into an issue where when using useInput or ink-select-input; pressing ctrl+c exits the app but not the process.

This is probably because our app has live connections. Is there a way I can subscribe to when the React app exits? I know there's waitUntilExit but I don't think that's accessible via Pastel (correct me if I'm wrong).

If exitOnCtrlC was set to false, I could check for key.ctrl && input === "c" in the useInput callback, and then call process.exit(0). I've confirmed this by editing the hook in node_modules. But:

  1. I want it to disable exitOnCtrlC at all.
  2. I don't think it can be disabled via Pastel (#35).
  3. I shouldn't have to manually handle ctrl+c anyway so this isn't a real solution πŸ€·β€β™‚οΈ.

The workaround I'm using right now is to manually listen to stdin via useStdin. When ctrl+c is pressed (I've copied the logic from useInput), I call process.exit(0).

The awkward thing is that if any third-party component uses useInput, a workaround like this has to be used.

I think this is related to #8

Divide into two libraries: CLI and Runtime. (stop installing deps such as "parcel-bundler" alongside the generated CLI app)

I believe that including pastel as a dependency includes unnecessary code to the generated CLI app, such as the parcel-bundler dependency.

Saving parcel with --save-dev doesn't work, because there's some runtime code in this library that's needed for the app to run. But the problem is that the runtime is installed alongside all the dev tooling.

As an example:

This is the package.json of the CLI (relevant parts only):

{
	"name": "@filmin/translations",
	"version": "2.0.4",
	"bin": {
		"filmin-translations-cli": "./build/cli.js"
	},
        "devDependencies": {
                "pastel": "^1.1.1",
        },

And this is the package.json of the consumer (relevant parts only):

{
        "name": "CONSUMER",
	"scripts": {
		"fetch-translations": "filmin-translations-cli <args...>"
	},
        "dependencies": {
                "@filmin/translations": "^2.0.4",
        },

Since pastel is a devDependency, the fetch-translations command fails:

❯ npm run fetch-translations
> filmin-translations-cli fetch <args...>

/Users/francisco/Sites/filminweb/node_modules/yoga-layout-prebuilt/yoga-layout/build/Release/nbind.js:53
        throw ex;
        ^

Error: Cannot find module 'pastel/boot'
Require stack:
- /Users/francisco/Sites/filminweb/node_modules/@filmin/translations/build/cli.js
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:815:15)
    at Function.Module._load (internal/modules/cjs/loader.js:667:27)
    at Module.require (internal/modules/cjs/loader.js:887:19)
    at require (internal/modules/cjs/helpers.js:74:18)
    at Object.<anonymous> (/Users/francisco/Sites/filminweb/node_modules/@filmin/translations/build/cli.js:5:14)
    at Module._compile (internal/modules/cjs/loader.js:999:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
    at Module.load (internal/modules/cjs/loader.js:863:32)
    at Function.Module._load (internal/modules/cjs/loader.js:708:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12) {
  code: 'MODULE_NOT_FOUND',
  requireStack: [
    '/Users/francisco/Sites/filminweb/node_modules/@filmin/translations/build/cli.js'
  ]
}

In order to prevent the npm run fetch-translations command to fail, I have to move pastel from devDependencies to dependencies. But doing so also causes "CONSUMER" to load libraries such as parcel-bundler, which I believe are not necessary.

As a proposed solution, we could consider splitting pastel into two separate packages, the "dev cli" and the "runtime" (a.k.a. pastel/boot).

Any thoughts?

Doesn't like having a CLI name that's different to the package name

It took me way too long to realise that you can do this in your package.json:

  "name": "@org/package-name",
  "bin": {
    "cli-name": "./bin/cli.js"
  }

So it would help to document that in the README.md first of all.

Everything works then; i.e. you can use cli-name in dev and prod to run the CLI. However, pastel dev still shows the package name;

Development mode

Pastel watches your "commands" directory for changes and rebuilds application
when needed. After first successful build Pastel will also link your CLI for
you, so feel free to run your command right away:

$ @org/package-name --help

Now go create some beautiful CLI!

Missing `index.js` builds, but displays nothing when run

Currently, given the directory structure:

demo
  commands
    hello.js

(note the missing index.js)

If I just run demo with no following commands, nothing displays.

Consider displaying the equivalent of demo --help if there is no index.js file to run (same with nested folders with no index.js, which currently fails the build)

Add TypeScript support

Hi,
First, thanks for a great tool! This together with Ink has made writing cli-tools a joy!

But I was wondering if you would be interested in a PR to enable Typescript-files as entrypoints for commands.

I've noticed that parcel is used to bundle the scripts and parcel has built in support for TS. And today I can use TS-files if they're imported deeper in the tree.

I did some fiddling and found a way to at least get started.

I just changed a few lines in lib/read-commands.js:

const readCommands = async (dirPath, buildDirPath) => {
	const paths = fs.readdirSync(dirPath);
	const commands = [];

	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(fullPath, join(buildDirPath, path));
			const indexCommand = subCommands.find(isIndexCommand);

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

		if (stats.isFile()) {
			const {description, args} = await parseCommand(fullPath);

			commands.push({
				path: fullPath,
				// -------CHANGES BELOW-------
				buildPath: join(buildDirPath, path.replace('.tsx', '.js')), // This should probably be a more safe regular expression based replace
				name: basename(basename(fullPath, '.js'), '.tsx'),
				// -------CHANGES ABOVE-------
				description,
				args,
				subCommands: []
			});
		}
	});

	await Promise.all(promises);

	return commands;
};

This change deals with the first problem. Now at least both .js- and .tsx-files gets parsed.

But this solutions gives another problem, of course πŸ€”. If the entrypoint includes interfaces or other typescript features lib/parse-command.js wont be able to parse the file. I would need to dive into the world of Babel in order to see if it's enough to add @babel/plugin-transform-typescript to make it work, or if a separate typescript based parser is needed.

What do you think? Tell me if you would like me to try it out and see if I could get something working. If you're not interested, thats fine since I can use Typescript deeper in the tree!

VS Code debugging

Is there a .vscode/launch.json configuration that would work to allow debugging through a typescript pastel project?

dev-mode doesn't handle compile/build error

version: [email protected]

If pastel failed to build, dev-mode will exit and bail watch. However the expected behavior should be for it to retry on new change made to the source.

Will looking at patching this if I can find some time to look up how pastel run its watcher.

question: I want to learn use pastel with ink-table,can you give me some demo code...

and thanks.I can not understand it.ink-table use render function,but pastel not.
I try to export it,but a lot of error.

// data is valid array from demo code
const Basic = () => <Table data={data} />;
export default Basic;

but error:

  ERROR Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely  
       forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.

       Check the render method of `Skeleton`.

positional argument value is undefined in v1.1.0

@vadimdemedes @karaggeorge 1.1.0 is broken unfortunately.

When I run todesktop build ., todesktop build ./, etc. the argument is undefined. I see the usual prop-type warning that required property X is missing and then the command fails with an error (I pass the positional argument value to path.resolve).

It works fine when I go back to 1.0.3.

JSON file, .env and alias

Hi
how to work with json file and .env tried using dotenv it not working and failed to load json file with needs an import assertion of type "json"

looks like typescript alias is also not working
"paths": { "@/*": ["./source/*"] }

Separate pastel into runtime library

Currently, pastel needs to be added as a dependency which means that a lot of unnecessary sub-dependencies are bundled to end-users of our CLI.

Could pastel be separated into a runtime dependency that just does what's needed to boot the project and a devDependency for doing all the other stuff (Parcel/Babel etc.).

Anyway to get stdin?

The docs doesn't address reading from stdin. Any suggestion in how to do this with pastel?
Maybe having a hook (useStdin) would do it.

Support new line in /// comment

Currently there is no way to add additional new line in the command or option descriptions, i.e. ///. If I key in \n it'll just be printed out as it is.

Resolve "ERR_UNSUPPORTED_ESM_URL_SCHEME" Error in Windows

Hi there
First off, thank you for your amazing work, I really enjoy how you approach and adjust the CLI paradigm like Next.js.

Now I've encountered an issue on Windows where Node.js's ESM loader throws an ERR_UNSUPPORTED_ESM_URL_SCHEME error when trying to dynamically import a module using an absolute path.

This line of code in read-commands.js is causing the issue:

const m = (await import(filePath));

By using pathToFileUrl from node:url worked for me, this should ensure compatibility with Windows without affecting other OS.

import { pathToFileURL } from 'node:url';
...
const filePath = path.join(directory, file);
const fileURL = pathToFileURL(filePath);
const m = (await import(fileURL));

Note: Could you please confirm if you will be working on that? If not, I can contribute effortlessly.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.