Skip to content
Open
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: 1 addition & 1 deletion src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const FLAGS: FlagDef[] = [
long: '--workspace',
short: '-w',
key: 'workspace',
description: 'Run a package.json script across all workspaces',
description: 'Run a script or package manager command across all workspaces',
valueName: '<script>',
completionHint: 'none'
},
Expand Down
31 changes: 29 additions & 2 deletions src/config/workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,41 @@ describe('resolveWorkspaceProcesses', () => {
expect(Object.keys(result)).toEqual(['web'])
})

test('error when no workspaces matched', () => {
test('error when no script and not a built-in command', () => {
const dir = setupMonorepo('no-match', {
rootPkg: { workspaces: ['packages/*'] },
workspaces: {
'packages/web': { name: 'web', scripts: { build: 'tsc' } }
}
})
expect(() => resolveWorkspaceProcesses('dev', dir)).toThrow('No workspaces have a "dev" script')
expect(() => resolveWorkspaceProcesses('xyznotacommand', dir)).toThrow('is not a built-in npm command')
})

test('built-in PM command runs in all workspaces', () => {
const dir = setupMonorepo('builtin-cmd', {
rootPkg: { workspaces: ['packages/*'] },
workspaces: {
'packages/web': { name: 'web', scripts: { dev: 'next dev' } },
'packages/api': { name: 'api', scripts: { dev: 'bun run api' } }
}
})
const result = resolveWorkspaceProcesses('install', dir)
expect(Object.keys(result).sort()).toEqual(['api', 'web'])
expect(result.web.command).toBe('npm install')
})

test('script takes priority over built-in command', () => {
const dir = setupMonorepo('script-priority', {
rootPkg: { workspaces: ['packages/*'] },
workspaces: {
'packages/web': { name: 'web', scripts: { test: 'vitest' } },
'packages/api': { name: 'api', scripts: { dev: 'bun run api' } }
}
})
// "test" is a built-in npm command, but web has a test script — so only web runs
const result = resolveWorkspaceProcesses('test', dir)
expect(Object.keys(result)).toEqual(['web'])
expect(result.web.command).toBe('npm run test')
})

test('PM detection reflected in command string', () => {
Expand Down
51 changes: 39 additions & 12 deletions src/config/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import { basename, resolve } from 'node:path'
import type { NumuxConfig, NumuxProcessConfig, ResolvedProcessConfig } from '../types'
import { detectPackageManager } from './expand-scripts'

type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'

/** Probe whether a command is a built-in package manager command by running
* `<pm> <command> --help` and checking the exit code. */
export function isBuiltinPmCommand(pm: PackageManager, command: string): boolean {
const result = Bun.spawnSync([pm, command, '--help'], {
stdout: 'ignore',
stderr: 'ignore'
})
return result.exitCode === 0
}

export interface WorkspaceInfo {
dir: string
/** Scope-stripped pkg.name or dir basename */
Expand Down Expand Up @@ -168,24 +180,39 @@ export function resolveWorkspaceProcesses(script: string, cwd: string): Record<s

for (const ws of workspaces) {
if (!ws.scripts[script]) continue
addWorkspace(processes, usedNames, ws, `${pm} run ${script}`)
}

let name = ws.name
if (usedNames.has(name)) {
let suffix = 1
while (usedNames.has(`${name}-${suffix}`)) suffix++
name = `${name}-${suffix}`
// If no workspace has the script, fall back to a built-in PM command
// (e.g. `install`, `outdated`) run across every workspace. Such commands
// exit on completion, so persistence is auto-detected (no readyPattern).
if (Object.keys(processes).length === 0) {
if (!isBuiltinPmCommand(pm, script)) {
throw new Error(`No workspaces have a "${script}" script and "${script}" is not a built-in ${pm} command`)
}
usedNames.add(name)

processes[name] = {
command: `${pm} run ${script}`,
cwd: ws.dir
for (const ws of workspaces) {
addWorkspace(processes, usedNames, ws, `${pm} ${script}`)
}
}

if (Object.keys(processes).length === 0) {
throw new Error(`No workspaces have a "${script}" script`)
return processes
}

function addWorkspace(
processes: Record<string, ResolvedProcessConfig>,
usedNames: Set<string>,
ws: WorkspaceInfo,
command: string
): void {
// Deduplicate names
let name = ws.name
if (usedNames.has(name)) {
let suffix = 1
while (usedNames.has(`${name}-${suffix}`)) suffix++
name = `${name}-${suffix}`
}
usedNames.add(name)

return processes
processes[name] = { command, cwd: ws.dir }
}
Loading