Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ src/
- `T` toggles an `HH:mm:ss.SSS` timestamp gutter in TUI mode; also enabled via `timestamps: true` config or `--timestamps` flag. Accepts a format string (e.g. `timestamps: "HH:mm:ss"`) with tokens: `YYYY`, `MM`, `DD`, `HH`, `hh`, `mm`, `ss`, `SSS`, `A`
- Compact keybinding hints in the status bar; `H` or `?` opens a full help overlay. Config lives in `src/ui/keybindings.ts`
- `autowrap: false` config opts into disabling the host terminal's autowrap (DECAWM, `\x1b[?7l`) at startup, restored (`?7h`) on shutdown (`App.disableAutowrap()`). Default is `true` (untouched). Works around an OpenTUI renderer bug: its incremental diff positions every run with an absolute cursor move and never relies on autowrap, but it also never disables it and skips its right-edge cursor re-home to avoid tripping a wrap — so a run that fills the last column wraps into column 1 of the next row, smearing right-pane output into the tab sidebar. Because the diff records the *intended* cell (`syncCell`), the stray cells never repaint until a full repaint (resize). Pane content still wraps inside its own VT grid — only the host emit-cursor wrap is turned off. Off by default because it changes a global terminal mode; the proper fix is upstream ([anomalyco/opentui#1187](https://github.com/anomalyco/opentui/issues/1187))
- `--serial` (or `serial: true`) runs one process at a time in display order (`sort`). Each waits for the previous to become ready — for one-shot commands, to exit. `dependsOn` still holds, so the order is reordered when needed to keep dependencies first (`ProcessManager.serialOrder()`). A failed process does not skip the rest of the chain, unlike a failed dependency
- Script-pattern extra args (`'npm:lint:* --fix'`): only npm gets a `--` separator. yarn and pnpm forward the args as-is and would pass a literal `--` to the script, which breaks flag parsing in Go/cobra CLIs like `supabase`; bun accepts either form
- Set `interactive: true` on processes that need stdin (REPLs, shells)
- Non-interactive panes hide the terminal cursor (shown during input mode)
- Set `errorMatcher: true` to detect ANSI red output, or a regex string to match custom patterns — shows a red indicator on the tab while the process keeps running
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ scripts that have sub-scripts beneath them. E.g. if `format:check` has
`format:check` but keeps the leaf scripts.

**Extra args:** Anything after the first space in the pattern is forwarded
as extra arguments to each matched command: `lint:* --fix` → `bun run lint:js -- --fix`.
as extra arguments to each matched command: `lint:* --fix` → `bun run lint:js --fix`.
npm is the only package manager that needs a `--` separator, so it gets one:
`npm run lint:js -- --fix`.

**Template inheritance:** Config properties on a pattern entry (color, env,
dependsOn, etc.) are inherited by all expanded processes. Color arrays are
Expand Down Expand Up @@ -220,6 +222,7 @@ export default defineConfig({
| `-e,` `--env-file` `<path|false>` | Env file path, or "false" to disable env file loading |
| `--config` `<path>` | Config file path (default: auto-detect) |
| `-p,` `--prefix` | Prefixed output mode (no TUI, for CI/scripts) |
| `--serial` | Run one process at a time, in display order |
| `-o,` `--only` `<a,b,...>` | Only run these processes (+ their dependencies) |
| `-x,` `--exclude` `<a,b,...>` | Exclude these processes |
| `--kill-others` | Kill all processes when any exits (regardless of exit code) |
Expand Down Expand Up @@ -280,6 +283,7 @@ Top-level options apply to all processes (process-level settings override):
| `watch` | `string \| string[]` | Global watch patterns, inherited by processes without their own watch |
| `sort` | `'config' \| 'alphabetical' \| 'topological' \| 'status'` | Tab display order. `'config'` preserves definition order (package.json script order for wildcards), `'alphabetical'` sorts by process name, `'topological'` sorts by dependency tiers, `'status'` uses config order but moves finished/stopped/failed/skipped tabs to the bottom. |
| `prefix` | `boolean` | Use prefixed output mode instead of TUI (for CI/scripts) |
| `serial` | `boolean` | Run one process at a time, in display order (see `sort`). Each process waits for the previous one to become ready — for one-shot commands, to exit. Dependencies still hold: a process never starts before what it depends on. |
| `timestamps` | `boolean \| string` | Add timestamps to output lines. `true` uses default `HH:mm:ss.SSS` format, or pass a format string (e.g. `"HH:mm:ss"`) |
| `killOthers` | `boolean` | Kill all processes when any one exits (regardless of exit code) |
| `killOthersOnFail` | `boolean` | Kill all processes when any one exits with a non-zero exit code |
Expand Down
6 changes: 6 additions & 0 deletions src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ export const FLAGS: FlagDef[] = [
key: 'prefix',
description: 'Prefixed output mode (no TUI, for CI/scripts)'
},
{
type: 'boolean',
long: '--serial',
key: 'serial',
description: 'Run one process at a time, in display order'
},
{
type: 'value',
long: '--only',
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ParsedArgs {
logsProcess?: string
completions?: string
prefix: boolean
serial: boolean
killOthers: boolean
killOthersOnFail: boolean
timestamps: boolean | string
Expand Down Expand Up @@ -58,6 +59,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
exec: false,
logs: false,
prefix: false,
serial: false,
killOthers: false,
killOthersOnFail: false,
timestamps: false,
Expand Down
28 changes: 28 additions & 0 deletions src/config/expand-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,34 @@ describe('expandScriptPatterns', () => {
expect(proc(result, 'ts').command).toBe('npm run lint:ts -- --fix')
})

test('yarn gets no -- separator (it would reach the script as a literal arg)', () => {
const dir = setupDir('args-yarn', {
'package.json': pkgJson({ 'codegen:ts': 'gen-types', 'codegen:go': 'gen-go' }),
'yarn.lock': ''
})
const result = expandScriptPatterns({ processes: { 'npm:codegen:* --project-id abc': {} } }, dir)
expect(proc(result, 'ts').command).toBe('yarn run codegen:ts --project-id abc')
expect(proc(result, 'go').command).toBe('yarn run codegen:go --project-id abc')
})

test('pnpm gets no -- separator', () => {
const dir = setupDir('args-pnpm', {
'package.json': pkgJson({ lint: 'eslint' }),
'pnpm-lock.yaml': ''
})
const result = expandScriptPatterns({ processes: { 'npm:lint --fix': {} } }, dir)
expect(proc(result, 'lint').command).toBe('pnpm run lint --fix')
})

test('bun gets no -- separator', () => {
const dir = setupDir('args-bun', {
'package.json': pkgJson({ lint: 'eslint' }),
'bun.lock': ''
})
const result = expandScriptPatterns({ processes: { 'npm:lint --fix': {} } }, dir)
expect(proc(result, 'lint').command).toBe('bun run lint --fix')
})

test('multiple extra args forwarded', () => {
const dir = setupDir('args-multi', {
'package.json': pkgJson({ 'lint:js': 'eslint' })
Expand Down
13 changes: 10 additions & 3 deletions src/config/expand-scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,16 @@ function splitPatternArgs(raw: string): { glob: string; extraArgs: string } {
return { glob: raw.slice(0, i), extraArgs: raw.slice(i) }
}

/** Convert a script name (with optional extra args) into a `<pm> run <script>` command */
/** Convert a script name (with optional extra args) into a `<pm> run <script>` command.
*
* Only npm needs a `--` separator — it swallows args that come after the script
* name. yarn and pnpm forward those args as-is and pass a literal `--` through to
* the script (which breaks flag parsing in most CLIs); bun accepts either form. */
function expandScriptCommand(raw: string, pm: PackageManager): string {
const { glob: script, extraArgs } = splitPatternArgs(raw)
if (extraArgs) {
return `${pm} run ${script} --${extraArgs}`
const separator = pm === 'npm' ? ' --' : ''
return `${pm} run ${script}${separator}${extraArgs}`
}
return `${pm} run ${script}`
}
Expand All @@ -104,7 +109,9 @@ function expandScriptCommand(raw: string, pm: PackageManager): string {
* `format:check` but keeps the leaf scripts.
*
* **Extra args:** Anything after the first space in the pattern is forwarded
* as extra arguments to each matched command: `lint:* --fix` → `bun run lint:js -- --fix`.
* as extra arguments to each matched command: `lint:* --fix` → `bun run lint:js --fix`.
* npm is the only package manager that needs a `--` separator, so it gets one:
* `npm run lint:js -- --fix`.
*
* **Template inheritance:** Config properties on a pattern entry (color, env,
* dependsOn, etc.) are inherited by all expanded processes. Color arrays are
Expand Down
2 changes: 2 additions & 0 deletions src/config/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function validateConfig(raw: unknown, _warnings?: ValidationWarning[]): R

const sort = validateSort(config.sort)
const prefix = config.prefix === true ? true : undefined
const serial = config.serial === true ? true : undefined
const timestamps =
config.timestamps === true ? true : typeof config.timestamps === 'string' ? config.timestamps : undefined
const killOthers = config.killOthers === true ? true : undefined
Expand Down Expand Up @@ -181,6 +182,7 @@ export function validateConfig(raw: unknown, _warnings?: ValidationWarning[]): R
return {
...(sort ? { sort } : {}),
...(prefix ? { prefix } : {}),
...(serial ? { serial } : {}),
...(timestamps ? { timestamps } : {}),
...(killOthers ? { killOthers } : {}),
...(killOthersOnFail ? { killOthersOnFail } : {}),
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ async function main() {
config = filterConfig(config, parsed.only, parsed.exclude)
}

if (parsed.serial) config.serial = true

const tiers = resolveDependencyTiers(config)
const names = Object.keys(config.processes)
const filterNote = parsed.only || parsed.exclude ? ' (filtered)' : ''
Expand All @@ -152,6 +154,9 @@ async function main() {
console.info(` ${name}: ${proc.command}${suffix}`)
}
}
if (config.serial) {
console.info(`\nSerial: one at a time (${new ProcessManager(config).serialOrder().join(' → ')})`)
}
printWarnings(warnings)
process.exit(0)
}
Expand Down Expand Up @@ -244,6 +249,10 @@ async function main() {
config.sort = parsed.sort as SortOrder
}

if (parsed.serial) {
config.serial = true
}

if (parsed.envFile !== undefined) {
for (const proc of Object.values(config.processes)) {
proc.envFile = parsed.envFile
Expand Down
98 changes: 98 additions & 0 deletions src/process/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,104 @@ describe('ProcessManager — startAll', () => {
}, 10000)
})

describe('ProcessManager — serial mode', () => {
test('runs one process at a time in display order', async () => {
const config: ResolvedNumuxConfig = {
serial: true,
processes: {
a: { command: 'sleep 0.3' },
b: { command: 'true' },
c: { command: 'true' }
}
}
const mgr = new ProcessManager(config)
const running: string[] = []
const startOrder: string[] = []
let maxConcurrent = 0
mgr.on(e => {
if (e.type === 'status' && e.status === 'starting') {
startOrder.push(e.name)
running.push(e.name)
maxConcurrent = Math.max(maxConcurrent, running.length)
}
if (e.type === 'exit') {
const i = running.indexOf(e.name)
if (i !== -1) running.splice(i, 1)
}
})

await mgr.startAll(80, 24)

expect(startOrder).toEqual(['a', 'b', 'c'])
expect(maxConcurrent).toBe(1)
await mgr.stopAll()
}, 10000)

test('a failed process does not skip the rest of the chain', async () => {
const config: ResolvedNumuxConfig = {
serial: true,
processes: {
first: { command: 'sh -c "exit 1"' },
second: { command: 'true' }
}
}
const mgr = new ProcessManager(config)

await mgr.startAll(80, 24)

expect(mgr.getState('first')?.status).toBe('failed')
expect(mgr.getState('second')?.status).toBe('finished')
await mgr.stopAll()
}, 10000)

test('display order is reordered so dependencies still run first', async () => {
const config: ResolvedNumuxConfig = {
serial: true,
processes: {
web: { command: 'true', dependsOn: ['api'] },
api: { command: 'true' }
}
}
const mgr = new ProcessManager(config)
const startOrder: string[] = []
mgr.on(e => {
if (e.type === 'status' && e.status === 'starting') startOrder.push(e.name)
})

await mgr.startAll(80, 24)

expect(startOrder).toEqual(['api', 'web'])
await mgr.stopAll()
}, 10000)

test('processes start concurrently without serial', async () => {
const config: ResolvedNumuxConfig = {
processes: {
a: { command: 'sleep 0.3' },
b: { command: 'sleep 0.3' }
}
}
const mgr = new ProcessManager(config)
const running: string[] = []
let maxConcurrent = 0
mgr.on(e => {
if (e.type === 'status' && e.status === 'starting') {
running.push(e.name)
maxConcurrent = Math.max(maxConcurrent, running.length)
}
if (e.type === 'exit') {
const i = running.indexOf(e.name)
if (i !== -1) running.splice(i, 1)
}
})

await mgr.startAll(80, 24)

expect(maxConcurrent).toBe(2)
await mgr.stopAll()
}, 10000)
})

describe('ProcessManager — skip propagation', () => {
test('skips dependents when a dependency fails', async () => {
const config: ResolvedNumuxConfig = {
Expand Down
39 changes: 39 additions & 0 deletions src/process/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,43 @@ export class ProcessManager {
}
}

/**
* Run order for serial mode: display order, adjusted so no process comes
* before something it depends on (which would deadlock the chain).
*/
serialOrder(): string[] {
const remaining = this.getProcessNames()
const placed = new Set<string>()
const order: string[] = []

while (remaining.length > 0) {
const ready = remaining.findIndex(name =>
(this.config.processes[name].dependsOn ?? []).every(d => placed.has(d) || !this.config.processes[d])
)
// -1 only on a dependency cycle, which the resolver rejects earlier
const [next] = remaining.splice(ready === -1 ? 0 : ready, 1)
placed.add(next)
order.push(next)
}

return order
}

async startAll(cols: number, rows: number): Promise<void> {
log('Starting all processes')
this.lastCols = cols
this.lastRows = rows

// Serial mode: each process also waits for the one before it in display order
const serialPredecessor = new Map<string, string>()
if (this.config.serial) {
const order = this.serialOrder()
log('Serial mode, run order:', order)
for (let i = 1; i < order.length; i++) {
serialPredecessor.set(order[i], order[i - 1])
}
}

// Create a ready promise per process — each resolves when that process is ready
const readyPromises = new Map<string, Promise<void>>()
const readyResolvers = new Map<string, () => void>()
Expand Down Expand Up @@ -107,6 +139,13 @@ export class ProcessManager {
await Promise.all(deps.map(d => readyPromises.get(d)!))
}

// A failed predecessor does not skip the rest of the chain — unlike a
// failed dependency, it only says the slot is free
const predecessor = serialPredecessor.get(name)
if (predecessor) {
await readyPromises.get(predecessor)!
}

if (this.stopping) {
resolve()
return
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ export interface NumuxConfig<K extends string = string> {
* @default false
*/
prefix?: boolean
/**
* Run one process at a time, in display order (see `sort`). Each process waits
* for the previous one to become ready — for one-shot commands, to exit.
* Dependencies still hold: a process never starts before what it depends on.
* @default false
*/
serial?: boolean
/** Add timestamps to output lines. `true` uses default `HH:mm:ss.SSS` format, or pass a format string (e.g. `"HH:mm:ss"`) */
timestamps?: boolean | string
/**
Expand Down Expand Up @@ -156,6 +163,7 @@ export interface ResolvedProcessConfig extends Omit<NumuxProcessConfig, 'depends
export interface ResolvedNumuxConfig {
sort?: SortOrder
prefix?: boolean
serial?: boolean
timestamps?: boolean | string
killOthers?: boolean
killOthersOnFail?: boolean
Expand Down
Loading