From 7932b3a08da0363a417aa825eb8035f6b2c8459b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 06:49:17 +0000 Subject: [PATCH 1/3] bun run dev: run the servers with bun run --parallel scripts/dev.ts supervised the server and the UI by hand: process groups of their own, signal handlers, SIGSTOP/SIGCONT for Ctrl+Z. The terminal already sends Ctrl+C, Ctrl+Z and hangup to the whole foreground process group, and `bun run --parallel` runs scripts side by side with prefixed output and stops the others when one fails. So `dev` is now `bun scripts/dev-prepare.ts && bun run --parallel dev:server dev:ui`. scripts/dev-prepare.ts keeps the rest of what dev.ts did: .env from .env.example on the first run, the plugin build (aborting before any server starts), the hint with the UI URL and login. HAUL_DEV_UI needs no default in the script any more: .env.example has carried it since the variable exists, and both Bun and the server read .env. dev.test.ts tested the supervisor and goes with it; dev-prepare.test.ts covers the prepare step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y3rdfbWb1xDUdPRng7TX9Z --- docs/development.md | 11 ++- package.json | 4 +- scripts/dev-prepare.test.ts | 40 +++++++++ scripts/dev-prepare.ts | 12 +++ scripts/dev.test.ts | 169 ------------------------------------ scripts/dev.ts | 54 ------------ 6 files changed, 61 insertions(+), 229 deletions(-) create mode 100644 scripts/dev-prepare.test.ts create mode 100644 scripts/dev-prepare.ts delete mode 100644 scripts/dev.test.ts delete mode 100644 scripts/dev.ts diff --git a/docs/development.md b/docs/development.md index 0e3fc27..776a8f2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,8 +22,11 @@ bun run dev `bun run dev` creates `.env` from [`.env.example`](https://github.com/firsttris/haul/blob/main/.env.example) on the first run, builds the plugins and starts the server (`:8080`) and the UI with hot reload (`:5173`). Open http://localhost:5173 and log in with `admin` / `adminadmin`. Data goes to `./.data`. -Ctrl+C, an IDE's stop (SIGTERM) or closing the terminal (SIGHUP) stops both with everything they -started; when one of them exits, the other is stopped too. Ctrl+Z suspends both, `fg` resumes them. +The first part is [`scripts/dev-prepare.ts`](https://github.com/firsttris/haul/blob/main/scripts/dev-prepare.ts); then +`bun run --parallel dev:server dev:ui` runs `cargo run -p haul` and `bun run --cwd ui dev` side by +side, each line prefixed with its script's name, and stops the other when one of them exits with an +error. Ctrl+C, Ctrl+Z and closing the terminal reach both, as the terminal signals the whole +foreground process group; `fg` resumes them after Ctrl+Z. The server reads `.env` itself, so `cargo run -p haul` works on its own too. Variables set in the environment take precedence. As long as no UI is built into the binary, `HAUL_DEV_UI` @@ -35,7 +38,7 @@ environment take precedence. As long as no UI is built into the binary, `HAUL_DE cargo test --workspace cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check -bun run test # bun run dev's process handling, SDK helpers and hoster plugins against a fake context +bun run test # bun run dev's prepare step, SDK helpers and hoster plugins against a fake context bun run typecheck ``` @@ -118,7 +121,7 @@ ui web UI extension browser extension for Chrome and Firefox (bun run build:extension) docker Dockerfile, Compose file and Podman Quadlet units docs this documentation (mkdocs.yml, requirements-docs.txt) -scripts dev.ts (bun run dev), build-plugins.ts, screenshots/ (bun run screenshots) +scripts dev-prepare.ts (bun run dev), build-plugins.ts, screenshots/ (bun run screenshots) .github/workflows CI, extension, docs, screenshots, bump and release ``` diff --git a/package.json b/package.json index db7a3f4..91ee52f 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,13 @@ "extension" ], "scripts": { - "dev": "bun scripts/dev.ts", + "dev": "bun scripts/dev-prepare.ts && bun run --parallel dev:server dev:ui", "dev:server": "cargo run -p haul", "dev:ui": "bun run --cwd ui dev", "build": "bun run build:plugins && bun run --filter @haul/ui build", "build:plugins": "bun scripts/build-plugins.ts", "build:extension": "bun run --filter @haul/extension build", - "test": "bun test ./scripts/dev.test.ts && bun run --filter @haul/ui i18n && bun --bun vitest run", + "test": "bun test ./scripts/dev-prepare.test.ts && bun run --filter @haul/ui i18n && bun --bun vitest run", "typecheck": "bun run --workspaces typecheck", "screenshots": "bun --bun playwright test -c scripts/screenshots/playwright.config.ts" }, diff --git a/scripts/dev-prepare.test.ts b/scripts/dev-prepare.test.ts new file mode 100644 index 0000000..7d5fbf6 --- /dev/null +++ b/scripts/dev-prepare.test.ts @@ -0,0 +1,40 @@ +// scripts/dev-prepare.ts, the first step of `bun run dev`, in a sandbox with a stand-in plugin build +import { afterEach, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const PREPARE = resolve(import.meta.dir, 'dev-prepare.ts'); +let dir = ''; + +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = ''; +}); + +/** Runs dev-prepare.ts in a checkout with `.env.example` and a plugin build exiting with `pluginsExit` */ +function prepare(pluginsExit = 0) { + dir ||= mkdtempSync(join(tmpdir(), 'haul-dev-')); + mkdirSync(join(dir, 'scripts'), { recursive: true }); + writeFileSync(join(dir, '.env.example'), 'HAUL_DEV_UI=http://localhost:5173\n'); + writeFileSync(join(dir, 'scripts', 'build-plugins.ts'), `process.exit(${pluginsExit});\n`); + return Bun.spawnSync([process.execPath, PREPARE], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] }); +} + +test('creates .env from .env.example on the first run', () => { + expect(prepare().exitCode).toBe(0); + expect(readFileSync(join(dir, '.env'), 'utf8')).toBe('HAUL_DEV_UI=http://localhost:5173\n'); +}); + +test('keeps an existing .env', () => { + dir = mkdtempSync(join(tmpdir(), 'haul-dev-')); + writeFileSync(join(dir, '.env'), 'HAUL_USER=me\n'); + expect(prepare().exitCode).toBe(0); + expect(readFileSync(join(dir, '.env'), 'utf8')).toBe('HAUL_USER=me\n'); +}); + +test('fails with the plugin build, so `bun run dev` starts no server', () => { + const run = prepare(3); + expect(run.exitCode).toBe(3); + expect(run.stdout.toString()).not.toContain('Haul-UI'); +}); diff --git a/scripts/dev-prepare.ts b/scripts/dev-prepare.ts new file mode 100644 index 0000000..25e83af --- /dev/null +++ b/scripts/dev-prepare.ts @@ -0,0 +1,12 @@ +// First step of `bun run dev`: creates .env on the first run, builds the plugins and prints where +// the UI is. The servers are started by `bun run --parallel dev:server dev:ui` after it. +import { copyFileSync, existsSync } from 'node:fs'; + +if (!existsSync('.env')) { + copyFileSync('.env.example', '.env'); + console.log('.env aus .env.example angelegt'); +} +const plugins = Bun.spawnSync(['bun', 'scripts/build-plugins.ts'], { stdio: ['inherit', 'inherit', 'inherit'] }); +if (plugins.exitCode !== 0) process.exit(plugins.exitCode ?? 1); + +console.log('\n Haul-UI: http://localhost:5173 (Login aus .env, Standard admin / adminadmin)\n'); diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts deleted file mode 100644 index baeca09..0000000 --- a/scripts/dev.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -// `bun run dev` (scripts/dev.ts) with stand-ins for `cargo run -p haul`, `bun run --cwd ui dev` and -// the plugin build: what is left running when dev.ts is stopped or one server exits. As with the -// real commands, `cargo` execs the server (cargo run replaces itself with the binary) and `bun run` -// starts it as its child; each server starts a process of its own, as haul starts 7z or unrar to -// extract and Vite starts esbuild. -import { afterEach, expect, test } from 'bun:test'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; - -const DEV = resolve(import.meta.dir, 'dev.ts'); - -let dir = ''; -let pids: number[] = []; -let dev: Bun.Subprocess | undefined; - -afterEach(() => { - for (const pid of [...pids, dev?.pid ?? 0]) { - if (!pid) continue; - try { - process.kill(pid, 'SIGKILL'); - } catch {} - } - pids = []; - dev = undefined; - if (dir) rmSync(dir, { recursive: true, force: true }); - dir = ''; -}); - -// A server stand-in: notes its pid and its child's and the HAUL_DEV_UI it got, then waits on the -// child (a sleep with the default signal handling), or, with SERVER_EXIT or UI_EXIT set, exits -// with that code once both servers run. -const SERVER = ` -const [name] = process.argv.slice(2); -const dir = process.env.SANDBOX; -const child = Bun.spawn(['sleep', '600'], { stdio: ['ignore', 'ignore', 'ignore'] }); -await Bun.write(dir + '/' + name + '.env', process.env.HAUL_DEV_UI ?? ''); -await Bun.write(dir + '/' + name + '.pids', process.pid + ' ' + child.pid); -const exit = process.env[name.toUpperCase() + '_EXIT']; -if (exit) { - while (!(await Bun.file(dir + '/ui.pids').exists()) || !(await Bun.file(dir + '/server.pids').exists())) await Bun.sleep(50); - child.kill('SIGKILL'); - process.exit(Number(exit)); -} -await child.exited; -`; - -/** A checkout with `.env.example`, a plugin build, a `cargo` on PATH and a ui package */ -function sandbox({ pluginsExit = 0 } = {}) { - dir = mkdtempSync(join(tmpdir(), 'haul-dev-')); - const bin = join(dir, 'bin'); - for (const sub of ['bin', 'scripts', 'ui']) mkdirSync(join(dir, sub)); - writeFileSync(join(dir, '.env.example'), 'HAUL_DEV_UI=http://localhost:5173\n'); - writeFileSync(join(dir, 'server.ts'), SERVER); - writeFileSync(join(dir, 'scripts', 'build-plugins.ts'), `process.exit(${pluginsExit});\n`); - writeFileSync(join(bin, 'cargo'), `#!/bin/sh\nexec "${process.execPath}" "${dir}/server.ts" server\n`); - chmodSync(join(bin, 'cargo'), 0o755); - writeFileSync( - join(dir, 'ui', 'package.json'), - JSON.stringify({ name: 'ui', scripts: { dev: `exec "${process.execPath}" ../server.ts ui` } }), - ); - return bin; -} - -/** Starts dev.ts in a fresh sandbox, without HAUL_DEV_UI so its default applies */ -function start(env: Record = {}, pluginsExit = 0) { - const bin = sandbox({ pluginsExit }); - const { HAUL_DEV_UI: _, ...inherited } = process.env; - dev = Bun.spawn([process.execPath, DEV], { - cwd: dir, - env: { ...inherited, PATH: `${bin}:${process.env.PATH}`, SANDBOX: dir, ...env }, - stdio: ['ignore', 'ignore', 'ignore'], - }); - return dev; -} - -function alive(pid: number) { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function until(check: () => boolean, ms = 10_000) { - const end = Date.now() + ms; - while (!check()) { - if (Date.now() > end) throw new Error('timed out'); - await Bun.sleep(50); - } -} - -const NAMES = ['haul', "haul's child (7z)", 'vite', "vite's child (esbuild)"]; - -/** Waits for both stand-ins and notes their pids and their children's */ -async function started() { - const files = ['server.pids', 'ui.pids'].map((name) => join(dir, name)); - await until(() => files.every((file) => existsSync(file) && readFileSync(file, 'utf8').includes(' '))); - pids = files.flatMap((file) => readFileSync(file, 'utf8').trim().split(' ').map(Number)); -} - -/** What is still running of what dev.ts started, once everything had 2 s to stop */ -async function leftovers() { - await until(() => pids.every((pid) => !alive(pid)), 2_000).catch(() => {}); - return NAMES.filter((_, i) => alive(pids[i] ?? 0)); -} - -for (const [signal, number] of [ - ['SIGINT', 2], - ['SIGTERM', 15], - ['SIGHUP', 1], -] as const) { - test(`${signal} to dev.ts stops both servers and what they started`, async () => { - const proc = start(); - await started(); - - proc.kill(signal); - const code = await Promise.race([proc.exited, Bun.sleep(5_000).then(() => 'still running')]); - - expect(await leftovers()).toEqual([]); - expect(code).toBe(128 + number); - }, 20_000); -} - -/** The process states `ps` shows (T: stopped), one letter per pid */ -function states() { - const ps = Bun.spawnSync(['ps', '-o', 'stat=', '-p', pids.join(',')]); - return ps.stdout.toString().trim().split('\n').map((line) => line.trim()[0]); -} - -// Ctrl+Z reaches only dev.ts: the servers run in sessions of their own. Left running, Vite -// would keep reading the terminal and swallow what is typed into the shell. -test('SIGTSTP (Ctrl+Z) to dev.ts stops both servers and what they started, SIGCONT (fg) resumes them', async () => { - const proc = start(); - await started(); - - proc.kill('SIGTSTP'); - await until(() => states().every((state) => state === 'T'), 3_000).catch(() => {}); - expect(states()).toEqual(['T', 'T', 'T', 'T']); - - proc.kill('SIGCONT'); - await until(() => states().every((state) => state !== 'T'), 3_000).catch(() => {}); - expect(states().filter((state) => state === 'T')).toEqual([]); -}, 20_000); - -test('when one server exits, the other is stopped and dev.ts exits with its code', async () => { - const proc = start({ SERVER_EXIT: '3' }); - await started(); - - const code = await Promise.race([proc.exited, Bun.sleep(5_000).then(() => 'still running')]); - - expect(await leftovers()).toEqual([]); - expect(code).toBe(3); -}, 20_000); - -test('creates .env and passes HAUL_DEV_UI to the server', async () => { - start(); - await started(); - expect(readFileSync(join(dir, '.env'), 'utf8')).toBe('HAUL_DEV_UI=http://localhost:5173\n'); - expect(readFileSync(join(dir, 'server.env'), 'utf8')).toBe('http://localhost:5173'); -}, 20_000); - -test('a failing plugin build aborts before any server starts', async () => { - const proc = start({}, 4); - expect(await proc.exited).toBe(4); - expect(existsSync(join(dir, 'server.pids'))).toBe(false); - expect(existsSync(join(dir, 'ui.pids'))).toBe(false); -}, 20_000); diff --git a/scripts/dev.ts b/scripts/dev.ts deleted file mode 100644 index 6154f41..0000000 --- a/scripts/dev.ts +++ /dev/null @@ -1,54 +0,0 @@ -// `bun run dev`: creates .env on first run, builds the plugins, then runs the Rust server -// (:8080) and the Vite UI (:5173, proxies /api) side by side. Each runs in a process group of its -// own; Ctrl+C, SIGTERM (an IDE's stop, `kill`) and SIGHUP (the terminal closed) reach each group -// whole, with what the servers started (7z, esbuild), and when one exits, the other is stopped too. -// Tested in dev.test.ts (`bun test ./scripts/dev.test.ts`, part of `bun run test`). -import { copyFileSync, existsSync } from 'node:fs'; -import { constants } from 'node:os'; - -if (!existsSync('.env')) { - copyFileSync('.env.example', '.env'); - console.log('.env aus .env.example angelegt'); -} -const plugins = Bun.spawnSync(['bun', 'scripts/build-plugins.ts'], { stdio: ['inherit', 'inherit', 'inherit'] }); -if (plugins.exitCode !== 0) process.exit(plugins.exitCode ?? 1); - -console.log('\n Haul-UI: http://localhost:5173 (Login aus .env, Standard admin / adminadmin)\n'); - -// detached: a process group of its own, so a signal to the group reaches everything inside -const processes = [ - Bun.spawn(['cargo', 'run', '-p', 'haul'], { - stdio: ['ignore', 'inherit', 'inherit'], - // Until the UI is built into the binary, :8080 redirects to Vite. - env: { ...process.env, HAUL_DEV_UI: process.env.HAUL_DEV_UI ?? 'http://localhost:5173' }, - detached: true, - }), - Bun.spawn(['bun', 'run', '--cwd', 'ui', 'dev'], { stdio: ['inherit', 'inherit', 'inherit'], detached: true }), -]; - -const signalAll = (signal: NodeJS.Signals) => { - for (const p of processes) { - try { - process.kill(-p.pid, signal); - } catch { - // the group has already exited - } - } -}; - -for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.on(signal, () => signalAll(signal)); - -// Ctrl+Z (SIGTSTP) reaches only dev.ts, the servers' sessions are out of the terminal's reach: stop -// them too, or Vite keeps reading the terminal the shell has taken back. SIGSTOP, as the kernel drops -// SIGTSTP sent to an orphaned group (a session of its own is one); `fg` (SIGCONT) resumes them. -process.on('SIGTSTP', () => { - signalAll('SIGSTOP'); - process.kill(process.pid, 'SIGSTOP'); -}); -process.on('SIGCONT', () => signalAll('SIGCONT')); - -// The first to exit decides the exit code: its own, or 128 + n when signal n stopped it -const first = await Promise.race(processes.map((p) => p.exited.then(() => p))); -signalAll('SIGTERM'); -await Promise.all(processes.map((p) => p.exited)); -process.exit(first.signalCode ? 128 + constants.signals[first.signalCode] : (first.exitCode ?? 1)); From 15c7a5c17f353bc13ff70d113f3bf09e852b9c20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 07:00:17 +0000 Subject: [PATCH 2/3] Keep cargo's and Vite's colours under `bun run --parallel` --parallel pipes each script's output (for the name prefix) and gives it /dev/null as input, so neither server sees a terminal: cargo and Vite (picocolors checks stdout.isTTY or FORCE_COLOR) dropped the colours that scripts/dev.ts kept by passing the terminal on. In a terminal `dev` now sets FORCE_COLOR and CARGO_TERM_COLOR; with its output in a pipe nothing is forced. scripts/dev-run.test.ts runs the `dev` script in a pty (Bun.spawn terminal) and in a pipe with server stand-ins. Vite's keyboard shortcuts need a terminal on stdin and stay with `bun run dev:ui`; the docs say so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y3rdfbWb1xDUdPRng7TX9Z --- docs/development.md | 3 ++ package.json | 4 +- scripts/dev-run.test.ts | 86 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 scripts/dev-run.test.ts diff --git a/docs/development.md b/docs/development.md index 776a8f2..2e8a94c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,6 +27,9 @@ The first part is [`scripts/dev-prepare.ts`](https://github.com/firsttris/haul/b side, each line prefixed with its script's name, and stops the other when one of them exits with an error. Ctrl+C, Ctrl+Z and closing the terminal reach both, as the terminal signals the whole foreground process group; `fg` resumes them after Ctrl+Z. +`--parallel` pipes both scripts' output for the prefix and gives them no input, so in a terminal +`dev` sets `FORCE_COLOR` and `CARGO_TERM_COLOR` to keep their colours; Vite's keyboard shortcuts +(`h`, `r`, `o`, `q`) need a terminal on its input and are there only with `bun run dev:ui` on its own. The server reads `.env` itself, so `cargo run -p haul` works on its own too. Variables set in the environment take precedence. As long as no UI is built into the binary, `HAUL_DEV_UI` diff --git a/package.json b/package.json index 91ee52f..737467a 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,13 @@ "extension" ], "scripts": { - "dev": "bun scripts/dev-prepare.ts && bun run --parallel dev:server dev:ui", + "dev": "bun scripts/dev-prepare.ts && if [ -t 1 ]; then export FORCE_COLOR=1 CARGO_TERM_COLOR=always; fi && bun run --parallel dev:server dev:ui", "dev:server": "cargo run -p haul", "dev:ui": "bun run --cwd ui dev", "build": "bun run build:plugins && bun run --filter @haul/ui build", "build:plugins": "bun scripts/build-plugins.ts", "build:extension": "bun run --filter @haul/extension build", - "test": "bun test ./scripts/dev-prepare.test.ts && bun run --filter @haul/ui i18n && bun --bun vitest run", + "test": "bun test ./scripts/dev-prepare.test.ts ./scripts/dev-run.test.ts && bun run --filter @haul/ui i18n && bun --bun vitest run", "typecheck": "bun run --workspaces typecheck", "screenshots": "bun --bun playwright test -c scripts/screenshots/playwright.config.ts" }, diff --git a/scripts/dev-run.test.ts b/scripts/dev-run.test.ts new file mode 100644 index 0000000..3bdb845 --- /dev/null +++ b/scripts/dev-run.test.ts @@ -0,0 +1,86 @@ +// The `dev` script of package.json, run by `bun run dev` in a sandbox with stand-ins for +// `cargo run -p haul`, `bun run --cwd ui dev` and the plugin build: what the servers get to see. +import { afterEach, expect, test } from 'bun:test'; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dir, '..'); +const { scripts } = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + +let dir = ''; +afterEach(() => { + for (const name of ['server', 'ui']) { + const file = join(dir, `${name}.pids`); + if (!existsSync(file)) continue; + for (const pid of readFileSync(file, 'utf8').split(' ').map(Number)) { + try { + process.kill(pid, 'SIGKILL'); + } catch {} + } + } + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = ''; +}); + +// A server stand-in: notes what it sees, then waits (a sleep as its child) +const SERVER = ` +const name = process.argv[2]; +const child = Bun.spawn(['sleep', '600'], { stdio: ['ignore', 'ignore', 'ignore'] }); +await Bun.write(import.meta.dir + '/' + name + '.json', JSON.stringify({ + FORCE_COLOR: process.env.FORCE_COLOR ?? null, + CARGO_TERM_COLOR: process.env.CARGO_TERM_COLOR ?? null, +})); +await Bun.write(import.meta.dir + '/' + name + '.pids', process.pid + ' ' + child.pid); +await child.exited; +`; + +function sandbox() { + dir = mkdtempSync(join(tmpdir(), 'haul-dev-run-')); + for (const sub of ['bin', 'scripts', 'ui']) mkdirSync(join(dir, sub)); + const pick = ['dev', 'dev:server', 'dev:ui']; + const own = Object.fromEntries(pick.map((name) => [name, scripts[name]])); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'sandbox', private: true, scripts: own })); + copyFileSync(join(ROOT, 'scripts', 'dev-prepare.ts'), join(dir, 'scripts', 'dev-prepare.ts')); + writeFileSync(join(dir, 'scripts', 'build-plugins.ts'), 'process.exit(0);\n'); + writeFileSync(join(dir, '.env.example'), 'HAUL_DEV_UI=http://localhost:5173\n'); + writeFileSync(join(dir, 'server.ts'), SERVER); + writeFileSync(join(dir, 'bin', 'cargo'), `#!/bin/sh\nexec "${process.execPath}" "${dir}/server.ts" server\n`); + chmodSync(join(dir, 'bin', 'cargo'), 0o755); + writeFileSync( + join(dir, 'ui', 'package.json'), + JSON.stringify({ name: 'ui', scripts: { dev: `"${process.execPath}" ../server.ts ui` } }), + ); +} + +/** Runs `bun run dev` in a terminal (a pty from Bun.spawn) or with its output in a pipe */ +async function seen(terminal: boolean) { + sandbox(); + const { FORCE_COLOR: _f, CARGO_TERM_COLOR: _c, NO_COLOR: _n, ...env } = process.env; + const proc = Bun.spawn([process.execPath, 'run', 'dev'], { + cwd: dir, + env: { ...env, PATH: `${join(dir, 'bin')}:${process.env.PATH}` }, + ...(terminal ? { terminal: { cols: 80, rows: 24, data() {} } } : { stdio: ['ignore', 'pipe', 'pipe'] as const }), + }); + const files = ['server', 'ui'].map((name) => join(dir, `${name}.pids`)); + const end = Date.now() + 10_000; + while (!files.every(existsSync) && Date.now() < end) await Bun.sleep(50); + proc.kill('SIGKILL'); + return ['server', 'ui'].map((name) => JSON.parse(readFileSync(join(dir, `${name}.json`), 'utf8'))); +} + +// --parallel pipes the scripts' output for its name prefix, so cargo and Vite (picocolors) see no +// terminal and would drop their colours, which the old scripts/dev.ts kept by passing it on. +test('in a terminal, cargo and Vite are told to keep their colours', async () => { + expect(await seen(true)).toEqual([ + { FORCE_COLOR: '1', CARGO_TERM_COLOR: 'always' }, + { FORCE_COLOR: '1', CARGO_TERM_COLOR: 'always' }, + ]); +}, 20_000); + +test('with the output in a pipe, no colours are forced', async () => { + expect(await seen(false)).toEqual([ + { FORCE_COLOR: null, CARGO_TERM_COLOR: null }, + { FORCE_COLOR: null, CARGO_TERM_COLOR: null }, + ]); +}, 20_000); From 30c82d97c05427c21eecd4e9ee444f00625e6f5c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 07:00:17 +0000 Subject: [PATCH 3/3] Docs: a signal to `bun run dev` alone leaves the servers running scripts/dev.ts forwarded SIGTERM, SIGINT and SIGHUP to both servers' process groups. `bun run --parallel` does not: SIGTERM or SIGHUP to the `bun run dev` pid (`kill `, an IDE's stop outside a terminal) ends it and orphans cargo/haul and Vite with their children, SIGINT is ignored. Signals from the terminal reach the whole foreground group and are fine. No process supervisor by decision, so the docs say how to stop them instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y3rdfbWb1xDUdPRng7TX9Z --- docs/development.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/development.md b/docs/development.md index 2e8a94c..625c73e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -30,6 +30,9 @@ foreground process group; `fg` resumes them after Ctrl+Z. `--parallel` pipes both scripts' output for the prefix and gives them no input, so in a terminal `dev` sets `FORCE_COLOR` and `CARGO_TERM_COLOR` to keep their colours; Vite's keyboard shortcuts (`h`, `r`, `o`, `q`) need a terminal on its input and are there only with `bun run dev:ui` on its own. +A signal sent to the `bun run dev` process alone (`kill `, an IDE's stop without a terminal) +stops it but leaves both servers running: send the signal to its process group instead +(`kill -- -$(ps -o pgid= -p )`) or run the IDE's configuration in a terminal. The server reads `.env` itself, so `cargo run -p haul` works on its own too. Variables set in the environment take precedence. As long as no UI is built into the binary, `HAUL_DEV_UI`