From dbaaf2d62d9bc1de99fe8a59f09cf880d5e017f3 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:39:16 +0700 Subject: [PATCH 1/9] fix: forward SIGINT to child process group in shell commands When running shell commands, the spawned child process is in a separate process group (due to detached: true). This causes Ctrl+C to not properly terminate the child. Forward SIGINT signals to the child's process group. --- src/commands/run-shell-command.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/commands/run-shell-command.ts b/src/commands/run-shell-command.ts index 64b0300..2d6d58d 100644 --- a/src/commands/run-shell-command.ts +++ b/src/commands/run-shell-command.ts @@ -4,12 +4,26 @@ import { Effect } from 'effect'; export const runShellCommand = (cmd: ShellCommand.Command) => Effect.scoped( Effect.gen(function* () { - const process = yield* cmd.pipe( + const childProcess = yield* cmd.pipe( ShellCommand.stdin('inherit'), ShellCommand.stdout('inherit'), ShellCommand.stderr('inherit'), ShellCommand.start, ); - return yield* process.exitCode; + + // Forward SIGINT to the child's process group since @effect/platform + // spawns with detached: true, putting the child in a separate group + const pid = childProcess.pid as number; + const forwardSigint = () => { + try { + process.kill(-pid, 'SIGINT'); + } catch {} + }; + process.on('SIGINT', forwardSigint); + yield* Effect.addFinalizer(() => + Effect.sync(() => process.removeListener('SIGINT', forwardSigint)), + ); + + return yield* childProcess.exitCode; }), ); From 101a3964de6ecd86fb747d41cd4b63dc82a65849 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:44:03 +0700 Subject: [PATCH 2/9] fix: use tree-kill for reliable Ctrl+C in multi-level process trees The previous approach (forwarding SIGINT to child's process group via -pid) didn't work because deep descendants like vite create their own process groups. Now recursively walks the process tree and sends SIGINT to every descendant individually. --- src/commands/run-shell-command.ts | 74 +++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/src/commands/run-shell-command.ts b/src/commands/run-shell-command.ts index 2d6d58d..f5b7fd4 100644 --- a/src/commands/run-shell-command.ts +++ b/src/commands/run-shell-command.ts @@ -1,29 +1,57 @@ import { Command as ShellCommand } from '@effect/platform'; +import { execSync, spawn } from 'child_process'; import { Effect } from 'effect'; +function getDescendantPids(pid: number): number[] { + try { + const children = execSync(`pgrep -P ${pid}`, { encoding: 'utf-8' }) + .trim() + .split('\n') + .filter(Boolean) + .map(Number); + return children.flatMap((child) => [child, ...getDescendantPids(child)]); + } catch { + return []; + } +} + +function killTree(pid: number, signal: NodeJS.Signals) { + const pids = [pid, ...getDescendantPids(pid)]; + for (const p of pids) { + try { + process.kill(p, signal); + } catch {} + } +} + +/** + * Spawns with detached: true so only pm receives terminal SIGINT, + * then forwards the signal to the entire child process tree. + * This handles multi-level trees where descendants create their own process groups. + */ export const runShellCommand = (cmd: ShellCommand.Command) => - Effect.scoped( - Effect.gen(function* () { - const childProcess = yield* cmd.pipe( - ShellCommand.stdin('inherit'), - ShellCommand.stdout('inherit'), - ShellCommand.stderr('inherit'), - ShellCommand.start, - ); + Effect.async((resume) => { + const standard = cmd as ShellCommand.StandardCommand; + const child = spawn(standard.command, standard.args as string[], { + stdio: 'inherit', + detached: true, + }); - // Forward SIGINT to the child's process group since @effect/platform - // spawns with detached: true, putting the child in a separate group - const pid = childProcess.pid as number; - const forwardSigint = () => { - try { - process.kill(-pid, 'SIGINT'); - } catch {} - }; - process.on('SIGINT', forwardSigint); - yield* Effect.addFinalizer(() => - Effect.sync(() => process.removeListener('SIGINT', forwardSigint)), - ); + const forwardSigint = () => { + killTree(child.pid!, 'SIGINT'); + }; + process.on('SIGINT', forwardSigint); - return yield* childProcess.exitCode; - }), - ); + child.on('close', (code) => { + process.removeListener('SIGINT', forwardSigint); + resume(Effect.succeed(code ?? 1)); + }); + child.on('error', (err) => { + process.removeListener('SIGINT', forwardSigint); + resume(Effect.fail(err)); + }); + return Effect.sync(() => { + process.removeListener('SIGINT', forwardSigint); + killTree(child.pid!, 'SIGTERM'); + }); + }); From d052796f2c9177c0692e47563b10560d901e27ab Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:51:02 +0700 Subject: [PATCH 3/9] fix: suppress pnpm ELIFECYCLE noise during Ctrl+C shutdown Pipe stdout/stderr (instead of inherit) so we can unpipe before killing the child tree. This suppresses pnpm's "ELIFECYCLE Command failed." message that appears when its child exits non-zero during shutdown. --- src/commands/run-shell-command.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/run-shell-command.ts b/src/commands/run-shell-command.ts index f5b7fd4..7e5b441 100644 --- a/src/commands/run-shell-command.ts +++ b/src/commands/run-shell-command.ts @@ -27,18 +27,23 @@ function killTree(pid: number, signal: NodeJS.Signals) { /** * Spawns with detached: true so only pm receives terminal SIGINT, * then forwards the signal to the entire child process tree. - * This handles multi-level trees where descendants create their own process groups. + * Pipes stdout/stderr so we can suppress shutdown noise (e.g. pnpm ELIFECYCLE). */ export const runShellCommand = (cmd: ShellCommand.Command) => Effect.async((resume) => { const standard = cmd as ShellCommand.StandardCommand; const child = spawn(standard.command, standard.args as string[], { - stdio: 'inherit', + stdio: ['inherit', 'pipe', 'pipe'], detached: true, }); + child.stdout!.pipe(process.stdout); + child.stderr!.pipe(process.stderr); + const forwardSigint = () => { - killTree(child.pid!, 'SIGINT'); + child.stdout!.unpipe(process.stdout); + child.stderr!.unpipe(process.stderr); + killTree(child.pid!, 'SIGTERM'); }; process.on('SIGINT', forwardSigint); From 4fbb1e3be0c0096d8cf5bf1c94e868d72c43f58a Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:51:06 +0700 Subject: [PATCH 4/9] fix: preserve TTY + graceful shutdown by targeting only foreign process groups - Use stdio: 'inherit' to preserve colors, escape codes, and terminal clearing - On Ctrl+C, only SIGTERM descendants in different process groups (e.g. vite) while leaving the PM's group (pnpm/bun) untouched - This lets bun handle SIGINT naturally (prints "Shutting down", kills vite, exits 0) and pnpm sees a clean exit (no ELIFECYCLE) - Effect.uninterruptible prevents the runtime from racing to kill children --- src/commands/run-shell-command.ts | 95 +++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/src/commands/run-shell-command.ts b/src/commands/run-shell-command.ts index 7e5b441..fbf9fa2 100644 --- a/src/commands/run-shell-command.ts +++ b/src/commands/run-shell-command.ts @@ -24,39 +24,72 @@ function killTree(pid: number, signal: NodeJS.Signals) { } } +/** Finds descendant PIDs that are in a different process group than the root */ +function getOtherGroupDescendants(rootPid: number): number[] { + const descendants = getDescendantPids(rootPid); + if (descendants.length === 0) return []; + try { + const allPids = [rootPid, ...descendants]; + const output = execSync( + `ps -o pid=,pgid= -p ${allPids.join(',')}`, + { encoding: 'utf-8' }, + ); + const rootPgid = rootPid; // detached child is its own process group leader + return output + .trim() + .split('\n') + .map((line) => { + const parts = line.trim().split(/\s+/); + return { pid: Number(parts[0]), pgid: Number(parts[1]) }; + }) + .filter((p) => p.pgid !== rootPgid && p.pid !== rootPid) + .map((p) => p.pid); + } catch { + return descendants; + } +} + /** - * Spawns with detached: true so only pm receives terminal SIGINT, - * then forwards the signal to the entire child process tree. - * Pipes stdout/stderr so we can suppress shutdown noise (e.g. pnpm ELIFECYCLE). + * Spawns with detached: true so only pm receives terminal SIGINT. + * On Ctrl+C, sends SIGTERM only to descendant processes in other process groups + * (e.g. vite), avoiding signaling pnpm which would kill bun before it can clean up. + * The exit cascades naturally: vite exits → bun exits → pnpm exits. */ export const runShellCommand = (cmd: ShellCommand.Command) => - Effect.async((resume) => { - const standard = cmd as ShellCommand.StandardCommand; - const child = spawn(standard.command, standard.args as string[], { - stdio: ['inherit', 'pipe', 'pipe'], - detached: true, - }); - - child.stdout!.pipe(process.stdout); - child.stderr!.pipe(process.stderr); + Effect.uninterruptible( + Effect.async((resume) => { + const standard = cmd as ShellCommand.StandardCommand; + const child = spawn(standard.command, standard.args as string[], { + stdio: 'inherit', + detached: true, + }); - const forwardSigint = () => { - child.stdout!.unpipe(process.stdout); - child.stderr!.unpipe(process.stderr); - killTree(child.pid!, 'SIGTERM'); - }; - process.on('SIGINT', forwardSigint); + const forwardSigint = () => { + const otherGroupPids = getOtherGroupDescendants(child.pid!); + if (otherGroupPids.length > 0) { + for (const p of otherGroupPids) { + try { + process.kill(p, 'SIGTERM'); + } catch {} + } + } else { + // No separate process groups — kill the tree directly + killTree(child.pid!, 'SIGTERM'); + } + }; + process.on('SIGINT', forwardSigint); - child.on('close', (code) => { - process.removeListener('SIGINT', forwardSigint); - resume(Effect.succeed(code ?? 1)); - }); - child.on('error', (err) => { - process.removeListener('SIGINT', forwardSigint); - resume(Effect.fail(err)); - }); - return Effect.sync(() => { - process.removeListener('SIGINT', forwardSigint); - killTree(child.pid!, 'SIGTERM'); - }); - }); + child.on('close', (code) => { + process.removeListener('SIGINT', forwardSigint); + resume(Effect.succeed(code ?? 1)); + }); + child.on('error', (err) => { + process.removeListener('SIGINT', forwardSigint); + resume(Effect.fail(err)); + }); + return Effect.sync(() => { + process.removeListener('SIGINT', forwardSigint); + killTree(child.pid!, 'SIGTERM'); + }); + }), + ); From 4741de12eb3c772599347d94c271e1831a583b29 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:07:42 +0700 Subject: [PATCH 5/9] fix: propagate cwd/env/shell from Command and guard against PipedCommand --- src/commands/run-shell-command.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/commands/run-shell-command.ts b/src/commands/run-shell-command.ts index fbf9fa2..bc502df 100644 --- a/src/commands/run-shell-command.ts +++ b/src/commands/run-shell-command.ts @@ -1,6 +1,6 @@ import { Command as ShellCommand } from '@effect/platform'; import { execSync, spawn } from 'child_process'; -import { Effect } from 'effect'; +import { Effect, HashMap, Option } from 'effect'; function getDescendantPids(pid: number): number[] { try { @@ -58,10 +58,17 @@ function getOtherGroupDescendants(rootPid: number): number[] { export const runShellCommand = (cmd: ShellCommand.Command) => Effect.uninterruptible( Effect.async((resume) => { + if ('_tag' in cmd && cmd._tag !== 'StandardCommand') { + throw new Error(`PipedCommand is not supported`); + } const standard = cmd as ShellCommand.StandardCommand; + const env = Object.fromEntries(HashMap.toEntries(standard.env)); const child = spawn(standard.command, standard.args as string[], { stdio: 'inherit', detached: true, + cwd: Option.getOrUndefined(standard.cwd), + env: { ...process.env, ...env }, + shell: standard.shell, }); const forwardSigint = () => { From ee02190a6b431c6870bde392bbb419cf23c17b86 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:09:05 +0700 Subject: [PATCH 6/9] fix: handle undefined child.pid on spawn failure --- src/commands/run-shell-command.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/commands/run-shell-command.ts b/src/commands/run-shell-command.ts index bc502df..8fb7b9f 100644 --- a/src/commands/run-shell-command.ts +++ b/src/commands/run-shell-command.ts @@ -71,8 +71,18 @@ export const runShellCommand = (cmd: ShellCommand.Command) => shell: standard.shell, }); + if (child.pid === undefined) { + resume( + Effect.fail( + new Error(`Failed to spawn process: ${standard.command}`), + ), + ); + return; + } + const pid = child.pid; + const forwardSigint = () => { - const otherGroupPids = getOtherGroupDescendants(child.pid!); + const otherGroupPids = getOtherGroupDescendants(pid); if (otherGroupPids.length > 0) { for (const p of otherGroupPids) { try { @@ -81,7 +91,7 @@ export const runShellCommand = (cmd: ShellCommand.Command) => } } else { // No separate process groups — kill the tree directly - killTree(child.pid!, 'SIGTERM'); + killTree(pid, 'SIGTERM'); } }; process.on('SIGINT', forwardSigint); @@ -96,7 +106,7 @@ export const runShellCommand = (cmd: ShellCommand.Command) => }); return Effect.sync(() => { process.removeListener('SIGINT', forwardSigint); - killTree(child.pid!, 'SIGTERM'); + killTree(pid, 'SIGTERM'); }); }), ); From 9c966a86985bb35f203b2f056bcfcd5c06f0ec52 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:10:04 +0700 Subject: [PATCH 7/9] fix: add timeout to execSync calls and skip tree-kill on Windows --- src/commands/run-shell-command.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commands/run-shell-command.ts b/src/commands/run-shell-command.ts index 8fb7b9f..a33b2da 100644 --- a/src/commands/run-shell-command.ts +++ b/src/commands/run-shell-command.ts @@ -3,8 +3,12 @@ import { execSync, spawn } from 'child_process'; import { Effect, HashMap, Option } from 'effect'; function getDescendantPids(pid: number): number[] { + if (process.platform === 'win32') return []; try { - const children = execSync(`pgrep -P ${pid}`, { encoding: 'utf-8' }) + const children = execSync(`pgrep -P ${pid}`, { + encoding: 'utf-8', + timeout: 3000, + }) .trim() .split('\n') .filter(Boolean) @@ -26,13 +30,14 @@ function killTree(pid: number, signal: NodeJS.Signals) { /** Finds descendant PIDs that are in a different process group than the root */ function getOtherGroupDescendants(rootPid: number): number[] { + if (process.platform === 'win32') return []; const descendants = getDescendantPids(rootPid); if (descendants.length === 0) return []; try { const allPids = [rootPid, ...descendants]; const output = execSync( `ps -o pid=,pgid= -p ${allPids.join(',')}`, - { encoding: 'utf-8' }, + { encoding: 'utf-8', timeout: 3000 }, ); const rootPgid = rootPid; // detached child is its own process group leader return output From a1ee8b1cb6dd9ad865b0d046a754f44f65d6f6fc Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:10:59 +0700 Subject: [PATCH 8/9] fix: use process group signal when no foreign groups exist --- src/commands/run-shell-command.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/run-shell-command.ts b/src/commands/run-shell-command.ts index a33b2da..5d7fce0 100644 --- a/src/commands/run-shell-command.ts +++ b/src/commands/run-shell-command.ts @@ -95,8 +95,11 @@ export const runShellCommand = (cmd: ShellCommand.Command) => } catch {} } } else { - // No separate process groups — kill the tree directly - killTree(pid, 'SIGTERM'); + // All descendants share the child's process group (detached: true makes + // the child its own group leader). Sending SIGINT to the group (-pid) is + // equivalent to terminal Ctrl+C — it atomically signals every process in + // the group, unlike killTree which walks processes sequentially. + try { process.kill(-pid, 'SIGINT'); } catch {} } }; process.on('SIGINT', forwardSigint); From 91057389e5ad073f91ef49a22f934d763902ae99 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:17:17 +0700 Subject: [PATCH 9/9] docs: add passthrough commands and clean signal handling to README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 3059ecd..008fa68 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ A CLI for smarter package manager operations (especially in monorepos). - **Package manager agnostic** — works with pnpm and bun, no need to remember which one your project uses - **Scoped installs by default** — automatically installs only the current package, no more accidental full-monorepo installs +- **Clean signal handling** — `Ctrl+C` properly shuts down the entire process tree, no orphaned dev servers - **Easy navigation** — jump to any workspace package from anywhere https://github.com/user-attachments/assets/3d5496a9-91be-47dc-9e01-db8c5052c7c5 @@ -41,6 +42,8 @@ pm add Add a dependency (-D for dev) pm remove Remove a dependency pm ls List workspace packages as a tree pm cd cd into a workspace package +pm run