Pastel
🎨 Next.js-like framework for CLIs made with Ink
Install / Use
/learn @vadimdemedes/PastelREADME
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
npm install pastel ink react zod
Getting 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
<details><summary>Manual setup</summary>
<p>
- Set up a new project.
mkdir hello-world
cd hello-world
npm init --yes
- Install Pastel and TypeScript.
npm install pastel
npm install --save-dev typescript @sindresorhus/tsconfig
- Create a
tsconfig.jsonfile to set up TypeScript.
{
"extends": "@sindresorhus/tsconfig",
"compilerOptions": {
"outDir": "build",
"sourceMap": true
},
"include": [
"source"
]
}
- Create a
sourcefolder for the source code.
mkdir source
- Create a
source/cli.tsfile 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();
- Create
source/commandsfolder for defining CLI's commands.
mkdir source/commands
- Create an
source/commands/index.tsxfile 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>;
}
- Build your CLI.
npx tsc
- 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
- 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
</p></details>
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.
