Script types

A script entry declares exactly one variant. The variant is decided by which of four keys is present: command, extends, serial or parallel.

type PerOsCommand = {
  default: string;
  win32?: string;
  darwin?: string;
  linux?: string;
};

type KillSignal = 'SIGHUP' | 'SIGINT' | 'SIGQUIT' | 'SIGTERM' | 'SIGKILL';

type Common = {
  description?: string;
  cwd?: string;
  env?: Record<string, string>;
  envFile?: string;
};

// A delay without retries is meaningless, so the types refuse it.
type Retrying =
  | { retries: number; retryDelay?: number | 'exponential' }
  | { retries?: never; retryDelay?: never };

type Lifecycle = Retrying & {
  timeout?: number;
  killSignal?: KillSignal;
  killTimeout?: number;
  interactive?: boolean;
};

export type Script =
  | (Common & Lifecycle & { command: string | PerOsCommand; dependsOn?: string[] })
  | (Common & Lifecycle & { extends: string; appendArgs?: string[]; dependsOn?: string[] })
  | (Common & { serial: string[]; continueOnError?: boolean })
  | (Common & {
      parallel: string[];
      continueOnError?: boolean;
      successPolicy?: 'all' | 'first' | 'last';
    });

The published types also mark every field that belongs to another variant as never, so mixing two of them is a type error rather than a runtime one.

The same rules are enforced twice: by these types when the config is written, and by Rune when it is loaded. A fixture test asserts that anything tsc accepts, Rune accepts.

Shared fields

Every variant accepts these.

FieldTypeDefaultMeaning
descriptionstringnoneShown by rune list
cwdstringthe invoking packageWhere the script runs. A relative value resolves against the config that declared it; an absolute value is used as written
envRecord<string, string>{}Variables applied last, so they win over everything inherited
envFilestringnoneA dotenv file whose assignments fill gaps in the environment

A relative path belongs to the config that wrote it

cwd and envFile follow one rule: a relative path in a config is relative to that config, whichever directory the run started in.

// rune.config.ts, at the repository root
'start:api': { command: 'node server.js', cwd: 'apps/api' },

rune run start:api enters apps/api under the root from anywhere in the workspace. A package that narrows the script and writes its own cwd anchors on that package instead, because that package is the config that wrote the value.

Leaving cwd out is the way to say "wherever the run started". That is the default, and it is what makes one shared definition usable from each package in turn.

The command variant

test: {
  command: 'vitest run --coverage',
  description: 'Run unit tests',
}

command is handed to a shell, so operators work: tsc --build && node dist/main.js, jest | tee test.log, cross-env NODE_ENV=test vitest. Rune parses none of it. The shell is cmd.exe on Windows and /bin/sh elsewhere, or whatever npm_config_script_shell names.

Per-OS commands

Where one command cannot serve every platform, command takes an object. default is required and is used for any platform without its own entry.

clean: {
  command: {
    default: 'rm -rf dist',
    win32: 'if exist dist rmdir /s /q dist',
  },
}

Selection happens at resolution time, from the platform Rune is running on. rune inspect prints the selected string, not the object.

The extends variant

test: {
  extends: 'test',
  appendArgs: ['--maxWorkers=1'],
}

Resolves another script's command and appends arguments to it. Chains are followed transitively and cycles are rejected by name. See Inheritance and narrowing.

The group variants

ci: { serial: ['build', 'test', 'lint'] },
dev: { parallel: ['dev:api', 'dev:web'] },

Members are script names. A group carries no command and no lifecycle options; those belong on the members. See Groups.

Validation

Every rejection names the script it came from, because a config with thirty entries makes a message about "the shape" useless.

MistakeMessage
No variant keyscript `empty` has no command, then every script needs one of: `command`, `extends`, `serial`, `parallel`
Two variant keys``script ci sets both command and `extends``` , then what each one does
Misspelled field``script test has an unknown field `comand```, then the fields allowed there
Entry is not an objectscript `dev` must be an object; found a string
Not a default-exported objectrune.config.ts: a rune config must default-export an object, with an example
Per-OS command with no defaultscript `clean` has a per-operating-system `command` with no `default`

An unknown field is an error rather than a warning. Silent acceptance of retires instead of retries means the option never takes effect and nothing says so.

Diagnostics go to stderr on every platform. Standard output belongs to the script.