Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 42 additions & 6 deletions src/packages/pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?)
Expand Down Expand Up @@ -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(())
Expand All @@ -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" "), "");
}
}
17 changes: 17 additions & 0 deletions src/security/encryption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/security/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/sync/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.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) {
Expand Down
Loading