https://github.com/haltcase/run
Flexible, function-based task runner where command line options are props.
https://github.com/haltcase/run
Last synced: 6 months ago
JSON representation
Flexible, function-based task runner where command line options are props.
- Host: GitHub
- URL: https://github.com/haltcase/run
- Owner: haltcase
- License: mit
- Created: 2024-11-02T02:17:11.000Z (about 1 year ago)
- Default Branch: main
- Last Pushed: 2025-04-13T02:15:09.000Z (9 months ago)
- Last Synced: 2025-06-26T08:15:44.426Z (7 months ago)
- Language: TypeScript
- Size: 502 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: readme.md
- License: license
Awesome Lists containing this project
README
# `@haltcase/run` · [](https://www.npmjs.com/package/@haltcase/run) [](https://www.npmjs.com/package/@haltcase/run) [](https://haltcase.dev/style)
> Flexible, function-based task runner where command line options are props.
## Features
- ✅ Write task files in TypeScript or plain JavaScript
- ✅ [Execa] built in for shell command execution
- ✅ Tasks are "just functions" exported from your script files
- ✅ Integrated [Arktype] schema support for stricter task execution
## Quick start
Install `@haltcase/run`:
```shell
# pnpm
pnpm add --save-dev @haltcase/run
# npm
npm install --save-dev @haltcase/run
# yarn
yarn add --dev @haltcase/run
```
Create a file in a scripts folder at the root of your project and export your
tasks — they're just functions! [¹](#tasks)
```js
// scripts/tasks.js
export const hello = ({ name }) => {
console.log(`Hello, ${name}!`);
};
```
Now you can execute these functions and pass options from the command line:
```shell
pnpm hr tasks hello --name World
# → Hello, World!
```
## Task files
Task files are simple JavaScript or TypeScript files located (by default) in
the `scripts` folder within your current working directory. Organize them
however you like.
```
.
└── scripts/
├── build.js
├── database-tasks.ts
└── dev.ts
```
To execute a task within one of these files, pass the name of the file
and the name of the task. For example:
```shell
pnpm hr database-tasks seed
```
> [!NOTE]
> It is allowed (although confusing) to have multiple task files with the
> same base name, however you must supply a file extension to make it
> unambiguous which file you are referring to.
>
> ```shell
> ✖ Found multiple task files with the name 'miscellaneous'
> Rename the ambiguous files or specify an extension and try again
> C:\dev\sources\js\skrrt\scripts\miscellaneous.js
> C:\dev\sources\js\skrrt\scripts\miscellaneous.ts
> C:\dev\sources\js\skrrt\scripts\miscellaneous.mjs
> ```
> [!TIP]
> If you want `*.js` task files to use a different module type from your root
> project, add a `package.json` to your scripts folder that specifies
> `"type": "module"` or `"type": "commonjs"`.
### CommonJS support
While TypeScript or ESM are much more highly recommended, you can also write
task files in CommonJS. Use the `*.cjs` file extension or create a package.json
file in your scripts folder with `"type": "commonjs"` and use `exports`.
```js
// scripts/greetings.cjs
exports.hello = ({ name }) => {
console.log(`Hello, ${name}!`);
};
```
## Tasks
While tasks can be as simple as an exported function, they're capable of more.
### TypeScript types
So far, all tasks have been simple functions. However, you can get type-safety
by using the `task` function from `@haltcase/run`:
```ts
// scripts/hello.ts
import type { ParsedOptions } from "@haltcase/run";
import { task } from "@haltcase/run";
interface HelloProps extends ParsedOptions {
name: string;
}
export const hello = task(({ name }) => {
console.log(`Hello, ${name}!`);
});
```
### Asynchronous
Tasks can return a Promise:
```js
export const getUser = async ({ id }) => {
const userResponse = await fetch(
`https://fakerapi.it/api/v2/custom?_quantity=1&id=${id}&email=email&website=website`
);
const { data } = await userResponse.json();
console.log(data[0]);
};
```
### Positional arguments
Aside from named options of the form `--option`, task functions also receive
positional or unnamed arguments as an array of strings in the `_` property.
```js
// scripts/greetings.js
export const hello = ({ name, _ }) => {
console.log(`Hello, ${name}! ${_.join(" ")}`);
};
```
```shell
pnpm hr greetings hello --name World Welcome to the cosmos!
# → Hello, World! Welcome to the cosmos!
```
Everything following a `--` option terminator will be treated as positional:
```shell
pnpm hr greetings hello --name World -- --ThisIsNotAnOption
# → Hello, World! --ThisIsNotAnOption
```
### Environment variables
Tasks also receive environment variables as the `env` property:
```js
// scripts/greetings.ts
export const hello = ({ env, name }) => {
if (env.GREET_LOUDLY) {
console.log(`HELLO, ${name.toUpperCase()}! LOUD GREETINGS TO YOU.`);
} else {
console.log(`Hello, ${name}. Quiet greetings to you.`);
}
};
```
Example using `sh` syntax to set an environment variable for a command:
```shell
pnpm hr greetings hello --name World
# → Hello, World. Quiet greetings to you.
GREET_LOUDLY=true pnpm hr greetings hello --name World
# → HELLO, WORLD! LOUD GREETINGS TO YOU.
```
You can also use other typical methods for loading an environment, including
`dotenv-cli`:
```shell
dotenv -c development -- pnpm hr greetings hello --name World
# → HELLO, WORLD! LOUD GREETINGS TO YOU.
```
By default, `env` is a full reference to Node's `process.env` and is a fairly
loose dictionary from string keys to values that are `string | undefined`. If
you want to validate specific environment variables, use
[`task.strict`](#taskstrict).
### Shell execution
Each task additionally receives a second argument, giving you access to shell
execution powered by [Execa].
```js
export const build = async ({ mode }, { $ }) => {
const { stdout } = await $`vite build --mode ${mode}`;
console.log(`stdout = ${stdout}`);
};
```
See [API](#api) for full API details.
### Task option validation using Arktype schemas
Using `task.strict`, you can define an [Arktype] schema to move
validations out of your task's logic. If you transform the input (via [Morphs]),
your task function's TypeScript types will infer the output type of the morph and your task will receive the updated type.
See [`task.strict`](#taskstrict) for more.
## Configuration
You can configure `@haltcase/run` using a `haltcase.run.{extension}` file in
your current working directory.
Configuration is loaded using [c12] — see the documentation there for the
list of supported configuration locations, file types, and other features.
```ts
// haltcase.run.ts
import { HaltcaseRunConfig } from "@haltcase/run";
export default {
taskDirectory: "./scripts",
quiet: false
} satisfies HaltcaseRunConfig;
```
You can also set options in the `haltcase.run` property of your package.json:
```jsonc
{
// ...
"haltcase.run": {
"taskDirectory": "./scripts",
"quiet": false
}
}
```
### TypeScript configuration
You will likely want to create a `tsconfig.json` file in your scripts folder,
for example:
```jsonc
{
// if you want to extend your root config
"extends": "../tsconfig.json",
"compilerOptions": {
// if you want to allow non-TypeScript task files
"allowJs": true,
"noEmit": true
},
"include": [
// feel free to remove extensions here
"**/*.ts",
"**/*.mts",
"**/*.cts",
"**/*.js",
"**/*.mjs",
"**/*.cjs"
],
"exclude": ["node_modules"]
}
```
## API
### Option parsing
Command line option parsing is done with Node's [`util.parseArgs`][nodeparseargs],
but all options are treated as `string` types. In other words, all options
expect values, meaning the following usage will throw an error:
```shell
pnpm hr greetings hello --name
```
If you want to pass a boolean value for an option, pass `true`/`false` and
parse it yourself or use [`task.strict`](#taskstrict), for example using the
Arktype schema: `type("string").pipe((value) => value === "true")`.
### `task`
`task` is a very light wrapper around a function that provides improved type
inference.
```ts
task(fn: Task): BrandedTask
```
Usage:
```ts
import type { ParsedOptions } from "@haltcase/run";
import { task } from "@haltcase/run";
// without providing a type parameter
export const defaultOptions = task((options) => {
console.log(options._); // ok, `_: string[]`
console.log(options.name); // ok, but `name: unknown` and no hints
});
interface CustomOptions extends ParsedOptions {
name: string;
}
// providing a type parameter
export const customOptions = task((options) => {
console.log(options.name); // ok, `name: string`
});
```
> [!IMPORTANT]
> Because all command line arguments are strings, you should only use `string`
> as the value type for options with this method. If you want to be stricter
> about value types by validating or transforming them, implement the parsing
> yourself or use [`task.strict`](#taskstrict) with an [Arktype] schema.
### `Task`
The type for a task function, which accepts two arguments: the parsed command
line options and a [`utilities`](#taskutilities) object providing tools for shell execution.
```ts
= (
options: TOptions,
utilities: TaskUtilities
) => unknown;
```
### `TaskUtilities`
Properties:
- `$` – Run a command using Execa's [script mode][execascript].
- `command` – Run a command using [Execa] (e.g., shell command or script).
- `exec` – Same as `command`, but inherits the parent process' stdio streams by default.
### `task.strict`
Provide the shape for an [Arktype] schema as the first argument and a task
function as the second. The task function will receive the safely parsed and
validated output of the Arktype schema, and its types will be inferred
automatically.
```ts
task.strict(shape: TShape, fn: Task): BrandedTaskStrict
```
Usage:
```ts
// scripts/strict-tasks.ts
import { task } from "@haltcase/run";
export const printCharacter = task.strict(
{
// positional/unnamed arguments (default type, can be omitted)
_: "string[]",
// environment variables (default type, can be omitted)
env: "Record",
// a simple type
name: "string",
// an input → output morph (transform string to number)
armorClass: "string.numeric.parse"
},
async ({ name, armorClass }) => {
console.log(`🎲 ${name}\n🛡️ ${armorClass}`);
}
);
```
This also allows you to parse, validate, and safely type the `_` property
beyond an array of strings and the `env` property more strictly than a simple
reference to Node's `process.env`. For instance:
```ts
// scripts/extra.ts
import { task } from "@haltcase/run";
import { type } from "arktype";
export const fun = task.strict(
{
_: type("string[]").pipe((values) => values.length),
env: {
// Arktype undeclared property handling (default is "ignore", aka passthrough)
"+": "delete",
SECRET_KEY: "string >= 8"
}
},
async ({ _, env }) => {
console.log(`Positionals count (_) = ${_}`);
console.log(`SECRET_KEY = ${env.SECRET_KEY}`);
}
);
```
```shell
pnpm hr extra fun because there are more words
# Error: Environment variable 'SECRET_KEY': must be a string (was missing)
SECRET_KEY=abcdefgh pnpm hr extra fun because there are more words
# → 5
# → abcdefgh
```
If you already have an Arktype type, you can pass it to `task.strict`:
```ts
// scripts/extra.ts
import { task } from "@haltcase/run";
import { type } from "arktype";
const inputSchema = type({
_: type("string[]").pipe((values) => values.length),
env: {
// undeclared property handling (default is "ignore", aka passthrough)
"+": "delete",
SECRET_KEY: "string >= 8"
}
});
export const fun = task.strict(inputSchema, async ({ _, env }) => {
console.log(`Positionals count (_) = ${_}`);
console.log(`SECRET_KEY = ${env.SECRET_KEY}`);
// using "delete" for undeclared keys strips other properties:
console.log(Object.keys(env));
// ["SECRET_KEY"]
});
```
[execa]: https://github.com/sindresorhus/execa#readme
[execascript]: https://github.com/sindresorhus/execa/blob/main/docs/scripts.md
[arktype]: https://arktype.io/
[morphs]: https://arktype.io/docs/intro/morphs-and-more
[nodeparseargs]: https://nodejs.org/api/util.html#utilparseargsconfig
[c12]: https://github.com/unjs/c12?tab=readme-ov-file#-features