Skip to content

feat(cli): add local playground + suspicious-exit warning for shelve run#749

Merged
HugoRCD merged 8 commits into
mainfrom
chore/cli-playground
May 30, 2026
Merged

feat(cli): add local playground + suspicious-exit warning for shelve run#749
HugoRCD merged 8 commits into
mainfrom
chore/cli-playground

Conversation

@HugoRCD

@HugoRCD HugoRCD commented May 30, 2026

Copy link
Copy Markdown
Owner

Why

I had no fast loop on shelve run and one silent failure took an hour to reproduce. This PR sets up a self-contained local fixture so the full request flow (auth → fetch project → fetch env → fetch variables → spawn child with injected secrets) is exercised in <2s with no network, no Shelve app running, no real account.

What's in playground/run/

playground/run/
├── server.mjs              # fake Shelve API (vanilla node:http, ~110 lines)
├── start.mjs               # orchestrator (server → wait /health → CLI → cleanup)
├── seed.json               # team, project, 3 envs, variables, fake token
├── shelve.json             # points at 127.0.0.1:7777 (matches seed)
├── package.json            # 4 scripts: dev / start / echo / fail
├── scripts/print-env.mjs   # what dev/start actually invoke
└── README.md

Fake API

A ~110-line node:http server with no deps. Implements every endpoint shelve run calls:

Method Path Returns
GET /health { ok: true, ts }
GET /api/user/me seeded user
GET /api/teams/:slug/projects/name/:name seeded project
GET /api/teams/:slug/environments seeded environments
GET /api/teams/:slug/environments/:envName the matching environment
GET /api/teams/:slug/projects/:projectId/variables/env/:envId seeded variables for that env
POST /api/teams/:slug/projects echoes the project (auto-create)

All authed: Authorization: Bearer … required (any non-empty token, the orchestrator sends the seed token).

Plus two admin helpers (no auth) for exercising --watch:

Method Path Body
POST /__playground/variables { env, variables } — swap variables in-memory.
POST /__playground/reset — restore seed variables.

Orchestrator (start.mjs)

  1. Spawns server.mjs and probes /health for up to 5s.
  2. Spawns the locally-stubbed CLI with SHELVE_URL / SHELVE_TOKEN / SHELVE_TEAM_SLUG / SHELVE_PROJECT / SHELVE_DEFAULT_ENV injected from the seed.
  3. Inherits stdio, forwards exit codes, kills the server on exit/Ctrl-C.

Also strips a forwarded -- so pnpm play -- --debug reaches the CLI as --debug (otherwise pnpm splices the -- in literally, which the CLI's parseRawArgs then treats as the command separator).

Root scripts

"play":        "pnpm -C packages/cli stub && node playground/run/start.mjs run dev",
"play:start":  "… run start",
"play:fail":   "… run fail",
"play:watch":  "… run dev --watch",
"play:server": "node playground/run/server.mjs"

unbuild --stub writes a tiny dist/index.mjs that re-exports from src/, so edits in packages/cli/src/** are picked up without a rebuild.

Sanity guard in shelve run

While I was here I added a small UX guard for the kind of silent failure that started all this:

  • If the spawned child exits cleanly in <250ms (we didn't actually run anything useful), the CLI now prints a warning pointing at --debug and shelve run -- <pm> <script> to bypass script resolution.
  • Script-resolution errors from nypm.runScript are now debug-logged instead of being silently swallowed.

How to use

# every iteration
pnpm play                # → shelve run dev (fake API + seed)
pnpm play:start          # → shelve run start  (one-shot)
pnpm play:fail           # → shelve run fail   (verifies exit-code propagation)
pnpm play:watch          # → shelve run dev --watch
pnpm play -- --debug     # forward flags to the CLI

# in another terminal while play:watch is up:
curl -s -X POST http://127.0.0.1:7777/__playground/variables \
  -H 'content-type: application/json' \
  -d '{"env":"development","variables":[{"key":"HELLO","value":"changed"}]}'
# CLI prints "Variables changed — reloading child process." within ~5s

Smoke tests run locally

  • pnpm play:start — fetches project / env / variables from fake API, child gets 4 seed vars + 5 SHELVE_* overrides, exits 0.
  • pnpm play:fail — child exits 7, propagated as exit 7.
  • pnpm play -- --debug-- no longer eats the flag.
  • pnpm typecheck + pnpm lint clean.
  • Server shuts down on CLI exit and on Ctrl-C.

Out of scope

The "reinstall node_modules?" prompt I hit earlier today is pnpm in my own hr-folio host project (lockfile drift). nypm.runScript({ dry: true }) is pure — it doesn't execute or prompt — so it's not a CLI bug. The new playground sidesteps it by being depless, and the suspicious-exit warning catches similar cases in real projects.

Changeset

Included (@shelve/cli: patch).

Summary by CodeRabbit

Release Notes

  • New Features

    • Added local CLI playground fixture for end-to-end testing with pnpm play script family.
    • Added warning when scripts exit suspiciously fast with debugging guidance.
  • Bug Fixes

    • Fixed shelve run exiting prematurely after spawning child process.
    • Fixed --watch --restart-on-change terminating during first reload.
    • Improved error logging for script resolution failures.

Review Change Stack

Previously the only way to iterate on `shelve run` locally was to bump
the CLI version, publish to npm, install globally, then test in some
other repo. When something silently went wrong (e.g. the child exited
after a host-project pnpm reinstall prompt) there was no fast way to
reproduce and inspect.

Adds:

- `playground/run/`: standalone fixture (not part of the workspace,
  no deps) with `dev` / `start` / `fail` scripts that just call
  `scripts/print-env.mjs`. Keep-alive mode for testing signals and
  `--watch`. Edit `shelve.json` to point at a real project you own,
  log in once, then iterate.
- Root scripts `pnpm play`, `pnpm play:start`, `pnpm play:fail`,
  `pnpm play:watch`. Each stubs the CLI first (`unbuild --stub`) so
  source edits in `packages/cli/src/**` are picked up without a
  rebuild step.
- `shelve run` now warns when the child exits cleanly in <250ms,
  suggesting `--debug` and `shelve run -- <pm> <script>` to bypass
  script resolution. Script-resolution errors are also debug-logged
  instead of being silently swallowed.
@vercel

vercel Bot commented May 30, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

There is no GitHub account connected to this Vercel account.

@vercel

vercel Bot commented May 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
shelve-app Ready Ready Preview, Comment, Open in v0 May 30, 2026 5:17pm

@github-actions github-actions Bot added the feature New feature or request label May 30, 2026
@github-actions

github-actions Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Thank you for following the naming conventions! 🙏

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@HugoRCD, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 13 minutes and 13 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d8c57021-f8ef-400d-b3f1-39eb72a54a6a

📥 Commits

Reviewing files that changed from the base of the PR and between 6e3e0f2 and 682c8f1.

📒 Files selected for processing (6)
  • .changeset/cli-playground.md
  • packages/cli/src/commands/run.ts
  • playground/run/README.md
  • playground/run/scripts/print-env.mjs
  • playground/run/server.mjs
  • playground/run/start.mjs
📝 Walkthrough

Walkthrough

This PR adds a local CLI playground at playground/run/ for end-to-end shelve run testing, supplies root pnpm play* scripts to exercise it, and improves CLI lifecycle and script-resolution observability (quick-exit detection, restart suppression, debug-logged resolution failures, and quieter env polling).

Changes

Local CLI Playground and Observability

Layer / File(s) Summary
Playground fixture foundation
playground/run/package.json, playground/run/README.md, playground/run/seed.json, playground/run/server.mjs, playground/run/shelve.json, playground/run/scripts/print-env.mjs, playground/run/start.mjs
Adds a self-contained, non-workspace playground package with seed data, a zero-dependency fake Shelve HTTP server (server.mjs), an orchestrator (start.mjs) that waits for /health and spawns the CLI with injected SHELVE_* env vars, an env-print utility, and full README documentation.
CLI lifecycle & script-resolution
packages/cli/src/commands/run.ts, packages/cli/src/services/env.ts, packages/cli/src/index.ts
Wraps script resolution in try/catch with debug logging and fallback to literal binaries, records child spawn time and warns on suspiciously fast clean exits (suppressed for restart flows), makes env fetching quiet in watch polling, and adds a process.stdin EIO handler.
Root scripts, gitignore, and changeset
package.json, .changeset/cli-playground.md, .gitignore
Adds play, play:start, play:fail, play:watch, and play:server scripts that rebuild the CLI stub and run the playground; documents the fixture and CLI fixes in the changeset; ignores playground local files and node_modules.

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: adding a local playground and a suspicious-exit warning for shelve run.
Description check ✅ Passed The description is comprehensive and well-structured, covering the why, what, and how with detailed examples and test results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/cli-playground

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@playground/run/README.md`:
- Around line 25-28: Add a blank line immediately before the fenced code block
that contains "```sh" so the snippet starting with "pnpm cli login" is preceded
by an empty line; update the README.md section where the fenced block appears to
insert that blank line before the triple-backtick fence.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8a53b1ab-56a2-46bf-84c2-f84e401c1a83

📥 Commits

Reviewing files that changed from the base of the PR and between 2d855b8 and 6f70687.

📒 Files selected for processing (8)
  • .changeset/cli-playground.md
  • .gitignore
  • package.json
  • packages/cli/src/commands/run.ts
  • playground/run/README.md
  • playground/run/package.json
  • playground/run/scripts/print-env.mjs
  • playground/run/shelve.json

Comment thread playground/run/README.md Outdated
Previous playground required a real Shelve project + login. Replace
that with a zero-dep `node:http` fake server seeded from `seed.json`
(team / project / 3 environments / variables / fake token).

- `server.mjs` — implements the 4 endpoints `shelve run` hits, plus a
  `/health` probe and two `/__playground/{variables,reset}` admin
  helpers for exercising `--watch` from another terminal.
- `start.mjs` — orchestrator: boots the server, waits for `/health`,
  spawns the locally-stubbed CLI with `SHELVE_URL` / `SHELVE_TOKEN` /
  `SHELVE_TEAM_SLUG` / `SHELVE_PROJECT` / `SHELVE_DEFAULT_ENV` set
  from the seed, kills the server on exit. Strips a forwarded `--`
  so `pnpm play -- --debug` works.
- `seed.json` — single source of truth for the fixture data.
- `shelve.json` — points at `127.0.0.1:7777` so `shelve run` also
  works if you `cd playground/run && node ../../packages/cli/dist/index.mjs run dev`
  with the server already up via `pnpm play:server`.
- `pnpm play:server` — server-only, for iterating on the fake API.

Smoke-tested all variants. CLI fetches project → env → variables from
the fake server, child gets the 4 seeded vars + the 5 `SHELVE_*`
overrides, exit codes propagate (`play:fail` → 7), Ctrl-C tears down
the server.
@pkg-pr-new

pkg-pr-new Bot commented May 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@shelve/cli@749

commit: 682c8f1

Two long-standing `shelve run` bugs the playground surfaced:

1. `runMain(main).then(() => process.exit(0))` in index.ts resolves
   as soon as the `run` command function returns, which happens right
   after `await spawnChild(...)` — BEFORE the child has done anything.
   The CLI then force-exits, orphaning the child. With stdio:'inherit'
   the orphan keeps writing to the terminal so it *looks* fine, but
   signal forwarding, watch reload and exit-code propagation are all
   broken. Fix: the run command awaits a never-resolving promise, so
   the only path to process.exit is via attachLifecycle's child.on('exit').

2. With --watch --restart-on-change, killing the old child fires
   its exit handler which calls process.exit(143) before the new
   child can spawn. Fix: mark the outgoing child with __restarting=true
   so attachLifecycle's exit handler becomes a no-op while watch is
   swapping in a replacement.

Playground harden:

- `pnpm play:watch` now adds `--restart-on-change` so var changes
  are actually visible (plain --watch only sends SIGHUP and the
  child keeps its frozen process.env).
- print-env banner gets a timestamp + ── borders so reloads are
  obvious; SIGHUP re-prints the env in-place; var name filter is
  overridable via PLAYGROUND_SHOW_ENV_PATTERN.
- server.mjs uses closeAllConnections + a guard so it always
  releases the port (was leaving 7777 in TIME_WAIT under stress).
- start.mjs spawns the server and the CLI detached so killAll can
  signal whole process groups; teardown gives them 200-300ms to
  shut down cleanly before exiting.
- README documents the watch reload flow with a copy-pasteable
  curl, and the harmless `node_modules missing` pnpm warning.

Smoke-tested every variant: play, play:start, play:fail, play:watch
with live curl-driven reload, Ctrl-C cleanup, port release.
Two cosmetic-but-loud issues the playground surfaced once `shelve run`
actually stayed alive long enough to print:

1. Every spawn went through `pnpm run <script>` (via nypm), so the
   user got `$ node scripts/whatever.mjs` as the script prefix, plus
   `[WARN]  Local package.json exists, but node_modules missing` once
   per spawn, plus `[ELIFECYCLE] Command failed with exit code 143`
   every time we SIGTERM'd the child for a watch reload. The 143 in
   particular made it look like the reload failed.
   Fix: when `resolveCommandWithScripts` resolves a script through
   `pnpm`/`npm`, prepend `--silent` so the package-manager wrapper
   stops printing its own banner and exit-code complaints. The script
   itself still gets full stdio.

2. `EnvService.getEnvVariables` was wrapped in `withLoading('Fetch
   variables', …)`, so every watch poll (every 5s by default) flashed
   a spinner row. Now accepts `{ quiet: true }`; the watch loop uses
   it, so only the initial fetch shows the spinner.

Combined effect: a watch session is now essentially silent between
events, and a reload shows just `● Variables changed — reloading
child process.` followed immediately by the new banner.
Two robustness fixes for the case where a terminal is closed (or
killed) while shelve run is still alive:

- packages/cli: listen for `EIO` on `process.stdin` and exit 129
  (SIGHUP-equivalent). Without this, an orphaned CLI whose parent
  terminal died keeps polling the API on its watch interval, and
  every interactive render attempt throws `setRawMode EIO` to the
  user's next terminal. Now it just exits.
- playground/run/start.mjs: also handle SIGHUP so closing the host
  terminal cleanly tears down the fake server + the CLI subtree
  instead of leaving them as orphans.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/cli/src/commands/run.ts (1)

320-325: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove the process signal handlers when a child is replaced.

attachLifecycle() adds three process.on(...) listeners on every spawn, but watch restarts keep reattaching them. After a few reloads you'll accumulate stale handlers, hit MaxListenersExceededWarning, and fan signals out to dead child PIDs/process groups.

🧹 Proposed fix
   const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']
-  for (const sig of signals) process.on(sig, () => onSignal(sig))
+  const signalHandlers = signals.map((sig) => {
+    const handler = () => onSignal(sig)
+    process.on(sig, handler)
+    return [sig, handler] as const
+  })
+  const cleanup = () => {
+    for (const [sig, handler] of signalHandlers) process.off(sig, handler)
+  }
 
   child.on('exit', (code, signal) => {
     if (killTimer) clearTimeout(killTimer)
+    cleanup()
     if ((child as ChildWithFlag).__restarting) return
@@
   child.on('error', (err) => {
     if (killTimer) clearTimeout(killTimer)
+    cleanup()
     consola.error(`Failed to spawn process: ${err.message}`)
     process.exit(1)
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/run.ts` around lines 320 - 325, attachLifecycle
currently adds process.on handlers for signals (the signals array and onSignal)
on every spawn and never removes them, causing listener leaks on restarts;
update attachLifecycle so it returns a cleanup function (or store the bound
handler references) and ensure you call process.off/removeListener for each
signal when replacing or when the child is marked restarting (see
ChildWithFlag.__restarting and the child.on('exit') handler), i.e., register
handlers with named/bound functions, keep those references, and call
signals.forEach(sig => process.off(sig, boundHandler)) when the child is cleaned
up or about to be replaced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/run.ts`:
- Around line 161-164: The outgoing child process isn't marked as restarting
early enough: set the restart flag on the old child (cast to ChildWithFlag and
set old.__restarting = true) before you send SIGTERM or otherwise kill it inside
spawnNew (and the analogous restart path around spawnChild elsewhere); this
ensures attachLifecycle sees a restarting exit instead of treating it as a
normal exit and calling process.exit(143). Locate the spawnNew function and the
other restart block that creates a replacement child (references: spawnNew,
spawnChild, child, ChildWithFlag, __restarting, attachLifecycle, argv, env) and
move/ensure the assignment old.__restarting = true happens before any
kill/termination is issued.

In `@playground/run/README.md`:
- Around line 9-18: The README's directory-tree code fence is unlabeled and
flagged by markdownlint; edit the README.md directory-tree block that begins
with triple backticks and change the opening fence from ``` to ```text (or
```plain) so the block is explicitly marked as plain text; no other content
changes needed.

In `@playground/run/scripts/print-env.mjs`:
- Around line 4-6: The code currently constructs SHOW with new
RegExp(process.env.PLAYGROUND_SHOW_ENV_PATTERN) which will throw on invalid
patterns; wrap that RegExp construction in a try/catch: when
PLAYGROUND_SHOW_ENV_PATTERN is present attempt to create the RegExp inside try,
on error print a concise error to stderr (e.g. "Invalid
PLAYGROUND_SHOW_ENV_PATTERN: <error message>") and call process.exit(1); leave
the fallback default regex intact for the no-env case and reference the SHOW
constant name in your change so it’s clear where the guard is applied.

In `@playground/run/server.mjs`:
- Around line 114-117: The handler currently assigns mutableVariables[env] =
body.variables before validating body.variables, which can corrupt state if
body.variables is invalid and later accessing body.variables.length throws; fix
by validating payload first (check that body and body.variables exist and that
body.variables is the expected array/type and safe to read length) before
mutating mutableVariables[env], and only after validation perform the assignment
and call send(res, 200, { ok: true, env, count: body.variables.length }); use
existing notFound(res, `Unknown env: ${env}`) early for env checks and ensure
all validations occur prior to writing mutableVariables[env].
- Around line 34-45: The requireBearer function currently accepts any non-empty
token; update it to validate the token value against the configured expected
token (e.g., process.env.PLAYGROUND_BEARER_TOKEN or an app-config value) and
call unauthorized(res, 'Invalid Bearer token') when it does not match; keep the
existing format checks (startsWith 'Bearer ' and non-empty) and only return the
token when it equals the expected value, referencing the requireBearer function
and the unauthorized helper to locate the change.

---

Outside diff comments:
In `@packages/cli/src/commands/run.ts`:
- Around line 320-325: attachLifecycle currently adds process.on handlers for
signals (the signals array and onSignal) on every spawn and never removes them,
causing listener leaks on restarts; update attachLifecycle so it returns a
cleanup function (or store the bound handler references) and ensure you call
process.off/removeListener for each signal when replacing or when the child is
marked restarting (see ChildWithFlag.__restarting and the child.on('exit')
handler), i.e., register handlers with named/bound functions, keep those
references, and call signals.forEach(sig => process.off(sig, boundHandler)) when
the child is cleaned up or about to be replaced.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4df3b41d-40d8-4758-a923-023e343a5887

📥 Commits

Reviewing files that changed from the base of the PR and between 6f70687 and 6e3e0f2.

📒 Files selected for processing (11)
  • .changeset/cli-playground.md
  • package.json
  • packages/cli/src/commands/run.ts
  • packages/cli/src/index.ts
  • packages/cli/src/services/env.ts
  • playground/run/README.md
  • playground/run/scripts/print-env.mjs
  • playground/run/seed.json
  • playground/run/server.mjs
  • playground/run/shelve.json
  • playground/run/start.mjs

Comment thread packages/cli/src/commands/run.ts
Comment thread playground/run/README.md Outdated
Comment thread playground/run/scripts/print-env.mjs Outdated
Comment thread playground/run/server.mjs
Comment thread playground/run/server.mjs
Audit + rewrite of every process-handling path in `shelve run` and
the playground orchestrator. Goal: zero orphans, on any OS, with any
package manager, under any termination signal — including SIGKILL on
the parent.

shelve run (packages/cli/src/commands/run.ts)

- Use `tree-kill` (already a dep) for all process termination.
  Replaces `process.kill(-pid, sig)` which only works on Unix; on
  Windows it now uses `taskkill /F /T` under the hood.
- Drop `detached: !isWindows` on the spawned child. With tree-kill
  we don't need our own process group, and not being detached means
  the child correctly inherits SIGHUP when the controlling terminal
  closes.
- Add `shell: true` on Windows so `pnpm.cmd` / `npm.cmd` / `yarn.cmd`
  / `bun.cmd` shims actually resolve. tree-kill walks through the
  cmd.exe + shim chain.
- Add `windowsHide: true` to avoid opening flash console windows.
- Add a parent-death watchdog: every 2s, ping `process.ppid` with
  signal 0. If the parent is gone (terminal force-quit, Cursor
  crashed, …), kill the child tree and exit 129. This is the main
  defense against the `setRawMode EIO` orphan-spam case from earlier.
- Register the SIGINT/SIGTERM/SIGHUP handlers ONCE (module-level
  ref to current child) instead of once per spawn. Fixes the
  listener-leak warning after 10 watch reloads.

Playground orchestrator (playground/run/start.mjs)

- Inline a tiny cross-platform tree-kill (taskkill on Windows,
  process-group + direct kill on Unix). The playground has no deps,
  so we can't import tree-kill.
- Drop `detached: !isWindows` on both server and CLI spawn (same
  reasoning).
- Add `windowsHide: true`.
- Add SIGHUP handler (terminal close) and parent-death watchdog.
- Add `serverProc.on('exit')` so an unexpected server crash doesn't
  leave the CLI thinking everything's fine.
- Drop `setTimeout().unref()` on final-exit timer — was letting the
  orchestrator exit code-0 before the timer fired, swallowing the
  CLI's real exit code. Now `pnpm play:fail` correctly propagates 7.

Playground server (playground/run/server.mjs)

- Add parent-death watchdog (same poll-ppid pattern).

Smoke-tested on macOS:

- pnpm play + Ctrl-C → server + child clean shutdown, no orphans, port released.
- pnpm play + kill -9 on the orchestrator → 5s later: parent-death
  watchdogs fire, server logs SIGTERM, CLI logs SIGTERM, 0 orphans.
- pnpm play:watch + 2 live variable changes via curl → both reloads
  visible, no listener-leak warning, clean teardown.
- pnpm play:fail → exit 7 propagated cleanly, no [ELIFECYCLE] noise.
- run: set __restarting on the outgoing child before SIGTERM in watch
  restart (not inside spawnNew after killTree), closing the race where
  attachLifecycle could process.exit before the replacement spawns.
- server: reject Bearer tokens that do not match seed.json.
- server: validate body.variables is an array before mutating state.
- print-env: catch invalid PLAYGROUND_SHOW_ENV_PATTERN with a clear exit.
- README: label directory tree fence as text; document real token auth.
When stopping `shelve run dev` with Ctrl-C, pnpm/npm printed
`[ELIFECYCLE] Command failed with exit code 130` even though the
interruption was intentional.

- Resolve package.json script names by running the script body
  directly (`sh -c` / `cmd /c`) instead of `pnpm run <script>`, so
  the package-manager wrapper never sees a non-zero exit on SIGINT.
- Treat signal exit codes (130/143/129) and user-initiated shutdown
  as success (exit 0) in the CLI lifecycle handler.
- Map the same codes to exit 0 in the playground orchestrator so
  `pnpm play` also finishes cleanly.
@HugoRCD HugoRCD merged commit e64d2ec into main May 30, 2026
11 checks passed
@HugoRCD HugoRCD deleted the chore/cli-playground branch May 30, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant