diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efe1c95..02f026f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: run: | actionlint zizmor --persona regular .github/workflows + - name: Check the install script + # Served at https://docs.configfile.sh/install.sh, run by any POSIX shell. + run: shellcheck --shell=sh docs/public/install.sh test: strategy: @@ -82,6 +85,57 @@ jobs: configfile --help HOME="$RUNNER_TEMP/home" configfile history + binary: + # Builds the standalone binary on each system it is made for, as + # publish.yml does for a release, and runs the end-to-end tests with it. + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15, macos-15-intel] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + with: + version: 2026.9.18 # mise version, updated by Renovate + # The CLI only: the documentation site (docs/) is built by the docs job. + - run: pnpm install --frozen-lockfile --filter configfile + - run: pnpm build:binary + - name: Install the binary with the install script + # As users get it: from a release laid out as on GitHub, with SHA256SUMS. + run: | + archive=$(ls release/configfile-*.tar.gz) + name=$(basename "$archive") + tar -tzf "$archive" | sort > "$RUNNER_TEMP/members" + printf '%s\n' ./ ./LICENSE ./THIRD-PARTY-LICENSES ./configfile | sort | + diff - "$RUNNER_TEMP/members" + release="$RUNNER_TEMP/releases/download/ci" + mkdir -p "$release" + cp "$archive" "$release/" + (cd "$release" && shasum -a 256 "$name" > SHA256SUMS) + CONFIGFILE_DOWNLOAD_URL="file://$RUNNER_TEMP/releases" CONFIGFILE_VERSION=ci \ + CONFIGFILE_INSTALL_DIR="$RUNNER_TEMP/bin" sh docs/public/install.sh + - name: Run the end-to-end tests with the installed binary + run: CONFIGFILE_BINARY="$RUNNER_TEMP/bin/configfile" pnpm vitest run tests/e2e.test.ts + - name: Answer a question on a terminal, whatever NODE_OPTIONS says + run: | + bin="$RUNNER_TEMP/bin/configfile" + export HOME="$RUNNER_TEMP/home" NODE_OPTIONS="--require /nonexistent.js" + mkdir -p "$HOME" + remote="$RUNNER_TEMP/remote" + git init --quiet "$remote" + git -C "$remote" -c user.name=ci -c user.email=ci@example.com commit --quiet --allow-empty -m first + # The URL is typed once the question is shown; the input stays open meanwhile. + answer() { sleep 3; printf '%s\r' "$remote"; sleep 10; } + if [ "$RUNNER_OS" = macOS ]; then + answer | script -q /dev/null "$bin" init + else + answer | script -qec "$bin init" /dev/null + fi + grep -F "$remote" "$HOME/.configfilerc" + docs: # Builds the documentation site (docs/), as docs-deploy.yml publishes it. runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 502d7b4..f7cf7a7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,16 +4,23 @@ name: Publish # master, the release branch of git-flow: # # - build: checks the tag, runs the checks and packs the package once; -# - github-release: publishes that tarball, its SHA256SUMS and the CHANGELOG -# section as a GitHub release, with a build provenance attestation -# (`gh attestation verify configfile-.tgz --repo mindsers/configfile`); +# - binaries: builds the standalone binary on each system it is made for +# (tools/build-binary.ts), and runs the end-to-end tests with it; +# - github-release: publishes that tarball, the binaries, their SHA256SUMS +# and the CHANGELOG section as a GitHub release, with a build provenance +# attestation for each archive (`gh attestation verify --repo +# mindsers/configfile`). +# The binaries keep the same names from one release to the next, so that +# docs/public/install.sh finds the latest one at releases/latest/download/; # - npm: publishes the same tarball to npm, with npm trusted publishing (no # token stored, provenance statement). It only runs when the repository # variable NPM_PUBLISH is "true", once trusted publishing is set up on # npmjs.com (package settings > Trusted publishing): repository # mindsers/configfile, workflow publish.yml, environment npm. To publish a # version released while it was off, set the variable and re-run this -# workflow for its tag: an existing GitHub release keeps its notes. +# workflow for its tag: an existing GitHub release keeps its notes and the +# files it already has (a rebuilt archive is not byte for byte the same, and +# would break the checksums users have); only missing files are added. on: push: @@ -53,11 +60,8 @@ jobs: run: | mkdir release npm pack --pack-destination release - cd release - [ -f "configfile-$TAG.tgz" ] || { + [ -f "release/configfile-$TAG.tgz" ] || { echo "::error::npm pack did not produce configfile-$TAG.tgz."; exit 1; } - sha256sum "configfile-$TAG.tgz" > SHA256SUMS - cat SHA256SUMS - name: Extract the release notes from the CHANGELOG env: TAG: ${{ github.ref_name }} @@ -75,8 +79,36 @@ jobs: path: release/ if-no-files-found: error - github-release: + binaries: needs: build + strategy: + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15, macos-15-intel] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # No cache: nothing restored from another run can end up in a release. + - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + with: + version: 2026.9.18 # mise version, updated by Renovate + cache: false + - run: pnpm install --frozen-lockfile --filter configfile + - run: pnpm build:binary + - name: Run the end-to-end tests with the binary + run: | + mkdir "$RUNNER_TEMP/binary" + tar -xzf release/configfile-*.tar.gz -C "$RUNNER_TEMP/binary" + CONFIGFILE_BINARY="$RUNNER_TEMP/binary/configfile" pnpm vitest run tests/e2e.test.ts + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: binary-${{ matrix.os }} + path: release/configfile-*.tar.gz + if-no-files-found: error + + github-release: + needs: [build, binaries] runs-on: ubuntu-latest permissions: contents: write # create the release @@ -86,23 +118,57 @@ jobs: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: binary-* + merge-multiple: true + - name: Keep the files of an existing release + id: keep + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \ + --jq '.assets[].name' > published.txt 2> /dev/null || : > published.txt + # Kept aside until attested: only what this run built is attested. + mkdir published + grep '^configfile-' published.txt | while read -r name; do + rm -f "$name" + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir published + echo "::notice::$name is kept as published." + done + if ls configfile-* > /dev/null 2>&1; then + echo "built=true" >> "$GITHUB_OUTPUT" + fi + # Attests the files built by this run only: a published file, which may + # have been uploaded by hand, keeps the attestation it has (if any). - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + if: steps.keep.outputs.built == 'true' with: - subject-path: configfile-${{ github.ref_name }}.tgz + subject-path: configfile-* + - name: Compute the checksums + run: | + find published -type f -exec mv {} . \; + sha256sum configfile-* > SHA256SUMS + cat SHA256SUMS - name: Create the GitHub release env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} run: | - # A release created by hand, or by an earlier run: its notes are kept, - # and the files attested above replace any older ones. + # A release created by hand, or by an earlier run: its notes and files + # are kept, the missing files added, and SHA256SUMS (which covers the + # files as published) replaced. if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then - gh release upload "$TAG" "configfile-$TAG.tgz" SHA256SUMS \ - --repo "$GITHUB_REPOSITORY" --clobber - echo "::notice::The $TAG release already existed: its notes are kept, its files replaced." + for file in configfile-*; do + grep -qxF "$file" published.txt || + gh release upload "$TAG" "$file" --repo "$GITHUB_REPOSITORY" + done + gh release upload "$TAG" SHA256SUMS --repo "$GITHUB_REPOSITORY" --clobber + echo "::notice::The $TAG release already existed: its notes and files are kept, missing files added." exit 0 fi - gh release create "$TAG" "configfile-$TAG.tgz" SHA256SUMS \ + gh release create "$TAG" configfile-* SHA256SUMS \ --repo "$GITHUB_REPOSITORY" --verify-tag \ --title "$TAG" --notes-file notes.md diff --git a/.gitignore b/.gitignore index 7efa14f..0b9efe7 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ dist/ # Astro (docs/) .astro/ +release/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d07820d..afcbe64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Standalone binaries for macOS (13.5 or later) and Linux (GNU C library 2.28 or later), on x64 + and arm64, with each release, and an install script: + `curl -fsSL https://docs.configfile.sh/install.sh | sh` installs configfile on a new machine + without Node.js or a package manager ([#301](https://github.com/mindsers/configfile/issues/301)). - Scripts get the repository folder, their name and the system in the `CONFIGFILE_REPO`, `CONFIGFILE_SCRIPT` and `CONFIGFILE_OS` environment variables ([#295](https://github.com/mindsers/configfile/issues/295)). diff --git a/README.md b/README.md index 63f16f2..9a20f58 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ configfile update # later: brings the chan ## Installation +With one command, on macOS or Linux (no package manager or Node.js needed): + +```bash +curl -fsSL https://docs.configfile.sh/install.sh | sh +``` + With [Homebrew](https://brew.sh), on macOS or Linux (Node.js and git come with it): ```bash diff --git a/docs/public/install.sh b/docs/public/install.sh new file mode 100644 index 0000000..dee1ec8 --- /dev/null +++ b/docs/public/install.sh @@ -0,0 +1,130 @@ +#!/bin/sh +# Installs configfile on macOS or Linux, without Node.js or a package manager: +# +# curl -fsSL https://docs.configfile.sh/install.sh | sh +# +# It downloads the standalone binary for this system from the GitHub release, +# checks it against the release's SHA256SUMS, and installs it as +# ~/.local/bin/configfile. Run it again to update configfile. +# +# Settings, as environment variables (put them before `sh`): +# CONFIGFILE_VERSION the version to install (default: the latest release; +# releases have binaries from the one that +# introduced them, 1.0.0 has none) +# CONFIGFILE_INSTALL_DIR where to install it (default: ~/.local/bin) +# CONFIGFILE_DOWNLOAD_URL where releases are downloaded from (default: the +# GitHub releases of mindsers/configfile; for tests +# and mirrors) +# +# Source: https://github.com/mindsers/configfile/blob/master/docs/public/install.sh + +set -eu + +releases="${CONFIGFILE_DOWNLOAD_URL:-https://github.com/mindsers/configfile/releases}" + +say() { printf '%s\n' "$*"; } +fail() { + printf 'configfile installer: %s\n' "$*" >&2 + exit 1 +} + +# The name of the archive for this system, such as configfile-macos-arm64.tar.gz. +archive_name() { + case "$(uname -s)" in + Darwin) system=macos ;; + Linux) system=linux ;; + *) fail "no configfile binary for $(uname -s): it runs on macOS and Linux." ;; + esac + case "$(uname -m)" in + x86_64 | amd64) arch=x64 ;; + arm64 | aarch64) arch=arm64 ;; + *) fail "no configfile binary for $(uname -m) processors: install it with npm instead (https://docs.configfile.sh/getting-started/installation/)." ;; + esac + # A shell running under Rosetta reports x86_64 on Apple silicon. + if [ "$system" = macos ] && [ "$arch" = x64 ] && + [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = 1 ]; then + arch=arm64 + fi + # The binaries are built for the GNU C library, which Alpine doesn't use. + if [ "$system" = linux ] && ldd --version 2>&1 | grep -qi musl; then + fail "the configfile binary needs the GNU C library, not musl (Alpine): install it with npm instead (https://docs.configfile.sh/getting-started/installation/)." + fi + say "configfile-$system-$arch.tar.gz" +} + +download() { # url file + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 2 -o "$2" "$1" || fail "cannot download $1." + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$2" "$1" || fail "cannot download $1." + else + fail "curl or wget is needed to download configfile." + fi +} + +sha256() { # file + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d ' ' -f 1 + else + fail "sha256sum or shasum is needed to check the download." + fi +} + +main() { + name="$(archive_name)" + version="${CONFIGFILE_VERSION:-latest}" + if [ "$version" = latest ]; then + base="$releases/latest/download" + else + base="$releases/download/$version" + fi + dir="${CONFIGFILE_INSTALL_DIR:-$HOME/.local/bin}" + + work="$(mktemp -d)" + draft="$dir/.configfile.$$" + trap 'rm -rf "$work"; rm -f "$draft"' EXIT + # Stopped (Ctrl+C, kill): ends here, the exit trap cleaning up. + trap 'exit 130' INT + trap 'exit 143' TERM + + say "Downloading $base/$name..." + download "$base/$name" "$work/$name" + download "$base/SHA256SUMS" "$work/SHA256SUMS" + expected="$(awk -v name="$name" '$2 == name || $2 == "*" name { print $1 }' "$work/SHA256SUMS")" + [ -n "$expected" ] || fail "SHA256SUMS of the release has no line for $name." + actual="$(sha256 "$work/$name")" + [ "$actual" = "$expected" ] || + fail "$name does not match the checksum of the release: it was not installed." + + tar -xzf "$work/$name" -C "$work" ./configfile || fail "cannot extract $name." + mkdir -p "$dir" || fail "cannot create $dir." + { cp "$work/configfile" "$draft" && chmod 755 "$draft"; } || fail "cannot install configfile in $dir." + # Checked before it replaces anything: an installed configfile is kept when + # the new one cannot run on this system. + installed="$("$draft" --version)" || + fail "the downloaded configfile does not start on this system: nothing was changed in $dir." + # Moved into place in one step, so a configfile running meanwhile is not cut short. + mv -f "$draft" "$dir/configfile" || fail "cannot install configfile in $dir." + say "configfile $installed is installed in $dir/configfile." + + case ":$PATH:" in + *":$dir:"*) + found="$(command -v configfile || true)" + if [ -n "$found" ] && [ "$found" != "$dir/configfile" ]; then + say "Another configfile comes first in your PATH: $found. Remove it, or put $dir first." + fi + ;; + *) say "$dir is not in your PATH: add it to your shell's profile, for example: export PATH=\"$dir:\$PATH\"" ;; + esac + if ! command -v git >/dev/null 2>&1; then + case "$(uname -s)" in + Darwin) say "configfile needs git: install it with xcode-select --install" ;; + *) say "configfile needs git: install it with your system's package manager." ;; + esac + fi + say 'Next: run "configfile init" to connect it to your dotfiles repository.' +} + +main diff --git a/docs/src/content/docs/getting-started/installation.md b/docs/src/content/docs/getting-started/installation.md index 2190254..de79e80 100644 --- a/docs/src/content/docs/getting-started/installation.md +++ b/docs/src/content/docs/getting-started/installation.md @@ -1,25 +1,56 @@ --- title: Installation -description: Install configfile on macOS or Linux, with Homebrew or npm. +description: Install configfile on macOS or Linux, with one command, Homebrew or npm. sidebar: order: 1 --- ## Requirements -- macOS or Linux -- Node.js 24.11 or later (Homebrew installs it for you) -- git (Homebrew installs it for you; otherwise, when it is missing, *configfile* says how to install it on your system) +- macOS or Linux, on an Intel/AMD (x64) or ARM (arm64) processor +- git (when it is missing, *configfile* says how to install it on your system) + +Installed with Homebrew or npm, *configfile* also needs Node.js 24.11 or later (Homebrew installs it for you). The standalone binary doesn't: it comes with its own, and needs macOS 13.5 or later, or Linux with the GNU C library 2.28 or later. Its install script needs `curl` or `wget`, which most systems have. ## Install -With [Homebrew](https://brew.sh), on macOS or Linux (Node.js and git come with it): +### With one command + +On a new machine, this is the quickest way: no package manager and no Node.js needed. + +```bash +curl -fsSL https://docs.configfile.sh/install.sh | sh +``` + +The [script](https://github.com/mindsers/configfile/blob/master/docs/public/install.sh) downloads the standalone binary for your system from the [latest GitHub release](https://github.com/mindsers/configfile/releases/latest), checks it against the release's `SHA256SUMS`, and installs it as `~/.local/bin/configfile`, once it has checked that the binary starts on your system (a configfile already installed is otherwise left as it is). It says when that folder is not in your `PATH`, and how to add it. Two environment variables change what it does: + +| Variable | Effect | +| --- | --- | +| `CONFIGFILE_VERSION` | the version to install, instead of the latest (releases have binaries from the one that introduced them; 1.0.0 has none) | +| `CONFIGFILE_INSTALL_DIR` | where to install it, instead of `~/.local/bin` | + +```bash +# For every user of the machine (/usr/local/bin needs administrator rights). +curl -fsSL https://docs.configfile.sh/install.sh | sudo CONFIGFILE_INSTALL_DIR=/usr/local/bin sh +``` + +Run the command again to update *configfile*, with the same `CONFIGFILE_INSTALL_DIR` if you set one (`configfile update` syncs your dotfiles, not *configfile* itself). To uninstall it, delete `~/.local/bin/configfile`; your configuration (`~/.configfilerc`, `~/.configfile/`) stays. + +On Linux, the binaries need the GNU C library: on Alpine (musl), or a system older than the requirements above, install with npm. On macOS, a binary downloaded with a web browser is quarantined by Gatekeeper, as it is not notarized: use the install script, or run `xattr -d com.apple.quarantine configfile` on it. + +The binary's own Node.js runs *configfile* only: a `.js` (`.mjs`, `.cjs`) [script](/reference/scripts/) without a shebang line runs with the `node` in your `PATH`, and fails with a message saying so when Node.js is not installed. + +### With Homebrew + +On macOS or Linux (Node.js and git come with it): ```bash brew install mindsers/tap/configfile ``` -With npm, when Node.js 24.11 or later is installed, from the [GitHub release](https://github.com/mindsers/configfile/releases/latest): +### With npm + +When Node.js 24.11 or later is installed, from the [GitHub release](https://github.com/mindsers/configfile/releases/latest): ```bash npm install --global https://github.com/mindsers/configfile/releases/download/1.0.0/configfile-1.0.0.tgz @@ -27,4 +58,12 @@ npm install --global https://github.com/mindsers/configfile/releases/download/1. Install it one way only: two copies would compete in your `PATH`. -> Version 1.0 is not on the npm registry yet: `npm install --global configfile` still installs 0.3.1. Each release's tarball comes with a `SHA256SUMS` file and a build provenance attestation, which `gh attestation verify configfile-1.0.0.tgz --repo mindsers/configfile` checks. +> Version 1.0 is not on the npm registry yet: `npm install --global configfile` still installs 0.3.1. + +## Check a download + +Each release comes with a `SHA256SUMS` file, which the install script uses, and a build provenance attestation for each archive (the `.tgz` package and every `.tar.gz` binary), which shows it was built by the project's release workflow from the tagged source. Download the archive, then: + +```bash +gh attestation verify configfile-macos-arm64.tar.gz --repo mindsers/configfile +``` diff --git a/docs/src/content/docs/guides/new-machine.md b/docs/src/content/docs/guides/new-machine.md index f51d393..7c48538 100644 --- a/docs/src/content/docs/guides/new-machine.md +++ b/docs/src/content/docs/guides/new-machine.md @@ -9,13 +9,13 @@ This guide takes a machine from nothing to your configuration, using a dotfiles ## 1. Install configfile -On macOS, install [Homebrew](https://brew.sh) first if it isn't there yet, then: +On a new machine, install the standalone binary, which needs neither a package manager nor Node.js: ```sh -brew install mindsers/tap/configfile +curl -fsSL https://docs.configfile.sh/install.sh | sh ``` -Homebrew also installs Node.js and git. [Installation](/getting-started/installation/) lists the other ways. +It installs `~/.local/bin/configfile`, and says how to add that folder to your `PATH` when it is not there. *configfile* also needs git: on macOS, `xcode-select --install` installs it. [Installation](/getting-started/installation/) lists the other ways, such as Homebrew. ## 2. Give the machine access to your repository @@ -77,7 +77,10 @@ To set up machines regularly, put the steps in a script. Without a terminal to a #!/bin/sh set -e -brew install mindsers/tap/configfile +# Downloaded first: in "curl ... | sh", set -e would not see a failed download. +curl -fsSL https://docs.configfile.sh/install.sh -o /tmp/install-configfile.sh +sh /tmp/install-configfile.sh +export PATH="$HOME/.local/bin:$PATH" configfile init --repo git@github.com:me/dotfiles.git configfile modules deploy --all configfile scripts run setup diff --git a/docs/src/content/docs/guides/upgrading-from-0-3.md b/docs/src/content/docs/guides/upgrading-from-0-3.md index 3a5eee4..7dd0e1f 100644 --- a/docs/src/content/docs/guides/upgrading-from-0-3.md +++ b/docs/src/content/docs/guides/upgrading-from-0-3.md @@ -5,7 +5,7 @@ sidebar: order: 4 --- -- Node.js 24.11 or later is required, on macOS or Linux. Install again as described in [Installation](/getting-started/installation/). To switch to Homebrew, remove the npm version first (`npm uninstall --global configfile`). +- With Homebrew or npm, Node.js 24.11 or later is required, on macOS or Linux; the standalone binary needs none. Install again as described in [Installation](/getting-started/installation/). To switch to Homebrew or the install script, remove the npm version first (`npm uninstall --global configfile`). - `~/.configfilerc`, the repository layout and `settings.json` work as before, including `settings.json` files that are a plain list (the 0.3.1 format, deprecated: put the list in a `"files"` key). Links deployed by 0.3 are recognised as deployed. - If you installed 0.3 with Yarn, remove it first: `yarn global remove configfile`. - `"global": true | false` still works but is deprecated: replace it with `"deploy": "global" | "local"`. diff --git a/docs/src/content/docs/reference/scripts.md b/docs/src/content/docs/reference/scripts.md index fce0434..38752c9 100644 --- a/docs/src/content/docs/reference/scripts.md +++ b/docs/src/content/docs/reference/scripts.md @@ -12,7 +12,7 @@ A script can have a version for each system: `macos` or `linux` after the first Scripts can be written in any language: - a script with a shebang line (such as `#!/usr/bin/env python3`) is run directly when it is executable, and with that interpreter otherwise; -- a `.js` (or `.mjs`, `.cjs`) or `.sh` script without shebang line is run with `node` or `sh`; +- a `.js` (or `.mjs`, `.cjs`) or `.sh` script without shebang line is run with Node.js or `sh`: *configfile*'s own Node.js when installed with Homebrew or npm, the `node` in your `PATH` with the [standalone binary](/getting-started/installation/) (the script then fails, saying so, when there is none); - any other executable file (such as a compiled program) is run directly. *configfile* never changes the permissions of your files. Scripts run in the current folder and their output is not modified. `scripts run` doesn't wait for other *configfile* commands: don't run a script while `update` syncs the repository, as it could read files being replaced. With `configfile scripts run`, the messages of *configfile* go to stderr and the script's exit code is forwarded. diff --git a/package.json b/package.json index 93383fb..f43ef93 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "docs:dev": "pnpm --filter configfile-docs dev", "docs:build": "pnpm --filter configfile-docs build", "docs:commands": "node tools/docs-commands.ts", + "build:binary": "node tools/build-binary.ts", "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "prepack": "node --run clean && tsc -p tsconfig.build.json" }, @@ -57,6 +58,8 @@ "@types/node": "24.19.0", "@vitest/coverage-v8": "5.0.3", "ajv": "8.20.0", + "esbuild": "0.28.2", + "postject": "1.0.0-alpha.6", "typescript": "7.0.2", "vitest": "5.0.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca1b227..465e94e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,12 @@ importers: ajv: specifier: 8.20.0 version: 8.20.0 + esbuild: + specifier: 0.28.2 + version: 0.28.2 + postject: + specifier: 1.0.0-alpha.6 + version: 1.0.0-alpha.6 typescript: specifier: 7.0.2 version: 7.0.2 @@ -1438,6 +1444,10 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + common-ancestor-path@2.0.0: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} @@ -2014,6 +2024,11 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -3435,6 +3450,8 @@ snapshots: commander@15.0.0: {} + commander@9.5.0: {} + common-ancestor-path@2.0.0: {} cookie-es@1.2.3: {} @@ -4153,6 +4170,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + prismjs@1.30.0: {} process-ancestry@0.1.0: {} diff --git a/src/cli.ts b/src/cli.ts index 6064135..1a8c9fa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,11 +8,26 @@ import { interactivePrompts, nonInteractivePrompts } from './prompts.ts' const home = os.homedir() -process.exitCode = await main(process.argv.slice(2), { +// No top-level await: the standalone binaries run this file as CommonJS. +let settled = false +void main(process.argv.slice(2), { home, cwd: process.cwd(), platform: process.platform, output: new Output(process.stdout, process.stderr), prompts: process.stdin.isTTY ? interactivePrompts : nonInteractivePrompts, history: new History(home), +}).then( + code => { + settled = true + process.exitCode = code + }, + error => { + settled = true + throw error + }, +) +// As a top-level await that never settles: a run that stops halfway is not a success. +process.on('exit', () => { + if (!settled) process.exitCode = 13 }) diff --git a/src/process.ts b/src/process.ts index 57428d4..58af068 100644 --- a/src/process.ts +++ b/src/process.ts @@ -3,6 +3,7 @@ import { constants, type Stats } from 'node:fs' import { access, open, readFile, stat } from 'node:fs/promises' import { constants as osConstants } from 'node:os' import path from 'node:path' +import { isSea } from 'node:sea' import { CliError } from './errors.ts' import { errnoCode, messageOf } from './fsutil.ts' @@ -84,11 +85,17 @@ interface RunOptions { onSignal?: ((signal: NodeJS.Signals) => void) | undefined } +/** + * The Node.js that runs JavaScript scripts: configfile's own, or in a + * standalone binary (which runs nothing but configfile) the `node` in PATH. + */ +const NODE = isSea() ? 'node' : process.execPath + /** Used for scripts that have no shebang line. */ const INTERPRETERS: Record = { - '.js': process.execPath, - '.mjs': process.execPath, - '.cjs': process.execPath, + '.js': NODE, + '.mjs': NODE, + '.cjs': NODE, '.sh': 'sh', } @@ -98,7 +105,8 @@ const INTERPRETERS: Record = { * - a script with a shebang line (`#!/usr/bin/env python3`) is run directly * when executable, otherwise with that interpreter (arguments of the * shebang line are split on spaces); - * - a `.js` or `.sh` script without shebang line is run with node or sh; + * - a `.js` (`.mjs`, `.cjs`) or `.sh` script without shebang line is run with + * Node.js (see `NODE`) or sh; * - any other executable file (such as a compiled program) is run directly. */ export async function runScript( @@ -125,6 +133,23 @@ export async function runScript( } const interpreter = INTERPRETERS[path.extname(script.path)] + if (interpreter === 'node') { + try { + return await run(interpreter, [script.path, ...args], { cwd, env, onSignal }) + } catch (error) { + if (errnoCode(error) === 'EACCES') { + throw new CliError( + `Cannot run script "${script.name}": the node found in PATH cannot be executed ` + + '(permission denied).', + ) + } + if (errnoCode(error) !== 'ENOENT') throw launchError(script, undefined, error) + throw new CliError( + `Script "${script.name}" needs Node.js, which is not installed. Install it, or add a ` + + 'shebang line (such as "#!/bin/sh") to run it with another program.', + ) + } + } if (interpreter != null) return launch(interpreter, [script.path, ...args]) if (executable) return launch(script.path, args) diff --git a/src/program.ts b/src/program.ts index 950de6d..b1752ce 100644 --- a/src/program.ts +++ b/src/program.ts @@ -13,16 +13,24 @@ import { CliError, isPromptExit } from './errors.ts' import { messageOf } from './fsutil.ts' import { describeInvocation, type Invocation, shouldRecord } from './history.ts' -const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { - version: string -} +/** Set by tools/build-binary.ts: a standalone binary has no package.json to read. */ +declare const CONFIGFILE_VERSION: string | undefined + +const version = + typeof CONFIGFILE_VERSION === 'string' + ? CONFIGFILE_VERSION + : ( + JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { + version: string + } + ).version export function buildProgram(ctx: Context): Command { // showHelpAfterError, exitOverride and configureOutput are copied to // subcommands when they are created, so they are set before registering them. const program = new Command('configfile') .description('Manage your configuration files from a dotfiles git repository.') - .version(pkg.version) + .version(version) .showHelpAfterError('(add --help for additional information)') .exitOverride() .configureOutput({ @@ -56,7 +64,7 @@ export async function main(args: string[], ctx: Context): Promise { if (shouldRecord(invocation, error)) { const failed = await ctx.history.save({ time, - version: pkg.version, + version, invocation, cwd: ctx.cwd, exitCode: code, diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 0636e47..351a06d 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -11,17 +11,27 @@ import { createSandbox, type Sandbox } from './helpers.ts' // Built by tests/global-setup.ts before the test run. const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)) +/** + * The command that runs configfile as users do: the built package, or the + * standalone binary named by CONFIGFILE_BINARY (tools/build-binary.ts). + */ +function command(args: string[]): [string, string[]] { + const binary = process.env.CONFIGFILE_BINARY + return binary ? [binary, args] : [process.execPath, [cli, ...args]] +} + async function exec(args: string[]) { const sandbox = await createSandbox() await sandbox.configure() await sandbox.write('home/dotfiles/files/zsh/settings.json', '{"files": []}') await sandbox.write('home/dotfiles/scripts/fail.sh', 'exit 4\n') + await sandbox.write('home/dotfiles/scripts/hello.js', 'console.log("hello from Node.js")\n') const env: NodeJS.ProcessEnv = { ...process.env, HOME: sandbox.home, NO_COLOR: '1' } // FORCE_COLOR (set by many CI and shells) would override NO_COLOR. delete env.FORCE_COLOR - const result = spawnSync(process.execPath, [cli, ...args], { + const result = spawnSync(...command([...args]), { cwd: sandbox.cwd, env, encoding: 'utf8', @@ -31,6 +41,23 @@ async function exec(args: string[]) { } describe('built CLI', () => { + it('says its version', async () => { + const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) + + await expect(exec(['--version'])).resolves.toMatchObject({ + code: 0, + stdout: `${pkg.version}\n`, + }) + }) + + it('runs a JavaScript script without shebang line, with Node.js', async () => { + // In a standalone binary, with the node found in PATH. + await expect(exec(['scripts', 'run', 'hello'])).resolves.toMatchObject({ + code: 0, + stdout: 'hello from Node.js\n', + }) + }) + it('runs a command', async () => { await expect(exec(['modules', 'list'])).resolves.toMatchObject({ code: 0, @@ -85,7 +112,7 @@ describe('built CLI', () => { await sandbox.configure() await sandbox.write('home/dotfiles/scripts/long.sh', script) - const child = spawn(process.execPath, [cli, ...args], { + const child = spawn(...command([...args]), { cwd: sandbox.cwd, env: { ...process.env, HOME: sandbox.home, NO_COLOR: '1' }, detached: true, @@ -183,7 +210,7 @@ describe('built CLI', () => { const sandbox = await createSandbox() await withModules(sandbox, { pre_undeploy: 'long' }) await sandbox.configure() - spawnSync(process.execPath, [cli, 'modules', 'deploy', '--all'], { + spawnSync(...command(['modules', 'deploy', '--all']), { cwd: sandbox.cwd, env: { ...process.env, HOME: sandbox.home }, }) @@ -220,15 +247,11 @@ describe('built CLI', () => { it('says how to install git when it is missing, before changing anything', async () => { const sandbox = await createSandbox() - const result = spawnSync( - process.execPath, - [cli, 'init', '--repo', 'https://example.com/dotfiles.git'], - { - cwd: sandbox.cwd, - env: { ...process.env, HOME: sandbox.home, NO_COLOR: '1', PATH: '/nonexistent' }, - encoding: 'utf8', - }, - ) + const result = spawnSync(...command(['init', '--repo', 'https://example.com/dotfiles.git']), { + cwd: sandbox.cwd, + env: { ...process.env, HOME: sandbox.home, NO_COLOR: '1', PATH: '/nonexistent' }, + encoding: 'utf8', + }) expect(result.status).toBe(1) expect(result.stderr).toMatch(/git is not installed\. Install it with/) @@ -241,7 +264,7 @@ describe('built CLI', () => { await sandbox.configure() await mkdir(path.join(sandbox.repo, '.git'), { recursive: true }) - const result = spawnSync(process.execPath, [cli, 'update'], { + const result = spawnSync(...command(['update']), { cwd: sandbox.cwd, env: { ...process.env, HOME: sandbox.home, NO_COLOR: '1', PATH: '/nonexistent' }, encoding: 'utf8', @@ -259,7 +282,7 @@ describe('built CLI', () => { const pidFile = path.join(sandbox.root, 'git.pid') await sandbox.write('bin/git', `#!/bin/sh\necho $$ > "${pidFile}"\nexec sleep 60\n`, 0o755) - const child = spawn(process.execPath, [cli, 'update'], { + const child = spawn(...command(['update']), { cwd: sandbox.cwd, env: { ...process.env, @@ -308,7 +331,7 @@ describe('built CLI', () => { const runs = modules.map( name => new Promise(resolve => { - const child = spawn(process.execPath, [cli, 'modules', 'deploy', name], { + const child = spawn(...command(['modules', 'deploy', name]), { cwd: sandbox.cwd, env: { ...process.env, HOME: sandbox.home, NO_COLOR: '1' }, stdio: 'ignore', @@ -326,7 +349,7 @@ describe('built CLI', () => { // Undeploying every module unwinds the backups down to the original file. for (const _ of modules) { - spawnSync(process.execPath, [cli, 'modules', 'undeploy', '--all'], { + spawnSync(...command(['modules', 'undeploy', '--all']), { cwd: sandbox.cwd, env: { ...process.env, HOME: sandbox.home, NO_COLOR: '1' }, }) @@ -346,7 +369,7 @@ describe('built CLI', () => { { length: 16 }, () => new Promise(resolve => { - const child = spawn(process.execPath, [cli, 'scripts', 'run', 'ok'], { + const child = spawn(...command(['scripts', 'run', 'ok']), { cwd: sandbox.cwd, env, stdio: 'ignore', @@ -395,7 +418,7 @@ describe('built CLI', () => { { length: 16 }, () => new Promise(resolve => { - spawn(process.execPath, [cli, 'scripts', 'run', 'ok'], { + spawn(...command(['scripts', 'run', 'ok']), { cwd: sandbox.cwd, env, stdio: 'ignore', diff --git a/tests/install.test.ts b/tests/install.test.ts new file mode 100644 index 0000000..9a8745b --- /dev/null +++ b/tests/install.test.ts @@ -0,0 +1,365 @@ +// The install script (docs/public/install.sh, served at +// https://docs.configfile.sh/install.sh), run against a fake release on disk. +// Fake `uname`, `sysctl` and `ldd` commands, first in PATH, play the system. + +import { execFileSync, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, readFile, symlink, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { archiveName } from '../tools/build-binary.ts' +import { createSandbox, type Sandbox } from './helpers.ts' + +const script = fileURLToPath(new URL('../docs/public/install.sh', import.meta.url)) + +/** Every archive a release has, by the system (`uname -s`, `uname -m`) it is for. */ +const ARCHIVES: [string, string, string][] = [ + ['Darwin', 'arm64', archiveName('darwin', 'arm64')], + ['Darwin', 'x86_64', archiveName('darwin', 'x64')], + ['Linux', 'x86_64', archiveName('linux', 'x64')], + ['Linux', 'amd64', archiveName('linux', 'x64')], + ['Linux', 'aarch64', archiveName('linux', 'arm64')], + ['Linux', 'arm64', archiveName('linux', 'arm64')], +] +/** The system the tests play by default: Linux on x64. */ +const NAME = archiveName('linux', 'x64') + +/** + * A release in /releases/, laid out as on GitHub: an archive + * per system holding a stand-in for the binary (`binary`, which says its + * version by default), and SHA256SUMS. `sums` rewrites the lines of SHA256SUMS. + */ +async function withRelease( + sandbox: Sandbox, + version: string, + { + folder = `download/${version}`, + binary = `#!/bin/sh\necho ${version}\n`, + sums = (line: string) => line, + }: { folder?: string; binary?: string; sums?: (line: string) => string } = {}, +) { + const dir = path.join(sandbox.root, 'releases', folder) + // As tools/build-binary.ts lays it out. + await sandbox.write('build/configfile', binary, 0o755) + await sandbox.write('build/LICENSE', 'license') + await sandbox.write('build/THIRD-PARTY-LICENSES', 'licenses') + await mkdir(dir, { recursive: true }) + const lines = [`${'0'.repeat(64)} configfile-${version}.tgz`] + for (const name of new Set(ARCHIVES.map(([, , name]) => name))) { + execFileSync('tar', ['-czf', path.join(dir, name), '-C', path.join(sandbox.root, 'build'), '.']) + const sum = createHash('sha256') + .update(await readFile(path.join(dir, name))) + .digest('hex') + lines.push(sums(`${sum} ${name}`)) + } + await writeFile(path.join(dir, 'SHA256SUMS'), `${lines.join('\n')}\n`) + return dir +} + +interface System { + /** `uname -s` and `uname -m`. */ + uname?: [string, string] + /** What `sysctl -n sysctl.proc_translated` says (1: under Rosetta). */ + translated?: string + /** What `ldd --version` prints. */ + libc?: string + /** Replaces the PATH of the user, after the fake commands. */ + path?: string +} + +/** Runs install.sh as on `system`, with the version 9.9.9 by default. */ +async function install(sandbox: Sandbox, env: Record = {}, system: System = {}) { + const { uname = ['Linux', 'x86_64'], translated = '0', libc = 'ldd (GNU libc) 2.39' } = system + const fake = path.join(sandbox.root, 'fake') + await sandbox.write( + 'fake/uname', + `#!/bin/sh\ncase "$1" in -s) echo ${uname[0]} ;; -m) echo ${uname[1]} ;; esac\n`, + 0o755, + ) + await sandbox.write('fake/sysctl', `#!/bin/sh\necho ${translated}\n`, 0o755) + await sandbox.write('fake/ldd', `#!/bin/sh\necho '${libc}' >&2\n`, 0o755) + + const result = spawnSync('/bin/sh', [script], { + cwd: sandbox.cwd, + encoding: 'utf8', + env: { + PATH: `${fake}:${system.path ?? process.env.PATH ?? ''}`, + HOME: sandbox.home, + // As in most terminals: a UTF-8 locale changes how some shells read the script. + LANG: 'en_US.UTF-8', + CONFIGFILE_DOWNLOAD_URL: pathToFileURL(path.join(sandbox.root, 'releases')).href, + CONFIGFILE_VERSION: '9.9.9', + ...env, + }, + }) + return { code: result.status, stdout: result.stdout, stderr: result.stderr } +} + +/** + * A folder with links to these commands only, for a PATH without the others. + * Commands this system doesn't have are left out. + */ +async function only(sandbox: Sandbox, ...commands: string[]): Promise { + const dir = await mkdtemp(path.join(sandbox.root, 'only-')) + for (const command of commands) { + const found = spawnSync('/bin/sh', ['-c', `command -v ${command}`], { encoding: 'utf8' }) + if (found.status === 0) await symlink(found.stdout.trim(), path.join(dir, command)) + } + return dir +} + +/** What install.sh needs from the system, besides a downloader and a checksum tool. */ +const BASICS = [ + 'sh', + 'mktemp', + 'rm', + 'awk', + 'cut', + 'grep', + 'tar', + 'gzip', + 'mkdir', + 'cp', + 'chmod', + 'mv', +] + +const installed = (sandbox: Sandbox) => path.join(sandbox.home, '.local/bin/configfile') + +describe('install.sh', () => { + it('installs the binary of the release in ~/.local/bin, checked against SHA256SUMS', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + + const result = await install(sandbox) + + const binary = installed(sandbox) + expect(result.code, result.stderr).toBe(0) + expect(execFileSync(binary, { encoding: 'utf8' })).toBe('9.9.9\n') + expect(result.stdout).toContain(`configfile 9.9.9 is installed in ${binary}.`) + expect(result.stdout).toContain(`${path.dirname(binary)} is not in your PATH`) + expect(result.stdout).toContain('Next: run "configfile init"') + // Only the binary: the licenses stay in the archive, and no draft is left. + expect(await readdir(path.dirname(binary))).toEqual(['configfile']) + }) + + it.each(ARCHIVES)('downloads the archive for %s %s: %s', async (system, machine, name) => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + + const result = await install(sandbox, {}, { uname: [system, machine] }) + + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain(`/releases/download/9.9.9/${name}...`) + }) + + it('downloads the archive for Apple silicon from a shell running under Rosetta', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + + const result = await install(sandbox, {}, { uname: ['Darwin', 'x86_64'], translated: '1' }) + + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain(`/${archiveName('darwin', 'arm64')}...`) + }) + + it.each([ + [['FreeBSD', 'amd64'], 'no configfile binary for FreeBSD: it runs on macOS and Linux.'], + [['Linux', 'riscv64'], 'no configfile binary for riscv64 processors: install it with npm'], + ] as const)('refuses a system it has no binary for: %s', async (uname, message) => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + + const result = await install(sandbox, {}, { uname: [...uname] }) + + expect(result.code).toBe(1) + expect(result.stderr).toContain(message) + expect(existsSync(installed(sandbox))).toBe(false) + }) + + it('refuses a Linux without the GNU C library (Alpine)', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + + const result = await install(sandbox, {}, { libc: 'musl libc (x86_64)' }) + + expect(result.code).toBe(1) + expect(result.stderr).toContain( + 'needs the GNU C library, not musl (Alpine): install it with npm', + ) + expect(existsSync(installed(sandbox))).toBe(false) + }) + + it('installs where CONFIGFILE_INSTALL_DIR says, replacing an older version', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + const dir = path.join(sandbox.root, 'bin') + await sandbox.write('bin/configfile', '#!/bin/sh\necho 1.0.0\n', 0o755) + + const result = await install( + sandbox, + { CONFIGFILE_INSTALL_DIR: dir }, + { path: `${dir}:${process.env.PATH ?? ''}` }, + ) + + expect(result.code, result.stderr).toBe(0) + expect(execFileSync(path.join(dir, 'configfile'), { encoding: 'utf8' })).toBe('9.9.9\n') + expect(result.stdout).not.toContain('not in your PATH') + expect(result.stdout).not.toContain('Another configfile') + }) + + it('says when another configfile comes first in PATH', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + const other = path.join(sandbox.root, 'other') + await sandbox.write('other/configfile', '#!/bin/sh\necho 0.3.1\n', 0o755) + + const result = await install( + sandbox, + {}, + { path: `${other}:${path.dirname(installed(sandbox))}:${process.env.PATH ?? ''}` }, + ) + + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain( + `Another configfile comes first in your PATH: ${path.join(other, 'configfile')}.`, + ) + }) + + it('keeps the installed configfile when the new one does not start on this system', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9', { binary: '#!/bin/sh\nexit 1\n' }) + await sandbox.write('home/.local/bin/configfile', '#!/bin/sh\necho 1.0.0\n', 0o755) + + const result = await install(sandbox) + + expect(result.code).toBe(1) + expect(result.stderr).toContain( + `the downloaded configfile does not start on this system: nothing was changed in ${path.dirname(installed(sandbox))}.`, + ) + expect(execFileSync(installed(sandbox), { encoding: 'utf8' })).toBe('1.0.0\n') + expect(await readdir(path.dirname(installed(sandbox)))).toEqual(['configfile']) + }) + + it('installs nothing when the download does not match its checksum', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9', { sums: line => `${'f'.repeat(64)}${line.slice(64)}` }) + + const result = await install(sandbox) + + expect(result.code).toBe(1) + expect(result.stderr).toContain(`${NAME} does not match the checksum of the release`) + expect(existsSync(installed(sandbox))).toBe(false) + }) + + it('installs nothing when SHA256SUMS has no line for the archive', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9', { sums: () => '' }) + + const result = await install(sandbox) + + expect(result.code).toBe(1) + expect(result.stderr).toContain(`SHA256SUMS of the release has no line for ${NAME}.`) + expect(existsSync(installed(sandbox))).toBe(false) + }) + + it('reads SHA256SUMS lines written for binary files (*name)', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9', { sums: line => line.replace(' ', ' *') }) + + const result = await install(sandbox) + + expect(result.code, result.stderr).toBe(0) + }) + + it('says which download failed', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + + const result = await install(sandbox, { CONFIGFILE_VERSION: '8.8.8' }) + + expect(result.code).toBe(1) + expect(result.stderr).toContain(`cannot download ${pathToFileURL(sandbox.root).href}`) + expect(result.stderr).toContain(`/releases/download/8.8.8/${NAME}`) + }) + + it('installs the latest release by default', async () => { + const sandbox = await createSandbox() + // GitHub serves the latest release at releases/latest/download/. + await withRelease(sandbox, '9.9.9', { folder: 'latest/download' }) + + const result = await install(sandbox, { CONFIGFILE_VERSION: '' }) + + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain(`/releases/latest/download/${NAME}...`) + }) + + it('says when the folder to install into cannot be created', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + const file = await sandbox.write('file') + + const result = await install(sandbox, { CONFIGFILE_INSTALL_DIR: path.join(file, 'bin') }) + + expect(result.code).toBe(1) + expect(result.stderr).toContain(`cannot create ${path.join(file, 'bin')}.`) + }) + + it('downloads with wget when curl is missing', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + // wget cannot read file:// URLs: a stand-in copies the file. + await sandbox.write( + 'wget/wget', + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion. + '#!/bin/sh\n# wget -q -O \ncp "${4#file://}" "$3"\n', + 0o755, + ) + const tools = await only(sandbox, ...BASICS, 'sha256sum', 'shasum', 'git') + + const result = await install( + sandbox, + {}, + { path: `${path.join(sandbox.root, 'wget')}:${tools}` }, + ) + + expect(result.code, result.stderr).toBe(0) + expect(execFileSync(installed(sandbox), { encoding: 'utf8' })).toBe('9.9.9\n') + }) + + it('says what is missing to download or check the binary, once', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + + const noDownloader = await install( + sandbox, + {}, + { path: await only(sandbox, ...BASICS, 'sha256sum', 'shasum') }, + ) + const noChecksum = await install(sandbox, {}, { path: await only(sandbox, ...BASICS, 'curl') }) + + expect(noDownloader.code).toBe(1) + expect(noDownloader.stderr).toContain('curl or wget is needed to download configfile.') + expect(noChecksum.code).toBe(1) + expect(noChecksum.stderr.trim()).toBe( + 'configfile installer: sha256sum or shasum is needed to check the download.', + ) + }) + + it('says when git is missing', async () => { + const sandbox = await createSandbox() + await withRelease(sandbox, '9.9.9') + const tools = await only(sandbox, ...BASICS, 'curl', 'sha256sum', 'shasum') + + const result = await install(sandbox, {}, { path: tools }) + + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain( + "configfile needs git: install it with your system's package manager.", + ) + }) +}) diff --git a/tests/process-sea.test.ts b/tests/process-sea.test.ts new file mode 100644 index 0000000..b470ddb --- /dev/null +++ b/tests/process-sea.test.ts @@ -0,0 +1,67 @@ +// In a standalone binary (tools/build-binary.ts), configfile's Node.js runs +// only configfile: JavaScript scripts without shebang line run with the node +// found in PATH. isSea() is read when src/process.ts loads, hence this file. + +import { chmod, readFile } from 'node:fs/promises' +import path from 'node:path' + +import { describe, expect, it, vi } from 'vitest' + +import { runScript } from '../src/process.ts' +import { createSandbox, type Sandbox } from './helpers.ts' + +vi.mock('node:sea', () => ({ isSea: () => true })) + +async function withScript(sandbox: Sandbox) { + const file = await sandbox.write('scripts/hello.js', 'console.log("hello")\n') + return { name: 'hello', file: 'hello.js', path: file } +} + +describe('runScript in a standalone binary', () => { + it('runs a .js script without shebang line with the node found in PATH', async () => { + const sandbox = await createSandbox() + const script = await withScript(sandbox) + const log = path.join(sandbox.root, 'log') + // A stand-in for node: says what it was given. + await sandbox.write('bin/node', `#!/bin/sh\necho "$@" > "${log}"\nexit 3\n`, 0o755) + + const code = await runScript(script, ['--flag'], { + cwd: sandbox.cwd, + env: { PATH: `${path.join(sandbox.root, 'bin')}:/usr/bin:/bin` }, + }) + + expect(code).toBe(3) + expect(await readFile(log, 'utf8')).toBe(`${script.path} --flag\n`) + }) + + it('says to install Node.js when there is none', async () => { + const sandbox = await createSandbox() + const script = await withScript(sandbox) + + await expect( + runScript(script, [], { cwd: sandbox.cwd, env: { PATH: path.join(sandbox.root, 'none') } }), + ).rejects.toThrow( + 'Script "hello" needs Node.js, which is not installed. Install it, or add a shebang line', + ) + }) + + // root runs any file whatever its permissions. + it.skipIf(process.getuid?.() === 0)( + 'names the node of PATH when it cannot be executed', + async () => { + const sandbox = await createSandbox() + const script = await withScript(sandbox) + const node = await sandbox.write('bin/node', '#!/bin/sh\n') + await chmod(node, 0o644) + + await expect( + runScript(script, [], { + cwd: sandbox.cwd, + env: { PATH: path.join(sandbox.root, 'bin') }, + }), + ).rejects.toThrow( + 'Cannot run script "hello": the node found in PATH cannot be executed (permission denied).', + ) + }, + ) +}) diff --git a/tools/build-binary.ts b/tools/build-binary.ts new file mode 100644 index 0000000..107c206 --- /dev/null +++ b/tools/build-binary.ts @@ -0,0 +1,150 @@ +// Builds the standalone binary of configfile for the system it runs on: a +// copy of this Node.js with configfile bundled into it (Node.js single +// executable applications), so that a new machine needs neither Node.js nor +// a package manager. Run it with the Node.js the binary should embed: +// +// node tools/build-binary.ts +// +// It writes release/configfile--.tar.gz, holding the binary +// `configfile`, its LICENSE, and THIRD-PARTY-LICENSES (Node.js and the +// bundled packages). The binary is run once, to check it starts. + +import { execFileSync } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { build } from 'esbuild' + +const root = fileURLToPath(new URL('..', import.meta.url)) +/** The sentinel Node.js looks for to know it carries an application. */ +const FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2' +/** The systems and architectures binaries are made for, by their name in releases. */ +const SYSTEMS: Partial> = { darwin: 'macos', linux: 'linux' } +const ARCHS: Partial> = { arm64: 'arm64', x64: 'x64' } + +/** The name of the archive for this system, such as `configfile-macos-arm64.tar.gz`. */ +export function archiveName(platform: NodeJS.Platform, arch: NodeJS.Architecture): string { + const system = SYSTEMS[platform] + const machine = ARCHS[arch] + if (system == null || machine == null) { + throw new Error(`No binary is made for ${platform} ${arch}.`) + } + return `configfile-${system}-${machine}.tar.gz` +} + +async function main(): Promise { + const { version } = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')) as { + version: string + } + const archive = path.join(root, 'release', archiveName(process.platform, process.arch)) + const work = await mkdtemp(path.join(tmpdir(), 'configfile-binary-')) + + try { + // 1. One CommonJS file: the form Node.js runs in a single executable. + const bundle = path.join(work, 'configfile.cjs') + const { metafile } = await build({ + entryPoints: [path.join(root, 'src/cli.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + target: `node${process.versions.node}`, + outfile: bundle, + define: { CONFIGFILE_VERSION: JSON.stringify(version) }, + legalComments: 'none', + metafile: true, + logLevel: 'warning', + // import.meta is only read when CONFIGFILE_VERSION is not set, which it is here. + logOverride: { 'empty-import-meta': 'silent' }, + }) + + // 2. The blob Node.js loads at start, then a copy of Node.js carrying it. + const config = path.join(work, 'sea-config.json') + const blob = path.join(work, 'sea.blob') + await writeFile( + config, + JSON.stringify({ + main: bundle, + output: blob, + disableExperimentalSEAWarning: true, + // NODE_OPTIONS is for the user's Node.js: it must not change how configfile starts. + execArgvExtension: 'none', + }), + ) + execFileSync(process.execPath, ['--experimental-sea-config', config], { stdio: 'inherit' }) + + const folder = path.join(work, 'configfile') + await mkdir(folder) + const binary = path.join(folder, 'configfile') + await copyFile(process.execPath, binary) + // macOS checks the signature of every binary: the one Node.js came with + // no longer matches once the blob is added, so it is replaced. + if (process.platform === 'darwin') { + execFileSync('codesign', ['--remove-signature', binary], { stdio: 'inherit' }) + } + execFileSync( + path.join(root, 'node_modules/.bin/postject'), + [ + binary, + 'NODE_SEA_BLOB', + blob, + '--sentinel-fuse', + FUSE, + ...(process.platform === 'darwin' ? ['--macho-segment-name', 'NODE_SEA'] : []), + ], + { stdio: 'inherit' }, + ) + if (process.platform === 'darwin') { + execFileSync('codesign', ['--sign', '-', binary], { stdio: 'inherit' }) + } + + // 3. It starts, and says the version it was built for. + const reported = execFileSync(binary, ['--version'], { encoding: 'utf8' }).trim() + if (reported !== version) { + throw new Error(`The binary says it is ${reported}, not ${version}.`) + } + + // 4. The archive, with the licenses of what the binary contains. + await copyFile(path.join(root, 'LICENSE'), path.join(folder, 'LICENSE')) + await writeFile( + path.join(folder, 'THIRD-PARTY-LICENSES'), + await thirdPartyLicenses(Object.keys(metafile.inputs)), + ) + await mkdir(path.dirname(archive), { recursive: true }) + await rm(archive, { force: true }) + execFileSync('tar', ['-czf', archive, '-C', folder, '.'], { stdio: 'inherit' }) + console.log( + `${path.relative(root, archive)}: configfile ${version}, Node.js ${process.version}`, + ) + } finally { + await rm(work, { recursive: true, force: true }) + } +} + +/** + * The licenses of Node.js (the binary is a copy of it) and of every package + * bundled into it, found from the files the bundle was made of. + */ +async function thirdPartyLicenses(inputs: string[]): Promise { + const sections: string[] = [] + const node = path.resolve(path.dirname(process.execPath), '..', 'LICENSE') + sections.push(`Node.js ${process.version}\n\n${await readFile(node, 'utf8')}`) + + const packages = new Set() + for (const input of inputs) { + const match = /^(.*node_modules\/(?:@[^/]+\/)?[^/]+)\//.exec(input) + if (match?.[1] != null) packages.add(path.resolve(root, match[1])) + } + for (const folder of [...packages].sort()) { + const { name, version } = JSON.parse( + await readFile(path.join(folder, 'package.json'), 'utf8'), + ) as { name: string; version: string } + const file = (await readdir(folder)).find(entry => /^licen[sc]e/i.test(entry)) + if (file == null) throw new Error(`${name} ${version} has no license file to ship.`) + sections.push(`${name} ${version}\n\n${await readFile(path.join(folder, file), 'utf8')}`) + } + return `${sections.map(section => section.trimEnd()).join(`\n\n${'-'.repeat(72)}\n\n`)}\n` +} + +if (import.meta.main) await main()