diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index 4aef5ca84..3a7325c3f 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; use tokio::sync::{mpsc, Mutex}; -use crate::events::{BootstrapEvent, Manifest, StageState}; +use crate::events::{BootstrapEvent, LogStream, Manifest, StageState}; use crate::install_script::{self, Pin, ScriptKind, ScriptSource}; use crate::powershell::{self, StreamSink}; use crate::AppState; @@ -366,6 +366,7 @@ async fn run_bootstrap( BootstrapEvent::Log { stage: None, line: line.to_string(), + stream: LogStream::Stdout, }, ); // Bump to info-level so the line shows in bootstrap-installer.log @@ -700,6 +701,7 @@ async fn run_install_script( BootstrapEvent::Log { stage: stage_for_stdout.clone(), line: line.to_string(), + stream: LogStream::Stdout, }, ); // Tee to the rolling installer log so we have a persistent @@ -718,7 +720,8 @@ async fn run_install_script( &app_for_stderr, BootstrapEvent::Log { stage: stage_for_stderr.clone(), - line: format!("stderr: {line}"), + line: line.to_string(), + stream: LogStream::Stderr, }, ); // stderr-level lines get warn! so they're visually distinct diff --git a/apps/bootstrap-installer/src-tauri/src/events.rs b/apps/bootstrap-installer/src-tauri/src/events.rs index 2add0f54b..e00105013 100644 --- a/apps/bootstrap-installer/src-tauri/src/events.rs +++ b/apps/bootstrap-installer/src-tauri/src/events.rs @@ -51,6 +51,16 @@ pub enum StageState { Failed, } +/// Which pipe a raw log line came from. Reported as structured metadata so +/// the UI can style stderr subtly rather than mislabeling it as an error: +/// uv/pip/git/npm write normal progress to stderr by design. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LogStream { + Stdout, + Stderr, +} + /// The single event channel `bootstrap` emits these. `type` discriminates. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "lowercase")] @@ -72,11 +82,14 @@ pub enum BootstrapEvent { #[serde(skip_serializing_if = "Option::is_none")] error: Option, }, - /// Raw stdout/stderr line from install.ps1 (or our wrapper). + /// Raw stdout/stderr line from install.ps1 (or our wrapper). `stream` + /// tells the UI which pipe it came from so stderr can be styled subtly + /// instead of being mislabeled as an error. Log { #[serde(skip_serializing_if = "Option::is_none")] stage: Option, line: String, + stream: LogStream, }, /// Sent once when all stages complete successfully. Complete { diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index 23ebc51ce..c5761362e 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -31,7 +31,7 @@ use tauri::{AppHandle, Emitter}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; -use crate::events::{BootstrapEvent, StageInfo, StageState}; +use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState}; /// `hermes update` exit code meaning "another hermes process is holding the /// venv shim open / dirty precondition" — see _cmd_update_impl in @@ -342,6 +342,7 @@ async fn run_update(app: AppHandle) -> Result<()> { emit_log( &app, None, + LogStream::Stdout, &format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."), ); } @@ -356,7 +357,7 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) { let shim = venv_hermes(install_root); let deadline = Instant::now() + DESKTOP_EXIT_WAIT; - emit_log(app, Some("update"), "[update] waiting for Hermes to exit…"); + emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…"); loop { if !is_locked(&shim) { @@ -366,6 +367,7 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) { emit_log( app, Some("update"), + LogStream::Stdout, "[update] timed out waiting for Hermes to exit; proceeding anyway", ); return; @@ -429,22 +431,22 @@ async fn run_streamed( loop { tokio::select! { line = out.next_line() => match line { - Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &l), + Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l), Ok(None) => break, Err(e) => { tracing::warn!("stdout read error: {e}"); break; } }, line = err.next_line() => match line { - Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}")), + Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l), Ok(None) => {} Err(e) => { tracing::warn!("stderr read error: {e}"); } }, } } while let Ok(Some(l)) = out.next_line().await { - emit_log(app, stage_owned.as_deref(), &l); + emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l); } while let Ok(Some(l)) = err.next_line().await { - emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}")); + emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l); } let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?; @@ -736,7 +738,7 @@ fn emit_stage( ); } -fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) { +fn emit_log(app: &AppHandle, stage: Option<&str>, stream: LogStream, line: &str) { match stage { Some(s) => tracing::info!(target: "bootstrap.log", stage = %s, "{line}"), None => tracing::info!(target: "bootstrap.log", "{line}"), @@ -746,6 +748,7 @@ fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) { BootstrapEvent::Log { stage: stage.map(|s| s.to_string()), line: line.to_string(), + stream, }, ); } diff --git a/apps/bootstrap-installer/src/routes/progress.tsx b/apps/bootstrap-installer/src/routes/progress.tsx index f59ac3be4..4a1dc2569 100644 --- a/apps/bootstrap-installer/src/routes/progress.tsx +++ b/apps/bootstrap-installer/src/routes/progress.tsx @@ -115,9 +115,7 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) { key={idx} className={clsx( 'whitespace-pre-wrap', - entry.line.startsWith('stderr:') - ? 'text-destructive' - : 'text-foreground/70' + entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70' )} > {entry.line} diff --git a/apps/bootstrap-installer/src/store.ts b/apps/bootstrap-installer/src/store.ts index 2ae4a3c0c..cb4c1e621 100644 --- a/apps/bootstrap-installer/src/store.ts +++ b/apps/bootstrap-installer/src/store.ts @@ -42,7 +42,7 @@ export interface BootstrapStateModel { currentStage: string | null installRoot: string | null error: string | null - logs: Array<{ stage?: string; line: string }> + logs: Array<{ stage?: string; line: string; stream?: 'stdout' | 'stderr' }> } const INITIAL: BootstrapStateModel = { @@ -106,6 +106,7 @@ interface BootstrapLogEvent { type: 'log' stage?: string line: string + stream?: 'stdout' | 'stderr' } interface BootstrapCompleteEvent { @@ -192,7 +193,7 @@ export async function initialize(): Promise { break } case 'log': { - const logs = [...cur.logs, { stage: payload.stage, line: payload.line }] + const logs = [...cur.logs, { stage: payload.stage, line: payload.line, stream: payload.stream }] // Keep the rolling buffer bounded so the UI doesn't get OOM'd // during a long install (playwright chromium download is ~10k lines). const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs index 51f24090b..fa7a47ee2 100644 --- a/apps/desktop/electron/bootstrap-runner.cjs +++ b/apps/desktop/electron/bootstrap-runner.cjs @@ -22,7 +22,7 @@ * { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] } * { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed', * json?, durationMs?, error? } - * { type: 'log', stage?, line } // raw line from install.ps1 + * { type: 'log', stage?, line, stream: 'stdout'|'stderr' } // raw line from install.ps1 * { type: 'complete', marker: } * { type: 'failed', stage?, error } // bootstrap aborted * @@ -229,7 +229,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme while ((nl = stdoutBuf.indexOf('\n')) !== -1) { const line = stdoutBuf.slice(0, nl).replace(/\r$/, '') stdoutBuf = stdoutBuf.slice(nl + 1) - if (line) emit && emit({ type: 'log', stage: stageName, line }) + if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' }) } }) @@ -241,7 +241,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme while ((nl = stderrBuf.indexOf('\n')) !== -1) { const line = stderrBuf.slice(0, nl).replace(/\r$/, '') stderrBuf = stderrBuf.slice(nl + 1) - if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` }) + if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' }) } }) @@ -253,8 +253,8 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme child.on('close', (code, signal) => { if (abortSignal) abortSignal.removeEventListener('abort', onAbort) // Flush any trailing bytes - if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf }) - if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` }) + if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' }) + if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' }) resolve({ stdout, stderr, code, signal, killed }) }) }) @@ -299,7 +299,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome while ((nl = stdoutBuf.indexOf('\n')) !== -1) { const line = stdoutBuf.slice(0, nl).replace(/\r$/, '') stdoutBuf = stdoutBuf.slice(nl + 1) - if (line) emit && emit({ type: 'log', stage: stageName, line }) + if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' }) } }) @@ -311,7 +311,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome while ((nl = stderrBuf.indexOf('\n')) !== -1) { const line = stderrBuf.slice(0, nl).replace(/\r$/, '') stderrBuf = stderrBuf.slice(nl + 1) - if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` }) + if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' }) } }) @@ -322,8 +322,8 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome child.on('close', (code, signal) => { if (abortSignal) abortSignal.removeEventListener('abort', onAbort) - if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf }) - if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` }) + if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' }) + if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' }) resolve({ stdout, stderr, code, signal, killed }) }) }) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ada6303c4..d94decaf5 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -722,7 +722,7 @@ function broadcastBootstrapEvent(ev) { error: ev.error ?? null } } else if (ev.type === 'log') { - bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line }) + bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' }) if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) { bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX) } diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 16ccc6ad3..c2c2c9646 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -177,7 +177,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De } } if (ev.type === 'log') { - const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line }) + const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line, stream: ev.stream }) while (next.length > 500) next.shift() return { ...state, log: next } } @@ -431,7 +431,10 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP ) : ( <> {state.log.map((entry, i) => ( -
+
{entry.stage ? [{entry.stage}] : null} {entry.line}
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 2da85e722..0888e60d0 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -222,7 +222,7 @@ export interface DesktopBootstrapState { manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null stages: Record error: string | null - log: Array<{ ts: number; stage: string | null; line: string }> + log: Array<{ ts: number; stage: string | null; line: string; stream?: 'stdout' | 'stderr' }> startedAt: number | null completedAt: number | null unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null @@ -238,7 +238,7 @@ export type DesktopBootstrapEvent = json?: DesktopBootstrapStageResult['json'] error?: string | null } - | { type: 'log'; stage?: string | null; line: string } + | { type: 'log'; stage?: string | null; line: string; stream?: 'stdout' | 'stderr' } | { type: 'complete'; marker: Record } | { type: 'failed'; stage?: string | null; error: string } | {