diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ebacd5..76c7113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - run: pnpm test - run: pnpm build - run: node bin/worktrellis.mjs --version + - run: node bin/worktrellis.mjs info --config examples/basic/worktrellis.config.ts --json - run: npm pack --dry-run compose-integration: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9023e65..8564c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to WorkTrellis are documented here. The project follows ## Unreleased +## 0.4.3 - 2026-08-04 + +### Fixed + +- Windows Git Bash shutdown now keeps the real WorkTrellis supervisor in the + foreground, while supervised application wrappers run in a separate hidden + console so one Ctrl+C cannot terminate every layer independently. +- Portless-wrapped Node commands no longer pass a `C:\Program Files\...` + executable through Portless 0.15.x's unquoted `cmd.exe` command string, and + other child arguments that need Windows command quoting are protected. +- Windows lost-state port recovery now discovers listener PIDs with + `Get-NetTCPConnection`, falling back to the system `netstat.exe`, and other + native diagnostic commands resolve explicitly from the Windows system + directory. + ## 0.4.2 - 2026-08-04 ### Added diff --git a/bin/worktrellis.mjs b/bin/worktrellis.mjs index c87bf40..ef39c51 100755 --- a/bin/worktrellis.mjs +++ b/bin/worktrellis.mjs @@ -4,18 +4,15 @@ // The CLI itself is compiled, but it runs through tsx so projects can keep a // typed `worktrellis.config.ts` without installing a loader of their own. -import { spawn } from "node:child_process"; -import { createRequire } from "node:module"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); const cli = path.join(here, "..", "dist", "cli.js"); -let tsxCli; +let register; try { - const require = createRequire(import.meta.url); - tsxCli = require.resolve("tsx/cli"); + ({ register } = await import("tsx/esm/api")); } catch { console.error( "WorkTrellis could not load its TypeScript runtime. Reinstall the worktrellis package.", @@ -23,12 +20,8 @@ try { process.exit(3); } -const child = spawn(process.execPath, [tsxCli, cli, ...process.argv.slice(2)], { - stdio: "inherit", - windowsHide: true, -}); - -child.on("exit", (code, signal) => { - if (signal) process.kill(process.pid, signal); - else process.exit(code ?? 0); -}); +// Run the compiled CLI in this process. An npm-bin relay process exits on the +// first Windows Ctrl+C before the real supervisor finishes its async cleanup, +// returning Git Bash to a prompt while descendants are still shutting down. +register(); +await import(pathToFileURL(cli).href); diff --git a/docs/cli.md b/docs/cli.md index aaf4e74..ada7ae0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -56,6 +56,12 @@ listener, walks to the highest live ancestor whose command line belongs to the current worktree, and refuses to signal the tree when ownership cannot be verified. +On Windows, supervised application wrappers use a separate hidden console so +Git Bash's console-wide Ctrl+C is handled by WorkTrellis first. The npm command +remains attached until port verification and any required descendant cleanup +finish. After a normal single-Ctrl+C shutdown, returning to the shell prompt +therefore means cleanup has completed. + ### `worktrellis status` Reports the worktree URL, supervised-process state, Compose health, and diff --git a/package.json b/package.json index 010993c..a9e1d29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worktrellis", - "version": "0.4.2", + "version": "0.4.3", "type": "module", "description": "Coordinate project-owned Compose stacks, isolated resources, ports, and environments across Git worktrees.", "license": "MIT", diff --git a/src/platform/port-owner.ts b/src/platform/port-owner.ts index a6fb033..92aeb0e 100644 --- a/src/platform/port-owner.ts +++ b/src/platform/port-owner.ts @@ -1,6 +1,10 @@ import { spawnSync } from "node:child_process"; -import { IS_WINDOWS, run } from "../util/proc"; +import { + IS_WINDOWS, + resolveWindowsSystemExecutable, + run, +} from "../util/proc"; import type { ContainerEngine } from "./engine"; import { isPortAvailable } from "./ports"; @@ -68,11 +72,15 @@ function foreignHolder( protocol: "tcp" | "udp", ): PortOwner { if (IS_WINDOWS) { - const netstat = spawnSync("netstat", ["-ano", "-p", protocol], { - encoding: "utf8", - windowsHide: true, - timeout: 15_000, - }); + const netstat = spawnSync( + resolveWindowsSystemExecutable("netstat.exe"), + ["-ano", "-p", protocol], + { + encoding: "utf8", + windowsHide: true, + timeout: 15_000, + }, + ); if (netstat.status !== 0 || !netstat.stdout) return { kind: "unknown" }; for (const line of netstat.stdout.split(/\r?\n/)) { @@ -86,7 +94,7 @@ function foreignHolder( // tasklist is far cheaper than starting PowerShell just for an image name. const tasklist = spawnSync( - "tasklist", + resolveWindowsSystemExecutable("tasklist.exe"), ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { encoding: "utf8", windowsHide: true, timeout: 15_000 }, ); diff --git a/src/supervise/reaper.ts b/src/supervise/reaper.ts index abb14fc..61fece1 100644 --- a/src/supervise/reaper.ts +++ b/src/supervise/reaper.ts @@ -110,8 +110,8 @@ export async function reapOrphans( continue; } - const normalized = info.commandLine.replace(/\\/g, "/").toLowerCase(); - const expected = child.cmdMustContain.replace(/\\/g, "/").toLowerCase(); + const normalized = normalizeProcessCommandLine(info.commandLine); + const expected = normalizeProcessCommandLine(child.cmdMustContain); if (!normalized.includes(expected)) { result.blocked.push({ @@ -211,7 +211,7 @@ export async function reapApplicationPort( } const described = describeWithLiveAncestors(listeners); - const expected = worktreeRoot.replace(/\\/g, "/").toLowerCase(); + const expected = normalizeProcessCommandLine(worktreeRoot); const earliestRecordedStart = options.record?.children .map((child) => child.startedAtMs) .filter(Number.isFinite) @@ -283,7 +283,7 @@ function belongsToWorktree( expectedRoot: string, earliestRecordedStart: number | undefined, ): boolean { - const command = processInfo.commandLine.replace(/\\/g, "/").toLowerCase(); + const command = normalizeProcessCommandLine(processInfo.commandLine); if (!command.includes(expectedRoot)) return false; if ( earliestRecordedStart !== undefined && @@ -295,6 +295,11 @@ function belongsToWorktree( return true; } +/** Normalize native and command-line-escaped Windows separators identically. */ +export function normalizeProcessCommandLine(value: string): string { + return value.replace(/\\+/g, "/").replace(/\/+/g, "/").toLowerCase(); +} + function describeWithLiveAncestors( pids: number[], ): Map { diff --git a/src/supervise/supervisor.ts b/src/supervise/supervisor.ts index b352b22..a1f7818 100644 --- a/src/supervise/supervisor.ts +++ b/src/supervise/supervisor.ts @@ -9,11 +9,7 @@ import { redactDiagnosticText } from "../core/env-resolve"; import { ensureDirectory } from "../util/fs"; import { c, info, NAMED_COLORS, PROCESS_COLORS, type Colorize } from "../util/log"; import { canConnect } from "../platform/ports"; -import { - IS_WINDOWS, - killTree, - requestCooperativeTreeShutdown, -} from "../util/proc"; +import { killTree, requestCooperativeTreeShutdown } from "../util/proc"; import { writeRunRecord, type RunChild, type RunRecord } from "./reaper"; import { clearShutdownRequest, @@ -74,6 +70,14 @@ export function sanitizeMultiplexedOutput(value: string): string { .replace(/\u001b\[(?:[0-9;?]*[HJf]|[0-9;]*[JK])/g, ""); } +/** Keep Windows wrappers out of Git Bash's console-wide Ctrl+C broadcast. */ +export function supervisedProcessIsolation(): { + detached: true; + windowsHide: true; +} { + return { detached: true, windowsHide: true }; +} + function resolveCommand( command: Command, projectRoot: string, @@ -267,11 +271,10 @@ export class Supervisor { cwd: this.options.projectRoot, env, stdio: ["ignore", "pipe", "pipe"], - // On POSIX this makes the child a process-group leader so the whole tree - // can be signalled at once. On Windows it would open a new console - // window, and taskkill /T walks the tree anyway. - detached: !IS_WINDOWS, - windowsHide: true, + // POSIX uses the new process group for tree signals. Windows uses a + // hidden, separate console so Git Bash's console-wide Ctrl+C reaches the + // WorkTrellis supervisor but not every nested wrapper independently. + ...supervisedProcessIsolation(), }); entry.child = child; diff --git a/src/url/portless.ts b/src/url/portless.ts index 92b9a0f..53233bf 100644 --- a/src/url/portless.ts +++ b/src/url/portless.ts @@ -146,6 +146,8 @@ export interface PortlessAppRunner { aliasName: string; listenPort: number; tailscale: boolean; + /** Portless 0.15.x joins Windows child argv into one cmd.exe string. */ + windowsCmdShell?: boolean; /** Allow Portless's own Tailscale CLI cleanup to finish before force-kill. */ cooperativeShutdownGraceMs?: number; } @@ -187,11 +189,14 @@ export function wrapCommandForPortless( const child = "node" in command ? [ - process.execPath, + path.basename(process.execPath), path.resolve(projectRoot, command.node[0]!), ...command.node.slice(1), ] : [command.bin, ...command.args]; + const compatibleChild = runner.windowsCmdShell + ? child.map(quoteWindowsCmdToken) + : child; return { node: [ @@ -202,11 +207,22 @@ export function wrapCommandForPortless( "--app-port", String(runner.listenPort), "--", - ...child, + ...compatibleChild, ], }; } +/** + * Portless 0.15.x invokes `cmd.exe /c` with `commandArgs.join(" ")` on + * Windows. Quote only tokens that cmd would otherwise split or interpret. + * The executable is normally the PATH-resolved `node.exe`, so a standard + * `C:\Program Files\nodejs` installation never appears in this command. + */ +export function quoteWindowsCmdToken(value: string): string { + if (!/[\s&|<>^()]/.test(value)) return value; + return `"${value.replace(/"/g, '\\"')}"`; +} + /** The hostname this workspace uses, without contacting the proxy. */ export function portlessAliasName( identity: WorkspaceIdentity, @@ -331,6 +347,7 @@ export async function resolvePortlessUrl( aliasName, listenPort, tailscale: options.tailscale ?? false, + windowsCmdShell: process.platform === "win32", ...(options.tailscale ? { cooperativeShutdownGraceMs: diff --git a/src/util/proc.ts b/src/util/proc.ts index faf7201..7ce0836 100644 --- a/src/util/proc.ts +++ b/src/util/proc.ts @@ -132,10 +132,14 @@ export function killTree( const force = signal === "SIGKILL"; const args = ["/pid", String(pid), "/T"]; if (force) args.push("/F"); - const result = spawnSync(windowsSystemExecutable("taskkill.exe"), args, { - stdio: "ignore", - windowsHide: true, - }); + const result = spawnSync( + resolveWindowsSystemExecutable("taskkill.exe"), + args, + { + stdio: "ignore", + windowsHide: true, + }, + ); return result.status === 0; } @@ -192,7 +196,7 @@ function signalProcessOrGroup(pid: number, signal: NodeJS.Signals): boolean { } } -function windowsSystemExecutable(name: string): string { +export function resolveWindowsSystemExecutable(name: string): string { const systemRoot = process.env.SystemRoot?.trim(); if (!systemRoot) return name; const candidate = path.join(systemRoot, "System32", name); @@ -365,8 +369,22 @@ export function listeningProcessIds(port: number): number[] { if (!Number.isInteger(port) || port <= 0 || port > 65_535) return []; if (IS_WINDOWS) { + const powershell = spawnSync( + windowsPowerShellExecutable(), + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`, + ], + { encoding: "utf8", windowsHide: true, timeout: 15_000 }, + ); + if (powershell.status === 0) { + return parseProcessIds(powershell.stdout ?? ""); + } + const result = spawnSync( - windowsSystemExecutable("netstat.exe"), + resolveWindowsSystemExecutable("netstat.exe"), ["-ano", "-p", "tcp"], { encoding: "utf8", windowsHide: true, timeout: 15_000 }, ); @@ -380,9 +398,13 @@ export function listeningProcessIds(port: number): number[] { { encoding: "utf8", timeout: 15_000 }, ); if (result.status !== 0 || !result.stdout) return []; + return parseProcessIds(result.stdout); +} + +export function parseProcessIds(output: string): number[] { return [ ...new Set( - result.stdout + output .split(/\r?\n/) .map((value) => Number.parseInt(value.trim(), 10)) .filter((pid) => Number.isInteger(pid) && pid > 0), diff --git a/test/runtime.test.ts b/test/runtime.test.ts index f482b69..ee55d8b 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -32,9 +32,11 @@ import { } from "../src/resources"; import { sanitizeMultiplexedOutput, + supervisedProcessIsolation, Supervisor, } from "../src/supervise/supervisor"; import { + normalizeProcessCommandLine, readRunRecord, reapApplicationPort, reapOrphans, @@ -49,12 +51,14 @@ import { import { isProcessAlive, killTree, + parseProcessIds, parseWindowsListeningProcessIds, } from "../src/util/proc"; import { resolveUrl } from "../src/url/provider"; import { parsePortlessSharingUrl, PORTLESS_TAILSCALE_CLEANUP_GRACE_MS, + quoteWindowsCmdToken, supportsReliablePortlessTailscale, wrapCommandForPortless, } from "../src/url/portless"; @@ -274,9 +278,34 @@ describe("WorkTrellis package boundary", () => { expect(runSelfCheck()).toBe(0); }); + + it("runs the npm bin in-process so Windows shutdown can finish", () => { + const launcher = fs.readFileSync( + path.join(process.cwd(), "bin", "worktrellis.mjs"), + "utf8", + ); + + expect(launcher).toContain('import("tsx/esm/api")'); + expect(launcher).not.toContain("node:child_process"); + }); }); describe("WorkTrellis process supervision", () => { + it("normalizes escaped Windows paths for ownership verification", () => { + expect( + normalizeProcessCommandLine( + String.raw`node.exe -e "D:\\a\\WorkTrellis\\WorkTrellis\\child.js"`, + ), + ).toContain("d:/a/worktrellis/worktrellis/child.js"); + }); + + it("isolates Windows wrappers from the caller's console interrupt", () => { + expect(supervisedProcessIsolation()).toEqual({ + detached: true, + windowsHide: true, + }); + }); + it("parses IPv4 and IPv6 Windows listeners without matching adjacent ports", () => { const output = [ " TCP 0.0.0.0:3202 0.0.0.0:0 LISTENING 19428", @@ -288,6 +317,12 @@ describe("WorkTrellis process supervision", () => { expect(parseWindowsListeningProcessIds(output, 3202)).toEqual([19428]); }); + it("parses unique listener PIDs returned by Windows PowerShell", () => { + expect(parseProcessIds("19428\r\n19428\r\n29124\r\n")).toEqual([ + 19428, 29124, + ]); + }); + it("prevents one multiplexed child from clearing sibling output", () => { expect( sanitizeMultiplexedOutput( @@ -478,7 +513,7 @@ setInterval(() => {}, 1000); } } }, - 10_000, + 30_000, ); it( @@ -1293,13 +1328,46 @@ describe("WorkTrellis Portless process delegation", () => { "--app-port", "3210", "--", - process.execPath, + path.basename(process.execPath), path.resolve("/project", "node_modules/next/dist/bin/next"), "dev", ], }); }); + it("quotes space-sensitive child tokens for Portless's Windows cmd wrapper", () => { + const projectRoot = path.resolve("/Users/Example Person/project"); + + expect(quoteWindowsCmdToken("C:\\Program Files\\nodejs\\node.exe")).toBe( + '"C:\\Program Files\\nodejs\\node.exe"', + ); + expect( + wrapCommandForPortless( + { node: ["node_modules/next/dist/bin/next", "dev"] }, + { + binary: path.join(projectRoot, "node_modules/portless/dist/cli.js"), + aliasName: "windows.test", + listenPort: 3202, + tailscale: false, + windowsCmdShell: true, + }, + projectRoot, + ), + ).toMatchObject({ + node: [ + path.join(projectRoot, "node_modules/portless/dist/cli.js"), + "--name", + "windows.test", + "--app-port", + "3202", + "--", + path.basename(process.execPath), + `"${path.join(projectRoot, "node_modules/next/dist/bin/next")}"`, + "dev", + ], + }); + }); + it("adds private sharing without changing local route ownership", () => { expect( wrapCommandForPortless(