diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 0b556cf40..57203fb21 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,6 +1,6 @@ # Hack desktop app (preview) -Unsupported preview of a minimal native window for Hack (HACK-2214): every +Unsupported preview of a minimal native window for Hack: every project, its state and services, and Start / Stop / Restart / Open. It is a view over the `hack` CLI and the `hackd` socket; it keeps no state beyond window layout, holds no credentials and runs `hack` as you for every action. Not part @@ -49,10 +49,9 @@ cargo run -p hack-desktop-core --bin hack-desktop-state -- --project ``` `hack-desktop-state` reads exactly what the window reads: hackd when its version -matches the CLI, plus native `hack ps` for native projects. Today's CLI listing -auto-registers runtime projects and writes the registry, so it is never used as -a fallback. Without a matching daemon the reader reports Unknown / needs R18 -(observe-only projects), rather than writing or claiming no projects exist. +matches the CLI, plus native `hack ps` for native projects. This app does not +use a CLI project-list fallback. Without a matching daemon the reader reports +Unknown, rather than writing or claiming no projects exist. The window retains stale names with Unknown states and disables project actions. It offers **Start hackd** when unreachable or **Restart hackd** for a version mismatch. Only a fresh matching snapshot restores observed states. @@ -88,10 +87,52 @@ degraded / stopping, failed-start, needs-attention): ## What v0 does not do yet -Logs, Env and Activity tabs, CPU and memory, Doctor view, onboarding, always-on, +Logs and Activity tabs, CPU and memory, Doctor view, onboarding, always-on, daemon actions and upgrades come in later units. The design and the CLI/daemon requests they need are in `DESIGN.md` (PR #800). +## Env tab (names only) + +Env reads `hack env list --json --path ` on tab open, project selection, +and **Check again**, with a 10-second deadline. Native projects select +`HACK_RUNTIME_BACKEND=native`. There is no polling or disk state for Env. +A bare report on exit 1 is still a report. Missing required names appear first +in red; both legacy scope arrays and canonical scope strings are supported. +An explicit empty `vars` array shows “This project has no env layers” only +when the report has no configured files/scopes or selected scope. Canonical +reports label the selected scope (global by default); an empty scope is not +project-wide absence. Other service scopes are not included in this listing. +Malformed output shows “Could not read environment metadata”. + +The model retains only name, layer/source, scope, storage kind, set and required +metadata. It discards unknown fields, including values/materialized data, and +never includes raw output in errors. No editing, value reads, reveal, copy or +export is offered. The locked state shows `hack doctor` with **Copy command**. +Missing/unreadable `key_state` shows a lock. Absent key state means readability +is unknown, never inferred healthy from `has_value`: Set indicates storage, +not whether a value can be read. + +Fixtures: `env-loading`, `env-error`, `env-normal`, `env-missing`, `env-locked`, +`env-empty`. Each renders offscreen in both themes through `--screenshots`. +To bound rendering to one state, combine `--fixtures env-normal` with +`--screenshots `. +Screenshots for this slice: [normal light](screenshots/env-normal-light.png), +[normal dark](screenshots/env-normal-dark.png), +[missing](screenshots/env-missing-light.png), [locked](screenshots/env-locked-dark.png). + +This Env change is stacked on `feat/desktop-app-core` for focused review; +the completed desktop stack ultimately targets `next`. + +Env reads refuse missing project markers and nested `.dev` targets shadowed by +an ancestor `.hack` project, using the CLI's discovery precedence. The adapter +canonicalizes the selected root and checks markers before and after the read; +concurrent filesystem edits are not an atomic fence. Empty legacy scope arrays +are shown as `global`. macOS keychain prompting remains unverified. Rapid tab +changes may launch overlapping bounded reads; stale results are discarded, but +in-flight reads are not deduplicated. + +## Process-cleanup tests + The supported desktop test command uses the repository-pinned nextest release (installed by `scripts/install-cargo-nextest.sh` at the repository root), with zero retries and one process per test. Raw threaded `cargo test --workspace` is diff --git a/apps/desktop/app/src/main.rs b/apps/desktop/app/src/main.rs index 52639bb3b..0536023fd 100644 --- a/apps/desktop/app/src/main.rs +++ b/apps/desktop/app/src/main.rs @@ -92,7 +92,7 @@ fn main() { let args = parse_args(); #[cfg(feature = "screenshots")] if let Some(dir) = &args.screenshots { - screenshots::run(dir); + screenshots::run(dir, args.fixture); return; } #[cfg(not(feature = "screenshots"))] @@ -114,6 +114,9 @@ fn main() { let mut app = view::HackApp::new(source, fixture.is_none(), cx); if let Some(f) = fixture { app.select(fixtures::selected(f)); + if f.name().starts_with("env-") { + app.show_env_fixture(); + } if matches!(f, Fixture::DaemonDown | Fixture::DaemonMismatch) { app.simulate_daemon_unavailable(f == Fixture::DaemonMismatch); } diff --git a/apps/desktop/app/src/screenshots.rs b/apps/desktop/app/src/screenshots.rs index 6c5b9b915..e9ea9680c 100644 --- a/apps/desktop/app/src/screenshots.rs +++ b/apps/desktop/app/src/screenshots.rs @@ -14,7 +14,7 @@ use hack_desktop_core::fixtures::{self, Fixture}; use crate::view::HackApp; -pub fn run(dir: &Path) { +pub fn run(dir: &Path, fixture: Option) { std::fs::create_dir_all(dir).expect("create screenshot dir"); let mut cx = HeadlessAppContext::with_platform( gpui_kit::platform::current_platform(true).text_system(), @@ -23,7 +23,10 @@ pub fn run(dir: &Path) { ); cx.update(gpui_kit::init); for mode in [ThemeMode::Light, ThemeMode::Dark] { - for f in Fixture::ALL { + for f in Fixture::ALL + .into_iter() + .filter(|f| fixture.is_none_or(|selected| selected == *f)) + { cx.update(|cx| Theme::change(mode, None, cx)); let opts = WindowOptions { focus: false, @@ -36,6 +39,9 @@ pub fn run(dir: &Path) { cx.new(|cx| { let mut app = HackApp::new(crate::source_for(Some(f)), false, cx); app.select(fixtures::selected(f)); + if f.name().starts_with("env-") { + app.show_env_fixture(); + } if matches!(f, Fixture::DaemonDown | Fixture::DaemonMismatch) { app.simulate_daemon_unavailable(f == Fixture::DaemonMismatch); } diff --git a/apps/desktop/app/src/view.rs b/apps/desktop/app/src/view.rs index 05f159fc4..77e6707cb 100644 --- a/apps/desktop/app/src/view.rs +++ b/apps/desktop/app/src/view.rs @@ -32,6 +32,7 @@ pub struct HackApp { model: AppModel, source: Arc, tab: usize, + env: hack_desktop_core::env::EnvModel, tail: Result, String>, follow: bool, live: bool, @@ -49,6 +50,7 @@ impl HackApp { model: AppModel::new(), source, tab: 0, + env: Default::default(), tail: Ok(Vec::new()), follow: true, live, @@ -78,6 +80,42 @@ impl HackApp { pub fn select(&mut self, name: &str) { self.model.apply(Event::Select(name.to_owned())); self.refresh_now(); + self.env.begin(); + } + + /// Select the Env fixture without opening a window. + pub fn show_env_fixture(&mut self) { + self.tab = 2; + let generation = self.env.begin(); + if let Some((root, native)) = self.selected_root() { + self.env.finish(generation, self.source.env(&root, native)); + } + } + + fn read_env(&mut self, cx: &mut Context) { + let generation = self.env.begin(); + let Some((root, native)) = self.selected_root() else { + return; + }; + if !self.live { + self.env.finish(generation, self.source.env(&root, native)); + return; + } + let source = self.source.clone(); + let requested = root.clone(); + cx.spawn(async move |this, cx| { + let view = cx + .background_executor() + .spawn(async move { source.env(&root, native) }) + .await; + let _ = this.update(cx, |this, cx| { + if this.tab == 2 && this.selected_root() == Some((requested, native)) { + this.env.finish(generation, view); + cx.notify(); + } + }); + }) + .detach(); } /// Fixtures: mark an action as in flight without running it. @@ -135,6 +173,10 @@ impl HackApp { } if previous != this.selected_root() { this.tail = Ok(Vec::new()); + this.env.begin(); + if this.tab == 2 { + this.read_env(cx); + } } cx.notify(); this.follow.then(|| this.selected_root()).flatten() @@ -223,10 +265,16 @@ impl HackApp { .await; let _ = this.update(cx, |this, cx| { let previous = this.selected_root(); - if this.model.finish_refresh(token, snap) && previous != this.selected_root() { - this.tail = Ok(Vec::new()); + if this.model.finish_refresh(token, snap) { + if previous != this.selected_root() { + this.tail = Ok(Vec::new()); + this.env.begin(); + if this.tab == 2 { + this.read_env(cx); + } + } + cx.notify(); } - cx.notify(); }); }) .detach(); @@ -410,6 +458,10 @@ impl HackApp { }) .on_click(cx.listener(move |this, _, _, cx| { this.model.apply(Event::Select(name.clone())); + this.env.begin(); + if this.tab == 2 { + this.read_env(cx); + } this.tail = Ok(Vec::new()); if !this.live { this.refresh_now(); @@ -520,7 +572,7 @@ impl HackApp { .checked(d.always_on) .small() .disabled(true) - .tooltip("Needs hack autostart (HACK-2213)"), + .tooltip("Always-on controls are not available yet"), ), ) .children(reason_line(&d.state, d.last_error.as_deref(), cx)) @@ -539,10 +591,14 @@ impl HackApp { .selected_index(self.tab) .child(Tab::new().label("Services")) .child(Tab::new().label("Logs").disabled(true)) - .child(Tab::new().label("Env").disabled(true)) + .child(Tab::new().label("Env")) .child(Tab::new().label("Activity").disabled(true)) .on_click(cx.listener(|this, ix: &usize, _, cx| { + this.env.begin(); this.tab = *ix; + if *ix == 2 { + this.read_env(cx); + } cx.notify(); })); // The header and tabs stay fixed; the tab content scrolls within the @@ -559,11 +615,122 @@ impl HackApp { .flex_1() .min_h_0() .overflow_y_scroll() - .child(services_table(&d.services, cx)) - .child(self.log_tail(cx)), + .child(if self.tab == 2 { + self.env_tab(cx).into_any_element() + } else { + v_flex() + .child(services_table(&d.services, cx)) + .child(self.log_tail(cx)) + .into_any_element() + }), ) } + fn env_tab(&self, cx: &mut Context) -> impl IntoElement { + use hack_desktop_core::env::{EnvView, KeyState}; + let t = cx.theme().clone(); + let mut body = v_flex().p_5().gap_3().text_sm().child( + h_flex().gap_3().child("Environment · names only").child( + Button::new("env-check") + .label("Check again") + .small() + .on_click(cx.listener(|this, _, _, cx| { + this.read_env(cx); + cx.notify(); + })), + ), + ); + match &self.env.view { + EnvView::Loading => body = body.child("Reading environment metadata…"), + EnvView::Unreadable => { + body = body + .child("Could not read environment metadata. Check again or run hack doctor.") + } + EnvView::Report(report) => { + if let Some(scope) = &report.selected_scope { + body = body.child(format!( + "Scope: {scope} · other scopes are not included in this listing" + )); + } + body = body.child(match report.key_state { + KeyState::Missing | KeyState::Unreadable => { + "🔒 Names are shown; stored values cannot be read." + } + KeyState::Unknown => "Key readability is unknown.", + KeyState::Ok => "Key is readable", + }); + body = body.child( + "Set means a value is stored. It does not confirm that the value can be read.", + ); + if matches!(report.key_state, KeyState::Missing | KeyState::Unreadable) { + body = body.child( + h_flex() + .gap_3() + .child( + div() + .font_family(t.mono_font_family.clone()) + .child("hack doctor"), + ) + .child( + Button::new("env-doctor") + .label("Copy command") + .small() + .on_click(cx.listener(|_, _, _, cx| { + cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string( + "hack doctor".into(), + )); + })), + ), + ); + } + if report.vars.is_empty() { + body = body.child(report.empty_label()).child( + Button::new("env-docs") + .label("Environment docs") + .small() + .on_click(cx.listener(|_, _, _, cx| { + cx.open_url( + "https://github.com/hack-dance/hack/blob/next/docs/env.md", + ); + })), + ); + } else { + body = body.child( + h_flex().gap_2().children( + ["Name", "Layer / source", "Scope", "Kind", "Set", "Required"] + .map(|s| div().flex_1().child(s)), + ), + ); + for row in &report.vars { + body = body.child( + h_flex() + .gap_2() + .py_2() + .border_b_1() + .border_color(t.border) + .when(row.missing, |d| d.text_color(t.danger)) + .children( + [ + row.key.clone(), + row.layer + .clone() + .or_else(|| row.resolved_from.clone()) + .unwrap_or_else(|| "—".into()), + row.scope.label(), + row.storage.kind.clone(), + if row.has_value { "✓" } else { "✗" }.into(), + if row.required { "Yes" } else { "No" }.into(), + ] + .map(|s| div().flex_1().min_w_0().child(s)), + ), + ); + } + } + } + } + body + } + /// Shown, never acted on, once an action has run past /// `LiveSource::LIFECYCLE_WARN_AFTER`. fn slow_action_warning(&self, project: &str) -> Option { diff --git a/apps/desktop/core/src/env.rs b/apps/desktop/core/src/env.rs new file mode 100644 index 000000000..e66926d6b --- /dev/null +++ b/apps/desktop/core/src/env.rs @@ -0,0 +1,264 @@ +//! Names-only environment metadata. Unknown fields are discarded at deserialization; +//! neither raw CLI output nor parse diagnostics escape this boundary. +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum KeyState { + Ok, + Missing, + Unreadable, + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum Scope { + Canonical(String), + Legacy(Vec), +} +impl Scope { + #[must_use] + pub fn label(&self) -> String { + match self { + Self::Canonical(s) => s.clone(), + Self::Legacy(s) if s.is_empty() => "global".into(), + Self::Legacy(s) => s.join(", "), + } + } +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Storage { + pub kind: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct EnvRow { + pub key: String, + pub required: bool, + pub storage: Storage, + pub resolved_from: Option, + pub has_value: bool, + pub scope: Scope, + pub layer: Option, + #[serde(skip)] + pub missing: bool, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct EnvReport { + pub vars: Vec, + pub missing_required: Vec, + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub scopes: Vec, + pub selected_scope: Option, + #[serde(default)] + pub key_state: KeyState, +} +impl EnvReport { + /// An empty default-scope listing does not establish project-wide absence. + #[must_use] + pub fn empty_label(&self) -> &'static str { + if self.files.is_empty() && self.scopes.is_empty() && self.selected_scope.is_none() { + "This project has no env layers" + } else { + "No names in this scope. Other env scopes or layers may be configured." + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum EnvView { + #[default] + Loading, + Unreadable, + Report(EnvReport), +} +impl EnvView { + /// A bare report on exit 1 is valid. Malformed output never becomes empty. + #[must_use] + pub fn parse(output: &str, code: Option) -> Self { + if !matches!(code, Some(0 | 1)) { + return Self::Unreadable; + } + let Ok(mut report) = serde_json::from_str::(output) else { + return Self::Unreadable; + }; + for row in &mut report.vars { + row.missing = report.missing_required.contains(&row.key); + } + for key in &report.missing_required { + if !report.vars.iter().any(|r| &r.key == key) { + report.vars.push(EnvRow { + key: key.clone(), + required: true, + storage: Storage { + kind: "unknown".into(), + }, + resolved_from: None, + has_value: false, + scope: Scope::Canonical("unknown".into()), + layer: None, + missing: true, + }); + } + } + report + .vars + .sort_by(|a, b| b.missing.cmp(&a.missing).then(a.key.cmp(&b.key))); + Self::Report(report) + } +} + +/// Monotonic request identity also rejects A → B → A stale completions. +#[derive(Default)] +pub struct EnvModel { + pub view: EnvView, + generation: u64, +} +impl EnvModel { + pub fn begin(&mut self) -> u64 { + self.generation += 1; + self.view = EnvView::Loading; + self.generation + } + pub fn finish(&mut self, generation: u64, view: EnvView) { + if generation == self.generation { + self.view = view; + } + } +} + +#[must_use] +pub fn fixture(name: &str) -> EnvView { + match name { + "env-loading" => EnvView::Loading, + "env-error" => EnvView::Unreadable, + "env-empty" => EnvView::parse(r#"{"vars":[],"missing_required":[]}"#, Some(0)), + _ => { + let key_state = if name == "env-locked" { + "missing" + } else { + "unknown" + }; + let missing = if name == "env-missing" { + r#""API_TOKEN""# + } else { + "" + }; + EnvView::parse( + &format!( + r#"{{"files":["hack.env.default.yaml"],"scopes":["global","api"],"selected_scope":"global","key_state":"{key_state}","missing_required":[{missing}],"vars":[{{"key":"APP_MODE","required":false,"storage":{{"kind":"plain"}},"resolved_from":"project","has_value":true,"scope":"project","layer":null}},{{"key":"API_TOKEN","required":true,"storage":{{"kind":"encrypted"}},"resolved_from":null,"has_value":false,"scope":["runtime"],"layer":"local"}}]}}"# + ), + Some(1), + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn metadata_only_and_missing_first() { + let view = EnvView::parse( + r#"{"vars":[{"key":"Z","required":true,"storage":{"kind":"plain"},"resolved_from":null,"has_value":false,"scope":["runtime"],"layer":null,"value":"SECRET_SENTINEL","materialized":"SECRET_SENTINEL"}],"missing_required":["A"]}"#, + Some(1), + ); + let EnvView::Report(r) = &view else { + panic!("report") + }; + assert_eq!(r.vars[0].key, "A"); + assert!(r.vars[0].missing); + assert_eq!(r.key_state, KeyState::Unknown); + assert!(!format!("{view:?}").contains("SECRET_SENTINEL")); + assert_eq!( + EnvView::parse("SECRET_SENTINEL", Some(1)), + EnvView::Unreadable + ); + assert_eq!( + EnvView::parse(r#"{"missing_required":[]}"#, Some(0)), + EnvView::Unreadable + ); + assert!(matches!(fixture("env-empty"), EnvView::Report(r) if r.vars.is_empty())); + } + #[test] + fn fixtures_and_key_states_preserve_unknowns() { + assert_eq!(fixture("env-loading"), EnvView::Loading); + assert_eq!(fixture("env-error"), EnvView::Unreadable); + assert!( + matches!(fixture("env-locked"), EnvView::Report(r) if r.key_state == KeyState::Missing) + ); + assert!(matches!(fixture("env-missing"), EnvView::Report(r) if r.vars[0].missing)); + for state in ["ok", "missing", "unreadable", "future"] { + let report = EnvView::parse( + &format!(r#"{{"vars":[],"missing_required":[],"key_state":"{state}"}}"#), + Some(0), + ); + let EnvView::Report(report) = report else { + panic!("valid metadata"); + }; + assert_eq!( + report.key_state, + match state { + "ok" => KeyState::Ok, + "missing" => KeyState::Missing, + "unreadable" => KeyState::Unreadable, + _ => KeyState::Unknown, + } + ); + } + let EnvView::Report(report) = fixture("env-normal") else { + panic!("report"); + }; + assert!( + report + .vars + .iter() + .any(|r| matches!(r.scope, Scope::Canonical(_))) + ); + assert!( + report + .vars + .iter() + .any(|r| matches!(r.scope, Scope::Legacy(_))) + ); + } + + #[test] + fn empty_default_scope_does_not_hide_configured_layers() { + let view = EnvView::parse( + r#"{"vars":[],"missing_required":[],"files":["hack.env.default.yaml"],"scopes":["api"],"selected_scope":"global"}"#, + Some(0), + ); + let EnvView::Report(report) = view else { + panic!("report"); + }; + assert_ne!(report.empty_label(), "This project has no env layers"); + assert_eq!(report.selected_scope.as_deref(), Some("global")); + let EnvView::Report(empty) = fixture("env-empty") else { + panic!("report"); + }; + assert_eq!(empty.empty_label(), "This project has no env layers"); + } + + #[test] + fn legacy_empty_scope_is_global() { + assert_eq!(Scope::Legacy(vec![]).label(), "global"); + assert_eq!(Scope::Legacy(vec!["api".into()]).label(), "api"); + assert_eq!(Scope::Canonical("global".into()).label(), "global"); + } + + #[test] + fn stale_completion_is_ignored() { + let mut model = EnvModel::default(); + let old = model.begin(); + let current = model.begin(); + model.finish(old, fixture("env-empty")); + assert_eq!(model.view, EnvView::Loading); + model.finish(current, fixture("env-normal")); + assert!(matches!(model.view, EnvView::Report(_))); + } +} diff --git a/apps/desktop/core/src/fixtures.rs b/apps/desktop/core/src/fixtures.rs index e32b708c1..e9ee41a87 100644 --- a/apps/desktop/core/src/fixtures.rs +++ b/apps/desktop/core/src/fixtures.rs @@ -15,6 +15,12 @@ use crate::state::{Action, LocalContext}; /// Every designed state that can be shown with `--fixtures `. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Fixture { + EnvLoading, + EnvError, + EnvNormal, + EnvMissing, + EnvLocked, + EnvEmpty, // List states. Loading, Empty, @@ -37,7 +43,13 @@ pub enum Fixture { } impl Fixture { - pub const ALL: [Self; 15] = [ + pub const ALL: [Self; 21] = [ + Self::EnvLoading, + Self::EnvError, + Self::EnvNormal, + Self::EnvMissing, + Self::EnvLocked, + Self::EnvEmpty, Self::Loading, Self::Empty, Self::DaemonDown, @@ -58,6 +70,12 @@ impl Fixture { #[must_use] pub fn name(self) -> &'static str { match self { + Self::EnvLoading => "env-loading", + Self::EnvError => "env-error", + Self::EnvNormal => "env-normal", + Self::EnvMissing => "env-missing", + Self::EnvLocked => "env-locked", + Self::EnvEmpty => "env-empty", Self::Loading => "loading", Self::Empty => "empty", Self::DaemonDown => "daemon-down", diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index d28e0f3c7..a37469092 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -19,5 +19,7 @@ pub mod source; pub mod state; pub mod viewmodel; +pub mod env; + #[cfg(test)] mod test_support; diff --git a/apps/desktop/core/src/source.rs b/apps/desktop/core/src/source.rs index 207c7a82a..e769fce5b 100644 --- a/apps/desktop/core/src/source.rs +++ b/apps/desktop/core/src/source.rs @@ -119,6 +119,11 @@ pub const NATIVE_LOGS_NOTE: &str = "Native logs are read one service at a time; they arrive with the Logs tab."; pub trait Source: Send + Sync { + /// Read names-only env metadata; diagnostics must never contain CLI output. + fn env(&self, _root: &Path, _native: bool) -> crate::env::EnvView { + crate::env::EnvView::Unreadable + } + /// Explicit user recovery only; never called by a poll. /// # Errors /// Failed or unavailable daemon command. @@ -194,6 +199,34 @@ impl LiveSource { } impl Source for LiveSource { + fn env(&self, root: &Path, native: bool) -> crate::env::EnvView { + // env list walks ancestors. Refuse missing or shadowed project markers + // instead of attributing an ancestor's names to the selected project. + if !root.is_absolute() { + return crate::env::EnvView::Unreadable; + } + let Ok(root) = std::fs::canonicalize(root) else { + return crate::env::EnvView::Unreadable; + }; + if !root.is_dir() || !cli_selects_exactly(&root) { + return crate::env::EnvView::Unreadable; + } + match self + .runner_for(native) + .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + .run_raw( + &["env", "list", "--json"], + Some(&root), + Duration::from_secs(10), + ) { + Ok((out, _, code)) if cli_selects_exactly(&root) => { + crate::env::EnvView::parse(&out, code) + } + Ok(_) => crate::env::EnvView::Unreadable, + Err(_) => crate::env::EnvView::Unreadable, + } + } + fn recover_daemon(&self, action: DaemonRecovery) -> Result<(), String> { let (_, stderr, code) = self .runner @@ -466,6 +499,10 @@ fn cli_selects_exactly(root: &Path) -> bool { pub struct FixtureSource(pub Fixture); impl Source for FixtureSource { + fn env(&self, _root: &Path, _native: bool) -> crate::env::EnvView { + crate::env::fixture(self.0.name()) + } + fn snapshot(&self) -> Result { fixtures::snapshot(self.0) } diff --git a/apps/desktop/core/tests/env_source.rs b/apps/desktop/core/tests/env_source.rs new file mode 100644 index 000000000..de9ebdffc --- /dev/null +++ b/apps/desktop/core/tests/env_source.rs @@ -0,0 +1,104 @@ +//! Env observation against a fake executable only; no owner registry or daemon. +use hack_desktop_core::{ + env::EnvView, + runner::CliRunner, + source::{LiveSource, Source}, +}; +use std::os::unix::fs::PermissionsExt; + +#[test] +fn names_only_native_read_accepts_exit_one_and_redacts_failures() { + let dir = std::env::temp_dir().join(format!( + "desktop-env-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&dir).unwrap(); + let project = dir.join("project"); + std::fs::create_dir_all(project.join(".hack")).unwrap(); + std::fs::write(project.join(".hack/hack.project.json"), "{}").unwrap(); + let project = std::fs::canonicalize(project).unwrap(); + let exe = dir.join("hack"); + std::fs::write(&exe, r#"#!/bin/sh +[ "$#" = 5 ] || exit 2 +[ "$1 $2 $3 $4" = 'env list --json --path' ] || exit 2 +[ "$5" = "$EXPECTED_ROOT" ] || exit 2 +[ "$HOME" = "$HACK_HOME" ] || exit 2 +[ "$HACK_DAEMON_AUTOSTART_DISABLE" = 1 ] || exit 2 +if [ "$HACK_RUNTIME_BACKEND" = native ]; then + echo '{"vars":[],"missing_required":["API_TOKEN"],"key_state":"unreadable","value":"SECRET_SENTINEL","materialized":{"API_TOKEN":"SECRET_SENTINEL"}}' +else + echo 'SECRET_SENTINEL' >&2 + echo '{"error":"SECRET_SENTINEL"}' +fi +exit 1 +"#).unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o700)).unwrap(); + let source = LiveSource { + runner: CliRunner::new(exe) + .with_env("EXPECTED_ROOT", project.to_str().unwrap()) + .with_env("HOME", dir.to_str().unwrap()) + .with_env("HACK_HOME", dir.to_str().unwrap()) + .with_env("HACK_RUNTIME_BACKEND", "compose"), + daemon: None, + }; + let report = source.env(&project, true); + assert!( + matches!(&report, EnvView::Report(r) if r.vars[0].missing && r.vars[0].key == "API_TOKEN") + ); + assert!(!format!("{report:?}").contains("SECRET_SENTINEL")); + assert_eq!(source.env(&project, false), EnvView::Unreadable); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn missing_and_shadowed_nested_roots_never_launch_env_read() { + let dir = std::env::temp_dir().join(format!( + "desktop-env-roots-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let parent = dir.join("parent"); + let nested = parent.join("nested"); + std::fs::create_dir_all(parent.join(".hack")).unwrap(); + std::fs::create_dir(&nested).unwrap(); + std::fs::write(parent.join(".hack/docker-compose.yml"), "services: {}\n").unwrap(); + let called = dir.join("called"); + let exe = dir.join("hack"); + std::fs::write( + &exe, + r#"#!/bin/sh +printf x >> "$CALL_LOG" +echo '{"vars":[],"missing_required":[]}' +"#, + ) + .unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o700)).unwrap(); + let source = LiveSource { + runner: CliRunner::new(exe) + .with_env("HOME", dir.to_str().unwrap()) + .with_env("HACK_HOME", dir.to_str().unwrap()) + .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1") + .with_env("CALL_LOG", called.to_str().unwrap()), + daemon: None, + }; + assert_eq!(source.env(&nested, false), EnvView::Unreadable); + assert_eq!(source.env(&dir.join("missing"), false), EnvView::Unreadable); + // A nested .dev project is shadowed by an ancestor's primary .hack project. + std::fs::create_dir(nested.join(".dev")).unwrap(); + std::fs::write(nested.join(".dev/docker-compose.yml"), "services: {}\n").unwrap(); + assert_eq!(source.env(&nested, false), EnvView::Unreadable); + assert!(!called.exists(), "no CLI read for missing/shadowed roots"); + // An exact primary marker restores a usable, names-only read. + std::fs::create_dir(nested.join(".hack")).unwrap(); + std::fs::write(nested.join(".hack/docker-compose.yml"), "services: {}\n").unwrap(); + assert!(matches!(source.env(&nested, false), EnvView::Report(_))); + assert_eq!(std::fs::read_to_string(called).unwrap(), "x"); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/apps/desktop/screenshots/env-empty-dark.png b/apps/desktop/screenshots/env-empty-dark.png new file mode 100644 index 000000000..b307930dc Binary files /dev/null and b/apps/desktop/screenshots/env-empty-dark.png differ diff --git a/apps/desktop/screenshots/env-empty-light.png b/apps/desktop/screenshots/env-empty-light.png new file mode 100644 index 000000000..7545a4de4 Binary files /dev/null and b/apps/desktop/screenshots/env-empty-light.png differ diff --git a/apps/desktop/screenshots/env-error-dark.png b/apps/desktop/screenshots/env-error-dark.png new file mode 100644 index 000000000..ae4e67e2e Binary files /dev/null and b/apps/desktop/screenshots/env-error-dark.png differ diff --git a/apps/desktop/screenshots/env-error-light.png b/apps/desktop/screenshots/env-error-light.png new file mode 100644 index 000000000..f1b045905 Binary files /dev/null and b/apps/desktop/screenshots/env-error-light.png differ diff --git a/apps/desktop/screenshots/env-loading-dark.png b/apps/desktop/screenshots/env-loading-dark.png new file mode 100644 index 000000000..cc58917e4 Binary files /dev/null and b/apps/desktop/screenshots/env-loading-dark.png differ diff --git a/apps/desktop/screenshots/env-loading-light.png b/apps/desktop/screenshots/env-loading-light.png new file mode 100644 index 000000000..91441833b Binary files /dev/null and b/apps/desktop/screenshots/env-loading-light.png differ diff --git a/apps/desktop/screenshots/env-locked-dark.png b/apps/desktop/screenshots/env-locked-dark.png new file mode 100644 index 000000000..4baebb3b9 Binary files /dev/null and b/apps/desktop/screenshots/env-locked-dark.png differ diff --git a/apps/desktop/screenshots/env-locked-light.png b/apps/desktop/screenshots/env-locked-light.png new file mode 100644 index 000000000..a396d0677 Binary files /dev/null and b/apps/desktop/screenshots/env-locked-light.png differ diff --git a/apps/desktop/screenshots/env-missing-dark.png b/apps/desktop/screenshots/env-missing-dark.png new file mode 100644 index 000000000..c287efea8 Binary files /dev/null and b/apps/desktop/screenshots/env-missing-dark.png differ diff --git a/apps/desktop/screenshots/env-missing-light.png b/apps/desktop/screenshots/env-missing-light.png new file mode 100644 index 000000000..72ecddeb8 Binary files /dev/null and b/apps/desktop/screenshots/env-missing-light.png differ diff --git a/apps/desktop/screenshots/env-normal-dark.png b/apps/desktop/screenshots/env-normal-dark.png new file mode 100644 index 000000000..68debbad7 Binary files /dev/null and b/apps/desktop/screenshots/env-normal-dark.png differ diff --git a/apps/desktop/screenshots/env-normal-light.png b/apps/desktop/screenshots/env-normal-light.png new file mode 100644 index 000000000..5d059cf42 Binary files /dev/null and b/apps/desktop/screenshots/env-normal-light.png differ