salinger

Ecosystem-free and flexible task runner that goes well with npm scripts.

Usage no npm install needed!

<script type="module">
  import salinger from 'https://cdn.skypack.dev/salinger';
</script>

README

Salinger

Ecosystem-free task runner that goes well with npm scripts.

Travis Status Badge AppVeyor Status Badge Coverage Status Badge Code Climate Badge bitHound Overall Score Badge

Salinger is (almost) just a Promise wrapper around the native fs.exec() calls. And it replaces your favorite task runner (See: Trade-offs).

Easy to step in, easy to step out. No attachment to the glue modules between the runner and the tools.

Salinger walk-through

Contents

What Salinger offers

  • A well structured build environment.
  • Write scripts in any of these: Unix Shell, JavaScript, Python, Ruby, Perl, Lua.
  • Use CLI or programmatic API for a given task, whatever suits your needs better.
  • Easily inject variables to the scripts.
  • No ecosystem of plugins to adapt to. Use the core packages.
  • A compact package.json
  • Orchestrate your scripts with promises.
  • Almost non-existent learning curve.

An example with npm run-scripts and Salinger

Let's say we have some scripts in our package.json:

"scripts": {
  "foo": "someCrazyComplicatedStuff && anotherComplicatedThingGoesRightHere",
  "bar": "aTaskThatRequiresYouToWriteThisLongScriptInOneLine",
  "fooBar": "npm run foo && npm run bar"
}

If we had used Salinger, the package.json would look like this:

"scripts": {
  "foo": "salinger foo",
  "bar": "salinger bar",
  "fooBar": "salinger fooBar"
}

And we would implement chaining logic in scripts/tasks.js:

var run = require('salinger').run

module.exports = {
  foo() {
    return run('crazy_complicated')
      .then(_ => run('another_complicated'))
  },
  bar() {
    return run('my_long_script')
  },
  fooBar() {
    this.foo().then(this.bar)
  }
}

(Normally we wouldn't have to return in tasks if we didn't reuse them.)

Finally, we would have the actual scripts in scripts/tasks/:

crazy_complicated.sh
another_complicated.js
my_long_script.rb

(Yes, they can be written in any scripting language.)

So, what did we do here? We have separated the entry points, the orchestration/chaining part and the actual script contents.

  • Keep the package.json clean and brief, it only has entry points to our build system.
  • Chain the tasks in a more familiar and powerful way, in a fresh environment.
  • Write the actual scripts in whatever way you want. CLI or programmatic; sh or js or... You decide.

Motivation

After spending some time with npm scripts, problems arise:

  • It's unpleasant reading and writing several long lines of CLI code in the package.json. Not eye candy, at best.
  • A json file is apparently not the most comfortable place to write the whole script contents in it. Its syntax rules are prohibitive against writing any complex code in it.
  • The way of creating and using variables is counterintuitive.
  • We can't use the programmatic API of a tool in the package.json without:
    • a) Writing the js code in one line as a parameter to node -e (full of backslashes).
    • b) Creating a separate file for it, which breaks the integrity of script definitions. We have to organize these separate scripts somehow.

As a general note, an ideal task runner should run any tasks we want it to. Not the only tasks that are compliant with its API.

Install

npm install --save-dev salinger

Getting started

We have a simple boilerplate project. It'll surely help to understand better what's going on. Really, check it out.

So, let's start a new project and use Salinger in it.

Initialize an empty project:

mkdir test-project
cd test-project
npm init -y

Make sure you've installed Salinger with this:

npm install --save-dev salinger

Let's have a dependency for our project:

npm install --save-dev http-server

Add a start script in the package.json which forwards to our start task:

"scripts": {
  "start": "salinger start"
}

Next, create a folder named scripts in the root directory of our project. We'll use this folder as the home directory for Salinger-related things. It will eventually look like this:

├─┬ scripts/
│ ├── env.js
│ ├── tasks.js
│ └─┬ tasks/
│   └── server.sh

First, let's create the tasks.js inside the scripts:

var run = require('salinger').run

module.exports = {
  start() {
    run('server')
  }
}

So, we have our start task that npm start will redirect to. It runs a script called server, so let's create it.

Create a folder named tasks inside the scripts. This folder will contain all future script files.

mkdir scripts/tasks

Create server.sh inside this folder, and copy the below code and save:

http-server -p $PORT

Last missing part: the script looks for a PORT environment variable but we didn't pass it.

Create env.js inside the scripts folder:

const PORT = process.env.PORT || '8081'

module.exports = {
  PORT
}

Variables you export from env.js is accessible from all scripts, via process.env.

Let's check what we got:

npm start
# starts an http server at 8081

Now that you can add more tasks, that executes different scripts, and chain them together.

At this point I recommend checking out the Salinger-Basic Boilerplate and reading the docs below to explore the possibilities.

Docs

Salinger.run()

Currently being the only member of Salinger's API, run takes two parameters and returns a Promise:

  • Filename of the script to run. This doesn't include the extension and it's not a path; just the filename. If Salinger finds a file with the supplied filename and a supported extension, it will execute it. Otherwise you'll see errors on your console.

    If there are multiple files with the same name but different extensions, only one of them will be selected every time (Lookup order is: sh, js, py, rb, pl, lua).

  • Optional - Environment variables specific to this run call. See: Environment variables.

run('do-things', {
  HELLO: 'world'
})

You can chain run calls just like any other Promise:

run('foo')
  .then(_ => run('bar'))
  .then(_ => run('bam'))

Concurrently executing scripts is a no-brainer:

run('foo')
run('bar')

You can use Promise.all, Promise.race etc.

One interesting pattern would be chaining and reusing the exported Salinger tasks:

// scripts/tasks.js
var run = require('salinger').run

module.exports = {

  lorem() {
    return run('foo')
  },

  ipsum() {
    return run('bar')
  },
  
  dolor() {
    this.lorem()
      .then(_ => run('grapes', { HERE: 'A_VARIABLE' }))
      .then(this.ipsum)
      .then(_ => run('trek'))
  }
}

Changing the default scripts directory

You can choose to have Salinger-related files in a different folder. If that is the case, just add this config to your package.json:

"config": {
  "salinger-home": "path/to/new-folder"
}

Now, you can move everything to that folder and Salinger will start to work with that path. Just be aware that you may need to fix any paths you set in env.js.

Environment variables

There must be a file named env.js in the salinger-home directory. Values exported from this module will be accessible to all tasks through process.env. A sample env.js may look like this:

var path = require('path')

const SRC = path.join(__dirname, '..', 'src')
const DIST = path.join(__dirname, '..', 'dist')

const PORT = 8080

module.exports = {
  SRC,
  DIST,
  PORT
}

This will extend the process.env during the execution of the scripts.

Also, Salinger's run method takes an optional second parameter which also extends process.env with the provided values. But, these values are available only for this specific run call. Let's say we run a script from a task, like this:

myTask(these, parameters, are, coming, from, CLI) {

  // Maybe do some logic depending on the values of CLI parameters.
  // ...

  run('my-script', {
  
    // Or inject those parameters as environment variables to a script
    these: these,
    parameters: parameters,
    are: are,
    from: from,
    CLI: CLI,
    
    // Let's pass a variable that conflicts with an existing key in the env.js
    PORT: 5001
    
  })
  
}

And, of course, add an entry point for this task to package.json:

"scripts": {
  "myTask": "salinger myTask hello there from planet earth"
}

Say, this is a production environment and there's already a PORT environment variable independent from all of these. Now when we run npm run myTask, that PORT variable will be overridden by 8080, since it's defined in env.js. And, since we specify that variable again, in the second parameter of run call of 'my-script', it gets overriden again just for this execution of 'my-script'. So, PORT is 5001 for my-script, only for this call.

What's exported from env.js, though, will be accessible from process.env (not persistent) during the execution of all scripts.

Trade-offs

This project doesn't claim to be a full-fledged build solution. It helps bringing some consistency and some freedom to the build scripts of projects, especially to the ones that are formerly written with npm run scripts.

Salinger currently doesn't (and, by nature, probably will never) use virtual-fs or streams, which puts it behind the tools like Gulp, in terms of build performance. If your priority is superior build performance, just use Gulp or whatever suits your needs better.

Salinger is more about freedom. It's ecosystem-free, learning-curve-free, provides freedom to choose betwen the CLI and the API. This freedom comes with little to no abstraction. Therefore, it has little to no performance improvements or optimizations.

Windows support

Salinger may or may not work on cmd.exe. Consider using one of these:

...and everything should be fine.

If you encounter a problem with Salinger on Windows, please see the Windows Issues and open a new one if necessary.

Contributing

See: CONTRIBUTING.md

Credits

Many thanks to Ufuk Sarp Selçok (@ufuksarp) for the project logo.