Pastel
Next.js-like framework for CLIs made with Ink
README
Pastel
Next.js-like framework for CLIs made with Ink .
Features
Create files in commandsfolder to add commands.
Create folders in commandsto 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
- ``` session
- 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.
- ``` session
- npm create pastel-app hello-world
- hello-world
- ```
Commands
Pastel treats every file in the commandsfolder 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 loginand logoutcommands:
- ``` null
- commands/
- login.tsx
- logout.tsx
- ```
login.tsx
- ``` tsx
- import React from 'react';
- import {Text} from 'ink';
- export default function Login() {
- return <Text>Logging in</Text>;
- }
- ```
logout.tsx
- ``` 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:
- ``` null
- $ my-cli login
- $ my-cli logout
- ```
Index commands
Files named index.tsxare index commands. They will be executed by default, when no other command isn't specified.
- ``` null
- commands/
- index.tsx
- login.tsx
- logout.tsx
- ```
Running my-cliwithout a command name will execute index.tsxcommand.
- ``` null
- $ 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, loginand logout.
- ``` null
- commands/
- deploy.tsx
- login.tsx
- logout.tsx
- ```
Each of them can be executed by typing their name.
- ``` null
- $ my-cli deploy
- $ my-cli login
- $ my-cli logout
- ```
Chances are, deploycommand is going to be used a lot more frequently than loginand logout, so it makes sense to make deploya default command in this CLI.
Export a variable named isDefaultfrom the command file and set it to trueto mark that command as a default one.
- ``` diff
- import React from 'react';
- import {Text} from 'ink';
- + export const isDefault = true;
- export default function Deploy() {
- return <Text>Deploying...</Text>;
- }
- ```
Now, running my-clior my-cli deploywill execute a deploycommand.
- ``` null
- $ my-cli
- ```
Vercel's CLI is a real-world example of this approach, where both verceland vercel deploytrigger 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 commandsfolder 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:
- ``` null
- commands/
- deploy.tsx
- login.tsx
- logout.tsx
- domains/
- list.tsx
- add.tsx
- remove.tsx
- ```
Commands for managing domains would be executed like so:
- ``` null
- $ 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 iinstead of npm install.
Any command in Pastel can assign an alias by exporting a variable named alias:
- ``` diff
- import React from 'react';
- import {Text} from 'ink';
- + export const alias = 'i';
- export default function Install() {
- return <Text>Installing something...</Text>;
- }
- ```
Now the same installcommand can be executed by only typing i:
- ``` null
- $ 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 optionsand 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 optionsprop to your component.
- ``` tsx
- 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 deploycommand:
- ``` null
- $ 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.
- ``` null
- $ 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 --namestring option:
- ``` tsx
- 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>;
- }
- ```
- ``` null
- $ my-cli --name=Jane
- Name = Jane
- ```
Number
Example that defines a --sizenumber option:
- ``` tsx
- 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>;
- }
- ```
- ``` null
- $ my-cli --age=28
- Age = 28
- ```
Boolean
Example that defines a --compressnumber option:
- ``` tsx
- 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>;
- }
- ```
- ``` null
- $ 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.
- ``` tsx
- 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>;
- }
- ```
- ``` null
- $ my-cli --no-compress
- Compress = false
- ```
Enum
Example that defines an --osenum option with a set of allowed values.
- ``` tsx
- 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>;
- }
- ```
- ``` null
- $ 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 --tagarray option, which can be specified multiple times.
- ``` tsx
- 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>;
- }
- ```
- ``` null
- $ 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 --tagset 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.
- ``` tsx
- 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>;
- }
- ```
- ``` null
- $ 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).