diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8ddce..ea03fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Onboarding a machine that shares a hostname with an existing machine no longer overwrites that machine's sync state — machine identity is now a random id rather than the hostname +- `pnpm` failures now surface the real error text (pnpm writes errors to stdout, not stderr, so failures previously showed a blank reason) + +### Changed + +- New machines get a random machine id; existing machines keep their hostname-based id, so no migration runs on upgrade. A fleet that already contains two machines sharing a hostname is not auto-repaired — re-id one of them by removing its `~/.tether/state.json` and running `tether init` again + ## [1.11.10] - 2026-04-08 ### Fixed diff --git a/src/packages/pnpm.rs b/src/packages/pnpm.rs index 3f5354e..6c913ae 100644 --- a/src/packages/pnpm.rs +++ b/src/packages/pnpm.rs @@ -15,8 +15,10 @@ impl PnpmManager { let output = Command::new("pnpm").args(args).output().await?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("pnpm command failed: {}", stderr)); + return Err(anyhow::anyhow!( + "pnpm command failed: {}", + pnpm_error_message(&output.stderr, &output.stdout) + )); } Ok(String::from_utf8(output.stdout)?) @@ -93,8 +95,10 @@ impl PackageManager for PnpmManager { let output = Command::new("pnpm").args(["update", "-g"]).output().await?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("pnpm update failed: {}", stderr)); + return Err(anyhow::anyhow!( + "pnpm update failed: {}", + pnpm_error_message(&output.stderr, &output.stdout) + )); } Ok(()) @@ -107,10 +111,42 @@ impl PackageManager for PnpmManager { .await?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("pnpm remove failed: {}", stderr)); + return Err(anyhow::anyhow!( + "pnpm remove failed: {}", + pnpm_error_message(&output.stderr, &output.stdout) + )); } Ok(()) } } + +/// pnpm writes failures to stdout, not stderr — fall back when stderr is empty. +fn pnpm_error_message(stderr: &[u8], stdout: &[u8]) -> String { + let err = String::from_utf8_lossy(stderr); + let trimmed = err.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + String::from_utf8_lossy(stdout).trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefers_trimmed_stderr() { + assert_eq!(pnpm_error_message(b" boom ", b"ignored"), "boom"); + } + + #[test] + fn falls_back_to_stdout_when_stderr_blank() { + assert_eq!(pnpm_error_message(b" \n", b" ENOENT "), "ENOENT"); + } + + #[test] + fn empty_when_both_blank() { + assert_eq!(pnpm_error_message(b"", b" "), ""); + } +} diff --git a/src/security/encryption.rs b/src/security/encryption.rs index a032613..a0b58a5 100644 --- a/src/security/encryption.rs +++ b/src/security/encryption.rs @@ -14,6 +14,13 @@ pub fn generate_key() -> [u8; KEY_SIZE] { key } +/// Generate a random hex id. +pub fn random_hex_id() -> String { + let mut bytes = [0u8; 6]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + /// Encrypt data using AES-256-GCM /// Format: [nonce (12 bytes)][ciphertext + auth tag] pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result> { @@ -96,6 +103,16 @@ mod tests { assert_ne!(key1, key2); // Keys should be random } + #[test] + fn test_random_hex_id() { + let id1 = random_hex_id(); + let id2 = random_hex_id(); + + assert_eq!(id1.len(), 12); + assert!(id1.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(id1, id2); + } + #[test] fn test_encrypt_decrypt_roundtrip() { let key = generate_key(); diff --git a/src/security/mod.rs b/src/security/mod.rs index acdf0f0..6fa7f03 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -58,7 +58,7 @@ pub fn write_owner_only(path: &Path, data: &[u8]) -> Result<()> { Ok(()) } -pub use encryption::{decrypt, encrypt, generate_key}; +pub use encryption::{decrypt, encrypt, generate_key, random_hex_id}; pub use keychain::{ clear_cached_key, get_encryption_key, has_encryption_key, is_unlocked, store_encryption_key_with_passphrase, unlock_with_passphrase, diff --git a/src/sync/state.rs b/src/sync/state.rs index b2204c8..f986ea2 100644 --- a/src/sync/state.rs +++ b/src/sync/state.rs @@ -278,11 +278,11 @@ impl SyncState { } } + /// A random id, not the hostname — hostnames collide across machines, and a + /// collision makes one machine's sync overwrite another's `machines/.json` + /// record. The human-readable hostname lives on `MachineState` as metadata. fn generate_machine_id() -> String { - hostname::get() - .ok() - .and_then(|h| h.into_string().ok()) - .unwrap_or_else(|| "unknown".to_string()) + crate::security::random_hex_id() } pub fn update_file(&mut self, path: &str, hash: String) {