feat(plugin-vite): render Vite build and dev-server output in the terminal multi-logger - #4390
claude[bot] wants to merge 19 commits into
Conversation
…gger Replace the express + xterm.js web UI with an ink-based terminal renderer. Tabs, statuses and child-process output render right in the console when attached to a TTY, with a tag-prefixed plain fallback for CI and pipes. Everything logged before start() is buffered, and stop() flushes it as plain text if the UI never rendered. Enables "jsx": "react-jsx" in the shared tsconfig so the package can use JSX, and teaches vitest/oxlint/knip about .tsx sources. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…m it Spawn Electron with stdout/stderr piped instead of inherited. A postStart hook that starts reading a stream claims it; anything still unclaimed once the hooks have run is forwarded to Forge's own stdio, so behaviour is unchanged unless a plugin opts in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Swap @electron-forge/web-multi-logger for @electron-forge/multi-logger. Compiler output is buffered during preStart and rendered from postStart, once listr is done, with the Electron app's own output attached as a tab (reused across restarts). Tabs report building/success/warning/error status with counts and durations, and `r` restarts Electron since ink's raw mode hides the `rs` line from core. `loggerPort` is now deprecated and ignored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
BREAKING CHANGE: loggerPort no longer exists; compiler output renders in the terminal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…e raw mode after restart
The tab bar and footer are `height={1}` Boxes with ink's default
`flexShrink: 1`. The body was sized in logical lines, so once lines wrapped
(long webpack output, or the `[Tab] ` prefixes of the merged view) the body
overflowed the fixed-height root and Yoga shrank the header to nothing,
leaving a blank first row and desyncing ink's line diff. The chrome now has
`flexShrink={0}` and the body keeps only the tail that fits in its screen
rows, measured with the same `wrap-ansi` settings ink uses.
The tab bar also relied on flex clipping, which cut the last chip mid-word
and hid `All` entirely at 120 columns. It is now measured with
`string-width` and degrades in steps: status details go first, then the
title, and finally the chips wrap onto more rows (the body shrinks to
match), so every chip, `All` included, is always visible.
Also:
- rename the Electron tab to `App` (and make it the default name for
`attachProcess`); add `initialTab` so the host can pick the first view
(a tab name or `'all'`);
- switch to a tab whose status turns to `error`, at most once per
`errorSwitchDebounceMs` (default 15s), never re-triggering for a tab
that stays in error;
- put stdin back into raw mode when an attached child exits: a child that
inherited the tty (Electron is a Node embedder) restores cooked mode on
exit while `stdin.isRaw` still reads `true`, so ink never re-asserted it
and keys were echoed instead of handled after a restart;
- do not colour the status glyph on the active (inverse) chip, where the
colour became a background cell.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
A terminal can only show one UI, so a host and the plugins running inside it
need to share a logger rather than each creating their own. `ensureSharedLogger`
returns the process-wide instance (creating it on first use), `getSharedLogger`
peeks at it and `resetSharedLogger` forgets it for tests. The instance lives
under `Symbol.for('@electron-forge/multi-logger')` on `globalThis`, so duplicate
copies of the package in one node_modules tree still share it. Options passed to
a later `ensureSharedLogger` call are merged additively through the new
`Logger#extendOptions`: extra keys, and a title / initial tab if none is set.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
`@electron-forge/core` now owns the tabbed terminal UI: every interactive `forge start` creates the process-wide shared logger before any hook runs, attaches the Electron app's stdout/stderr to its "App" tab (the default view) on every spawn and restart, and starts it once the app is up. Plugins only add tabs. While the UI is drawing, `r` restarts the app instead of typing `rs`, the "Restarting Electron app" notice goes into the App tab rather than over the alternate screen, and the UI is torn down when the app exits for good. Non-interactive (programmatic) starts are unchanged: no UI is created and the app's output is forwarded to the parent's stdio as before. BREAKING CHANGE: plugins must add tabs to the shared logger instead of creating their own; the app's stdio is attached to the App tab in interactive mode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…minal multi-logger During `electron-forge start` the Vite plugin now puts each `build` target and each renderer dev server in a tab of the shared terminal UI instead of writing straight to the console. * Each watch subprocess (`Main (src/main.ts)`, `Preload (src/preload.ts)`, numbered when two targets share an entry) is attached to a tab with `attachProcess`, so its stdout/stderr land there. The worker now also sends `build-start`, `build-done` (with the watcher's duration) and `build-error` IPC messages from the Rollup watcher events, which the plugin maps onto the tab's status; `first-build-done`/`first-build-error` keep settling the "wait for the first build" promise. The stderr copy used for a worker that dies before its first build is kept. * Each renderer dev server (`Renderer (<name>)`) gets a Vite `customLogger` that writes into its tab, formats timestamped lines like Vite does, honors the configured `logLevel`, counts warnings/errors in the status and returns to `success` when Vite starts a fresh cycle (`clear: true`). The dev server URLs are logged into the tab once it is listening. A renderer config that already sets `customLogger` keeps it. * The logger itself is owned by core (`ensureSharedLogger()`); the plugin only creates tabs on it and does not start, stop or attach the app. Tests cover tab naming, the status mapping, the custom logger, the worker's watch-mode IPC messages (first build, duration, failing build) and that the postStart hook leaves the app's stdio alone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
aa452ce to
746c9c0
Compare
The watch-mode worker sends `build-error` before printing the error, and stdio pipes are asynchronous on Windows, so the test killed the worker before the printed error reached it and `stderr` came back empty. Re-check the stop condition on every stderr chunk too and, for the failing build, include the printed error in it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…s non-interactive `start()` now owns the shared logger for every run, not only interactive ones. Plugins add tabs unconditionally (webpack does so from its preStart hook), and previously a non-interactive start (`process.stdin.isTTY` false, as in CI, Docker or piped input, or a programmatic `api.start()`) never started the logger, so the plugins' compiler output and watch-mode build errors were buffered and silently dropped. Core now creates the logger with `interactive: false` in that case, which forces plain mode, starts it once the app is up so tabs are printed as `[Tab]`-prefixed lines, and stops it when the app exits. A project without plugins prints nothing: an empty plain logger is silent. The app's stdio is only piped when there is an App tab to show it in (interactive). Non-interactive starts spawn Electron with `stdio: 'inherit'` again, exactly as before this branch, so the app keeps the real terminal fds (`isTTY`, window size) and no output can be lost in a Node-level pipe. The `forwardUnclaimedStream` helper that piped the streams back to our stdio is gone with it. Two follow-ups from review: - `ensureSharedLogger()` replaces a singleton that has been stopped, so a second `api.start()` in the same process gets a live logger rather than one whose `start()` no-ops. - `Logger.mode` now reports the renderer actually in use: it switches to `'plain'` when the interactive UI fails to load. `start()` decides whether to read `rs` from stdin after `logger.start()` has resolved, so the app can still be restarted from the keyboard after such a fallback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…iped stdio A plugin's `startLogic` may hand back a child it spawned with inherited stdio. Attaching it to the App tab was a no-op (both streams null) while the terminal UI's alternate screen hid the app's real output. Core now detects that case, skips attaching and forces the shared logger into plain mode (new `Logger.forcePlain()`), so plugin tabs still print as `[Tab]` lines and the app keeps the terminal. Also stop claiming the Vite Plugin adds tabs to the terminal UI; only the Webpack Plugin is wired to the shared logger in this change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
The org-required Zizmor Audit workflow now gates `main` (it follows the default branch), and main's ci.yml/gh-pages.yml never received the cache-poisoning hardening 7.x got in #4200. Mirror that change so PRs against main can go green again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
|
Working with Samuel Attard and Erick Zhao
Generated by Claude Code |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, this run also examined and ruled out two other candidates: the new per-rebuild process.send() calls in subprocess-worker.ts's Rollup watcher event handler use the same unguarded process.send?.() pattern the pre-existing first-build-done/first-build-error messages already relied on, so the higher call frequency isn't a new failure mode; and a renderer dev-server startup failure (VitePlugin.ts:529) still propagates as a rejected Listr task rather than being silently swallowed, even though the tab visually stays at "building".
Extended reasoning...
This run reported one confirmed pre-existing bug (stderr cleared before being read into the error message in spawnViteBuildWatch's settle()) as an inline comment, so approval is off the table per the guidelines. Two prior rounds' inline threads (VitePlugin.ts:533 forcing tab status to success, logging.ts:123 status updates ignoring the log threshold) were resolved only by this bot's own session, which the instructions say is not independent sign-off, so I did not restate or treat them as settled. I additionally verified, by reading the diff directly, that the confirmed settle()/stderr bug is byte-for-byte identical to the base branch's version (just wrapped in the new logger/tab plumbing), confirming it's pre-existing rather than newly introduced. I also spot-checked the two other candidates the pipeline flagged and ruled out (unguarded process.send() on every rebuild, and a renderer tab getting stuck at "building" on a startup failure) against the code and found the ruling-out reasoning plausible: the send pattern is unchanged from what already existed for the first-build messages, and a startup failure's exception still propagates through the Listr task rather than being swallowed. Since new, non-redundant information exists (the ruled-out items) beyond what I posted in the prior two reviews, a brief informational note is warranted rather than staying fully silent, but decision cannot be "approve" while a verified finding is being posted inline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Requested by Erick Zhao · Slack thread
Stacked on #4389 (merged).
Before: With the Vite plugin,
forge startprints the two Vite watch subprocesses' stdout/stderr straight to the console, interleaved with the Electron app's output and with each other; the renderer dev servers' request/HMR/error logs go to the console through Vite's default logger; nothing tells you at a glance which target is rebuilding, succeeded or failed.After: Each
buildtarget (Main (src/main.ts),Preload (src/preload.ts)) and each renderer dev server (Renderer (main_window)) gets its own tab in the terminal UI next to core'sApptab, with a live status (spinner while building,✔ 11mson success,✖ 1on error) that updates on every rebuild; Vite's HMR and error output lands in the renderer's tab and marks itwarning/erroruntil the next HMR cycle. In a non-TTY orCIthe same output is written as[Main (src/main.ts)] …lines, and a failing first build still shows the Vite error text.This wires the Vite plugin into the shared multi-logger the same way the webpack plugin is, so the Vite output shows up as tabs with statuses instead of raw console writes.
How
ensureSharedLogger()and only creates tabs on it; core owns the logger (start/stop, theApptab,r).logger.attachProcess(child, tab.name)replaces the directprocess.stdout/stderrforwarding. The stderr copy used to explain a worker that dies before its first build is kept as a seconddatalistener.subprocess-worker.tsnow sendsbuild-start,build-done { durationMs }andbuild-error { message }from the Rollup watcher'sSTART/BUNDLE_END/ERRORevents (typed in a newworker-messages.ts), alongside the existingfirst-build-done/first-build-error/reload-renderers.statusFromWorkerMessagemaps them onto the tab status;first-build-doneonly fills insuccesswhenBUNDLE_ENDhas not already reported the duration.createTabLogger(tab, logLevel)is passed as Vite'scustomLogger(a user-providedcustomLoggerstill wins). It formats timestamped lines like Vite (<time> [vite] (client) …), honorslogLevel, dedupeswarnOnce, counts warnings/errors in the status and returns tosuccesswhen Vite starts a fresh cycle (infowithclear: true, i.e. an HMR update or page reload). The server URLs are logged into the tab once it is listening.#2) when two targets share an entry. Docs: a "Compiler output" section indocs/config/plugins/vite.mdx.postStartleaves the app's stdio to core.Open questions
Renderer,Main,Preload. Webpack listsMain Processfirst; happy to pre-create the build tabs so main comes first if that consistency matters.successonclear: true" heuristic for renderer tabs is a judgement call: it is what Vite uses to mark a new HMR/reload cycle, and a still-broken transform re-marks the taberrorright after, but there is no explicit "resolved" signal from Vite.closeBundlehook sendsreload-rendererseven when the build errored (unlikepluginHotRestart, which guards on the error argument). Worth a follow-up.logLevel: 'silent'and prints its own one-liners, so the tab showstarget built src/main.jsper rebuild rather than Vite's full asset table. Switching to Vite's own build output in the tab would be a small follow-up if preferred.Smoke-tested through the real CLI on a template-derived fixture with a stubbed
electron, both under a PTY (ink UI, tab switching, quit summary) and piped/CI(plain lines), including a broken main entry.🤖 Generated with Claude Code
https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ