From 078a949bfa23807848c758c401b54c0ca432f787 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Feb 2026 13:20:34 +1000 Subject: [PATCH 01/15] feat: add Windows platform support - WinGet package manager with cross-platform mapping - Task Scheduler daemon with restart-on-failure - Symlink fallback to copy without Developer Mode - Windows file permissions via icacls - Cross-platform path normalization - CI matrix and release pipeline for Windows --- .github/workflows/ci.yml | 5 +- .github/workflows/release.yml | 65 ++++- CHANGELOG.md | 12 + Cargo.toml | 13 +- WINDOWS_SUPPORT.md | 40 +++ src/cli/commands/config.rs | 80 +++--- src/cli/commands/daemon.rs | 206 ++++++++++--- src/cli/commands/diff.rs | 180 ++++++------ src/cli/commands/identity.rs | 2 +- src/cli/commands/init.rs | 11 +- src/cli/commands/machines.rs | 41 +-- src/cli/commands/packages.rs | 3 +- src/cli/commands/resolve.rs | 4 +- src/cli/commands/status.rs | 357 +++++++---------------- src/cli/commands/sync.rs | 251 ++++++++-------- src/cli/commands/team.rs | 8 +- src/cli/commands/upgrade.rs | 25 +- src/cli/output.rs | 66 +++++ src/config.rs | 146 ++++++---- src/daemon/mod.rs | 1 + src/daemon/pid.rs | 41 +++ src/daemon/server.rs | 528 +++++++++++++++++++--------------- src/github.rs | 18 +- src/lib.rs | 4 + src/packages/brew.rs | 34 ++- src/packages/bun.rs | 90 +----- src/packages/gem.rs | 94 +----- src/packages/manager.rs | 66 ++++- src/packages/mapping.rs | 368 ++++++++++++++++++++++++ src/packages/mod.rs | 10 + src/packages/npm.rs | 94 +----- src/packages/pnpm.rs | 94 +----- src/packages/uv.rs | 83 +----- src/packages/winget.rs | 350 ++++++++++++++++++++++ src/security/encryption.rs | 10 - src/security/keychain.rs | 8 +- src/security/mod.rs | 25 +- src/security/recipients.rs | 18 +- src/security/secrets.rs | 14 +- src/sync/backup.rs | 4 +- src/sync/conflict.rs | 83 +++--- src/sync/engine.rs | 3 +- src/sync/git.rs | 49 +++- src/sync/layers.rs | 18 +- src/sync/mod.rs | 170 ++++++++++- src/sync/packages.rs | 248 +++++++++++++++- src/sync/state.rs | 54 ++-- src/sync/team.rs | 54 +--- 48 files changed, 2643 insertions(+), 1505 deletions(-) create mode 100644 WINDOWS_SUPPORT.md create mode 100644 src/daemon/pid.rs create mode 100644 src/packages/mapping.rs create mode 100644 src/packages/winget.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a06370..2cbbe66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,10 @@ env: jobs: test: - runs-on: macos-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 374120e..5f03466 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ env: SIGNING_IDENTITY: "Developer ID Application: Paddo Tech PTY LTD (D9Q57P9D3L)" jobs: - build: + build-macos: runs-on: self-hosted steps: - uses: actions/checkout@v4 @@ -77,14 +77,62 @@ jobs: - name: Package binaries run: | + mkdir -p dist + cd target/aarch64-apple-darwin/release tar -czf tether-aarch64-apple-darwin.tar.gz tether shasum -a 256 tether-aarch64-apple-darwin.tar.gz > tether-aarch64-apple-darwin.tar.gz.sha256 + cp tether-aarch64-apple-darwin.tar.gz tether-aarch64-apple-darwin.tar.gz.sha256 ../../../dist/ cd - cd target/x86_64-apple-darwin/release tar -czf tether-x86_64-apple-darwin.tar.gz tether shasum -a 256 tether-x86_64-apple-darwin.tar.gz > tether-x86_64-apple-darwin.tar.gz.sha256 + cp tether-x86_64-apple-darwin.tar.gz tether-x86_64-apple-darwin.tar.gz.sha256 ../../../dist/ + + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-binaries + path: dist/ + + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build release + run: cargo build --release + + - name: Package binary + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist + cd target/release + Compress-Archive -Path tether.exe -DestinationPath tether-x86_64-pc-windows-msvc.zip + $hash = (Get-FileHash tether-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash.ToLower() + "$hash tether-x86_64-pc-windows-msvc.zip" | Out-File -NoNewline -Encoding ascii tether-x86_64-pc-windows-msvc.zip.sha256 + Copy-Item tether-x86_64-pc-windows-msvc.zip, tether-x86_64-pc-windows-msvc.zip.sha256 ../../dist/ + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: windows-binary + path: dist/ + + release: + needs: [build-macos, build-windows] + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts - name: Get version from Cargo.toml id: version @@ -129,10 +177,12 @@ jobs: prerelease: ${{ steps.version.outputs.prerelease == 'true' }} generate_release_notes: true files: | - target/aarch64-apple-darwin/release/tether-aarch64-apple-darwin.tar.gz - target/aarch64-apple-darwin/release/tether-aarch64-apple-darwin.tar.gz.sha256 - target/x86_64-apple-darwin/release/tether-x86_64-apple-darwin.tar.gz - target/x86_64-apple-darwin/release/tether-x86_64-apple-darwin.tar.gz.sha256 + artifacts/macos-binaries/tether-aarch64-apple-darwin.tar.gz + artifacts/macos-binaries/tether-aarch64-apple-darwin.tar.gz.sha256 + artifacts/macos-binaries/tether-x86_64-apple-darwin.tar.gz + artifacts/macos-binaries/tether-x86_64-apple-darwin.tar.gz.sha256 + artifacts/windows-binary/tether-x86_64-pc-windows-msvc.zip + artifacts/windows-binary/tether-x86_64-pc-windows-msvc.zip.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -143,8 +193,8 @@ jobs: VERSION: ${{ steps.version.outputs.version }} IS_PRERELEASE: ${{ steps.version.outputs.prerelease }} run: | - SHA_ARM=$(cat target/aarch64-apple-darwin/release/tether-aarch64-apple-darwin.tar.gz.sha256 | awk '{print $1}') - SHA_X86=$(cat target/x86_64-apple-darwin/release/tether-x86_64-apple-darwin.tar.gz.sha256 | awk '{print $1}') + SHA_ARM=$(cat artifacts/macos-binaries/tether-aarch64-apple-darwin.tar.gz.sha256 | awk '{print $1}') + SHA_X86=$(cat artifacts/macos-binaries/tether-x86_64-apple-darwin.tar.gz.sha256 | awk '{print $1}') # Clone the tap repo rm -rf /tmp/homebrew-tap @@ -207,4 +257,3 @@ jobs: git add "$FORMULA_FILE" git commit -m "tether-cli ${VERSION}" git push - diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fbc3ee..df9cfcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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] + +### Added + +- Windows platform support with winget package manager +- Cross-platform package mapping for dotfile sync across macOS/Linux/Windows +- Windows daemon scheduling via Task Scheduler +- Windows secrets file permissions via `icacls` +- Symlink fallback to file copy when Windows privileges unavailable +- Cross-platform path normalization (forward slashes in sync state) +- Windows CI test matrix and release builds (`.zip` + SHA256) + ## [1.6.1] - 2026-01-30 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index fba6dc4..a703981 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,9 +59,6 @@ which = "6.0" log = "0.4" env_logger = "0.11" -# IPC for daemon -interprocess = "2.0" - # Async trait async-trait = "0.1" @@ -83,10 +80,16 @@ glob = "0.3" # Text diffing similar = "2.6" -# System signals -libc = "0.2" +# Temp files tempfile = "3.8" +# File locking +fs2 = "0.4" + +# System signals (Unix only) +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tempfile = "3.8" assert_cmd = "2.0" diff --git a/WINDOWS_SUPPORT.md b/WINDOWS_SUPPORT.md new file mode 100644 index 0000000..f8d4f00 --- /dev/null +++ b/WINDOWS_SUPPORT.md @@ -0,0 +1,40 @@ +# Windows Support + +## What works + +- **Dotfile sync** — Push/pull encrypted dotfiles, `.gitconfig` syncs via `~/.gitconfig` (`%USERPROFILE%\.gitconfig`) +- **Package management** — WinGet integration: list, install, uninstall, upgrade, export/import manifests +- **Daemon** — Background sync via Task Scheduler (`schtasks`), RestartOnFailure for auto-restart +- **Project configs** — Sync project-level configs with symlink support (falls back to copy without Developer Mode) +- **Team sync** — Symlinks for team configs, copy fallback on Windows +- **Notifications** — PowerShell toast notifications for sync conflicts +- **CI** — `ubuntu-latest` + `macos-latest` + `windows-latest` matrix +- **Release** — Signed macOS binaries + Windows `.zip` with SHA256 + +## Platform behavior differences + +| Feature | macOS | Windows | +|---------|-------|---------| +| Daemon install | launchd (KeepAlive) | Task Scheduler (RestartOnFailure) | +| Symlinks | Native | Requires Developer Mode; copies as fallback | +| Notifications | AppleScript | PowerShell toast | +| Default merge tool | `opendiff` | `code` (VS Code) | +| Default editor | `nano` | `notepad` | +| File permissions | `0o600` for secrets | ACL restricted via `icacls` | +| Process management | `kill`/signals (graceful SIGTERM) | `tasklist`/`taskkill` (force kill only) | +| Package manager | Homebrew | WinGet | + +## Architecture notes + +- Package managers check `is_available()` at runtime — brew returns false on Windows, winget returns false on macOS +- Default dotfiles (`.zshrc`, `.bashrc`) have `create_if_missing: false` so they won't be created on Windows +- Config dir is `~/.tether/` → `C:\Users\\.tether\` via the `home` crate +- State keys use forward slashes on all platforms (normalized with `.replace('\\', "/")`) +- Path validation rejects `..`, `/`, `\`, and drive letters (`C:\`) for traversal safety +- `create_symlink()` in `sync/mod.rs` handles the Developer Mode fallback centrally + +## Known limitations + +- No ARM64 Windows build (x86_64 runs under emulation) +- `winget list` parser uses fixed-width column positions — handles CJK double-width characters but may break with non-English locales (different header text) +- No WinGet manifest in release pipeline (users install from GitHub releases) diff --git a/src/cli/commands/config.rs b/src/cli/commands/config.rs index 8820ea2..0682908 100644 --- a/src/cli/commands/config.rs +++ b/src/cli/commands/config.rs @@ -39,7 +39,7 @@ pub async fn get(key: &str) -> Result<()> { toml::Value::Table(_) => { println!("{}", toml::to_string_pretty(current)?); } - _ => println!("{:?}", current), + toml::Value::Datetime(dt) => println!("{}", dt), } Ok(()) @@ -115,6 +115,8 @@ pub async fn edit() -> Result<()> { let editor = std::env::var("EDITOR").unwrap_or_else(|_| { if cfg!(target_os = "macos") { "nano".to_string() + } else if cfg!(target_os = "windows") { + "notepad".to_string() } else { "vi".to_string() } @@ -442,36 +444,49 @@ fn manage_dotfile_list( /// List all features and their status pub async fn features_list() -> Result<()> { + use owo_colors::OwoColorize; + let config = Config::load()?; Output::header("Feature Toggles"); println!(); - print_feature( - "personal_dotfiles", - config.features.personal_dotfiles, - "Sync shell configs (.zshrc, .gitconfig)", - ); - print_feature( - "personal_packages", - config.features.personal_packages, - "Sync packages (brew, npm, etc.)", - ); - print_feature( - "team_dotfiles", - config.features.team_dotfiles, - "Sync org-based team dotfiles", - ); - print_feature( - "collab_secrets", - config.features.collab_secrets, - "Share project secrets with collaborators", - ); - print_feature( - "team_layering", - config.features.team_layering, - "Merge team + personal dotfiles (experimental)", - ); + let features = [ + ( + "personal_dotfiles", + config.features.personal_dotfiles, + "Sync shell configs (.zshrc, .gitconfig)", + ), + ( + "personal_packages", + config.features.personal_packages, + "Sync packages (brew, npm, etc.)", + ), + ( + "team_dotfiles", + config.features.team_dotfiles, + "Sync org-based team dotfiles", + ), + ( + "collab_secrets", + config.features.collab_secrets, + "Share project secrets with collaborators", + ), + ( + "team_layering", + config.features.team_layering, + "Merge team + personal dotfiles (experimental)", + ), + ]; + + for (name, enabled, desc) in features { + if enabled { + Output::key_value_colored(name, "enabled", |v| v.green().to_string()); + } else { + Output::key_value_colored(name, "disabled", |v| v.dimmed().to_string()); + } + println!(" {}", desc.dimmed()); + } println!(); Output::dim("Enable/disable: tether config features "); @@ -479,19 +494,6 @@ pub async fn features_list() -> Result<()> { Ok(()) } -fn print_feature(name: &str, enabled: bool, desc: &str) { - use owo_colors::OwoColorize; - - let status = if enabled { - "enabled".green().to_string() - } else { - "disabled".dimmed().to_string() - }; - - println!(" {} [{}]", name.bold(), status); - println!(" {}", desc.dimmed()); -} - /// Enable a feature pub async fn features_enable(feature: &str) -> Result<()> { let mut config = Config::load()?; diff --git a/src/cli/commands/daemon.rs b/src/cli/commands/daemon.rs index 03284e2..dd230ae 100644 --- a/src/cli/commands/daemon.rs +++ b/src/cli/commands/daemon.rs @@ -1,9 +1,9 @@ use crate::cli::Output; use crate::config::Config; +use crate::daemon::pid::{is_process_running, read_daemon_pid}; use crate::daemon::DaemonServer; use anyhow::Result; use std::fs::{self, OpenOptions}; -use std::io; use std::path::PathBuf; use std::process::{Command, Stdio}; use tokio::time::{sleep, Duration}; @@ -49,13 +49,20 @@ pub async fn start() -> Result<()> { .append(true) .open(&paths.log)?; - let child = Command::new(exe) - .arg("daemon") + let mut cmd = Command::new(exe); + cmd.arg("daemon") .arg("run") .stdin(Stdio::null()) .stdout(Stdio::from(stdout)) - .stderr(Stdio::from(stderr)) - .spawn()?; + .stderr(Stdio::from(stderr)); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x00000008 | 0x00000200); // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP + } + + let child = cmd.spawn()?; let pid = child.id(); fs::write(&paths.pid, pid.to_string())?; @@ -79,13 +86,7 @@ pub async fn stop() -> Result<()> { return Ok(()); } - let signal_result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; - if signal_result != 0 { - let err = io::Error::last_os_error(); - if err.kind() != io::ErrorKind::NotFound { - return Err(anyhow::anyhow!("Failed to stop daemon: {}", err)); - } - } + terminate_process(pid)?; // Graceful: wait up to 10 seconds for _ in 0..50 { @@ -97,8 +98,8 @@ pub async fn stop() -> Result<()> { // Force kill if still running if is_process_running(pid) { - log::debug!("Daemon did not exit gracefully, sending SIGKILL"); - unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; + log::debug!("Daemon did not exit gracefully, force killing"); + force_kill_process(pid); // Wait for forced termination for _ in 0..10 { @@ -112,7 +113,7 @@ pub async fn stop() -> Result<()> { // Final check if is_process_running(pid) { return Err(anyhow::anyhow!( - "Daemon did not exit after SIGKILL. Check logs: {}", + "Daemon did not exit after force kill. Check logs: {}", paths.log.display() )); } @@ -159,28 +160,49 @@ pub async fn run_daemon() -> Result<()> { result } -fn read_daemon_pid() -> Result> { - let pid_path = DaemonPaths::new()?.pid; - if !pid_path.exists() { - return Ok(None); - } - - let contents = fs::read_to_string(&pid_path)?; - match contents.trim().parse::() { - Ok(pid) if pid > 0 => Ok(Some(pid)), - _ => Ok(None), +#[cfg(unix)] +fn terminate_process(pid: u32) -> Result<()> { + let result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; + if result != 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::ESRCH) { + return Err(anyhow::anyhow!("Failed to stop daemon: {}", err)); + } } + Ok(()) } -fn is_process_running(pid: u32) -> bool { - unsafe { - if libc::kill(pid as libc::pid_t, 0) == 0 { - true - } else { - let err = io::Error::last_os_error(); - err.kind() != io::ErrorKind::NotFound +#[cfg(windows)] +fn terminate_process(pid: u32) -> Result<()> { + use std::process::Command; + // taskkill without /F sends WM_CLOSE — ineffective for detached/console-less processes. + // Don't error on failure; stop() will fall through to force_kill_process. + let output = Command::new("taskkill") + .args(["/PID", &pid.to_string()]) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("not found") { + log::debug!( + "Graceful taskkill failed (expected for detached): {}", + stderr.trim() + ); } } + Ok(()) +} + +#[cfg(unix)] +fn force_kill_process(pid: u32) { + unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; +} + +#[cfg(windows)] +fn force_kill_process(pid: u32) { + use std::process::Command; + let _ = Command::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .output(); } fn cleanup_pid_file(expected_pid: Option) -> Result<()> { @@ -200,16 +222,19 @@ fn cleanup_pid_file(expected_pid: Option) -> Result<()> { Ok(()) } +#[cfg(target_os = "macos")] const LAUNCHD_LABEL: &str = "com.tether.daemon"; +#[cfg(target_os = "macos")] fn launchd_plist_path() -> Result { - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; Ok(home .join("Library") .join("LaunchAgents") .join(format!("{LAUNCHD_LABEL}.plist"))) } +#[cfg(target_os = "macos")] fn generate_plist() -> Result { let exe = std::env::current_exe()?; let paths = DaemonPaths::new()?; @@ -247,11 +272,89 @@ fn generate_plist() -> Result { } pub async fn install() -> Result<()> { - #[cfg(not(target_os = "macos"))] + #[cfg(windows)] { - return Err(anyhow::anyhow!( - "Launchd is only available on macOS. Use 'tether daemon start' instead." - )); + if let Some(pid) = read_daemon_pid()? { + if is_process_running(pid) { + Output::info("Stopping existing daemon..."); + stop().await?; + } + } + + let exe = std::env::current_exe()?; + let exe_escaped = exe + .display() + .to_string() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """); + // XML task definition enables RestartOnFailure (equivalent to macOS KeepAlive) + let task_xml = format!( + r#" + + + true + + + + PT1M + 999 + + PT0S + false + false + + + + {exe_escaped} + daemon run + + +"# + ); + + // schtasks expects UTF-16 LE with BOM; use random temp file to avoid symlink attacks + let mut xml_file = tempfile::Builder::new().suffix(".xml").tempfile()?; + let xml_path = xml_file.path().to_path_buf(); + let utf16: Vec = task_xml.encode_utf16().collect(); + let mut bytes = vec![0xFF, 0xFE]; // UTF-16 LE BOM + for word in &utf16 { + bytes.extend_from_slice(&word.to_le_bytes()); + } + std::io::Write::write_all(&mut xml_file, &bytes)?; + + // Remove existing task if present + let _ = Command::new("schtasks") + .args(["/Delete", "/TN", "TetherDaemon", "/F"]) + .output(); + + let output = Command::new("schtasks") + .args(["/Create", "/TN", "TetherDaemon", "/XML"]) + .arg(&xml_path) + .output()?; + + drop(xml_file); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Failed to create scheduled task: {}", + stderr + )); + } + + start().await?; + Output::success("Scheduled task installed"); + Output::info("Daemon will now start automatically on login and restart if it exits"); + Ok(()) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Err(anyhow::anyhow!( + "Daemon auto-start not supported on this platform. Use 'tether daemon start' instead." + )) } #[cfg(target_os = "macos")] @@ -302,9 +405,34 @@ pub async fn install() -> Result<()> { } pub async fn uninstall() -> Result<()> { - #[cfg(not(target_os = "macos"))] + #[cfg(windows)] + { + let output = Command::new("schtasks") + .args(["/Delete", "/TN", "TetherDaemon", "/F"]) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("does not exist") || stderr.contains("cannot find") { + Output::info("Scheduled task is not installed"); + return Ok(()); + } + return Err(anyhow::anyhow!( + "Failed to delete scheduled task: {}", + stderr + )); + } + + stop().await.ok(); + Output::success("Scheduled task uninstalled"); + Ok(()) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] { - return Err(anyhow::anyhow!("Launchd is only available on macOS")); + Err(anyhow::anyhow!( + "Daemon auto-start not supported on this platform" + )) } #[cfg(target_os = "macos")] diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index 99bfef3..3f3bc9e 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -7,14 +7,6 @@ use owo_colors::OwoColorize; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; -fn format_diff_line(symbol: &str, status: &str, pkg: &str) -> String { - match status { - "added" => format!(" {} {}", symbol.green(), pkg), - "removed" => format!(" {} {}", symbol.red(), pkg), - _ => format!(" {} {}", symbol.yellow(), pkg), - } -} - pub async fn run(machine: Option<&str>) -> Result<()> { let config = match Config::load() { Ok(c) => c, @@ -37,7 +29,7 @@ pub async fn run(machine: Option<&str>) -> Result<()> { let state = SyncState::load()?; let sync_path = SyncEngine::sync_path()?; - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; // Pull latest to ensure we have current remote state Output::info("Fetching latest changes..."); @@ -187,12 +179,15 @@ fn show_dotfile_diff( } async fn show_package_diff(config: &Config, sync_path: &std::path::Path) -> Result<()> { - use crate::packages::{BrewManager, NpmManager, PackageManager, PnpmManager, UvManager}; + use crate::packages::{ + BrewManager, BunManager, GemManager, NpmManager, PackageManager, PnpmManager, UvManager, + WingetManager, + }; let manifests_dir = sync_path.join("manifests"); let mut has_diff = false; - // Homebrew diff + // Homebrew diff (special: uses Brewfile format) if config.packages.brew.enabled { let brew = BrewManager::new(); if brew.is_available().await { @@ -214,7 +209,7 @@ async fn show_package_diff(config: &Config, sync_path: &std::path::Path) -> Resu "removed" => "-", _ => "~", }; - println!("{}", format_diff_line(symbol, &status, &pkg)); + Output::diff_line(symbol, &pkg, &status); } println!(); } @@ -222,95 +217,95 @@ async fn show_package_diff(config: &Config, sync_path: &std::path::Path) -> Resu } } - // npm diff - if config.packages.npm.enabled { - let npm = NpmManager::new(); - if npm.is_available().await { - let npm_path = manifests_dir.join("npm.txt"); - if npm_path.exists() { - let remote_manifest = std::fs::read_to_string(&npm_path)?; - let local_manifest = npm.export_manifest().await?; - - let remote_packages: Vec<_> = - remote_manifest.lines().filter(|l| !l.is_empty()).collect(); - let local_packages: Vec<_> = - local_manifest.lines().filter(|l| !l.is_empty()).collect(); - - let diff = diff_package_lists(&remote_packages, &local_packages); - if !diff.is_empty() { - has_diff = true; - println!("{}", "npm:".bright_cyan().bold()); - for (pkg, status) in diff { - let symbol = match status.as_str() { - "added" => "+", - "removed" => "-", - _ => "~", - }; - println!("{}", format_diff_line(symbol, &status, &pkg)); - } - println!(); - } - } + // Simple manifest managers (line-based .txt files) + let simple_managers: Vec<(bool, Box, &str, &str)> = vec![ + ( + config.packages.npm.enabled, + Box::new(NpmManager::new()), + "npm.txt", + "npm", + ), + ( + config.packages.pnpm.enabled, + Box::new(PnpmManager::new()), + "pnpm.txt", + "pnpm", + ), + ( + config.packages.bun.enabled, + Box::new(BunManager::new()), + "bun.txt", + "bun", + ), + ( + config.packages.gem.enabled, + Box::new(GemManager::new()), + "gems.txt", + "gem", + ), + ( + config.packages.uv.enabled, + Box::new(UvManager::new()), + "uv.txt", + "uv", + ), + ]; + + for (enabled, manager, filename, label) in simple_managers { + if !enabled || !manager.is_available().await { + continue; } - } - - // pnpm diff - if config.packages.pnpm.enabled { - let pnpm = PnpmManager::new(); - if pnpm.is_available().await { - let pnpm_path = manifests_dir.join("pnpm.txt"); - if pnpm_path.exists() { - let remote_manifest = std::fs::read_to_string(&pnpm_path)?; - let local_manifest = pnpm.export_manifest().await?; - - let remote_packages: Vec<_> = - remote_manifest.lines().filter(|l| !l.is_empty()).collect(); - let local_packages: Vec<_> = - local_manifest.lines().filter(|l| !l.is_empty()).collect(); - - let diff = diff_package_lists(&remote_packages, &local_packages); - if !diff.is_empty() { - has_diff = true; - println!("{}", "pnpm:".bright_cyan().bold()); - for (pkg, status) in diff { - let symbol = match status.as_str() { - "added" => "+", - "removed" => "-", - _ => "~", - }; - println!("{}", format_diff_line(symbol, &status, &pkg)); - } - println!(); - } + let manifest_path = manifests_dir.join(filename); + if !manifest_path.exists() { + continue; + } + let remote_manifest = std::fs::read_to_string(&manifest_path)?; + let local_manifest = manager.export_manifest().await?; + + let remote_packages: Vec<_> = remote_manifest.lines().filter(|l| !l.is_empty()).collect(); + let local_packages: Vec<_> = local_manifest.lines().filter(|l| !l.is_empty()).collect(); + + let diff = diff_package_lists(&remote_packages, &local_packages, false); + if !diff.is_empty() { + has_diff = true; + println!("{}", format!("{}:", label).bright_cyan().bold()); + for (pkg, status) in diff { + let symbol = match status.as_str() { + "added" => "+", + "removed" => "-", + _ => "~", + }; + Output::diff_line(symbol, &pkg, &status); } + println!(); } } - // uv diff - if config.packages.uv.enabled { - let uv = UvManager::new(); - if uv.is_available().await { - let uv_path = manifests_dir.join("uv.txt"); - if uv_path.exists() { - let remote_manifest = std::fs::read_to_string(&uv_path)?; - let local_manifest = uv.export_manifest().await?; + // winget diff + if config.packages.winget.enabled { + let winget = WingetManager::new(); + if winget.is_available().await { + let winget_path = manifests_dir.join("winget.txt"); + if winget_path.exists() { + let remote_manifest = std::fs::read_to_string(&winget_path)?; + let local_manifest = winget.export_manifest().await?; let remote_packages: Vec<_> = remote_manifest.lines().filter(|l| !l.is_empty()).collect(); let local_packages: Vec<_> = local_manifest.lines().filter(|l| !l.is_empty()).collect(); - let diff = diff_package_lists(&remote_packages, &local_packages); + let diff = diff_package_lists(&remote_packages, &local_packages, true); if !diff.is_empty() { has_diff = true; - println!("{}", "uv:".bright_cyan().bold()); + println!("{}", "winget:".bright_cyan().bold()); for (pkg, status) in diff { let symbol = match status.as_str() { "added" => "+", "removed" => "-", _ => "~", }; - println!("{}", format_diff_line(symbol, &status, &pkg)); + Output::diff_line(symbol, &pkg, &status); } println!(); } @@ -389,17 +384,30 @@ fn diff_packages( diff } -fn diff_package_lists(remote: &[&str], local: &[&str]) -> Vec<(String, String)> { +fn diff_package_lists( + remote: &[&str], + local: &[&str], + case_insensitive: bool, +) -> Vec<(String, String)> { let mut diff = Vec::new(); + let contains = |haystack: &[&str], needle: &str| { + if case_insensitive { + let lower = needle.to_lowercase(); + haystack.iter().any(|s| s.to_lowercase() == lower) + } else { + haystack.contains(&needle) + } + }; + for pkg in local { - if !remote.contains(pkg) { + if !contains(remote, pkg) { diff.push((pkg.to_string(), "added".to_string())); } } for pkg in remote { - if !local.contains(pkg) { + if !contains(local, pkg) { diff.push((pkg.to_string(), "removed".to_string())); } } @@ -525,7 +533,7 @@ fn show_machine_diff(current: &MachineState, other: &MachineState) -> Result<()> println!("{}", format!("{}:", manager).bright_cyan().bold()); for (pkg, status) in diffs { let symbol = if status == "added" { "+" } else { "-" }; - println!("{}", format_diff_line(symbol, &status, &pkg)); + Output::diff_line(symbol, &pkg, &status); } println!(); } diff --git a/src/cli/commands/identity.rs b/src/cli/commands/identity.rs index def26c9..37e3dc4 100644 --- a/src/cli/commands/identity.rs +++ b/src/cli/commands/identity.rs @@ -82,7 +82,7 @@ pub async fn reset() -> Result<()> { } // Clear existing - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("No home directory"))?; + let home = crate::home_dir()?; let _ = std::fs::remove_file(home.join(".tether").join("identity.age")); let _ = std::fs::remove_file(home.join(".tether").join("identity.pub")); let _ = std::fs::remove_file(home.join(".tether").join("identity.cache")); diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index 8a60da6..4f42d7c 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -102,6 +102,8 @@ pub async fn run(repo: Option<&str>, no_daemon: bool, team_only: bool) -> Result std::fs::create_dir_all(sync_path.join("dotfiles"))?; std::fs::create_dir_all(sync_path.join("machines"))?; + crate::sync::check_sync_format_version(&sync_path)?; + // Setup encryption if enabled if config.security.encrypt_dotfiles { setup_encryption()?; @@ -269,7 +271,14 @@ async fn setup_github_automatic() -> Result { if !GitHubCli::is_installed() { Output::warning("GitHub CLI (gh) is not installed"); - if Prompt::confirm("Install GitHub CLI via Homebrew?", true)? { + let install_method = if cfg!(target_os = "macos") { + "Homebrew" + } else if cfg!(target_os = "windows") { + "winget" + } else { + "package manager" + }; + if Prompt::confirm(&format!("Install GitHub CLI via {install_method}?"), true)? { let pb = Progress::spinner("Installing GitHub CLI..."); GitHubCli::install().await?; Progress::finish_success(&pb, "GitHub CLI installed"); diff --git a/src/cli/commands/machines.rs b/src/cli/commands/machines.rs index 9054fd1..03cbcd9 100644 --- a/src/cli/commands/machines.rs +++ b/src/cli/commands/machines.rs @@ -3,7 +3,7 @@ use crate::config::Config; use crate::sync::{GitBackend, MachineState, SyncEngine, SyncState}; use anyhow::Result; use chrono::Local; -use comfy_table::{presets::UTF8_FULL, Attribute, Cell, Color, ContentArrangement, Table}; +use comfy_table::{Attribute, Cell, Color}; use owo_colors::OwoColorize; pub async fn list() -> Result<()> { @@ -28,28 +28,34 @@ pub async fn list() -> Result<()> { println!("{}", "Synced Machines".bright_cyan().bold()); println!(); - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("Machine") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Hostname") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Last Sync") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("").add_attribute(Attribute::Bold).fg(Color::Cyan), - ]); + let mut table = Output::table_full(); + table.set_header(vec![ + Cell::new("Machine") + .add_attribute(Attribute::Bold) + .fg(Color::Cyan), + Cell::new("Hostname") + .add_attribute(Attribute::Bold) + .fg(Color::Cyan), + Cell::new("Version") + .add_attribute(Attribute::Bold) + .fg(Color::Cyan), + Cell::new("Last Sync") + .add_attribute(Attribute::Bold) + .fg(Color::Cyan), + Cell::new("").add_attribute(Attribute::Bold).fg(Color::Cyan), + ]); for machine in &machines { let is_current = &machine.machine_id == current_machine; let marker = if is_current { "(this machine)" } else { "" }; let local_time = machine.last_sync.with_timezone(&Local); + let version = if machine.cli_version.is_empty() { + "-".to_string() + } else { + machine.cli_version.clone() + }; + table.add_row(vec![ if is_current { Cell::new(&machine.machine_id).fg(Color::Green) @@ -57,6 +63,7 @@ pub async fn list() -> Result<()> { Cell::new(&machine.machine_id) }, Cell::new(&machine.hostname), + Cell::new(version), Cell::new(local_time.format("%Y-%m-%d %H:%M:%S").to_string()), Cell::new(marker).fg(Color::Green), ]); diff --git a/src/cli/commands/packages.rs b/src/cli/commands/packages.rs index a57d0cb..d428d08 100644 --- a/src/cli/commands/packages.rs +++ b/src/cli/commands/packages.rs @@ -4,7 +4,7 @@ use crate::cli::output::Output; use crate::cli::prompts::Prompt; use crate::packages::{ BrewManager, BunManager, GemManager, NpmManager, PackageInfo, PackageManager, PnpmManager, - UvManager, + UvManager, WingetManager, }; struct PackageEntry { @@ -27,6 +27,7 @@ pub async fn run(list_only: bool, yes: bool) -> Result<()> { Box::new(BunManager::new()), Box::new(GemManager::new()), Box::new(UvManager::new()), + Box::new(WingetManager::new()), ]; // Collect packages grouped by manager diff --git a/src/cli/commands/resolve.rs b/src/cli/commands/resolve.rs index 39b27cd..755ffc5 100644 --- a/src/cli/commands/resolve.rs +++ b/src/cli/commands/resolve.rs @@ -20,7 +20,7 @@ pub async fn run(file: Option<&str>) -> Result<()> { return Ok(()); } - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; let sync_path = SyncEngine::sync_path()?; let dotfiles_dir = sync_path.join("dotfiles"); @@ -75,7 +75,7 @@ pub async fn run(file: Option<&str>) -> Result<()> { let enc_file = dotfiles_dir.join(format!("{}.enc", filename)); if enc_file.exists() { let encrypted = std::fs::read(&enc_file)?; - crate::security::decrypt_file(&encrypted, key.as_ref().unwrap())? + crate::security::decrypt(&encrypted, key.as_ref().unwrap())? } else { Vec::new() } diff --git a/src/cli/commands/status.rs b/src/cli/commands/status.rs index c021b0e..b29a44e 100644 --- a/src/cli/commands/status.rs +++ b/src/cli/commands/status.rs @@ -1,11 +1,10 @@ +use crate::cli::output::relative_time; use crate::cli::Output; use crate::config::Config; -use crate::sync::{ConflictState, FileState, SyncState}; +use crate::daemon::pid::{is_process_running, read_daemon_pid}; +use crate::sync::{ConflictState, SyncState}; use anyhow::Result; -use chrono::Local; -use comfy_table::{Attribute, Cell, Color}; use owo_colors::OwoColorize; -use std::path::PathBuf; pub async fn run() -> Result<()> { let config = match Config::load() { @@ -26,7 +25,26 @@ pub async fn run() -> Result<()> { Output::section("Tether Status"); println!(); - // Features summary (one line) + // Machine + Output::key_value("Machine", &state.machine_id); + Output::key_value("Version", env!("CARGO_PKG_VERSION")); + + // Last Sync + let sync_time = relative_time(state.last_sync); + let sync_badge = Output::badge("synced", true); + Output::key_value("Last Sync", &format!("{} {}", sync_time, sync_badge)); + + // Daemon status + let pid = read_daemon_pid()?; + let (status_label, is_running) = match pid { + Some(pid) if is_process_running(pid) => (format!("Running (PID {pid})"), true), + Some(pid) => (format!("Not running (stale PID {pid})"), false), + None => ("Not running".to_string(), false), + }; + let daemon_badge = Output::badge(if is_running { "active" } else { "stopped" }, is_running); + Output::key_value("Daemon", &format!("{} {}", status_label, daemon_badge)); + + // Features summary let mut enabled_features = Vec::new(); if config.features.personal_dotfiles { enabled_features.push("dotfiles"); @@ -41,153 +59,62 @@ pub async fn run() -> Result<()> { enabled_features.push("collab"); } if !enabled_features.is_empty() { - Output::dim(&format!("Features: {}", enabled_features.join(", "))); - println!(); + Output::key_value("Features", &enabled_features.join(", ")); } - // Daemon status - let pid = read_daemon_pid()?; - let (status_label, is_running) = match pid { - Some(pid) if is_process_running(pid) => (format!("Running (PID {pid})"), true), - Some(pid) => (format!("Not running (stale PID {pid})"), false), - None => ("Not running".to_string(), false), - }; - - let mut daemon_table = Output::table_full(); - daemon_table - .set_header(vec![ - Cell::new("Daemon") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new(""), - ]) - .add_row(vec![ - Cell::new("Status"), - Cell::new(format!("{} {}", Output::DOT, status_label)).fg(if is_running { - Color::Green - } else { - Color::Yellow - }), - ]) - .add_row(vec![ - Cell::new("Log"), - Cell::new(daemon_log_path()?.display().to_string()), - ]); - println!("{daemon_table}"); - println!(); - // Conflicts warning let conflict_state = ConflictState::load().unwrap_or_default(); if !conflict_state.conflicts.is_empty() { - let mut conflict_table = Output::table_full(); - conflict_table.set_header(vec![ - Cell::new(format!("{} Conflicts", Output::WARN)) - .add_attribute(Attribute::Bold) - .fg(Color::Red), - Cell::new("Detected") - .add_attribute(Attribute::Bold) - .fg(Color::Red), - ]); - + println!(); + println!(" {}", format!("{} Conflicts", Output::WARN).red().bold()); + Output::divider(); for conflict in &conflict_state.conflicts { - let local_time = conflict.detected_at.with_timezone(&Local); - conflict_table.add_row(vec![ - Cell::new(&conflict.file_path).fg(Color::Yellow), - Cell::new(local_time.format("%Y-%m-%d %H:%M").to_string()), - ]); + let time = relative_time(conflict.detected_at); + println!( + " {:<18} {}", + conflict.file_path.yellow(), + time.bright_black() + ); } - println!("{conflict_table}"); println!( "{}", "Run 'tether resolve' to fix conflicts".yellow().bold() ); - println!(); } - // Sync info - let mut sync_table = Output::table_full(); - sync_table - .set_header(vec![ - Cell::new("Sync") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new(""), - ]) - .add_row(vec![ - Cell::new("Last Sync"), - Cell::new( - state - .last_sync - .with_timezone(&Local) - .format("%Y-%m-%d %H:%M") - .to_string(), - ) - .fg(Color::Green), - ]) - .add_row(vec![ - Cell::new("Last Upgrade"), - Cell::new( - state - .last_upgrade - .map(|t| t.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "Never".to_string()), - ), - ]) - .add_row(vec![Cell::new("Machine"), Cell::new(&state.machine_id)]) - .add_row(vec![Cell::new("Backend"), Cell::new(&config.backend.url)]); - println!("{sync_table}"); - println!(); - // Split files into dotfiles and project configs let (dotfiles, project_configs): (Vec<_>, Vec<_>) = state .files .iter() .partition(|(file, _)| !file.starts_with("project:")); - // Dotfiles - minimal table for lists (only show if feature enabled) + // Dotfiles if config.features.personal_dotfiles && !dotfiles.is_empty() { - let mut files_table = Output::table_minimal(); - files_table.set_header(vec![ - Cell::new("Dotfiles") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Status") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Modified") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - ]); - + println!(); + println!(" {}", "Dotfiles".bright_cyan().bold()); + Output::divider(); for (file, file_state) in &dotfiles { - let (status, color) = if file_state.synced { - (format!("{} Synced", Output::CHECK), Color::Green) + let (icon, status) = if file_state.synced { + (Output::CHECK.green().to_string(), "Synced".to_string()) } else { - (format!("{} Modified", Output::WARN), Color::Yellow) + (Output::WARN.yellow().to_string(), "Modified".to_string()) }; - - files_table.add_row(vec![ - Cell::new(file), - Cell::new(status).fg(color), - Cell::new( - file_state - .last_modified - .with_timezone(&Local) - .format("%Y-%m-%d %H:%M") - .to_string(), - ), - ]); + let time = relative_time(file_state.last_modified); + println!( + " {:<18} {} {:<10} {}", + file, + icon, + status, + time.bright_black() + ); } - println!("{files_table}"); - println!(); } else if config.features.personal_dotfiles { - Output::dim(" No dotfiles synced yet"); println!(); + Output::dim(" No dotfiles synced yet"); } - // Project configs - split by team vs personal + // Project configs if !project_configs.is_empty() { - // Build org -> team mapping let mut org_to_team: std::collections::HashMap = std::collections::HashMap::new(); if let Some(teams) = &config.teams { @@ -200,14 +127,14 @@ pub async fn run() -> Result<()> { } } - // Split projects by ownership - let mut team_projects: std::collections::HashMap> = - std::collections::HashMap::new(); - let mut personal_projects: Vec<(&String, &FileState)> = Vec::new(); + let mut team_projects: std::collections::HashMap< + String, + Vec<(&String, &crate::sync::FileState)>, + > = std::collections::HashMap::new(); + let mut personal_projects: Vec<(&String, &crate::sync::FileState)> = Vec::new(); for (file, file_state) in &project_configs { let display_name = file.strip_prefix("project:").unwrap_or(file); - // Extract org from path like "github.com/org/repo/..." if let Some(org) = crate::sync::extract_org_from_normalized_url(display_name) { if let Some(team_name) = org_to_team.get(&org.to_lowercase()) { team_projects @@ -222,149 +149,79 @@ pub async fn run() -> Result<()> { } } - // Show team projects first for (team_name, projects) in &team_projects { - let mut project_table = Output::table_minimal(); - project_table.set_header(vec![ - Cell::new(format!("Team: {} (project secrets)", team_name)) - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Status") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Modified") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - ]); - + println!(); + println!( + " {}", + format!("Team: {} (project secrets)", team_name) + .bright_cyan() + .bold() + ); + Output::divider(); for (file, file_state) in projects { - let (status, color) = if file_state.synced { - (format!("{} Synced", Output::CHECK), Color::Green) + let display_name = (*file).strip_prefix("project:").unwrap_or(file); + let (icon, status) = if file_state.synced { + (Output::CHECK.green().to_string(), "Synced".to_string()) } else { - (format!("{} Modified", Output::WARN), Color::Yellow) + (Output::WARN.yellow().to_string(), "Modified".to_string()) }; - let display_name = (*file).strip_prefix("project:").unwrap_or(file); - project_table.add_row(vec![ - Cell::new(display_name), - Cell::new(status).fg(color), - Cell::new( - file_state - .last_modified - .with_timezone(&Local) - .format("%Y-%m-%d %H:%M") - .to_string(), - ), - ]); + let time = relative_time(file_state.last_modified); + println!( + " {:<18} {} {:<10} {}", + display_name, + icon, + status, + time.bright_black() + ); } - println!("{project_table}"); - println!(); } - // Show personal projects if !personal_projects.is_empty() { - let mut project_table = Output::table_minimal(); - project_table.set_header(vec![ - Cell::new("Personal Project Configs") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Status") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Modified") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - ]); - + println!(); + println!(" {}", "Personal Project Configs".bright_cyan().bold()); + Output::divider(); for (file, file_state) in &personal_projects { - let (status, color) = if file_state.synced { - (format!("{} Synced", Output::CHECK), Color::Green) + let display_name = (*file).strip_prefix("project:").unwrap_or(file); + let (icon, status) = if file_state.synced { + (Output::CHECK.green().to_string(), "Synced".to_string()) } else { - (format!("{} Modified", Output::WARN), Color::Yellow) + (Output::WARN.yellow().to_string(), "Modified".to_string()) }; - let display_name = (*file).strip_prefix("project:").unwrap_or(file); - project_table.add_row(vec![ - Cell::new(display_name), - Cell::new(status).fg(color), - Cell::new( - file_state - .last_modified - .with_timezone(&Local) - .format("%Y-%m-%d %H:%M") - .to_string(), - ), - ]); + let time = relative_time(file_state.last_modified); + println!( + " {:<18} {} {:<10} {}", + display_name, + icon, + status, + time.bright_black() + ); } - println!("{project_table}"); - println!(); } } - // Packages - minimal table for lists (only show if feature enabled) + // Packages if config.features.personal_packages && !state.packages.is_empty() { - let mut packages_table = Output::table_minimal(); - packages_table.set_header(vec![ - Cell::new("Packages") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Modified") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Upgraded") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - ]); - + println!(); + println!(" {}", "Packages".bright_cyan().bold()); + Output::divider(); for (manager, pkg_state) in &state.packages { - packages_table.add_row(vec![ - Cell::new(format!("{} {}", Output::CHECK, manager)).fg(Color::Green), - Cell::new( - pkg_state - .last_modified - .map(|t| t.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "-".to_string()), - ), - Cell::new( - pkg_state - .last_upgrade - .map(|t| t.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "-".to_string()), - ), - ]); + let time = pkg_state + .last_modified + .map(relative_time) + .unwrap_or_else(|| "-".to_string()); + println!( + " {:<18} {} {:<10} {}", + manager, + Output::CHECK.green(), + "Synced", + time.bright_black() + ); } - println!("{packages_table}"); - println!(); } else if config.features.personal_packages { - Output::dim(" No packages synced yet"); println!(); + Output::dim(" No packages synced yet"); } + println!(); Ok(()) } - -fn daemon_log_path() -> Result { - Ok(Config::config_dir()?.join("daemon.log")) -} - -fn read_daemon_pid() -> Result> { - let pid_path = Config::config_dir()?.join("daemon.pid"); - if !pid_path.exists() { - return Ok(None); - } - - let contents = std::fs::read_to_string(&pid_path)?; - match contents.trim().parse::() { - Ok(pid) if pid > 0 => Ok(Some(pid)), - _ => Ok(None), - } -} - -fn is_process_running(pid: u32) -> bool { - unsafe { - if libc::kill(pid as libc::pid_t, 0) == 0 { - true - } else { - let err = std::io::Error::last_os_error(); - err.kind() != std::io::ErrorKind::NotFound - } - } -} diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index ce7a28a..c3d84a8 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -2,6 +2,7 @@ use crate::cli::{Output, Progress, Prompt}; use crate::config::Config; use crate::packages::{ BrewManager, BunManager, GemManager, NpmManager, PackageManager, PnpmManager, UvManager, + WingetManager, }; use crate::sync::git::{find_git_repos, get_remote_url, normalize_remote_url}; use crate::sync::{ @@ -38,6 +39,13 @@ pub async fn run(dry_run: bool, _force: bool) -> Result<()> { Output::info("Dry-run mode"); } + // Acquire sync lock (wait up to 2s for other syncs to finish) + let _sync_lock = if !dry_run { + Some(crate::sync::acquire_sync_lock(true)?) + } else { + None + }; + let config = Config::load()?; // No personal features: skip personal sync, only sync teams @@ -60,13 +68,14 @@ pub async fn run(dry_run: bool, _force: bool) -> Result<()> { crate::security::unlock_with_passphrase(&passphrase)?; } let sync_path = SyncEngine::sync_path()?; - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; // Pull latest changes from personal repo let git = GitBackend::open(&sync_path)?; if !dry_run { Output::info("Pulling latest changes..."); git.pull()?; + crate::sync::check_sync_format_version(&sync_path)?; } // Pull from team repo if enabled @@ -147,7 +156,7 @@ pub async fn run(dry_run: bool, _force: bool) -> Result<()> { if config.security.encrypt_dotfiles { let key = crate::security::get_encryption_key()?; - let encrypted = crate::security::encrypt_file(&content, &key)?; + let encrypted = crate::security::encrypt(&content, &key)?; let dest = dotfiles_dir.join(format!("{}.enc", filename)); if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent)?; @@ -370,7 +379,9 @@ fn sync_collab_secrets(config: &Config, home: &Path) -> Result<()> { // Pull latest if let Ok(git) = GitBackend::open(&collab_dir) { - git.pull().ok(); + if let Err(e) = git.pull() { + log::warn!("Failed to pull collab '{}': {}", collab_name, e); + } } // Walk projects directory @@ -555,7 +566,7 @@ fn decrypt_from_repo( if enc_file.exists() { let encrypted_content = std::fs::read(&enc_file)?; - match crate::security::decrypt_file(&encrypted_content, &key) { + match crate::security::decrypt(&encrypted_content, &key) { Ok(plaintext) => { let local_file = home.join(&file); @@ -692,7 +703,7 @@ fn decrypt_from_repo( } if let Ok(encrypted_content) = std::fs::read(file_path) { - match crate::security::decrypt_file(&encrypted_content, &key) { + match crate::security::decrypt(&encrypted_content, &key) { Ok(plaintext) => { let local_file = home.join(rel_path_no_enc); if let Some(parent) = local_file.parent() { @@ -735,8 +746,6 @@ fn decrypt_from_repo( /// Ensure checkout_file is a symlink pointing to canonical_path. /// Handles: missing, wrong symlink, real file (migrates to symlink). fn ensure_symlink(checkout_file: &Path, canonical_path: &Path) -> Result<()> { - use std::os::unix::fs::symlink; - if let Some(parent) = checkout_file.parent() { std::fs::create_dir_all(parent)?; } @@ -794,7 +803,7 @@ fn ensure_symlink(checkout_file: &Path, canonical_path: &Path) -> Result<()> { ); } - symlink(canonical_path, checkout_file)?; + crate::sync::create_symlink(canonical_path, checkout_file)?; Ok(()) } @@ -906,7 +915,7 @@ fn decrypt_project_configs( } if let Ok(encrypted_content) = std::fs::read(enc_file) { - match crate::security::decrypt_file(&encrypted_content, key) { + match crate::security::decrypt(&encrypted_content, key) { Ok(plaintext) => { let remote_hash = format!("{:x}", Sha256::digest(&plaintext)); let state_key = format!("project:{}/{}", project_name, rel_path_no_enc); @@ -1008,7 +1017,7 @@ fn sync_tether_config(sync_path: &Path, home: &Path) -> Result> { let key = crate::security::get_encryption_key()?; let encrypted_content = std::fs::read(&enc_file)?; - match crate::security::decrypt_file(&encrypted_content, &key) { + match crate::security::decrypt(&encrypted_content, &key) { Ok(plaintext) => { let local_config_path = home.join(".tether/config.toml"); let local_content = std::fs::read(&local_config_path).ok(); @@ -1074,14 +1083,14 @@ fn export_tether_config(sync_path: &Path, home: &Path, state: &mut SyncState) -> // Check if file on disk differs let file_hash = std::fs::read(&dest).ok().and_then(|enc| { let key = crate::security::get_encryption_key().ok()?; - crate::security::decrypt_file(&enc, &key) + crate::security::decrypt(&enc, &key) .ok() .map(|plain| format!("{:x}", Sha256::digest(&plain))) }); if file_hash.as_ref() != Some(&hash) { let key = crate::security::get_encryption_key()?; - let encrypted = crate::security::encrypt_file(&content, &key)?; + let encrypted = crate::security::encrypt(&content, &key)?; std::fs::write(&dest, encrypted)?; state.update_file(".tether/config.toml", hash); } @@ -1138,7 +1147,7 @@ fn sync_directories( if config.security.encrypt_dotfiles { let key = crate::security::get_encryption_key()?; - let encrypted = crate::security::encrypt_file(&content, &key)?; + let encrypted = crate::security::encrypt(&content, &key)?; std::fs::write(format!("{}.enc", dest.display()), encrypted)?; } else { std::fs::write(&dest, &content)?; @@ -1157,7 +1166,8 @@ fn sync_directories( if entry.file_type().is_file() { let file_path = entry.path(); let rel_to_home = file_path.strip_prefix(home).unwrap_or(file_path); - let state_key = format!("~/{}", rel_to_home.display()); + let state_key = + format!("~/{}", rel_to_home.to_string_lossy().replace('\\', "/")); if let Ok(content) = std::fs::read(file_path) { let hash = format!("{:x}", Sha256::digest(&content)); @@ -1176,7 +1186,7 @@ fn sync_directories( if config.security.encrypt_dotfiles { let key = crate::security::get_encryption_key()?; - let encrypted = crate::security::encrypt_file(&content, &key)?; + let encrypted = crate::security::encrypt(&content, &key)?; std::fs::write(format!("{}.enc", dest.display()), encrypted)?; } else { std::fs::write(&dest, &content)?; @@ -1201,7 +1211,8 @@ fn sync_project_configs( dry_run: bool, ) -> Result<()> { use crate::sync::git::{ - find_git_repos, get_remote_url, is_gitignored, normalize_remote_url, should_skip_dir, + find_git_repos, get_remote_url, is_gitignored, normalize_remote_url, + should_skip_dir_for_project_configs, }; use walkdir::WalkDir; @@ -1248,7 +1259,7 @@ fn sync_project_configs( e.file_type().is_file() || e.file_name() .to_str() - .map(|n| !should_skip_dir(n)) + .map(|n| !should_skip_dir_for_project_configs(n)) .unwrap_or(true) }); for entry in walker { @@ -1300,8 +1311,11 @@ fn sync_project_configs( let rel_to_repo = file_path .strip_prefix(&repo_path) .map_err(|e| anyhow::anyhow!("Failed to strip prefix: {}", e))?; - let state_key = - format!("project:{}/{}", normalized_url, rel_to_repo.display()); + let state_key = format!( + "project:{}/{}", + normalized_url, + rel_to_repo.to_string_lossy().replace('\\', "/") + ); let file_changed = state .files @@ -1318,7 +1332,7 @@ fn sync_project_configs( if config.security.encrypt_dotfiles { let key = crate::security::get_encryption_key()?; - let encrypted = crate::security::encrypt_file(&content, &key)?; + let encrypted = crate::security::encrypt(&content, &key)?; std::fs::write(format!("{}.enc", dest.display()), encrypted)?; } else { std::fs::write(&dest, &content)?; @@ -1345,8 +1359,9 @@ async fn build_machine_state( let mut machine_state = MachineState::load_from_repo(sync_path, &state.machine_id)? .unwrap_or_else(|| MachineState::new(&state.machine_id)); - // Update last_sync time + // Update last_sync time and CLI version machine_state.last_sync = chrono::Utc::now(); + machine_state.cli_version = env!("CARGO_PKG_VERSION").to_string(); // Collect file hashes machine_state.files.clear(); @@ -1384,65 +1399,33 @@ async fn build_machine_state( } } - // npm - if config.packages.npm.enabled { - let npm = NpmManager::new(); - if npm.is_available().await { - if let Ok(packages) = npm.list_installed().await { - machine_state.packages.insert( - "npm".to_string(), - packages.iter().map(|p| p.name.clone()).collect(), - ); - } - } - } - - // pnpm - if config.packages.pnpm.enabled { - let pnpm = PnpmManager::new(); - if pnpm.is_available().await { - if let Ok(packages) = pnpm.list_installed().await { - machine_state.packages.insert( - "pnpm".to_string(), - packages.iter().map(|p| p.name.clone()).collect(), - ); - } - } - } - - // bun - if config.packages.bun.enabled { - let bun = BunManager::new(); - if bun.is_available().await { - if let Ok(packages) = bun.list_installed().await { + // Standard managers (same pattern: check enabled, check available, list installed) + let managers: Vec<(bool, Box)> = vec![ + (config.packages.npm.enabled, Box::new(NpmManager::new())), + (config.packages.pnpm.enabled, Box::new(PnpmManager::new())), + (config.packages.bun.enabled, Box::new(BunManager::new())), + (config.packages.gem.enabled, Box::new(GemManager::new())), + (config.packages.uv.enabled, Box::new(UvManager::new())), + ]; + + for (enabled, manager) in managers { + if enabled && manager.is_available().await { + if let Ok(packages) = manager.list_installed().await { machine_state.packages.insert( - "bun".to_string(), + manager.name().to_string(), packages.iter().map(|p| p.name.clone()).collect(), ); } } } - // gem - if config.packages.gem.enabled { - let gem = GemManager::new(); - if gem.is_available().await { - if let Ok(packages) = gem.list_installed().await { + // winget + if config.packages.winget.enabled { + let winget = WingetManager::new(); + if winget.is_available().await { + if let Ok(packages) = winget.list_installed().await { machine_state.packages.insert( - "gem".to_string(), - packages.iter().map(|p| p.name.clone()).collect(), - ); - } - } - } - - // uv - if config.packages.uv.enabled { - let uv = UvManager::new(); - if uv.is_available().await { - if let Ok(packages) = uv.list_installed().await { - machine_state.packages.insert( - "uv".to_string(), + "winget".to_string(), packages.iter().map(|p| p.name.clone()).collect(), ); } @@ -1453,7 +1436,7 @@ async fn build_machine_state( detect_removed_packages(&mut machine_state, &previous_packages); // Populate dotfiles list from config (files that exist locally, with glob expansion) - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; machine_state.dotfiles.clear(); for entry in &config.dotfiles.files { if !entry.is_safe_path() { @@ -1470,15 +1453,19 @@ async fn build_machine_state( machine_state.dotfiles.sort(); // Populate project_configs from state (tracked project files) + // State keys are formatted as "project:host/org/repo/rel/path" + // The project key is the first 3 path components (host/org/repo) machine_state.project_configs.clear(); for key in state.files.keys() { if let Some(rest) = key.strip_prefix("project:") { - if let Some((project_key, rel_path)) = rest.split_once('/') { + let parts: Vec<&str> = rest.splitn(4, '/').collect(); + if parts.len() == 4 { + let project_key = format!("{}/{}/{}", parts[0], parts[1], parts[2]); machine_state .project_configs - .entry(project_key.to_string()) + .entry(project_key) .or_default() - .push(rel_path.to_string()); + .push(parts[3].to_string()); } } } @@ -1559,7 +1546,6 @@ fn detect_removed_packages( /// Sync project secrets from team repos to local projects pub fn sync_team_project_secrets(config: &Config, home: &Path) -> Result<()> { - use crate::sync::git::{find_git_repos, get_remote_url, normalize_remote_url}; use crate::sync::{backup_file, create_backup_dir}; use walkdir::WalkDir; @@ -1568,26 +1554,21 @@ pub fn sync_team_project_secrets(config: &Config, home: &Path) -> Result<()> { None => return Ok(()), }; - // Build map of local projects: normalized_url -> local_path - let mut local_projects: std::collections::HashMap = - std::collections::HashMap::new(); - - for search_path_str in &config.project_configs.search_paths { - let search_path = if let Some(stripped) = search_path_str.strip_prefix("~/") { - home.join(stripped) - } else { - PathBuf::from(search_path_str) - }; - - if let Ok(repos) = find_git_repos(&search_path) { - for repo_path in repos { - if let Ok(remote_url) = get_remote_url(&repo_path) { - let normalized = normalize_remote_url(&remote_url); - local_projects.insert(normalized, repo_path); - } + // Build map of local projects: normalized_url -> all local checkout paths + let search_paths: Vec = config + .project_configs + .search_paths + .iter() + .map(|p| { + if let Some(stripped) = p.strip_prefix("~/") { + home.join(stripped) + } else { + PathBuf::from(p) } - } - } + }) + .collect(); + + let local_projects = build_project_map(&search_paths); // Try to load user's identity for decryption let identity = match crate::security::load_identity(None) { @@ -1667,9 +1648,9 @@ pub fn sync_team_project_secrets(config: &Config, home: &Path) -> Result<()> { } // Check if we have this project locally - let local_project = match local_projects.get(&normalized_url) { - Some(p) => p, - None => continue, + let checkouts = match local_projects.get(&normalized_url) { + Some(c) if !c.is_empty() => c, + _ => continue, }; // Get relative file path (remove .age extension) @@ -1677,46 +1658,48 @@ pub fn sync_team_project_secrets(config: &Config, home: &Path) -> Result<()> { let rel_file_str = rel_file_path.to_string_lossy(); let rel_file_no_age = rel_file_str.trim_end_matches(".age"); - let local_file = local_project.join(rel_file_no_age); - - // Decrypt and write + // Decrypt and write to all checkouts match std::fs::read(file_path) { Ok(encrypted) => { match crate::security::decrypt_with_identity(&encrypted, &identity) { Ok(decrypted) => { - // Only write if different or doesn't exist - let should_write = if local_file.exists() { - std::fs::read(&local_file) - .map(|existing| existing != decrypted) - .unwrap_or(true) - } else { - true - }; - - if should_write { - // Backup before overwriting - if local_file.exists() { - if backup_dir.is_none() { - backup_dir = Some(create_backup_dir()?); + for local_project in checkouts { + let local_file = local_project.join(rel_file_no_age); + + // Only write if different or doesn't exist + let should_write = if local_file.exists() { + std::fs::read(&local_file) + .map(|existing| existing != decrypted) + .unwrap_or(true) + } else { + true + }; + + if should_write { + // Backup before overwriting + if local_file.exists() { + if backup_dir.is_none() { + backup_dir = Some(create_backup_dir()?); + } + let backup_path = + format!("{}/{}", normalized_url, rel_file_no_age); + backup_file( + backup_dir.as_ref().unwrap(), + "team-projects", + &backup_path, + &local_file, + )?; } - let backup_path = - format!("{}/{}", normalized_url, rel_file_no_age); - backup_file( - backup_dir.as_ref().unwrap(), - "team-projects", - &backup_path, - &local_file, - )?; - } - if let Some(parent) = local_file.parent() { - std::fs::create_dir_all(parent)?; + if let Some(parent) = local_file.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&local_file, &decrypted)?; + Output::success(&format!( + "Team secret: {} → {}", + rel_file_no_age, + local_project.file_name().unwrap().to_string_lossy() + )); } - std::fs::write(&local_file, &decrypted)?; - Output::success(&format!( - "Team secret: {} → {}", - rel_file_no_age, - local_project.file_name().unwrap().to_string_lossy() - )); } } Err(e) => { @@ -1752,7 +1735,7 @@ pub fn sync_team_project_secrets(config: &Config, home: &Path) -> Result<()> { /// Team-only sync: skip personal dotfiles/packages, only sync team repos async fn run_team_only_sync(config: &Config, dry_run: bool) -> Result<()> { - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; let teams = match &config.teams { Some(t) if !t.active.is_empty() => t, diff --git a/src/cli/commands/team.rs b/src/cli/commands/team.rs index 02d4be8..cf66911 100644 --- a/src/cli/commands/team.rs +++ b/src/cli/commands/team.rs @@ -887,7 +887,7 @@ async fn apply_layer_sync( detect_file_type, init_layers, sync_dotfile_with_layers, sync_team_to_layer, FileType, }; - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; let dotfiles_dir = team_repo_dir.join("dotfiles"); Output::info("Setting up team dotfile sync..."); @@ -1159,7 +1159,7 @@ fn remove_gitconfig_include( /// Clean up all injected source/include lines for a team fn cleanup_team_injections(team_name: &str) -> Result<()> { - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; let team_repo_dir = Config::team_repo_dir(team_name)?; // Shell files to check @@ -1504,7 +1504,7 @@ fn migrate_personal_to_team( // Decrypt personal secret let encrypted_content = std::fs::read(file_path)?; - let plaintext = crate::security::decrypt_file(&encrypted_content, &personal_key) + let plaintext = crate::security::decrypt(&encrypted_content, &personal_key) .map_err(|e| anyhow::anyhow!("Failed to decrypt {}: {}", file_path.display(), e))?; // Re-encrypt with team recipients @@ -2202,7 +2202,7 @@ pub async fn files_diff(file: Option<&str>) -> Result<()> { use similar::{ChangeTag, TextDiff}; let (team_name, _repo_dir) = get_active_team_repo()?; - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; let files_to_diff = if let Some(f) = file { vec![f.to_string()] diff --git a/src/cli/commands/upgrade.rs b/src/cli/commands/upgrade.rs index 61258f0..004a2a3 100644 --- a/src/cli/commands/upgrade.rs +++ b/src/cli/commands/upgrade.rs @@ -1,7 +1,7 @@ use crate::cli::output::Output; use crate::packages::{ brew::BrewManager, bun::BunManager, gem::GemManager, manager::PackageManager, npm::NpmManager, - pnpm::PnpmManager, uv::UvManager, + pnpm::PnpmManager, uv::UvManager, winget::WingetManager, }; use crate::sync::SyncState; use anyhow::Result; @@ -17,24 +17,35 @@ pub async fn run() -> Result<()> { Box::new(BunManager::new()), Box::new(GemManager::new()), Box::new(UvManager::new()), + Box::new(WingetManager::new()), ]; - let mut any_upgraded = false; - let mut any_actual_updates = false; - - for manager in &managers { + // Determine which managers are available and have packages + let mut available: Vec<(usize, usize)> = Vec::new(); + for (i, manager) in managers.iter().enumerate() { if !manager.is_available().await { continue; } - let packages = manager.list_installed().await?; if packages.is_empty() { continue; } + available.push((i, packages.len())); + } + + let total = available.len(); + let mut any_upgraded = false; + let mut any_actual_updates = false; + for (step_num, (i, pkg_count)) in available.iter().enumerate() { + let manager = &managers[*i]; let hash_before = manager.compute_manifest_hash().await.ok(); - println!(" {} ({} packages)...", manager.name(), packages.len()); + Output::step( + step_num + 1, + total, + &format!("{} ({} packages)", manager.name(), pkg_count), + ); manager.update_all().await?; any_upgraded = true; diff --git a/src/cli/output.rs b/src/cli/output.rs index 279bde9..b63097f 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -88,4 +88,70 @@ impl Output { .set_content_arrangement(ContentArrangement::Dynamic); table } + + pub fn key_value(key: &str, value: &str) { + let padded = format!("{:14}", key); + println!(" {} {}", padded.bright_white().bold(), value); + } + + pub fn key_value_colored(key: &str, value: &str, color_fn: impl Fn(&str) -> String) { + let padded = format!("{:14}", key); + println!(" {} {}", padded.bright_white().bold(), color_fn(value)); + } + + pub fn divider() { + println!( + " {}", + "────────────────────────────────────────────".bright_black() + ); + } + + pub fn badge(text: &str, good: bool) -> String { + let badge = format!("[{}]", text); + if good { + badge.green().to_string() + } else { + badge.red().to_string() + } + } + + pub fn diff_line(symbol: &str, text: &str, kind: &str) { + match kind { + "added" => println!(" {} {}", symbol.green(), text), + "removed" => println!(" {} {}", symbol.red(), text), + _ => println!(" {} {}", symbol.yellow(), text), + } + } +} + +pub fn relative_time(dt: chrono::DateTime) -> String { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(dt); + + let seconds = duration.num_seconds(); + if seconds < 60 { + return "just now".to_string(); + } + + let minutes = duration.num_minutes(); + if minutes < 60 { + return format!("{}m ago", minutes); + } + + let hours = duration.num_hours(); + if hours < 24 { + return format!("{}h ago", hours); + } + + let days = duration.num_days(); + if days < 2 { + return "yesterday".to_string(); + } + if days < 7 { + return format!("{}d ago", days); + } + + dt.with_timezone(&chrono::Local) + .format("%b %d %H:%M") + .to_string() } diff --git a/src/config.rs b/src/config.rs index 7e8d953..24a41ad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -120,6 +120,11 @@ pub enum BackendType { pub struct PackagesConfig { #[serde(default)] pub remove_unlisted: bool, + /// Sync packages across platforms using built-in name mappings (e.g., brew "git" → winget "Git.Git") + #[serde(default)] + pub cross_platform_sync: bool, + #[serde(default)] + pub mapping: Vec, #[serde(default = "default_brew_config")] pub brew: BrewConfig, #[serde(default = "default_npm_config")] @@ -132,6 +137,8 @@ pub struct PackagesConfig { pub gem: GemConfig, #[serde(default)] pub uv: UvConfig, + #[serde(default)] + pub winget: WingetConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -180,6 +187,20 @@ impl Default for UvConfig { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WingetConfig { + pub enabled: bool, +} + +#[allow(clippy::derivable_impls)] // cfg!(target_os) evaluates at compile time — not derivable cross-platform +impl Default for WingetConfig { + fn default() -> Self { + Self { + enabled: cfg!(target_os = "windows"), + } + } +} + fn default_brew_config() -> BrewConfig { BrewConfig { enabled: true, @@ -273,13 +294,20 @@ pub fn is_safe_dotfile_path(path: &str) -> bool { // Strip leading ~/ for validation (it's expanded to home dir) let path_to_check = path.strip_prefix("~/").unwrap_or(path); - // Reject absolute paths - if path_to_check.starts_with('/') { + // Reject absolute paths (Unix and Windows) + if path_to_check.starts_with('/') || path_to_check.starts_with('\\') { + return false; + } + // Reject Windows drive letters (e.g., C:\) + if path_to_check.len() >= 2 + && path_to_check.as_bytes()[0].is_ascii_alphabetic() + && path_to_check.as_bytes()[1] == b':' + { return false; } - // Reject paths with .. components - for component in path_to_check.split('/') { + // Reject paths with .. components (both / and \ separators) + for component in path_to_check.split(&['/', '\\']) { if component == ".." { return false; } @@ -319,6 +347,8 @@ pub struct MergeConfig { fn default_merge_command() -> String { if cfg!(target_os = "macos") { "opendiff".to_string() + } else if cfg!(target_os = "windows") { + "code".to_string() } else { "vimdiff".to_string() } @@ -332,6 +362,14 @@ fn default_merge_args() -> Vec { "-merge".to_string(), "{merged}".to_string(), ] + } else if cfg!(target_os = "windows") { + vec![ + "--merge".to_string(), + "{local}".to_string(), + "{remote}".to_string(), + "{merged}".to_string(), + "--wait".to_string(), + ] } else { vec![ "{local}".to_string(), @@ -379,10 +417,10 @@ const ALLOWED_MERGE_TOOLS: &[&str] = &[ impl MergeConfig { /// Validates the merge tool command is in the allowlist pub fn is_valid_command(&self) -> bool { - // Extract base command name (without path) + // Extract base command name (without path, handling both / and \) let cmd = self .command - .rsplit('/') + .rsplit(&['/', '\\']) .next() .unwrap_or(&self.command) .to_lowercase(); @@ -483,59 +521,18 @@ fn deserialize_active_teams<'de, D>(deserializer: D) -> Result, D::E where D: serde::Deserializer<'de>, { - use serde::de::{self, Visitor}; - use std::fmt; - - struct ActiveTeamsVisitor; - - impl<'de> Visitor<'de> for ActiveTeamsVisitor { - type Value = Vec; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a string or array of strings") - } - - fn visit_none(self) -> Result - where - E: de::Error, - { - Ok(Vec::new()) - } - - fn visit_some(self, deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - deserializer.deserialize_any(self) - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - Ok(vec![value.to_string()]) - } - - fn visit_string(self, value: String) -> Result - where - E: de::Error, - { - Ok(vec![value]) - } - - fn visit_seq(self, mut seq: A) -> Result - where - A: de::SeqAccess<'de>, - { - let mut teams = Vec::new(); - while let Some(team) = seq.next_element::()? { - teams.push(team); - } - Ok(teams) - } - } - - deserializer.deserialize_option(ActiveTeamsVisitor) + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrVec { + Single(String), + Multiple(Vec), + } + + Ok(match Option::::deserialize(deserializer)? { + Some(StringOrVec::Single(s)) => vec![s], + Some(StringOrVec::Multiple(v)) => v, + None => Vec::new(), + }) } /// Project-local config syncing. @@ -577,8 +574,7 @@ impl Default for ProjectConfigSettings { impl Config { pub fn config_dir() -> Result { - let home = - home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; Ok(home.join(".tether")) } @@ -685,11 +681,17 @@ impl Config { let mut config: Self = toml::from_str(&content)?; if config.config_version > CURRENT_CONFIG_VERSION { + let upgrade_hint = if cfg!(target_os = "macos") { + "brew upgrade tether" + } else { + "visit https://github.com/paddo-tech/tether-cli/releases" + }; bail!( "Config version {} is newer than this tether version supports (max: {}). \ - Please upgrade tether: brew upgrade tether", + Please upgrade tether: {}", config.config_version, - CURRENT_CONFIG_VERSION + CURRENT_CONFIG_VERSION, + upgrade_hint ); } @@ -728,6 +730,8 @@ impl Default for Config { }, packages: PackagesConfig { remove_unlisted: false, + cross_platform_sync: false, + mapping: Vec::new(), brew: BrewConfig { enabled: true, sync_casks: true, @@ -750,6 +754,7 @@ impl Default for Config { sync_versions: false, }, uv: UvConfig::default(), + winget: WingetConfig::default(), }, dotfiles: DotfilesConfig { files: vec![ @@ -831,6 +836,15 @@ mod tests { fn test_unsafe_absolute_path() { assert!(!is_safe_dotfile_path("/etc/passwd")); assert!(!is_safe_dotfile_path("/Users/foo/.zshrc")); + assert!(!is_safe_dotfile_path("C:\\Windows\\System32\\config")); + assert!(!is_safe_dotfile_path("D:\\Users\\foo\\.zshrc")); + assert!(!is_safe_dotfile_path("\\\\server\\share")); + } + + #[test] + fn test_unsafe_backslash_traversal() { + assert!(!is_safe_dotfile_path("..\\..\\Windows\\System32")); + assert!(!is_safe_dotfile_path("foo\\..\\..\\etc\\passwd")); } #[test] @@ -872,6 +886,12 @@ mod tests { args: vec![], }; assert!(config.is_valid_command()); + + let config = MergeConfig { + command: "C:\\Program Files\\Microsoft VS Code\\bin\\code".to_string(), + args: vec![], + }; + assert!(config.is_valid_command()); } #[test] diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index a4eb041..4d522e2 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -1,3 +1,4 @@ +pub mod pid; pub mod server; pub use server::{is_daemon_mode, DaemonServer}; diff --git a/src/daemon/pid.rs b/src/daemon/pid.rs new file mode 100644 index 0000000..2b8021a --- /dev/null +++ b/src/daemon/pid.rs @@ -0,0 +1,41 @@ +use crate::config::Config; +use anyhow::Result; + +pub fn read_daemon_pid() -> Result> { + let pid_path = Config::config_dir()?.join("daemon.pid"); + if !pid_path.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(&pid_path)?; + match contents.trim().parse::() { + Ok(pid) if pid > 0 => Ok(Some(pid)), + _ => Ok(None), + } +} + +#[cfg(unix)] +pub fn is_process_running(pid: u32) -> bool { + unsafe { + if libc::kill(pid as libc::pid_t, 0) == 0 { + true + } else { + let err = std::io::Error::last_os_error(); + err.kind() != std::io::ErrorKind::NotFound + } + } +} + +#[cfg(windows)] +pub fn is_process_running(pid: u32) -> bool { + use std::process::Command; + Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) + .output() + .map(|o| { + let out = String::from_utf8_lossy(&o.stdout); + // CSV format quotes the PID: "name","1234",... — exact match avoids substring false positives + out.contains(&format!("\"{pid}\"")) + }) + .unwrap_or(false) +} diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 0dc9d6c..07dfd7e 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -1,13 +1,14 @@ -use crate::config::{is_safe_dotfile_path, Config}; +use crate::config::Config; use crate::packages::{ - BrewManager, BunManager, GemManager, NpmManager, PackageManager, PnpmManager, + BrewManager, BunManager, GemManager, NpmManager, PackageManager, PnpmManager, UvManager, + WingetManager, }; use crate::sync::{ detect_conflict, import_packages, notify_conflicts, notify_deferred_casks, ConflictState, GitBackend, MachineState, SyncEngine, SyncState, }; use anyhow::Result; -use chrono::Local; +use chrono::{Local, Utc}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -20,6 +21,7 @@ use std::os::unix::fs::OpenOptionsExt; use tokio::signal::unix::{signal, SignalKind}; const DEFAULT_SYNC_INTERVAL_SECS: u64 = 300; // 5 minutes +const MAX_LOG_BYTES: u64 = 5_000_000; // 5 MB /// Thread-safe flag indicating daemon mode (avoids unsafe std::env::set_var in async) static DAEMON_MODE: AtomicBool = AtomicBool::new(false); @@ -29,6 +31,11 @@ pub fn is_daemon_mode() -> bool { DAEMON_MODE.load(Ordering::Relaxed) } +enum TickResult { + Continue, + Exit, +} + pub struct DaemonServer { sync_interval: Duration, last_update_date: Option, @@ -79,38 +86,14 @@ impl DaemonServer { let mut sync_timer = self.sync_interval(); let mut sigterm = signal(SignalKind::terminate())?; let mut sighup = signal(SignalKind::hangup())?; - let ctrl_c = tokio::signal::ctrl_c(); tokio::pin!(ctrl_c); - - // Skip first tick (immediate) sync_timer.tick().await; loop { tokio::select! { _ = sync_timer.tick() => { - // Check for binary update before doing work - if self.binary_updated() { - log::info!("Binary updated, exiting for restart"); - break; - } - - log::info!("Running periodic sync..."); - if let Err(e) = self.run_sync().await { - log::error!("Sync failed: {}", e); - } - // Check if we should run daily package updates - if self.should_run_update() { - log::info!("Running daily package update..."); - if let Err(e) = self.run_package_updates().await { - log::error!("Package update failed: {}", e); - } - // Re-check after upgrades (tether itself may have been updated) - if self.binary_updated() { - log::info!("Binary updated during package upgrade, exiting for restart"); - break; - } - } + if let TickResult::Exit = self.run_tick().await { break; } }, _ = &mut ctrl_c => { log::info!("Received Ctrl+C, stopping daemon"); @@ -135,35 +118,12 @@ impl DaemonServer { let mut sync_timer = self.sync_interval(); let ctrl_c = tokio::signal::ctrl_c(); tokio::pin!(ctrl_c); - - // Skip first tick (immediate) sync_timer.tick().await; loop { tokio::select! { _ = sync_timer.tick() => { - // Check for binary update before doing work - if self.binary_updated() { - log::info!("Binary updated, exiting for restart"); - break; - } - - log::info!("Running periodic sync..."); - if let Err(e) = self.run_sync().await { - log::error!("Sync failed: {}", e); - } - // Check if we should run daily package updates - if self.should_run_update() { - log::info!("Running daily package update..."); - if let Err(e) = self.run_package_updates().await { - log::error!("Package update failed: {}", e); - } - // Re-check after upgrades (tether itself may have been updated) - if self.binary_updated() { - log::info!("Binary updated during package upgrade, exiting for restart"); - break; - } - } + if let TickResult::Exit = self.run_tick().await { break; } }, _ = &mut ctrl_c => { log::info!("Received Ctrl+C, stopping daemon"); @@ -177,7 +137,60 @@ impl DaemonServer { Ok(()) } + /// Rotate daemon.log if it exceeds MAX_LOG_BYTES. + /// Copies to .log.1 and truncates in-place to keep the logger's fd valid. + fn rotate_log_if_needed(&self) { + let log_path = match crate::config::Config::config_dir() { + Ok(d) => d.join("daemon.log"), + Err(_) => return, + }; + if let Ok(meta) = std::fs::metadata(&log_path) { + if meta.len() > MAX_LOG_BYTES { + let backup = log_path.with_extension("log.1"); + let _ = std::fs::copy(&log_path, &backup); + let _ = std::fs::File::create(&log_path); // truncate in-place + log::info!("Rotated daemon.log ({} bytes)", meta.len()); + } + } + } + + /// Shared tick logic: sync + conditional package updates + binary update checks + async fn run_tick(&mut self) -> TickResult { + self.rotate_log_if_needed(); + + if self.binary_updated() { + log::info!("Binary updated, exiting for restart"); + return TickResult::Exit; + } + + log::info!("Running periodic sync..."); + if let Err(e) = self.run_sync().await { + log::error!("Sync failed: {}", e); + } + + if self.should_run_update() { + log::info!("Running daily package update..."); + if let Err(e) = self.run_package_updates().await { + log::error!("Package update failed: {}", e); + } + if self.binary_updated() { + log::info!("Binary updated during package upgrade, exiting for restart"); + return TickResult::Exit; + } + } + + TickResult::Continue + } + async fn run_sync(&self) -> Result<()> { + let _sync_lock = match crate::sync::acquire_sync_lock(false) { + Ok(lock) => lock, + Err(_) => { + log::info!("Sync already in progress, skipping this tick"); + return Ok(()); + } + }; + let config = Config::load()?; // No personal features: only sync team repos @@ -186,14 +199,15 @@ impl DaemonServer { } let sync_path = SyncEngine::sync_path()?; - let home = - home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; // Pull latest changes log::debug!("Pulling latest changes..."); let git = GitBackend::open(&sync_path)?; git.pull()?; + crate::sync::check_sync_format_version(&sync_path)?; + // Pull from team repo if enabled if let Some(team) = &config.team { if team.enabled { @@ -211,55 +225,112 @@ impl DaemonServer { // Load state and conflict tracking let mut state = SyncState::load()?; - let mut conflict_state = ConflictState::load().unwrap_or_default(); + let mut conflict_state = match ConflictState::load() { + Ok(state) => state, + Err(e) => { + log::warn!("Failed to load conflict state: {}", e); + ConflictState::default() + } + }; let mut new_conflicts = Vec::new(); + // Load machine state for ignored_dotfiles filtering + let machine_state_for_decrypt = + MachineState::load_from_repo(&sync_path, &state.machine_id)?.unwrap_or_default(); + + // Lazy backup dir for overwrite protection + let mut backup_dir: Option = None; + // Apply remote changes first (with conflict detection) // Only sync dotfiles if feature enabled if config.features.personal_dotfiles && config.security.encrypt_dotfiles { let key = crate::security::get_encryption_key()?; for entry in &config.dotfiles.files { - let file = entry.path(); - // Security: validate path to prevent traversal attacks - if !is_safe_dotfile_path(file) { - log::warn!("Skipping unsafe dotfile path: {}", file); + if !entry.is_safe_path() { + log::warn!("Skipping unsafe dotfile path: {}", entry.path()); continue; } - let filename = file.trim_start_matches('.'); - let enc_file = dotfiles_dir.join(format!("{}.enc", filename)); + let pattern = entry.path(); + let create_if_missing = + entry.create_if_missing() || crate::sync::is_glob_pattern(pattern); - if enc_file.exists() { - if let Ok(encrypted_content) = std::fs::read(&enc_file) { - if let Ok(plaintext) = - crate::security::decrypt_file(&encrypted_content, &key) - { - let local_file = home.join(file); + // Expand glob patterns by scanning sync repo for matching .enc files + let expanded = crate::sync::expand_from_sync_repo(pattern, &dotfiles_dir); - // Skip if file doesn't exist and create_if_missing is false - if !local_file.exists() && !entry.create_if_missing() { - continue; - } + for file in expanded { + // Skip files ignored on this machine + if machine_state_for_decrypt + .ignored_dotfiles + .iter() + .any(|f| f == &file) + { + continue; + } - let last_synced_hash = state.files.get(file).map(|f| f.hash.as_str()); + let filename = file.trim_start_matches('.'); + let enc_file = dotfiles_dir.join(format!("{}.enc", filename)); - if let Some(conflict) = - detect_conflict(file, &local_file, &plaintext, last_synced_hash) + if enc_file.exists() { + if let Ok(encrypted_content) = std::fs::read(&enc_file) { + if let Ok(plaintext) = + crate::security::decrypt(&encrypted_content, &key) { - log::warn!("Conflict detected in {}", file); - new_conflicts.push(( - file.to_string(), - conflict.local_hash, - conflict.remote_hash, - )); - } else { - // No conflict, safe to apply remote (create parent dirs if needed) - if let Some(parent) = local_file.parent() { - std::fs::create_dir_all(parent)?; + let local_file = home.join(&file); + + // Skip if file doesn't exist and create_if_missing is false + if !local_file.exists() && !create_if_missing { + continue; + } + + let last_synced_hash = + state.files.get(&file).map(|f| f.hash.as_str()); + + if let Some(conflict) = detect_conflict( + &file, + &local_file, + &plaintext, + last_synced_hash, + ) { + log::warn!("Conflict detected in {}", file); + new_conflicts.push(( + file.to_string(), + conflict.local_hash, + conflict.remote_hash, + )); + } else { + // No true conflict - preserve local-only changes + let remote_hash = format!("{:x}", Sha256::digest(&plaintext)); + let local_hash = std::fs::read(&local_file) + .ok() + .map(|c| format!("{:x}", Sha256::digest(&c))); + let local_unchanged = local_hash.as_deref() == last_synced_hash; + if local_unchanged && local_hash.as_ref() != Some(&remote_hash) + { + // Backup before overwriting + if local_file.exists() { + if backup_dir.is_none() { + backup_dir = + Some(crate::sync::create_backup_dir()?); + } + crate::sync::backup_file( + backup_dir.as_ref().unwrap(), + "dotfiles", + &file, + &local_file, + )?; + } + if let Some(parent) = local_file.parent() { + std::fs::create_dir_all(parent)?; + } + write_file_secure(&local_file, &plaintext)?; + log::debug!("Applied remote changes to {}", file); + } else if !local_unchanged { + log::debug!("Preserving local changes to {}", file); + } + conflict_state.remove_conflict(&file); } - write_file_secure(&local_file, &plaintext)?; - log::debug!("Applied remote changes to {}", file); } } } @@ -268,11 +339,11 @@ impl DaemonServer { } // Save conflicts and notify + for (file, local_hash, remote_hash) in &new_conflicts { + conflict_state.add_conflict(file, local_hash, remote_hash); + } + conflict_state.save()?; if !new_conflicts.is_empty() { - for (file, local_hash, remote_hash) in &new_conflicts { - conflict_state.add_conflict(file, local_hash, remote_hash); - } - conflict_state.save()?; notify_conflicts(new_conflicts.len()).ok(); log::info!( "{} conflicts detected, user notification sent", @@ -286,51 +357,54 @@ impl DaemonServer { // Sync dotfiles to remote (only if feature enabled) if config.features.personal_dotfiles { for entry in &config.dotfiles.files { - let file = entry.path(); - // Security: validate path to prevent traversal attacks - if !is_safe_dotfile_path(file) { - log::warn!("Skipping unsafe dotfile path: {}", file); + if !entry.is_safe_path() { + log::warn!("Skipping unsafe dotfile path: {}", entry.path()); continue; } - // Skip files with conflicts - if conflict_state.conflicts.iter().any(|c| c.file_path == file) { - continue; - } + let pattern = entry.path(); + let expanded = crate::sync::expand_dotfile_glob(pattern, &home); - let source = home.join(file); - if source.exists() { - if let Ok(content) = std::fs::read(&source) { - let hash = format!("{:x}", Sha256::digest(&content)); - let file_changed = state - .files - .get(file) - .map(|f| f.hash != hash) - .unwrap_or(true); - - if file_changed { - log::info!("File changed: {}", file); - let filename = file.trim_start_matches('.'); - - if config.security.encrypt_dotfiles { - let key = crate::security::get_encryption_key()?; - let encrypted = crate::security::encrypt_file(&content, &key)?; - let dest = dotfiles_dir.join(format!("{}.enc", filename)); - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&dest, encrypted)?; - } else { - let dest = dotfiles_dir.join(filename); - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; + for file in expanded { + // Skip files with conflicts (by expanded name) + if conflict_state.conflicts.iter().any(|c| c.file_path == file) { + continue; + } + + let source = home.join(&file); + if source.exists() { + if let Ok(content) = std::fs::read(&source) { + let hash = format!("{:x}", Sha256::digest(&content)); + let file_changed = state + .files + .get(&file) + .map(|f| f.hash != hash) + .unwrap_or(true); + + if file_changed { + log::info!("File changed: {}", file); + let filename = file.trim_start_matches('.'); + + if config.security.encrypt_dotfiles { + let key = crate::security::get_encryption_key()?; + let encrypted = crate::security::encrypt(&content, &key)?; + let dest = dotfiles_dir.join(format!("{}.enc", filename)); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&dest, encrypted)?; + } else { + let dest = dotfiles_dir.join(filename); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&dest, &content)?; } - std::fs::write(&dest, &content)?; - } - state.update_file(file, hash.clone()); - changes_made = true; + state.update_file(&file, hash.clone()); + changes_made = true; + } } } } @@ -390,8 +464,16 @@ impl DaemonServer { changes_made |= self.sync_packages(&config, &mut state, &sync_path).await?; } - // Commit and push if changes made - if changes_made { + // Update machine state with current CLI version + let mut machine_state = MachineState::load_from_repo(&sync_path, &state.machine_id)? + .unwrap_or_else(|| MachineState::new(&state.machine_id)); + machine_state.cli_version = env!("CARGO_PKG_VERSION").to_string(); + machine_state.last_sync = chrono::Utc::now(); + machine_state.save_to_repo(&sync_path)?; + + // Commit and push if changes made (including machine state updates) + let has_changes = git.has_changes()?; + if changes_made || has_changes { log::info!("Committing changes..."); git.commit("Auto-sync from daemon", &state.machine_id)?; git.push()?; @@ -405,7 +487,8 @@ impl DaemonServer { Ok(()) } - /// Team-only sync: only sync team repositories + /// Team-only sync: only sync team repositories. + /// Note: caller (run_sync) already holds the sync lock. async fn run_team_only_sync(&self, config: &Config) -> Result<()> { let teams = match &config.teams { Some(t) if !t.active.is_empty() => t, @@ -441,9 +524,10 @@ impl DaemonServer { } // Sync team project secrets - let home = - home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; - crate::cli::commands::sync::sync_team_project_secrets(config, &home).ok(); + let home = crate::home_dir()?; + if let Err(e) = crate::cli::commands::sync::sync_team_project_secrets(config, &home) { + log::warn!("Failed to sync team project secrets: {}", e); + } log::info!("Team-only sync complete"); Ok(()) @@ -473,7 +557,6 @@ impl DaemonServer { .unwrap_or(true) { std::fs::write(manifests_dir.join("Brewfile"), &manifest)?; - use chrono::Utc; let now = Utc::now(); let existing = state.packages.get("brew"); state.packages.insert( @@ -492,46 +575,67 @@ impl DaemonServer { } } - // npm - if config.packages.npm.enabled { - changes_made |= self - .sync_package_manager(&NpmManager::new(), "npm", "npm.txt", state, &manifests_dir) - .await?; + let managers: Vec<(Box, &str, bool)> = vec![ + ( + Box::new(NpmManager::new()), + "npm.txt", + config.packages.npm.enabled, + ), + ( + Box::new(PnpmManager::new()), + "pnpm.txt", + config.packages.pnpm.enabled, + ), + ( + Box::new(BunManager::new()), + "bun.txt", + config.packages.bun.enabled, + ), + ( + Box::new(GemManager::new()), + "gems.txt", + config.packages.gem.enabled, + ), + ( + Box::new(UvManager::new()), + "uv.txt", + config.packages.uv.enabled, + ), + ]; + + for (manager, filename, enabled) in &managers { + if *enabled { + changes_made |= self + .sync_package_manager( + manager.as_ref(), + manager.name(), + filename, + state, + &manifests_dir, + ) + .await?; + } } - // pnpm - if config.packages.pnpm.enabled { + // winget + if config.packages.winget.enabled { changes_made |= self .sync_package_manager( - &PnpmManager::new(), - "pnpm", - "pnpm.txt", + &WingetManager::new(), + "winget", + "winget.txt", state, &manifests_dir, ) .await?; } - // bun - if config.packages.bun.enabled { - changes_made |= self - .sync_package_manager(&BunManager::new(), "bun", "bun.txt", state, &manifests_dir) - .await?; - } - - // gem - if config.packages.gem.enabled { - changes_made |= self - .sync_package_manager(&GemManager::new(), "gem", "gems.txt", state, &manifests_dir) - .await?; - } - Ok(changes_made) } - async fn sync_package_manager( + async fn sync_package_manager( &self, - manager: &P, + manager: &dyn PackageManager, name: &str, filename: &str, state: &mut SyncState, @@ -550,7 +654,6 @@ impl DaemonServer { .unwrap_or(true) { std::fs::write(manifests_dir.join(filename), &manifest)?; - use chrono::Utc; let now = Utc::now(); let existing = state.packages.get(name); state.packages.insert( @@ -603,82 +706,31 @@ impl DaemonServer { let config = Config::load()?; let mut any_actual_updates = false; - if config.packages.brew.enabled { - let brew = BrewManager::new(); - if brew.is_available().await { - log::info!("Updating Homebrew packages..."); - let hash_before = brew.compute_manifest_hash().await.ok(); - if let Err(e) = brew.update_all().await { - log::error!("Homebrew update failed: {}", e); - } else { - let hash_after = brew.compute_manifest_hash().await.ok(); - if hash_before != hash_after { - any_actual_updates = true; - } - } - } - } - - if config.packages.npm.enabled { - let npm = NpmManager::new(); - if npm.is_available().await { - log::info!("Updating npm packages..."); - let hash_before = npm.compute_manifest_hash().await.ok(); - if let Err(e) = npm.update_all().await { - log::error!("npm update failed: {}", e); - } else { - let hash_after = npm.compute_manifest_hash().await.ok(); - if hash_before != hash_after { - any_actual_updates = true; - } - } - } - } - - if config.packages.pnpm.enabled { - let pnpm = PnpmManager::new(); - if pnpm.is_available().await { - log::info!("Updating pnpm packages..."); - let hash_before = pnpm.compute_manifest_hash().await.ok(); - if let Err(e) = pnpm.update_all().await { - log::error!("pnpm update failed: {}", e); - } else { - let hash_after = pnpm.compute_manifest_hash().await.ok(); - if hash_before != hash_after { - any_actual_updates = true; - } - } - } - } - - if config.packages.bun.enabled { - let bun = BunManager::new(); - if bun.is_available().await { - log::info!("Updating bun packages..."); - let hash_before = bun.compute_manifest_hash().await.ok(); - if let Err(e) = bun.update_all().await { - log::error!("bun update failed: {}", e); - } else { - let hash_after = bun.compute_manifest_hash().await.ok(); - if hash_before != hash_after { - any_actual_updates = true; - } - } + let managers: Vec<(Box, bool)> = vec![ + (Box::new(BrewManager::new()), config.packages.brew.enabled), + (Box::new(NpmManager::new()), config.packages.npm.enabled), + (Box::new(PnpmManager::new()), config.packages.pnpm.enabled), + (Box::new(BunManager::new()), config.packages.bun.enabled), + (Box::new(GemManager::new()), config.packages.gem.enabled), + (Box::new(UvManager::new()), config.packages.uv.enabled), + ( + Box::new(WingetManager::new()), + config.packages.winget.enabled, + ), + ]; + + for (manager, enabled) in &managers { + if !enabled || !manager.is_available().await { + continue; } - } - - if config.packages.gem.enabled { - let gem = GemManager::new(); - if gem.is_available().await { - log::info!("Updating Ruby gems..."); - let hash_before = gem.compute_manifest_hash().await.ok(); - if let Err(e) = gem.update_all().await { - log::error!("gem update failed: {}", e); - } else { - let hash_after = gem.compute_manifest_hash().await.ok(); - if hash_before != hash_after { - any_actual_updates = true; - } + log::info!("Updating {} packages...", manager.name()); + let hash_before = manager.compute_manifest_hash().await.ok(); + if let Err(e) = manager.update_all().await { + log::error!("{} update failed: {}", manager.name(), e); + } else { + let hash_after = manager.compute_manifest_hash().await.ok(); + if hash_before != hash_after { + any_actual_updates = true; } } } @@ -722,6 +774,8 @@ fn write_file_secure(path: &Path, contents: &[u8]) -> Result<()> { #[cfg(not(unix))] { std::fs::write(path, contents)?; + #[cfg(windows)] + crate::security::restrict_file_permissions(path)?; Ok(()) } } diff --git a/src/github.rs b/src/github.rs index eee05d0..df4d5d1 100644 --- a/src/github.rs +++ b/src/github.rs @@ -10,13 +10,23 @@ impl GitHubCli { which::which("gh").is_ok() } - /// Install gh CLI via Homebrew + /// Install gh CLI via platform package manager pub async fn install() -> Result<()> { - let output = Command::new("brew") - .args(["install", "gh"]) + let (cmd, args): (&str, &[&str]) = if cfg!(target_os = "macos") { + ("brew", &["install", "gh"]) + } else if cfg!(target_os = "windows") { + ("winget", &["install", "--id", "GitHub.cli", "-e"]) + } else { + return Err(anyhow::anyhow!( + "Automatic install not supported on this platform. Install gh manually: https://cli.github.com" + )); + }; + + let output = Command::new(cmd) + .args(args) .output() .await - .context("Failed to run brew install gh")?; + .context(format!("Failed to run {cmd}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src/lib.rs b/src/lib.rs index ea7cf1f..ae30091 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,3 +7,7 @@ pub mod security; pub mod sync; pub use config::Config; + +pub fn home_dir() -> anyhow::Result { + home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory")) +} diff --git a/src/packages/brew.rs b/src/packages/brew.rs index 4ae0822..cc2eb36 100644 --- a/src/packages/brew.rs +++ b/src/packages/brew.rs @@ -86,7 +86,7 @@ impl BrewManager { let requested_version = &formula[at_pos + 1..]; // Check what versions of this formula are installed - let output = Command::new("brew") + let output = Command::new(super::resolve_program("brew")) .args(["list", "--versions"]) .output() .await?; @@ -106,7 +106,7 @@ impl BrewManager { if installed_base == base_name && installed_version != requested_version { - let _ = Command::new("brew") + let _ = Command::new(super::resolve_program("brew")) .args(["unlink", installed_name]) .output() .await; @@ -122,7 +122,10 @@ impl BrewManager { } async fn run_brew(&self, args: &[&str]) -> Result { - let output = Command::new("brew").args(args).output().await?; + let output = Command::new(super::resolve_program("brew")) + .args(args) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -134,8 +137,7 @@ impl BrewManager { /// Get a temporary file path for Brewfile operations fn temp_brewfile_path() -> Result { - let home = - home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; Ok(home.join(".tether").join("Brewfile.tmp")) } @@ -170,7 +172,7 @@ impl BrewManager { pub async fn install_cask(&self, cask: &str, allow_interactive: bool) -> Result { use std::process::Stdio; - let mut cmd = Command::new("brew"); + let mut cmd = Command::new(super::resolve_program("brew")); cmd.args(["install", "--cask", cask]) .env("NONINTERACTIVE", "1") .env("HOMEBREW_NO_AUTO_UPDATE", "1"); @@ -269,7 +271,7 @@ impl PackageManager for BrewManager { } // Generate Brewfile - let output = Command::new("brew") + let output = Command::new(super::resolve_program("brew")) .args([ "bundle", "dump", @@ -314,7 +316,7 @@ impl PackageManager for BrewManager { // Use `brew bundle install` to install packages from Brewfile // --no-upgrade: don't upgrade existing packages (faster, less disruptive) // Stream output to terminal so user can see progress and any errors - let status = Command::new("brew") + let status = Command::new(super::resolve_program("brew")) .args([ "bundle", "install", @@ -368,7 +370,7 @@ impl PackageManager for BrewManager { // Remove packages not in manifest for pkg in installed { if !desired.contains(pkg.name.as_str()) { - let output = Command::new("brew") + let output = Command::new(super::resolve_program("brew")) .args(["uninstall", &pkg.name]) .output() .await?; @@ -391,9 +393,15 @@ impl PackageManager for BrewManager { } // Update Homebrew itself and upgrade all packages - Command::new("brew").args(["update"]).output().await?; + Command::new(super::resolve_program("brew")) + .args(["update"]) + .output() + .await?; - let output = Command::new("brew").args(["upgrade"]).output().await?; + let output = Command::new(super::resolve_program("brew")) + .args(["upgrade"]) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -404,7 +412,7 @@ impl PackageManager for BrewManager { } async fn uninstall(&self, package: &str) -> Result<()> { - let output = Command::new("brew") + let output = Command::new(super::resolve_program("brew")) .args(["uninstall", package]) .output() .await?; @@ -418,7 +426,7 @@ impl PackageManager for BrewManager { } async fn get_dependents(&self, package: &str) -> Result> { - let output = Command::new("brew") + let output = Command::new(super::resolve_program("brew")) .args(["uses", "--installed", package]) .output() .await?; diff --git a/src/packages/bun.rs b/src/packages/bun.rs index acbce98..59ca1af 100644 --- a/src/packages/bun.rs +++ b/src/packages/bun.rs @@ -22,7 +22,10 @@ impl BunManager { } async fn run_bun(&self, args: &[&str]) -> Result { - let output = Command::new("bun").args(args).output().await?; + let output = Command::new(super::resolve_program("bun")) + .args(args) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -117,87 +120,6 @@ impl PackageManager for BunManager { "bun" } - async fn export_manifest(&self) -> Result { - // Get list of installed packages - let packages = self.list_installed().await?; - - // Create simple newline-delimited list of package names - let manifest = packages - .iter() - .map(|p| p.name.as_str()) - .collect::>() - .join("\n"); - - Ok(manifest) - } - - async fn import_manifest(&self, manifest_content: &str) -> Result<()> { - // Parse package names from manifest - let package_names: Vec<&str> = manifest_content - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect(); - - if package_names.is_empty() { - return Ok(()); // Nothing to install - } - - // Get currently installed packages - let installed = self.list_installed().await?; - let installed_names: std::collections::HashSet<_> = - installed.iter().map(|p| p.name.as_str()).collect(); - - // Install missing packages - for name in package_names { - if !installed_names.contains(name) { - // Install the package - let output = Command::new("bun") - .args(["add", "-g", name]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // Log warning but continue with other packages - eprintln!("Warning: Failed to install {}: {}", name, stderr); - } - } - } - - Ok(()) - } - - async fn remove_unlisted(&self, manifest_content: &str) -> Result<()> { - let desired: std::collections::HashSet<&str> = manifest_content - .lines() - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .collect(); - - if desired.is_empty() { - return Ok(()); - } - - let installed = self.list_installed().await?; - - for pkg in installed { - if !desired.contains(pkg.name.as_str()) { - let output = Command::new("bun") - .args(["remove", "-g", &pkg.name]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("Warning: Failed to uninstall {}: {}", pkg.name, stderr); - } - } - } - - Ok(()) - } - async fn update_all(&self) -> Result<()> { let packages = self.list_installed().await?; if packages.is_empty() { @@ -207,7 +129,7 @@ impl PackageManager for BunManager { // bun update -g is broken (only updates first package) // Workaround: reinstall each package to get latest version for pkg in packages { - let output = Command::new("bun") + let output = Command::new(super::resolve_program("bun")) .args(["add", "-g", &pkg.name]) .output() .await?; @@ -222,7 +144,7 @@ impl PackageManager for BunManager { } async fn uninstall(&self, package: &str) -> Result<()> { - let output = Command::new("bun") + let output = Command::new(super::resolve_program("bun")) .args(["remove", "-g", package]) .output() .await?; diff --git a/src/packages/gem.rs b/src/packages/gem.rs index 57038c9..c91aaaa 100644 --- a/src/packages/gem.rs +++ b/src/packages/gem.rs @@ -11,7 +11,10 @@ impl GemManager { } async fn run_gem(&self, args: &[&str]) -> Result { - let output = Command::new("gem").args(args).output().await?; + let output = Command::new(super::resolve_program("gem")) + .args(args) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -80,94 +83,13 @@ impl PackageManager for GemManager { "gem" } - async fn export_manifest(&self) -> Result { - // Get list of installed gems - let packages = self.list_installed().await?; - - // Create simple newline-delimited list of gem names - let manifest = packages - .iter() - .map(|p| p.name.as_str()) - .collect::>() - .join("\n"); - - Ok(manifest) - } - - async fn import_manifest(&self, manifest_content: &str) -> Result<()> { - // Parse gem names from manifest - let gem_names: Vec<&str> = manifest_content - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect(); - - if gem_names.is_empty() { - return Ok(()); // Nothing to install - } - - // Get currently installed gems - let installed = self.list_installed().await?; - let installed_names: std::collections::HashSet<_> = - installed.iter().map(|p| p.name.as_str()).collect(); - - // Install missing gems - for name in gem_names { - if !installed_names.contains(name) { - // Install the gem to user directory - let output = Command::new("gem") - .args(["install", name, "--user-install"]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // Log warning but continue with other gems - eprintln!("Warning: Failed to install {}: {}", name, stderr); - } - } - } - - Ok(()) - } - - async fn remove_unlisted(&self, manifest_content: &str) -> Result<()> { - let desired: std::collections::HashSet<&str> = manifest_content - .lines() - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .collect(); - - if desired.is_empty() { - return Ok(()); - } - - let installed = self.list_installed().await?; - - for pkg in installed { - if !desired.contains(pkg.name.as_str()) { - let output = Command::new("gem") - .args(["uninstall", &pkg.name, "-x", "-a"]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("Warning: Failed to uninstall {}: {}", pkg.name, stderr); - } - } - } - - Ok(()) - } - async fn update_all(&self) -> Result<()> { let packages = self.list_installed().await?; if packages.is_empty() { return Ok(()); } - let output = Command::new("gem") + let output = Command::new(super::resolve_program("gem")) .args(["update", "--user-install"]) .output() .await?; @@ -181,7 +103,7 @@ impl PackageManager for GemManager { } async fn uninstall(&self, package: &str) -> Result<()> { - let output = Command::new("gem") + let output = Command::new(super::resolve_program("gem")) .args(["uninstall", package, "-x", "-a"]) .output() .await?; @@ -196,7 +118,7 @@ impl PackageManager for GemManager { async fn get_dependents(&self, package: &str) -> Result> { // gem dependency -R shows reverse dependencies - let output = Command::new("gem") + let output = Command::new(super::resolve_program("gem")) .args(["dependency", "-R", package]) .output() .await?; @@ -222,8 +144,6 @@ impl PackageManager for GemManager { } // Extract gem name (format: " gemname-version") if let Some(name) = trimmed.split_whitespace().next() { - // Strip version suffix if present - let name = name.split('-').next().unwrap_or(name); if !name.is_empty() { dependents.push(name.to_string()); } diff --git a/src/packages/manager.rs b/src/packages/manager.rs index a86082e..906c067 100644 --- a/src/packages/manager.rs +++ b/src/packages/manager.rs @@ -1,6 +1,7 @@ use anyhow::Result; use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PackageInfo { @@ -24,14 +25,73 @@ pub trait PackageManager: Send + Sync { /// Export installed packages to a manifest file using native tooling /// Returns the content of the manifest as a String - async fn export_manifest(&self) -> Result; + async fn export_manifest(&self) -> Result { + let packages = self.list_installed().await?; + let manifest = packages + .iter() + .map(|p| p.name.as_str()) + .collect::>() + .join("\n"); + Ok(manifest) + } /// Import packages from a manifest file using native tooling /// The manifest_content is the content that was previously exported - async fn import_manifest(&self, manifest_content: &str) -> Result<()>; + async fn import_manifest(&self, manifest_content: &str) -> Result<()> { + let package_names: Vec<&str> = manifest_content + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .collect(); + + if package_names.is_empty() { + return Ok(()); + } + + let installed = self.list_installed().await?; + let installed_names: HashSet<_> = installed.iter().map(|p| p.name.as_str()).collect(); + + for name in package_names { + if !installed_names.contains(name) { + if let Err(e) = self + .install(&PackageInfo { + name: name.to_string(), + version: None, + }) + .await + { + eprintln!("Warning: Failed to install {}: {}", name, e); + } + } + } + + Ok(()) + } /// Remove packages not in the manifest - async fn remove_unlisted(&self, manifest_content: &str) -> Result<()>; + async fn remove_unlisted(&self, manifest_content: &str) -> Result<()> { + let desired: HashSet<&str> = manifest_content + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect(); + + if desired.is_empty() { + return Ok(()); + } + + let installed = self.list_installed().await?; + + for pkg in installed { + if !desired.contains(pkg.name.as_str()) { + if let Err(e) = self.uninstall(&pkg.name).await { + eprintln!("Warning: Failed to uninstall {}: {}", pkg.name, e); + } + } + } + + Ok(()) + } /// Update all installed packages to latest versions async fn update_all(&self) -> Result<()>; diff --git a/src/packages/mapping.rs b/src/packages/mapping.rs new file mode 100644 index 0000000..fc78637 --- /dev/null +++ b/src/packages/mapping.rs @@ -0,0 +1,368 @@ +use std::collections::HashMap; + +pub struct PackageMapping { + pub brew_formula: Option<&'static str>, + pub brew_cask: Option<&'static str>, + pub winget: Option<&'static str>, +} + +/// Built-in cross-platform package mappings. +fn default_mappings() -> Vec { + vec![ + // CLI tools + PackageMapping { + brew_formula: Some("git"), + brew_cask: None, + winget: Some("Git.Git"), + }, + PackageMapping { + brew_formula: Some("curl"), + brew_cask: None, + winget: Some("cURL.cURL"), + }, + PackageMapping { + brew_formula: Some("wget"), + brew_cask: None, + winget: Some("JernejSimoncic.Wget"), + }, + PackageMapping { + brew_formula: Some("jq"), + brew_cask: None, + winget: Some("jqlang.jq"), + }, + PackageMapping { + brew_formula: Some("gh"), + brew_cask: None, + winget: Some("GitHub.cli"), + }, + PackageMapping { + brew_formula: Some("ripgrep"), + brew_cask: None, + winget: Some("BurntSushi.ripgrep.MSVC"), + }, + PackageMapping { + brew_formula: Some("fd"), + brew_cask: None, + winget: Some("sharkdp.fd"), + }, + PackageMapping { + brew_formula: Some("bat"), + brew_cask: None, + winget: Some("sharkdp.bat"), + }, + PackageMapping { + brew_formula: Some("fzf"), + brew_cask: None, + winget: Some("junegunn.fzf"), + }, + PackageMapping { + brew_formula: Some("tree"), + brew_cask: None, + winget: Some("IDRIX.Tree"), + }, + PackageMapping { + brew_formula: Some("cmake"), + brew_cask: None, + winget: Some("Kitware.CMake"), + }, + // Languages & runtimes + PackageMapping { + brew_formula: Some("node"), + brew_cask: None, + winget: Some("OpenJS.NodeJS.LTS"), + }, + PackageMapping { + brew_formula: Some("python"), + brew_cask: None, + winget: Some("Python.Python.3"), + }, + PackageMapping { + brew_formula: Some("go"), + brew_cask: None, + winget: Some("GoLang.Go"), + }, + PackageMapping { + brew_formula: Some("rustup"), + brew_cask: None, + winget: Some("Rustlang.Rustup"), + }, + // Cask ↔ winget (GUI apps) + PackageMapping { + brew_formula: None, + brew_cask: Some("docker"), + winget: Some("Docker.DockerDesktop"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("firefox"), + winget: Some("Mozilla.Firefox"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("google-chrome"), + winget: Some("Google.Chrome"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("visual-studio-code"), + winget: Some("Microsoft.VisualStudioCode"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("slack"), + winget: Some("SlackTechnologies.Slack"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("discord"), + winget: Some("Discord.Discord"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("spotify"), + winget: Some("Spotify.Spotify"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("1password"), + winget: Some("AgileBits.1Password"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("notion"), + winget: Some("Notion.Notion"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("obsidian"), + winget: Some("Obsidian.Obsidian"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("figma"), + winget: Some("Figma.Figma"), + }, + PackageMapping { + brew_formula: None, + brew_cask: Some("postman"), + winget: Some("Postman.Postman"), + }, + ] +} + +/// Config-level mapping entry (user overrides). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MappingEntry { + pub brew: Option, + pub cask: Option, + pub winget: Option, +} + +/// Resolved lookup tables built from defaults + config overrides. +pub struct MappingTable { + brew_formula_to_winget: HashMap, + brew_cask_to_winget: HashMap, + winget_to_brew_formula: HashMap, + winget_to_brew_cask: HashMap, +} + +impl MappingTable { + pub fn build(config_overrides: &[MappingEntry]) -> Self { + let mut table = Self { + brew_formula_to_winget: HashMap::new(), + brew_cask_to_winget: HashMap::new(), + winget_to_brew_formula: HashMap::new(), + winget_to_brew_cask: HashMap::new(), + }; + + // Load built-in defaults + for m in default_mappings() { + table.insert_mapping( + m.brew_formula.map(|s| s.to_string()), + m.brew_cask.map(|s| s.to_string()), + m.winget.map(|s| s.to_string()), + ); + } + + // Apply config overrides (overwrite existing entries) + for entry in config_overrides { + table.insert_mapping(entry.brew.clone(), entry.cask.clone(), entry.winget.clone()); + } + + table + } + + fn insert_mapping( + &mut self, + brew_formula: Option, + brew_cask: Option, + winget: Option, + ) { + if let (Some(formula), Some(wg)) = (&brew_formula, &winget) { + // Remove stale reverse entry if this formula previously mapped to a different winget ID + if let Some(old_wg) = self.brew_formula_to_winget.get(formula) { + if *old_wg != *wg { + self.winget_to_brew_formula.remove(&old_wg.to_lowercase()); + } + } + self.brew_formula_to_winget + .insert(formula.clone(), wg.clone()); + self.winget_to_brew_formula + .insert(wg.to_lowercase(), formula.clone()); + } + if let (Some(cask), Some(wg)) = (&brew_cask, &winget) { + if let Some(old_wg) = self.brew_cask_to_winget.get(cask) { + if *old_wg != *wg { + self.winget_to_brew_cask.remove(&old_wg.to_lowercase()); + } + } + self.brew_cask_to_winget.insert(cask.clone(), wg.clone()); + self.winget_to_brew_cask + .insert(wg.to_lowercase(), cask.clone()); + } + } + + /// Map brew formula names to winget IDs. + pub fn formulae_to_winget<'a>(&self, formulae: &'a [String]) -> Vec<(&'a str, &str)> { + formulae + .iter() + .filter_map(|f| { + self.brew_formula_to_winget + .get(f.as_str()) + .map(|wg| (f.as_str(), wg.as_str())) + }) + .collect() + } + + /// Map brew cask names to winget IDs. + pub fn casks_to_winget<'a>(&self, casks: &'a [String]) -> Vec<(&'a str, &str)> { + casks + .iter() + .filter_map(|c| { + self.brew_cask_to_winget + .get(c.as_str()) + .map(|wg| (c.as_str(), wg.as_str())) + }) + .collect() + } + + /// Map winget IDs to brew formula names. + pub fn winget_to_formulae<'a>(&self, ids: &'a [String]) -> Vec<(&'a str, &str)> { + ids.iter() + .filter_map(|id| { + self.winget_to_brew_formula + .get(&id.to_lowercase()) + .map(|f| (id.as_str(), f.as_str())) + }) + .collect() + } + + /// Map winget IDs to brew cask names. + pub fn winget_to_casks<'a>(&self, ids: &'a [String]) -> Vec<(&'a str, &str)> { + ids.iter() + .filter_map(|id| { + self.winget_to_brew_cask + .get(&id.to_lowercase()) + .map(|c| (id.as_str(), c.as_str())) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builtin_formula_to_winget() { + let table = MappingTable::build(&[]); + let formulae = vec!["git".to_string(), "unknown-pkg".to_string()]; + let mapped = table.formulae_to_winget(&formulae); + assert_eq!(mapped.len(), 1); + assert_eq!(mapped[0], ("git", "Git.Git")); + } + + #[test] + fn test_builtin_cask_to_winget() { + let table = MappingTable::build(&[]); + let casks = vec!["visual-studio-code".to_string()]; + let mapped = table.casks_to_winget(&casks); + assert_eq!(mapped.len(), 1); + assert_eq!( + mapped[0], + ("visual-studio-code", "Microsoft.VisualStudioCode") + ); + } + + #[test] + fn test_winget_to_formula() { + let table = MappingTable::build(&[]); + let ids = vec!["Git.Git".to_string(), "Unknown.Pkg".to_string()]; + let mapped = table.winget_to_formulae(&ids); + assert_eq!(mapped.len(), 1); + assert_eq!(mapped[0], ("Git.Git", "git")); + } + + #[test] + fn test_winget_to_cask() { + let table = MappingTable::build(&[]); + let ids = vec!["Microsoft.VisualStudioCode".to_string()]; + let mapped = table.winget_to_casks(&ids); + assert_eq!(mapped.len(), 1); + assert_eq!( + mapped[0], + ("Microsoft.VisualStudioCode", "visual-studio-code") + ); + } + + #[test] + fn test_config_override() { + let overrides = vec![MappingEntry { + brew: Some("my-tool".to_string()), + cask: None, + winget: Some("MyOrg.MyTool".to_string()), + }]; + let table = MappingTable::build(&overrides); + let formulae = vec!["my-tool".to_string()]; + let mapped = table.formulae_to_winget(&formulae); + assert_eq!(mapped.len(), 1); + assert_eq!(mapped[0], ("my-tool", "MyOrg.MyTool")); + } + + #[test] + fn test_config_override_replaces_builtin() { + let overrides = vec![MappingEntry { + brew: Some("git".to_string()), + cask: None, + winget: Some("Custom.Git".to_string()), + }]; + let table = MappingTable::build(&overrides); + let formulae = vec!["git".to_string()]; + let mapped = table.formulae_to_winget(&formulae); + assert_eq!(mapped[0], ("git", "Custom.Git")); + } + + #[test] + fn test_override_removes_stale_reverse_entry() { + // Builtin: git → Git.Git. Override: git → Custom.Git. + // The old reverse entry Git.Git → git should be removed. + let overrides = vec![MappingEntry { + brew: Some("git".to_string()), + cask: None, + winget: Some("Custom.Git".to_string()), + }]; + let table = MappingTable::build(&overrides); + let ids = vec!["Git.Git".to_string()]; + let mapped = table.winget_to_formulae(&ids); + assert!(mapped.is_empty()); + } + + #[test] + fn test_winget_lookup_case_insensitive() { + let table = MappingTable::build(&[]); + let ids = vec!["git.git".to_string()]; + let mapped = table.winget_to_formulae(&ids); + assert_eq!(mapped.len(), 1); + assert_eq!(mapped[0], ("git.git", "git")); + } +} diff --git a/src/packages/mod.rs b/src/packages/mod.rs index 12e29d3..c2f82eb 100644 --- a/src/packages/mod.rs +++ b/src/packages/mod.rs @@ -2,9 +2,11 @@ pub mod brew; pub mod bun; pub mod gem; pub mod manager; +pub mod mapping; pub mod npm; pub mod pnpm; pub mod uv; +pub mod winget; pub use brew::{normalize_formula_name, BrewManager, BrewfilePackages}; pub use bun::BunManager; @@ -13,3 +15,11 @@ pub use manager::{PackageInfo, PackageManager}; pub use npm::NpmManager; pub use pnpm::PnpmManager; pub use uv::UvManager; +pub use winget::WingetManager; + +/// Resolve a program name to its full path. +/// On Windows, `Command::new("npm")` only finds `.exe` files, but tools like +/// npm/pnpm install as `.cmd` files. `which` respects PATHEXT and finds them. +pub(crate) fn resolve_program(name: &str) -> std::path::PathBuf { + which::which(name).unwrap_or_else(|_| name.into()) +} diff --git a/src/packages/npm.rs b/src/packages/npm.rs index 8ffc9cd..bd5783e 100644 --- a/src/packages/npm.rs +++ b/src/packages/npm.rs @@ -23,7 +23,10 @@ impl NpmManager { } async fn run_npm(&self, args: &[&str]) -> Result { - let output = Command::new("npm").args(args).output().await?; + let output = Command::new(super::resolve_program("npm")) + .args(args) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -83,95 +86,16 @@ impl PackageManager for NpmManager { "npm" } - async fn export_manifest(&self) -> Result { - // Get list of installed packages - let packages = self.list_installed().await?; - - // Create simple newline-delimited list of package names - // Format: package_name (no versions, let npm install latest) - let manifest = packages - .iter() - .map(|p| p.name.as_str()) - .collect::>() - .join("\n"); - - Ok(manifest) - } - - async fn import_manifest(&self, manifest_content: &str) -> Result<()> { - // Parse package names from manifest - let package_names: Vec<&str> = manifest_content - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect(); - - if package_names.is_empty() { - return Ok(()); // Nothing to install - } - - // Get currently installed packages - let installed = self.list_installed().await?; - let installed_names: std::collections::HashSet<_> = - installed.iter().map(|p| p.name.as_str()).collect(); - - // Install missing packages - for name in package_names { - if !installed_names.contains(name) { - // Install the package - let output = Command::new("npm") - .args(["install", "-g", name]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // Log warning but continue with other packages - eprintln!("Warning: Failed to install {}: {}", name, stderr); - } - } - } - - Ok(()) - } - - async fn remove_unlisted(&self, manifest_content: &str) -> Result<()> { - let desired: std::collections::HashSet<&str> = manifest_content - .lines() - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .collect(); - - if desired.is_empty() { - return Ok(()); - } - - let installed = self.list_installed().await?; - - for pkg in installed { - if !desired.contains(pkg.name.as_str()) { - let output = Command::new("npm") - .args(["uninstall", "-g", &pkg.name]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("Warning: Failed to uninstall {}: {}", pkg.name, stderr); - } - } - } - - Ok(()) - } - async fn update_all(&self) -> Result<()> { let packages = self.list_installed().await?; if packages.is_empty() { return Ok(()); } - let output = Command::new("npm").args(["update", "-g"]).output().await?; + let output = Command::new(super::resolve_program("npm")) + .args(["update", "-g"]) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -182,7 +106,7 @@ impl PackageManager for NpmManager { } async fn uninstall(&self, package: &str) -> Result<()> { - let output = Command::new("npm") + let output = Command::new(super::resolve_program("npm")) .args(["uninstall", "-g", package]) .output() .await?; diff --git a/src/packages/pnpm.rs b/src/packages/pnpm.rs index 98fbc47..6cfdca6 100644 --- a/src/packages/pnpm.rs +++ b/src/packages/pnpm.rs @@ -12,7 +12,10 @@ impl PnpmManager { } async fn run_pnpm(&self, args: &[&str]) -> Result { - let output = Command::new("pnpm").args(args).output().await?; + let output = Command::new(super::resolve_program("pnpm")) + .args(args) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -84,95 +87,16 @@ impl PackageManager for PnpmManager { "pnpm" } - async fn export_manifest(&self) -> Result { - // Get list of installed packages - let packages = self.list_installed().await?; - - // Create simple newline-delimited list of package names - // Format: package_name (no versions, let pnpm install latest) - let manifest = packages - .iter() - .map(|p| p.name.as_str()) - .collect::>() - .join("\n"); - - Ok(manifest) - } - - async fn import_manifest(&self, manifest_content: &str) -> Result<()> { - // Parse package names from manifest - let package_names: Vec<&str> = manifest_content - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect(); - - if package_names.is_empty() { - return Ok(()); // Nothing to install - } - - // Get currently installed packages - let installed = self.list_installed().await?; - let installed_names: std::collections::HashSet<_> = - installed.iter().map(|p| p.name.as_str()).collect(); - - // Install missing packages - for name in package_names { - if !installed_names.contains(name) { - // Install the package - let output = Command::new("pnpm") - .args(["add", "-g", name]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // Log warning but continue with other packages - eprintln!("Warning: Failed to install {}: {}", name, stderr); - } - } - } - - Ok(()) - } - - async fn remove_unlisted(&self, manifest_content: &str) -> Result<()> { - let desired: std::collections::HashSet<&str> = manifest_content - .lines() - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .collect(); - - if desired.is_empty() { - return Ok(()); - } - - let installed = self.list_installed().await?; - - for pkg in installed { - if !desired.contains(pkg.name.as_str()) { - let output = Command::new("pnpm") - .args(["remove", "-g", &pkg.name]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("Warning: Failed to uninstall {}: {}", pkg.name, stderr); - } - } - } - - Ok(()) - } - async fn update_all(&self) -> Result<()> { let packages = self.list_installed().await?; if packages.is_empty() { return Ok(()); } - let output = Command::new("pnpm").args(["update", "-g"]).output().await?; + let output = Command::new(super::resolve_program("pnpm")) + .args(["update", "-g"]) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -183,7 +107,7 @@ impl PackageManager for PnpmManager { } async fn uninstall(&self, package: &str) -> Result<()> { - let output = Command::new("pnpm") + let output = Command::new(super::resolve_program("pnpm")) .args(["remove", "-g", package]) .output() .await?; diff --git a/src/packages/uv.rs b/src/packages/uv.rs index 7796334..3a9340a 100644 --- a/src/packages/uv.rs +++ b/src/packages/uv.rs @@ -11,7 +11,10 @@ impl UvManager { } async fn run_uv(&self, args: &[&str]) -> Result { - let output = Command::new("uv").args(args).output().await?; + let output = Command::new(super::resolve_program("uv")) + .args(args) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -77,87 +80,13 @@ impl PackageManager for UvManager { "uv" } - async fn export_manifest(&self) -> Result { - let packages = self.list_installed().await?; - - let manifest = packages - .iter() - .map(|p| p.name.as_str()) - .collect::>() - .join("\n"); - - Ok(manifest) - } - - async fn import_manifest(&self, manifest_content: &str) -> Result<()> { - let package_names: Vec<&str> = manifest_content - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect(); - - if package_names.is_empty() { - return Ok(()); - } - - let installed = self.list_installed().await?; - let installed_names: std::collections::HashSet<_> = - installed.iter().map(|p| p.name.as_str()).collect(); - - for name in package_names { - if !installed_names.contains(name) { - let output = Command::new("uv") - .args(["tool", "install", name]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("Warning: Failed to install {}: {}", name, stderr); - } - } - } - - Ok(()) - } - - async fn remove_unlisted(&self, manifest_content: &str) -> Result<()> { - let desired: std::collections::HashSet<&str> = manifest_content - .lines() - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .collect(); - - if desired.is_empty() { - return Ok(()); - } - - let installed = self.list_installed().await?; - - for pkg in installed { - if !desired.contains(pkg.name.as_str()) { - let output = Command::new("uv") - .args(["tool", "uninstall", &pkg.name]) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("Warning: Failed to uninstall {}: {}", pkg.name, stderr); - } - } - } - - Ok(()) - } - async fn update_all(&self) -> Result<()> { let packages = self.list_installed().await?; if packages.is_empty() { return Ok(()); } - let output = Command::new("uv") + let output = Command::new(super::resolve_program("uv")) .args(["tool", "upgrade", "--all"]) .output() .await?; @@ -171,7 +100,7 @@ impl PackageManager for UvManager { } async fn uninstall(&self, package: &str) -> Result<()> { - let output = Command::new("uv") + let output = Command::new(super::resolve_program("uv")) .args(["tool", "uninstall", package]) .output() .await?; diff --git a/src/packages/winget.rs b/src/packages/winget.rs new file mode 100644 index 0000000..48c2a0e --- /dev/null +++ b/src/packages/winget.rs @@ -0,0 +1,350 @@ +use super::{PackageInfo, PackageManager}; +use anyhow::Result; +use async_trait::async_trait; +use tokio::process::Command; + +pub struct WingetManager; + +impl WingetManager { + pub fn new() -> Self { + Self + } + + async fn run_winget(&self, args: &[&str]) -> Result { + let output = Command::new(super::resolve_program("winget")) + .args(args) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("winget command failed: {}", stderr)); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } +} + +impl Default for WingetManager { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl PackageManager for WingetManager { + async fn list_installed(&self) -> Result> { + let output = self + .run_winget(&["list", "--source", "winget", "--disable-interactivity"]) + .await?; + + let packages = parse_winget_list(&output); + Ok(packages) + } + + async fn install(&self, package: &PackageInfo) -> Result<()> { + let mut args = vec![ + "install", + "--id", + &package.name, + "-e", + "--disable-interactivity", + "--accept-source-agreements", + "--accept-package-agreements", + ]; + let version_str; + if let Some(version) = &package.version { + version_str = version.clone(); + args.extend(["--version", &version_str]); + } + self.run_winget(&args).await?; + Ok(()) + } + + async fn is_available(&self) -> bool { + which::which("winget").is_ok() + } + + fn name(&self) -> &str { + "winget" + } + + async fn export_manifest(&self) -> Result { + let packages = self.list_installed().await?; + let manifest = packages + .iter() + .map(|p| p.name.as_str()) + .collect::>() + .join("\n"); + Ok(manifest) + } + + async fn import_manifest(&self, manifest_content: &str) -> Result<()> { + let package_ids: Vec<&str> = manifest_content + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .collect(); + + if package_ids.is_empty() { + return Ok(()); + } + + let installed = self.list_installed().await?; + let installed_ids: std::collections::HashSet<_> = + installed.iter().map(|p| p.name.to_lowercase()).collect(); + + for id in package_ids { + if !installed_ids.contains(&id.to_lowercase()) { + let output = Command::new(super::resolve_program("winget")) + .args(["install", "--id", id, "-e", "--disable-interactivity", "--accept-source-agreements", "--accept-package-agreements"]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("Warning: Failed to install {}: {}", id, stderr); + } + } + } + + Ok(()) + } + + async fn remove_unlisted(&self, manifest_content: &str) -> Result<()> { + let desired: std::collections::HashSet = manifest_content + .lines() + .map(|l| l.trim().to_lowercase()) + .filter(|l| !l.is_empty()) + .collect(); + + if desired.is_empty() { + return Ok(()); + } + + let installed = self.list_installed().await?; + + for pkg in installed { + if !desired.contains(&pkg.name.to_lowercase()) { + let output = Command::new(super::resolve_program("winget")) + .args([ + "uninstall", + "--id", + &pkg.name, + "-e", + "--disable-interactivity", + ]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("Warning: Failed to uninstall {}: {}", pkg.name, stderr); + } + } + } + + Ok(()) + } + + async fn update_all(&self) -> Result<()> { + let output = Command::new(super::resolve_program("winget")) + .args(["upgrade", "--all", "--disable-interactivity", "--accept-source-agreements", "--accept-package-agreements"]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("winget upgrade failed: {}", stderr)); + } + + Ok(()) + } + + async fn uninstall(&self, package: &str) -> Result<()> { + let output = Command::new(super::resolve_program("winget")) + .args([ + "uninstall", + "--id", + package, + "-e", + "--disable-interactivity", + ]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("winget uninstall failed: {}", stderr)); + } + + Ok(()) + } +} + +/// Approximate display width of a char. CJK ideographs and fullwidth forms are 2 columns. +fn display_width(c: char) -> usize { + let cp = c as u32; + if (0x1100..=0x115F).contains(&cp) // Hangul Jamo + || (0x2E80..=0x303E).contains(&cp) // CJK radicals, symbols + || (0x3040..=0x33BF).contains(&cp) // Hiragana, Katakana, CJK compat + || (0x3400..=0x4DBF).contains(&cp) // CJK Extension A + || (0x4E00..=0x9FFF).contains(&cp) // CJK Unified Ideographs + || (0xA960..=0xA97C).contains(&cp) // Hangul Jamo Extended-A + || (0xAC00..=0xD7A3).contains(&cp) // Hangul Syllables + || (0xF900..=0xFAFF).contains(&cp) // CJK Compatibility Ideographs + || (0xFE10..=0xFE6F).contains(&cp) // CJK compatibility forms, small forms + || (0xFF01..=0xFF60).contains(&cp) // Fullwidth forms + || (0xFFE0..=0xFFE6).contains(&cp) // Fullwidth signs + || (0x20000..=0x2FA1F).contains(&cp) + // CJK extensions B-F, compat supplement + { + 2 + } else { + 1 + } +} + +/// Slice a string by display column position, returning the substring between [start, end). +fn slice_by_display_col(s: &str, start: usize, end: usize) -> &str { + let mut col = 0; + let mut byte_start = s.len(); + let mut byte_end = s.len(); + for (i, c) in s.char_indices() { + if col >= end { + byte_end = i; + break; + } + if col >= start && byte_start == s.len() { + byte_start = i; + } + col += display_width(c); + } + if byte_start > s.len() { + return ""; + } + &s[byte_start..byte_end] +} + +/// Parse `winget list` fixed-width column output by reading column positions from the header. +/// Header is ASCII so byte offsets == display columns. Data lines are sliced by display width +/// to handle non-ASCII package names (e.g., CJK double-width characters). +fn parse_winget_list(output: &str) -> Vec { + let lines: Vec<&str> = output.lines().collect(); + + // Find the header line containing "Id" and "Version" + let Some(header_idx) = lines + .iter() + .position(|l| l.contains("Id") && l.contains("Version")) + else { + return Vec::new(); + }; + let header = lines[header_idx]; + + // Header is ASCII, so byte offset == display column + let Some(id_col) = header.find("Id") else { + return Vec::new(); + }; + let version_col = header.find("Version").unwrap_or(header.len()); + + // Find the separator line (dashes) after header + let data_start = lines + .iter() + .enumerate() + .skip(header_idx + 1) + .find(|(_, l)| l.starts_with('-')) + .map(|(i, _)| i + 1) + .unwrap_or(header_idx + 1); + + let mut packages = Vec::new(); + for line in lines.iter().skip(data_start) { + if line.trim().is_empty() { + continue; + } + let id = slice_by_display_col(line, id_col, version_col).trim(); + let version = { + let rest = slice_by_display_col(line, version_col, usize::MAX).trim(); + let v = rest.split_whitespace().next().unwrap_or(""); + if v.is_empty() { + None + } else { + Some(v.to_string()) + } + }; + if !id.is_empty() { + packages.push(PackageInfo { + name: id.to_string(), + version, + }); + } + } + + packages.sort_by(|a, b| a.name.cmp(&b.name)); + packages +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_winget_list_basic() { + let output = "\ +Name Id Version Available Source +----------------------------------------------------------------------------------------------- +Git Git.Git 2.43.0 2.44.0 winget +Visual Studio Code Microsoft.VisualStudioCode 1.87.0 winget +Microsoft Edge Microsoft.Edge 122.0 123.0 winget"; + + let packages = parse_winget_list(output); + assert_eq!(packages.len(), 3); + assert_eq!(packages[0].name, "Git.Git"); + assert_eq!(packages[0].version, Some("2.43.0".to_string())); + assert_eq!(packages[1].name, "Microsoft.Edge"); + assert_eq!(packages[2].name, "Microsoft.VisualStudioCode"); + assert_eq!(packages[2].version, Some("1.87.0".to_string())); + } + + #[test] + fn test_parse_winget_list_with_preamble() { + let output = "\ +Some winget preamble text +Another line of output +Name Id Version Source +----------------------------------------------------- +Git Git.Git 2.43.0 winget"; + + let packages = parse_winget_list(output); + assert_eq!(packages.len(), 1); + assert_eq!(packages[0].name, "Git.Git"); + } + + #[test] + fn test_parse_winget_list_empty() { + let packages = parse_winget_list(""); + assert!(packages.is_empty()); + } + + #[test] + fn test_parse_winget_list_no_header() { + let packages = parse_winget_list("some random output\nwith no header"); + assert!(packages.is_empty()); + } + + #[test] + fn test_parse_winget_list_non_ascii_names() { + // CJK chars are double-width in terminal display; winget aligns by display columns. + // "日本語App" = 3 CJK (6 cols) + 3 ASCII (3 cols) = 9 display cols + // Header "Id" starts at display column 20, so pad to 20. + let output = "\ +Name Id Version +------------------------------------------------- +日本語App Editor.Japanese 2.1.0 +Блокнот Notepad.App 1.0.0"; + + let packages = parse_winget_list(output); + assert_eq!(packages.len(), 2); + assert_eq!(packages[0].name, "Editor.Japanese"); + assert_eq!(packages[1].name, "Notepad.App"); + } +} diff --git a/src/security/encryption.rs b/src/security/encryption.rs index ab85312..787bab8 100644 --- a/src/security/encryption.rs +++ b/src/security/encryption.rs @@ -83,16 +83,6 @@ pub fn decrypt(encrypted_data: &[u8], key: &[u8]) -> Result> { Ok(plaintext) } -/// Encrypt a file using the provided key -pub fn encrypt_file(plaintext: &[u8], key: &[u8]) -> Result> { - encrypt(plaintext, key) -} - -/// Decrypt a file using the provided key -pub fn decrypt_file(ciphertext: &[u8], key: &[u8]) -> Result> { - decrypt(ciphertext, key) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/security/keychain.rs b/src/security/keychain.rs index 93848be..d9378c2 100644 --- a/src/security/keychain.rs +++ b/src/security/keychain.rs @@ -17,7 +17,7 @@ fn encrypted_key_path() -> Result { /// Get the path to the cached decrypted key (local only, not synced) fn cached_key_path() -> Result { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; Ok(home.join(".tether").join("key.cache")) } @@ -70,7 +70,11 @@ fn cache_key(key: &[u8]) -> Result<()> { file.write_all(key)?; } #[cfg(not(unix))] - fs::write(&path, key)?; + { + fs::write(&path, key)?; + #[cfg(windows)] + super::restrict_file_permissions(&path)?; + } Ok(()) } diff --git a/src/security/mod.rs b/src/security/mod.rs index c6a0021..6fb2b95 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -3,7 +3,7 @@ pub mod keychain; pub mod recipients; pub mod secrets; -pub use encryption::{decrypt_file, encrypt_file, generate_key}; +pub use encryption::{decrypt, encrypt, generate_key}; pub use keychain::{ clear_cached_key, get_encryption_key, has_encryption_key, is_unlocked, store_encryption_key_with_passphrase, unlock_with_passphrase, @@ -14,3 +14,26 @@ pub use recipients::{ load_identity, load_recipients, store_identity, validate_pubkey, }; pub use secrets::{scan_for_secrets, SecretFinding, SecretType}; + +/// Restrict file permissions to current user only (Windows equivalent of chmod 600) +#[cfg(windows)] +pub(crate) fn restrict_file_permissions(path: &std::path::Path) -> anyhow::Result<()> { + let path_str = path.to_string_lossy(); + let username = std::env::var("USERNAME").unwrap_or_default(); + if username.is_empty() { + anyhow::bail!("USERNAME not set, cannot restrict permissions on {}", path_str); + } + let output = std::process::Command::new("icacls") + .args([ + &*path_str, + "/inheritance:r", + "/grant:r", + &format!("{username}:F"), + ]) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("icacls failed on {}: {}", path_str, stderr.trim()); + } + Ok(()) +} diff --git a/src/security/recipients.rs b/src/security/recipients.rs index d15ce68..c0050f2 100644 --- a/src/security/recipients.rs +++ b/src/security/recipients.rs @@ -12,19 +12,19 @@ const PUBKEY_FILENAME: &str = "identity.pub"; /// Get path to user's encrypted identity file fn identity_path() -> Result { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; Ok(home.join(".tether").join(IDENTITY_FILENAME)) } /// Get path to user's public key file fn pubkey_path() -> Result { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; Ok(home.join(".tether").join(PUBKEY_FILENAME)) } /// Get path to cached decrypted identity (local only) fn cached_identity_path() -> Result { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; Ok(home.join(".tether").join("identity.cache")) } @@ -68,7 +68,11 @@ pub fn store_identity(identity: &age::x25519::Identity, passphrase: &str) -> Res file.write_all(&encrypted)?; } #[cfg(not(unix))] - fs::write(&path, &encrypted)?; + { + fs::write(&path, &encrypted)?; + #[cfg(windows)] + super::restrict_file_permissions(&path)?; + } // Also store public key for easy sharing let pubkey = identity.to_public().to_string(); @@ -145,7 +149,11 @@ fn cache_identity(identity: &age::x25519::Identity) -> Result<()> { file.write_all(identity_str.expose_secret().as_bytes())?; } #[cfg(not(unix))] - fs::write(&path, identity_str.expose_secret())?; + { + fs::write(&path, identity_str.expose_secret())?; + #[cfg(windows)] + super::restrict_file_permissions(&path)?; + } Ok(()) } diff --git a/src/security/secrets.rs b/src/security/secrets.rs index 2e4a218..db9485e 100644 --- a/src/security/secrets.rs +++ b/src/security/secrets.rs @@ -1,6 +1,7 @@ use anyhow::Result; use regex::Regex; use std::path::Path; +use std::sync::LazyLock; #[derive(Debug, Clone)] pub enum SecretType { @@ -124,10 +125,10 @@ impl SecretScanner { } fn redact_line(line: &str) -> String { - // Redact potential secret values - let redacted = Regex::new(r#"[=:]\s*['"]?([a-zA-Z0-9+/=_\-]{8,})['"]?"#) - .unwrap() - .replace_all(line, "=***REDACTED***"); + static REDACT_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"[=:]\s*['"]?([a-zA-Z0-9+/=_\-]{8,})['"]?"#).unwrap()); + + let redacted = REDACT_RE.replace_all(line, "=***REDACTED***"); // Truncate if too long if redacted.len() > 80 { @@ -145,10 +146,11 @@ impl Default for SecretScanner { } /// Scan a file for potential secrets +static GLOBAL_SCANNER: LazyLock = LazyLock::new(SecretScanner::default); + pub fn scan_for_secrets(file_path: &Path) -> Result> { let content = std::fs::read_to_string(file_path)?; - let scanner = SecretScanner::new()?; - Ok(scanner.scan_content(&content)) + Ok(GLOBAL_SCANNER.scan_content(&content)) } #[cfg(test)] diff --git a/src/sync/backup.rs b/src/sync/backup.rs index 0358131..6ce05d0 100644 --- a/src/sync/backup.rs +++ b/src/sync/backup.rs @@ -6,7 +6,7 @@ const MAX_BACKUPS: usize = 5; /// Get the backups directory pub fn backups_dir() -> Result { - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; Ok(home.join(".tether/backups")) } @@ -110,7 +110,7 @@ pub fn restore_file(timestamp: &str, category: &str, relative_path: &str) -> Res anyhow::bail!("Backup file not found: {}/{}", category, relative_path); } - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; let dest = match category { "dotfiles" => home.join(relative_path), diff --git a/src/sync/conflict.rs b/src/sync/conflict.rs index b5719fd..0190efc 100644 --- a/src/sync/conflict.rs +++ b/src/sync/conflict.rs @@ -283,8 +283,7 @@ pub struct ConflictState { impl ConflictState { pub fn path() -> Result { - let home = - home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; Ok(home.join(".tether").join("conflicts.json")) } @@ -324,6 +323,7 @@ impl ConflictState { } /// Escape a string for safe use in AppleScript +#[cfg(target_os = "macos")] fn escape_applescript(s: &str) -> String { // Remove any control characters and limit length for safety let sanitized: String = s.chars().filter(|c| !c.is_control()).take(100).collect(); @@ -331,50 +331,60 @@ fn escape_applescript(s: &str) -> String { sanitized.replace('\\', "\\\\").replace('"', "\\\"") } -/// Send macOS notification about conflict +/// Send desktop notification about conflict pub fn notify_conflict(file_path: &str) -> Result<()> { - use std::process::Command; - - let safe_path = escape_applescript(file_path); - let script = format!( - r#"display notification "Conflict detected in {}" with title "Tether" subtitle "Run 'tether resolve' to fix""#, - safe_path - ); - - Command::new("osascript").args(["-e", &script]).output()?; - - Ok(()) + send_notification( + &format!("Conflict detected in {file_path}"), + "Run 'tether resolve' to fix", + ) } -/// Send macOS notification about multiple conflicts +/// Send desktop notification about multiple conflicts pub fn notify_conflicts(count: usize) -> Result<()> { - use std::process::Command; + send_notification( + &format!("{count} file conflicts detected"), + "Run 'tether resolve' to fix", + ) +} - // count is a usize, no escaping needed - let script = format!( - r#"display notification "{} file conflicts detected" with title "Tether" subtitle "Run 'tether resolve' to fix""#, - count - ); +/// Send desktop notification about deferred casks +pub fn notify_deferred_casks(casks: &[String]) -> Result<()> { + let count = casks.len(); + let s = if count == 1 { "" } else { "s" }; + let verb = if count == 1 { "s" } else { "" }; + send_notification( + &format!("{count} cask{s} need{verb} password"), + "Run 'tether sync' to install", + ) +} +#[cfg(target_os = "macos")] +fn send_notification(message: &str, subtitle: &str) -> Result<()> { + use std::process::Command; + let safe_msg = escape_applescript(message); + let safe_sub = escape_applescript(subtitle); + let script = + format!(r#"display notification "{safe_msg}" with title "Tether" subtitle "{safe_sub}""#); Command::new("osascript").args(["-e", &script]).output()?; - Ok(()) } -/// Send macOS notification about deferred casks -pub fn notify_deferred_casks(casks: &[String]) -> Result<()> { +#[cfg(windows)] +fn send_notification(message: &str, subtitle: &str) -> Result<()> { use std::process::Command; + // Pass values via env vars to avoid PowerShell injection through string interpolation + let script = r#"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); $text = $xml.GetElementsByTagName('text'); $text.Item(0).AppendChild($xml.CreateTextNode("Tether: $env:TETHER_SUB")) > $null; $text.Item(1).AppendChild($xml.CreateTextNode($env:TETHER_MSG)) > $null; [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Tether').Show([Windows.UI.Notifications.ToastNotification]::new($xml))"#; + Command::new("powershell") + .env("TETHER_SUB", subtitle) + .env("TETHER_MSG", message) + .args(["-Command", script]) + .output()?; + Ok(()) +} - let count = casks.len(); - let script = format!( - r#"display notification "{} cask{} need{} password" with title "Tether" subtitle "Run 'tether sync' to install""#, - count, - if count == 1 { "" } else { "s" }, - if count == 1 { "s" } else { "" } - ); - - Command::new("osascript").args(["-e", &script]).output()?; - +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn send_notification(message: &str, _subtitle: &str) -> Result<()> { + log::info!("Notification: {message}"); Ok(()) } @@ -506,21 +516,25 @@ mod tests { } // escape_applescript tests + #[cfg(target_os = "macos")] #[test] fn test_escape_applescript_plain() { assert_eq!(escape_applescript("hello"), "hello"); } + #[cfg(target_os = "macos")] #[test] fn test_escape_applescript_quotes() { assert_eq!(escape_applescript("hello\"world"), "hello\\\"world"); } + #[cfg(target_os = "macos")] #[test] fn test_escape_applescript_backslashes() { assert_eq!(escape_applescript("path\\to\\file"), "path\\\\to\\\\file"); } + #[cfg(target_os = "macos")] #[test] fn test_escape_applescript_truncates_long() { let long = "a".repeat(200); @@ -528,6 +542,7 @@ mod tests { assert!(escaped.len() <= 100); } + #[cfg(target_os = "macos")] #[test] fn test_escape_applescript_removes_control_chars() { let with_control = "hello\nworld\ttab"; diff --git a/src/sync/engine.rs b/src/sync/engine.rs index 8eebd0b..ee2bc3e 100644 --- a/src/sync/engine.rs +++ b/src/sync/engine.rs @@ -5,8 +5,7 @@ pub struct SyncEngine; impl SyncEngine { pub fn sync_path() -> Result { - let home = - home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; Ok(home.join(".tether").join("sync")) } } diff --git a/src/sync/git.rs b/src/sync/git.rs index 9e467cf..57da49b 100644 --- a/src/sync/git.rs +++ b/src/sync/git.rs @@ -334,43 +334,48 @@ pub fn is_gitignored(file_path: &Path) -> Result { /// Directories to skip when scanning for git repos or project files. /// These are typically build artifacts, dependencies, or caches. pub fn should_skip_dir(name: &str) -> bool { - // Hidden directories + should_skip_dir_inner(name, true) +} + +/// Like `should_skip_dir` but allows specific hidden dirs like `.vscode` and `.idea` +/// that project config scanning needs to traverse. +pub fn should_skip_dir_for_project_configs(name: &str) -> bool { + should_skip_dir_inner(name, false) +} + +fn should_skip_dir_inner(name: &str, skip_all_hidden: bool) -> bool { if name.starts_with('.') { + if skip_all_hidden { + return true; + } + // Allow project config dirs, skip everything else + return !matches!(name, ".vscode" | ".idea" | ".run"); + } + + if name.ends_with(".egg-info") { return true; } matches!( name, - // Node.js "node_modules" | "bower_components" - // Rust | "target" - // Python | "__pycache__" - | ".venv" | "venv" | "env" - | ".eggs" - | "*.egg-info" - // .NET | "bin" | "obj" | "packages" - // Java/Kotlin | "build" | "out" - // Go | "vendor" - // Ruby | "bundle" - // General | "dist" | "coverage" | "tmp" | "temp" | "cache" - | ".cache" ) } @@ -539,6 +544,24 @@ mod tests { assert!(!should_skip_dir("components")); } + #[test] + fn test_project_config_skip_allows_ide_dirs() { + // Base function skips all hidden dirs + assert!(should_skip_dir(".vscode")); + assert!(should_skip_dir(".idea")); + assert!(should_skip_dir(".run")); + // Project config variant allows IDE config dirs + assert!(!should_skip_dir_for_project_configs(".vscode")); + assert!(!should_skip_dir_for_project_configs(".idea")); + assert!(!should_skip_dir_for_project_configs(".run")); + // But still skips other hidden dirs + assert!(should_skip_dir_for_project_configs(".git")); + assert!(should_skip_dir_for_project_configs(".cache")); + // And still skips non-hidden build dirs + assert!(should_skip_dir_for_project_configs("node_modules")); + assert!(should_skip_dir_for_project_configs("target")); + } + #[test] fn test_checkout_id_from_path() { use std::path::Path; diff --git a/src/sync/layers.rs b/src/sync/layers.rs index bb86ac8..c84dac9 100644 --- a/src/sync/layers.rs +++ b/src/sync/layers.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use std::fs; use std::path::{Path, PathBuf}; @@ -6,7 +6,7 @@ use crate::sync::merge::{detect_file_type, merge_files, FileType}; /// Get the layers directory (~/.tether/layers) pub fn layers_dir() -> Result { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; Ok(home.join(".tether").join("layers")) } @@ -22,7 +22,7 @@ pub fn team_layer_dir(team_name: &str) -> Result { /// Get the merged output directory pub fn merged_dir() -> Result { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; Ok(home.join(".tether").join("merged")) } @@ -91,7 +91,7 @@ pub fn sync_team_to_layer(team_name: &str, team_repo_dotfiles: &Path) -> Result< /// Capture personal dotfile to personal layer (if not already captured) /// Returns true if captured, false if already exists pub fn capture_personal_to_layer(filename: &str) -> Result { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; let personal_file = home.join(filename); let layer_file = personal_layer_dir()?.join(filename); @@ -107,7 +107,7 @@ pub fn capture_personal_to_layer(filename: &str) -> Result { /// Update personal layer from home directory (for ongoing sync) pub fn update_personal_layer(filename: &str) -> Result<()> { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; let personal_file = home.join(filename); let layer_file = personal_layer_dir()?.join(filename); @@ -150,7 +150,7 @@ pub fn merge_layers(team_name: &str, filename: &str) -> Result { /// Apply merged file to home directory pub fn apply_merged_to_home(filename: &str) -> Result<()> { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; let merged_file = merged_dir()?.join(filename); let home_file = home.join(filename); @@ -177,7 +177,7 @@ pub fn apply_merged_to_home(filename: &str) -> Result<()> { /// 2. Merge team + personal /// 3. Apply to home pub fn sync_dotfile_with_layers(team_name: &str, filename: &str) -> Result { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; let team_file = team_layer_dir(team_name)?.join(filename); let personal_layer_file = personal_layer_dir()?.join(filename); let home_file = home.join(filename); @@ -271,7 +271,7 @@ pub fn remerge_all(team_name: &str) -> Result> { /// Reset a file to the team version (clobber local changes) /// This copies the team version directly to home, bypassing personal layer pub fn reset_to_team(team_name: &str, filename: &str) -> Result<()> { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; let team_file = team_layer_dir(team_name)?.join(filename); let personal_file = personal_layer_dir()?.join(filename); let home_file = home.join(filename); @@ -316,7 +316,7 @@ pub fn reset_all_to_team(team_name: &str) -> Result> { /// Promote a local file to the team repository /// This copies the home version to the team repo's dotfiles directory pub fn promote_to_team(team_name: &str, filename: &str, team_repo_path: &Path) -> Result<()> { - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; let home_file = home.join(filename); if !home_file.exists() { diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 8a6427a..d5be689 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -34,8 +34,63 @@ pub use team::{ }; use anyhow::Result; +use std::fs::File; use std::path::{Path, PathBuf}; +pub const CURRENT_SYNC_FORMAT_VERSION: u32 = 1; + +/// Check sync repo format version. Creates file if missing, errors if newer than supported. +pub fn check_sync_format_version(sync_path: &Path) -> Result<()> { + let version_file = sync_path.join("format_version"); + if version_file.exists() { + let content = std::fs::read_to_string(&version_file)?; + let version: u32 = content + .trim() + .parse() + .map_err(|_| anyhow::anyhow!("Invalid format_version file"))?; + if version > CURRENT_SYNC_FORMAT_VERSION { + anyhow::bail!( + "Sync repo format version {} is newer than supported ({}). Run: brew upgrade tether", + version, + CURRENT_SYNC_FORMAT_VERSION + ); + } + } else { + std::fs::create_dir_all(sync_path)?; + std::fs::write(&version_file, format!("{}\n", CURRENT_SYNC_FORMAT_VERSION))?; + } + Ok(()) +} + +/// Acquire an exclusive lock on ~/.tether/sync.lock. +/// If `wait` is true (CLI), retries up to 20 times at 100ms intervals. +/// If `wait` is false (daemon), fails immediately. +pub fn acquire_sync_lock(wait: bool) -> Result { + use fs2::FileExt; + + let lock_path = crate::home_dir()?.join(".tether/sync.lock"); + std::fs::create_dir_all(lock_path.parent().unwrap())?; + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path)?; + + if wait { + for _ in 0..20 { + if file.try_lock_exclusive().is_ok() { + return Ok(file); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + anyhow::bail!("Could not acquire sync lock after 2 seconds. Another sync may be running."); + } else { + file.try_lock_exclusive() + .map_err(|_| anyhow::anyhow!("Sync already in progress, skipping"))?; + } + Ok(file) +} + /// Get the canonical storage path for a project config file. /// Files are stored at ~/.tether/projects// pub fn canonical_project_file_path(normalized_url: &str, rel_path: &str) -> Result { @@ -43,11 +98,16 @@ pub fn canonical_project_file_path(normalized_url: &str, rel_path: &str) -> Resu if normalized_url.contains("..") || rel_path.contains("..") { anyhow::bail!("Path traversal not allowed in project path"); } - if normalized_url.starts_with('/') || rel_path.starts_with('/') { - anyhow::bail!("Absolute paths not allowed in project path"); + for s in [normalized_url, rel_path] { + if s.starts_with('/') || s.starts_with('\\') { + anyhow::bail!("Absolute paths not allowed in project path"); + } + if s.len() >= 2 && s.as_bytes()[0].is_ascii_alphabetic() && s.as_bytes()[1] == b':' { + anyhow::bail!("Absolute paths not allowed in project path"); + } } - let home = home::home_dir().ok_or_else(|| anyhow::anyhow!("No home dir"))?; + let home = crate::home_dir()?; Ok(home .join(".tether/projects") .join(normalized_url) @@ -76,7 +136,7 @@ pub fn expand_dotfile_glob(pattern: &str, home: &Path) -> Vec { .filter_map(|p| { p.strip_prefix(home) .ok() - .map(|r| r.to_string_lossy().to_string()) + .map(|r| r.to_string_lossy().replace('\\', "/")) }) .collect(); if expanded.is_empty() { @@ -113,7 +173,7 @@ pub fn expand_from_sync_repo(pattern: &str, dotfiles_dir: &Path) -> Vec .filter_map(Result::ok) .filter_map(|p| { p.strip_prefix(dotfiles_dir).ok().and_then(|r| { - let s = r.to_string_lossy(); + let s = r.to_string_lossy().replace('\\', "/"); // Remove .enc suffix and add leading dot s.strip_suffix(".enc").map(|s| format!(".{}", s)) }) @@ -133,6 +193,60 @@ pub fn expand_from_sync_repo(pattern: &str, dotfiles_dir: &Path) -> Vec } } +/// Create a symlink. On Windows, falls back to copy if Developer Mode is not enabled. +#[cfg(unix)] +pub fn create_symlink(src: &Path, dst: &Path) -> Result<()> { + std::os::unix::fs::symlink(src, dst)?; + Ok(()) +} + +#[cfg(windows)] +pub fn create_symlink(src: &Path, dst: &Path) -> Result<()> { + let result = if src.is_dir() { + std::os::windows::fs::symlink_dir(src, dst) + } else { + std::os::windows::fs::symlink_file(src, dst) + }; + match result { + Ok(()) => Ok(()), + Err(e) if e.raw_os_error() == Some(1314) => { + // ERROR_PRIVILEGE_NOT_HELD — need Developer Mode or admin for symlinks + if src.is_dir() { + copy_dir_recursive(src, dst, 0)?; + } else { + std::fs::copy(src, dst)?; + } + log::warn!( + "Symlink requires Developer Mode, copied instead: {}", + dst.display() + ); + Ok(()) + } + Err(e) => Err(e.into()), + } +} + +#[cfg(windows)] +const MAX_COPY_DEPTH: u32 = 10; + +#[cfg(windows)] +fn copy_dir_recursive(src: &Path, dst: &Path, depth: u32) -> Result<()> { + if depth > MAX_COPY_DEPTH { + anyhow::bail!("Directory copy exceeded max depth ({})", MAX_COPY_DEPTH); + } + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let target = dst.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir_recursive(&entry.path(), &target, depth + 1)?; + } else { + std::fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + /// Atomically write content to a file by writing to a temp file and renaming. /// This prevents file corruption from interrupted writes. pub fn atomic_write(path: &Path, content: &[u8]) -> Result<()> { @@ -228,4 +342,50 @@ mod tests { let result = expand_from_sync_repo(".config/nonexistent/*.json", tmp.path()); assert!(result.is_empty()); } + + #[test] + fn test_format_version_creates_file() { + let tmp = TempDir::new().unwrap(); + check_sync_format_version(tmp.path()).unwrap(); + let content = std::fs::read_to_string(tmp.path().join("format_version")).unwrap(); + assert_eq!(content, format!("{}\n", CURRENT_SYNC_FORMAT_VERSION)); + } + + #[test] + fn test_format_version_accepts_current() { + let tmp = TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("format_version"), + format!("{}\n", CURRENT_SYNC_FORMAT_VERSION), + ) + .unwrap(); + check_sync_format_version(tmp.path()).unwrap(); + } + + #[test] + fn test_format_version_rejects_newer() { + let tmp = TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("format_version"), + format!("{}\n", CURRENT_SYNC_FORMAT_VERSION + 1), + ) + .unwrap(); + let err = check_sync_format_version(tmp.path()).unwrap_err(); + assert!(err.to_string().contains("brew upgrade tether")); + } + + #[test] + fn test_format_version_accepts_older() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("format_version"), "0\n").unwrap(); + check_sync_format_version(tmp.path()).unwrap(); + } + + #[test] + fn test_format_version_rejects_invalid() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("format_version"), "abc\n").unwrap(); + let err = check_sync_format_version(tmp.path()).unwrap_err(); + assert!(err.to_string().contains("Invalid format_version")); + } } diff --git a/src/sync/packages.rs b/src/sync/packages.rs index d614fa5..18f009e 100644 --- a/src/sync/packages.rs +++ b/src/sync/packages.rs @@ -2,7 +2,7 @@ use crate::cli::Output; use crate::config::Config; use crate::packages::{ normalize_formula_name, BrewManager, BrewfilePackages, BunManager, GemManager, NpmManager, - PackageManager, PnpmManager, UvManager, + PackageManager, PnpmManager, UvManager, WingetManager, }; use crate::sync::state::PackageState; use crate::sync::{MachineState, SyncState}; @@ -47,6 +47,11 @@ const SIMPLE_MANAGERS: &[PackageManagerDef] = &[ display_name: "uv", manifest_file: "uv.txt", }, + PackageManagerDef { + state_key: "winget", + display_name: "winget", + manifest_file: "winget.txt", + }, ]; /// Import packages from manifests, installing only missing packages. @@ -91,6 +96,7 @@ pub async fn import_packages( "bun" => config.packages.bun.enabled, "gem" => config.packages.gem.enabled, "uv" => config.packages.uv.enabled, + "winget" => config.packages.winget.enabled, _ => false, }; @@ -102,9 +108,210 @@ pub async fn import_packages( } } + // Cross-platform mapping: brew ↔ winget + if config.packages.cross_platform_sync + && import_cross_platform(config, &manifests_dir, machine_state).await + { + let key = if cfg!(target_os = "windows") { + "winget" + } else { + "brew" + }; + update_last_upgrade(state, key); + } + Ok(deferred_casks) } +/// Import packages cross-platform using brew ↔ winget mappings. +/// On Windows: reads Brewfile, maps to winget IDs, installs missing. +/// On macOS: reads winget.txt, maps to brew formulas/casks, installs missing. +#[cfg(target_os = "windows")] +async fn import_cross_platform( + config: &Config, + manifests_dir: &Path, + machine_state: &MachineState, +) -> bool { + use crate::packages::mapping::MappingTable; + + if !config.packages.winget.enabled { + return false; + } + + let winget = WingetManager::new(); + if !winget.is_available().await { + return false; + } + + let Some(manifest) = manifests_dir + .join("Brewfile") + .exists() + .then(|| std::fs::read_to_string(manifests_dir.join("Brewfile")).ok()) + .flatten() + else { + return false; + }; + + let table = MappingTable::build(&config.packages.mapping); + let brew_packages = BrewfilePackages::parse(&manifest); + + let local_winget: HashSet = machine_state + .packages + .get("winget") + .map(|v| v.iter().map(|s| s.to_lowercase()).collect()) + .unwrap_or_default(); + let removed_winget: HashSet = machine_state + .removed_packages + .get("winget") + .map(|v| v.iter().map(|s| s.to_lowercase()).collect()) + .unwrap_or_default(); + + let mut to_install: Vec = Vec::new(); + + for (_brew_name, winget_id) in table.formulae_to_winget(&brew_packages.formulae) { + let id_lower = winget_id.to_lowercase(); + if !local_winget.contains(&id_lower) && !removed_winget.contains(&id_lower) { + to_install.push(winget_id.to_string()); + } + } + + for (_cask_name, winget_id) in table.casks_to_winget(&brew_packages.casks) { + let id_lower = winget_id.to_lowercase(); + if !local_winget.contains(&id_lower) && !removed_winget.contains(&id_lower) { + to_install.push(winget_id.to_string()); + } + } + + if to_install.is_empty() { + return false; + } + + Output::info(&format!( + "Cross-platform: installing {} winget package{} from Brewfile...", + to_install.len(), + if to_install.len() == 1 { "" } else { "s" } + )); + let manifest_str = to_install.join("\n") + "\n"; + match winget.import_manifest(&manifest_str).await { + Ok(_) => true, + Err(e) => { + Output::warning(&format!("Cross-platform winget import failed: {}", e)); + false + } + } +} + +#[cfg(target_os = "macos")] +async fn import_cross_platform( + config: &Config, + manifests_dir: &Path, + machine_state: &MachineState, +) -> bool { + use crate::packages::mapping::MappingTable; + + if !config.packages.brew.enabled { + return false; + } + + let brew = BrewManager::new(); + if !brew.is_available().await { + return false; + } + + let Some(manifest) = manifests_dir + .join("winget.txt") + .exists() + .then(|| std::fs::read_to_string(manifests_dir.join("winget.txt")).ok()) + .flatten() + else { + return false; + }; + + let table = MappingTable::build(&config.packages.mapping); + let winget_ids: Vec = manifest + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|s| s.trim().to_string()) + .collect(); + + let local_formulae: HashSet<_> = machine_state + .packages + .get("brew_formulae") + .map(|v| v.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + let local_casks: HashSet<_> = machine_state + .packages + .get("brew_casks") + .map(|v| v.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + let removed_formulae: HashSet<_> = machine_state + .removed_packages + .get("brew_formulae") + .map(|v| v.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + let removed_casks: HashSet<_> = machine_state + .removed_packages + .get("brew_casks") + .map(|v| v.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + + let mut formulae_to_install: Vec = Vec::new(); + let mut casks_to_install: Vec = Vec::new(); + + for (_winget_id, formula) in table.winget_to_formulae(&winget_ids) { + if !local_formulae.contains(formula) && !removed_formulae.contains(formula) { + formulae_to_install.push(formula.to_string()); + } + } + + for (_winget_id, cask) in table.winget_to_casks(&winget_ids) { + if !local_casks.contains(cask) && !removed_casks.contains(cask) { + casks_to_install.push(cask.to_string()); + } + } + + let total = formulae_to_install.len() + casks_to_install.len(); + if total == 0 { + return false; + } + + Output::info(&format!( + "Cross-platform: installing {} brew package{} from winget manifest...", + total, + if total == 1 { "" } else { "s" } + )); + + let mut installed = false; + + if !formulae_to_install.is_empty() { + let brew_pkgs = BrewfilePackages { + taps: Vec::new(), + formulae: formulae_to_install, + casks: Vec::new(), + }; + if brew.import_manifest(&brew_pkgs.generate()).await.is_ok() { + installed = true; + } + } + + for cask in &casks_to_install { + if brew.install_cask(cask, true).await.is_ok() { + installed = true; + } + } + + installed +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +async fn import_cross_platform( + _config: &Config, + _manifests_dir: &Path, + _machine_state: &MachineState, +) -> bool { + false +} + /// Update last_upgrade timestamp for a package manager fn update_last_upgrade(state: &mut SyncState, manager: &str) { let now = chrono::Utc::now(); @@ -306,6 +513,7 @@ async fn import_simple_manager( "bun" => Box::new(BunManager::new()), "gem" => Box::new(GemManager::new()), "uv" => Box::new(UvManager::new()), + "winget" => Box::new(WingetManager::new()), _ => return false, }; @@ -318,16 +526,38 @@ async fn import_simple_manager( Err(_) => return false, }; - let local_packages: HashSet<_> = machine_state + let case_insensitive = def.state_key == "winget"; + + let local_packages: HashSet = machine_state .packages .get(def.state_key) - .map(|v| v.iter().cloned().collect()) + .map(|v| { + v.iter() + .map(|s| { + if case_insensitive { + s.to_lowercase() + } else { + s.clone() + } + }) + .collect() + }) .unwrap_or_default(); - let removed_packages: HashSet<_> = machine_state + let removed_packages: HashSet = machine_state .removed_packages .get(def.state_key) - .map(|v| v.iter().cloned().collect()) + .map(|v| { + v.iter() + .map(|s| { + if case_insensitive { + s.to_lowercase() + } else { + s.clone() + } + }) + .collect() + }) .unwrap_or_default(); // Filter to only missing packages @@ -335,7 +565,12 @@ async fn import_simple_manager( .lines() .filter(|line| { let pkg = line.trim(); - !pkg.is_empty() && !removed_packages.contains(pkg) && !local_packages.contains(pkg) + let key = if case_insensitive { + pkg.to_lowercase() + } else { + pkg.to_string() + }; + !pkg.is_empty() && !removed_packages.contains(&key) && !local_packages.contains(&key) }) .map(|s| s.to_string()) .collect(); @@ -405,6 +640,7 @@ pub async fn sync_packages( "bun" => config.packages.bun.enabled, "gem" => config.packages.gem.enabled, "uv" => config.packages.uv.enabled, + "winget" => config.packages.winget.enabled, _ => false, }; diff --git a/src/sync/state.rs b/src/sync/state.rs index c89b9f1..3b8c46a 100644 --- a/src/sync/state.rs +++ b/src/sync/state.rs @@ -58,6 +58,8 @@ pub struct MachineState { pub last_sync: DateTime, #[serde(default)] pub os_version: String, + #[serde(default)] + pub cli_version: String, /// File paths and their hashes pub files: HashMap, /// Package manager -> list of installed packages @@ -103,6 +105,7 @@ impl MachineState { hostname, last_sync: Utc::now(), os_version: String::new(), + cli_version: env!("CARGO_PKG_VERSION").to_string(), files: HashMap::new(), packages: HashMap::new(), removed_packages: HashMap::new(), @@ -235,8 +238,7 @@ impl MachineState { impl SyncState { pub fn state_path() -> Result { - let home = - home::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let home = crate::home_dir()?; Ok(home.join(".tether").join("state.json")) } @@ -299,49 +301,33 @@ mod tests { use super::*; use tempfile::TempDir; - // Package name safety tests #[test] fn test_safe_package_names() { assert!(MachineState::is_safe_package_name("git")); assert!(MachineState::is_safe_package_name("node-18.x")); assert!(MachineState::is_safe_package_name("@angular/cli")); assert!(MachineState::is_safe_package_name("python3.11")); + assert!(MachineState::is_safe_package_name(&"a".repeat(256))); } #[test] - fn test_unsafe_package_name_shell_injection() { + fn test_unsafe_package_names_rejected() { + // Shell injection assert!(!MachineState::is_safe_package_name("git; rm -rf /")); assert!(!MachineState::is_safe_package_name("$(whoami)")); assert!(!MachineState::is_safe_package_name("pkg`id`")); assert!(!MachineState::is_safe_package_name("pkg|cat /etc/passwd")); assert!(!MachineState::is_safe_package_name("pkg&background")); - } - - #[test] - fn test_unsafe_package_name_quotes() { + // Quotes/escapes assert!(!MachineState::is_safe_package_name("pkg'injection")); assert!(!MachineState::is_safe_package_name("pkg\"injection")); assert!(!MachineState::is_safe_package_name("pkg\\escape")); - } - - #[test] - fn test_unsafe_package_name_newlines() { + // Newlines assert!(!MachineState::is_safe_package_name("pkg\nmalicious")); assert!(!MachineState::is_safe_package_name("pkg\rmalicious")); - } - - #[test] - fn test_unsafe_package_name_empty() { + // Empty / too long assert!(!MachineState::is_safe_package_name("")); - } - - #[test] - fn test_unsafe_package_name_too_long() { - let long_name = "a".repeat(300); - assert!(!MachineState::is_safe_package_name(&long_name)); - - let max_len = "a".repeat(256); - assert!(MachineState::is_safe_package_name(&max_len)); + assert!(!MachineState::is_safe_package_name(&"a".repeat(300))); } // Validation tests @@ -463,6 +449,7 @@ mod tests { .unwrap(); assert_eq!(loaded.machine_id, "test-machine"); + assert_eq!(loaded.cli_version, env!("CARGO_PKG_VERSION")); assert_eq!( loaded.packages.get("npm"), Some(&vec!["typescript".to_string()]) @@ -518,8 +505,14 @@ mod tests { } #[test] - fn test_machine_state_checkouts_defaults_empty() { - // Simulate loading old state without checkouts field + fn test_machine_state_new_has_cli_version() { + let state = MachineState::new("test"); + assert!(!state.cli_version.is_empty()); + assert_eq!(state.cli_version, env!("CARGO_PKG_VERSION")); + } + + #[test] + fn test_machine_state_old_json_defaults_all_optional_fields() { let old_json = r#"{ "machine_id": "test", "hostname": "test-host", @@ -529,7 +522,14 @@ mod tests { }"#; let loaded: MachineState = serde_json::from_str(old_json).unwrap(); + assert_eq!(loaded.cli_version, ""); + assert_eq!(loaded.os_version, ""); assert!(loaded.checkouts.is_empty()); + assert!(loaded.removed_packages.is_empty()); + assert!(loaded.dotfiles.is_empty()); + assert!(loaded.ignored_dotfiles.is_empty()); + assert!(loaded.project_configs.is_empty()); + assert!(loaded.ignored_project_configs.is_empty()); } #[test] diff --git a/src/sync/team.rs b/src/sync/team.rs index 7b5ac96..ac2c7bc 100644 --- a/src/sync/team.rs +++ b/src/sync/team.rs @@ -245,7 +245,7 @@ pub fn glob_match(pattern: &str, text: &str) -> bool { /// Discovers directories in team repo that should be symlinked pub fn discover_symlinkable_dirs(team_sync_dir: &Path) -> Result> { let mut dirs = Vec::new(); - let home = home::home_dir().context("Could not find home directory")?; + let home = crate::home_dir()?; // Check for common config directories let candidates = vec![ @@ -357,8 +357,7 @@ impl SymlinkableDir { std::fs::remove_file(&target_item)?; // Remove old symlink if exists } - #[cfg(unix)] - std::os::unix::fs::symlink(&team_item, &target_item).with_context(|| { + super::create_symlink(&team_item, &target_item).with_context(|| { format!( "Failed to create symlink: {} -> {}", target_item.display(), @@ -366,31 +365,6 @@ impl SymlinkableDir { ) })?; - #[cfg(windows)] - { - if team_item.is_dir() { - std::os::windows::fs::symlink_dir(&team_item, &target_item).with_context( - || { - format!( - "Failed to create directory symlink: {} -> {}", - target_item.display(), - team_item.display() - ) - }, - )?; - } else { - std::os::windows::fs::symlink_file(&team_item, &target_item).with_context( - || { - format!( - "Failed to create file symlink: {} -> {}", - target_item.display(), - team_item.display() - ) - }, - )?; - } - } - manifest.add_symlink(team_name, target_item.clone(), team_item); results.push(SymlinkResult::Created(target_item)); } @@ -526,19 +500,9 @@ pub fn resolve_conflict(target: &Path, team_source: &Path) -> Result Result Date: Tue, 10 Feb 2026 15:07:58 +1000 Subject: [PATCH 02/15] fix: Windows daemon, symlink, permissions, and pid improvements - Force-kill detached processes directly (no graceful WM_CLOSE attempt) - Shorten daemon stop wait on Windows (already force-killed) - Fix is_process_running to check ESRCH instead of ErrorKind::NotFound - Strip all ACEs before granting current user in icacls - Handle copy-mode files in ensure_symlink on Windows - Add winget interactivity flags for gh install - Use platform-generic upgrade message in format_version check --- WINDOWS_SUPPORT.md | 2 +- src/cli/commands/daemon.rs | 19 ++++++++----------- src/cli/commands/sync.rs | 27 +++++++++++++++------------ src/daemon/pid.rs | 4 ++-- src/github.rs | 4 ++-- src/security/mod.rs | 5 +++++ src/sync/mod.rs | 4 ++-- 7 files changed, 35 insertions(+), 30 deletions(-) diff --git a/WINDOWS_SUPPORT.md b/WINDOWS_SUPPORT.md index f8d4f00..3c5b0b2 100644 --- a/WINDOWS_SUPPORT.md +++ b/WINDOWS_SUPPORT.md @@ -21,7 +21,7 @@ | Default merge tool | `opendiff` | `code` (VS Code) | | Default editor | `nano` | `notepad` | | File permissions | `0o600` for secrets | ACL restricted via `icacls` | -| Process management | `kill`/signals (graceful SIGTERM) | `tasklist`/`taskkill` (force kill only) | +| Process management | `kill`/signals (graceful SIGTERM) | `tasklist`/`taskkill /F` (no graceful signal for detached processes) | | Package manager | Homebrew | WinGet | ## Architecture notes diff --git a/src/cli/commands/daemon.rs b/src/cli/commands/daemon.rs index dd230ae..335a0a2 100644 --- a/src/cli/commands/daemon.rs +++ b/src/cli/commands/daemon.rs @@ -88,20 +88,21 @@ pub async fn stop() -> Result<()> { terminate_process(pid)?; - // Graceful: wait up to 10 seconds - for _ in 0..50 { + // On Windows, terminate_process already force-kills (no graceful signal available), + // so only do the extended wait on Unix where SIGTERM allows graceful shutdown. + let wait_rounds = if cfg!(windows) { 10 } else { 50 }; + for _ in 0..wait_rounds { if !is_process_running(pid) { break; } sleep(Duration::from_millis(200)).await; } - // Force kill if still running + // Force kill if still running (Unix only — redundant on Windows) if is_process_running(pid) { log::debug!("Daemon did not exit gracefully, force killing"); force_kill_process(pid); - // Wait for forced termination for _ in 0..10 { if !is_process_running(pid) { break; @@ -175,18 +176,14 @@ fn terminate_process(pid: u32) -> Result<()> { #[cfg(windows)] fn terminate_process(pid: u32) -> Result<()> { use std::process::Command; - // taskkill without /F sends WM_CLOSE — ineffective for detached/console-less processes. - // Don't error on failure; stop() will fall through to force_kill_process. + // Detached/console-less processes can't receive WM_CLOSE, so use /F directly. let output = Command::new("taskkill") - .args(["/PID", &pid.to_string()]) + .args(["/F", "/PID", &pid.to_string()]) .output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); if !stderr.contains("not found") { - log::debug!( - "Graceful taskkill failed (expected for detached): {}", - stderr.trim() - ); + log::debug!("taskkill failed: {}", stderr.trim()); } } Ok(()) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index c3d84a8..170ae73 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -770,23 +770,26 @@ fn ensure_symlink(checkout_file: &Path, canonical_path: &Path) -> Result<()> { ); } Ok(_) => { - // Real file exists - migrate content to canonical if newer + // Real file exists — on Windows without Developer Mode, create_symlink + // falls back to copy, so the file IS the managed copy. If content matches + // canonical, no work needed. let checkout_content = std::fs::read(checkout_file)?; let canonical_content = std::fs::read(canonical_path).ok(); - if canonical_content.as_ref() != Some(&checkout_content) { - let checkout_mtime = std::fs::metadata(checkout_file)?.modified()?; - let canonical_mtime = std::fs::metadata(canonical_path) - .and_then(|m| m.modified()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + if canonical_content.as_ref() == Some(&checkout_content) { + return Ok(()); // Already in sync (likely a copy-mode file on Windows) + } - if checkout_mtime > canonical_mtime { - // Checkout is newer - write to canonical - if let Some(parent) = canonical_path.parent() { - std::fs::create_dir_all(parent)?; - } - crate::sync::atomic_write(canonical_path, &checkout_content)?; + let checkout_mtime = std::fs::metadata(checkout_file)?.modified()?; + let canonical_mtime = std::fs::metadata(canonical_path) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + + if checkout_mtime > canonical_mtime { + if let Some(parent) = canonical_path.parent() { + std::fs::create_dir_all(parent)?; } + crate::sync::atomic_write(canonical_path, &checkout_content)?; } std::fs::remove_file(checkout_file)?; } diff --git a/src/daemon/pid.rs b/src/daemon/pid.rs index 2b8021a..d997aa9 100644 --- a/src/daemon/pid.rs +++ b/src/daemon/pid.rs @@ -20,8 +20,8 @@ pub fn is_process_running(pid: u32) -> bool { if libc::kill(pid as libc::pid_t, 0) == 0 { true } else { - let err = std::io::Error::last_os_error(); - err.kind() != std::io::ErrorKind::NotFound + // EPERM means process exists but we can't signal it + std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) } } } diff --git a/src/github.rs b/src/github.rs index df4d5d1..3e3e6c2 100644 --- a/src/github.rs +++ b/src/github.rs @@ -15,14 +15,14 @@ impl GitHubCli { let (cmd, args): (&str, &[&str]) = if cfg!(target_os = "macos") { ("brew", &["install", "gh"]) } else if cfg!(target_os = "windows") { - ("winget", &["install", "--id", "GitHub.cli", "-e"]) + ("winget", &["install", "--id", "GitHub.cli", "-e", "--disable-interactivity", "--accept-source-agreements", "--accept-package-agreements"]) } else { return Err(anyhow::anyhow!( "Automatic install not supported on this platform. Install gh manually: https://cli.github.com" )); }; - let output = Command::new(cmd) + let output = Command::new(crate::packages::resolve_program(cmd)) .args(args) .output() .await diff --git a/src/security/mod.rs b/src/security/mod.rs index 6fb2b95..dc32aba 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -23,10 +23,15 @@ pub(crate) fn restrict_file_permissions(path: &std::path::Path) -> anyhow::Resul if username.is_empty() { anyhow::bail!("USERNAME not set, cannot restrict permissions on {}", path_str); } + // Remove all ACEs then grant only current user — ensures no other accounts have access let output = std::process::Command::new("icacls") .args([ &*path_str, "/inheritance:r", + "/remove:g", + "*S-1-1-0", + "/remove:g", + "BUILTIN\\Administrators", "/grant:r", &format!("{username}:F"), ]) diff --git a/src/sync/mod.rs b/src/sync/mod.rs index d5be689..9d417ce 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -50,7 +50,7 @@ pub fn check_sync_format_version(sync_path: &Path) -> Result<()> { .map_err(|_| anyhow::anyhow!("Invalid format_version file"))?; if version > CURRENT_SYNC_FORMAT_VERSION { anyhow::bail!( - "Sync repo format version {} is newer than supported ({}). Run: brew upgrade tether", + "Sync repo format version {} is newer than supported ({}). Please update tether.", version, CURRENT_SYNC_FORMAT_VERSION ); @@ -371,7 +371,7 @@ mod tests { ) .unwrap(); let err = check_sync_format_version(tmp.path()).unwrap_err(); - assert!(err.to_string().contains("brew upgrade tether")); + assert!(err.to_string().contains("update tether")); } #[test] From cdbc1ac4495d1af91b1358b66e003504a85850f0 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Feb 2026 16:48:00 +1000 Subject: [PATCH 03/15] fix: tempfile lock prevents schtasks from reading XML on Windows NamedTempFile holds exclusive lock; use into_temp_path() to release before schtasks reads. Also fix platform-specific "launchd" help text. --- src/cli/commands/daemon.rs | 5 +++-- src/cli/commands/mod.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/daemon.rs b/src/cli/commands/daemon.rs index 335a0a2..ea90c48 100644 --- a/src/cli/commands/daemon.rs +++ b/src/cli/commands/daemon.rs @@ -313,13 +313,14 @@ pub async fn install() -> Result<()> { // schtasks expects UTF-16 LE with BOM; use random temp file to avoid symlink attacks let mut xml_file = tempfile::Builder::new().suffix(".xml").tempfile()?; - let xml_path = xml_file.path().to_path_buf(); let utf16: Vec = task_xml.encode_utf16().collect(); let mut bytes = vec![0xFF, 0xFE]; // UTF-16 LE BOM for word in &utf16 { bytes.extend_from_slice(&word.to_le_bytes()); } std::io::Write::write_all(&mut xml_file, &bytes)?; + // Close file handle before schtasks reads it (Windows exclusive lock) + let xml_path = xml_file.into_temp_path(); // Remove existing task if present let _ = Command::new("schtasks") @@ -331,7 +332,7 @@ pub async fn install() -> Result<()> { .arg(&xml_path) .output()?; - drop(xml_file); + drop(xml_path); if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 3300993..d3a3fe8 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -150,9 +150,9 @@ pub enum DaemonAction { Restart, /// View daemon logs Logs, - /// Install launchd service (auto-start on login) + /// Install auto-start service (login trigger) Install, - /// Uninstall launchd service + /// Uninstall auto-start service Uninstall, /// Internal daemon runner #[command(hide = true)] From e70a2e9b55576b93d7716d51b985cc00d3311c88 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Feb 2026 16:58:57 +1000 Subject: [PATCH 04/15] feat: hint when winget is not installed on Windows --- src/cli/commands/packages.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/cli/commands/packages.rs b/src/cli/commands/packages.rs index d428d08..b25f401 100644 --- a/src/cli/commands/packages.rs +++ b/src/cli/commands/packages.rs @@ -35,6 +35,10 @@ pub async fn run(list_only: bool, yes: bool) -> Result<()> { for manager in &managers { if !manager.is_available().await { + #[cfg(windows)] + if manager.name() == "winget" { + Output::info("winget not found — install from https://aka.ms/winget to sync Windows packages"); + } continue; } From 2ce755b052c0a55e21c679567290d728d5507dee Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Feb 2026 17:16:34 +1000 Subject: [PATCH 05/15] fix: winget list parser fails due to \r spinner in output winget emits \r-based progress spinners that end up on the same line as the header. This shifts column offsets, causing empty parse results. Strip content before the last \r on each line before parsing. --- src/packages/winget.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/packages/winget.rs b/src/packages/winget.rs index 48c2a0e..8157811 100644 --- a/src/packages/winget.rs +++ b/src/packages/winget.rs @@ -230,7 +230,11 @@ fn slice_by_display_col(s: &str, start: usize, end: usize) -> &str { /// Header is ASCII so byte offsets == display columns. Data lines are sliced by display width /// to handle non-ASCII package names (e.g., CJK double-width characters). fn parse_winget_list(output: &str) -> Vec { - let lines: Vec<&str> = output.lines().collect(); + // winget emits \r-based progress spinners; take only content after the last \r per line + let lines: Vec<&str> = output + .lines() + .map(|l| l.rsplit('\r').next().unwrap_or(l)) + .collect(); // Find the header line containing "Id" and "Version" let Some(header_idx) = lines From 8b092137fefb5e3245566502a2884f637f3a5353 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Feb 2026 23:30:44 +1000 Subject: [PATCH 06/15] test: add symlink and copy_dir_recursive tests --- src/sync/mod.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 9d417ce..332e1fb 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -388,4 +388,49 @@ mod tests { let err = check_sync_format_version(tmp.path()).unwrap_err(); assert!(err.to_string().contains("Invalid format_version")); } + + #[test] + fn test_create_symlink_file() { + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("source.txt"); + let dst = tmp.path().join("link.txt"); + std::fs::write(&src, "hello").unwrap(); + create_symlink(&src, &dst).unwrap(); + assert_eq!(std::fs::read_to_string(&dst).unwrap(), "hello"); + } + + #[test] + fn test_create_symlink_dir() { + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("srcdir"); + std::fs::create_dir(&src).unwrap(); + std::fs::write(src.join("a.txt"), "aaa").unwrap(); + std::fs::create_dir(src.join("sub")).unwrap(); + std::fs::write(src.join("sub").join("b.txt"), "bbb").unwrap(); + + let dst = tmp.path().join("linkdir"); + create_symlink(&src, &dst).unwrap(); + assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa"); + assert_eq!( + std::fs::read_to_string(dst.join("sub").join("b.txt")).unwrap(), + "bbb" + ); + } + + #[cfg(windows)] + #[test] + fn test_copy_dir_recursive_respects_max_depth() { + let tmp = TempDir::new().unwrap(); + let mut path = tmp.path().join("d0"); + std::fs::create_dir(&path).unwrap(); + for i in 1..=12 { + path = path.join(format!("d{}", i)); + std::fs::create_dir(&path).unwrap(); + } + std::fs::write(path.join("deep.txt"), "deep").unwrap(); + + let dst = tmp.path().join("copy"); + let err = copy_dir_recursive(&tmp.path().join("d0"), &dst, 0).unwrap_err(); + assert!(err.to_string().contains("max depth")); + } } From 94e84890431e46f3135ed07bba9c7a014d871596 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Wed, 11 Feb 2026 12:42:35 +1000 Subject: [PATCH 07/15] style: rustfmt long lines --- src/github.rs | 13 ++++++++++++- src/packages/winget.rs | 18 ++++++++++++++++-- src/security/mod.rs | 5 ++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/github.rs b/src/github.rs index 3e3e6c2..a83003e 100644 --- a/src/github.rs +++ b/src/github.rs @@ -15,7 +15,18 @@ impl GitHubCli { let (cmd, args): (&str, &[&str]) = if cfg!(target_os = "macos") { ("brew", &["install", "gh"]) } else if cfg!(target_os = "windows") { - ("winget", &["install", "--id", "GitHub.cli", "-e", "--disable-interactivity", "--accept-source-agreements", "--accept-package-agreements"]) + ( + "winget", + &[ + "install", + "--id", + "GitHub.cli", + "-e", + "--disable-interactivity", + "--accept-source-agreements", + "--accept-package-agreements", + ], + ) } else { return Err(anyhow::anyhow!( "Automatic install not supported on this platform. Install gh manually: https://cli.github.com" diff --git a/src/packages/winget.rs b/src/packages/winget.rs index 8157811..6e8b9d5 100644 --- a/src/packages/winget.rs +++ b/src/packages/winget.rs @@ -97,7 +97,15 @@ impl PackageManager for WingetManager { for id in package_ids { if !installed_ids.contains(&id.to_lowercase()) { let output = Command::new(super::resolve_program("winget")) - .args(["install", "--id", id, "-e", "--disable-interactivity", "--accept-source-agreements", "--accept-package-agreements"]) + .args([ + "install", + "--id", + id, + "-e", + "--disable-interactivity", + "--accept-source-agreements", + "--accept-package-agreements", + ]) .output() .await?; @@ -149,7 +157,13 @@ impl PackageManager for WingetManager { async fn update_all(&self) -> Result<()> { let output = Command::new(super::resolve_program("winget")) - .args(["upgrade", "--all", "--disable-interactivity", "--accept-source-agreements", "--accept-package-agreements"]) + .args([ + "upgrade", + "--all", + "--disable-interactivity", + "--accept-source-agreements", + "--accept-package-agreements", + ]) .output() .await?; diff --git a/src/security/mod.rs b/src/security/mod.rs index dc32aba..edb93a2 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -21,7 +21,10 @@ pub(crate) fn restrict_file_permissions(path: &std::path::Path) -> anyhow::Resul let path_str = path.to_string_lossy(); let username = std::env::var("USERNAME").unwrap_or_default(); if username.is_empty() { - anyhow::bail!("USERNAME not set, cannot restrict permissions on {}", path_str); + anyhow::bail!( + "USERNAME not set, cannot restrict permissions on {}", + path_str + ); } // Remove all ACEs then grant only current user — ensures no other accounts have access let output = std::process::Command::new("icacls") From fbfd9003de6ff2574336d90d561ce49a41480815 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Mar 2026 09:45:38 +1000 Subject: [PATCH 08/15] fix: prevent duplicate daemon instances and redundant force-kill on Windows Single-instance guard in run_daemon() ensures scheduled task and manual start can't spawn duplicates. Skip second taskkill /F on Windows since terminate_process already force-kills. --- src/cli/commands/daemon.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/daemon.rs b/src/cli/commands/daemon.rs index ea90c48..a41bf07 100644 --- a/src/cli/commands/daemon.rs +++ b/src/cli/commands/daemon.rs @@ -98,7 +98,8 @@ pub async fn stop() -> Result<()> { sleep(Duration::from_millis(200)).await; } - // Force kill if still running (Unix only — redundant on Windows) + // Force kill if still running (Unix only — on Windows terminate_process already uses /F) + #[cfg(unix)] if is_process_running(pid) { log::debug!("Daemon did not exit gracefully, force killing"); force_kill_process(pid); @@ -151,8 +152,22 @@ pub async fn logs() -> Result<()> { } pub async fn run_daemon() -> Result<()> { - let mut server = DaemonServer::new(); + let paths = DaemonPaths::new()?; let pid = std::process::id(); + + // Single-instance guard: exit if another daemon is already running + if let Some(existing_pid) = read_daemon_pid()? { + if existing_pid != pid && is_process_running(existing_pid) { + log::warn!("Another daemon is already running (PID {existing_pid}), exiting"); + return Ok(()); + } + } + + // Write PID file so both `start()` and scheduled task get protection + fs::create_dir_all(&paths.dir)?; + fs::write(&paths.pid, pid.to_string())?; + + let mut server = DaemonServer::new(); log::info!("Daemon process starting (PID {pid})"); let result = server.run().await; if let Err(err) = cleanup_pid_file(Some(pid)) { From 2bdbcf6245a166ffe54da710f50985a3f6e3ac9d Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Mar 2026 09:52:01 +1000 Subject: [PATCH 09/15] fix: O(n*m) package diff and redundant copy cycle on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff_package_lists used linear scan per element; restore HashSet lookup. ensure_symlink deleted and re-copied identical content on Windows copy-mode when checkout was newer — skip the cycle since atomic_write already synced canonical. --- src/cli/commands/diff.rs | 40 +++++++++++++++++++++++++--------------- src/cli/commands/sync.rs | 5 +++++ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index 3f3bc9e..ddccdb7 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -389,26 +389,36 @@ fn diff_package_lists( local: &[&str], case_insensitive: bool, ) -> Vec<(String, String)> { + use std::collections::HashSet; let mut diff = Vec::new(); - let contains = |haystack: &[&str], needle: &str| { - if case_insensitive { - let lower = needle.to_lowercase(); - haystack.iter().any(|s| s.to_lowercase() == lower) - } else { - haystack.contains(&needle) - } - }; + if case_insensitive { + let remote_set: HashSet = remote.iter().map(|s| s.to_lowercase()).collect(); + let local_set: HashSet = local.iter().map(|s| s.to_lowercase()).collect(); - for pkg in local { - if !contains(remote, pkg) { - diff.push((pkg.to_string(), "added".to_string())); + for pkg in local { + if !remote_set.contains(&pkg.to_lowercase()) { + diff.push((pkg.to_string(), "added".to_string())); + } } - } + for pkg in remote { + if !local_set.contains(&pkg.to_lowercase()) { + diff.push((pkg.to_string(), "removed".to_string())); + } + } + } else { + let remote_set: HashSet<&str> = remote.iter().copied().collect(); + let local_set: HashSet<&str> = local.iter().copied().collect(); - for pkg in remote { - if !contains(local, pkg) { - diff.push((pkg.to_string(), "removed".to_string())); + for pkg in local { + if !remote_set.contains(pkg) { + diff.push((pkg.to_string(), "added".to_string())); + } + } + for pkg in remote { + if !local_set.contains(pkg) { + diff.push((pkg.to_string(), "removed".to_string())); + } } } diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 035ccb7..c50c4af 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -790,7 +790,12 @@ fn ensure_symlink(checkout_file: &Path, canonical_path: &Path) -> Result<()> { std::fs::create_dir_all(parent)?; } crate::sync::atomic_write(canonical_path, &checkout_content)?; + // We just wrote checkout content to canonical, so they match. + // On Windows copy-mode, no need to delete and re-copy the same content. + #[cfg(windows)] + return Ok(()); } + // Canonical is newer — delete stale checkout file and re-link/copy below std::fs::remove_file(checkout_file)?; } Err(_) => { From e3ee578e85dc41d5c54343049c8e6ec507dd337f Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Mar 2026 10:17:03 +1000 Subject: [PATCH 10/15] fix: SID-based file permissions, TOCTOU on secrets, locale-safe winget parser - Use whoami /user SID instead of spoofable USERNAME env var for icacls - Write sensitive files to temp, restrict ACLs, then rename into place - Derive winget column positions from separator+header layout instead of hardcoded English column names --- src/daemon/server.rs | 9 +++-- src/packages/winget.rs | 72 +++++++++++++++++++++++++++----------- src/security/keychain.rs | 10 +++--- src/security/mod.rs | 57 ++++++++++++++++++++++++------ src/security/recipients.rs | 20 +++++------ 5 files changed, 117 insertions(+), 51 deletions(-) diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 07dfd7e..f0b29ed 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -771,11 +771,14 @@ fn write_file_secure(path: &Path, contents: &[u8]) -> Result<()> { std::io::Write::write_all(&mut file, contents)?; Ok(()) } - #[cfg(not(unix))] + #[cfg(windows)] + { + crate::security::write_file_secure(path, contents)?; + Ok(()) + } + #[cfg(not(any(unix, windows)))] { std::fs::write(path, contents)?; - #[cfg(windows)] - crate::security::restrict_file_permissions(path)?; Ok(()) } } diff --git a/src/packages/winget.rs b/src/packages/winget.rs index 6e8b9d5..23c90af 100644 --- a/src/packages/winget.rs +++ b/src/packages/winget.rs @@ -240,9 +240,10 @@ fn slice_by_display_col(s: &str, start: usize, end: usize) -> &str { &s[byte_start..byte_end] } -/// Parse `winget list` fixed-width column output by reading column positions from the header. -/// Header is ASCII so byte offsets == display columns. Data lines are sliced by display width -/// to handle non-ASCII package names (e.g., CJK double-width characters). +/// Parse `winget list` fixed-width column output. +/// Column positions are derived from the separator line (dashes with spaces), which is +/// locale-independent. Data lines are sliced by display width to handle non-ASCII +/// package names (e.g., CJK double-width characters). fn parse_winget_list(output: &str) -> Vec { // winget emits \r-based progress spinners; take only content after the last \r per line let lines: Vec<&str> = output @@ -250,29 +251,45 @@ fn parse_winget_list(output: &str) -> Vec { .map(|l| l.rsplit('\r').next().unwrap_or(l)) .collect(); - // Find the header line containing "Id" and "Version" - let Some(header_idx) = lines - .iter() - .position(|l| l.contains("Id") && l.contains("Version")) - else { + // Find the separator line (all dashes). This is locale-independent. + let Some(sep_idx) = lines.iter().position(|l| { + let trimmed = l.trim(); + !trimmed.is_empty() && trimmed.chars().all(|c| c == '-') + }) else { return Vec::new(); }; - let header = lines[header_idx]; - // Header is ASCII, so byte offset == display column - let Some(id_col) = header.find("Id") else { + // The header is the line before the separator. Derive column positions from it + // using display-width columns (not byte offsets) to handle non-ASCII headers. + // Column boundaries: a space followed by a non-space, preceded by at least 2 spaces. + if sep_idx == 0 { return Vec::new(); + } + let header = lines[sep_idx - 1]; + let col_starts: Vec = { + let mut starts = vec![0usize]; // first column always starts at 0 + let chars: Vec = header.chars().collect(); + let mut col = 0; + let mut prev_was_space = false; + let mut prev2_was_space = false; + for &c in &chars { + if c != ' ' && prev_was_space && prev2_was_space { + starts.push(col); + } + prev2_was_space = prev_was_space; + prev_was_space = c == ' '; + col += display_width(c); + } + starts }; - let version_col = header.find("Version").unwrap_or(header.len()); - // Find the separator line (dashes) after header - let data_start = lines - .iter() - .enumerate() - .skip(header_idx + 1) - .find(|(_, l)| l.starts_with('-')) - .map(|(i, _)| i + 1) - .unwrap_or(header_idx + 1); + // Need at least 3 columns: Name, Id, Version + if col_starts.len() < 3 { + return Vec::new(); + } + let id_col = col_starts[1]; + let version_col = col_starts[2]; + let data_start = sep_idx + 1; let mut packages = Vec::new(); for line in lines.iter().skip(data_start) { @@ -349,6 +366,21 @@ Git Git.Git 2.43.0 winget"; assert!(packages.is_empty()); } + #[test] + fn test_parse_winget_list_localized_headers() { + // Japanese locale: headers are translated but separator line is universal + let output = "\ +名前 ID バージョン 利用可能 ソース +----------------------------------------------------------------------------------------------- +Git Git.Git 2.43.0 2.44.0 winget +Visual Studio Code Microsoft.VisualStudioCode 1.87.0 winget"; + + let packages = parse_winget_list(output); + assert_eq!(packages.len(), 2); + assert_eq!(packages[0].name, "Git.Git"); + assert_eq!(packages[1].name, "Microsoft.VisualStudioCode"); + } + #[test] fn test_parse_winget_list_non_ascii_names() { // CJK chars are double-width in terminal display; winget aligns by display columns. diff --git a/src/security/keychain.rs b/src/security/keychain.rs index d9378c2..ed6d8fe 100644 --- a/src/security/keychain.rs +++ b/src/security/keychain.rs @@ -69,12 +69,10 @@ fn cache_key(key: &[u8]) -> Result<()> { .open(&path)?; file.write_all(key)?; } - #[cfg(not(unix))] - { - fs::write(&path, key)?; - #[cfg(windows)] - super::restrict_file_permissions(&path)?; - } + #[cfg(windows)] + super::write_file_secure(&path, key)?; + #[cfg(not(any(unix, windows)))] + fs::write(&path, key)?; Ok(()) } diff --git a/src/security/mod.rs b/src/security/mod.rs index edb93a2..ee8ad70 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -15,18 +15,30 @@ pub use recipients::{ }; pub use secrets::{scan_for_secrets, SecretFinding, SecretType}; -/// Restrict file permissions to current user only (Windows equivalent of chmod 600) +/// Write sensitive data to a file with restricted permissions, avoiding TOCTOU. +/// On Unix: opens with mode 0o600 atomically. +/// On Windows: writes to a temp file, restricts ACLs, then renames into place. +#[cfg(windows)] +pub(crate) fn write_file_secure( + path: &std::path::Path, + contents: &[u8], +) -> anyhow::Result<()> { + let dir = path.parent().unwrap_or(path); + std::fs::create_dir_all(dir)?; + let tmp = tempfile::NamedTempFile::new_in(dir)?; + std::io::Write::write_all(&mut &*tmp.as_file(), contents)?; + restrict_file_permissions(tmp.path())?; + tmp.persist(path)?; + Ok(()) +} + +/// Restrict file permissions to current user only (Windows equivalent of chmod 600). +/// Uses the current user's SID from `whoami /user` to avoid env var spoofing. #[cfg(windows)] pub(crate) fn restrict_file_permissions(path: &std::path::Path) -> anyhow::Result<()> { let path_str = path.to_string_lossy(); - let username = std::env::var("USERNAME").unwrap_or_default(); - if username.is_empty() { - anyhow::bail!( - "USERNAME not set, cannot restrict permissions on {}", - path_str - ); - } - // Remove all ACEs then grant only current user — ensures no other accounts have access + let sid = current_user_sid()?; + // Remove inherited ACEs, strip Everyone and Admins, grant only current user's SID let output = std::process::Command::new("icacls") .args([ &*path_str, @@ -36,7 +48,7 @@ pub(crate) fn restrict_file_permissions(path: &std::path::Path) -> anyhow::Resul "/remove:g", "BUILTIN\\Administrators", "/grant:r", - &format!("{username}:F"), + &format!("*{sid}:F"), ]) .output()?; if !output.status.success() { @@ -45,3 +57,28 @@ pub(crate) fn restrict_file_permissions(path: &std::path::Path) -> anyhow::Resul } Ok(()) } + +/// Get the current user's SID via `whoami /user /fo csv /nh`. +/// Returns a SID string like "S-1-5-21-...". Not spoofable via env vars. +#[cfg(windows)] +fn current_user_sid() -> anyhow::Result { + let output = std::process::Command::new("whoami") + .args(["/user", "/fo", "csv", "/nh"]) + .output()?; + if !output.status.success() { + anyhow::bail!("whoami /user failed"); + } + // Output format: "DOMAIN\user","S-1-5-21-..." + let stdout = String::from_utf8_lossy(&output.stdout); + let sid = stdout + .trim() + .rsplit(',') + .next() + .and_then(|s| s.trim().strip_prefix('"')) + .and_then(|s| s.strip_suffix('"')) + .ok_or_else(|| anyhow::anyhow!("Failed to parse SID from whoami output: {}", stdout))?; + if !sid.starts_with("S-") { + anyhow::bail!("Invalid SID format: {}", sid); + } + Ok(sid.to_string()) +} diff --git a/src/security/recipients.rs b/src/security/recipients.rs index c0050f2..c09de84 100644 --- a/src/security/recipients.rs +++ b/src/security/recipients.rs @@ -67,12 +67,10 @@ pub fn store_identity(identity: &age::x25519::Identity, passphrase: &str) -> Res .open(&path)?; file.write_all(&encrypted)?; } - #[cfg(not(unix))] - { - fs::write(&path, &encrypted)?; - #[cfg(windows)] - super::restrict_file_permissions(&path)?; - } + #[cfg(windows)] + super::write_file_secure(&path, &encrypted)?; + #[cfg(not(any(unix, windows)))] + fs::write(&path, &encrypted)?; // Also store public key for easy sharing let pubkey = identity.to_public().to_string(); @@ -148,12 +146,10 @@ fn cache_identity(identity: &age::x25519::Identity) -> Result<()> { .open(&path)?; file.write_all(identity_str.expose_secret().as_bytes())?; } - #[cfg(not(unix))] - { - fs::write(&path, identity_str.expose_secret())?; - #[cfg(windows)] - super::restrict_file_permissions(&path)?; - } + #[cfg(windows)] + super::write_file_secure(&path, identity_str.expose_secret().as_bytes())?; + #[cfg(not(any(unix, windows)))] + fs::write(&path, identity_str.expose_secret())?; Ok(()) } From 3715c091f95b20cdd9cf6b0eb0f2d1b8b890db61 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Mar 2026 10:26:44 +1000 Subject: [PATCH 11/15] fix: locale-safe icacls, sync_all before persist, symlink upgrade support - Use well-known SID *S-1-5-32-544 instead of localized BUILTIN\Administrators group name in icacls - sync_all() before persist() in write_file_secure for crash durability - Allow copy-mode files to upgrade to symlinks when Developer Mode is enabled later (symlinks_available probe) --- src/cli/commands/sync.rs | 9 ++++++--- src/security/mod.rs | 5 +++-- src/sync/mod.rs | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index c50c4af..2f8afc8 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -790,10 +790,13 @@ fn ensure_symlink(checkout_file: &Path, canonical_path: &Path) -> Result<()> { std::fs::create_dir_all(parent)?; } crate::sync::atomic_write(canonical_path, &checkout_content)?; - // We just wrote checkout content to canonical, so they match. - // On Windows copy-mode, no need to delete and re-copy the same content. + // Content now matches. On Windows without symlink support, + // skip the delete+re-copy cycle. If symlinks are available, + // fall through to upgrade the copy to a proper symlink. #[cfg(windows)] - return Ok(()); + if !crate::sync::symlinks_available() { + return Ok(()); + } } // Canonical is newer — delete stale checkout file and re-link/copy below std::fs::remove_file(checkout_file)?; diff --git a/src/security/mod.rs b/src/security/mod.rs index ee8ad70..25e2c05 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -26,7 +26,8 @@ pub(crate) fn write_file_secure( let dir = path.parent().unwrap_or(path); std::fs::create_dir_all(dir)?; let tmp = tempfile::NamedTempFile::new_in(dir)?; - std::io::Write::write_all(&mut &*tmp.as_file(), contents)?; + std::io::Write::write_all(&mut tmp.as_file(), contents)?; + tmp.as_file().sync_all()?; restrict_file_permissions(tmp.path())?; tmp.persist(path)?; Ok(()) @@ -46,7 +47,7 @@ pub(crate) fn restrict_file_permissions(path: &std::path::Path) -> anyhow::Resul "/remove:g", "*S-1-1-0", "/remove:g", - "BUILTIN\\Administrators", + "*S-1-5-32-544", "/grant:r", &format!("*{sid}:F"), ]) diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 332e1fb..c482d3e 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -193,6 +193,25 @@ pub fn expand_from_sync_repo(pattern: &str, dotfiles_dir: &Path) -> Vec } } +/// Check if symlinks are available on this platform. +#[cfg(unix)] +pub fn symlinks_available() -> bool { + true +} + +#[cfg(windows)] +pub fn symlinks_available() -> bool { + // Try creating a symlink in temp to test Developer Mode / privilege + let dir = std::env::temp_dir(); + let src = dir.join(".tether_symlink_probe_src"); + let dst = dir.join(".tether_symlink_probe_dst"); + let _ = std::fs::write(&src, ""); + let ok = std::os::windows::fs::symlink_file(&src, &dst).is_ok(); + let _ = std::fs::remove_file(&dst); + let _ = std::fs::remove_file(&src); + ok +} + /// Create a symlink. On Windows, falls back to copy if Developer Mode is not enabled. #[cfg(unix)] pub fn create_symlink(src: &Path, dst: &Path) -> Result<()> { From 81494bcfa8fd0e04110a8d38794902541f2a220f Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Mar 2026 10:36:32 +1000 Subject: [PATCH 12/15] fix: cache symlink probe result and use PID-unique filenames OnceLock avoids repeated temp file create/delete per sync cycle. PID suffix prevents races between concurrent tether processes. --- src/sync/mod.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/sync/mod.rs b/src/sync/mod.rs index c482d3e..7cfbf5b 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -194,6 +194,7 @@ pub fn expand_from_sync_repo(pattern: &str, dotfiles_dir: &Path) -> Vec } /// Check if symlinks are available on this platform. +/// Result is cached for the lifetime of the process. #[cfg(unix)] pub fn symlinks_available() -> bool { true @@ -201,15 +202,19 @@ pub fn symlinks_available() -> bool { #[cfg(windows)] pub fn symlinks_available() -> bool { - // Try creating a symlink in temp to test Developer Mode / privilege - let dir = std::env::temp_dir(); - let src = dir.join(".tether_symlink_probe_src"); - let dst = dir.join(".tether_symlink_probe_dst"); - let _ = std::fs::write(&src, ""); - let ok = std::os::windows::fs::symlink_file(&src, &dst).is_ok(); - let _ = std::fs::remove_file(&dst); - let _ = std::fs::remove_file(&src); - ok + use std::sync::OnceLock; + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| { + let dir = std::env::temp_dir(); + let id = std::process::id(); + let src = dir.join(format!(".tether_symlink_probe_{id}_src")); + let dst = dir.join(format!(".tether_symlink_probe_{id}_dst")); + let _ = std::fs::write(&src, ""); + let ok = std::os::windows::fs::symlink_file(&src, &dst).is_ok(); + let _ = std::fs::remove_file(&dst); + let _ = std::fs::remove_file(&src); + ok + }) } /// Create a symlink. On Windows, falls back to copy if Developer Mode is not enabled. From 80df3dc906797904fa69b293944d9835fd7e53c8 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Tue, 10 Mar 2026 10:41:32 +1000 Subject: [PATCH 13/15] chore: remove dead force_kill_process Windows variant terminate_process already uses taskkill /F; the force_kill path is unix-only so the Windows impl was unreachable. --- src/cli/commands/daemon.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/cli/commands/daemon.rs b/src/cli/commands/daemon.rs index a41bf07..78eb015 100644 --- a/src/cli/commands/daemon.rs +++ b/src/cli/commands/daemon.rs @@ -209,14 +209,6 @@ fn force_kill_process(pid: u32) { unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; } -#[cfg(windows)] -fn force_kill_process(pid: u32) { - use std::process::Command; - let _ = Command::new("taskkill") - .args(["/F", "/PID", &pid.to_string()]) - .output(); -} - fn cleanup_pid_file(expected_pid: Option) -> Result<()> { let paths = DaemonPaths::new()?; if !paths.pid.exists() { From 0c65b96c1b8239e8ef94701266afe64c5524eb41 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Sun, 29 Mar 2026 12:31:34 +1000 Subject: [PATCH 14/15] style: rustfmt --- src/security/mod.rs | 5 +---- src/sync/mod.rs | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/security/mod.rs b/src/security/mod.rs index 25e2c05..597497e 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -19,10 +19,7 @@ pub use secrets::{scan_for_secrets, SecretFinding, SecretType}; /// On Unix: opens with mode 0o600 atomically. /// On Windows: writes to a temp file, restricts ACLs, then renames into place. #[cfg(windows)] -pub(crate) fn write_file_secure( - path: &std::path::Path, - contents: &[u8], -) -> anyhow::Result<()> { +pub(crate) fn write_file_secure(path: &std::path::Path, contents: &[u8]) -> anyhow::Result<()> { let dir = path.parent().unwrap_or(path); std::fs::create_dir_all(dir)?; let tmp = tempfile::NamedTempFile::new_in(dir)?; diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 0ec5eba..e9c4a6f 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -488,7 +488,6 @@ pub fn migrate_dotfile_shared_change( } } - /// Atomically write content to a file by writing to a temp file and renaming. /// This prevents file corruption from interrupted writes. pub fn atomic_write(path: &Path, content: &[u8]) -> Result<()> { From 587f37696801c341fb117481b60e1aeb9a313e7b Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Sun, 29 Mar 2026 22:53:21 +1000 Subject: [PATCH 15/15] fix: address review issues in Windows support - team cleanup removes copied files, not just symlinks - write_decrypted restricts ACLs on Windows (was default perms) - cache current_user_sid in OnceLock (was shelling out every call) - extract winget_cmd() builder to deduplicate Command construction - extract generate_schtasks_xml() with tests for XML escaping - add tests for write_file_secure, restrict_file_permissions, copy_dir_recursive - allow clippy::too_many_arguments on status::render --- src/cli/commands/daemon.rs | 91 ++++++++++++++++++++++----------- src/cli/commands/sync.rs | 6 ++- src/dashboard/widgets/status.rs | 1 + src/packages/winget.rs | 91 ++++++++++++++++----------------- src/security/mod.rs | 43 +++++++++++++++- src/sync/mod.rs | 22 ++++++++ src/sync/team.rs | 13 ++++- 7 files changed, 186 insertions(+), 81 deletions(-) diff --git a/src/cli/commands/daemon.rs b/src/cli/commands/daemon.rs index 45e5433..e84cb34 100644 --- a/src/cli/commands/daemon.rs +++ b/src/cli/commands/daemon.rs @@ -292,37 +292,7 @@ pub async fn install() -> Result<()> { } let exe = std::env::current_exe()?; - let exe_escaped = exe - .display() - .to_string() - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """); - // XML task definition enables RestartOnFailure (equivalent to macOS KeepAlive) - let task_xml = format!( - r#" - - - true - - - - PT1M - 999 - - PT0S - false - false - - - - {exe_escaped} - daemon run - - -"# - ); + let task_xml = generate_schtasks_xml(&exe); // schtasks expects UTF-16 LE with BOM; use random temp file to avoid symlink attacks let mut xml_file = tempfile::Builder::new().suffix(".xml").tempfile()?; @@ -473,3 +443,62 @@ pub async fn uninstall() -> Result<()> { Ok(()) } } + +#[cfg(windows)] +fn generate_schtasks_xml(exe: &std::path::Path) -> String { + let exe_escaped = exe + .display() + .to_string() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """); + format!( + r#" + + + true + + + + PT1M + 999 + + PT0S + false + false + + + + {exe_escaped} + daemon run + + +"# + ) +} + +#[cfg(test)] +mod tests { + #[cfg(windows)] + #[test] + fn test_generate_schtasks_xml_valid() { + let xml = super::generate_schtasks_xml(std::path::Path::new( + r"C:\Program Files\tether\tether.exe", + )); + assert!(xml.contains(r#"encoding="UTF-16""#)); + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains(r"C:\Program Files\tether\tether.exe")); + assert!(xml.contains("daemon run")); + } + + #[cfg(windows)] + #[test] + fn test_generate_schtasks_xml_escapes_special_chars() { + let xml = super::generate_schtasks_xml(std::path::Path::new(r#"C:\a&b"d"#)); + assert!(xml.contains(r"C:\a&b<c>"d")); + // Must not contain raw special chars inside XML element + assert!(!xml.contains("C:\\a&b")); + } +} diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 55597d4..a4aae7d 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -650,7 +650,7 @@ fn preserve_executable_bit(source: &Path, dest: &Path) { } } -/// Write decrypted content with secure permissions (0o600 on Unix) +/// Write decrypted content with secure permissions (0o600 on Unix, restricted ACL on Windows) fn write_decrypted(path: &Path, contents: &[u8]) -> Result<()> { std::fs::write(path, contents)?; #[cfg(unix)] @@ -658,6 +658,10 @@ fn write_decrypted(path: &Path, contents: &[u8]) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; } + #[cfg(windows)] + { + crate::security::restrict_file_permissions(path)?; + } Ok(()) } diff --git a/src/dashboard/widgets/status.rs b/src/dashboard/widgets/status.rs index f4a1fc4..d1fa182 100644 --- a/src/dashboard/widgets/status.rs +++ b/src/dashboard/widgets/status.rs @@ -8,6 +8,7 @@ pub enum FlashMessage<'a> { Success(&'a str), } +#[allow(clippy::too_many_arguments)] pub fn render( f: &mut Frame, area: Rect, diff --git a/src/packages/winget.rs b/src/packages/winget.rs index 23c90af..5df5dcd 100644 --- a/src/packages/winget.rs +++ b/src/packages/winget.rs @@ -10,11 +10,14 @@ impl WingetManager { Self } + fn winget_cmd(args: &[&str]) -> Command { + let mut cmd = Command::new(super::resolve_program("winget")); + cmd.args(args); + cmd + } + async fn run_winget(&self, args: &[&str]) -> Result { - let output = Command::new(super::resolve_program("winget")) - .args(args) - .output() - .await?; + let output = Self::winget_cmd(args).output().await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -96,18 +99,17 @@ impl PackageManager for WingetManager { for id in package_ids { if !installed_ids.contains(&id.to_lowercase()) { - let output = Command::new(super::resolve_program("winget")) - .args([ - "install", - "--id", - id, - "-e", - "--disable-interactivity", - "--accept-source-agreements", - "--accept-package-agreements", - ]) - .output() - .await?; + let output = Self::winget_cmd(&[ + "install", + "--id", + id, + "-e", + "--disable-interactivity", + "--accept-source-agreements", + "--accept-package-agreements", + ]) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -134,16 +136,15 @@ impl PackageManager for WingetManager { for pkg in installed { if !desired.contains(&pkg.name.to_lowercase()) { - let output = Command::new(super::resolve_program("winget")) - .args([ - "uninstall", - "--id", - &pkg.name, - "-e", - "--disable-interactivity", - ]) - .output() - .await?; + let output = Self::winget_cmd(&[ + "uninstall", + "--id", + &pkg.name, + "-e", + "--disable-interactivity", + ]) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -156,16 +157,15 @@ impl PackageManager for WingetManager { } async fn update_all(&self) -> Result<()> { - let output = Command::new(super::resolve_program("winget")) - .args([ - "upgrade", - "--all", - "--disable-interactivity", - "--accept-source-agreements", - "--accept-package-agreements", - ]) - .output() - .await?; + let output = Self::winget_cmd(&[ + "upgrade", + "--all", + "--disable-interactivity", + "--accept-source-agreements", + "--accept-package-agreements", + ]) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -176,16 +176,15 @@ impl PackageManager for WingetManager { } async fn uninstall(&self, package: &str) -> Result<()> { - let output = Command::new(super::resolve_program("winget")) - .args([ - "uninstall", - "--id", - package, - "-e", - "--disable-interactivity", - ]) - .output() - .await?; + let output = Self::winget_cmd(&[ + "uninstall", + "--id", + package, + "-e", + "--disable-interactivity", + ]) + .output() + .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src/security/mod.rs b/src/security/mod.rs index 597497e..4f9f7cd 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -57,9 +57,14 @@ pub(crate) fn restrict_file_permissions(path: &std::path::Path) -> anyhow::Resul } /// Get the current user's SID via `whoami /user /fo csv /nh`. -/// Returns a SID string like "S-1-5-21-...". Not spoofable via env vars. +/// Returns a SID string like "S-1-5-21-...". Cached after first call. #[cfg(windows)] fn current_user_sid() -> anyhow::Result { + use std::sync::OnceLock; + static SID: OnceLock = OnceLock::new(); + if let Some(sid) = SID.get() { + return Ok(sid.clone()); + } let output = std::process::Command::new("whoami") .args(["/user", "/fo", "csv", "/nh"]) .output()?; @@ -78,5 +83,39 @@ fn current_user_sid() -> anyhow::Result { if !sid.starts_with("S-") { anyhow::bail!("Invalid SID format: {}", sid); } - Ok(sid.to_string()) + let sid = sid.to_string(); + let _ = SID.set(sid.clone()); + Ok(sid) +} + +#[cfg(test)] +mod tests { + #[cfg(windows)] + #[test] + fn test_current_user_sid() { + let sid = super::current_user_sid().unwrap(); + assert!(sid.starts_with("S-1-5-")); + // Cached: second call returns same value + assert_eq!(sid, super::current_user_sid().unwrap()); + } + + #[cfg(windows)] + #[test] + fn test_write_file_secure_creates_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("secret.txt"); + super::write_file_secure(&path, b"sensitive data").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"sensitive data"); + } + + #[cfg(windows)] + #[test] + fn test_restrict_file_permissions() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("restricted.txt"); + std::fs::write(&path, "test").unwrap(); + super::restrict_file_permissions(&path).unwrap(); + // Verify file is still readable by current user + assert_eq!(std::fs::read_to_string(&path).unwrap(), "test"); + } } diff --git a/src/sync/mod.rs b/src/sync/mod.rs index e9c4a6f..11b7e71 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -754,6 +754,28 @@ mod tests { assert!(err.to_string().contains("max depth")); } + #[cfg(windows)] + #[test] + fn test_copy_dir_recursive_preserves_content() { + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src"); + std::fs::create_dir(&src).unwrap(); + std::fs::write(src.join("a.txt"), "aaa").unwrap(); + std::fs::create_dir(src.join("sub")).unwrap(); + std::fs::write(src.join("sub").join("b.txt"), "bbb").unwrap(); + // empty dir + std::fs::create_dir(src.join("empty")).unwrap(); + + let dst = tmp.path().join("dst"); + copy_dir_recursive(&src, &dst, 0).unwrap(); + assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa"); + assert_eq!( + std::fs::read_to_string(dst.join("sub").join("b.txt")).unwrap(), + "bbb" + ); + assert!(dst.join("empty").is_dir()); + } + /// Helper: build a v1-style Config then migrate to v2, returning the migrated config. fn make_migrated_config( dotfiles: Vec, diff --git a/src/sync/team.rs b/src/sync/team.rs index ac2c7bc..69239c9 100644 --- a/src/sync/team.rs +++ b/src/sync/team.rs @@ -99,9 +99,20 @@ impl TeamManifest { if let Some(team_symlinks) = self.symlinks.get(team) { for target_str in team_symlinks.keys() { let target = PathBuf::from(target_str); - if target.exists() && target.is_symlink() { + if target.is_symlink() { std::fs::remove_file(&target) .with_context(|| format!("Failed to remove symlink: {}", target_str))?; + } else if target.exists() { + // On Windows without Developer Mode, create_symlink falls back to copy + if target.is_dir() { + std::fs::remove_dir_all(&target).with_context(|| { + format!("Failed to remove copied dir: {}", target_str) + })?; + } else { + std::fs::remove_file(&target).with_context(|| { + format!("Failed to remove copied file: {}", target_str) + })?; + } } } }