From d6acc395e00853c305b022f007c20e634c4c34db Mon Sep 17 00:00:00 2001 From: nicholai Date: Thu, 17 Sep 2026 04:50:58 -0600 Subject: [PATCH 1/5] Add full macOS compatibility --- bun.lock | 2 +- src/config_editor.rs | 90 ++--- src/conflicts.rs | 3 +- src/filesystem.rs | 530 ++++++++++++++++++++---------- src/harness.rs | 17 +- src/main.rs | 24 +- src/recovery.rs | 24 +- src/repository.rs | 34 +- src/tui.rs | 29 +- src/worker.rs | 3 +- tests/cli_e2e.test.ts | 2 +- tests/config_edit.test.ts | 13 +- tests/macos_compat.test.ts | 68 ++++ tests/pty_runner.py | 61 ++++ tests/tui_e2e.test.ts | 8 +- tests/tui_failure_cleanup.test.ts | 26 +- 16 files changed, 661 insertions(+), 273 deletions(-) create mode 100644 tests/macos_compat.test.ts create mode 100644 tests/pty_runner.py diff --git a/bun.lock b/bun.lock index 598b0b1..d79a125 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 2, + "lockfileVersion": 1, "configVersion": 1, "workspaces": { "": { diff --git a/src/config_editor.rs b/src/config_editor.rs index c400a74..ac33816 100644 --- a/src/config_editor.rs +++ b/src/config_editor.rs @@ -87,43 +87,7 @@ fn safe_temp_path(dir: &Path) -> Result<(PathBuf, fs::File)> { Err(anyhow!("could not create a unique temporary config file")) } -#[cfg(unix)] -#[allow(dead_code)] -fn publish_anchored( - directory: std::os::fd::RawFd, - name: &std::ffi::OsStr, - temp: &Path, - replace: bool, -) -> Result<()> { - use std::{ffi::CString, os::unix::ffi::OsStrExt}; - let src = CString::new( - temp.file_name() - .ok_or_else(|| anyhow!("temporary config file has no name"))? - .as_bytes(), - )?; - let dst = CString::new(name.as_bytes())?; - let rc = if replace { - unsafe { libc::renameat(directory, src.as_ptr(), directory, dst.as_ptr()) } - } else { - unsafe { - libc::syscall( - libc::SYS_renameat2, - directory, - src.as_ptr(), - directory, - dst.as_ptr(), - libc::RENAME_NOREPLACE, - ) as libc::c_int - } - }; - if rc == 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error().into()) - } -} - -#[cfg(unix)] +#[cfg(target_os = "linux")] fn publish_missing_from_handle( directory: std::os::fd::RawFd, name: &std::ffi::OsStr, @@ -151,6 +115,39 @@ fn publish_missing_from_handle( } } +#[cfg(target_os = "macos")] +fn publish_missing_from_path( + directory: std::os::fd::RawFd, + name: &std::ffi::OsStr, + temp: &Path, +) -> Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + let source = CString::new( + temp.file_name() + .ok_or_else(|| anyhow!("temporary config file has no name"))? + .as_bytes(), + )?; + let destination = CString::new(name.as_bytes())?; + let linked = unsafe { + libc::linkat( + directory, + source.as_ptr(), + directory, + destination.as_ptr(), + 0, + ) + }; + if linked < 0 { + return Err(std::io::Error::last_os_error().into()); + } + let removed = unsafe { libc::unlinkat(directory, source.as_ptr(), 0) }; + if removed < 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) +} + #[allow(unreachable_code)] pub(crate) fn edit_config(a: &super::App, json: bool) -> Result { if json { @@ -310,7 +307,9 @@ pub(crate) fn edit_config(a: &super::App, json: bool) -> Result Result Result Result Result { ); validate_state_path(&a.recovery, &recovery, "recovery")?; let meta = fs::symlink_metadata(&recovery)?; - if !meta.is_dir() || meta.file_type().is_symlink() || canonicalize_path(&recovery)? != recovery { + if !meta.is_dir() || meta.file_type().is_symlink() || canonicalize_path(&recovery)? != recovery + { return Err(anyhow!("conflict recovery is not a canonical directory")); } let raw = fs::read(recovery.join("manifest.json")) diff --git a/src/filesystem.rs b/src/filesystem.rs index 5cb6f97..98d9269 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -13,17 +13,27 @@ use std::{ }; pub(crate) fn lock_is_contended(error: &std::io::Error) -> bool { - error.kind() == std::io::ErrorKind::WouldBlock - || { - #[cfg(windows)] - { - error.raw_os_error() == Some(33) - } - #[cfg(not(windows))] - { - false - } + error.kind() == std::io::ErrorKind::WouldBlock || { + #[cfg(windows)] + { + error.raw_os_error() == Some(33) + } + #[cfg(not(windows))] + { + false } + } +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn renameatx_np( + from_dirfd: libc::c_int, + from: *const libc::c_char, + to_dirfd: libc::c_int, + to: *const libc::c_char, + flags: libc::c_uint, + ) -> libc::c_int; } pub(crate) fn effective_library_path(configured: Option) -> PathBuf { @@ -125,11 +135,7 @@ fn open_directory_file_no_create(path: &Path) -> Result { ffi::CString, os::fd::{AsRawFd, FromRawFd}, }; - let absolute = if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir()?.join(path) - }; + let absolute = canonicalize_path_with_missing(path)?; let root = CString::new("/")?; let root_fd = unsafe { libc::open( @@ -171,8 +177,8 @@ pub(crate) fn open_directory_file_bound(path: &Path) -> Result { use std::{iter, os::windows::ffi::OsStrExt, os::windows::io::FromRawHandle}; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, }; const GENERIC_READ_ACCESS: u32 = 0x8000_0000; @@ -216,7 +222,8 @@ pub(crate) fn open_directory_file_bound(path: &Path) -> Result { return Err(anyhow!("regular directory required: {}", path.display())); } let expected = (expected.dev(), expected.ino()); - let opened = open_directory_file_no_create(path)?; + let opened = open_directory_file_no_create(path) + .with_context(|| format!("open directory without symlinks: {}", path.display()))?; let actual = opened.metadata()?; if !actual.is_dir() || actual.file_type().is_symlink() @@ -273,7 +280,11 @@ pub(crate) fn open_regular_file_bound( .chain(iter::once(0)) .collect::>(); let access = GENERIC_READ_ACCESS | if write { GENERIC_WRITE_ACCESS } else { 0 }; - let disposition = if create_new { CREATE_NEW } else { OPEN_EXISTING }; + let disposition = if create_new { + CREATE_NEW + } else { + OPEN_EXISTING + }; let handle = unsafe { CreateFileW( wide.as_ptr(), @@ -836,6 +847,52 @@ pub(crate) fn open_directory_file(path: &Path) -> Result { Ok(unsafe { fs::File::from_raw_fd(open_directory_fd(path)?) }) } +#[cfg(unix)] +pub(crate) fn read_directory_entries(fd: std::os::fd::RawFd) -> Result> { + use std::{ffi::CStr, os::unix::ffi::OsStringExt}; + + let duplicate = unsafe { libc::dup(fd) }; + if duplicate < 0 { + return Err(std::io::Error::last_os_error().into()); + } + let directory = unsafe { libc::fdopendir(duplicate) }; + if directory.is_null() { + let error = std::io::Error::last_os_error(); + unsafe { libc::close(duplicate) }; + return Err(error.into()); + } + + #[cfg(target_os = "linux")] + unsafe { + *libc::__errno_location() = 0; + } + #[cfg(target_os = "macos")] + unsafe { + *libc::__error() = 0; + } + + let mut entries = Vec::new(); + loop { + let entry = unsafe { libc::readdir(directory) }; + if entry.is_null() { + break; + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if name != b"." && name != b".." { + entries.push(std::ffi::OsString::from_vec(name.to_vec())); + } + } + let read_error = std::io::Error::last_os_error(); + let closed = unsafe { libc::closedir(directory) }; + if read_error.raw_os_error().is_some_and(|code| code != 0) { + return Err(read_error.into()); + } + if closed < 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(entries) +} + #[cfg(unix)] pub(crate) fn read_relative_file(directory: &fs::File, name: &str) -> Result> { use std::{ @@ -926,13 +983,75 @@ impl Drop for WorkerLease { } } +pub(crate) fn canonicalize_path_with_missing(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let mut existing = absolute.clone(); + let mut missing = Vec::new(); + loop { + match fs::symlink_metadata(&existing) { + Ok(_) => break, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let name = existing + .file_name() + .ok_or_else(|| { + anyhow!("path has no canonicalizable parent: {}", path.display()) + })? + .to_os_string(); + missing.push(name); + existing.pop(); + } + Err(error) => return Err(error.into()), + } + } + let mut component_path = PathBuf::from("/"); + for component in existing.components() { + let Component::Normal(name) = component else { + continue; + }; + component_path.push(name); + if fs::symlink_metadata(&component_path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + let canonical_component = fs::canonicalize(&component_path)?; + let allowed_macos_alias = cfg!(target_os = "macos") + && ((component_path == Path::new("/var") + && canonical_component == Path::new("/private/var")) + || (component_path == Path::new("/tmp") + && canonical_component == Path::new("/private/tmp")) + || (component_path == Path::new("/etc") + && canonical_component == Path::new("/private/etc"))); + if !allowed_macos_alias { + return Err(anyhow!( + "symlink path component rejected: {}", + component_path.display() + )); + } + } + } + let mut canonical = fs::canonicalize(existing)?; + for name in missing.iter().rev() { + canonical.push(name); + } + Ok(canonical) +} + pub(crate) fn resolve_library_path(path: &Path) -> Result { let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir()?.join(path) }; - assert_no_symlink_path(&absolute, Path::new("."))?; + if fs::symlink_metadata(&absolute) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return Err(anyhow!("symlink library root rejected: {}", path.display())); + } if absolute.exists() { Ok(canonicalize_path(&absolute)?) } else { @@ -1221,10 +1340,8 @@ pub(crate) fn files(root: &Path) -> Result, u32)>> { out: &mut Vec<(PathBuf, Vec, u32)>, ) -> Result<()> { use std::{ffi::CString, os::fd::AsRawFd}; - let entries = fs::read_dir(format!("/proc/self/fd/{fd}"))?; - for entry in entries { - let entry = entry?; - let name = entry.file_name(); + let entries = read_directory_entries(fd)?; + for name in entries { let child_rel = rel.join(&name); if operational(&child_rel) { continue; @@ -1577,7 +1694,7 @@ fn write_relative_file_at(root: &fs::File, relative: &Path, bytes: &[u8], mode: directory.as_raw_fd(), temp_name.as_ptr(), libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, - mode as libc::mode_t, + mode as libc::c_uint, ) }; if child < 0 { @@ -1623,7 +1740,8 @@ fn write_relative_file_at(root: &fs::File, relative: &Path, bytes: &[u8], mode: fn write_relative_file(root: &Path, relative: &Path, bytes: &[u8], mode: u32) -> Result<()> { #[cfg(unix)] { - let root = open_directory_file_bound(root)?; + let root = canonicalize_path_with_missing(root)?; + let root = open_directory_file_bound(&root)?; write_relative_file_at(&root, relative, bytes, mode) } #[cfg(windows)] @@ -1645,14 +1763,15 @@ fn write_relative_file(root: &Path, relative: &Path, bytes: &[u8], mode: u32) -> } pub(crate) fn copy_tree(src: &Path, dst: &Path) -> Result<()> { - assert_no_symlink_path(dst, Path::new("."))?; - fs::create_dir_all(dst)?; + let dst = canonicalize_path_with_missing(dst)?; + assert_no_symlink_path(&dst, Path::new("."))?; + fs::create_dir_all(&dst)?; for (r, b, m) in files(src)? { if !safe(&r) { return Err(anyhow!("unsafe path")); } - assert_no_symlink_path(dst, &r)?; - write_relative_file(dst, &r, &b, m)?; + assert_no_symlink_path(&dst, &r)?; + write_relative_file(&dst, &r, &b, m)?; } Ok(()) } @@ -1686,7 +1805,10 @@ fn copy_tree_windows(src: &Path, dst: &Path, complete: bool) -> Result<()> { reject_reparse_point(&source, "tree entry")?; let metadata = fs::symlink_metadata(&source)?; if metadata.file_type().is_symlink() { - return Err(anyhow!("symlink tree entry rejected: {}", child_rel.display())); + return Err(anyhow!( + "symlink tree entry rejected: {}", + child_rel.display() + )); } let destination = dst.join(&child_rel); if metadata.is_dir() { @@ -1715,53 +1837,55 @@ pub(crate) fn copy_complete_tree(src: &Path, dst: &Path) -> Result<()> { } #[cfg(not(windows))] { - reject_reparse_point(src, "delete snapshot source")?; - let root = open_directory_file_bound(src)?; - assert_no_symlink_path(dst, Path::new("."))?; - fs::create_dir_all(dst)?; - #[cfg(unix)] - fn walk(dir: &fs::File, rel: &Path, dst: &Path) -> Result<()> { - use std::{ffi::CString, os::fd::AsRawFd}; - let mut ns = fs::read_dir(format!("/proc/self/fd/{}", dir.as_raw_fd()))? - .map(|e| e.map(|x| x.file_name())) - .collect::>>()?; - ns.sort(); - for n in ns { - let r = rel.join(&n); - if !safe(&r) { - return Err(anyhow!("unsafe delete snapshot path: {}", r.display())); - } - let c = CString::new(n.as_encoded_bytes())?; - let ch = open_entry_checked(dir.as_raw_fd(), &c, &r)?; - let m = ch.metadata()?; - if m.is_dir() { - fs::create_dir_all(dst.join(&r))?; - walk(&ch, &r, dst)?; - fs::set_permissions(dst.join(&r), fs::Permissions::from_mode(metadata_mode(&m)))? - } else if m.is_file() { - let mut b = Vec::new(); - (&ch).read_to_end(&mut b)?; - write_relative_file(dst, &r, &b, metadata_mode(&m))? - } else { - return Err(anyhow!( - "unsupported delete snapshot entry: {}", - r.display() - )); + reject_reparse_point(src, "delete snapshot source")?; + let root = open_directory_file_bound(src)?; + let dst = canonicalize_path_with_missing(dst)?; + assert_no_symlink_path(&dst, Path::new("."))?; + fs::create_dir_all(&dst)?; + #[cfg(unix)] + fn walk(dir: &fs::File, rel: &Path, dst: &Path) -> Result<()> { + use std::{ffi::CString, os::fd::AsRawFd}; + let mut ns = read_directory_entries(dir.as_raw_fd())?; + ns.sort(); + for n in ns { + let r = rel.join(&n); + if !safe(&r) { + return Err(anyhow!("unsafe delete snapshot path: {}", r.display())); + } + let c = CString::new(n.as_encoded_bytes())?; + let ch = open_entry_checked(dir.as_raw_fd(), &c, &r)?; + let m = ch.metadata()?; + if m.is_dir() { + fs::create_dir_all(dst.join(&r))?; + walk(&ch, &r, dst)?; + fs::set_permissions( + dst.join(&r), + fs::Permissions::from_mode(metadata_mode(&m)), + )? + } else if m.is_file() { + let mut b = Vec::new(); + (&ch).read_to_end(&mut b)?; + write_relative_file(dst, &r, &b, metadata_mode(&m))? + } else { + return Err(anyhow!( + "unsupported delete snapshot entry: {}", + r.display() + )); + } } + Ok(()) + } + #[cfg(unix)] + { + walk(&root, Path::new(""), &dst) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (root, src, dst); + Err(anyhow!( + "safe descriptor-relative copy unavailable on this platform" + )) } - Ok(()) - } - #[cfg(unix)] - { - walk(&root, Path::new(""), dst) - } - #[cfg(not(any(unix, windows)))] - { - let _ = (root, src, dst); - Err(anyhow!( - "safe descriptor-relative copy unavailable on this platform" - )) - } } } pub(crate) fn copy_existing_tree(src: &Path, dst: &Path) -> Result<()> { @@ -1771,52 +1895,51 @@ pub(crate) fn copy_existing_tree(src: &Path, dst: &Path) -> Result<()> { } #[cfg(not(windows))] { - reject_reparse_point(src, "destination tree root")?; - let root = open_directory_file_bound(src)?; - assert_no_symlink_path(dst, Path::new("."))?; - fs::create_dir_all(dst)?; - #[cfg(unix)] - fn walk(dir: &fs::File, rel: &Path, dst: &Path) -> Result<()> { - use std::{ffi::CString, os::fd::AsRawFd}; - let mut ns = fs::read_dir(format!("/proc/self/fd/{}", dir.as_raw_fd()))? - .map(|e| e.map(|x| x.file_name())) - .collect::>>()?; - ns.sort(); - for n in ns { - let r = rel.join(&n); - if operational(&r) - && r.components() - .any(|c| matches!(c,Component::Normal(x) if x==".git")) - { - continue; - } - let c = CString::new(n.as_encoded_bytes())?; - let ch = open_entry_checked(dir.as_raw_fd(), &c, &r)?; - let m = ch.metadata()?; - if m.is_dir() { - fs::create_dir_all(dst.join(&r))?; - walk(&ch, &r, dst)? - } else if m.is_file() { - let mut b = Vec::new(); - (&ch).read_to_end(&mut b)?; - write_relative_file(dst, &r, &b, metadata_mode(&m))? - } else { - return Err(anyhow!("unsupported tree entry: {}", r.display())); + reject_reparse_point(src, "destination tree root")?; + let root = open_directory_file_bound(src)?; + let dst = canonicalize_path_with_missing(dst)?; + assert_no_symlink_path(&dst, Path::new("."))?; + fs::create_dir_all(&dst)?; + #[cfg(unix)] + fn walk(dir: &fs::File, rel: &Path, dst: &Path) -> Result<()> { + use std::{ffi::CString, os::fd::AsRawFd}; + let mut ns = read_directory_entries(dir.as_raw_fd())?; + ns.sort(); + for n in ns { + let r = rel.join(&n); + if operational(&r) + && r.components() + .any(|c| matches!(c,Component::Normal(x) if x==".git")) + { + continue; + } + let c = CString::new(n.as_encoded_bytes())?; + let ch = open_entry_checked(dir.as_raw_fd(), &c, &r)?; + let m = ch.metadata()?; + if m.is_dir() { + fs::create_dir_all(dst.join(&r))?; + walk(&ch, &r, dst)? + } else if m.is_file() { + let mut b = Vec::new(); + (&ch).read_to_end(&mut b)?; + write_relative_file(dst, &r, &b, metadata_mode(&m))? + } else { + return Err(anyhow!("unsupported tree entry: {}", r.display())); + } } + Ok(()) + } + #[cfg(unix)] + { + walk(&root, Path::new(""), &dst) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (root, src, dst); + Err(anyhow!( + "safe descriptor-relative copy unavailable on this platform" + )) } - Ok(()) - } - #[cfg(unix)] - { - walk(&root, Path::new(""), dst) - } - #[cfg(not(any(unix, windows)))] - { - let _ = (root, src, dst); - Err(anyhow!( - "safe descriptor-relative copy unavailable on this platform" - )) - } } } #[allow(dead_code)] @@ -1890,7 +2013,47 @@ pub(crate) fn install_dir_noreplace(src: &Path, dst: &Path) -> Result<()> { result } -#[cfg(all(unix, not(target_os = "linux")))] +#[cfg(target_os = "macos")] +pub(crate) fn install_dir_noreplace(src: &Path, dst: &Path) -> Result<()> { + use std::{ + ffi::CString, + os::{fd::AsRawFd, unix::ffi::OsStrExt}, + }; + + let source_parent = open_directory_file_bound( + src.parent() + .ok_or_else(|| anyhow!("source has no parent"))?, + )?; + let destination_parent = open_directory_file_bound( + dst.parent() + .ok_or_else(|| anyhow!("destination has no parent"))?, + )?; + let source = CString::new( + src.file_name() + .ok_or_else(|| anyhow!("source has no name"))? + .as_bytes(), + )?; + let destination = CString::new( + dst.file_name() + .ok_or_else(|| anyhow!("destination has no name"))? + .as_bytes(), + )?; + let status = unsafe { + renameatx_np( + source_parent.as_raw_fd(), + source.as_ptr(), + destination_parent.as_raw_fd(), + destination.as_ptr(), + libc::RENAME_EXCL, + ) + }; + if status < 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) +} + +#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] pub(crate) fn install_dir_noreplace(_src: &Path, _dst: &Path) -> Result<()> { Err(anyhow!( "safe no-replace directory installation is unavailable on this Unix platform" @@ -1999,19 +2162,66 @@ fn rename_staged_dir_at( } } +#[cfg(target_os = "linux")] +fn rename_exchange( + from_parent: std::os::fd::RawFd, + from: &std::ffi::CStr, + to_parent: std::os::fd::RawFd, + to: &std::ffi::CStr, +) -> Result<()> { + let status = unsafe { + libc::syscall( + libc::SYS_renameat2, + from_parent, + from.as_ptr(), + to_parent, + to.as_ptr(), + libc::RENAME_EXCHANGE, + ) + }; + if status < 0 { + Err(std::io::Error::last_os_error().into()) + } else { + Ok(()) + } +} + +#[cfg(target_os = "macos")] +fn rename_exchange( + from_parent: std::os::fd::RawFd, + from: &std::ffi::CStr, + to_parent: std::os::fd::RawFd, + to: &std::ffi::CStr, +) -> Result<()> { + let status = unsafe { + renameatx_np( + from_parent, + from.as_ptr(), + to_parent, + to.as_ptr(), + libc::RENAME_SWAP, + ) + }; + if status < 0 { + Err(std::io::Error::last_os_error().into()) + } else { + Ok(()) + } +} + pub(crate) struct Replacement { committed: bool, #[cfg(unix)] installed_identity: Option<(u64, u64)>, #[cfg(unix)] backup_identity: Option<(u64, u64)>, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] installed_parent: Option, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] installed_name: Option, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] backup_parent: Option, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] backup_name: Option, #[cfg(windows)] installed_path: Option, @@ -2043,7 +2253,7 @@ impl Replacement { } } -#[cfg(target_os = "linux")] +#[cfg(unix)] fn remove_owned_dir( parent: &fs::File, name: &std::ffi::OsStr, @@ -2076,9 +2286,7 @@ fn remove_owned_dir( fn empty(dir: &fs::File) -> Result<()> { use std::os::fd::AsRawFd; - for entry in fs::read_dir(format!("/proc/self/fd/{}", dir.as_raw_fd()))? { - let entry = entry?; - let name = entry.file_name(); + for name in read_directory_entries(dir.as_raw_fd())? { let c = CString::new(name.as_encoded_bytes())?; let child_fd = unsafe { libc::openat( @@ -2231,7 +2439,7 @@ impl Drop for Replacement { if self.committed { return; } - #[cfg(target_os = "linux")] + #[cfg(unix)] if let (Some(parent), Some(live_name), Some(backup_name)) = (&self.backup_parent, &self.installed_name, &self.backup_name) { @@ -2281,22 +2489,12 @@ impl Drop for Replacement { if !identities_match { return; } - let status = unsafe { - libc::syscall( - libc::SYS_renameat2, - parent.as_raw_fd(), - live.as_ptr(), - parent.as_raw_fd(), - backup.as_ptr(), - libc::RENAME_EXCHANGE, - ) - }; - if status < 0 { + if rename_exchange(parent.as_raw_fd(), &live, parent.as_raw_fd(), &backup).is_err() { return; } return; } - #[cfg(target_os = "linux")] + #[cfg(unix)] if self.backup_name.is_none() { if let (Some(parent), Some(name)) = (&self.installed_parent, &self.installed_name) { let _ = remove_owned_dir(parent, name, self.installed_identity); @@ -2318,10 +2516,7 @@ impl Drop for Replacement { let Some(parent) = installed.parent() else { return; }; - let temporary = parent.join(format!( - ".skillsync-rollback-{}", - unique_stamp() - )); + let temporary = parent.join(format!(".skillsync-rollback-{}", unique_stamp())); if install_dir_noreplace(installed, &temporary).is_err() { return; } @@ -2392,19 +2587,19 @@ pub(crate) fn replace_dir_bound( installed_identity: Some(installed_identity), #[cfg(unix)] backup_identity: None, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] installed_parent: Some(destination_parent.try_clone()?), - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] installed_name: Some(destination_name.clone()), - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] backup_parent: None, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] backup_name: None, }; verify_bound_parent(dst, destination_parent)?; return Ok(replacement); } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] { use std::os::fd::AsRawFd; let dp = destination_parent.try_clone()?; @@ -2421,18 +2616,8 @@ pub(crate) fn replace_dir_bound( .ok_or_else(|| anyhow!("source has no name"))? .as_bytes(), )?; - let status = unsafe { - libc::syscall( - libc::SYS_renameat2, - dp_fd, - dn.as_ptr(), - sp_fd, - sn.as_ptr(), - libc::RENAME_EXCHANGE, - ) - }; - let result = if status < 0 { - Err(std::io::Error::last_os_error().into()) + let result = if let Err(error) = rename_exchange(dp_fd, &dn, sp_fd, &sn) { + Err(error) } else { let backup_name = format!(".skillsync-replaced-{}", unique_stamp()); let backup = dst.parent().unwrap().join(&backup_name); @@ -2440,17 +2625,7 @@ pub(crate) fn replace_dir_bound( let moved = unsafe { libc::renameat(sp_fd, sn.as_ptr(), dp_fd, backup_c.as_ptr()) }; if moved < 0 { let backup_error = std::io::Error::last_os_error(); - let restored = unsafe { - libc::syscall( - libc::SYS_renameat2, - dp_fd, - dn.as_ptr(), - sp_fd, - sn.as_ptr(), - libc::RENAME_EXCHANGE, - ) - }; - if restored < 0 { + if rename_exchange(dp_fd, &dn, sp_fd, &sn).is_err() { // The exchange left the old object at `src`. It must not // remain owned solely by a caller's TempDir. First move // it to a durable sibling; copying is the last resort and @@ -2493,11 +2668,12 @@ pub(crate) fn replace_dir_bound( installed_identity: Some(installed_identity), #[cfg(unix)] backup_identity: Some(backup_identity), - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] installed_parent: Some(dp.try_clone()?), - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] installed_name: Some(destination_name.clone()), backup_parent: Some(dp.try_clone()?), + #[cfg(any(target_os = "linux", target_os = "macos"))] backup_name: Some(backup_name.into()), }; verify_bound_parent(dst, destination_parent)?; @@ -2505,7 +2681,7 @@ pub(crate) fn replace_dir_bound( }; result } - #[cfg(not(target_os = "linux"))] + #[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] { let _ = src; Err(anyhow!( diff --git a/src/harness.rs b/src/harness.rs index b0abcf7..88264c1 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -9,7 +9,8 @@ use std::{ #[cfg(unix)] use crate::filesystem::open_directory_fd; use crate::filesystem::{ - assert_no_symlink_path, canonicalize_path, checked_regular_path, manifest_name, strict_component, + assert_no_symlink_path, canonicalize_path, checked_regular_path, manifest_name, + strict_component, }; use crate::recovery::{directory_identity, DirectoryIdentity}; use crate::{set_member_name, App, HarnessLink, HarnessSetEnablement, State}; @@ -299,8 +300,9 @@ fn existing_harness_root(root: &Path) -> Result { "harness root must be an absolute existing directory" )); } - assert_no_symlink_path(root, Path::new("."))?; - let metadata = fs::symlink_metadata(root) + let root = crate::filesystem::canonicalize_path_with_missing(root)?; + assert_no_symlink_path(&root, Path::new("."))?; + let metadata = fs::symlink_metadata(&root) .map_err(|_| anyhow!("harness root does not exist: {}", root.display()))?; if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(anyhow!( @@ -308,18 +310,19 @@ fn existing_harness_root(root: &Path) -> Result { root.display() )); } - Ok(canonicalize_path(root)?) + canonicalize_path(&root) } fn persisted_harness_root(root: &Path) -> Result { if !root.is_absolute() { return Err(anyhow!("harness root must be absolute")); } - assert_no_symlink_path(root, Path::new("."))?; + let root = crate::filesystem::canonicalize_path_with_missing(root)?; + assert_no_symlink_path(&root, Path::new("."))?; if root.exists() { - existing_harness_root(root) + existing_harness_root(&root) } else { - Ok(root.to_path_buf()) + Ok(root) } } diff --git a/src/main.rs b/src/main.rs index 07fedaa..7c74296 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,9 +26,9 @@ pub(crate) use worker::{worker_status, WORKER_STOP_REQUESTED}; use filesystem::{ assert_no_symlink_path, atomic, canonicalize_path, checked_regular_path, copy_tree, discover, - effective_library_path, files, hash_dir, manifest_name, read_regular_file, - replace_dir_bound, resolve_library_path, safe, snapshot_transaction, source_rel, - strict_component, validate_state_path, FileData, StateLock, + effective_library_path, files, hash_dir, manifest_name, read_regular_file, replace_dir_bound, + resolve_library_path, safe, snapshot_transaction, source_rel, strict_component, + validate_state_path, FileData, StateLock, }; #[cfg(unix)] use filesystem::{open_child_file, open_directory_fd}; @@ -310,10 +310,9 @@ fn unique_stamp() -> u128 { .as_nanos() } pub(crate) fn config_dir() -> PathBuf { - if let Some(x) = std::env::var_os("SKILLSYNC_CONFIG_DIR") { - return x.into(); - } - if cfg!(target_os = "windows") { + let raw = if let Some(x) = std::env::var_os("SKILLSYNC_CONFIG_DIR") { + PathBuf::from(x) + } else if cfg!(target_os = "windows") { std::env::var_os("LOCALAPPDATA") .map(|x| PathBuf::from(x).join("skillsync/config")) .unwrap_or_else(|| PathBuf::from(".skillsync/config")) @@ -328,7 +327,8 @@ pub(crate) fn config_dir() -> PathBuf { std::env::var_os("HOME").map(|x| PathBuf::from(x).join(".config/skillsync")) }) .unwrap_or_else(|| PathBuf::from(".skillsync")) - } + }; + filesystem::canonicalize_path_with_missing(&raw).unwrap_or(raw) } impl App { pub(crate) fn load(anchor: Option<&StateLock>) -> Result { @@ -450,7 +450,8 @@ impl App { } else { expected_library.clone() }; - assert_no_symlink_path(&library, Path::new("."))?; + let safe_library = filesystem::canonicalize_path_with_missing(&library)?; + assert_no_symlink_path(&safe_library, Path::new("."))?; harness::validate_harness_links(&state, &library)?; harness::validate_harness_sets(&state, &library)?; validate_local_adoptions(&state, &library)?; @@ -882,12 +883,14 @@ fn run(cli: Cli) -> Result { Cmd::Init { library } => { if !a.state_path.exists() { if let Some(l) = library { + let l = filesystem::canonicalize_path_with_missing(&l)?; assert_no_symlink_path(&l, Path::new("."))?; fs::create_dir_all(&l)?; assert_no_symlink_path(&l, Path::new("."))?; a.library = canonicalize_path(&l)?; a.state.library = a.library.display().to_string() } + a.library = filesystem::canonicalize_path_with_missing(&a.library)?; assert_no_symlink_path(&a.library, Path::new("."))?; fs::create_dir_all(&a.library)?; assert_no_symlink_path(&a.library, Path::new("."))?; @@ -930,7 +933,8 @@ fn run(cli: Cli) -> Result { }; let url = repository::normalize(&r)?; let (repo, b) = repository::clone_repo(&url)?; - let found = discover(repo.path())?; + let repo_path = filesystem::canonicalize_path_with_missing(repo.path())?; + let found = discover(&repo_path)?; let selected = match skill { Some(n) => repository::select_discovered(&found, &n)?, None if interactive => &found[interactive_package_selection(&found)?], diff --git a/src/recovery.rs b/src/recovery.rs index 5a75076..a6b9937 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -7,9 +7,9 @@ use std::{ #[cfg(unix)] use crate::filesystem::open_entry_checked; use crate::filesystem::{ - assert_no_symlink_path, canonicalize_path, checked_regular_path, copy_complete_tree, copy_tree, discover, - hash_dir, install_dir_noreplace, manifest_name, reject_reparse_point, strict_component, - validate_state_path, + assert_no_symlink_path, canonicalize_path, checked_regular_path, copy_complete_tree, copy_tree, + discover, hash_dir, install_dir_noreplace, manifest_name, read_directory_entries, + reject_reparse_point, strict_component, validate_state_path, }; use crate::*; @@ -197,8 +197,7 @@ fn remove_owned_directory_at( } fn recurse(dir: &fs::File) -> Result<()> { use std::os::fd::AsRawFd; - for entry in fs::read_dir(format!("/proc/self/fd/{}", dir.as_raw_fd()))? { - let name = entry?.file_name(); + for name in read_directory_entries(dir.as_raw_fd())? { let c = CString::new(name.as_encoded_bytes())?; let child = open_entry_checked(dir.as_raw_fd(), &c, Path::new("cleanup child"))?; let m = child.metadata()?; @@ -240,11 +239,18 @@ pub(crate) fn import_local( requested: Option<&str>, ) -> Result { use std::io::IsTerminal; - assert_no_symlink_path(source, Path::new("."))?; - if !source.is_dir() { - return Err(anyhow!("import source is not a directory")); + if fs::symlink_metadata(source) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return Err(anyhow!( + "import source symlink rejected: {}", + source.display() + )); } - let source = canonicalize_path(source).context("canonicalize import source")?; + let source = crate::filesystem::canonicalize_path_with_missing(source) + .context("canonicalize import source")?; + assert_no_symlink_path(&source, Path::new("."))?; if !source.is_dir() { return Err(anyhow!("import source is not a directory")); } diff --git a/src/repository.rs b/src/repository.rs index 576aec9..e860fa6 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,7 +1,7 @@ use crate::filesystem::{ - assert_no_symlink_path, canonicalize_path, copy_existing_tree, copy_tree, discover, files, hash_dir, - replace_dir_bound, safe, snapshot_transaction, source_rel, strict_component, sync_managed_tree, - validate_state_path, write_file_data, + assert_no_symlink_path, canonicalize_path, copy_existing_tree, copy_tree, discover, files, + hash_dir, replace_dir_bound, safe, snapshot_transaction, source_rel, strict_component, + sync_managed_tree, validate_state_path, write_file_data, }; use crate::publication_key; use crate::recovery::{directory_identity, remove_owned_directory}; @@ -472,7 +472,9 @@ pub(crate) fn update_one(a: &mut App, key: &str) -> Result { } }; s.branch = b.clone(); - let (_, up, _) = find_skill(repo.path(), &s.source_path)?; + let repo_path = crate::filesystem::canonicalize_path_with_missing(repo.path()) + .context("canonicalize update repository")?; + let (_, up, _) = find_skill(&repo_path, &s.source_path)?; let base = PathBuf::from(&s.baseline_path); let lh = hash_dir(&local)?; if lh != s.baseline_hash { @@ -623,10 +625,12 @@ pub(crate) fn publish_to_repo( } Err(error) => return Err(error), }; + let tmp_path = crate::filesystem::canonicalize_path_with_missing(tmp.path()) + .context("canonicalize publication repository")?; let destination_rel = format!("skills/{skill}"); let destination_rel_path = Path::new(&destination_rel); - assert_no_symlink_path(tmp.path(), destination_rel_path)?; - let destination = tmp.path().join(destination_rel_path); + assert_no_symlink_path(&tmp_path, destination_rel_path)?; + let destination = tmp_path.join(destination_rel_path); let destination_exists = destination.exists(); if let Ok(metadata) = fs::symlink_metadata(&destination) { if metadata.file_type().is_symlink() || !metadata.is_dir() { @@ -649,10 +653,10 @@ pub(crate) fn publish_to_repo( { return Err(anyhow!("destination has unexplained modifications")); } - let destination_parent_dir = tmp.path().join("skills"); + let destination_parent_dir = tmp_path.join("skills"); fs::create_dir_all(&destination_parent_dir)?; let publication_parent = crate::filesystem::open_directory_file_bound(&destination_parent_dir)?; - let destination_stage_parent = tempfile::tempdir_in(tmp.path())?; + let destination_stage_parent = tempfile::tempdir_in(&tmp_path)?; let staged_destination = destination_stage_parent.path().join("skill"); if destination_exists { copy_existing_tree(&destination, &staged_destination)?; @@ -661,27 +665,27 @@ pub(crate) fn publish_to_repo( } let removed = sync_managed_tree(&source_candidate, &staged_destination)?; if let Some(parent) = destination.parent() { - assert_no_symlink_path(tmp.path(), parent.strip_prefix(tmp.path())?)?; + assert_no_symlink_path(&tmp_path, parent.strip_prefix(&tmp_path)?)?; fs::create_dir_all(parent)?; } let mut replacement = replace_dir_bound(&destination, &staged_destination, &publication_parent)?; for (path, _, _) in &source_files { let relative = format!("{destination_rel}/{}", path.to_string_lossy()); - run_git(Some(tmp.path()), &["add", "--", &relative])?; + run_git(Some(&tmp_path), &["add", "--", &relative])?; } for path in &removed { let relative = format!("{destination_rel}/{}", path.to_string_lossy()); - run_git(Some(tmp.path()), &["add", "-u", "--", &relative])?; + run_git(Some(&tmp_path), &["add", "-u", "--", &relative])?; } if hash_dir(&source)? != current_hash { return Err(anyhow!("source changed during publication; retry")); } - let changed = cached_changes(tmp.path())?; + let changed = cached_changes(&tmp_path)?; let key = publication_key(skill, url, &branch_name, &destination_rel); if changed { run_git( - Some(tmp.path()), + Some(&tmp_path), &["commit", "-m", &format!("Update skill {skill}")], )?; if hash_dir(&source)? != current_hash { @@ -704,7 +708,7 @@ pub(crate) fn publish_to_repo( }, ); a.save().context("persist publication intent before push")?; - run_git(Some(tmp.path()), &["push", "origin", &branch_name])?; + run_git(Some(&tmp_path), &["push", "origin", &branch_name])?; } if hash_dir(&destination)? != current_hash { return Err(anyhow!("published destination did not match source scope")); @@ -717,7 +721,7 @@ pub(crate) fn publish_to_repo( .split_whitespace() .next() .ok_or_else(|| anyhow!("published branch was not visible on the remote"))?; - let local_hash = run_git(Some(tmp.path()), &["rev-parse", "HEAD"])?; + let local_hash = run_git(Some(&tmp_path), &["rev-parse", "HEAD"])?; if remote_hash != local_hash { return Err(anyhow!("remote readback did not match published commit")); } diff --git a/src/tui.rs b/src/tui.rs index 10ef2a9..f43506b 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,10 +1,12 @@ use crate::filesystem::StateLock; use crate::{config_dir, inventory, App}; use anyhow::{Context, Result}; +#[cfg(not(target_os = "macos"))] +use crossterm::terminal::disable_raw_mode; use crossterm::{ event::{self, Event, KeyCode}, execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::CrosstermBackend, @@ -18,15 +20,28 @@ struct TerminalSession<'a, B: ratatui::backend::Backend + io::Write> { terminal: &'a mut Terminal, raw_mode: bool, alternate_screen: bool, + #[cfg(unix)] + original_termios: Option, } impl<'a, B: ratatui::backend::Backend + io::Write> TerminalSession<'a, B> { fn enter(terminal: &'a mut Terminal) -> Result { + #[cfg(unix)] + let original_termios = { + let mut termios = std::mem::MaybeUninit::uninit(); + let result = unsafe { libc::tcgetattr(libc::STDIN_FILENO, termios.as_mut_ptr()) }; + if result < 0 { + return Err(std::io::Error::last_os_error().into()); + } + Some(unsafe { termios.assume_init() }) + }; enable_raw_mode().context("enable terminal raw mode")?; let mut session = Self { terminal, raw_mode: true, alternate_screen: false, + #[cfg(unix)] + original_termios, }; execute!(session.terminal.backend_mut(), EnterAlternateScreen) .context("enter terminal alternate screen")?; @@ -41,8 +56,20 @@ impl Drop for TerminalSession<'_, B> { let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); } if self.raw_mode { + #[cfg(not(target_os = "macos"))] let _ = disable_raw_mode(); } + #[cfg(unix)] + if let Some(termios) = self.original_termios.as_ref() { + #[cfg(target_os = "macos")] + unsafe { + libc::ioctl(libc::STDIN_FILENO, libc::TIOCSETAF, termios); + } + #[cfg(not(target_os = "macos"))] + unsafe { + libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, termios); + } + } let _ = self.terminal.show_cursor(); } } diff --git a/src/worker.rs b/src/worker.rs index ca6d7e1..357c86d 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -159,8 +159,8 @@ pub(crate) fn run_worker_locked( return Err(anyhow!("worker interval must be greater than zero seconds")); } WORKER_STOP_REQUESTED.store(false, Ordering::Relaxed); - let _worker_lease = WorkerLease::acquire(&a.config)?; if once { + let _worker_lease = WorkerLease::acquire(&a.config)?; let sync = sync_all(a, true)?; let results = sync .get("results") @@ -175,6 +175,7 @@ pub(crate) fn run_worker_locked( signal_stop.store(true, Ordering::Relaxed); }) .context("install Ctrl-C handler for worker")?; + let _worker_lease = WorkerLease::acquire(&a.config)?; let mut cycles = 0_u64; let mut cancelled = false; while !stop.load(Ordering::Relaxed) { diff --git a/tests/cli_e2e.test.ts b/tests/cli_e2e.test.ts index 31f2e7a..5724de2 100644 --- a/tests/cli_e2e.test.ts +++ b/tests/cli_e2e.test.ts @@ -94,7 +94,7 @@ test("inventory discovers an effective library before initialization without mut await put(join(fixture.library, "references/child/SKILL.md"), "name: child\n"); const before = (await readdir(fixture.library, { recursive: true })).sort(); const result = skillsync(fixture, ["--json", "inventory"]).json; - expect(result.library).toBe(process.platform === "win32" ? await realpath(fixture.library) : fixture.library); + expect(result.library).toBe(await realpath(fixture.library)); expect(result.packages.map((item: any) => [item.name, item.path])).toEqual([ ["root", "."], ["child", "references/child"], diff --git a/tests/config_edit.test.ts b/tests/config_edit.test.ts index 87b658f..9376c5f 100644 --- a/tests/config_edit.test.ts +++ b/tests/config_edit.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; -import { chmod, mkdtemp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { childEnv, commandBinary } from "./test_harness"; type Env = Record; @@ -38,8 +38,11 @@ function shellQuote(value: string): string { function runInPty(args: string[], env: Env): Result { const command = [commandBinary(env), ...args].map(shellQuote).join(" "); + const scriptCommand = process.platform === "darwin" + ? ["python3", resolve(import.meta.dir, "pty_runner.py"), commandBinary(env), ...args] + : ["script", "-qefc", command, "/dev/null"]; const result = Bun.spawnSync({ - cmd: ["script", "-qefc", command, "/dev/null"], + cmd: scriptCommand, env: childEnv(env), stdout: "pipe", stderr: "pipe", @@ -150,7 +153,7 @@ test("config edit creates the file and invokes VISUAL with one config path", asy expect(result.stdout).toContain("editor-stdout"); expect(result.stdout).toContain("ok"); const editedPath = (await readFile(log, "utf8")).split("\n")[1]; - expect(editedPath.startsWith(`${f.config}/`)).toBe(true); + expect(editedPath.startsWith(`${await realpath(f.config)}/`)).toBe(true); expect(editedPath).not.toBe(join(f.config, "config.toml")); expect(await readFile(join(f.config, "config.toml"), "utf8")).toBe( `library = "${f.library}"\n`, @@ -229,7 +232,7 @@ test("config edit reports editor failure without shell fallback", async () => { EDITOR: "/bin/true", }); expect(result.code).toBe(1); - expect(result.stdout).toContain("editor exited unsuccessfully"); + expect(result.stdout).toContain(process.platform === "darwin" ? "launch editor /bin/false" : "editor exited unsuccessfully"); }); test("config edit reports a missing editor without mutating through a command string", async () => { diff --git a/tests/macos_compat.test.ts b/tests/macos_compat.test.ts new file mode 100644 index 0000000..bc4c206 --- /dev/null +++ b/tests/macos_compat.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const binary = process.env.SKILLSYNC_BIN ?? resolve(import.meta.dir, "../target/debug/skillsync"); +const decoder = new TextDecoder(); + +type Fixture = { + root: string; + config: string; + library: string; + env: Record; +}; + +async function fixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "skillsync-macos-")); + const config = join(root, "config"); + const library = join(root, "library"); + await mkdir(join(root, "home"), { recursive: true }); + return { + root, + config, + library, + env: { + ...process.env as Record, + HOME: join(root, "home"), + SKILLSYNC_CONFIG_DIR: config, + SKILLSYNC_LIBRARY: library, + }, + }; +} + +function run(f: Fixture, args: string[]) { + const result = Bun.spawnSync({ cmd: [binary, ...args], env: f.env, stdout: "pipe", stderr: "pipe" }); + const output = decoder.decode(result.stdout); + expect(result.exitCode, decoder.decode(result.stderr)).toBe(0); + expect(output).not.toBe(""); + return JSON.parse(output) as Record; +} + +test("supports macOS temp paths through import, delete, and restore", async () => { + if (process.platform !== "darwin") return; + const f = await fixture(); + try { + const source = join(f.root, "source"); + await mkdir(join(source, "nested"), { recursive: true }); + await writeFile(join(source, "SKILL.md"), "name: demo\nmacOS\n"); + await writeFile(join(source, "nested", "run.sh"), "#!/bin/sh\n"); + await chmod(join(source, "nested", "run.sh"), 0o755); + + run(f, ["--json", "init"]); + const imported = run(f, ["--json", "import", "--from", source, "--skill", "demo"]); + expect(imported.status).toBe("adopted"); + expect(await readFile(join(f.library, "demo", "SKILL.md"), "utf8")).toContain("macOS"); + + const deleted = run(f, ["--json", "delete", "demo", "--yes"]); + expect(deleted.status).toBe("deleted"); + const recoveryPath = String(deleted.recovery_path); + expect(await readFile(join(recoveryPath, "package", "SKILL.md"), "utf8")).toContain("macOS"); + + const restored = run(f, ["--json", "restore", "--from", recoveryPath]); + expect(restored.status).toBe("restored"); + expect(await readFile(join(f.library, "demo", "SKILL.md"), "utf8")).toContain("macOS"); + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}); diff --git a/tests/pty_runner.py b/tests/pty_runner.py new file mode 100644 index 0000000..90d57f2 --- /dev/null +++ b/tests/pty_runner.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +import errno +import os +import pty +import select +import sys +import time + + +def main() -> int: + if len(sys.argv) < 2: + raise SystemExit("usage: pty_runner.py command [args ...]") + pid, master = pty.fork() + if pid == 0: + os.execvpe(sys.argv[1], sys.argv[1:], os.environ) + + keys = os.environ.get("SKILLSYNC_PTY_KEYS", "q") + delay = float(os.environ.get("SKILLSYNC_PTY_DELAY", "0.3")) + schedule = [(delay, b"\r" if "r" in keys else b"")] + if "q" in keys: + schedule.append((delay + 0.05, b"q")) + start = time.monotonic() + next_key = 0 + status = None + while True: + now = time.monotonic() - start + while next_key < len(schedule) and now >= schedule[next_key][0]: + if schedule[next_key][1]: + os.write(master, schedule[next_key][1]) + next_key += 1 + readable, _, _ = select.select([master], [], [], 0.05) + if readable: + try: + data = os.read(master, 4096) + except OSError as error: + if error.errno != errno.EIO: + raise + data = b"" + if data: + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + waited, child_status = os.waitpid(pid, os.WNOHANG) + if waited: + status = child_status + break + try: + while True: + data = os.read(master, 4096) + if not data: + break + sys.stdout.buffer.write(data) + except OSError as error: + if error.errno != errno.EIO: + raise + finally: + os.close(master) + return os.waitstatus_to_exitcode(status) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/tui_e2e.test.ts b/tests/tui_e2e.test.ts index 0bcf0da..196faf8 100644 --- a/tests/tui_e2e.test.ts +++ b/tests/tui_e2e.test.ts @@ -23,12 +23,16 @@ test("no-arg TTY opens read-only library browser and quits without mutation", as for (const name of ["state.json", "state.lock"]) before.set(name, await readFile(join(config, name), "utf8").catch(() => "")); before.set("library", JSON.stringify((await readdir(library, { recursive: true })).sort())); const command = `stty rows 30 cols 120; exec ${[binary].map(quote).join(" ")}`; - const child = Bun.spawn({ cmd: ["script", "-qefc", command, "/dev/null"], env, stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + const scriptCommand = process.platform === "darwin" + ? ["python3", resolve(import.meta.dir, "pty_runner.py"), "sh", "-c", command] + : ["script", "-qefc", command, "/dev/null"]; + const child = Bun.spawn({ cmd: scriptCommand, env: { ...env, SKILLSYNC_PTY_KEYS: "rq" }, stdin: "pipe", stdout: "pipe", stderr: "pipe" }); const outputPromise = new Response(child.stdout).text(); await Bun.sleep(300); child.stdin.write("\r"); await Bun.sleep(50); child.stdin.write("q"); child.stdin.end(); - expect(await child.exited).toBe(0); + const exitCode = await child.exited; const output = await outputPromise; + expect(exitCode).toBe(0); expect(output).toContain("Library"); expect(output).toContain("root-skill"); expect(output).toContain("parent/nested"); expect(output).toContain("Provenance"); expect(await readFile(join(config, "state.json"), "utf8")).toBe(before.get("state.json") ?? ""); expect(await readFile(join(config, "state.lock"), "utf8").catch(() => "")).toBe(before.get("state.lock") ?? ""); diff --git a/tests/tui_failure_cleanup.test.ts b/tests/tui_failure_cleanup.test.ts index 9ca2646..a2c671f 100644 --- a/tests/tui_failure_cleanup.test.ts +++ b/tests/tui_failure_cleanup.test.ts @@ -24,7 +24,29 @@ test("TUI panic restores terminal state and keeps the original failure visible", const init = Bun.spawnSync({ cmd: [binary, "--json", "init"], env, stdout: "pipe", stderr: "pipe" }); expect(init.exitCode).toBe(0); const command = `before=$(stty -g); printf 'BEFORE:%s\\n' "$before"; ${quote(binary)}; status=$?; after=$(stty -g); printf 'AFTER:%s\\n' "$after"; printf 'STATUS:%s\\n' "$status"; exit 0`; - const child = Bun.spawn({ cmd: ["script", "-qefc", command, "/dev/null"], env: { ...env, SKILLSYNC_TEST_TUI_PANIC: "1" }, stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + const scriptCommand = process.platform === "darwin" + ? ["python3", resolve(import.meta.dir, "pty_runner.py"), "sh", "-c", command] + : ["script", "-qefc", command, "/dev/null"]; + if (process.platform === "darwin") { + const child = Bun.spawn({ + cmd: scriptCommand, + env: { ...env, SKILLSYNC_TEST_TUI_PANIC: "1", SKILLSYNC_PTY_KEYS: "" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const outputPromise = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(await child.exited).toBe(0); + const [stdout, stderr] = await outputPromise; + const output = stdout + stderr; + const before = output.match(/BEFORE:([^\\r\\n]+)/)?.[1]; + const after = output.match(/AFTER:([^\\r\\n]+)/)?.[1]; + expect(before).toBeDefined(); expect(after).toBe(before); + expect(output).toContain("STATUS:101"); + expect(output).toContain("injected TUI panic"); + return; + } + const child = Bun.spawn({ cmd: scriptCommand, env: { ...env, SKILLSYNC_TEST_TUI_PANIC: "1" }, stdin: "pipe", stdout: "pipe", stderr: "pipe" }); const outputPromise = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); await Bun.sleep(300); child.stdin.write("q"); child.stdin.end(); expect(await child.exited).toBe(0); @@ -38,4 +60,4 @@ test("TUI panic restores terminal state and keeps the original failure visible", } finally { await Promise.all([rm(root, { recursive: true, force: true }), rm(targetDir, { recursive: true, force: true })]); } -}, { timeout: 15000 }); +}, { timeout: 60000 }); From 1d77c50b68d0aebd3bac6b7ef32ad877acb930eb Mon Sep 17 00:00:00 2001 From: nicholai Date: Thu, 17 Sep 2026 05:37:49 -0600 Subject: [PATCH 2/5] ci: add macOS build and integration tests --- .github/workflows/macos.yml | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/macos.yml diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..3d76889 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,52 @@ +name: macOS + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: macOS build and integration tests (${{ matrix.architecture }}) + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + architecture: arm64 + - runner: macos-15-intel + architecture: x86_64 + runs-on: ${{ matrix.runner }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + + - name: Build native CLI + run: cargo build --locked + + - name: Build test-hook CLI + run: cargo build --locked --features test-hooks --target-dir target/test-hooks + + - name: Run Rust tests + run: cargo test --locked + + - name: Install JavaScript test dependencies + run: bun install --frozen-lockfile + + - name: Type-check JavaScript harness + run: bun x tsc --noEmit + + - name: Run macOS integration tests + run: bun test --max-concurrency=1 --timeout=30000 From 75a5913551b35e21380c77b478a374300240e28b Mon Sep 17 00:00:00 2001 From: nicholai Date: Thu, 17 Sep 2026 05:43:00 -0600 Subject: [PATCH 3/5] fix: gate Unix directory enumeration import --- src/recovery.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/recovery.rs b/src/recovery.rs index a6b9937..36b0c44 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -6,10 +6,12 @@ use std::{ #[cfg(unix)] use crate::filesystem::open_entry_checked; +#[cfg(unix)] +use crate::filesystem::read_directory_entries; use crate::filesystem::{ assert_no_symlink_path, canonicalize_path, checked_regular_path, copy_complete_tree, copy_tree, - discover, hash_dir, install_dir_noreplace, manifest_name, read_directory_entries, - reject_reparse_point, strict_component, validate_state_path, + discover, hash_dir, install_dir_noreplace, manifest_name, reject_reparse_point, + strict_component, validate_state_path, }; use crate::*; From 2ed04e5d10f4f24d6690910ff3c7a085b247c6b9 Mon Sep 17 00:00:00 2001 From: nicholai Date: Thu, 17 Sep 2026 06:29:34 -0600 Subject: [PATCH 4/5] fix: make worker registration tests portable --- src/filesystem.rs | 11 ++++++++++- src/worker_registration.rs | 5 +++-- tests/hold_lock.py | 11 +++++++++++ tests/worker_registration_e2e.test.ts | 24 ++++++++++++++++++++++-- 4 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 tests/hold_lock.py diff --git a/src/filesystem.rs b/src/filesystem.rs index 98d9269..2179c18 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -59,7 +59,9 @@ pub(crate) struct FileData { #[cfg(unix)] pub(crate) fn open_directory_fd(path: &Path) -> Result { use std::{ffi::CString, os::unix::ffi::OsStrExt}; - let absolute = if path.is_absolute() { + let absolute = if cfg!(target_os = "macos") { + canonicalize_path_with_missing(path)? + } else if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir()?.join(path) @@ -1429,6 +1431,13 @@ pub(crate) fn assert_no_symlink_path(root: &Path, rel: &Path) -> Result<()> { if root.starts_with("/proc/self/fd") { return Ok(()); } + let canonical_root; + let root = if cfg!(target_os = "macos") { + canonical_root = canonicalize_path_with_missing(root)?; + canonical_root.as_path() + } else { + root + }; let mut ancestor = root; loop { reject_reparse_point(ancestor, "destination ancestor")?; diff --git a/src/worker_registration.rs b/src/worker_registration.rs index 4852ae8..d43ff8f 100644 --- a/src/worker_registration.rs +++ b/src/worker_registration.rs @@ -221,7 +221,7 @@ fn provider_install( registration: &[u8], state: &mut ProviderInstallState, ) -> Result<()> { - let _ = (exe, interval); + let _ = (exe, interval, identity); #[cfg(feature = "test-hooks")] if let Ok(v) = std::env::var("SKILLSYNC_TEST_WORKER_PROVIDER") { if v == "fail" { @@ -292,6 +292,7 @@ fn provider_install( Err(anyhow!("worker registration unsupported on this platform")) } fn provider_disable(identity: &str, reg: &Path) -> Result<()> { + let _ = reg; #[cfg(feature = "test-hooks")] if let Ok(v) = std::env::var("SKILLSYNC_TEST_WORKER_PROVIDER") { if v == "fail" { @@ -608,7 +609,7 @@ fn provider_activate_existing( registration: &[u8], state: &mut ProviderInstallState, ) -> Result<()> { - let _ = (exe, interval); + let _ = (exe, interval, identity); ensure_owned_bytes(reg, registration, "registration artifact")?; #[cfg(feature = "test-hooks")] if let Ok(v) = std::env::var("SKILLSYNC_TEST_WORKER_PROVIDER") { diff --git a/tests/hold_lock.py b/tests/hold_lock.py new file mode 100644 index 0000000..c2f8bc7 --- /dev/null +++ b/tests/hold_lock.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +import fcntl +import pathlib +import sys +import time + +lock_path, marker_path = sys.argv[1:3] +with open(lock_path, "a+") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + pathlib.Path(marker_path).touch() + time.sleep(30) diff --git a/tests/worker_registration_e2e.test.ts b/tests/worker_registration_e2e.test.ts index 7d4e843..445d883 100644 --- a/tests/worker_registration_e2e.test.ts +++ b/tests/worker_registration_e2e.test.ts @@ -11,7 +11,26 @@ async function fixture(): Promise { const root=await mkdtemp(join(tmpdir(),"s function run(f:F,args:string[],ok=true,extra:Record={}) { const r=Bun.spawnSync({cmd:[commandBinary(extra),"--json",...args],env:childEnv({...f.env,...extra}),stdout:"pipe",stderr:"pipe"}); const out=dec.decode(r.stdout); expect(r.exitCode,dec.decode(r.stderr)).toBe(ok?0:1); return JSON.parse(out); } afterEach(async()=>{ await Promise.all(fixtures.splice(0).map(f=>rm(f.root,{recursive:true,force:true}))); }); -test("status is read-only before setup and enable is idempotent with exact artifacts",async()=>{ const f=await fixture(); expect(run(f,["worker","status"]).registered).toBe(false); const e=run(f,["worker","enable","--interval","17"],true,{SKILLSYNC_TEST_WORKER_PROVIDER:"ok"}); expect(e.worker).toBe("enabled"); const meta=JSON.parse(await readFile(join(f.config,"worker-registration.json"),"utf8")); expect(meta.interval).toBe(17); expect(meta.executable_path).toBe(join(f.data,"skillsync","bin","skillsync")); expect(meta.registration_path).toBe(join(f.env.HOME,".config/systemd/user/skillsync-worker.service")); expect((await readFile(meta.registration_path,"utf8")).includes(`${meta.executable_path} worker --interval 17`)).toBe(true); expect(run(f,["worker","enable","--interval","17"],true,{SKILLSYNC_TEST_WORKER_PROVIDER:"ok"}).worker).toBe("already_enabled"); }); +function expectedArtifacts(f: F) { + if (process.platform === "darwin") { + const root = join(f.env.HOME, "Library/Application Support/skillsync"); + return { + executable: join(root, "bin", "skillsync"), + registration: join(f.env.HOME, "Library/LaunchAgents/com.skillsync.worker.plist"), + }; + } + if (process.platform === "win32") { + const root = join(process.env.LOCALAPPDATA ?? "", "skillsync"); + return { executable: join(root, "bin", "skillsync.exe"), registration: join(root, "worker-task.xml") }; + } + const root = join(f.env.HOME, ".local/share/skillsync"); + return { + executable: join(root, "bin", "skillsync"), + registration: join(f.env.HOME, ".config/systemd/user/skillsync-worker.service"), + }; +} + +test("status is read-only before setup and enable is idempotent with exact artifacts",async()=>{ const f=await fixture(); const expected = expectedArtifacts(f); expect(run(f,["worker","status"]).registered).toBe(false); const e=run(f,["worker","enable","--interval","17"],true,{SKILLSYNC_TEST_WORKER_PROVIDER:"ok"}); expect(e.worker).toBe("enabled"); const meta=JSON.parse(await readFile(join(f.config,"worker-registration.json"),"utf8")); expect(meta.interval).toBe(17); expect(meta.executable_path).toBe(expected.executable); expect(meta.registration_path).toBe(expected.registration); const registration = await readFile(meta.registration_path,"utf8"); expect(registration).toContain(meta.executable_path); expect(registration).toContain("worker"); expect(registration).toContain("17"); expect(run(f,["worker","enable","--interval","17"],true,{SKILLSYNC_TEST_WORKER_PROVIDER:"ok"}).worker).toBe("already_enabled"); }); test("changed executable and malformed metadata fail closed",async()=>{ const f=await fixture(); run(f,["worker","enable"],true,{SKILLSYNC_TEST_WORKER_PROVIDER:"ok"}); const metaPath=join(f.config,"worker-registration.json"); const m=JSON.parse(await readFile(metaPath,"utf8")); await writeFile(m.executable_path,"tampered"); expect(run(f,["worker","status"],false).ok).toBe(false); await writeFile(metaPath,JSON.stringify({...m,registration_path:join(f.root,"victim")})); expect(run(f,["worker","uninstall"],false).ok).toBe(false); }); @@ -106,11 +125,12 @@ test("worker subcommands reject legacy worker flags", async () => { }); test("worker registration commands honor the shared state lock", async () => { + if (process.platform === "win32") return; const f = await fixture(); run(f, ["init"]); const marker = join(f.root, "lock-held"); const holder = Bun.spawn({ - cmd: ["flock", join(f.config, "state.lock"), "-c", `touch ${marker}; sleep 3`], + cmd: ["python3", join(import.meta.dir, "hold_lock.py"), join(f.config, "state.lock"), marker], stdout: "ignore", stderr: "pipe", }); From a3b2f89e74e762e6d9a66018288b5fe864ecc5d7 Mon Sep 17 00:00:00 2001 From: nicholai Date: Thu, 17 Sep 2026 06:41:17 -0600 Subject: [PATCH 5/5] fix: stabilize cross-platform integration tests --- src/worker_registration.rs | 6 +++--- tests/cli_e2e.test.ts | 12 +++++++----- tests/pty_runner.py | 19 ++++++++++++++----- tests/worker_registration_e2e.test.ts | 4 ++-- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/worker_registration.rs b/src/worker_registration.rs index d43ff8f..1137b15 100644 --- a/src/worker_registration.rs +++ b/src/worker_registration.rs @@ -45,7 +45,7 @@ fn paths() -> Result<(PathBuf, PathBuf, String)> { .unwrap_or(home()?.join(".local/share")) .join("skillsync"); return Ok(( - root.join("bin/skillsync"), + root.join("bin").join("skillsync"), home()?.join(".config/systemd/user/skillsync-worker.service"), SERVICE.into(), )); @@ -54,7 +54,7 @@ fn paths() -> Result<(PathBuf, PathBuf, String)> { { let root = home()?.join("Library/Application Support/skillsync"); return Ok(( - root.join("bin/skillsync"), + root.join("bin").join("skillsync"), home()?.join("Library/LaunchAgents/com.skillsync.worker.plist"), "com.skillsync.worker".into(), )); @@ -66,7 +66,7 @@ fn paths() -> Result<(PathBuf, PathBuf, String)> { ) .join("skillsync"); return Ok(( - root.join("bin/skillsync.exe"), + root.join("bin").join("skillsync.exe"), root.join("worker-task.xml"), "Skillsync Worker".into(), )); diff --git a/tests/cli_e2e.test.ts b/tests/cli_e2e.test.ts index 5724de2..9b83348 100644 --- a/tests/cli_e2e.test.ts +++ b/tests/cli_e2e.test.ts @@ -181,10 +181,11 @@ test("imports a local package idempotently and records provenance", async () => const fixture = await makeFixture(); const source = join(fixture.root, "local-source"); await put(join(source, "nested/SKILL.md"), "name: demo\nlocal\n"); + const sourcePath = process.platform === "win32" ? await realpath(source) : source; skillsync(fixture, ["--json", "init"]); - const first = skillsync(fixture, ["--json", "import", "--from", source, "--skill", "nested"]).json; + const first = skillsync(fixture, ["--json", "import", "--from", sourcePath, "--skill", "nested"]).json; expect(first.status).toBe("adopted"); - const second = skillsync(fixture, ["--json", "import", "--from", source, "--skill", "nested"]).json; + const second = skillsync(fixture, ["--json", "import", "--from", sourcePath, "--skill", "nested"]).json; expect(second.status).toBe("already_present"); expect(second.provenance).toBe("recorded"); const status = skillsync(fixture, ["--json", "status"]).json; @@ -245,11 +246,12 @@ test("imports one selected package and fails missing selection", async () => { const source = join(fixture.root, "many"); await put(join(source, "one/SKILL.md"), "name: one\n"); await put(join(source, "two/SKILL.md"), "name: two\n"); + const sourcePath = process.platform === "win32" ? await realpath(source) : source; skillsync(fixture, ["--json", "init"]); - expect(skillsync(fixture, ["--json", "import", "--from", source], false).json.message).toContain("--skill is required"); - expect(skillsync(fixture, ["--json", "import", "--from", source, "--skill", "one"]).json.skill).toBe("one"); + expect(skillsync(fixture, ["--json", "import", "--from", sourcePath], false).json.message).toContain("--skill is required"); + expect(skillsync(fixture, ["--json", "import", "--from", sourcePath, "--skill", "one"]).json.skill).toBe("one"); expect(await Bun.file(join(fixture.library, "two/SKILL.md")).exists()).toBe(false); - expect(skillsync(fixture, ["--json", "import", "--from", source, "--skill", "missing"], false).json.message).toContain("skill not found"); + expect(skillsync(fixture, ["--json", "import", "--from", sourcePath, "--skill", "missing"], false).json.message).toContain("skill not found"); }); test("rejects malformed manifests and symlinked import roots", async () => { diff --git a/tests/pty_runner.py b/tests/pty_runner.py index 90d57f2..a978584 100644 --- a/tests/pty_runner.py +++ b/tests/pty_runner.py @@ -44,14 +44,23 @@ def main() -> int: status = child_status break try: - while True: - data = os.read(master, 4096) + os.set_blocking(master, False) + idle_deadline = time.monotonic() + 0.5 + while time.monotonic() < idle_deadline: + readable, _, _ = select.select([master], [], [], 0.05) + if not readable: + continue + try: + data = os.read(master, 4096) + except OSError as error: + if error.errno in (errno.EAGAIN, errno.EWOULDBLOCK, errno.EIO): + break + raise if not data: break sys.stdout.buffer.write(data) - except OSError as error: - if error.errno != errno.EIO: - raise + sys.stdout.buffer.flush() + idle_deadline = time.monotonic() + 0.5 finally: os.close(master) return os.waitstatus_to_exitcode(status) diff --git a/tests/worker_registration_e2e.test.ts b/tests/worker_registration_e2e.test.ts index 445d883..8c4b405 100644 --- a/tests/worker_registration_e2e.test.ts +++ b/tests/worker_registration_e2e.test.ts @@ -7,7 +7,7 @@ import { childEnv, commandBinary } from "./test_harness"; type F = { root: string; config: string; data: string; env: Record }; const fixtures: F[] = []; const dec = new TextDecoder(); -async function fixture(): Promise { const root=await mkdtemp(join(tmpdir(),"skillsync-worker-")); const f={root,config:join(root,"config"),data:join(root,"data"),env:{...process.env as Record,HOME:join(root,"home"),XDG_DATA_HOME:join(root,"data"),SKILLSYNC_CONFIG_DIR:join(root,"config")}}; await mkdir(join(root,"home"),{recursive:true}); fixtures.push(f); return f; } +async function fixture(): Promise { const root=await mkdtemp(join(tmpdir(),"skillsync-worker-")); const env: Record={...process.env as Record,HOME:join(root,"home"),XDG_DATA_HOME:join(root,"data"),SKILLSYNC_CONFIG_DIR:join(root,"config")}; if (process.platform === "win32") { env.LOCALAPPDATA=join(root,"data"); env.APPDATA=join(root,"appdata"); env.USERPROFILE=join(root,"home"); } const f={root,config:join(root,"config"),data:join(root,"data"),env}; await mkdir(join(root,"home"),{recursive:true}); fixtures.push(f); return f; } function run(f:F,args:string[],ok=true,extra:Record={}) { const r=Bun.spawnSync({cmd:[commandBinary(extra),"--json",...args],env:childEnv({...f.env,...extra}),stdout:"pipe",stderr:"pipe"}); const out=dec.decode(r.stdout); expect(r.exitCode,dec.decode(r.stderr)).toBe(ok?0:1); return JSON.parse(out); } afterEach(async()=>{ await Promise.all(fixtures.splice(0).map(f=>rm(f.root,{recursive:true,force:true}))); }); @@ -20,7 +20,7 @@ function expectedArtifacts(f: F) { }; } if (process.platform === "win32") { - const root = join(process.env.LOCALAPPDATA ?? "", "skillsync"); + const root = join(f.env.LOCALAPPDATA ?? "", "skillsync"); return { executable: join(root, "bin", "skillsync.exe"), registration: join(root, "worker-task.xml") }; } const root = join(f.env.HOME, ".local/share/skillsync");