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 545d0b9..a0627f1 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.11.8] - 2026-03-09 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 5e94a2c..15c8978 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ env_logger = "0.11" # IPC for daemon interprocess = "2.3" + # Async trait async-trait = "0.1" @@ -82,8 +83,7 @@ glob = "0.3" # Text diffing similar = "2.6" -# System signals -libc = "0.2" +# Temp files tempfile = "3.8" # File locking @@ -93,6 +93,10 @@ fs2 = "0.4" ratatui = "0.30" crossterm = "0.29" +# 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..3c5b0b2 --- /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 /F` (no graceful signal for detached processes) | +| 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 b503d87..0682908 100644 --- a/src/cli/commands/config.rs +++ b/src/cli/commands/config.rs @@ -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() } diff --git a/src/cli/commands/daemon.rs b/src/cli/commands/daemon.rs index 1af5755..e84cb34 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,28 +86,24 @@ 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.raw_os_error() != Some(libc::ESRCH) { - return Err(anyhow::anyhow!("Failed to stop daemon: {}", err)); - } - } + 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 — on Windows terminate_process already uses /F) + #[cfg(unix)] 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 { if !is_process_running(pid) { break; @@ -112,7 +115,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() )); } @@ -149,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})"); // Write PID file so dashboard/CLI can detect the running daemon @@ -165,28 +182,37 @@ 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 { - // ESRCH = no such process - io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) +#[cfg(windows)] +fn terminate_process(pid: u32) -> Result<()> { + use std::process::Command; + // Detached/console-less processes can't receive WM_CLOSE, so use /F directly. + let output = Command::new("taskkill") + .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!("taskkill failed: {}", stderr.trim()); } } + Ok(()) +} + +#[cfg(unix)] +fn force_kill_process(pid: u32) { + unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; } fn cleanup_pid_file(expected_pid: Option) -> Result<()> { @@ -206,8 +232,10 @@ 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 = crate::home_dir()?; Ok(home @@ -216,6 +244,7 @@ fn launchd_plist_path() -> Result { .join(format!("{LAUNCHD_LABEL}.plist"))) } +#[cfg(target_os = "macos")] fn generate_plist() -> Result { let exe = std::env::current_exe()?; let paths = DaemonPaths::new()?; @@ -253,11 +282,60 @@ 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 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()?; + 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") + .args(["/Delete", "/TN", "TetherDaemon", "/F"]) + .output(); + + let output = Command::new("schtasks") + .args(["/Create", "/TN", "TetherDaemon", "/XML"]) + .arg(&xml_path) + .output()?; + + drop(xml_path); + + 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")] @@ -308,9 +386,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")] @@ -340,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/diff.rs b/src/cli/commands/diff.rs index f1f956e..6b390e5 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -184,6 +184,7 @@ fn show_dotfile_diff( async fn show_package_diff(config: &Config, sync_path: &std::path::Path) -> Result<()> { use crate::packages::{ BrewManager, BunManager, GemManager, NpmManager, PackageManager, PnpmManager, UvManager, + WingetManager, }; let manifests_dir = sync_path.join("manifests"); @@ -267,7 +268,7 @@ async fn show_package_diff(config: &Config, sync_path: &std::path::Path) -> Resu 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, false); if !diff.is_empty() { has_diff = true; println!("{}", format!("{}:", label).bright_cyan().bold()); @@ -283,6 +284,38 @@ async fn show_package_diff(config: &Config, sync_path: &std::path::Path) -> Resu } } + // 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, true); + if !diff.is_empty() { + has_diff = true; + println!("{}", "winget:".bright_cyan().bold()); + for (pkg, status) in diff { + let symbol = match status.as_str() { + "added" => "+", + "removed" => "-", + _ => "~", + }; + Output::diff_line(symbol, &pkg, &status); + } + println!(); + } + } + } + } + if !has_diff { println!( "{} {}", @@ -354,21 +387,41 @@ 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)> { use std::collections::HashSet; - let remote_set: HashSet<&str> = remote.iter().copied().collect(); - let local_set: HashSet<&str> = local.iter().copied().collect(); let mut diff = Vec::new(); - for pkg in local { - if !remote_set.contains(pkg) { - diff.push((pkg.to_string(), "added".to_string())); + 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 !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 !local_set.contains(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/init.rs b/src/cli/commands/init.rs index 6979f09..02f9d9f 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -419,7 +419,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/mod.rs b/src/cli/commands/mod.rs index d8f0db3..07039c1 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -167,9 +167,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)] diff --git a/src/cli/commands/packages.rs b/src/cli/commands/packages.rs index a57d0cb..b25f401 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 @@ -34,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; } diff --git a/src/cli/commands/status.rs b/src/cli/commands/status.rs index be32779..0726f35 100644 --- a/src/cli/commands/status.rs +++ b/src/cli/commands/status.rs @@ -1,6 +1,7 @@ use crate::cli::output::relative_time; use crate::cli::Output; use crate::config::Config; +use crate::daemon::pid::{is_process_running, read_daemon_pid}; use crate::sync::{ConflictState, SyncState}; use anyhow::Result; use owo_colors::OwoColorize; @@ -225,26 +226,3 @@ pub async fn run() -> Result<()> { println!(); Ok(()) } - -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 { - return true; - } - // ESRCH = no such process, EPERM = exists but no permission - std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) - } -} diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index f84265c..a4aae7d 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::{ @@ -649,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)] @@ -657,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(()) } @@ -1079,8 +1084,6 @@ pub fn prompt_new_items( /// 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)?; } @@ -1105,24 +1108,35 @@ 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)?; + // 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)] + 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)?; } Err(_) => { @@ -1138,7 +1152,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(()) } @@ -1531,7 +1545,8 @@ pub 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)); @@ -1680,8 +1695,11 @@ pub 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 @@ -1807,6 +1825,19 @@ pub async fn build_machine_state( } } + // 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( + "winget".to_string(), + packages.iter().map(|p| p.name.clone()).collect(), + ); + } + } + } + // Detect removed packages: packages that were in previous state but not installed now detect_removed_packages(&mut machine_state, &previous_packages); diff --git a/src/cli/commands/upgrade.rs b/src/cli/commands/upgrade.rs index 945c379..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,6 +17,7 @@ pub async fn run() -> Result<()> { Box::new(BunManager::new()), Box::new(GemManager::new()), Box::new(UvManager::new()), + Box::new(WingetManager::new()), ]; // Determine which managers are available and have packages diff --git a/src/config.rs b/src/config.rs index 02591a8..e403d7b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -125,6 +125,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")] @@ -137,6 +142,8 @@ pub struct PackagesConfig { pub gem: GemConfig, #[serde(default)] pub uv: UvConfig, + #[serde(default)] + pub winget: WingetConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -185,6 +192,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, @@ -278,13 +299,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; } @@ -385,6 +413,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() } @@ -398,6 +428,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(), @@ -445,10 +483,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(); @@ -747,6 +785,7 @@ impl Config { "bun" => self.packages.bun.enabled, "gem" => self.packages.gem.enabled, "uv" => self.packages.uv.enabled, + "winget" => self.packages.winget.enabled, _ => true, }; if !global_enabled { @@ -844,11 +883,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 ); } @@ -942,6 +987,8 @@ impl Default for Config { }, packages: PackagesConfig { remove_unlisted: false, + cross_platform_sync: false, + mapping: Vec::new(), brew: BrewConfig { enabled: true, sync_casks: true, @@ -964,6 +1011,7 @@ impl Default for Config { sync_versions: false, }, uv: UvConfig::default(), + winget: WingetConfig::default(), }, dotfiles: DotfilesConfig { files: vec![ @@ -1047,6 +1095,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] @@ -1088,6 +1145,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..d997aa9 --- /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 { + // EPERM means process exists but we can't signal it + std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) + } + } +} + +#[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 dfe9cb5..9bbb388 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -1,6 +1,7 @@ use crate::config::Config; use crate::packages::{ BrewManager, BunManager, GemManager, NpmManager, PackageManager, PnpmManager, UvManager, + WingetManager, }; use crate::sync::{ import_packages, notify_deferred_casks, GitBackend, MachineState, SyncEngine, SyncState, @@ -608,6 +609,10 @@ impl DaemonServer { (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 { diff --git a/src/dashboard/config_edit.rs b/src/dashboard/config_edit.rs index cb0085b..a3dd83f 100644 --- a/src/dashboard/config_edit.rs +++ b/src/dashboard/config_edit.rs @@ -1,4 +1,6 @@ -use crate::config::{is_safe_dotfile_path, Config, ConflictStrategy, DotfileEntry}; +use crate::config::{ + is_safe_dotfile_path, Config, ConflictStrategy, DotfileEntry, ProfileDotfileEntry, +}; use std::sync::LazyLock; #[derive(Clone, Copy, PartialEq)] @@ -408,10 +410,8 @@ pub fn toggle_dotfile_create(config: &mut Config, index: usize) -> bool { config.save().is_ok() } -/// Toggle shared flag for a profile dotfile by path. Returns false on failure. +/// Toggle the shared flag on a profile dotfile entry. pub fn toggle_profile_dotfile_shared(config: &mut Config, machine_id: &str, path: &str) -> bool { - use crate::config::ProfileDotfileEntry; - let profile_name = config.profile_name(machine_id).to_string(); let profile = match config.profiles.get_mut(&profile_name) { Some(p) => p, @@ -431,6 +431,41 @@ pub fn toggle_profile_dotfile_shared(config: &mut Config, machine_id: &str, path config.save().is_ok() } +/// Add a dotfile to the machine's profile. Returns false on unsafe path, duplicate, or save failure. +pub fn add_profile_dotfile(config: &mut Config, machine_id: &str, path: &str) -> bool { + let path = path.trim(); + if path.is_empty() || !is_safe_dotfile_path(path) { + return false; + } + let profile_name = config.profile_name(machine_id).to_string(); + let profile = match config.profiles.get_mut(&profile_name) { + Some(p) => p, + None => return false, + }; + if profile.dotfiles.iter().any(|e| e.path() == path) { + return false; + } + profile + .dotfiles + .push(ProfileDotfileEntry::Simple(path.to_string())); + config.save().is_ok() +} + +/// Remove a dotfile from the machine's profile by path. Returns false if not found or save failure. +pub fn remove_profile_dotfile(config: &mut Config, machine_id: &str, path: &str) -> bool { + let profile_name = config.profile_name(machine_id).to_string(); + let profile = match config.profiles.get_mut(&profile_name) { + Some(p) => p, + None => return false, + }; + let before = profile.dotfiles.len(); + profile.dotfiles.retain(|e| e.path() != path); + if profile.dotfiles.len() == before { + return false; + } + config.save().is_ok() +} + /// Validate interval format: number followed by s/m/h (e.g. "5m", "30s", "1h") fn is_valid_interval(val: &str) -> bool { if val.len() < 2 { diff --git a/src/dashboard/state.rs b/src/dashboard/state.rs index ce0d86c..b85ea21 100644 --- a/src/dashboard/state.rs +++ b/src/dashboard/state.rs @@ -41,46 +41,11 @@ impl DashboardState { } fn check_daemon() -> (Option, bool) { - // Try PID file first - if let Ok(dir) = Config::config_dir() { - let pid_path = dir.join("daemon.pid"); - if let Ok(contents) = std::fs::read_to_string(&pid_path) { - if let Ok(pid) = contents.trim().parse::() { - if pid > 0 { - let running = unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }; - if running { - return (Some(pid), true); - } - } - } - } + use crate::daemon::pid::{is_process_running, read_daemon_pid}; + match read_daemon_pid() { + Ok(Some(pid)) => (Some(pid), is_process_running(pid)), + _ => (None, false), } - - // Fallback: check launchd (handles missing/stale PID file) - #[cfg(target_os = "macos")] - { - if let Ok(output) = std::process::Command::new("launchctl") - .args(["list", "com.tether.daemon"]) - .output() - { - if output.status.success() { - // Parse PID from first line: "PID\tStatus\tLabel" or "{" for JSON - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(first) = stdout.lines().next() { - // launchctl list