diff --git a/CHANGELOG.md b/CHANGELOG.md index e122eaa..d877c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to `move` are documented here. 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). +## [1.3.2] - 2026-08-17 + +### Added +- `install.sh` is now interactive. When it finds an existing install and a + controlling terminal is available, it reports what's there and asks before + replacing it, instead of leaving `MOVE_FORCE` as the only control. If a + config file already exists it asks separately whether to reseed it from the + shipped defaults. Both questions are asked before anything is downloaded or + deleted, so declining changes nothing. +- `MOVE_RESEED_CONFIG=1` overwrites the user config with the shipped defaults + without prompting, for unattended use. The previous file is kept as + `config.json.bak`; the same backup is written when reseeding is confirmed + at the prompt. + +### Changed +- `MOVE_FORCE=1` now means "skip every prompt and reinstall unconditionally". + It deliberately does not touch the user config, so automation that + reinstalls the CLI can't take customizations down with it. +- Existing-install detection looks for the install tree and the wrapper, not + just the `.installed-version` marker, so a half-finished or hand-moved + install is caught rather than silently overwritten. + +### Fixed +- Installing a *different* version over an existing one used to wipe and + replace it with no warning; only an exact version match was ever reported. + That case now prompts. With no terminal (CI, cron, container builds) the + previous non-interactive behavior is preserved exactly: an identical + version is a no-op, a different version is replaced. + ## [1.3.1] - 2026-08-14 ### Added @@ -126,6 +155,7 @@ Initial release. - Source split into `src/{move,cli,config,keeper}.ts`. - `bin` entry + shebang so `bun link` registers `move` globally. +[1.3.2]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.1...v1.3.2 [1.3.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.0...v1.3.1 [1.3.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.2.0...v1.3.0 [1.2.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.1...v1.2.0 diff --git a/README.md b/README.md index 51a708a..5db57d8 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,38 @@ The installer respects the XDG Base Directory Specification: `XDG_BIN_HOME` is the widely-recognized de facto convention; XDG itself doesn't standardize a user bin dir. +### Reinstalling over an existing install + +The installer never replaces an existing install silently. When it finds +one and it can reach a terminal, it tells you what's there and asks: + +``` +==> Found an existing move install (v1.2.0) at /home/you/.local/share/move +Replace it with master? [Y/n] +``` + +If a config file already exists, it asks separately whether to overwrite +it with the shipped defaults (default: no). Both questions come *before* +anything is downloaded or deleted, so declining costs you nothing. + +This works under `curl ... | sh` too: the prompts read from `/dev/tty` +rather than stdin, which the piped script itself occupies. + +With no terminal available — CI, cron, a container build — there's nobody +to ask, so the installer falls back to its long-standing behavior: an +identical version is a no-op, a different version is replaced, and your +config is left alone. Use the env vars below to drive it explicitly. + Env vars (all optional): | Var | Default | Purpose | | --- | ------- | ------- | | `MOVE_VERSION` | `master` | Branch or tag to install. Pin with e.g. `v1.0.0`. | -| `MOVE_FORCE` | unset | Set to `1` to reinstall when the same version is already present. | +| `MOVE_FORCE` | unset | Set to `1` to skip every prompt and reinstall unconditionally. Never touches your config. | +| `MOVE_RESEED_CONFIG` | unset | Set to `1` to overwrite your config with the shipped defaults without asking. The old file is kept as `config.json.bak`. | | `XDG_DATA_HOME` | `$HOME/.local/share` | Where the source tree is installed (under `move/`). | | `XDG_BIN_HOME` | `$HOME/.local/bin` | Where the `move` wrapper is placed. | +| `XDG_CONFIG_HOME` | `$HOME/.config` | Where the config file lives (under `move/`). | Bun must already be installed; the installer fails with a clear pointer to if it isn't. @@ -123,11 +147,14 @@ ${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json ``` The installer seeds this file with the default values on a fresh install, -**only if no file already exists at that path**. Existing configs — yours -or from a previous install — are never overwritten. If you remove the -file later, `move` still works: missing defaults fall back to the values -baked into the binary (which match what was seeded, since both come from -`scripts/config.default.json`). +**only if no file already exists at that path**. An existing config — +yours or from a previous install — is never overwritten silently: the +installer asks first, and replaces it only if you say yes (or if you set +`MOVE_RESEED_CONFIG=1`), keeping the old file as `config.json.bak` either +way. `MOVE_FORCE=1` reinstalls the software but leaves your config alone. +If you remove the file later, `move` still works: missing defaults fall +back to the values baked into the binary (which match what was seeded, +since both come from `scripts/config.default.json`). Pass `-C` / `--config ` to point at a different file; in that mode the file must exist. diff --git a/package.json b/package.json index bcecd38..0445744 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "move", - "version": "1.3.1", + "version": "1.3.2", "private": true, "license": "GPL-3.0-only", "type": "module", diff --git a/scripts/install.sh b/scripts/install.sh index 47a51cd..86e4286 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -10,25 +10,41 @@ # 1. Detect platform; bail on anything @nut-tree-fork/nut-js doesn't ship. # 2. Require Bun; fail with a clear hint if missing (no auto-install). # 3. Resolve XDG-compliant install paths. -# 4. Idempotence check via a version marker file. -# 5. Download the source tarball from Gitea, extract under the install dir. +# 4. Detect an existing install and, on a terminal, ask before replacing it. +# 5. Download and build in a temp staging dir; swap it over the install +# dir only once it's complete, so a failed run can't destroy a working +# install. # 6. `bun install --production` (skips devDependencies). # 7. Drop a small wrapper script as `move` on the user's bin dir. -# 8. Seed the user's config file with defaults, only if one doesn't already -# exist at $XDG_CONFIG_HOME/move/config.json. +# 8. Seed the user's config file with defaults if one doesn't already exist +# at $XDG_CONFIG_HOME/move/config.json; if one does, offer to reseed it. # 9. Verify PATH, surface macOS Accessibility hint, print final status. # -# Env vars (all optional): -# MOVE_VERSION Branch or tag to install. Default: master. -# MOVE_FORCE Set to 1 to reinstall even if the version marker matches. -# XDG_DATA_HOME Source install root (default $HOME/.local/share). -# Final source location is $XDG_DATA_HOME/move. -# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin). -# Final binary location is $XDG_BIN_HOME/move. -# XDG_CONFIG_HOME User config root (default $HOME/.config). -# Default config file path is $XDG_CONFIG_HOME/move/config.json. +# Interactivity: +# When a controlling terminal is available, an existing install is never +# replaced without asking, and an existing config is never overwritten +# without asking. Both questions are put up front, before anything is +# downloaded or deleted, so declining costs nothing. With no terminal +# (CI, cron, container build) the script falls back to its historical +# non-interactive contract: an identical version is a no-op, a different +# version is replaced, and the config is left alone. # -# POSIX sh; no bashisms. +# Env vars (all optional): +# MOVE_VERSION Branch or tag to install. Default: master. +# MOVE_FORCE Set to 1 to skip every prompt and reinstall +# unconditionally. Does not touch the user config. +# MOVE_RESEED_CONFIG Set to 1 to overwrite the user config with the +# shipped defaults without asking. The previous file +# is saved alongside it as config.json.bak. +# XDG_DATA_HOME Source install root (default $HOME/.local/share). +# Final source location is $XDG_DATA_HOME/move. +# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin). +# Final binary location is $XDG_BIN_HOME/move. +# XDG_CONFIG_HOME User config root (default $HOME/.config). +# Default config file is $XDG_CONFIG_HOME/move/config.json. +# +# POSIX sh; no bashisms. Note the absence of `local`: helper functions use +# `_`-prefixed globals, which POSIX sh leaves us with. set -eu @@ -38,11 +54,13 @@ GITEA_HOST="gitea.cahlen.com" MOVE_VERSION="${MOVE_VERSION:-master}" MOVE_FORCE="${MOVE_FORCE:-0}" +MOVE_RESEED_CONFIG="${MOVE_RESEED_CONFIG:-0}" INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move" BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move" CONFIG_FILE="$CONFIG_DIR/config.json" +WRAPPER="$BIN_DIR/move" die() { printf 'Error: %s\n' "$1" >&2 @@ -62,6 +80,56 @@ assert_safe_dir() { esac } +# --- Interactive prompt support ---------------------------------------------- +# +# The documented entry point is `curl -fsSL ... | sh`, which means stdin is +# the *script source itself*. Reading a prompt answer from stdin would +# consume the rest of the program and truncate execution mid-run, so every +# prompt reads from /dev/tty directly. +# +# Detecting whether that's possible needs a real open(2) attempt. A `[ -r +# /dev/tty ]` test is not enough: the device node exists and is mode 0666 +# even in contexts with no controlling terminal (cron, CI, container +# builds), where opening it fails with ENXIO. The probe runs in a subshell +# because a redirection failure on `exec` -- a special built-in -- exits a +# non-interactive shell outright under POSIX. + +if (: >/dev/tty) 2>/dev/null; then + INTERACTIVE=1 +else + INTERACTIVE=0 +fi + +# confirm PROMPT DEFAULT -> 0 for yes, 1 for no. +# +# DEFAULT is 'y' or 'n' and is taken on a bare Enter or on EOF (^D), so the +# loop can't spin forever against a closed terminal. Prompts are written to +# /dev/tty rather than stdout so they stay visible when the caller redirects +# our output. +confirm() { + _prompt="$1" + _default="$2" + case "$_default" in + y) _hint='[Y/n]' ;; + *) _hint='[y/N]' ;; + esac + while :; do + printf '%s %s ' "$_prompt" "$_hint" > /dev/tty + if ! IFS= read -r _reply < /dev/tty; then + printf '\n' > /dev/tty + _reply='' + fi + if [ -z "$_reply" ]; then + _reply="$_default" + fi + case "$_reply" in + [yY] | [yY][eE][sS]) return 0 ;; + [nN] | [nN][oO]) return 1 ;; + *) printf "Please answer 'y' or 'n'.\n" > /dev/tty ;; + esac + done +} + # --- Prerequisite tools ------------------------------------------------------ for tool in curl tar mktemp; do @@ -100,53 +168,143 @@ fi BUN_VERSION=$(bun --version) printf '==> Using bun %s\n' "$BUN_VERSION" -# --- Idempotence check ------------------------------------------------------- +# --- Existing install check -------------------------------------------------- +# +# Both questions this script can ask are asked here, before anything is +# downloaded, deleted, or written. Declining therefore costs the user +# nothing, and no prompt appears minutes into a `bun install`. assert_safe_dir "$INSTALL_DIR" +assert_safe_dir "$CONFIG_DIR" -if [ "$MOVE_FORCE" != "1" ] && [ -f "$INSTALL_DIR/.installed-version" ]; then - CURRENT=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '') - if [ "$CURRENT" = "$MOVE_VERSION" ]; then - printf 'move %s is already installed at %s/move.\n' "$MOVE_VERSION" "$BIN_DIR" - printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n' - exit 0 +INSTALLED_VERSION='' +if [ -f "$INSTALL_DIR/.installed-version" ]; then + INSTALLED_VERSION=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '') +fi + +# Look wider than the version marker: a half-finished or hand-edited install +# can leave a tree or a wrapper behind without one, and steamrolling that +# silently is precisely what this check exists to prevent. +FOUND_EXISTING=0 +if [ -d "$INSTALL_DIR" ] || [ -e "$WRAPPER" ] || [ -L "$WRAPPER" ]; then + FOUND_EXISTING=1 +fi + +# Decided here, applied at the end -- the seed file it copies from only +# exists once the tarball has been extracted. +RESEED_CONFIG="$MOVE_RESEED_CONFIG" + +if [ "$FOUND_EXISTING" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then + if [ -n "$INSTALLED_VERSION" ]; then + printf '==> Found an existing move install (%s) at %s\n' \ + "$INSTALLED_VERSION" "$INSTALL_DIR" + else + printf '==> Found an existing move install at %s (version unknown)\n' \ + "$INSTALL_DIR" + fi + + if [ "$INTERACTIVE" = "1" ]; then + # The defaults below are chosen so that a bare Enter reproduces + # exactly what this script did before it learned to ask: skip when + # the version is identical, replace when it differs. + if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then + REPLACE_PROMPT="Reinstall move $MOVE_VERSION over it?" + REPLACE_DEFAULT=n + else + REPLACE_PROMPT="Replace it with $MOVE_VERSION?" + REPLACE_DEFAULT=y + fi + if ! confirm "$REPLACE_PROMPT" "$REPLACE_DEFAULT"; then + printf 'Leaving the existing install alone. Nothing was changed.\n' + exit 0 + fi + else + # Nowhere to ask, so fall back to the historical contract. + if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then + printf 'move %s is already installed at %s.\n' "$MOVE_VERSION" "$WRAPPER" + printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n' + exit 0 + fi + printf '==> No terminal available; replacing %s with %s\n' \ + "${INSTALLED_VERSION:-unknown}" "$MOVE_VERSION" fi fi -# --- Clean install dir ------------------------------------------------------- +if [ -e "$CONFIG_FILE" ] && [ "$RESEED_CONFIG" != "1" ] && + [ "$INTERACTIVE" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then + printf '==> A config file already exists at %s\n' "$CONFIG_FILE" + if confirm 'Overwrite it with the shipped defaults?' n; then + RESEED_CONFIG=1 + fi +fi + +# --- Stage, download, build -------------------------------------------------- +# +# Everything is assembled in a temp staging dir first; the existing install +# is removed only once the staged tree is fully built and ready to swap in. +# A failed download, extract, or `bun install` therefore leaves a working +# install untouched -- unlike the old flow, which wiped INSTALL_DIR before +# the download even started and left nothing behind on any failure. mkdir -p "$BIN_DIR" -rm -rf "$INSTALL_DIR" -mkdir -p "$INSTALL_DIR" -# --- Download source --------------------------------------------------------- +DATA_ROOT=$(dirname "$INSTALL_DIR") +mkdir -p "$DATA_ROOT" -TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz" TARBALL_TMP=$(mktemp) || die "could not create temp file" +# Stage on the same filesystem as INSTALL_DIR so the final swap is a rename, +# not a cross-device copy. +STAGE_DIR=$(mktemp -d "$DATA_ROOT/.move-stage.XXXXXX") || + { rm -f "$TARBALL_TMP"; die "could not create staging dir under $DATA_ROOT"; } +# On any exit, clean up the tarball and any leftover staging dir. After a +# successful swap STAGE_DIR has been renamed away, so the rm -rf is a no-op. cleanup() { rm -f "$TARBALL_TMP" + rm -rf "$STAGE_DIR" } trap cleanup EXIT INT TERM +TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz" + printf '==> Downloading %s\n' "$TARBALL_URL" if ! curl -fsSL "$TARBALL_URL" -o "$TARBALL_TMP"; then die "could not download $TARBALL_URL (check MOVE_VERSION='$MOVE_VERSION' and network)" fi -printf '==> Extracting source to %s\n' "$INSTALL_DIR" -if ! tar -xzf "$TARBALL_TMP" -C "$INSTALL_DIR" --strip-components=1; then +printf '==> Extracting source\n' +if ! tar -xzf "$TARBALL_TMP" -C "$STAGE_DIR" --strip-components=1; then die "could not extract tarball from $TARBALL_URL" fi -# --- Install runtime deps ---------------------------------------------------- - printf '==> Installing runtime dependencies (bun install --production)\n' -(cd "$INSTALL_DIR" && bun install --production) +(cd "$STAGE_DIR" && bun install --production) + +# Sanity-check the staged tree before we disturb the existing install: a +# truncated or wrong tarball that's missing the config seed should fail here, +# while the old install is still intact and swappable-out. +if [ ! -f "$STAGE_DIR/scripts/config.default.json" ]; then + die "downloaded tree is missing scripts/config.default.json (bad MOVE_VERSION='$MOVE_VERSION'?)" +fi + +# Record the version inside the staged tree so the install is self-consistent +# the instant it lands. +printf '%s\n' "$MOVE_VERSION" > "$STAGE_DIR/.installed-version" + +# --- Swap staged tree into place --------------------------------------------- +# +# The only destructive step, kept as late as possible: the window where +# INSTALL_DIR is absent is just this rm + rename, not the whole build. + +assert_safe_dir "$INSTALL_DIR" +printf '==> Installing to %s\n' "$INSTALL_DIR" +rm -rf "$INSTALL_DIR" +if ! mv "$STAGE_DIR" "$INSTALL_DIR"; then + die "could not move staged install into place at $INSTALL_DIR" +fi # --- Drop the wrapper -------------------------------------------------------- -WRAPPER="$BIN_DIR/move" printf '==> Writing wrapper to %s\n' "$WRAPPER" cat > "$WRAPPER" < "$INSTALL_DIR/.installed-version" - -# --- Seed user config file (only if absent) ---------------------------------- +# --- Seed user config file --------------------------------------------------- # # The defaults file shipped with the source tree (scripts/config.default.json) # is also the single source of truth for the runtime defaults loaded by # src/config.ts, so seeding a fresh user file from the same place keeps the # CLI behavior and the user-visible config in sync. # -# Strict policy: never overwrite an existing user config. The uninstaller -# follows the matching policy of never removing it; together that -# preserves user customizations unconditionally across (re)installs and -# uninstalls. +# Policy: an existing user config is never overwritten *silently*. It is +# replaced only on an explicit answer to the prompt above or an explicit +# MOVE_RESEED_CONFIG=1, and even then the previous file is kept as a .bak +# rather than destroyed. Everything else -- MOVE_FORCE=1, a non-interactive +# run -- leaves it untouched, so automation that reinstalls the software +# can't take a user's customizations down with it. The uninstaller follows +# the matching policy of never removing the config at all. -assert_safe_dir "$CONFIG_DIR" SEED_SRC="$INSTALL_DIR/scripts/config.default.json" if [ ! -f "$SEED_SRC" ]; then @@ -181,6 +337,11 @@ mkdir -p "$CONFIG_DIR" if [ ! -e "$CONFIG_FILE" ]; then cp "$SEED_SRC" "$CONFIG_FILE" printf '==> Wrote default config to %s\n' "$CONFIG_FILE" +elif [ "$RESEED_CONFIG" = "1" ]; then + cp "$CONFIG_FILE" "$CONFIG_FILE.bak" + cp "$SEED_SRC" "$CONFIG_FILE" + printf '==> Reseeded %s (previous file saved as %s)\n' \ + "$CONFIG_FILE" "$CONFIG_FILE.bak" else printf '==> Config already exists at %s; leaving it alone\n' "$CONFIG_FILE" fi