More than 3 years have passed since last update.

posted at

Write a simple CLI in PureScript

Previously, I wrote about how I made the Spago2Nix project and what was involved, but didn't go much into the details of how the implementation worked. This time, I'll write about how putting together a command line application with minimal setup doesn't need to involve too much work, and some of the most common tools you'll end up using to do so.

Writing a CLI typically involves three common tasks, so I will cover them in the sections below:

  • Args
  • Files
  • Child processes


The main thing you need to know about handling args in Node is that there is a global process in Node, of which you can get its argv property as an array of strings. For example, given a index.js file that can be executed (chmod +x)

#!/usr/bin/env node

console.log('hi, here is argv:');

When you execute this file, you will get a process.argv that has two args by default, which are

  • the Node executable being run
  • the path of the script that is being run (index.js)

Like so:

$ ./index.js
hi, here is argv:
[ '/some/path/nodejs/bin/node',
  '/home/justin/Code/spago2nix/index.js' ]

# we can also call node ourselves
$ node index.js
hi, here is argv:
[ '/some/path/nodejs/bin/node',
  '/home/justin/Code/spago2nix/index.js' ]

We can pass args to this to give it some more arguments:

$ node index.js hello world
hi, here is argv:
[ '/some/path/nodejs/bin/node',
  'world' ]

So knowing this, we can handle args quite easily in a PureScript program, where we first prepare a FFI file with the export:

// src/Main.js
exports.argv = process.argv;
// btw this is the only FFI we need in our project

Then we can use this in our program:

# src/Main.purs
import Data.List (List, (:))
import Effect (Effect)
import Effect.Aff (Aff)
import Effect.Aff as Aff

foreign import argv :: Array String

args :: List String
args = List.drop 2 $ List.fromFoldable argv

main :: Effect Unit
main = Aff.launchAff_ do
  case args of
    "generate" : List.Nil -> Generate.generate
    "install" : rest -> install rest
    "build" : rest -> build SpagoStyle rest
    "build-nix" : rest -> build NixStyle rest
    "help" : rest -> log help
    List.Nil -> log help
    _ -> do
      log $ "Unknown arguments: " <> List.intercalate " " args

As simple as that, we can handle arguments to our program by pattern matching on a List String.

If you have more various things to handle, you might consider having some data type for arguments that you serialize to, using some other library like purescript-optparse, or just anything more, but I think this does handle a good number of simple usages that people need.


Chances are, if you use PureScript, you use Aff to handle asynchronous effects. Accordingly, there is a node-fs-aff library that you can use to use Node's file system module to read and write files.

For example,

import Node.FS.Aff as FS

-- this ensures that I have a .spago2nix folder to work with,
-- handling errors (i.e. from an existing directory) with a no-op
ensureSetup = do
  FS.mkdir "./.spago2nix" <|> pure unit

-- this ensures that I have prefetched all of my dependencies,
-- then writes a UTF8 text file with my results
generate = do
  packages <- exitOnError spagoListPackages
  fetches <- toResult <$> parTraverse ensureFetchPackage packages
  case fetches of
    Left errors -> do
      error "errors from fetching packages:"
      traverse_ (error <<< show) errors
      exit 1
    Right xs -> do
      FS.writeTextFile UTF8 spagoPackagesNix (printResults xs)
      log $ "wrote " <> spagoPackagesNix
      exit 0

Child processes

A CLI typically also needs to source information from other programs in various ways, most commonly by running other programs as child processes.

While working with the Node child process API via node-child-process isn't too much more than juggling a bunch of effects, I have made a small library that makes this as easy as using a record and some String values:

import Sunde as S

newtype DhallExpr = DhallExpr String

runDhallToJSON :: DhallExpr -> Aff String
runDhallToJSON (DhallExpr expr) = do
  result <- S.spawn
    { cmd: "dhall-to-json", args: [], stdin: Just expr }
  case result.exit of
    CP.Normally 0 -> do
      pure result.stdout
    _ -> do
      error "error running dhall-to-json:"
      error $ show result.exit
      error result.stderr
      Aff.throwError $ Aff.error result.stderr

And so, we are able to easily run a program, use a string to feed into stdin, and get the result of running our program as a product of the exit code, stdout, and stderr. For more specific uses, you may want to deal with the Node child process API yourself either through the node-child-process library or by FFI, but most of the time, this will do it.

Putting it all together

To actually consume this CLI, the simplest thing to do is to bundle the main module and allow for a shim to execute it. For example, first prepare an executable bin:

// package.json
  "name": "your-name",
  // ...
  "bin": {
    // this will tell npm to symlink this bin when it installs
    "your-bin-name": "bin/index.js"
  "scripts": {
    // this will let us bundle our app to output.js
    "mkbin": "spago bundle-app -t bin/output.js"
  // ...

// bin/index.js
#!/usr/bin/env node

With this setup, we can simply check in the result of npm run mkbin. Running npm link will make your-bin-name available to run.

$ which your-bin-name


Hopefully this has shown you that making a CLI with PureScript on Node is quite easy, and doesn't require any fancy preparations. If you do want to try to use something more, you can, but there's no need to spend hours on trying to do any part of this if you have some ideas you want to test quickly.


Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up