diff --git a/DESCRIPTION b/DESCRIPTION index c37c3aa..d21c6a9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -13,7 +13,6 @@ URL: https://github.com/r-lib/Rapp BugReports: https://github.com/r-lib/Rapp/issues Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 Suggests: testthat (>= 3.0.0), withr @@ -22,3 +21,4 @@ Config/testthat/parallel: true Config/testthat/start-first: help-snapshots, basics Imports: yaml12 +Config/roxygen2/version: 8.0.0 diff --git a/NEWS.md b/NEWS.md index 8b62627..90af59e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,6 +18,10 @@ ## Bug fixes +- On macOS, `install_pkg_cli_apps()` now adds the default `~/.local/bin` + install directory to the user's zsh profile when it is not already + on `PATH`, respecting `ZDOTDIR` and warning rather than failing if + the profile cannot be updated (#35). - Launcher front matter now accepts documented kebab-case option names such as `default-packages`. Installation docs now clarify that package apps are discovered as `exec/*.R`, installed without the `.R` extension diff --git a/R/install.R b/R/install.R index faee65b..15ae403 100644 --- a/R/install.R +++ b/R/install.R @@ -50,10 +50,13 @@ #' - Windows: `%LOCALAPPDATA%\Programs\R\Rapp\bin` #' #' On Windows, the resolved `destdir` is explicitly added to `PATH` (it -#' generally is not by default). To disable adding it to the `PATH`, set envvar -#' `RAPP_NO_MODIFY_PATH=1`. +#' generally is not by default). On macOS, when the default `~/.local/bin` +#' destination is not already on `PATH`, it is added to `~/.zprofile`, or +#' `$ZDOTDIR/.zprofile` when `ZDOTDIR` is set. If the profile cannot be +#' updated, a warning is emitted and installation continues. To disable adding +#' it to the `PATH`, set envvar `RAPP_NO_MODIFY_PATH=1`. #' -#' On macOS or Linux, `~/.local/bin` is typically already on `PATH` if it +#' On Linux, `~/.local/bin` is typically already on `PATH` if it #' exists. Note: some shells add `~/.local/bin` to `PATH` only if it exists at #' login. If `install_pkg_cli_apps()` created the directory, you may need to #' restart the shell for the new apps to be found on `PATH`. @@ -89,6 +92,8 @@ install_pkg_cli_apps <- function( if (is_windows()) { ensure_path_windows(destdir) + } else if (is_macos()) { + ensure_path_macos(destdir) } # existing Rapp launchers we're either overwriting or deleting @@ -518,6 +523,126 @@ get_env_win_registry <- function(name) { utils::readRegistry("Environment", hive = "HCU", view = "default")[[name]] } +ensure_path_macos <- function(destdir = rapp_install_dir()) { + if (Sys.getenv("RAPP_NO_MODIFY_PATH") != "") { + return(FALSE) + } + stopifnot(is_macos()) + + destdir <- normalizePath(destdir, mustWork = TRUE) + if (!identical(destdir, macos_default_install_dir())) { + return(FALSE) + } + if (path_has_dir(destdir)) { + return(FALSE) + } + + zprofile <- macos_zprofile() + zprofile_display <- macos_zprofile_display(zprofile) + lines <- macos_path_lines() + + if (profile_has_lines(zprofile, lines)) { + warning( + "~/.local/bin PATH setup is already present in ", + zprofile_display, + ", ", + "but ~/.local/bin is still not on PATH.\n", + "Restart your shell, or run:\n\n", + " source ", zprofile_display, + call. = FALSE + ) + } else if (write_macos_path_lines(zprofile, lines, zprofile_display)) { + message( + "Added ~/.local/bin to PATH in ", zprofile_display, ".\n", + "Restart your shell, or run:\n\n", + " source ", zprofile_display + ) + } + + Sys.setenv(PATH = paste(destdir, Sys.getenv("PATH"), sep = .Platform$path.sep)) + TRUE +} + +macos_default_install_dir <- function() { + normalizePath(file.path(path.expand("~"), ".local", "bin"), mustWork = FALSE) +} + +macos_zprofile <- function() { + zdotdir <- Sys.getenv("ZDOTDIR") + if (!nzchar(zdotdir)) { + zdotdir <- path.expand("~") + } + path(zdotdir, ".zprofile") +} + +macos_zprofile_display <- function(zprofile) { + if (identical(path(zprofile), path(path.expand("~"), ".zprofile"))) { + "~/.zprofile" + } else { + zprofile + } +} + +macos_path_lines <- function() { + c( + 'case ":$PATH:" in', + ' *:"$HOME/.local/bin":*) ;;', + ' *) export PATH="$HOME/.local/bin:$PATH" ;;', + "esac" + ) +} + +profile_has_lines <- function(profile, lines) { + if (!file.exists(profile)) { + return(FALSE) + } + profile <- tryCatch( + paste(readLines(profile, warn = FALSE), collapse = "\n"), + error = function(e) "", + warning = function(w) "" + ) + grepl(paste(lines, collapse = "\n"), profile, fixed = TRUE) +} + +write_macos_path_lines <- function(profile, lines, profile_display) { + tryCatch( + { + suppressWarnings(cat( + paste0(c("", lines), collapse = "\n"), + "\n", + file = profile, + append = TRUE, + sep = "" + )) + TRUE + }, + error = function(e) { + warning( + "Could not add ~/.local/bin to PATH in ", + profile_display, + ": ", + conditionMessage(e), + call. = FALSE + ) + FALSE + } + ) +} + +path_has_dir <- function(dir, env_path = Sys.getenv("PATH")) { + normalizePath(dir, mustWork = FALSE) %in% path_entries(env_path) +} + +path_entries <- function(env_path = Sys.getenv("PATH")) { + entries <- strsplit(env_path, .Platform$path.sep, fixed = TRUE)[[1L]] + entries <- entries[nzchar(entries)] + normalizePath(entries, mustWork = FALSE) +} + +is_macos <- function() { + identical(Sys.info()[["sysname"]], "Darwin") +} + path <- function(...) { normalizePath(file.path(...), mustWork = FALSE) } diff --git a/README.md b/README.md index 9cd90f5..faaaf32 100644 --- a/README.md +++ b/README.md @@ -425,10 +425,13 @@ App launchers are written to `destdir`, which defaults to the first available location from `RAPP_BIN_DIR`, `XDG_BIN_HOME`, `XDG_DATA_HOME/../bin`, or the default location, `~/.local/bin` on macOS and Linux and `%LOCALAPPDATA%\Programs\R\Rapp\bin` on Windows. On -Windows the directory is automatically added to `PATH`; on macOS and -Linux the directory generally is already present on `PATH` (you may need -to restart your shell if the Rapp installer created the directory). Use -the `destdir` argument if you prefer an alternate location. +Windows the directory is automatically added to `PATH`; on macOS, the +default `~/.local/bin` directory is added to `~/.zprofile` when needed +(`$ZDOTDIR/.zprofile` when `ZDOTDIR` is set). If that profile cannot be +updated, Rapp warns and continues installing launchers. On Linux the +directory generally is already present on `PATH` (you may need to log +out and back in if the Rapp installer created the directory). Use the +`destdir` argument if you prefer an alternate location. Use `#| launcher:` front matter to customize the installed launcher. For example, `name` changes the installed command name, and `vanilla`, diff --git a/man/install_pkg_cli_apps.Rd b/man/install_pkg_cli_apps.Rd index 53db53f..7f7d5d4 100644 --- a/man/install_pkg_cli_apps.Rd +++ b/man/install_pkg_cli_apps.Rd @@ -72,10 +72,13 @@ If \code{destdir} is not provided, it is resolved in this order: } On Windows, the resolved \code{destdir} is explicitly added to \code{PATH} (it -generally is not by default). To disable adding it to the \code{PATH}, set envvar -\code{RAPP_NO_MODIFY_PATH=1}. +generally is not by default). On macOS, when the default \verb{~/.local/bin} +destination is not already on \code{PATH}, it is added to \verb{~/.zprofile}, or +\verb{$ZDOTDIR/.zprofile} when \code{ZDOTDIR} is set. If the profile cannot be +updated, a warning is emitted and installation continues. To disable adding +it to the \code{PATH}, set envvar \code{RAPP_NO_MODIFY_PATH=1}. -On macOS or Linux, \verb{~/.local/bin} is typically already on \code{PATH} if it +On Linux, \verb{~/.local/bin} is typically already on \code{PATH} if it exists. Note: some shells add \verb{~/.local/bin} to \code{PATH} only if it exists at login. If \code{install_pkg_cli_apps()} created the directory, you may need to restart the shell for the new apps to be found on \code{PATH}. diff --git a/tests/testthat/test-install.R b/tests/testthat/test-install.R index a746903..7268a85 100644 --- a/tests/testthat/test-install.R +++ b/tests/testthat/test-install.R @@ -213,6 +213,362 @@ test_that("non-Rapp executables respect overwrite flag", { }) +test_that("install_pkg_cli_apps adds default macOS install dir to PATH setup", { + skip_if_not(identical(Sys.info()[["sysname"]], "Darwin")) + skip_on_cran() + + home <- tempfile("rapp-home-") + dir.create(home, recursive = TRUE) + withr::local_envvar( + HOME = home, + RAPP_NO_MODIFY_PATH = NA, + RAPP_BIN_DIR = NA, + XDG_BIN_HOME = NA, + XDG_DATA_HOME = NA, + PATH = "/usr/bin" + ) + on.exit(unlink(home, recursive = TRUE), add = TRUE) + + pkg <- "rappTestMacPath" + fake <- setup_fake_rapp_package(tempdir(), "-install-mac-path", package = pkg) + on.exit(unlink(fake[["lib"]], recursive = TRUE), add = TRUE) + + app_path <- file.path(fake[["exec"]], "hello.R") + writeLines(c("#!/usr/bin/env Rapp", "print('hello')"), app_path) + + messages <- character() + created <- withCallingHandlers( + install_pkg_cli_apps(pkg, lib.loc = fake[["lib"]]), + message = function(m) { + messages <<- c(messages, conditionMessage(m)) + invokeRestart("muffleMessage") + } + ) + + destdir <- path(home, ".local", "bin") + expect_same_path(created, path(destdir, "hello")) + expect_true(any(grepl("Added .*\\.local/bin to PATH", messages))) + expect_true(any(grepl("source ~/.zprofile", messages, fixed = TRUE))) + + zprofile <- file.path(home, ".zprofile") + expect_true(file.exists(zprofile)) + expect_identical( + readLines(zprofile), + c( + "", + 'case ":$PATH:" in', + ' *:"$HOME/.local/bin":*) ;;', + ' *) export PATH="$HOME/.local/bin:$PATH" ;;', + "esac" + ) + ) + expect_true(grepl(destdir, Sys.getenv("PATH"), fixed = TRUE)) +}) + + +test_that("install_pkg_cli_apps adds explicit default macOS destdir to PATH setup", { + skip_if_not(identical(Sys.info()[["sysname"]], "Darwin")) + skip_on_cran() + + home <- tempfile("rapp-home-") + dir.create(home, recursive = TRUE) + withr::local_envvar( + HOME = home, + RAPP_NO_MODIFY_PATH = NA, + RAPP_BIN_DIR = NA, + XDG_BIN_HOME = NA, + XDG_DATA_HOME = NA, + PATH = "/usr/bin" + ) + on.exit(unlink(home, recursive = TRUE), add = TRUE) + destdir <- path(home, ".local", "bin") + + pkg <- "rappTestMacExplicitPath" + fake <- setup_fake_rapp_package( + tempdir(), + "-install-mac-explicit-path", + package = pkg + ) + on.exit(unlink(fake[["lib"]], recursive = TRUE), add = TRUE) + + app_path <- file.path(fake[["exec"]], "hello.R") + writeLines(c("#!/usr/bin/env Rapp", "print('hello')"), app_path) + + suppressMessages( + created <- install_pkg_cli_apps( + pkg, + destdir = "~/.local/bin", + lib.loc = fake[["lib"]] + ) + ) + + expect_same_path(created, path(destdir, "hello")) + expect_true(file.exists(file.path(home, ".zprofile"))) +}) + + +test_that("install_pkg_cli_apps respects ZDOTDIR for macOS PATH setup", { + skip_if_not(identical(Sys.info()[["sysname"]], "Darwin")) + skip_on_cran() + + home <- tempfile("rapp-home-") + zdotdir <- tempfile("rapp-zdotdir-") + dir.create(home, recursive = TRUE) + dir.create(zdotdir, recursive = TRUE) + withr::local_envvar( + HOME = home, + ZDOTDIR = zdotdir, + RAPP_NO_MODIFY_PATH = NA, + RAPP_BIN_DIR = NA, + XDG_BIN_HOME = NA, + XDG_DATA_HOME = NA, + PATH = "/usr/bin" + ) + on.exit(unlink(c(home, zdotdir), recursive = TRUE), add = TRUE) + + pkg <- "rappTestMacZdotdir" + fake <- setup_fake_rapp_package(tempdir(), "-install-mac-zdotdir", package = pkg) + on.exit(unlink(fake[["lib"]], recursive = TRUE), add = TRUE) + + app_path <- file.path(fake[["exec"]], "hello.R") + writeLines(c("#!/usr/bin/env Rapp", "print('hello')"), app_path) + + suppressMessages(install_pkg_cli_apps(pkg, lib.loc = fake[["lib"]])) + + expect_false(file.exists(file.path(home, ".zprofile"))) + expect_true(file.exists(file.path(zdotdir, ".zprofile"))) +}) + + +test_that("install_pkg_cli_apps warns and continues when macOS PATH setup cannot be written", { + skip_if_not(identical(Sys.info()[["sysname"]], "Darwin")) + skip_on_cran() + + home <- tempfile("rapp-home-") + dir.create(home, recursive = TRUE) + dir.create(file.path(home, ".zprofile")) + withr::local_envvar( + HOME = home, + RAPP_NO_MODIFY_PATH = NA, + RAPP_BIN_DIR = NA, + XDG_BIN_HOME = NA, + XDG_DATA_HOME = NA, + PATH = "/usr/bin" + ) + on.exit(unlink(home, recursive = TRUE), add = TRUE) + + pkg <- "rappTestMacPathReadonly" + fake <- setup_fake_rapp_package( + tempdir(), + "-install-mac-path-readonly", + package = pkg + ) + on.exit(unlink(fake[["lib"]], recursive = TRUE), add = TRUE) + + app_path <- file.path(fake[["exec"]], "hello.R") + writeLines(c("#!/usr/bin/env Rapp", "print('hello')"), app_path) + + expect_warning( + suppressMessages( + created <- install_pkg_cli_apps(pkg, lib.loc = fake[["lib"]]) + ), + "Could not add ~/.local/bin to PATH" + ) + expect_same_path(created, path(home, ".local", "bin", "hello")) +}) + + +test_that("install_pkg_cli_apps does not duplicate macOS PATH setup", { + skip_if_not(identical(Sys.info()[["sysname"]], "Darwin")) + skip_on_cran() + + home <- tempfile("rapp-home-") + dir.create(home, recursive = TRUE) + withr::local_envvar( + HOME = home, + RAPP_NO_MODIFY_PATH = NA, + RAPP_BIN_DIR = NA, + XDG_BIN_HOME = NA, + XDG_DATA_HOME = NA, + PATH = "/usr/bin" + ) + on.exit(unlink(home, recursive = TRUE), add = TRUE) + + pkg <- "rappTestMacPathTwice" + fake <- setup_fake_rapp_package(tempdir(), "-install-mac-path-twice", package = pkg) + on.exit(unlink(fake[["lib"]], recursive = TRUE), add = TRUE) + + app_path <- file.path(fake[["exec"]], "hello.R") + writeLines(c("#!/usr/bin/env Rapp", "print('hello')"), app_path) + + suppressMessages(install_pkg_cli_apps(pkg, lib.loc = fake[["lib"]])) + suppressMessages(install_pkg_cli_apps(pkg, lib.loc = fake[["lib"]])) + + zprofile <- file.path(home, ".zprofile") + lines <- readLines(zprofile) + expect_equal(sum(grepl('case ":\\$PATH:" in', lines)), 1L) +}) + + +test_that("install_pkg_cli_apps ignores unrelated macOS profile mentions", { + skip_if_not(identical(Sys.info()[["sysname"]], "Darwin")) + skip_on_cran() + + home <- tempfile("rapp-home-") + dir.create(home, recursive = TRUE) + withr::local_envvar( + HOME = home, + RAPP_NO_MODIFY_PATH = NA, + RAPP_BIN_DIR = NA, + XDG_BIN_HOME = NA, + XDG_DATA_HOME = NA, + PATH = "/usr/bin" + ) + on.exit(unlink(home, recursive = TRUE), add = TRUE) + + zprofile <- file.path(home, ".zprofile") + writeLines("# ~/.local/bin is where Rapp writes launchers", zprofile) + + pkg <- "rappTestMacPathComment" + fake <- setup_fake_rapp_package( + tempdir(), + "-install-mac-path-comment", + package = pkg + ) + on.exit(unlink(fake[["lib"]], recursive = TRUE), add = TRUE) + + app_path <- file.path(fake[["exec"]], "hello.R") + writeLines(c("#!/usr/bin/env Rapp", "print('hello')"), app_path) + + suppressMessages(install_pkg_cli_apps(pkg, lib.loc = fake[["lib"]])) + + expect_identical( + readLines(zprofile), + c( + "# ~/.local/bin is where Rapp writes launchers", + "", + 'case ":$PATH:" in', + ' *:"$HOME/.local/bin":*) ;;', + ' *) export PATH="$HOME/.local/bin:$PATH" ;;', + "esac" + ) + ) +}) + + +test_that("install_pkg_cli_apps warns when macOS PATH setup already exists but PATH is stale", { + skip_if_not(identical(Sys.info()[["sysname"]], "Darwin")) + skip_on_cran() + + home <- tempfile("rapp-home-") + dir.create(home, recursive = TRUE) + withr::local_envvar( + HOME = home, + RAPP_NO_MODIFY_PATH = NA, + RAPP_BIN_DIR = NA, + XDG_BIN_HOME = NA, + XDG_DATA_HOME = NA, + PATH = "/usr/bin" + ) + on.exit(unlink(home, recursive = TRUE), add = TRUE) + + zprofile <- file.path(home, ".zprofile") + writeLines( + c( + 'case ":$PATH:" in', + ' *:"$HOME/.local/bin":*) ;;', + ' *) export PATH="$HOME/.local/bin:$PATH" ;;', + "esac" + ), + zprofile + ) + + pkg <- "rappTestMacPathStale" + fake <- setup_fake_rapp_package( + tempdir(), + "-install-mac-path-stale", + package = pkg + ) + on.exit(unlink(fake[["lib"]], recursive = TRUE), add = TRUE) + + app_path <- file.path(fake[["exec"]], "hello.R") + writeLines(c("#!/usr/bin/env Rapp", "print('hello')"), app_path) + + expect_warning( + suppressMessages(install_pkg_cli_apps(pkg, lib.loc = fake[["lib"]])), + "~/.local/bin is still not on PATH" + ) + expect_equal(sum(grepl('case ":\\$PATH:" in', readLines(zprofile))), 1L) +}) + + +test_that("install_pkg_cli_apps leaves macOS profile alone when PATH already includes destdir", { + skip_if_not(identical(Sys.info()[["sysname"]], "Darwin")) + skip_on_cran() + + home <- tempfile("rapp-home-") + dir.create(home, recursive = TRUE) + destdir <- path(home, ".local", "bin") + withr::local_envvar( + HOME = home, + RAPP_NO_MODIFY_PATH = NA, + RAPP_BIN_DIR = NA, + XDG_BIN_HOME = NA, + XDG_DATA_HOME = NA, + PATH = paste(destdir, "/usr/bin", sep = .Platform$path.sep) + ) + on.exit(unlink(home, recursive = TRUE), add = TRUE) + + pkg <- "rappTestMacPathPresent" + fake <- setup_fake_rapp_package( + tempdir(), + "-install-mac-path-present", + package = pkg + ) + on.exit(unlink(fake[["lib"]], recursive = TRUE), add = TRUE) + + app_path <- file.path(fake[["exec"]], "hello.R") + writeLines(c("#!/usr/bin/env Rapp", "print('hello')"), app_path) + + suppressMessages(install_pkg_cli_apps(pkg, lib.loc = fake[["lib"]])) + + expect_false(file.exists(file.path(home, ".zprofile"))) +}) + + +test_that("install_pkg_cli_apps leaves macOS PATH setup alone when disabled", { + skip_if_not(identical(Sys.info()[["sysname"]], "Darwin")) + skip_on_cran() + + home <- tempfile("rapp-home-") + dir.create(home, recursive = TRUE) + withr::local_envvar( + HOME = home, + RAPP_NO_MODIFY_PATH = "1", + RAPP_BIN_DIR = NA, + XDG_BIN_HOME = NA, + XDG_DATA_HOME = NA, + PATH = "/usr/bin" + ) + on.exit(unlink(home, recursive = TRUE), add = TRUE) + + pkg <- "rappTestMacPathDisabled" + fake <- setup_fake_rapp_package( + tempdir(), + "-install-mac-path-disabled", + package = pkg + ) + on.exit(unlink(fake[["lib"]], recursive = TRUE), add = TRUE) + + app_path <- file.path(fake[["exec"]], "hello.R") + writeLines(c("#!/usr/bin/env Rapp", "print('hello')"), app_path) + + suppressMessages(install_pkg_cli_apps(pkg, lib.loc = fake[["lib"]])) + + expect_false(file.exists(file.path(home, ".zprofile"))) +}) + + test_that("front matter customises launcher options", { withr::local_envvar(RAPP_NO_MODIFY_PATH = "1")