# liftoff solves the two-install problem of Node command line tools

> liftoff is a small library from the Gulp team that lets a command line tool find its local installation, find its configuration file by walking up the directory tree, and register a compiler for a non JavaScript config format. It is MIT licensed, at version 5.0.1, and requires Node 22.15 or newer.

**gulpjs/liftoff** — Launch your command line tool with ease.

- Repository: https://github.com/gulpjs/liftoff
- Stars: 841 · Forks: 50
- Language: JavaScript
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/gulpjs-liftoff

## The two-install problem, spelled out

The README opens with a scenario rather than a feature list, and the scenario is the whole design brief.

Say you are writing a CLI tool, called hacker in the example. You want to configure it with a Hackerfile. Because it is a Node tool, you install it locally in each project that uses it. But to get the hacker command onto your PATH, you also install it globally.

Now there are two copies of your tool, and the one on your PATH is the global one, which is not the one with the project's dependencies. The README says the goal is that when you run hacker, it uses the Hackerfile in your current directory and it executes using the local installation of your tool. It also wants the command to walk up the folder tree until it finds a Hackerfile, so you can run it from a subdirectory, and it wants to support launching from outside the project by specifying a working directory. Liftoff manages this.

The extra problem arrives when the config file is not plain JavaScript. If the Hackerfile is written in CoffeeScript, the tool has to load the compiler for it and register the extension with node so that requiring the file works. The README says Liftoff can do that, and a good deal more besides.

This is a small, unglamorous problem, and the fact that it needed a library is the argument for the library. Every Node CLI that has a local install and a project config reimplements the same directory walk and the same compiler registration.

## prepare and execute split the work in two

The usage example shows the shape of the API, and the two method names describe the two phases.

```js
const Liftoff = require("liftoff");

const Hacker = new Liftoff({
  name: "hacker",
  processTitle: "hacker",
  moduleName: "hacker",
  configName: "hackerfile",
  extensions: {
    ".js": null,
    ".json": null,
    ".coffee": "coffee-script/register",
  },
  v8flags: ["--harmony"], // or v8flags: require('v8flags')
});

Hacker.prepare({}, function (env) {
  Hacker.execute(env, function (env) {
    // Do post-execute things
  });
});
```

prepare does the discovery: it locates the local installation of the module, searches for the configuration file, and works out the environment, which arrives as the env argument. execute then runs the resolved entry point using that environment, and its callback fires after execution with the environment again, which is where the comment about doing post-execute things belongs.

The constructor options are where most of the behaviour lives, and the naming is worth reading closely. moduleName is the package to resolve locally, configName is the base name of the config file to look for, and processTitle sets what the process shows up as in a terminal or a process list. The name option is sugar that sets all three at once, which the README shows explicitly as two equivalent forms.

```js
const Hacker = Liftoff({ name: "hacker" });
```

One thing to note is that configName lookup is case-insensitive, so a configName of hackerfile finds Hackerfile, HackerFile and similar without extra configuration.

## The extensions map is a compiler registry

The extensions option answers a question the docs phrase as which extensions to include when searching for a configuration file, and, if an external module is needed to load a given extension, the module name should be specified as the value for the key.

The default is .js and .json, both mapped to null, which means no compiler is needed for them. Adding a key with a module name means Liftoff will try to require that module from the current working directory when it finds a config file with that extension.

```js
const MyApp = new Liftoff({
  name: "myapp",
  extensions: {
    ".js": null,
    ".json": null,
    ".coffee": "coffee-script/register",
  },
});
```

In that example Liftoff will look for myappfile{.js,.json,.coffee}, and if the config with the .coffee extension is found it will try to require coffee-script/require from the current working directory. The registration module is what teaches node how to handle the extension, which is the whole trick.

The mapping is not limited to JavaScript variants. The README also shows a case with an extension key of rc, which finds .myapp{rc}, and a case where the extensions value is require("interpret").jsVariants, which makes Liftoff automatically attempt to load the correct module for any JavaScript variant that interpret supports, as long as it does not need a register method.

The practical limit is the require from the current working directory. The compiler has to be installed in the project, not globally, which is consistent with the local install philosophy of the whole library.

## configFiles and path arguments control where the config is found

The configFiles option takes an array of configuration files to find, and the order of the array indicates the priority that config file overrides are applied in. The README notes this is useful if you want to support an.apprc file in addition to an appfile.js, and that if you only need a single configuration file you probably do not need it.

Each entry is a path argument, and the underlying search is delegated to the fined module, which accepts a string or an object with several keys. path is required and expands from a bare string. name is the basename of the file to find, with extensions appended during lookup. extensions overrides the extensions to append. cwd is the base directory of path if it is relative. findUp controls whether the path should be traversed upward to find the file, and it defaults to false.

The three examples show the range.

```js
const MyApp = new Liftoff({
  name: "hacker",
  configFiles: [{ name: ".hacker", path: "." }],
});
```

That looks for the.hacker.js file relative to the cwd declared in configFiles.

```js
const MyApp = new Liftoff({
  name: "hacker",
  configFiles: [
    {
      name: ".hacker",
      path: "~",
      extensions: {
        rc: null,
      },
    },
  ],
});
```

The second of those looks for.hackerrc in the home directory, which is the user level configuration case, and the third shows the upward traversal.

```js
const MyApp = new Liftoff({
  name: "hacker",
  configFiles: [
    {
      name: ".hacker",
      path: ".",
      findUp: true,
    },
  ],
});
```

That one looks in the cwd and then walks up the tree for the.hacker.js file, which is the behaviour the opening scenario describes.

## v8flags sends flags to node rather than to your program

The v8flags option exists because some flags belong to the runtime and not to the application. The README gives the case of myapp --harmony command, where the --harmony should be passed to node and not to the program.

The implementation uses the flagged-respawn module, which re-executes the process with the extra flags when they are present. The option accepts an array or a function, and if it is a function it should take a node style callback that yields an array of flags, which is the shape the v8flags package produces.

```js
v8flags: ["--harmony"], // or v8flags: require('v8flags')
```

There is a second small feature in the same family. The processTitle option sets what the process title will be, and the completions option is a method for handling bash or zsh completions. The completions hook exists because a CLI that has a completion script needs to print it when invoked in a special way, and how that is done differs by shell.

Taken together, the options cover the parts of CLI plumbing that are tedious and easy to get wrong: which copy of the tool runs, where its configuration comes from, how a non JavaScript config format is compiled, and which flags belong to the runtime.

## A Node 22 floor and a five year gap between major versions

The package metadata records version 5.0.1, MIT licensed, with an engines field requiring node 22.15.0 or newer. That is a strict floor, and it is the kind of requirement that eliminates liftoff from the dependency list of a tool that still supports Node 14 or 16.

The release history is short at the top. v5.0.1 was published on 2025-06-01, v5.0.0 on 2024-03-16, and v4.0.0 on 2021-11-22, so the gap between the last two major versions is about two and a half years. The last push to the repository was on 2026-10-12, and the project is not archived, so the code is being touched even though the tagged releases are not frequent.

The dependency list is short and tells you the shape of the implementation: extend and is-plain-object for options handling, findup-sync and fined for the file search, flagged-respawn for the V8 flags, rechoir for the compiler registration, and resolve for module resolution. None of them is large, which is what you want in something that runs before your tool does.

The published files are index.js, lib and the LICENSE, and the test setup is mocha with nyc for coverage, run with the --async-only flag, with eslint and prettier as the lint and format steps. The repository also carries a .devcontainer directory, so contributors can work in a container.

## When liftoff is the wrong dependency

Three situations make it unnecessary.

The first is a tool that is only installed globally and has no project local copy. If there is no local install to find and no project directory with a config file, the discovery machinery has nothing to do, and the tool can require its own config directly.

The second is a tool that is not Node. Liftoff is a Node library that resolves a Node module and registers a Node extension handler. A tool written in Go, Rust or Python has its own conventions for locating a config file, and using a Node library to do it would mean shipping Node as well.

The third is an ES modules tool with no CommonJS entry point. The usage example begins with require("liftoff"), and the package itself is CommonJS with main pointing at index.js. A tool that is purely ESM, with type set to module and no require export, does not fit the model liftoff assumes, since the whole design is about a local require of the tool and a require of the config file.

The case that remains is narrower than it might appear: a Node CLI, installed both globally and locally, with a project level configuration file, that wants to support more than one config format. For that case liftoff saves real work, and the cost is one dependency and a Node version floor.

## Conclusion

liftoff fits a Node command line tool that is installed both globally for the PATH and locally per project, and that expects to read a configuration file from the working directory tree, because that is the exact arrangement liftoff automates. It is the wrong choice for a single binary or a tool that is only ever installed globally, and for a tool that has moved to ES modules without a CommonJS entry point. Before adopting it, confirm your tool still has a CommonJS entry point, check that your config files can be discovered by the fined based lookup, and note that liftoff v5 requires Node 22.15 or newer, which rules out the older runtimes many command line tools still support.

## FAQ

### What does liftoff do for a Node command line tool?

It manages the arrangement where a tool is installed both globally for the PATH and locally in each project. It finds the local installation, searches for the configuration file by walking up the directory tree, and can register a compiler so that a config file written in another JavaScript variant can be required.

### How do I register a compiler for a CoffeeScript config file with liftoff?

Map the extension to the registration module in the extensions option, for example ".coffee": "coffee-script/register". Liftoff will then look for myappfile{.js,.json,.coffee} and require that module from the current working directory when it finds the .coffee file.

### What is the difference between the name option and the individual options in liftoff?

The name option is sugar that sets processTitle, moduleName and configName at once, so Liftoff({ name: "hacker" }) is equivalent to passing processTitle, moduleName and configName separately with the same values.

### How do I make liftoff search parent directories for the config file?

Use configFiles with a path argument that sets findUp to true, and set path to ".". The README's example looks for the .hacker.js file in the cwd and then walks up the tree for it.

### What Node version does liftoff 5 require?

The package metadata sets engines.node to >= 22.15.0, and the current version is 5.0.1, published on 2025-06-01. The project is MIT licensed.

## Sources

- [gulpjs/liftoff on GitHub](https://github.com/gulpjs/liftoff)
- [Issues](https://github.com/gulpjs/liftoff/issues)
- [License: MIT](https://github.com/gulpjs/liftoff/blob/main/LICENSE)
- [README](https://github.com/gulpjs/liftoff/blob/main/README.md)
- [Releases](https://github.com/gulpjs/liftoff/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/gulpjs-liftoff
