Pastel

Next.js-like framework for CLIs made with Ink

README

undefined

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


  1. ``` session
  2. npm install pastel ink react zod
  3. ```

Geting started


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

  1. ``` session
  2. npm create pastel-app hello-world
  3. hello-world
  4. ```

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:

  1. ``` null
  2. commands/
  3. login.tsx
  4. logout.tsx

  5. ```

login.tsx

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';

  4. export default function Login() {
  5. return <Text>Logging in</Text>;
  6. }
  7. ```

logout.tsx

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';

  4. export default function Logout() {
  5. return <Text>Logging out</Text>;
  6. }
  7. ```

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

  1. ``` null
  2. $ my-cli login
  3. $ my-cli logout

  4. ```

Index commands


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

  1. ``` null
  2. commands/
  3. index.tsx
  4. login.tsx
  5. logout.tsx

  6. ```

Running my-cliwithout a command name will execute index.tsxcommand.

  1. ``` null
  2. $ my-cli

  3. ```

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.

  1. ``` null
  2. commands/
  3. deploy.tsx
  4. login.tsx
  5. logout.tsx

  6. ```

Each of them can be executed by typing their name.

  1. ``` null
  2. $ my-cli deploy
  3. $ my-cli login
  4. $ my-cli logout

  5. ```

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.

  1. ``` diff
  2. import React from 'react';
  3. import {Text} from 'ink';

  4. + export const isDefault = true;

  5. export default function Deploy() {
  6. return <Text>Deploying...</Text>;
  7. }
  8. ```

Now, running my-clior my-cli deploywill execute a deploycommand.

  1. ``` null
  2. $ my-cli

  3. ```

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:

  1. ``` null
  2. commands/
  3. deploy.tsx
  4. login.tsx
  5. logout.tsx
  6. domains/
  7.   list.tsx
  8.   add.tsx
  9.   remove.tsx

  10. ```

Commands for managing domains would be executed like so:

  1. ``` null
  2. $ my-cli domains list
  3. $ my-cli domains add
  4. $ my-cli domains remove

  5. ```

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:

  1. ``` diff
  2. import React from 'react';
  3. import {Text} from 'ink';

  4. + export const alias = 'i';

  5. export default function Install() {
  6. return <Text>Installing something...</Text>;
  7. }
  8. ```

Now the same installcommand can be executed by only typing i:

  1. ``` null
  2. $ my-cli i

  3. ```

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.

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';
  4. import zod from 'zod';

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

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

  14. export default function Deploy({options}: Props) {
  15. return (
  16.   <Text>
  17.    Deploying a server named "{options.name}" running {options.os} with memory
  18.    size of {options.memory} MB in {options.region} region
  19.   </Text>
  20. );
  21. }
  22. ```

With options set up, here's an example deploycommand:

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

  4. ```

Help message is auto-generated for you as well.

  1. ``` null
  2. $ my-cli deploy --help
  3. Usage: my-cli deploy [options]

  4. Options:
  5.   --name         Server name
  6.   --os           Operating system (choices: "Ubuntu", "Debian")
  7.   --memory       Memory size
  8.   --region       Region
  9.   -v, --version  Show version number
  10.   -h, --help     Show help

  11. ```

Types


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

String


Example that defines a --namestring option:

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';
  4. import zod from 'zod';

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

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

  11. export default function Example({options}: Props) {
  12. return <Text>Name = {options.name}</Text>;
  13. }
  14. ```

  1. ``` null
  2. $ my-cli --name=Jane
  3. Name = Jane

  4. ```

Number


Example that defines a --sizenumber option:

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';
  4. import zod from 'zod';

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

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

  11. export default function Example({options}: Props) {
  12. return <Text>Age = {options.age}</Text>;
  13. }
  14. ```

  1. ``` null
  2. $ my-cli --age=28
  3. Age = 28

  4. ```

Boolean


Example that defines a --compressnumber option:

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';
  4. import zod from 'zod';

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

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

  11. export default function Example({options}: Props) {
  12. return <Text>Compress = {String(options.compress)}</Text>;
  13. }
  14. ```

  1. ``` null
  2. $ my-cli --compress
  3. Compress = true

  4. ```

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.

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';
  4. import zod from 'zod';

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

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

  11. export default function Example({options}: Props) {
  12. return <Text>Compress = {String(options.compress)}</Text>;
  13. }
  14. ```

  1. ``` null
  2. $ my-cli --no-compress
  3. Compress = false

  4. ```

Enum


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

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';
  4. import zod from 'zod';

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

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

  11. export default function Example({options}: Props) {
  12. return <Text>Operating system = {options.os}</Text>;
  13. }
  14. ```

  1. ``` null
  2. $ my-cli --os=Ubuntu
  3. Operating system = Ubuntu

  4. $ my-cli --os=Debian
  5. Operating system = Debian

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

  8. ```

Array


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

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';
  4. import zod from 'zod';

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

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

  11. export default function Example({options}: Props) {
  12. return <Text>Tags = {options.tags.join(', ')}</Text>;
  13. }
  14. ```

  1. ``` null
  2. $ my-cli --tag=App --tag=Production
  3. Tags = App, Production

  4. ```

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.

  1. ``` tsx
  2. import React from 'react';
  3. import {Text} from 'ink';
  4. import zod from 'zod';

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

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

  11. export default function Example({options}: Props) {
  12. return <Text>Tags = {[...options.tags].join(', ')}</Text>;
  13. }
  14. ```

  1. ``` null
  2. $ my-cli --tag=App --tag=Production --tag=Production
  3. Tags = App, Production

  4. ```

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