diff --git a/crates/solstone-linux/src/cli.rs b/crates/solstone-linux/src/cli.rs index c78ab29..c86895e 100644 --- a/crates/solstone-linux/src/cli.rs +++ b/crates/solstone-linux/src/cli.rs @@ -446,7 +446,11 @@ fn cmd_settings(paths: ConfigPaths, prompt: &mut dyn PromptIo) -> i32 { prompt.write_line(&format!( "\nSettings saved to {}", config.config_path().display() - )) + ))?; + // Config is read once at startup and never re-read, so a running sol keeps the + // old values. Saying only "saved" invites the owner to believe otherwise. + prompt.write_line("These take effect the next time sol starts.")?; + prompt.write_line(" systemctl --user restart solstone-linux") })(); if let Err(error) = result { eprintln!("Error editing settings: {error}"); @@ -464,12 +468,20 @@ fn cmd_status(paths: ConfigPaths, runner: &dyn Runner, output: &mut dyn Write) - } else { &config.stream }; + // Resolved before the first line is written: "managed privately" describes a link that + // exists. Printing it while sol is telling the owner to pair contradicts the sync line + // two lines below it. + let link_line = if crate::private_link::credential_present(&config.config_dir) { + "Journal link: managed privately" + } else { + "Journal link: not paired" + }; let mut render = || -> io::Result<()> { write_line( output, format!("Config: {}", config.config_path().display()), )?; - write_line(output, "Journal link: managed privately")?; + write_line(output, link_line)?; write_line(output, format!("Stream: {stream}"))?; write_line(output, "")?; let captures = config.captures_dir(); @@ -1212,6 +1224,9 @@ mod tests { let mut config = load_config(paths(t)).config; config.stream = "test-stream".into(); save_config(&config).unwrap(); + // These fixtures model a configured observer, which is a paired one. The link line + // is presence-only, so the bytes never have to be a real credential. + fs::write(config.config_dir.join("credentials.json"), "{}").unwrap(); config } @@ -1266,6 +1281,7 @@ mod tests { let t = tempfile::tempdir().unwrap(); fs::create_dir_all(t.path().join("config")).unwrap(); fs::write(t.path().join("config/config.json"), "[]").unwrap(); + fs::write(t.path().join("config/credentials.json"), "{}").unwrap(); let mut out = Vec::new(); assert_eq!(cmd_status(paths(&t), &StatusRunner(None), &mut out), 0); assert!( @@ -1275,6 +1291,22 @@ mod tests { ); } + // AC: the link line reports the link that exists, so it cannot contradict a sync line + // telling the owner to pair. This is the upgrade shape — config present, never paired. + #[test] + fn status_reports_an_absent_link_as_not_paired() { + let t = tempfile::tempdir().unwrap(); + let mut config = load_config(paths(&t)).config; + config.stream = "test-stream".into(); + save_config(&config).unwrap(); + assert!(!config.config_dir.join("credentials.json").exists()); + let mut out = Vec::new(); + assert_eq!(cmd_status(paths(&t), &StatusRunner(None), &mut out), 0); + let out = String::from_utf8(out).unwrap(); + assert!(out.contains("Journal link: not paired")); + assert!(!out.contains("managed privately")); + } + #[test] fn status_never_surfaces_discarded_legacy_values() { let t = tempfile::tempdir().unwrap(); @@ -1288,6 +1320,7 @@ mod tests { "stream":"desktop" }"#, ) + .and_then(|()| fs::write(t.path().join("config/credentials.json"), "{}")) .unwrap(); let mut out = Vec::new(); assert_eq!(cmd_status(paths(&t), &StatusRunner(None), &mut out), 0); diff --git a/crates/solstone-linux/src/private_link.rs b/crates/solstone-linux/src/private_link.rs index 5084774..61880be 100644 --- a/crates/solstone-linux/src/private_link.rs +++ b/crates/solstone-linux/src/private_link.rs @@ -704,6 +704,16 @@ fn read_private_file( Ok(Some(bytes)) } +/// Whether pairing material exists, without reading or parsing it. +/// +/// Status rendering needs to know that a link exists; it has no business holding the +/// credential to find that out. A present-but-unreadable file still counts as present — +/// the health surfaces already distinguish broken private state from absent state, and +/// reporting "not paired" for a file we merely failed to read would be its own lie. +pub(crate) fn credential_present(config_root: &Path) -> bool { + config_root.join(CREDENTIALS_FILENAME).exists() +} + pub(crate) fn load_credential(config_root: &Path) -> Result, PrivateStateError> { let Some(bytes) = read_private_file( &config_root.join(CREDENTIALS_FILENAME),