diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a53df2f..02f026f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,13 +19,17 @@ jobs: - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 with: version: 2026.9.18 # mise version, updated by Renovate - - run: pnpm install --frozen-lockfile + # The CLI only: the documentation site (docs/) is built by the docs job. + - run: pnpm install --frozen-lockfile --filter configfile - run: pnpm lint - run: pnpm typecheck - name: Check the workflows 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: @@ -46,7 +50,8 @@ jobs: version: 2026.9.18 # mise version, updated by Renovate # One cache per Node.js version: the override is not part of mise.toml. cache_key_prefix: mise-node-${{ matrix.node }} - - run: pnpm install --frozen-lockfile + # The CLI only: the documentation site (docs/) is built by the docs job. + - run: pnpm install --frozen-lockfile --filter configfile - run: pnpm test:coverage package: @@ -59,7 +64,8 @@ jobs: - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 with: version: 2026.9.18 # mise version, updated by Renovate - - run: pnpm install --frozen-lockfile + # The CLI only: the documentation site (docs/) is built by the docs job. + - run: pnpm install --frozen-lockfile --filter configfile - run: pnpm pack --out "$RUNNER_TEMP/configfile.tgz" - name: Check npm publishes package.json as written # npm publish silently drops what it considers invalid (a "./" bin path @@ -78,3 +84,69 @@ jobs: configfile --version 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 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # the pages show when they were last updated, from git + persist-credentials: false + - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + with: + version: 2026.9.18 # mise version, updated by Renovate + - run: pnpm install --frozen-lockfile + - run: pnpm --filter configfile-docs typecheck + - run: pnpm docs:build diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000..c4cffd1 --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,56 @@ +name: Docs + +# Publishes the documentation site (docs/) to GitHub Pages, served at +# https://docs.configfile.sh. It runs on pushes to master, the branch of +# released versions (git-flow), so the site describes the latest release. It +# can also be run by hand from the Actions tab, to publish master again. +# +# Set up once in the repository settings: Pages > Source: GitHub Actions, +# custom domain docs.configfile.sh, Enforce HTTPS. The github-pages +# environment only accepts deployments from master. + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +# One deployment at a time; a newer one waits instead of cancelling. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # the pages show when they were last updated, from git + persist-credentials: false + # No cache: nothing restored from another run can end up on the site. + - 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 + - run: pnpm docs:build + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: docs/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write # publish the site + id-token: write # prove the deployment comes from this workflow + steps: + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8559243..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: @@ -44,7 +51,8 @@ jobs: version=$(node -p "require('./package.json').version") [ "$version" = "$TAG" ] || { echo "::error::package.json is $version, the tag is $TAG."; exit 1; } - - run: pnpm install --frozen-lockfile + # The CLI only: the documentation site (docs/) is not part of a release. + - run: pnpm install --frozen-lockfile --filter configfile - run: pnpm check - name: Pack the package env: @@ -52,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 }} @@ -74,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 @@ -85,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 87a215c..0b9efe7 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,7 @@ dist/ # macOS .DS_Store + +# Astro (docs/) +.astro/ +release/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c60ad57..16bf26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-10-14 +### 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)). +- A JSON Schema for `settings.json`, at `https://docs.configfile.sh/schemas/settings.json`: with + `"$schema"` at the top of the file, editors complete and check it as you type. +- A documentation site, [docs.configfile.sh](https://docs.configfile.sh), with a tutorial, guides, + a reference and explanations. The README is now a short introduction that links to it. +- Modules can name scripts to run when they are deployed or undeployed, with `"pre_deploy"`, + `"post_deploy"`, `"pre_undeploy"` and `"post_undeploy"` in `settings.json`. A script that fails + stops the command there, unless `--keep-going` is given, and a `pre_` script that fails leaves + the files of its module as they are; `--no-hooks` runs none. A name that matches no script + fails the command before anything is changed. `modules status` shows the scripts + ([#298](https://github.com/mindsers/configfile/issues/298)). + +### Changed +- `modules deploy` and `modules undeploy` work on one module at a time, taking the lock for each + module in turn instead of the whole run. When a local file already exists, the question to + replace it is asked once the other files of its module are deployed, no longer at the very end. + +### Fixed +- `update` and `init` take the lock that `modules deploy` and `undeploy` use, so files are never + deployed while a sync or a new configuration replaces them: each waits for the other, and says + so ([#313](https://github.com/mindsers/configfile/issues/313)). +- Two configfile commands started together could both take over the lock of a process that no + longer exists, and change files at the same time. The lock is now created with its owner's pid + already in it (where the filesystem supports hard links), and taken over by one process at a + time. A lock that cannot be read, or that holds no process number, is no longer taken over. +- A lock that cannot be removed at the end of a command no longer hides the command's result: a + warning says to delete it. A lock replaced or removed by someone else while a command held it is + reported too. +- When `~/.configfile` or its lock file cannot be created, written or read, the commands that take + the lock (`init`, `update`, `modules deploy` and `undeploy`) say that the lock cannot be taken, + and why, instead of reporting an unexpected error or waiting. +- When more than one settings entry failed, `modules deploy` and `undeploy` reported "settings + entrys" instead of "settings entries". + ## [1.0.0] - 2026-10-05 ### Added - The `modules undeploy` command to remove the files configfile deployed and restore the saved @@ -125,7 +167,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `run` command to run custom and saved scripts. Scripts can be write in all languages. - The README file to give first information on the project (installation, usage, etc.). -[Unreleased]: https://github.com/mindsers/configfile/compare/1.0.0...HEAD +[Unreleased]: https://github.com/mindsers/configfile/compare/1.1.0...HEAD +[1.1.0]: https://github.com/mindsers/configfile/tree/1.1.0 [1.0.0]: https://github.com/mindsers/configfile/tree/1.0.0 [0.3.1]: https://github.com/mindsers/configfile/tree/0.3.1 [0.3.0]: https://github.com/mindsers/configfile/tree/0.3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1751eac..14539da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,7 @@ Everyone taking part in the project follows the [code of conduct](CODE_OF_CONDUC ### Do you want to contribute to the documentation? -The documentation lives with the source code: the [README](README.md) for users, and this file for contributors. +The documentation lives with the source code: the [documentation site](docs/) for users (published at [docs.configfile.sh](https://docs.configfile.sh), run it locally with `pnpm docs:dev`), and this file for contributors. Each page of the site is a Markdown file in `docs/src/content/docs/`. The README only introduces configfile and links to the site: document behavior on the site. The usage lines and option tables of the commands page are generated from the commands' definitions: after adding or changing a command, an argument or an option, run `pnpm docs:commands` (a test fails until the page is up to date). A new command first needs its section and an empty `` / `` block on the page, which the script then fills. The format of `settings.json` is also described by a JSON Schema, `docs/public/schemas/settings.json`: when configfile starts accepting or refusing something new there, update the schema and the examples of `tests/settings-schema.test.ts`, which check that both agree. * Please refer to the "Do you intend to add a new feature or change an existing one?" section. diff --git a/README.md b/README.md index c522e16..a0fe8da 100644 --- a/README.md +++ b/README.md @@ -1,199 +1,73 @@ # configfile [![Release](https://img.shields.io/github/v/release/mindsers/configfile?style=flat-square)](https://github.com/mindsers/configfile/releases/latest) -[![npm](https://img.shields.io/npm/dt/configfile.svg?style=flat-square)](https://www.npmjs.com/package/configfile) [![CI](https://img.shields.io/github/actions/workflow/status/mindsers/configfile/ci.yml?branch=develop&style=flat-square)](https://github.com/mindsers/configfile/actions/workflows/ci.yml) -[![npm](https://img.shields.io/npm/l/configfile.svg?style=flat-square)](https://github.com/mindsers/configfile/blob/develop/LICENSE) +[![License](https://img.shields.io/github/license/mindsers/configfile?style=flat-square)](https://github.com/mindsers/configfile/blob/develop/LICENSE) [![GitHub Sponsors](https://img.shields.io/github/sponsors/mindsers?logo=githubsponsors&style=flat-square)](https://github.com/sponsors/mindsers) -*configfile* is a command line tool that helps you manage your configuration files (dotfiles) and setup scripts from a git repository. +*configfile* manages your configuration files (dotfiles) and setup scripts from a git repository you own. It links each file where your tools expect it, runs your setup scripts, and keeps every machine in sync with the repository, on macOS and Linux. -## Requirements +**Documentation: [docs.configfile.sh](https://docs.configfile.sh)** -- 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) +## How it looks -## Installation - -With [Homebrew](https://brew.sh), 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): - -```bash -npm install --global https://github.com/mindsers/configfile/releases/download/1.0.0/configfile-1.0.0.tgz -``` - -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. - -## Data storage - -This tool **does not store** configuration files for you. A git repository ([dotfiles](https://github.com/topics/dotfiles)) is needed to store your configuration files. - -You edit your dotfiles in your own working copy of that repository and push them. *configfile* keeps its own copy, a mirror of the remote repository in `~/.configfile/dotfiles`, and deploys from it: `configfile update` makes the mirror identical to the remote, so unpushed or conflicting work never blocks it. Don't edit the mirror (or deployed links, which point into it): see [`update`](#usage) for what happens to such changes. - -The repository must have this structure: +Your repository groups files by tool, and says where each one goes: ```txt files/ zsh/ settings.json zshrc - git/ - settings.json - gitconfig scripts/ setup.sh - macos/ - index.sh ``` -### Modules - -Every folder of `files/` (or symbolic link to a folder) that contains a `settings.json` is a **module**. Its name is the folder name, lowercased, with spaces replaced by `-` and other characters than ASCII letters, digits, `_` and `-` removed: `My Zsh.d` is the `my-zshd` module. Hidden folders are ignored. - -`settings.json` lists the files of the module: - ```json { "files": [ - { "source_path": "zshrc", "target_path": "~/.zshrc", "deploy": "global" }, - { "source_path": "editorconfig", "target_path": ".editorconfig", "deploy": "local" }, - { "source_path": "old-aliases", "target_path": "~/.aliases", "deploy": "none" } + { "source_path": "zshrc", "target_path": "~/.zshrc", "deploy": "global" } ] } ``` -| Key | Description | -| --- | --- | -| `source_path` | Path of the file (or folder), relative to the module folder. It must stay inside the module folder, symbolic links included. | -| `target_path` | Where the file is deployed. `~` is your home folder. A relative path is relative to your home folder for global files (`.zshrc` is `~/.zshrc`) and to the current folder for local files. Targets can be anywhere you can write, except: your home folder, the current folder, the dotfiles repository, the module folder, *configfile*'s own files (`~/.configfilerc`, `~/.configfile/`), one of their parents, or anything inside the repository. These are recognised whatever the path used to reach them (letter case, symbolic links). | -| `deploy` | `"global"`: the file is **symlinked** by `configfile modules deploy`. `"local"`: the file is **copied** by `configfile modules deploy --local`, typically into a project folder (symbolic links in the source are followed, so the copy never points into the repository). `"none"`: the file is **never deployed**, which keeps it in the repository for later. | -| `global` | **Deprecated**, removed in 2.0: older spelling of `deploy`. `true` is `"global"`, `false` is `"local"`. It still works in 1.x, with a warning. Use one or the other, not both. | - -An entry without `deploy` or `global` is not deployed, and `modules deploy` warns about it. - -When a global file is deployed and something else already exists at its target (a file, a folder or another link), it is moved to `.old` (or `.old.1`, …). A link that already points to the right file is left as is, so deploying twice is safe. - -When a local file already exists and differs from the one in the repository, *configfile* asks whether to replace it, once the other files are deployed. The existing file or folder is then moved to `.old` (or `.old.1`, …) before the copy, so nothing is lost. - -*configfile* records what it deploys and the backups it makes in `~/.configfile/state.json`. `configfile modules undeploy` reverts a deployment: it removes the links and the local copies *configfile* made (unless they were modified since), and moves the most recent backup it made back in place, if that backup is unchanged. Anything else is left untouched: files it did not create (even when identical to the repository), local copies that were modified or saved again by an editor since, and `.old` files you made yourself. - -When an entry leaves the repository (removed, set to `"deploy": "none"`, given another `target_path`, or its module deleted), what *configfile* deployed for it stays in place until it is undeployed: `modules status` lists these files, `update` warns about them, and `modules undeploy --removed` (or `--all`) removes them and restores the files they replaced. Local copies are handled from the folder they were copied into, like `modules deploy --local`. A removed file that *configfile* cannot remove safely (replaced by your own file, a modified copy, a copy whose source is gone) is left where it is, and *configfile* stops tracking it. - -To stay safe, *configfile* never undeploys a file when it cannot tell whether the repository still deploys it: files of a module whose `settings.json` cannot be used or has invalid entries, of a module folder that is not usable (a broken symbolic link, a name another folder uses), and entries without a deployment strategy. `modules status` lists them separately. Links deployed by 0.3 and not deployed again since were not recorded, so they are not found either. - -Only one *configfile* at a time changes files: a second one waits for the first to finish (the lock is `~/.configfile/lock`). - -> **Deploying a repository means trusting it**, like code you run: its files end up in your shell configuration, and its scripts run on your machine. Only deploy repositories you trust. +On each machine: -### Scripts - -Every file of `scripts/`, and every folder of `scripts/` (or symbolic link to a folder) containing an `index` file (`index`, `index.sh`, …), is a **script**. Hidden files are ignored. To only use some extensions, set `script_extensions` in the [configuration](#configuration). - -A script's name is its file name (or folder name) up to the first dot, with the same rules as module names: `scripts/setup.sh` is named `setup`. When two scripts have the same name, only the first one, alphabetically, is used, with a warning. - -A script can have a version for each system: `macos` or `linux` after the first dot (`scripts/setup.macos.sh`, `scripts/setup.linux.py`, or a folder such as `scripts/setup.macos/`). On macOS, `configfile scripts run setup` runs `setup.macos.sh`; on another system, it runs the generic `setup.sh`, if there is one. A version for another system is ignored. - -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`; -- 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, their output is not modified (the messages of *configfile* go to stderr), and their exit code is forwarded. - -## Usage - -- `configfile init` (`i`): ask for the URL of your dotfiles repository (any URL git can clone, such as `https://github.com/me/dotfiles.git` or `git@github.com:me/dotfiles.git`, or an existing local folder), clone it into `~/.configfile/dotfiles` and save the configuration in `~/.configfilerc`. If the folder already contains a git repository, it is used as is. When you run `init` again, the existing folder is kept. - - `-f, --force`: overwrite an existing configuration without asking. - - `--repo `: give the URL from the command line, for non-interactive setups. - - `--folder `: clone somewhere else than `~/.configfile/dotfiles`. -- `configfile modules list` (`m l`, or just `configfile modules`): list available modules. -- `configfile modules status [modules...]` (`m st`): show whether each global file of the modules (all modules by default) is deployed, not deployed, or blocked by another file. Without module names, it also lists the deployed files the repository no longer deploys. - - `-l, --local`: check the local files, in the current folder. -- `configfile modules deploy [modules...]` (`m d`): deploy the global files of the given modules. Without module names, asks to deploy all modules. - - `-l, --local`: copy the local files of the modules instead. - - `-a, --all`: deploy every module without asking. - - `-f, --force`: replace existing local files without asking (they are moved to `.old`). - - `-n, --dry-run`: show what would be done, without changing anything. -- `configfile modules undeploy [modules...]` (`m u`): remove the deployed global files of the given modules and restore their backups. Without module names, asks to undeploy all modules. - - `-a, --all`: undeploy every module without asking, and the files the repository no longer deploys. - - `--removed`: only undeploy the files the repository no longer deploys (see [Modules](#modules)); with `--local`, the copies made in the current folder. - - `-l, --local` and `-n, --dry-run`: as for `deploy`. -- `configfile scripts list` (`s l`, or just `configfile scripts`): list available scripts. -- `configfile scripts run [-- args...]` (`s r`): run a script. Arguments after `--` are passed to the script. -- `configfile update` (`u`): sync *configfile*'s copy of your dotfiles repository with the remote: it fetches the remote and makes the copy identical to it, whatever happened to the copy. Local changes found in the copy (uncommitted edits, new files or unpushed commits, for example edits made through a deployed link) are first saved as a patch in `~/.configfile/saved/`; apply it in your own working copy with `git am ` to keep them. Global files are symbolic links, so they are up to date right away; run `modules deploy --all` for new files, and `modules deploy --local` to refresh local copies. It warns about deployed files the repository no longer deploys. -- `configfile history`: show what *configfile* changed, most recent last (see [History](#history)). - - `-n, --limit `: number of runs to show (20 by default). - - `--json`: print the raw history lines (JSON Lines), for scripts. - -*configfile* exits with a non-zero code when a command fails, and with the script's exit code when a script fails, so it can be used from other scripts. When stdin is not a terminal, a command that would ask a question fails and names the option to pass instead (`--repo`, `--folder` and `--force` for `init`; module names or `--all` for `modules deploy` and `undeploy`). Without a terminal, `modules deploy --local` skips existing local files and exits with an error, unless `--force` is given. Ctrl+C at a question exits with code 130. Each run of a command that changes files, and any unexpected error, is also recorded in the [history](#history). - -### Configuration - -*configfile* keeps its configuration in `~/.configfilerc`, and its working files in the `~/.configfile/` folder: the mirror of your repository (`dotfiles/`), the record of deployments (`state.json`), the [history](#history) (`history.jsonl`), the lock, and saved local changes (`saved/`). - -`~/.configfilerc` is a JSON file, readable by you only: the repository URL may contain credentials, so if other users can read it, *configfile* makes it private and warns you. - -```json -{ - "repo_url": "git@github.com:me/dotfiles.git", - "folder_path": "/Users/me/.configfile/dotfiles", - "script_extensions": [".js", ".sh", ""], - "history_max_size": "1MB" -} +```sh +configfile init --repo git@github.com:me/dotfiles.git # once +configfile modules deploy --all # links ~/.zshrc, keeping the old one as ~/.zshrc.old +configfile scripts run setup # runs scripts/setup.sh +configfile update # later: brings the changes you pushed ``` -`folder_path` may start with `~`; a relative path is relative to your home folder. `script_extensions` is optional: without it, every file of `scripts/` is a script. With it, only files with one of these extensions are; the dot may be omitted (`"py"`), and `""` means files without extension. `history_max_size` is optional: see [History](#history). - -### History - -*configfile* records what each command changed, when, and whether it failed, in `~/.configfile/history.jsonl`: one JSON line per run of `init`, `modules deploy`, `modules undeploy`, `update` and `scripts run` (dry runs and usage errors excepted), and per unexpected error of any command. Each line has the time, the *configfile* version, the command and its options, the current folder, the exit code, the error message, and the changes: files linked, copied, moved aside to `.old`, removed, restored, kept or skipped, syncs (from which commit to which, and saved patches), scripts and their exit codes. +## Installation -Script arguments, environment variables and file contents are never recorded. In URLs, user names and passwords (`https://user:token@host`) and query parameters that look like secrets (`?private_token=…`) are hidden. Paths are recorded in full, so they include your user name. When *configfile* creates the file, only its owner can read it. A run stopped by a signal (Ctrl+C outside a question) is not recorded, unless the signal comes while a script or git runs: *configfile* then leaves it to that program, and records the run with its exit code. +With one command, on macOS or Linux (no package manager or Node.js needed): -`configfile history` shows the last runs: +```bash +curl -fsSL https://docs.configfile.sh/install.sh | sh +``` -```txt -2026-10-01 11:12 modules deploy zsh ok - linked ~/.zshrc (previous file moved to ~/.zshrc.old) - 2 unchanged +With [Homebrew](https://brew.sh), on macOS or Linux (Node.js and git come with it): -2026-10-01 11:15 update exit 128 - error git fetch failed (exit code 128). +```bash +brew install mindsers/tap/configfile ``` -`-n ` shows another number of runs, and `--json` prints the lines as they are written. - -The file is also easy to query with `jq`, for example to list the failed runs: +With npm, when Node.js 24.11 or later is installed: ```bash -jq 'select(.exitCode != 0)' ~/.configfile/history.jsonl +npm install --global https://github.com/mindsers/configfile/releases/download/1.1.0/configfile-1.1.0.tgz ``` -When the file reaches `history_max_size` (1MB by default; a number of bytes, or a size such as `"512KB"` or `"5MB"` in `~/.configfilerc`), it is renamed to `history.1.jsonl`, replacing the previous one, so the history takes about twice that size at most. `"history_max_size": 0` turns the history off. An invalid value gives the default and a warning; when `~/.configfilerc` cannot be read, nothing is recorded. - -## Upgrading from 0.3 - -- Node.js 24.11 or later is required, on macOS or Linux. Install again as described in [Installation](#installation). To switch to Homebrew, 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"`. -- A **relative** `target_path` of a global file is now relative to your home folder, not to the folder you run *configfile* from. Targets starting with `~/` or `/` are not affected. -- Scripts keep their names (up to the first dot) and every file of `scripts/` is still a script. They are no longer made executable by *configfile*: a script needs a shebang line, a `.js`/`.sh` extension, or to be executable. -- Targets inside the dotfiles repository, or containing it, are now refused. -- `modules undeploy` also removes the links made by 0.3 that point to the repository, but it only restores the backups it made itself (recorded in `~/.configfile/state.json`): the `.old` files made by 0.3 are not put back, and local copies made by 0.3 are kept. -- A `source_path` must stay inside its module folder. -- `init` clones into `~/.configfile/dotfiles` by default, and an existing `folder_path` is kept. `update` now makes that folder identical to the remote, saving local changes as a patch first. If your configured folder is also your working copy (for example `~/.dotfiles`), give *configfile* its own copy: run `configfile modules undeploy --all` (files that 1.0 moved aside come back; links made by 0.3 are removed, and their `.old` files stay where they are), then `configfile init --force --repo --folder ~/.configfile/dotfiles`, then `configfile modules deploy --all` (links now point to the copy). -- With `script_extensions` set, only files with one of these extensions are scripts, and `""` means files without extension (0.3 matched any file containing the text). -- Commands now exit with a non-zero code on failure. +[Installation](https://docs.configfile.sh/getting-started/installation/) has the details, including how to verify a download. + +## Learn more + +- [Your first dotfiles repository](https://docs.configfile.sh/getting-started/tutorial/): a ten-minute tutorial. +- [How it works](https://docs.configfile.sh/concepts/how-it-works/) and [Safety and trust](https://docs.configfile.sh/concepts/safety/): what *configfile* does with your files, and what it refuses to touch. +- Guides: [set up a new machine](https://docs.configfile.sh/guides/new-machine/), [use configfile in scripts and CI](https://docs.configfile.sh/guides/scripts-and-ci/), [run scripts when a module is deployed](https://docs.configfile.sh/guides/module-scripts/), [upgrade from 0.3](https://docs.configfile.sh/guides/upgrading-from-0-3/). +- Reference: [commands](https://docs.configfile.sh/reference/commands/), [modules and `settings.json`](https://docs.configfile.sh/reference/modules/), [scripts](https://docs.configfile.sh/reference/scripts/), [configuration](https://docs.configfile.sh/reference/configuration/), [history](https://docs.configfile.sh/reference/history/). +- [Changelog](https://github.com/mindsers/configfile/blob/develop/CHANGELOG.md). ## Contribution diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs new file mode 100644 index 0000000..55b25e6 --- /dev/null +++ b/docs/astro.config.mjs @@ -0,0 +1,28 @@ +// The documentation site, published at https://docs.configfile.sh. +// Everything specific to configfile is in this file; the pages are in +// src/content/docs/, one folder per section listed in `sidebar` below. +import starlight from '@astrojs/starlight' +import { defineConfig } from 'astro/config' + +export default defineConfig({ + site: 'https://docs.configfile.sh', + integrations: [ + starlight({ + title: 'configfile', + description: + 'Manage your dotfiles and setup scripts from a git repository, on macOS and Linux.', + social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/mindsers/configfile' }], + // Changes are proposed against develop, as for any other change (git-flow). + editLink: { + baseUrl: 'https://github.com/mindsers/configfile/edit/develop/docs/', + }, + lastUpdated: true, + sidebar: [ + { label: 'Getting started', items: [{ autogenerate: { directory: 'getting-started' } }] }, + { label: 'Guides', items: [{ autogenerate: { directory: 'guides' } }] }, + { label: 'Reference', items: [{ autogenerate: { directory: 'reference' } }] }, + { label: 'Concepts', items: [{ autogenerate: { directory: 'concepts' } }] }, + ], + }), + ], +}) diff --git a/docs/home.md b/docs/home.md deleted file mode 100644 index f5e3086..0000000 --- a/docs/home.md +++ /dev/null @@ -1,65 +0,0 @@ -# Configfile - -Configfile is a command line application that helps developers manage their development setup from a dotfiles git repository. - -## Getting Started - -To install Configfile on macOS or Linux, with [Homebrew](https://brew.sh) (it also installs Node.js and git): - -```bash -brew install mindsers/tap/configfile -``` - -Or with npm, when Node.js 24.11 or later and git are 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 -``` - -Generating first configuration: - -```bash -configfile init -``` - -It asks for your repository URL and clones it into `~/.configfile/dotfiles`, a copy configfile keeps in sync with the remote (edit your dotfiles in your own working copy). To set it up without questions (in a script, for example): - -```bash -configfile init --repo git@github.com:me/dotfiles.git -``` - -### Running the tests - -```bash -pnpm install -pnpm check # lint, type check and tests -``` - -See [How to contribute](../CONTRIBUTING.md) for the development setup. - -### Additional commands - -- [configfile modules list](../README.md#usage) -- [configfile modules status](../README.md#usage) -- [configfile modules deploy](../README.md#usage) -- [configfile modules undeploy](../README.md#usage) -- [configfile scripts list](../README.md#usage) -- [configfile scripts run](../README.md#usage) -- [configfile update](../README.md#usage) -- [configfile history](../README.md#history) - -## Configfiles Configuration - -- [Data folder structure](../README.md#data-storage) -- [Module settings](../README.md#modules) -- [Scripts](../README.md#scripts) -- [~/.configfilerc](../README.md#configuration) -- [History](../README.md#history) - -## Project - -- [How to contribute](../CONTRIBUTING.md) -- [Code of conduct](../CODE_OF_CONDUCT.md) -- [Security policy](../SECURITY.md) -- [Changelog](../CHANGELOG.md) -- [Support configfile on GitHub Sponsors](https://github.com/sponsors/mindsers) diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..dcbec17 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,19 @@ +{ + "name": "configfile-docs", + "private": true, + "type": "module", + "description": "The configfile documentation site (docs.configfile.sh), built with Astro Starlight.", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "typecheck": "astro sync && tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@astrojs/starlight": "0.42.5", + "astro": "7.3.6" + }, + "devDependencies": { + "typescript": "7.0.2" + } +} diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg new file mode 100644 index 0000000..37e652e --- /dev/null +++ b/docs/public/favicon.svg @@ -0,0 +1 @@ +configfile 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/public/schemas/settings.json b/docs/public/schemas/settings.json new file mode 100644 index 0000000..69d5749 --- /dev/null +++ b/docs/public/schemas/settings.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docs.configfile.sh/schemas/settings.json", + "title": "configfile module settings", + "description": "The settings.json file of a configfile module: the files of the module, how each one is deployed, and the scripts run when the module is deployed or undeployed. See https://docs.configfile.sh/reference/modules/", + "oneOf": [ + { + "type": "object", + "properties": { + "$schema": { + "description": "The JSON Schema of this file, for editor completion and validation. configfile ignores it.", + "type": "string" + }, + "files": { + "description": "The files of the module.", + "type": "array", + "items": { "$ref": "#/$defs/entry" } + }, + "pre_deploy": { + "$ref": "#/$defs/path", + "description": "The name of a script of the repository (as listed by configfile scripts list), run by configfile modules deploy before the files of the module are deployed. When it fails, the module is not deployed." + }, + "post_deploy": { + "$ref": "#/$defs/path", + "description": "The name of a script of the repository (as listed by configfile scripts list), run by configfile modules deploy once the files of the module are deployed." + }, + "pre_undeploy": { + "$ref": "#/$defs/path", + "description": "The name of a script of the repository, run by configfile modules undeploy before the files of the module are removed. When it fails, the module stays deployed." + }, + "post_undeploy": { + "$ref": "#/$defs/path", + "description": "The name of a script of the repository, run by configfile modules undeploy once the files of the module are removed." + } + }, + "required": ["files"], + "additionalProperties": false + }, + { + "description": "Deprecated: the configfile 0.3 format, a plain list of entries. Put the list in a \"files\" key instead. It will stop working in 2.0.", + "deprecated": true, + "type": "array", + "items": { "$ref": "#/$defs/entry" } + } + ], + "$defs": { + "path": { + "type": "string", + "pattern": "\\S" + }, + "entry": { + "type": "object", + "properties": { + "source_path": { + "$ref": "#/$defs/path", + "description": "Path of the file or folder, relative to the module folder. It must stay inside the module folder." + }, + "target_path": { + "$ref": "#/$defs/path", + "description": "Where the file is deployed. ~ is your home folder. A relative path is relative to the home folder for global files, and to the current folder for local files." + }, + "deploy": { + "description": "How the file is deployed: \"global\" links it (configfile modules deploy), \"local\" copies it into the current folder (configfile modules deploy --local), \"none\" keeps it in the repository without deploying it.", + "enum": ["global", "local", "none"] + }, + "global": { + "description": "Deprecated: the older spelling of \"deploy\" (true is \"global\", false is \"local\"). It will stop working in 2.0.", + "deprecated": true, + "type": "boolean" + } + }, + "additionalProperties": false, + "not": { + "description": "\"deploy\" and \"global\" cannot be used together.", + "required": ["deploy", "global"] + }, + "allOf": [ + { + "description": "Every entry names its file, except those that are never deployed.", + "if": { "properties": { "deploy": { "const": "none" } }, "required": ["deploy"] }, + "else": { "required": ["source_path"] } + }, + { + "description": "Deployed files need a target.", + "if": { + "anyOf": [ + { + "properties": { "deploy": { "enum": ["global", "local"] } }, + "required": ["deploy"] + }, + { "required": ["global"] } + ] + }, + "then": { "required": ["target_path"] } + } + ] + } + } +} diff --git a/docs/src/content.config.ts b/docs/src/content.config.ts new file mode 100644 index 0000000..9cfdab0 --- /dev/null +++ b/docs/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from 'astro:content' +import { docsLoader } from '@astrojs/starlight/loaders' +import { docsSchema } from '@astrojs/starlight/schema' + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +} diff --git a/docs/src/content/docs/concepts/how-it-works.md b/docs/src/content/docs/concepts/how-it-works.md new file mode 100644 index 0000000..f9044d1 --- /dev/null +++ b/docs/src/content/docs/concepts/how-it-works.md @@ -0,0 +1,80 @@ +--- +title: How it works +description: Where your dotfiles live, how configfile deploys them, and how it keeps track of what it did. +sidebar: + order: 1 +--- + +*configfile* doesn't store your configuration files. They live in a git repository that you own, usually called `dotfiles` ([plenty of people share theirs](https://github.com/topics/dotfiles)). *configfile* puts the files of that repository where your tools expect them, runs your setup scripts, and keeps each machine in sync with the repository. + +## Where your files live + +Your dotfiles exist in up to four places: + +```txt +your working copy ~/dotfiles + │ + │ git push + ▼ +remote repository GitHub, GitLab… + │ + │ configfile update + ▼ +configfile's mirror ~/.configfile/dotfiles + │ + │ configfile modules deploy (once per file) + ▼ +your home folder ~/.zshrc → link into the mirror +``` + +- **Your working copy** is a clone of the repository, wherever you like. It's the only place where you edit your dotfiles, commit and push, as for any git repository. +- **The remote repository** is the reference that every machine follows. +- **configfile's mirror** is a copy of the remote repository that *configfile* keeps for itself, in `~/.configfile/dotfiles` unless you chose another folder with `configfile init --folder`. [`configfile update`](/reference/commands/#configfile-update) makes it identical to the remote. +- **Your home folder** receives the files your tools read, such as `~/.zshrc`; project folders receive local copies. + +### Why a mirror, and not your working copy + +If *configfile* deployed from your working copy, uncommitted edits, a branch you're working on or a conflict would block syncing, and changes you haven't pushed yet would reach your configuration before your other machines have them. + +The mirror only follows the remote. `update` doesn't merge anything, so your work in progress never blocks it: it fetches the remote and makes the mirror identical to it. It still needs the remote to be reachable, and stops with an error if fetching fails. + +You don't edit the mirror. If something changes in it anyway, for example an edit made through a deployed link, `update` first saves the uncommitted changes and unpushed commits as a patch in `~/.configfile/saved/`, so that you can recover them: apply the patch in your working copy with `git am`. + +:::caution +`update` resets the mirror's folder, whatever it is. A configuration made with *configfile* 0.3 may point to your own working copy (for example `~/.dotfiles`): `update` would then reset your working copy to the remote, after saving its uncommitted changes and unpushed commits as a patch. [Upgrading from 0.3](/guides/upgrading-from-0-3/) explains how to give *configfile* its own copy. +::: + +## The repository's layout + +```txt +files/ one folder per module + zsh/ + settings.json + zshrc + git/ + settings.json + gitconfig +scripts/ scripts, run with configfile scripts run , or when a module is deployed + setup.sh +``` + +A **module** is a folder of `files/` that groups the configuration of one tool. Its `settings.json` lists the module's files: each **entry** names a file of the module, where it goes, and how it's deployed. Scripts in `scripts/` do what files alone can't, such as installing tools: you run them yourself, or a module names the ones to [run when it is deployed or undeployed](/guides/module-scripts/). The [modules](/reference/modules/) and [scripts](/reference/scripts/) references describe both folders in detail, and [Your first dotfiles repository](/getting-started/tutorial/) builds one step by step. + +## How files are deployed + +Each entry says how its file is deployed: + +- **Global files are linked.** `~/.zshrc` becomes a symbolic link to the file in the mirror. You deploy a file once: when `update` brings a new version, the link already points to it. +- **Local files are copied** into the current folder by `configfile modules deploy --local`. They suit files that belong in each project, such as an `.editorconfig`, and that a project may change. A copy doesn't follow the repository: deploy it again to refresh it, and *configfile* asks before replacing a copy that differs. + +An entry with `"deploy": "none"` stays in the repository without being deployed. + +## Keeping track + +*configfile* records each file it deploys, and each file it moves aside, in `~/.configfile/state.json`. That record is what lets it: + +- show what is deployed, with [`configfile modules status`](/reference/commands/#configfile-modules-status); +- undo a deployment, with [`configfile modules undeploy`](/reference/commands/#configfile-modules-undeploy), putting back the file it replaced; +- find the files it deployed for entries that have since left the repository, which `update` warns about and `configfile modules undeploy --removed` removes. + +It also logs each run of the commands that change things (`init`, `modules deploy` and `undeploy` except dry runs, `update`, `scripts run`) in `~/.configfile/history.jsonl`, along with any command that fails unexpectedly; [`configfile history`](/reference/history/) shows them. [Safety and trust](/concepts/safety/) explains how *configfile* avoids losing or overwriting your files. diff --git a/docs/src/content/docs/concepts/safety.md b/docs/src/content/docs/concepts/safety.md new file mode 100644 index 0000000..e1d4d32 --- /dev/null +++ b/docs/src/content/docs/concepts/safety.md @@ -0,0 +1,48 @@ +--- +title: Safety and trust +description: What deploying a repository means, how configfile keeps a copy of what it replaces, and what it refuses to touch. +sidebar: + order: 2 +--- + +*configfile* replaces files in your home folder, where your shell, editor and tools keep their configuration. This page explains what you trust when you deploy a repository, the precautions *configfile* takes before replacing a file, and the cases where it refuses to act. The [modules reference](/reference/modules/) gives the exact rules. + +## Deploying a repository means trusting it + +The files of a repository end up in your shell's and your tools' configuration, and its scripts run on your machine with your permissions, including the scripts a module names, which `modules deploy` and `modules undeploy` run by themselves (`--dry-run` lists them, `--no-hooks` skips them). Treat deploying a dotfiles repository like running its code: only deploy repositories you trust, and read a shared one before using it. + +## A copy of what is replaced + +- **Global files:** when something already exists where a file is deployed (a file, a folder or another link), it is moved aside to `.old` (or `.old.1`, `.old.2`…) before the link is created. A link that already points to the right file is left as it is, so deploying twice is safe; an outdated link *configfile* made itself is simply replaced. +- **Local files:** an existing file that differs from the repository, whether *configfile* copied it or not, is only replaced after you answer yes (or with `--force`), and it is moved aside to `.old` the same way. Without a terminal to ask in, it is skipped and the command exits with an error. +- **Preview:** `--dry-run` on `modules deploy` and `modules undeploy` shows what would happen, without changing anything. + +Apart from the backup that `undeploy` puts back in place, `.old` files stay where they are: delete the ones you no longer need. + +## What undeploy removes + +`configfile modules undeploy` uses the record *configfile* keeps (see [Keeping track](/concepts/how-it-works/#keeping-track)): + +- it removes the links that point to the repository's files, including those made by *configfile* 0.3, and the local copies it made, as long as a copy is still the file it made and matches the repository's current version; +- it moves the most recent backup it made back in place, if that backup is unchanged. + +Everything else stays: files it didn't create, even when they are identical to the repository (links to the repository's files excepted, as above); local copies that differ from the repository (because you changed them, or because the repository changed since) or that an editor saved again; and `.old` files it didn't make. + +## When in doubt, nothing is removed + +*configfile* doesn't remove a deployed file when it cannot tell whether the repository still deploys it, for example when the file's module cannot be read, or when it doesn't know which entry the file comes from. `modules status` lists such files separately, so that you can fix the module. + +## Refused targets + +A `target_path` can't put something important at risk: your home folder, the current folder, the dotfiles repository and its modules, or *configfile*'s own files (`~/.configfilerc`, `~/.configfile/`). Targets are checked by what they are, not by how they are written, so a symbolic link or different letter case doesn't get around the check. In the same way, a `source_path` must stay inside its module. + +## Deployments don't overlap + +`modules deploy` and `modules undeploy` change files one at a time across processes: while one runs, another one (except a dry run) waits for it, saying `Waiting for another configfile (process N) to finish…`, up to 10 seconds, then stops with an error, which names the other process when it can. The lock is `~/.configfile/lock`; the lock of a process that no longer exists is taken over, by one process at a time (`~/.configfile/lock.takeover` marks the one doing it). A lock without a process number, as configfile 1.0 leaves for a moment while it creates the lock, is waited for, never taken over. A lock that cannot be read is never taken over: its owner cannot be checked, so the command stops and says why. Both take it for the files of each module in turn, and release it while a script of the module runs, so that script can run *configfile* itself. Another deployment can therefore take its turn between two modules; files are still never changed by two processes at once. + +`update` takes the same lock for the whole sync, from fetching the remote to resetting the mirror, and `init` while it clones or reuses the repository and saves the configuration. A git prompt for a password or a passphrase during the fetch or the clone holds the lock too: run `update` and `init` where you can answer, or use an SSH key or a git credential helper. Files are therefore never deployed while a sync replaces them: one waits for the other. As the lock is released between two modules, an `update` can still run in the middle of `modules deploy --all`; the modules after it are then deployed from the synced repository, with the settings read when the deployment started. `scripts run` doesn't take the lock, as a script can run for a long time and run *configfile* itself: don't run scripts during an `update`. + +## Your configuration and history stay private + +- `~/.configfilerc` is readable by you only, since the repository URL may contain credentials. If other users can read it, *configfile* makes it private and warns you. `init` also warns when the URL contains credentials; an SSH key or a git credential helper is safer. +- The [history](/reference/history/) never records script arguments, environment variables or file contents. It hides passwords and tokens in URLs (an SSH user name such as `git` is kept) and query parameters that look like secrets. It does record full paths, which include your user name. diff --git a/docs/src/content/docs/getting-started/installation.md b/docs/src/content/docs/getting-started/installation.md new file mode 100644 index 0000000..0a87b92 --- /dev/null +++ b/docs/src/content/docs/getting-started/installation.md @@ -0,0 +1,69 @@ +--- +title: Installation +description: Install configfile on macOS or Linux, with one command, Homebrew or npm. +sidebar: + order: 1 +--- + +## Requirements + +- 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 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): + +```bash +npm install --global https://github.com/mindsers/configfile/releases/download/1.1.0/configfile-1.1.0.tgz +``` + +Install it one way only: two copies would compete in your `PATH`. + +> Versions 1.x are 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/getting-started/tutorial.md b/docs/src/content/docs/getting-started/tutorial.md new file mode 100644 index 0000000..f5984fe --- /dev/null +++ b/docs/src/content/docs/getting-started/tutorial.md @@ -0,0 +1,206 @@ +--- +title: Your first dotfiles repository +description: From an empty repository to a deployed configuration file and a setup script, in ten minutes. +sidebar: + order: 2 +--- + +This tutorial takes you from nothing to a working setup: a dotfiles repository with one configuration file and one setup script, deployed on your machine by *configfile*. It takes about ten minutes. + +You need *configfile* [installed](/getting-started/installation/) and a GitHub account (any git hosting works the same way). + +## 1. Create your dotfiles repository + +*configfile* doesn't store your files: they live in a git repository that you own, usually called `dotfiles`. + +Create an empty repository named `dotfiles` on GitHub, then clone it. This clone is your **working copy**: it's where you edit your dotfiles. + +```sh +git clone git@github.com:me/dotfiles.git ~/dotfiles +cd ~/dotfiles +``` + +## 2. Add a first module + +A **module** is a folder of `files/` that groups the configuration of one tool. Create one for zsh, with the file you want on every machine: + +```sh +mkdir -p files/zsh +echo 'alias ll="ls -la"' > files/zsh/zshrc +``` + +Then tell *configfile* where this file goes, in the module's `settings.json`: + +```json title="files/zsh/settings.json" +{ + "files": [ + { "source_path": "zshrc", "target_path": "~/.zshrc", "deploy": "global" } + ] +} +``` + +`"deploy": "global"` means the file is linked into your home folder. The [modules reference](/reference/modules/) lists every option. + +## 3. Add a setup script + +Scripts go in `scripts/`. They're handy for what files alone can't do, such as installing tools: + +```sh title="scripts/setup.sh" +#!/bin/sh +echo "Setting up $CONFIGFILE_OS from $CONFIGFILE_REPO" +``` + +`CONFIGFILE_OS` and `CONFIGFILE_REPO` are set by *configfile* when it runs the script (see [Scripts](/reference/scripts/)). + +Commit and push: + +```sh +git add . +git commit -m "First module" +git push +``` + +Your repository now looks like this: + +```txt +files/ + zsh/ + settings.json + zshrc +scripts/ + setup.sh +``` + +## 4. Connect configfile to the repository + +```sh +configfile init --repo git@github.com:me/dotfiles.git +``` + +```txt + Info Cloning git@github.com:me/dotfiles.git into ~/.configfile/dotfiles… +Cloning into '/Users/me/.configfile/dotfiles'... +done. + Done configfile is ready. Configuration saved to ~/.configfilerc. +``` + +*configfile* keeps its own copy of the repository in `~/.configfile/dotfiles`, and deploys from it. You never edit this copy: you edit your working copy, push, and *configfile* follows. [How it works](/concepts/how-it-works/) explains why. + +## 5. Deploy the module + +Check where things stand: + +```sh +configfile modules status +``` + +```txt +zsh: + ~/.zshrc (not deployed: a file is in the way) +``` + +You probably already have a `~/.zshrc`. Preview what deploying would do, without changing anything: + +```sh +configfile modules deploy zsh --dry-run +``` + +```txt + Info Dry run, nothing is changed. Deploying zsh would do: +- ~/.zshrc (would be linked, the existing file moved to ~/.zshrc.old) + Done Dry run finished. +``` + +Your current file is kept, as `~/.zshrc.old`. Deploy: + +```sh +configfile modules deploy zsh +``` + +```txt + Info Deploying zsh… +- ~/.zshrc (deployed, previous file moved to ~/.zshrc.old) + Done Deployment finished. +``` + +`~/.zshrc` is now a link to the file in the repository: + +```sh +ls -l ~/.zshrc +# ~/.zshrc -> ~/.configfile/dotfiles/files/zsh/zshrc +``` + +## 6. Run the setup script + +```sh +configfile scripts run setup +``` + +```txt + Info Running "setup"… +Setting up macos from ~/.configfile/dotfiles + Done Script "setup" finished. +``` + +## 7. Change a file and sync + +Edit the file in your working copy, then push, as for any git repository: + +```sh +cd ~/dotfiles +echo 'alias gs="git status"' >> files/zsh/zshrc +git commit -am "Add gs alias" +git push +``` + +Then bring the change to this machine: + +```sh +configfile update +``` + +```txt + Info Syncing ~/.configfile/dotfiles… + Done Synced with origin/main (117f764). Global files are links, so they are up to date; run "configfile modules deploy --all" for new files. +``` + +Because `~/.zshrc` is a link, the new alias is there right away. On your other machines, `configfile update` does the same. + +:::tip +To set up a new machine, install *configfile*, then run `configfile init`, `configfile modules deploy --all` and your setup script. +::: + +## 8. See what happened, and undo it + +*configfile* records every change it makes. In `configfile history`, the deployment and the sync look like this: + +```sh +configfile history +``` + +```txt +2026-10-08 11:29 modules deploy zsh ok + linked ~/.zshrc (previous file moved to ~/.zshrc.old) + +2026-10-08 11:29 update ok + synced ~/.configfile/dotfiles with origin/main (f9a3f1e → 117f764) +``` + +And every deployment can be reverted. `undeploy` removes the link and puts your previous file back: + +```sh +configfile modules undeploy zsh +``` + +```txt + Info Undeploying zsh… +- ~/.zshrc (removed, ~/.zshrc.old restored) + Done Undeployment finished. +``` + +## Next steps + +- [Modules and `settings.json`](/reference/modules/): local files copied into a project, files kept in the repository without being deployed, and every rule for targets. +- [Scripts](/reference/scripts/): scripts in any language, folder scripts, and versions per system (`setup.macos.sh`). +- [Run scripts when a module is deployed](/guides/module-scripts/): install plugins or start a service each time a module is deployed, and clean up when it is undeployed. +- [Commands](/reference/commands/): every command and option, including the ones for running *configfile* from scripts and CI. diff --git a/docs/src/content/docs/guides/module-scripts.md b/docs/src/content/docs/guides/module-scripts.md new file mode 100644 index 0000000..8a31b49 --- /dev/null +++ b/docs/src/content/docs/guides/module-scripts.md @@ -0,0 +1,122 @@ +--- +title: Run scripts when a module is deployed +description: Install plugins, rebuild a cache or start a service each time a module is deployed, and clean up when it is undeployed. +sidebar: + order: 3 +--- + +Linking a configuration file is often half of the job: tmux needs its plugins installed, `mise` needs to install the tools its configuration lists, a font cache needs rebuilding. A module can name scripts for these steps, and *configfile* runs them when it deploys or undeploys the module. This guide sets one up for tmux; the [modules reference](/reference/modules/#run-scripts-when-a-module-is-deployed-or-undeployed) has every rule. + +## 1. Write the script + +Scripts go in `scripts/`, like the ones you run with `configfile scripts run`. This one installs the tmux plugin manager, then the plugins listed in `~/.tmux.conf`: + +```sh title="scripts/tmux-plugins.sh" +#!/bin/sh +set -e + +tpm="$HOME/.tmux/plugins/tpm" +[ -d "$tpm" ] || git clone https://github.com/tmux-plugins/tpm "$tpm" +"$tpm/bin/install_plugins" +``` + +The script runs **every time** the module is deployed, so it must be safe to run again: here it only clones the plugin manager when it isn't there yet. + +## 2. Name it in the module + +In the module's `settings.json`, next to `files`: + +```json title="files/tmux/settings.json" {5} +{ + "files": [ + { "source_path": "tmux.conf", "target_path": "~/.tmux.conf", "deploy": "global" } + ], + "post_deploy": "tmux-plugins" +} +``` + +`post_deploy` is the script's name, as `configfile scripts list` shows it, not its path. Push the change and run `configfile update`, as for any change to your dotfiles. + +## 3. Check, then deploy + +`modules status` shows the scripts of each module, and says when a name matches no script: + +```txt +$ configfile modules status tmux +tmux: + ~/.tmux.conf (not deployed) + post_deploy: script "tmux-plugins" +``` + +A dry run shows when the script would run, without running it: + +```txt +$ configfile modules deploy --all --dry-run + Info Dry run, nothing is changed. Deploying tmux, zsh would do: +- ~/.tmux.conf (would be linked) +- script "tmux-plugins" (post_deploy of tmux: would run) +- ~/.zshrc (would be linked) + Done Dry run finished. +``` + +Then deploy. Modules are deployed one at a time: the files of `tmux`, its script, then `zsh`. + +```txt +$ configfile modules deploy --all + Info Deploying tmux, zsh… +- ~/.tmux.conf (deployed) + Info Running "tmux-plugins", the post_deploy script of tmux… +- ~/.zshrc (deployed) + Done Deployment finished. +``` + +The output of the script appears where it runs. `configfile modules deploy tmux` alone now leaves the module fully set up, and you can still run the script by hand with `configfile scripts run tmux-plugins`. + +## When a script fails + +When the script exits with an error, the command stops there and fails: the modules after it are not deployed. What is already deployed stays in place. + +```txt + Error tmux: post_deploy script "tmux-plugins" exited with code 1. + Error The post_deploy script of tmux failed. 1 module was not deployed: zsh. Use --keep-going to go on after a script that fails. +``` + +Fix the cause and deploy again: files already in place are left as they are, and the script runs again. + +A name that matches no script is caught earlier: *configfile* checks the names before it changes anything, and fails without deploying any module. To deploy the other modules anyway, pass `--keep-going`; the command still fails, so a bootstrap script notices. To deploy the files without running any script, pass `--no-hooks`. + +## Clean up when the module is undeployed + +`configfile modules undeploy tmux` removes the link, not what the script installed. Name a second script for that: + +```sh title="scripts/tmux-plugins-remove.sh" +#!/bin/sh +rm -rf "$HOME/.tmux/plugins" +``` + +```json title="files/tmux/settings.json" {6} +{ + "files": [ + { "source_path": "tmux.conf", "target_path": "~/.tmux.conf", "deploy": "global" } + ], + "post_deploy": "tmux-plugins", + "pre_undeploy": "tmux-plugins-remove" +} +``` + +## Which setting to use + +| Setting | The script runs | Typical use | +| --- | --- | --- | +| `pre_deploy` | before the files are deployed | install the tool the configuration is for; when the script fails, the module is not deployed | +| `post_deploy` | once all the files are in place | install plugins, rebuild a cache, start or reload a service | +| `pre_undeploy` | before the files are removed, while they are still in place | stop a service, remove what `post_deploy` installed; when the script fails, the module stays deployed | +| `post_undeploy` | once the files are removed | reload a tool that now uses its defaults | + +## Good to know + +- **One script for several settings or modules.** A script gets the setting that named it in `CONFIGFILE_HOOK`, the module in `CONFIGFILE_MODULE`, and `global` or `local` in `CONFIGFILE_DEPLOY`, in addition to the [variables every script gets](/reference/scripts/). +- **Local files.** `configfile modules deploy --local` runs the scripts too, in the project folder, for the modules that have local files. A script that only makes sense once per machine can leave early: `[ "$CONFIGFILE_DEPLOY" = global ] || exit 0`. +- **Versions per system.** As for any script, `tmux-plugins.macos.sh` is used on macOS instead of `tmux-plugins.sh`. A script that only has a version for another system is skipped on this one, and the module is deployed all the same: a step that only makes sense on macOS needs no empty script for Linux. +- **Scripts that run configfile.** They can: no deployment is in progress while a script runs. +- **Trust.** These scripts run without a question, as part of `modules deploy` and `modules undeploy`. Read the scripts of a repository that isn't yours before deploying it, or use `--dry-run` to see which ones would run. See [Safety and trust](/concepts/safety/). diff --git a/docs/src/content/docs/guides/new-machine.md b/docs/src/content/docs/guides/new-machine.md new file mode 100644 index 0000000..7c48538 --- /dev/null +++ b/docs/src/content/docs/guides/new-machine.md @@ -0,0 +1,99 @@ +--- +title: Set up a new machine +description: Bring your configuration to a new computer, by hand or with a bootstrap script. +sidebar: + order: 1 +--- + +This guide takes a machine from nothing to your configuration, using a dotfiles repository you already have. If you don't have one yet, start with [Your first dotfiles repository](/getting-started/tutorial/). + +## 1. Install configfile + +On a new machine, install the standalone binary, which needs neither a package manager nor Node.js: + +```sh +curl -fsSL https://docs.configfile.sh/install.sh | sh +``` + +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 + +For a private repository, the machine needs your git credentials before *configfile* can clone it: add an SSH key to your git host, or sign in with its command line tool (`gh auth login` for GitHub). A public repository can be cloned with its `https://` URL without credentials. + +Avoid putting a token in the repository URL: *configfile* saves the URL in `~/.configfilerc`, and warns when it contains credentials. + +## 3. Connect configfile to the repository + +```sh +configfile init +``` + +*configfile* asks for the repository's URL and clones it into `~/.configfile/dotfiles`. + +## 4. Deploy your modules + +See what would be deployed, and what is in the way: + +```sh +configfile modules status +configfile modules deploy --all --dry-run +``` + +Then deploy every module: + +```sh +configfile modules deploy --all +``` + +Files that already exist where yours go, such as the default `~/.zshrc` of a new machine, are moved aside to `.old`, so you can compare them or bring them back with `configfile modules undeploy`. + +Modules that [name a script to run when they are deployed](/guides/module-scripts/), for example to install plugins, run it at this step, and the dry run lists it. If one fails, the modules after it are not deployed: fix the cause and run the command again. + +## 5. Run your setup script + +If your repository has a setup script, for example to install your tools, run it: + +```sh +configfile scripts run setup +``` + +With a version per system (`setup.macos.sh`, `setup.linux.sh`), *configfile* picks the one for this machine. See [Scripts](/reference/scripts/). + +## 6. Copy local files into your projects + +Local files, such as an `.editorconfig`, are copied into the current folder rather than linked. In each project that needs them: + +```sh +cd ~/projects/my-project +configfile modules deploy --all --local +``` + +## Do it all with one script + +To set up machines regularly, put the steps in a script. Without a terminal to ask questions in, *configfile* needs everything as options: `--repo` for `init`, and module names or `--all` for `modules deploy`. [Use configfile in scripts and CI](/guides/scripts-and-ci/) lists them. + +```sh title="bootstrap.sh" +#!/bin/sh +set -e + +# 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 +``` + +`set -e` stops the script at the first step that fails: *configfile* exits with a non-zero code when a command fails. On a machine that already has a configuration, `init` refuses to replace it without `--force`. + +## Keep the machine in sync + +When you push changes to your repository, bring them to the machine with: + +```sh +configfile update +``` + +Linked files are up to date right away. Run `configfile modules deploy --all` again when you add files or modules, as `update` reminds you, and `configfile modules undeploy --removed` to remove what the repository no longer deploys, which `update` warns about. diff --git a/docs/src/content/docs/guides/scripts-and-ci.md b/docs/src/content/docs/guides/scripts-and-ci.md new file mode 100644 index 0000000..85c6020 --- /dev/null +++ b/docs/src/content/docs/guides/scripts-and-ci.md @@ -0,0 +1,101 @@ +--- +title: Use configfile in scripts and CI +description: Run configfile without a terminal, rely on its exit codes, and check a dotfiles repository on every push. +sidebar: + order: 2 +--- + +Every *configfile* command can run without anyone at the keyboard: in a bootstrap script, a cron job, or a CI workflow. This guide covers what changes without a terminal, and how to check your dotfiles repository automatically. + +## Answering questions with options + +When it runs in a terminal, *configfile* asks for what it needs. When its input is not a terminal, it can't ask: a command that would need an answer stops with an error that names the option to pass instead. + +```txt +$ configfile modules deploy < /dev/null + Error No module given. Pass module names, or --all to deploy every module. +``` + +| Command | Question in a terminal | Option to pass instead | +| --- | --- | --- | +| `configfile init` | the repository's URL | `--repo ` | +| `configfile init` | whether to replace an existing configuration | `--force` | +| `configfile modules deploy` and `undeploy` | whether to use every module | module names, or `--all` | +| `configfile modules deploy --local` | whether to replace an existing file that differs | `--force` (without it, the file is skipped and the command fails) | + +Two more options matter in a script, although no question stands behind them: `--keep-going` deploys (or undeploys) the remaining modules when a [script named by a module](/guides/module-scripts/) fails, and `--no-hooks` runs none of these scripts. + +`configfile init --folder ` also lets a script choose where the repository is cloned. The [commands reference](/reference/commands/) lists every option. + +## Exit codes + +*configfile* exits with: + +- `0` when the command succeeded; +- another code when it failed, with an error message saying why: usually `1`, or git's own code when a git command fails (for example `128` when the repository can't be fetched); +- the script's own exit code with `configfile scripts run`, so a failing setup script fails the step that runs it; +- `1` when a [script named by a module](/guides/module-scripts/) fails during `modules deploy` or `undeploy` (its own exit code is in the [history](/reference/history/)); +- `130` when you press Ctrl+C at a question, or while a script named by a module runs (`143` when *configfile* gets SIGTERM during such a script). + +In a shell script, `set -e` therefore stops at the first *configfile* command that fails. + +Commands that change files (`init`, `update`, `modules deploy` and `undeploy`) never change files at the same time: a command started while another holds the lock waits for it, saying `Waiting for another configfile (process N) to finish…`, and fails with `1` after 10 seconds. `modules deploy` and `undeploy` release the lock between two modules, so another command can run in between. In a script, run them one after the other, for example `update`, then `modules deploy --all`. + +A git prompt for a password or a passphrase makes `update` and `init` wait for an answer, and holds the lock meanwhile: without a terminal it fails. Give scripts a way to reach the repository without a question, such as an SSH key loaded in an agent or a git credential helper. + +## Output + +With `configfile scripts run`, *configfile*'s own messages go to stderr and the script's output to stdout, unchanged: you can pipe or redirect the script's output as if you had run it directly. During `modules deploy` and `undeploy`, the output of the scripts named by the modules is mixed with *configfile*'s on stdout. + +`configfile history --json` prints the [history](/reference/history/) as JSON Lines, one run per line, for scripts that need to know what *configfile* did. + +## Check your dotfiles repository on every push + +A mistake in a `settings.json`, such as invalid JSON, a missing `target_path`, or a target *configfile* refuses, is usually found on the next machine you set up. A CI workflow can find it when you push, by deploying the repository into a throwaway home folder with `--dry-run`: *configfile* reads every module and entry, checks every target, and exits with `1` if anything can't be deployed. + +With GitHub Actions, add this workflow to your dotfiles repository: + +```yaml title=".github/workflows/check.yml" +name: Check dotfiles + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: npm install --global https://github.com/mindsers/configfile/releases/download/1.1.0/configfile-1.1.0.tgz + # The following steps use a throwaway home folder, not the runner's. + - run: | + mkdir -p "$RUNNER_TEMP/home" + echo "HOME=$RUNNER_TEMP/home" >> "$GITHUB_ENV" + - run: configfile init --repo "$GITHUB_WORKSPACE" + - run: configfile modules deploy --all --dry-run +``` + +`init` accepts a local folder as the repository, here the checkout of the commit being tested. Nothing is deployed for real, and the runner's home folder isn't touched. The dry run fails if any module or entry is invalid, even when the others are fine, local files included. It lists the scripts the modules name to run when they are deployed, without running them, and fails before listing anything if one of them doesn't exist. A file whose entry has no `deploy` strategy only produces a warning, as it does on your machines. + +To also test your setup script, add a step that runs it, on the systems it supports: + +```yaml + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + # … the steps above, then: + - run: configfile scripts run setup +``` + +Run it only if the script is safe to run on a fresh CI machine: it installs and changes things for real, as on your own computer. + +:::tip +In your editor, the [JSON Schema of `settings.json`](/reference/modules/#completion-and-validation-in-your-editor) catches most of these mistakes before you even commit. +::: diff --git a/docs/src/content/docs/guides/upgrading-from-0-3.md b/docs/src/content/docs/guides/upgrading-from-0-3.md new file mode 100644 index 0000000..7dd0e1f --- /dev/null +++ b/docs/src/content/docs/guides/upgrading-from-0-3.md @@ -0,0 +1,19 @@ +--- +title: Upgrading from 0.3 +description: What changes for users of configfile 0.3. +sidebar: + order: 4 +--- + +- 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"`. +- A **relative** `target_path` of a global file is now relative to your home folder, not to the folder you run *configfile* from. Targets starting with `~/` or `/` are not affected. +- Scripts keep their names (up to the first dot) and every file of `scripts/` is still a script. They are no longer made executable by *configfile*: a script needs a shebang line, a `.js`/`.sh` extension, or to be executable. +- Targets inside the dotfiles repository, or containing it, are now refused. +- `modules undeploy` also removes the links made by 0.3 that point to the repository, but it only restores the backups it made itself (recorded in `~/.configfile/state.json`): the `.old` files made by 0.3 are not put back, and local copies made by 0.3 are kept. +- A `source_path` must stay inside its module folder. +- `init` clones into `~/.configfile/dotfiles` by default, and an existing `folder_path` is kept. `update` now makes that folder identical to the remote, saving local changes as a patch first. If your configured folder is also your working copy (for example `~/.dotfiles`), give *configfile* its own copy: run `configfile modules undeploy --all` (files that 1.0 moved aside come back; links made by 0.3 are removed, and their `.old` files stay where they are), then `configfile init --force --repo --folder ~/.configfile/dotfiles`, then `configfile modules deploy --all` (links now point to the copy). +- With `script_extensions` set, only files with one of these extensions are scripts, and `""` means files without extension (0.3 matched any file containing the text). +- Commands now exit with a non-zero code on failure. diff --git a/docs/src/content/docs/index.md b/docs/src/content/docs/index.md new file mode 100644 index 0000000..4ebd3d1 --- /dev/null +++ b/docs/src/content/docs/index.md @@ -0,0 +1,32 @@ +--- +title: configfile +description: Manage your dotfiles and setup scripts from a git repository, on macOS and Linux. +--- + +*configfile* is a command line tool that helps you manage your configuration files (dotfiles) and setup scripts from a git repository. + +- [Install configfile](/getting-started/installation/), then follow [Your first dotfiles repository](/getting-started/tutorial/), a ten-minute tutorial. +- [How it works](/concepts/how-it-works/): your repository, the mirror configfile keeps, and what it deploys. +- [Safety and trust](/concepts/safety/): what configfile protects, and what it refuses to touch. +- Reference: [commands](/reference/commands/), [modules and `settings.json`](/reference/modules/), [scripts](/reference/scripts/), [configuration](/reference/configuration/), [history](/reference/history/). +- Guides: [set up a new machine](/guides/new-machine/), [use configfile in scripts and CI](/guides/scripts-and-ci/), [run scripts when a module is deployed](/guides/module-scripts/), [upgrade from 0.3](/guides/upgrading-from-0-3/). +- [Changelog](https://github.com/mindsers/configfile/blob/develop/CHANGELOG.md): what changed in each version. + +## Contribution + +Contributions are welcome and greatly appreciated. Read [How to contribute to configfile](https://github.com/mindsers/configfile/blob/develop/CONTRIBUTING.md) before opening a pull request, and follow the [code of conduct](https://github.com/mindsers/configfile/blob/develop/CODE_OF_CONDUCT.md). + +- **Questions and bugs:** [open an issue](https://github.com/mindsers/configfile/issues/new/choose). +- **Security vulnerabilities:** never in a public issue. See the [security policy](https://github.com/mindsers/configfile/blob/develop/SECURITY.md). + +## Support + +*configfile* is free and open source software, under the Apache-2.0 license, built on my free time. + +If you use it and want to support its development, you can sponsor me on [GitHub Sponsors](https://github.com/sponsors/mindsers). Thank you! + +The people who support *configfile* are listed in [SPONSORS.md](https://github.com/mindsers/configfile/blob/develop/SPONSORS.md). + +## License + +This project is under the Apache-2.0 license. See the [LICENSE](https://github.com/mindsers/configfile/blob/develop/LICENSE) file. diff --git a/docs/src/content/docs/reference/commands.md b/docs/src/content/docs/reference/commands.md new file mode 100644 index 0000000..97c99f2 --- /dev/null +++ b/docs/src/content/docs/reference/commands.md @@ -0,0 +1,157 @@ +--- +title: Commands +description: Every configfile command and option, exit codes, and use without a terminal. +--- + +Every command prints its own help with `--help`, and most have a short form, shown under their usage. The usage lines and the tables of arguments and options below are generated from *configfile* itself. + +## `configfile init` + +Asks for the URL of your dotfiles repository (any URL git can clone, such as `https://github.com/me/dotfiles.git` or `git@github.com:me/dotfiles.git`, or an existing local folder), clones it into `~/.configfile/dotfiles` and saves the configuration in `~/.configfilerc`. If the folder already contains a git repository, it is used as is. When you run `init` again, the existing folder is kept. After its questions, it waits for a deployment or `update` in progress, saying `Waiting for another configfile (process N) to finish…`, up to 10 seconds; it then checks the folder and the configuration again: it reuses a repository another configfile cloned meanwhile, and stops if another configfile saved a configuration meanwhile (unless `--force`). + + +```sh +configfile init [options] +``` + +Short form: `configfile i`. + +| Argument or option | Description | +| --- | --- | +| `-f, --force` | overwrite the existing configuration without asking | +| `--repo ` | dotfiles repository URL (skips the question) | +| `--folder ` | where to clone the repository (default: ~/.configfile/dotfiles) | + + +## `configfile modules list` + +Lists the available modules. `configfile modules` alone does the same. + + +```sh +configfile modules list +``` + +Short form: `configfile m l`. + + +## `configfile modules status` + +Shows whether each global file of the modules (all modules by default) is deployed, not deployed, or blocked by another file, and the scripts each module runs when it is deployed or undeployed. Without module names, it also lists the deployed files the repository no longer deploys. + + +```sh +configfile modules status [options] [modules...] +``` + +Short form: `configfile m st`. + +| Argument or option | Description | +| --- | --- | +| `modules` | modules to check (all when omitted) | +| `-l, --local` | check the local files of the modules, in the current folder | + + +## `configfile modules deploy` + +Deploys the global files of the given modules, one module at a time, and runs the [`pre_deploy` and `post_deploy` scripts](/reference/modules/#run-scripts-when-a-module-is-deployed-or-undeployed) of the modules that have them. Without module names, asks to deploy all modules. + + +```sh +configfile modules deploy [options] [modules...] +``` + +Short form: `configfile m d`. + +| Argument or option | Description | +| --- | --- | +| `modules` | modules to deploy (use --all, or answer a question, when omitted) | +| `-l, --local` | copy the local files of the modules instead of linking global files | +| `-a, --all` | deploy every module without asking | +| `-f, --force` | replace existing local files without asking (they are moved to .old) | +| `-n, --dry-run` | show what would be done, without changing anything | +| `--no-hooks` | do not run the pre_deploy and post_deploy scripts of the modules | +| `--keep-going` | deploy the remaining modules when a pre_deploy or post_deploy script fails | + + +## `configfile modules undeploy` + +Removes the deployed global files of the given modules and restores their backups, one module at a time, and runs the [`pre_undeploy` and `post_undeploy` scripts](/reference/modules/#run-scripts-when-a-module-is-deployed-or-undeployed) of the modules that have them. Without module names, asks to undeploy all modules. `--local` and `--dry-run` work as for `deploy`. [Modules](/reference/modules/) explains which files `--removed` covers; with `--local`, it covers the copies made in the current folder. + + +```sh +configfile modules undeploy [options] [modules...] +``` + +Short form: `configfile m u`. + +| Argument or option | Description | +| --- | --- | +| `modules` | modules to undeploy (use --all, or answer a question, when omitted) | +| `-l, --local` | remove the local copies of the modules instead of global links | +| `-a, --all` | undeploy every module without asking, and the files the repository no longer deploys | +| `--removed` | only undeploy the files the repository no longer deploys | +| `-n, --dry-run` | show what would be done, without changing anything | +| `--no-hooks` | do not run the pre_undeploy and post_undeploy scripts of the modules | +| `--keep-going` | undeploy the remaining modules when a pre_undeploy or post_undeploy script fails | + + +## `configfile scripts list` + +Lists the available scripts. `configfile scripts` alone does the same. + + +```sh +configfile scripts list +``` + +Short form: `configfile s l`. + + +## `configfile scripts run` + +Runs a script. Arguments after `--` are passed to the script. + + +```sh +configfile scripts run [args...] +``` + +Short form: `configfile s r`. + +| Argument or option | Description | +| --- | --- | +| `name` | script to run | +| `args` | arguments passed to the script (put them after "--") | + + +## `configfile update` + +Syncs *configfile*'s copy of your dotfiles repository with the remote: it fetches the remote and makes the copy identical to it, whatever happened to the copy. Local changes found in the copy (uncommitted edits, new files or unpushed commits, for example edits made through a deployed link) are first saved as a patch in `~/.configfile/saved/`; apply it in your own working copy with `git am ` to keep them. Global files are symbolic links, so they are up to date right away; run `modules deploy --all` for new files, and `modules deploy --local` to refresh local copies. It warns about deployed files the repository no longer deploys. It waits for a deployment or `init` in progress, saying `Waiting for another configfile (process N) to finish…`, up to 10 seconds, and deployments started during the sync wait for it, up to 10 seconds: a slow fetch can make them fail. + + +```sh +configfile update +``` + +Short form: `configfile u`. + + +## `configfile history` + +Shows what *configfile* changed, most recent last (see [History](/reference/history/)). + + +```sh +configfile history [options] +``` + +| Argument or option | Description | +| --- | --- | +| `-n, --limit ` | number of runs to show (default: 20) | +| `--json` | print the raw history lines (JSON Lines), for scripts | + + +## Exit codes and use in scripts + +*configfile* exits with a non-zero code when a command fails, and with the script's exit code when a script run with `scripts run` fails, so it can be used from other scripts (a script named by a module that fails makes `modules deploy` or `undeploy` exit with `1`). When stdin is not a terminal, a command that would ask a question fails and names the option to pass instead (`--repo`, `--folder` and `--force` for `init`; module names or `--all` for `modules deploy` and `undeploy`). Without a terminal, `modules deploy --local` skips existing local files and exits with an error, unless `--force` is given. Ctrl+C at a question, or while a script named by a module runs, exits with code 130. Each run of a command that changes files, and any unexpected error, is also recorded in the [history](/reference/history/). diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md new file mode 100644 index 0000000..6e8ed0a --- /dev/null +++ b/docs/src/content/docs/reference/configuration.md @@ -0,0 +1,19 @@ +--- +title: Configuration +description: The ~/.configfilerc file and the ~/.configfile/ folder. +--- + +*configfile* keeps its configuration in `~/.configfilerc`, and its working files in the `~/.configfile/` folder: the mirror of your repository (`dotfiles/`), the record of deployments (`state.json`), the [history](/reference/history/) (`history.jsonl`), the lock, and saved local changes (`saved/`). + +`~/.configfilerc` is a JSON file, readable by you only: the repository URL may contain credentials, so if other users can read it, *configfile* makes it private and warns you. + +```json +{ + "repo_url": "git@github.com:me/dotfiles.git", + "folder_path": "/Users/me/.configfile/dotfiles", + "script_extensions": [".js", ".sh", ""], + "history_max_size": "1MB" +} +``` + +`folder_path` may start with `~`; a relative path is relative to your home folder. `script_extensions` is optional: without it, every file of `scripts/` is a script. With it, only files with one of these extensions are; the dot may be omitted (`"py"`), and `""` means files without extension. `history_max_size` is optional: see [History](/reference/history/). diff --git a/docs/src/content/docs/reference/history.md b/docs/src/content/docs/reference/history.md new file mode 100644 index 0000000..0d4d8bb --- /dev/null +++ b/docs/src/content/docs/reference/history.md @@ -0,0 +1,29 @@ +--- +title: History +description: What configfile records about each run, and how to read it. +--- + +*configfile* records what each command changed, when, and whether it failed, in `~/.configfile/history.jsonl`: one JSON line per run of `init`, `modules deploy`, `modules undeploy`, `update` and `scripts run` (dry runs and usage errors excepted), and per unexpected error of any command. Each line has the time, the *configfile* version, the command and its options, the current folder, the exit code, the error message, and the changes: files linked, copied, moved aside to `.old`, removed, restored, kept or skipped, syncs (from which commit to which, and saved patches), scripts and their exit codes (with the module and the setting, for a script a module names, and the scripts a module names that were not run on purpose). + +Script arguments, environment variables and file contents are never recorded. In URLs, user names and passwords (`https://user:token@host`) and query parameters that look like secrets (`?private_token=…`) are hidden. Paths are recorded in full, so they include your user name. When *configfile* creates the file, only its owner can read it. A run stopped by a signal (Ctrl+C outside a question) is not recorded, unless the signal comes while a script or git runs: *configfile* then leaves it to that program, and records the run with its exit code (for a deployment or undeployment stopped during a script a module names: `130` after Ctrl+C, `143` after SIGTERM, whatever the script's own exit code). + +`configfile history` shows the last runs: + +```txt +2026-10-01 11:12 modules deploy zsh ok + linked ~/.zshrc (previous file moved to ~/.zshrc.old) + 2 unchanged + +2026-10-01 11:15 update exit 128 + error git fetch failed (exit code 128). +``` + +`-n ` shows another number of runs, and `--json` prints the lines as they are written. + +The file is also easy to query with `jq`, for example to list the failed runs: + +```bash +jq 'select(.exitCode != 0)' ~/.configfile/history.jsonl +``` + +When the file reaches `history_max_size` (1MB by default; a number of bytes, or a size such as `"512KB"` or `"5MB"` in `~/.configfilerc`), it is renamed to `history.1.jsonl`, replacing the previous one, so the history takes about twice that size at most. `"history_max_size": 0` turns the history off. An invalid value gives the default and a warning; when `~/.configfilerc` cannot be read, nothing is recorded. diff --git a/docs/src/content/docs/reference/modules.md b/docs/src/content/docs/reference/modules.md new file mode 100644 index 0000000..214b33e --- /dev/null +++ b/docs/src/content/docs/reference/modules.md @@ -0,0 +1,95 @@ +--- +title: Modules and settings.json +description: Module folders, the settings.json format, and how files are deployed and undeployed. +--- + +Every folder of `files/` (or symbolic link to a folder) that contains a `settings.json` is a **module**. Its name is the folder name, lowercased, with spaces replaced by `-` and other characters than ASCII letters, digits, `_` and `-` removed: `My Zsh.d` is the `my-zshd` module. Hidden folders are ignored. + +`settings.json` lists the files of the module: + +```json +{ + "files": [ + { "source_path": "zshrc", "target_path": "~/.zshrc", "deploy": "global" }, + { "source_path": "editorconfig", "target_path": ".editorconfig", "deploy": "local" }, + { "source_path": "old-aliases", "target_path": "~/.aliases", "deploy": "none" } + ] +} +``` + +| Key | Description | +| --- | --- | +| `source_path` | Path of the file (or folder), relative to the module folder. It must stay inside the module folder, symbolic links included. | +| `target_path` | Where the file is deployed. `~` is your home folder. A relative path is relative to your home folder for global files (`.zshrc` is `~/.zshrc`) and to the current folder for local files. Targets can be anywhere you can write, except: your home folder, the current folder, the dotfiles repository, the module folder, *configfile*'s own files (`~/.configfilerc`, `~/.configfile/`), one of their parents, or anything inside the repository. These are recognised whatever the path used to reach them (letter case, symbolic links). | +| `deploy` | `"global"`: the file is **symlinked** by `configfile modules deploy`. `"local"`: the file is **copied** by `configfile modules deploy --local`, typically into a project folder (symbolic links in the source are followed, so the copy never points into the repository). `"none"`: the file is **never deployed**, which keeps it in the repository for later. | +| `global` | **Deprecated**, removed in 2.0: older spelling of `deploy`. `true` is `"global"`, `false` is `"local"`. It still works in 1.x, with a warning. Use one or the other, not both. | + +An entry without `deploy` or `global` is not deployed, and `modules deploy` warns about it. + +When a global file is deployed and something else already exists at its target (a file, a folder or another link), it is moved to `.old` (or `.old.1`, …). A link that already points to the right file is left as is, so deploying twice is safe. + +When a local file already exists and differs from the one in the repository, *configfile* asks whether to replace it, once the other files of its module are deployed. The existing file or folder is then moved to `.old` (or `.old.1`, …) before the copy, so nothing is lost. + +*configfile* records what it deploys and the backups it makes in `~/.configfile/state.json`. `configfile modules undeploy` reverts a deployment: it removes the links that point to the repository's files and the local copies *configfile* made, as long as a copy is still the file it made and matches the repository's current version, and moves the most recent backup it made back in place, if that backup is unchanged. Anything else is left untouched: files it did not create (even when identical to the repository), apart from links to the repository's files, local copies that differ from the repository (changed by you, or because the repository changed since) or were saved again by an editor, and `.old` files you made yourself. + +When an entry leaves the repository (removed, set to `"deploy": "none"`, given another `target_path`, or its module deleted), what *configfile* deployed for it stays in place until it is undeployed: `modules status` lists these files, `update` warns about them, and `modules undeploy --removed` (or `--all`) removes them and restores the files they replaced. Local copies are handled from the folder they were copied into, like `modules deploy --local`. A removed file that *configfile* cannot remove safely (replaced by your own file, a modified copy, a copy whose source is gone) is left where it is, and *configfile* stops tracking it. + +To stay safe, *configfile* never undeploys a file when it cannot tell whether the repository still deploys it: files of a module whose `settings.json` cannot be used or has invalid entries, of a module folder that is not usable (a broken symbolic link, a name another folder uses), and entries without a deployment strategy. `modules status` lists them separately. Links deployed by 0.3 and not deployed again since were not recorded, so they are not found either. + +`modules deploy` and `modules undeploy` change files one at a time across processes: while one runs, another one (except a dry run) waits for it, saying `Waiting for another configfile (process N) to finish…`, up to 10 seconds, then stops with an error (the lock is `~/.configfile/lock`). Both take the lock for the files of each module in turn, and release it while a [script of the module](#run-scripts-when-a-module-is-deployed-or-undeployed) runs: another deployment can take its turn between two modules, and a deployment that cannot take the lock again stops there, saying which modules were left out. `update` and `init` take the same lock while they change configfile's copy of the repository or its configuration, so files are never deployed from a repository being synced. The modules and their settings are read when the deployment starts, before it takes the lock. + +> **Deploying a repository means trusting it**, like code you run: its files end up in your shell configuration, and its scripts run on your machine. Only deploy repositories you trust. [Safety and trust](/concepts/safety/) explains what *configfile* protects, and what it refuses to touch. + +## Run scripts when a module is deployed or undeployed + +[Run scripts when a module is deployed](/guides/module-scripts/) is a step-by-step guide. Some modules need a step around their files: installing what the configuration needs, installing the plugins it lists, rebuilding a cache, starting a service, and the reverse when the module is removed. Name [scripts](/reference/scripts/) of the repository next to `files`: + +```json +{ + "files": [ + { "source_path": "tmux.conf", "target_path": "~/.tmux.conf", "deploy": "global" } + ], + "post_deploy": "tmux-plugins", + "pre_undeploy": "tmux-plugins-remove" +} +``` + +| Setting | The script runs | +| --- | --- | +| `pre_deploy` | before `modules deploy` deploys the files of the module | +| `post_deploy` | once `modules deploy` has deployed all of them | +| `pre_undeploy` | before `modules undeploy` removes the files of the module, while they are still in place | +| `post_undeploy` | once `modules undeploy` has removed them | + +Each one is a script's name, as `configfile scripts list` shows it: here `scripts/tmux-plugins.sh`, or `scripts/tmux-plugins.macos.sh` on macOS. It is run the way `configfile scripts run tmux-plugins` runs it (same interpreters, in the current folder, with the same variables), without arguments, and also gets the setting's name in `CONFIGFILE_HOOK`, the module's name in `CONFIGFILE_MODULE` and `global` or `local` in `CONFIGFILE_DEPLOY`. + +`modules deploy` and `modules undeploy` work on one module at a time, in the order of `modules list`: the `pre_` script of a module, its files, its `post_` script, then the next module. + +- The scripts run **every time** the module is deployed or undeployed, including when its files were already up to date or not deployed, so write them to be safe to run again. +- They only run when the module has files of the kind the command works on (local files with `--local`, global files otherwise). When you name such a module on the command line, the command says, with a warning, that its script was not run. +- When a `pre_` script fails, the files of the module are **left as they are** (not deployed, or still deployed) and its `post_` script is not run. +- The `post_deploy` script only runs when all the files of the module are in place. When a file fails or is skipped, or an entry is invalid, it is not run, with a warning. In the same way, the `post_undeploy` script is not run when a file could not be undeployed; a file that `undeploy` keeps on purpose (a copy you modified, a file it did not create) does not prevent it. +- When a script fails (it exits with an error or cannot be started), the modules after it are **left as they are** and the command fails. What was already done stays done. `--keep-going` goes on with the remaining modules anyway; the command still fails. It exits with `1`, not with the script's exit code, which is in the [history](/reference/history/). +- A name that matches no script is found **before anything is changed**: the command names every such script and fails. With `--keep-going`, it starts anyway, and the script fails when its turn comes. +- A script that only has versions for other systems (`tmux-plugins.macos.sh` alone, on Linux) is not a mistake: it is skipped, the command says so, and the module is deployed or undeployed as if it named no script. +- Ctrl+C while a script runs is left to the script, as with `scripts run`; once it ends, the command stops there, with or without `--keep-going`, and exits with `130`. +- `--no-hooks` deploys or undeploys the files without running any script. +- `--dry-run` shows the scripts that would run, and those that would not because a file would fail or be skipped, or an entry is invalid, without running them. It fails like the real command when a script does not exist. +- A setting that is not a script name (not text, or empty) is ignored: no script runs for it. The command it belongs to reports it as an error and fails, once the files are deployed or undeployed, even with `--no-hooks` (`modules deploy` for `pre_deploy` and `post_deploy`, `modules undeploy` for the other two). The other one only warns about it; `modules list` and `status` show it as an error. +- `modules list` names the settings a module uses, and `modules status` shows the scripts, and says when one does not exist. + +No script runs for the files the repository no longer deploys (`modules undeploy --removed`, or `--all`): their module or entry is gone, with its settings. With `--all`, they are undeployed after the modules; when a script stops the command before that, they are left in place, and the command says so. A `settings.json` in the 0.3 list format cannot have these keys. + +## Completion and validation in your editor + +A [JSON Schema](https://docs.configfile.sh/schemas/settings.json) describes `settings.json`. Add it at the top of the file, and editors that support JSON Schema (VS Code, Zed, JetBrains IDEs, and others) suggest the keys and their values, show their descriptions, and flag mistakes as you type: + +```json +{ + "$schema": "https://docs.configfile.sh/schemas/settings.json", + "files": [] +} +``` + +*configfile* ignores the `$schema` key. The schema checks the structure of the file; the rules about where paths may point are checked by *configfile* when it reads the module. It is also stricter, to catch typos: it flags keys *configfile* doesn't know (such as `target-path`), which *configfile* ignores. + diff --git a/docs/src/content/docs/reference/scripts.md b/docs/src/content/docs/reference/scripts.md new file mode 100644 index 0000000..38752c9 --- /dev/null +++ b/docs/src/content/docs/reference/scripts.md @@ -0,0 +1,34 @@ +--- +title: Scripts +description: Script files and folders, per-system versions, interpreters and environment variables. +--- + +Every file of `scripts/`, and every folder of `scripts/` (or symbolic link to a folder) containing an `index` file (`index`, `index.sh`, …), is a **script**. Hidden files are ignored. To only use some extensions, set `script_extensions` in the [configuration](/reference/configuration/). + +A script's name is its file name (or folder name) up to the first dot, with the same rules as [module names](/reference/modules/): `scripts/setup.sh` is named `setup`. When two scripts have the same name, only the first one, alphabetically, is used, with a warning. + +A script can have a version for each system: `macos` or `linux` after the first dot (`scripts/setup.macos.sh`, `scripts/setup.linux.py`, or a folder such as `scripts/setup.macos/`). On macOS, `configfile scripts run setup` runs `setup.macos.sh`; on another system, it runs the generic `setup.sh`, if there is one. A version for another system is ignored. + +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.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. + +Scripts get these environment variables, in addition to *configfile*'s own environment (they replace variables of the same name): + +| Variable | Value | +| --- | --- | +| `CONFIGFILE_REPO` | the full path of the dotfiles repository (`folder_path`), to reach its files: `"$CONFIGFILE_REPO/Brewfile"` | +| `CONFIGFILE_SCRIPT` | the script's name (`setup`) | +| `CONFIGFILE_OS` | the system: `macos` or `linux`, as in the names of script versions | + +A module can name scripts to run each time it is deployed or undeployed, with [`pre_deploy`, `post_deploy`, `pre_undeploy` and `post_undeploy`](/reference/modules/#run-scripts-when-a-module-is-deployed-or-undeployed) in its `settings.json`. [Run scripts when a module is deployed](/guides/module-scripts/) shows how. Their exit code is not forwarded: when one fails, `modules deploy` or `modules undeploy` exits with `1`. Such a script gets no arguments, and three more variables: + +| Variable | Value | +| --- | --- | +| `CONFIGFILE_HOOK` | the setting that named the script: `pre_deploy`, `post_deploy`, `pre_undeploy` or `post_undeploy` | +| `CONFIGFILE_MODULE` | the name of the module being deployed or undeployed (`tmux`) | +| `CONFIGFILE_DEPLOY` | `global` or `local` (with `--local`) | diff --git a/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/package.json b/package.json index 5af2096..292eb19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "configfile", - "version": "1.0.0", + "version": "1.1.0", "description": "Manage your dotfiles and setup scripts from a git repository.", "keywords": [ "dotfiles", @@ -8,7 +8,7 @@ "cli", "symlink" ], - "homepage": "https://github.com/mindsers/configfile#readme", + "homepage": "https://docs.configfile.sh", "bugs": "https://github.com/mindsers/configfile/issues", "funding": "https://github.com/sponsors/mindsers", "repository": { @@ -42,6 +42,10 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "check": "pnpm lint && pnpm typecheck && pnpm test", + "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" }, @@ -53,6 +57,9 @@ "@biomejs/biome": "2.5.15", "@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 1f77b36..465e94e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,15 +182,145 @@ importers: '@vitest/coverage-v8': specifier: 5.0.3 version: 5.0.3(vitest@5.0.3) + 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 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)) + version: 5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1)) + + docs: + dependencies: + '@astrojs/starlight': + specifier: 0.42.5 + version: 0.42.5(astro@7.3.6(@types/node@24.19.0)(yaml@2.9.1))(typescript@7.0.2) + astro: + specifier: 7.3.6 + version: 7.3.6(@types/node@24.19.0)(yaml@2.9.1) + devDependencies: + typescript: + specifier: 7.0.2 + version: 7.0.2 packages: + '@astrojs/compiler-binding-android-arm64@0.5.1': + resolution: {integrity: sha512-nMb2nMIJtcWLNH+fV75Enmi+jp4tIOYOfsv4XtpgdOWkqc9P4uSSdK5u1xb340AzrXo5V18SxLl/nc3vcqtamQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-darwin-arm64@0.5.1': + resolution: {integrity: sha512-5k4+nZ/WRt+dcVow4z7rBuI7iv9A58h/uQ91YJe1PrTivbcCkq3u2Ynx/AZJwII4CsZawBCeGpojkFjutZBhBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.5.1': + resolution: {integrity: sha512-0Buw+YST9Ff2EqQyhRMQujuFq/dijLw3zXWhsvI0dJ2pGrT38iVoxTEca2eBX/PfhlwkVWSRJEKO5gMqKHyeHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.1': + resolution: {integrity: sha512-x5LRpyCoMKuu3INr18cn42YhwSOaRpp9b5Ny3eJJ/sWVeejC16Zeshu1JlvlSmEZACLeWxU5JM0RX6T6nI+YyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.1': + resolution: {integrity: sha512-mL7tQEuDk3rGfaKmrO63nw6WBy1TPVioQGmVyjcAr+C+a/k3SVMYBytNSMqH9mZPT/Swj30JaZ2N842Z1NnOEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.1': + resolution: {integrity: sha512-uLGc9jtxwiDe4cQyhNPQim4ECiB/YzbWlXssfLAv8r374kArfTwBHjwR2+j+XVyF71yCx5mF9YNRclRqdNBgeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.5.1': + resolution: {integrity: sha512-G0cywinaQUc8v1MrytvYKUX7svTDMmYtOCZfxjwKPbJi59zIqM+Ll0rWgO0GQWLZH3XoPYo19ZwlP3Xt9ep9YA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.5.1': + resolution: {integrity: sha512-yMhLL8asmLydoAdnEZxAw6eLfNDs3YJl6jqn0pjPt6zrb2U5qIDLnii4/twThpIFODKkQ6YDpBRTOz/vSfZCxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.1': + resolution: {integrity: sha512-aClIG6poFt3Wf/bZykrEAX2nAnKvBHW6RXiKUa69QjBtWH0/FOlOAjnFcxRNlhej2AMWNYFx0o+LGjwMcH8P7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.1': + resolution: {integrity: sha512-/E6uBCagY70YpWqssBC+/FuniNSloeQPRgj7l3LDWiqjxIyTLnIgX1sTt18FU7jrigetpSnC9cHAM6b6IxSgPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.5.1': + resolution: {integrity: sha512-oczyolLUl9mNrtuBuNjuvn5thpHrFLXAHAWGTRg0JDHQ8cGVsVeFekOu2uSoQGgainJIi+A3/9m0DyYwz3Nlnw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.5.1': + resolution: {integrity: sha512-1k1acnX7DepIiea9U9/Y+d1hdAFfylu1ZQQkkYgRSwBOBAsXInolyZuh/2tax+83vu/A+hOYP6P3+265RvMTvw==} + engines: {node: '>=22.12.0'} + + '@astrojs/internal-helpers@0.12.0': + resolution: {integrity: sha512-pKPuwCDtO0L7zgmt1JGd9X4Aes1+0SX3IEBSqBNPHsScYdPMlfmed3Y7DTbXyF7E+8ylnb563o/0OzGQPOFehw==} + + '@astrojs/markdown-satteri@0.4.3': + resolution: {integrity: sha512-PFbEHPnwUlLvpXd7RXrrKM+j12akF4bG+DSuNuoN+3eiwjOwp4HaPi+QQdbkBV7QEYXmzyKca50OJsmGxhNIUA==} + + '@astrojs/mdx@8.0.3': + resolution: {integrity: sha512-SmwnVrGZSohDG0B9LXiAPbR6fe9TOumli9Vw/9zo815xGTeB1T3Fqt6HEClmBsVwIKFg/ccos4TgjZAedvg7zA==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + '@astrojs/markdown-satteri': ^0.4.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/sitemap@3.7.4': + resolution: {integrity: sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==} + + '@astrojs/starlight@0.42.5': + resolution: {integrity: sha512-S6hjUexk9rrKtjJF3WR8T9dM1woNR7X2XU8e3wj4OpRT4X15AXk/mzPjWe/Qd5+P8whHj2+ves+k06qirBzimg==} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/telemetry@3.3.3': + resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -229,43 +359,456 @@ packages: cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.15': - resolution: {integrity: sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==} - engines: {node: '>=14.21.3'} - cpu: [arm64] + '@biomejs/cli-linux-arm64-musl@2.5.15': + resolution: {integrity: sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.15': + resolution: {integrity: sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.15': + resolution: {integrity: sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.15': + resolution: {integrity: sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.15': + resolution: {integrity: sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.15': + resolution: {integrity: sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@bruits/satteri-darwin-arm64@0.10.5': + resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.10.5': + resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.10.5': + resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.10.5': + resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.10.5': + resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.10.5': + resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.10.5': + resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} + cpu: [x64] + os: [win32] + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@expressive-code/core@0.44.2': + resolution: {integrity: sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==} + + '@expressive-code/plugin-frames@0.44.2': + resolution: {integrity: sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==} + + '@expressive-code/plugin-shiki@0.44.2': + resolution: {integrity: sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==} + + '@expressive-code/plugin-text-markers@0.44.2': + resolution: {integrity: sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] os: [linux] - libc: [musl] + libc: [glibc] - '@biomejs/cli-linux-arm64@2.5.15': - resolution: {integrity: sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==} - engines: {node: '>=14.21.3'} - cpu: [arm64] + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.15': - resolution: {integrity: sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==} - engines: {node: '>=14.21.3'} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.15': - resolution: {integrity: sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==} - engines: {node: '>=14.21.3'} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - libc: [glibc] + libc: [musl] - '@biomejs/cli-win32-arm64@2.5.15': - resolution: {integrity: sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==} - engines: {node: '>=14.21.3'} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.15': - resolution: {integrity: sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==} - engines: {node: '>=14.21.3'} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -413,9 +956,57 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.2.5': + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-project/types@0.152.0': resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.5.2': + resolution: {integrity: sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==} + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==} + cpu: [x64] + os: [win32] + '@rolldown/binding-android-arm-eabi@1.2.12': resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -515,18 +1106,82 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@shikijs/core@4.5.0': + resolution: {integrity: sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.5.0': + resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.5.0': + resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==} + engines: {node: '>=20'} + + '@shikijs/langs@4.5.0': + resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.5.0': + resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==} + engines: {node: '>=20'} + + '@shikijs/themes@4.5.0': + resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==} + engines: {node: '>=20'} + + '@shikijs/types@4.5.0': + resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node@24.19.0': resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==} + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -647,6 +1302,9 @@ packages: cpu: [x64] os: [win32] + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vitest/coverage-v8@5.0.3': resolution: {integrity: sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==} peerDependencies: @@ -678,6 +1336,27 @@ packages: '@vitest/spy@5.0.3': resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + am-i-vibing@0.5.0: + resolution: {integrity: sha512-KLcsDhqf1XRrJmJr+9I/C0EX2K+sGrAGZFgkmd84c8TJO9zJBu+/kFInjazu1KfJWFhWDKdRWkpBzEs7s35DVA==} + hasBin: true + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -685,41 +1364,231 @@ packages: ast-v8-to-istanbul@1.0.7: resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + astro-expressive-code@0.44.2: + resolution: {integrity: sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==} + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 + + astro@7.3.6: + resolution: {integrity: sha512-3q0MKxfE4cpr5IoQ9EWrN0gwxeT5tkCaSVe3SrGlLR5fnTIywHqjVqFokQkQiZexfwpNtB9dtBdUcXV9FuD+4w==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.1: + resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@15.0.0: 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'} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-module-lexer@3.0.3: + resolution: {integrity: sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + expressive-code@0.44.2: + resolution: {integrity: sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -732,18 +1601,143 @@ packages: picomatch: optional: true + find-proc@0.2.0: + resolution: {integrity: sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w==} + engines: {node: ^20.19.0 || >=22.12.0} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-tsconfig@5.0.0-beta.6: + resolution: {integrity: sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-select@6.0.4: + resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + + i18next@26.4.2: + resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -818,12 +1812,119 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + magic-string@1.4.2: resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + mdast-util-directive@3.1.1: + resolution: {integrity: sha512-Gqpa3MHorXYEZ0VfbroyvBlVKUfz371V6mIERqOYMcb0W1D3LwmFq1ZW4O2TDrbn+4ZmGLwMWeFRJgYZ+bc5fQ==} + + mdast-util-from-markdown@2.1.0: + resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.2.0: + resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -833,21 +1934,144 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - obug@2.2.1: - resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} - engines: {node: '>=12.20.0'} + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + + p-queue@9.3.3: + resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + engines: {node: '>=20'} + + p-timeout@7.0.2: + resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==} + engines: {node: '>=20'} + + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + + pagefind@1.5.2: + resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} + hasBin: true + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + postcss@8.5.28: 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'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-expressive-code@0.44.2: + resolution: {integrity: sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==} + + remark-directive@4.0.0: + resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + rolldown@1.2.12: resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -856,21 +2080,76 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + satteri@0.10.5: + resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shiki@4.5.0: + resolution: {integrity: sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==} + engines: {node: '>=20'} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@9.0.1: + resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==} + engines: {node: '>=20.19.5', npm: '>=10.8.2'} + hasBin: true + + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@6.2.0: resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} engines: {node: '>=20.0.0'} + tinyclip@1.0.3: + resolution: {integrity: sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==} + engines: {node: ^16.14.0 || >= 17.3.0} + tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} @@ -883,14 +2162,139 @@ packages: resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.5: + resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + url-extras@0.1.0: + resolution: {integrity: sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==} + engines: {node: '>=20'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + verkit@0.5.0: + resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} + engines: {node: '>=18.12.0'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -934,6 +2338,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@5.0.3: resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} @@ -975,61 +2387,501 @@ packages: jsdom: optional: true - why-is-node-running@3.2.1: - resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} - engines: {node: '>=20.11'} - hasBin: true + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/compiler-binding-android-arm64@0.5.1': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.5.1': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.5.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.5.1': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.5.1': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.1': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.1': + optional: true + + '@astrojs/compiler-binding@0.5.1': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.5.1 + '@astrojs/compiler-binding-darwin-arm64': 0.5.1 + '@astrojs/compiler-binding-darwin-x64': 0.5.1 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.5.1 + '@astrojs/compiler-binding-linux-arm64-musl': 0.5.1 + '@astrojs/compiler-binding-linux-x64-gnu': 0.5.1 + '@astrojs/compiler-binding-linux-x64-musl': 0.5.1 + '@astrojs/compiler-binding-wasm32-wasi': 0.5.1 + '@astrojs/compiler-binding-win32-arm64-msvc': 0.5.1 + '@astrojs/compiler-binding-win32-x64-msvc': 0.5.1 + + '@astrojs/compiler-rs@0.5.1': + dependencies: + '@astrojs/compiler-binding': 0.5.1 + + '@astrojs/internal-helpers@0.12.0': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.5.0 + smol-toml: 1.9.0 + unified: 11.0.5 + yaml: 2.9.1 + + '@astrojs/markdown-satteri@0.4.3': + dependencies: + '@astrojs/internal-helpers': 0.12.0 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + satteri: 0.10.5 + + '@astrojs/mdx@8.0.3(@astrojs/markdown-satteri@0.4.3)(astro@7.3.6(@types/node@24.19.0)(yaml@2.9.1))': + dependencies: + '@astrojs/internal-helpers': 0.12.0 + '@astrojs/markdown-satteri': 0.4.3 + astro: 7.3.6(@types/node@24.19.0)(yaml@2.9.1) + es-module-lexer: 3.0.3 + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/sitemap@3.7.4': + dependencies: + sitemap: 9.0.1 + zod: 4.6.5 + + '@astrojs/starlight@0.42.5(astro@7.3.6(@types/node@24.19.0)(yaml@2.9.1))(typescript@7.0.2)': + dependencies: + '@astrojs/markdown-satteri': 0.4.3 + '@astrojs/mdx': 8.0.3(@astrojs/markdown-satteri@0.4.3)(astro@7.3.6(@types/node@24.19.0)(yaml@2.9.1)) + '@astrojs/sitemap': 3.7.4 + '@pagefind/default-ui': 1.5.2 + '@types/hast': 3.0.5 + '@types/js-yaml': 4.0.9 + '@types/mdast': 4.0.4 + astro: 7.3.6(@types/node@24.19.0)(yaml@2.9.1) + astro-expressive-code: 0.44.2(astro@7.3.6(@types/node@24.19.0)(yaml@2.9.1)) + bcp-47: 2.1.1 + hast-util-format: 1.1.0 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-string: 3.0.1 + hastscript: 9.0.1 + i18next: 26.4.2(typescript@7.0.2) + js-yaml: 4.3.2 + klona: 2.0.6 + magic-string: 1.4.2 + mdast-util-directive: 3.1.1 + mdast-util-to-markdown: 2.2.0 + mdast-util-to-string: 4.0.0 + pagefind: 1.5.2 + remark-directive: 4.0.0 + satteri: 0.10.5 + ultrahtml: 1.7.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + - typescript + + '@astrojs/telemetry@3.3.3': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.9.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/biome@2.5.15': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.15 + '@biomejs/cli-darwin-x64': 2.5.15 + '@biomejs/cli-linux-arm64': 2.5.15 + '@biomejs/cli-linux-arm64-musl': 2.5.15 + '@biomejs/cli-linux-x64': 2.5.15 + '@biomejs/cli-linux-x64-musl': 2.5.15 + '@biomejs/cli-win32-arm64': 2.5.15 + '@biomejs/cli-win32-x64': 2.5.15 + + '@biomejs/cli-darwin-arm64@2.5.15': + optional: true + + '@biomejs/cli-darwin-x64@2.5.15': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.15': + optional: true + + '@biomejs/cli-linux-arm64@2.5.15': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.15': + optional: true + + '@biomejs/cli-linux-x64@2.5.15': + optional: true + + '@biomejs/cli-win32-arm64@2.5.15': + optional: true + + '@biomejs/cli-win32-x64@2.5.15': + optional: true + + '@bruits/satteri-darwin-arm64@0.10.5': + optional: true + + '@bruits/satteri-darwin-x64@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.10.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.10.5': + optional: true + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@ctrl/tinycolor@4.2.1': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true -snapshots: + '@esbuild/linux-arm64@0.28.2': + optional: true - '@babel/helper-string-parser@7.29.7': {} + '@esbuild/linux-arm@0.28.2': + optional: true - '@babel/helper-validator-identifier@7.29.7': {} + '@esbuild/linux-ia32@0.28.2': + optional: true - '@babel/parser@7.29.9': + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@expressive-code/core@0.44.2': dependencies: - '@babel/types': 7.29.8 + '@ctrl/tinycolor': 4.2.1 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + postcss: 8.5.28 + postcss-nested: 6.2.0(postcss@8.5.28) + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 - '@babel/types@7.29.8': + '@expressive-code/plugin-frames@0.44.2': dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 + '@expressive-code/core': 0.44.2 - '@bcoe/v8-coverage@1.0.2': {} + '@expressive-code/plugin-shiki@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + shiki: 4.5.0 - '@biomejs/biome@2.5.15': + '@expressive-code/plugin-text-markers@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.15 - '@biomejs/cli-darwin-x64': 2.5.15 - '@biomejs/cli-linux-arm64': 2.5.15 - '@biomejs/cli-linux-arm64-musl': 2.5.15 - '@biomejs/cli-linux-x64': 2.5.15 - '@biomejs/cli-linux-x64-musl': 2.5.15 - '@biomejs/cli-win32-arm64': 2.5.15 - '@biomejs/cli-win32-x64': 2.5.15 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true - '@biomejs/cli-darwin-arm64@2.5.15': + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@biomejs/cli-darwin-x64@2.5.15': + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 optional: true - '@biomejs/cli-linux-arm64-musl@2.5.15': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@biomejs/cli-linux-arm64@2.5.15': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@biomejs/cli-linux-x64-musl@2.5.15': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@biomejs/cli-linux-x64@2.5.15': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@biomejs/cli-win32-arm64@2.5.15': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@biomejs/cli-win32-x64@2.5.15': + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': optional: true '@inquirer/ansi@2.0.8': {} @@ -1160,8 +3012,47 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@oslojs/encoding@1.1.0': {} + '@oxc-project/types@0.152.0': {} + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/default-ui@1.5.2': {} + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + '@rolldown/binding-android-arm-eabi@1.2.12': optional: true @@ -1209,19 +3100,96 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@shikijs/core@4.5.0': + dependencies: + '@shikijs/primitive': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + + '@shikijs/primitive@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + + '@shikijs/types@4.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/js-yaml@4.0.9': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + '@types/node@24.19.0': dependencies: undici-types: 7.24.6 + '@types/sax@1.2.7': + dependencies: + '@types/node': 24.19.0 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1282,6 +3250,8 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@ungap/structured-clone@1.4.0': {} + '@vitest/coverage-v8@5.0.3(vitest@5.0.3)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -1292,7 +3262,7 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)) + vitest: 5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1)) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -1300,49 +3270,334 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/mocker@5.0.3(vite@8.3.1(@types/node@24.19.0))': + '@vitest/mocker@5.0.3(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.3 estree-walker: 3.0.3 magic-string: 1.4.2 optionalDependencies: - vite: 8.3.1(@types/node@24.19.0) + vite: 8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1) + + '@vitest/spy@5.0.3': {} + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + am-i-vibing@0.5.0: + dependencies: + process-ancestry: 0.1.0 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + astro-expressive-code@0.44.2(astro@7.3.6(@types/node@24.19.0)(yaml@2.9.1)): + dependencies: + astro: 7.3.6(@types/node@24.19.0)(yaml@2.9.1) + rehype-expressive-code: 0.44.2 + url-extras: 0.1.0 + + astro@7.3.6(@types/node@24.19.0)(yaml@2.9.1): + dependencies: + '@astrojs/compiler-rs': 0.5.1 + '@astrojs/internal-helpers': 0.12.0 + '@astrojs/markdown-satteri': 0.4.3 + '@astrojs/telemetry': 3.3.3 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.8.1 + '@oslojs/encoding': 1.1.0 + am-i-vibing: 0.5.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.1 + devalue: 5.9.4 + diff: 9.0.0 + dset: 3.1.4 + es-module-lexer: 3.0.3 + esbuild: 0.28.2 + find-proc: 0.2.0 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.6 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.3.0 + jsonc-parser: 3.3.1 + magic-string: 1.4.2 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 3.0.0 + p-limit: 7.3.3 + p-queue: 9.3.3 + package-manager-detector: 1.9.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + rolldown: 1.2.12 + shiki: 4.5.0 + smol-toml: 1.9.0 + svgo: 4.1.0 + tinyclip: 1.0.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + ultrahtml: 1.7.0 + unifont: 0.7.5 + unstorage: 1.17.5 + verkit: 0.5.0 + vite: 8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.6.5 + optionalDependencies: + sharp: 0.35.5(@types/node@24.19.0) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + bcp-47-match@2.0.3: {} + + bcp-47@2.1.1: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + + boolbase@1.0.0: {} + + ccount@2.0.1: {} + + chai@6.3.0: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chardet@2.2.0: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + ci-info@4.4.0: {} + + cli-width@4.1.0: {} + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + commander@15.0.0: {} + + commander@9.5.0: {} + + common-ancestor-path@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@2.0.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-selector-parser@3.3.0: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + css-what@7.0.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} - '@vitest/spy@5.0.3': {} + devalue@5.9.4: {} - assertion-error@2.0.1: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 - ast-v8-to-istanbul@1.0.7: + diff@9.0.0: {} + + direction@2.0.1: {} + + dom-serializer@2.0.0: dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 - chai@6.3.0: {} + domelementtype@2.3.0: {} - chardet@2.2.0: {} + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 - cli-width@4.1.0: {} + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 - commander@15.0.0: {} + dset@3.1.4: {} - detect-libc@2.1.2: {} + entities@4.5.0: {} es-module-lexer@2.3.2: {} + es-module-lexer@3.0.3: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + eventemitter3@5.0.4: {} + expect-type@1.4.0: {} + expressive-code@0.44.2: + dependencies: + '@expressive-code/core': 0.44.2 + '@expressive-code/plugin-frames': 0.44.2 + '@expressive-code/plugin-shiki': 0.44.2 + '@expressive-code/plugin-text-markers': 0.44.2 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 + fast-uri@3.1.8: {} + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -1351,15 +3606,186 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + find-proc@0.2.0: {} + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + fsevents@2.3.3: optional: true + get-tsconfig@5.0.0-beta.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.2 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-select@6.0.4: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.3.0 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-cache-semantics@4.3.0: {} + + i18next@26.4.2(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 + iron-webcrypto@1.2.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-docker@4.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + js-tokens@10.0.0: {} + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + jsonc-parser@3.3.1: {} + + klona@2.0.6: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -1409,6 +3835,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + longest-streak@3.1.0: {} + + lru-cache@11.5.3: {} + magic-string@1.4.2: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -1419,22 +3849,374 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.2 + mdast-util-directive@3.1.1: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + micromark-util-character: 2.1.1 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-from-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.2.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + parse-entities: 4.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + mrmime@2.0.1: {} + + ms@2.1.3: {} + mute-stream@3.0.0: {} nanoid@3.3.19: {} + neotraverse@1.0.1: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.5: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + obug@2.2.1: {} + obug@3.0.0: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.12: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.3: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.2 + + p-timeout@7.0.2: {} + + package-manager-detector@1.9.0: {} + + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + piccolore@0.1.3: {} + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.7: {} + postcss-nested@6.2.0(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.28: dependencies: nanoid: 3.3.19 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: {} + + property-information@7.2.0: {} + + radix3@1.1.2: {} + + readdirp@5.1.1: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-expressive-code@0.44.2: + dependencies: + expressive-code: 0.44.2 + + remark-directive@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.1 + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + rolldown@1.2.12: dependencies: '@oxc-project/types': 0.152.0 @@ -1458,14 +4240,113 @@ snapshots: safer-buffer@2.1.2: {} + satteri@0.10.5: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.10.5 + '@bruits/satteri-darwin-x64': 0.10.5 + '@bruits/satteri-linux-arm64-gnu': 0.10.5 + '@bruits/satteri-linux-arm64-musl': 0.10.5 + '@bruits/satteri-linux-x64-gnu': 0.10.5 + '@bruits/satteri-linux-x64-musl': 0.10.5 + '@bruits/satteri-wasm32-wasi': 0.10.5 + '@bruits/satteri-win32-arm64-msvc': 0.10.5 + '@bruits/satteri-win32-x64-msvc': 0.10.5 + + sax@1.6.1: {} + + semver@7.8.5: + optional: true + + sharp@0.35.5(@types/node@24.19.0): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 24.19.0 + optional: true + + shiki@4.5.0: + dependencies: + '@shikijs/core': 4.5.0 + '@shikijs/engine-javascript': 4.5.0 + '@shikijs/engine-oniguruma': 4.5.0 + '@shikijs/langs': 4.5.0 + '@shikijs/themes': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + + sitemap@9.0.1: + dependencies: + '@types/node': 24.19.0 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.6.1 + + smol-toml@1.9.0: {} + source-map-js@1.2.2: {} + space-separated-tokens@2.0.2: {} + std-env@4.3.0: {} + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + + tiny-inflate@1.0.3: {} + tinybench@6.2.0: {} + tinyclip@1.0.3: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: @@ -1475,6 +4356,13 @@ snapshots: tinyrainbow@3.2.0: {} + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: + optional: true + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -1498,9 +4386,88 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + undici-types@7.24.6: {} - vite@8.3.1(@types/node@24.19.0): + undici@8.11.2: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.5: + dependencies: + css-tree: 3.2.1 + ohash: 2.0.12 + undici: 8.11.2 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.3 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + url-extras@0.1.0: {} + + util-deprecate@1.0.2: {} + + verkit@0.5.0: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1509,12 +4476,18 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.19.0 + esbuild: 0.28.2 fsevents: 2.3.3 + yaml: 2.9.1 - vitest@5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)): + vitefu@1.1.3(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1)): + optionalDependencies: + vite: 8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1) + + vitest@5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.3(vite@8.3.1(@types/node@24.19.0)) + '@vitest/mocker': 5.0.3(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -1525,7 +4498,7 @@ snapshots: tinybench: 6.2.0 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.1(@types/node@24.19.0) + vite: 8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 24.19.0 @@ -1534,3 +4507,15 @@ snapshots: - msw why-is-node-running@3.2.1: {} + + xxhash-wasm@1.1.0: {} + + yaml@2.9.1: {} + + yargs-parser@22.0.0: {} + + yocto-queue@1.2.2: {} + + zod@4.6.5: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3476775..260e5bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ +# The documentation site (docs/) is a workspace package: one lockfile for both. +packages: + - docs + # Supply-chain protections for installing dependencies. # Wait a day before installing a newly published version: most malicious @@ -9,3 +13,7 @@ trustPolicy: no-downgrade # No dependency may run install scripts unless listed here. strictDepBuilds: true onlyBuiltDependencies: [] +# Install scripts not needed: these packages ship their binaries as +# platform-specific optional dependencies (esbuild comes with Astro, for docs/). +allowBuilds: + esbuild: false 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/commands/history.ts b/src/commands/history.ts index ba52585..21fd6d2 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -105,6 +105,8 @@ function describeRun(line: HistoryLine): string { for (const flag of ['local', 'all', 'force', 'removed'] as const) { if (options[flag] === true) words.push(`--${flag}`) } + if (options.keepGoing === true) words.push('--keep-going') + if (options.hooks === false) words.push('--no-hooks') if (typeof options.argCount === 'number' && options.argCount > 0) { words.push(`(${plural(options.argCount, 'argument')})`) } @@ -159,7 +161,16 @@ function describeChange(change: Change): string { case 'saved-patch': return line('saved', `local changes to ${change.file}`) case 'script': - return line('script', `${change.name} exit ${change.exitCode}`) + return line( + 'script', + `${change.name} exit ${change.exitCode}` + + (change.module == null ? '' : ` (${change.hook ?? 'hook'} of ${change.module})`), + ) + case 'script-skipped': + return line( + 'skipped', + `${change.name} (${change.hook} of ${change.module}: ${change.reason})`, + ) case 'cloned': return line('cloned', `${change.repository} into ${change.folder}`) case 'reused': diff --git a/src/commands/init.ts b/src/commands/init.ts index aa75c8a..2c04949 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -8,6 +8,7 @@ import { ConfigStore } from '../config.ts' import type { Context } from '../context.ts' import { CliError } from '../errors.ts' import { checkRepositoryUrl } from '../git-url.ts' +import { withCommandLock } from '../lock.ts' import { redactUrl } from '../output.ts' import { configfilePaths, resolveUserPath } from '../paths.ts' import { ensureGit, gitClone } from '../process.ts' @@ -64,10 +65,10 @@ async function init(options: InitOptions, ctx: Context): Promise { options.folder != null ? resolveUserPath(options.folder.trim(), ctx) : (previous.folderPath ?? own.dotfiles) - const folder = await inspectFolder(folderPath) // A clone needs git: checked before asking for the URL, so a missing git is // found before the URL is typed and before the folder to clone into is created. - if (folder === 'missing' || folder === 'empty') await ensureGit() + if (needsClone(await inspectFolder(folderPath))) await ensureGit() + const configured = store.exists() const validate = (value: string) => { const checked = checkRepositoryUrl(value, ctx) @@ -95,6 +96,43 @@ async function init(options: InitOptions, ctx: Context): Promise { await mkdir(own.dir, { recursive: true, mode: 0o700 }) } + // After the questions: the folder and the configuration that deployments + // and syncs read change here. + await withCommandLock(ctx, () => + setUp({ folderPath, repoUrl, configured, force: options.force === true }, store, ctx), + ) + output.success(`configfile is ready. Configuration saved to ${store.path}.`) +} + +function needsClone(folder: Awaited>): boolean { + return folder === 'missing' || folder === 'empty' +} + +/** + * Clones the repository or reuses the folder, then saves the configuration. + * Runs with the lock held: what was seen before waiting for it is checked + * again, as another configfile may have cloned or configured meanwhile. + * `configured`: a configuration existed when the user answered. + */ +async function setUp( + { + folderPath, + repoUrl, + configured, + force, + }: { folderPath: string; repoUrl: string; configured: boolean; force: boolean }, + store: ConfigStore, + ctx: Context, +): Promise { + const { output } = ctx + if (!configured && !force && store.exists()) { + throw new CliError( + `Another configfile saved a configuration to ${store.path} meanwhile. ` + + 'Run "configfile init" again to replace it.', + ) + } + const folder = await inspectFolder(folderPath) + if (needsClone(folder)) await ensureGit() switch (folder) { case 'not-a-directory': throw new CliError(`${folderPath} exists and is not a folder.`) @@ -129,7 +167,6 @@ async function init(options: InitOptions, ctx: Context): Promise { ) } ctx.history.record({ kind: 'configured', file: store.path }) - output.success(`configfile is ready. Configuration saved to ${store.path}.`) } async function inspectFolder(folderPath: string) { diff --git a/src/commands/modules.ts b/src/commands/modules.ts index fb200be..ceae777 100644 --- a/src/commands/modules.ts +++ b/src/commands/modules.ts @@ -1,3 +1,4 @@ +import { constants as osConstants } from 'node:os' import { inspect } from 'node:util' import type { Command } from 'commander' @@ -20,12 +21,23 @@ import { type UndeployResult, undeployFile, } from '../deploy.ts' -import { CliError } from '../errors.ts' +import { CliError, isPromptExit } from '../errors.ts' import { messageOf } from '../fsutil.ts' -import { withLock } from '../lock.ts' +import { withCommandLock } from '../lock.ts' import { plural } from '../output.ts' +import { runScript } from '../process.ts' import { findRemovedFiles, type HeldBack, type RecordedFile } from '../removed.ts' -import { loadRepository, type Module, type ModuleFile } from '../repository.ts' +import { + HOOKS, + type Hook, + loadRepository, + loadScripts, + type Module, + type ModuleFile, + type Script, + scriptEnvironment, + systemName, +} from '../repository.ts' import { DeploymentRecord } from '../state.ts' type Strategy = ModuleFile['strategy'] @@ -38,6 +50,8 @@ interface RawOptions { force?: boolean dryRun?: boolean removed?: boolean + hooks?: boolean + keepGoing?: boolean } /** Options once normalized at the command boundary. */ @@ -48,6 +62,10 @@ interface Options { dryRun: boolean /** Undeploy: the files the repository no longer deploys. */ removed: boolean + /** Run the scripts the modules name (`pre_deploy`, `post_undeploy`…). */ + hooks: boolean + /** Go on with the modules that come after a script that failed. */ + keepGoing: boolean } function normalize(options: RawOptions): Options { @@ -57,6 +75,8 @@ function normalize(options: RawOptions): Options { force: options.force === true, dryRun: options.dryRun === true, removed: options.removed === true, + hooks: options.hooks !== false, + keepGoing: options.keepGoing === true, } } @@ -89,6 +109,11 @@ export function registerModulesCommand(program: Command, ctx: Context): void { .option('-a, --all', 'deploy every module without asking') .option('-f, --force', 'replace existing local files without asking (they are moved to .old)') .option('-n, --dry-run', 'show what would be done, without changing anything') + .option('--no-hooks', 'do not run the pre_deploy and post_deploy scripts of the modules') + .option( + '--keep-going', + 'deploy the remaining modules when a pre_deploy or post_deploy script fails', + ) .action((names: string[], options: RawOptions) => deploy(names, normalize(options), ctx)) modules @@ -103,6 +128,11 @@ export function registerModulesCommand(program: Command, ctx: Context): void { ) .option('--removed', 'only undeploy the files the repository no longer deploys') .option('-n, --dry-run', 'show what would be done, without changing anything') + .option('--no-hooks', 'do not run the pre_undeploy and post_undeploy scripts of the modules') + .option( + '--keep-going', + 'undeploy the remaining modules when a pre_undeploy or post_undeploy script fails', + ) .action((names: string[], options: RawOptions) => undeploy(names, normalize(options), ctx)) } @@ -145,11 +175,18 @@ async function list(ctx: Context): Promise { ctx.output.print(`${plural(modules.length, 'module')} found.`) for (const module of modules) { - ctx.output.print(`- ${module.name}${module.error == null ? '' : ` (ignored: ${module.error})`}`) + ctx.output.print(`- ${module.name}${describeListed(module)}`) } for (const module of modules) reportSettings(module, ctx) } +/** What `modules list` adds to a module's name: why it is ignored, or the scripts it names. */ +function describeListed(module: Module): string { + if (module.error != null) return ` (ignored: ${module.error})` + const hooks = HOOKS.filter(hook => module.hooks[hook] != null) + return hooks.length === 0 ? '' : ` (${hooks.join(', ')})` +} + async function status(names: string[], options: Options, ctx: Context): Promise { const { output } = ctx const repo = await loadRepository(ctx) @@ -167,6 +204,20 @@ async function status(names: string[], options: Options, ctx: Context): Promise< return } + // Scripts named by the modules are checked too: a wrong name would stop a deployment. + let scripts: Scripts | null = null + if ( + selected.some(module => module.error == null && HOOKS.some(hook => module.hooks[hook] != null)) + ) { + try { + scripts = await loadScripts(ctx) + } catch (error) { + // Status still shows the files when the scripts cannot be listed. + if (!(error instanceof CliError)) throw error + output.warn(`The scripts the modules name cannot be checked: ${messageOf(error)}`) + } + } + const describeTarget = async (file: Pick) => { try { return describeState(await inspectFile(file, record)) @@ -194,6 +245,17 @@ async function status(names: string[], options: Options, ctx: Context): Promise< if (files.length === 0 && module.undecided.length === 0) { output.print(` no ${options.strategy} file`) } + for (const hook of HOOKS) { + if (module.hooks[hook] == null) continue + const found = scripts == null ? null : findHook(module, hook, scripts) + const note = + found === 'missing' + ? ' (not found in scripts/)' + : found === 'other-system' + ? ' (no version for this system)' + : '' + output.print(` ${hook}: script "${module.hooks[hook]}"${note}`) + } } if (removed.length > 0) { @@ -212,52 +274,159 @@ async function status(names: string[], options: Options, ctx: Context): Promise< } } +type Verb = 'deploy' | 'undeploy' +type Unit = { module: UsableModule; files: ModuleFile[] } +type Scripts = Awaited> + +/** The files of each module a command works on; modules without any are left out. */ +function unitsOf(modules: readonly UsableModule[], strategy: Strategy): Unit[] { + return modules + .map(module => ({ module, files: module.files.filter(file => file.strategy === strategy) })) + .filter(unit => unit.files.length > 0) +} + +/** The settings that name the scripts a command runs, in the order they run. */ +function hooksOf(verb: Verb): readonly [Hook, Hook] { + return [`pre_${verb}`, `post_${verb}`] +} + +/** + * The scripts of the repository, when `--no-hooks` is not given and the + * modules to work on name some to run; `null` otherwise. + * + * A name that matches no script is a mistake in the repository, found here + * before anything is changed: the command fails, unless `--keep-going` is + * given (the script then fails when its turn comes). A script that only has + * versions for other systems is not a mistake: it is skipped in its turn. + * + * `named`: the modules were named on the command line. A named module whose + * script will not run, because it has no file of the kind the command works + * on, is said so; with every module selected, that is the usual case. + */ +async function loadHookScripts( + verb: Verb, + { modules, named: explicit }: { modules: readonly UsableModule[]; named: boolean }, + units: readonly Unit[], + options: Options, + ctx: Context, +): Promise { + if (!options.hooks) return null + + for (const module of explicit ? modules : []) { + if (units.some(unit => unit.module === module)) continue + for (const hook of hooksOf(verb)) { + if (module.hooks[hook] == null) continue + ctx.output.warn( + `${module.name}: ${describeHook(module, hook)} ${options.dryRun ? 'would not run' : 'was not run'}, ` + + `the module has no ${options.strategy} file.`, + ) + } + } + + const named = units.flatMap(({ module }) => + hooksOf(verb).flatMap(hook => (module.hooks[hook] == null ? [] : [{ module, hook }])), + ) + if (named.length === 0) return null + + let scripts: Scripts + try { + scripts = await loadScripts(ctx) + } catch (error) { + if (!(error instanceof CliError)) throw error + const message = messageOf(error) + throw new CliError( + `${message}${/[.!?]$/.test(message) ? '' : '.'} ` + + `Use --no-hooks to ${verb} without running the scripts the modules name.`, + { cause: error }, + ) + } + if (options.keepGoing) return scripts + + const missing = named.filter(({ module, hook }) => findHook(module, hook, scripts) === 'missing') + if (missing.length === 0) return scripts + for (const { module, hook } of missing) reportHookError(module, hookNotFound(module, hook), ctx) + throw new CliError( + `Nothing ${options.dryRun ? 'would be' : 'was'} ${verb}ed: ` + + `${plural(missing.length, 'script')} the modules name ${missing.length === 1 ? 'does' : 'do'} not exist.`, + ) +} + async function deploy(names: string[], options: Options, ctx: Context): Promise { const { output, prompts } = ctx const run = await prepare('deploy', await loadRepository(ctx), names, options, ctx) - if (run == null || run.files.length === 0) return - - const { files } = run + if (run == null) return + + const units = unitsOf(run.modules, options.strategy) + const scripts = await loadHookScripts( + 'deploy', + { modules: run.modules, named: !run.all }, + units, + options, + ctx, + ) + if (units.length === 0) return let failures = run.failures - const reportError = (file: ModuleFile, error: unknown) => { - failures++ - reportFileError(file, error, ctx) - } if (options.dryRun) { const context = await deployContext(ctx) + const failed = new FailedHooks('deploy') let wouldSkip = 0 - for (const file of files) { - try { - const decision = await planDeploy(file, { ...context, force: options.force }) - if (decision.action === 'conflict' && !prompts.interactive) wouldSkip++ - output.print( - `- ${file.target} (${describePlannedDeploy(file, decision, prompts.interactive)})`, - ) - } catch (error) { - reportError(file, error) + for (const { module, files } of units) { + // Without its pre_deploy script, the files of a module would not be deployed. + if (!planHook(module, 'pre_deploy', scripts, ctx)) { + failed.add('pre_deploy', module) + continue + } + + let complete = module.invalidEntries.length === 0 + for (const file of files) { + try { + const decision = await planDeploy(file, { ...context, force: options.force }) + if (decision.action === 'conflict' && !prompts.interactive) { + wouldSkip++ + complete = false + } + output.print( + `- ${file.target} (${describePlannedDeploy(file, decision, prompts.interactive)})`, + ) + } catch (error) { + failures++ + complete = false + reportFileError(file, error, ctx) + } + } + if (!planHook(module, 'post_deploy', scripts, ctx, { complete })) { + failed.add('post_deploy', module) } } - finish(failures, skippedProblems(wouldSkip, prompts.interactive), 'Dry run finished.', ctx) + const problems = [...skippedProblems(wouldSkip, prompts.interactive), ...failed.problems(true)] + finish(failures, problems, 'Dry run finished.', ctx) return } - await withLock(ctx.home, async () => { - const context = await deployContext(ctx) - await deployAll(files, options, context, ctx, failures) + let skipped = 0 + const outcome = await eachModule('deploy', units, scripts, options, ctx, async unit => { + const result = await withCommandLock(ctx, async () => + deployFiles(unit.files, options, await deployContext(ctx), ctx), + ) + failures += result.failures + skipped += result.skipped + return result.failures === 0 && result.skipped === 0 && unit.module.invalidEntries.length === 0 }) + + const problems = [...skippedProblems(skipped, prompts.interactive), ...outcome.problems] + finish(failures, problems, 'Deployment finished.', ctx, outcome.exitCode) } -/** Deploys `files` while the lock is held, then reports the outcome. */ -async function deployAll( +/** Deploys the files of a module while the lock is held. */ +async function deployFiles( files: ModuleFile[], options: Options, context: DeployContext, ctx: Context, - initialFailures: number, -): Promise { +): Promise<{ failures: number; skipped: number }> { const { output, prompts } = ctx - let failures = initialFailures + let failures = 0 const reportError = (file: ModuleFile, error: unknown) => { failures++ reportFileError(file, error, ctx) @@ -278,7 +447,7 @@ async function deployAll( } } - // Asked once everything else is deployed, so an answer never blocks other files. + // Asked once the other files of the module are deployed, so an answer never blocks them. let skipped = 0 for (const file of conflicts) { const replace = @@ -307,7 +476,309 @@ async function deployAll( } } - finish(failures, skippedProblems(skipped, prompts.interactive), 'Deployment finished.', ctx) + return { failures, skipped } +} + +/** + * Deploys or undeploys modules one at a time: the `pre_` script of a module, + * its files (`work`, which takes the lock), its `post_` script, then the next + * module. Scripts run without the lock, as they may run configfile themselves. + * + * - A `pre_` script that fails leaves the files of its module untouched. + * - `work` returns whether every file of the module was handled: the `post_` + * script is not run otherwise. When it fails as a whole (the lock or the + * record cannot be used), the run ends there. + * - A script that fails ends the run, unless `--keep-going` is given. A signal + * received while a script runs always ends it, with the signal's exit code. + * + * Returns the problems to report, whether the run ended early, and the exit + * code to use when a signal ended it. + */ +async function eachModule( + verb: Verb, + units: readonly Unit[], + scripts: Scripts | null, + options: Options, + ctx: Context, + work: (unit: Unit) => Promise, +): Promise<{ problems: string[]; stopped: boolean; exitCode?: number }> { + const [pre, post] = hooksOf(verb) + const failed = new FailedHooks(verb) + /** + * Something was attempted already: a script was due to run (even one that + * is missing or could not be started), or the files of a module were handled. + */ + let started = false + /** The modules whose files were left untouched, and why. */ + const leftOut: { name: string; reason: string }[] = [] + let recorded = 0 + let hint = '' + let exitCode: number | undefined + const leaveOut = (from: number, to: number, reason: string) => { + for (const { module } of units.slice(from, to)) leftOut.push({ name: module.name, reason }) + } + /** Writes them to the history: called on every way out, each module once. */ + const recordLeftOut = () => { + for (const { name, reason } of leftOut.slice(recorded)) { + ctx.history.record({ kind: 'failed', module: name, reason: `not ${verb}ed: ${reason}` }) + } + recorded = leftOut.length + } + + /** Runs a script of the module at `index`. `stop`: the run ends there. */ + const runStep = async (hook: Hook, index: number): Promise<'ok' | 'failed' | 'stop'> => { + const { module } = units[index] as Unit + if (scripts == null || module.hooks[hook] == null) return 'ok' + + started = true + const result = await runHook(module, hook, scripts, options.strategy, ctx) + if (!result.ok) failed.add(hook, module) + // Before its files, the module itself is left out too. + const next = hook === pre ? index : index + 1 + + if (result.signal != null) { + // configfile was asked to stop while the script ran: --keep-going does not apply. + const reason = `interrupted (${result.signal}) during the ${hook} script of ${module.name}` + leaveOut(next, units.length, reason) + exitCode = 128 + (osConstants.signals[result.signal] ?? 0) + return 'stop' + } + if (result.ok) return 'ok' + + const reason = `the ${hook} script of ${module.name} failed` + if (options.keepGoing) { + leaveOut(next, index + 1, reason) + return 'failed' + } + leaveOut(next, units.length, reason) + hint = ' Use --keep-going to go on after a script that fails.' + return 'stop' + } + + let stopped = false + for (const [index, unit] of units.entries()) { + const before = await runStep(pre, index) + if (before === 'failed') continue + stopped = before === 'stop' + if (stopped) break + + let complete: boolean + try { + complete = await work(unit) + } catch (error) { + // A question cancelled with Ctrl+C ends the command as usual: only the + // history says what was left out (the files of this module may be done). + if (isPromptExit(error)) leaveOut(index + 1, units.length, 'cancelled') + // The lock is taken again for each module: when it (or the record) can + // no longer be, say what was left out. Before anything was done, the + // error says it all. + if (!(error instanceof CliError) || !started) { + recordLeftOut() + throw error + } + ctx.output.error(messageOf(error)) + leaveOut(index, units.length, messageOf(error)) + stopped = true + break + } + started = true + + if (!complete && scripts != null && unit.module.hooks[post] != null) { + const reason = hookNeedsFiles(verb) + ctx.output.warn( + `${unit.module.name}: ${describeHook(unit.module, post)} was not run, ${reason}.`, + ) + recordSkippedHook(unit.module, post, reason, ctx) + continue + } + stopped = complete && (await runStep(post, index)) === 'stop' + if (stopped) break + } + + const problems = failed.problems(false) + if (exitCode != null) problems.push('Interrupted.') + recordLeftOut() + if (leftOut.length > 0) { + problems.push( + `${plural(leftOut.length, 'module')} ${leftOut.length === 1 ? 'was' : 'were'} ` + + `not ${verb}ed: ${leftOut.map(({ name }) => name).join(', ')}.${hint}`, + ) + } + return { problems, stopped, ...(exitCode != null && { exitCode }) } +} + +/** The modules whose scripts failed, for the summary of a run or of a dry run. */ +class FailedHooks { + readonly #modules = new Map() + + constructor(verb: Verb) { + for (const hook of hooksOf(verb)) this.#modules.set(hook, []) + } + + add(hook: Hook, module: UsableModule): void { + this.#modules.get(hook)?.push(module.name) + } + + problems(dryRun: boolean): string[] { + return [...this.#modules] + .filter(([, modules]) => modules.length > 0) + .map( + ([hook, modules]) => + `The ${hook} script of ${modules.join(', ')} ${dryRun ? 'would fail' : 'failed'}.`, + ) + } +} + +/** A `post_` script only runs once every file of its module is handled. */ +function hookNeedsFiles(verb: Verb): string { + return verb === 'deploy' + ? 'some files of the module are not deployed' + : 'some files of the module could not be undeployed' +} + +function describeHook(module: UsableModule, hook: Hook): string { + return `${hook} script "${module.hooks[hook]}"` +} + +/** + * The script a setting of the module names. `other-system`: it only has + * versions for other systems, so there is nothing to run on this one. + */ +function findHook( + module: UsableModule, + hook: Hook, + { scripts, otherSystems }: Scripts, +): Script | 'other-system' | 'missing' { + const name = module.hooks[hook] + const script = scripts.find(candidate => candidate.name === name) + if (script != null) return script + return name != null && otherSystems.includes(name) ? 'other-system' : 'missing' +} + +/** A script that is not run on purpose: said in the history, without failing the run. */ +function recordSkippedHook(module: UsableModule, hook: Hook, reason: string, ctx: Context): void { + ctx.history.record({ + kind: 'script-skipped', + name: module.hooks[hook] ?? '', + hook, + module: module.name, + reason, + }) +} + +function noVersionFor(ctx: Context): string { + return `no version for ${systemName(ctx.platform)}` +} + +function hookNotFound(module: UsableModule, hook: Hook): string { + return ( + `${describeHook(module, hook)} not found. ` + + 'Run "configfile scripts list" to see available scripts.' + ) +} + +function reportHookError(module: UsableModule, reason: string, ctx: Context): void { + ctx.output.error(`${module.name}: ${reason}`) + ctx.history.record({ kind: 'failed', module: module.name, reason }) +} + +/** + * Dry runs: says whether a script of the module would run. Returns `false` + * when the script does not exist, which only happens with `--keep-going`: + * without it, `loadHookScripts` has already failed the command. + * `complete: false`: some files of the module would not be handled, so its + * `post_` script would not run. + */ +function planHook( + module: UsableModule, + hook: Hook, + scripts: Scripts | null, + ctx: Context, + { complete = true }: { complete?: boolean } = {}, +): boolean { + if (scripts == null || module.hooks[hook] == null) return true + + const describe = (plan: string) => + `- script "${module.hooks[hook]}" (${hook} of ${module.name}: ${plan})` + if (!complete) { + const verb = hook.endsWith('_undeploy') ? 'undeploy' : 'deploy' + ctx.output.print(describe(`would not run, ${hookNeedsFiles(verb)}`)) + return true + } + const script = findHook(module, hook, scripts) + if (script === 'missing') { + reportHookError(module, hookNotFound(module, hook), ctx) + return false + } + ctx.output.print( + describe(script === 'other-system' ? `would be skipped, ${noVersionFor(ctx)}` : 'would run'), + ) + return true +} + +/** + * Runs a script a module names (`pre_deploy`, `post_undeploy`…) the way + * `scripts run` runs it, with the hook, the module's name and the kind of + * files added to its environment. Unlike `scripts run`, configfile's messages + * stay on stdout and the exit code of the script is not the command's. + * + * Returns whether it succeeded, and the signal configfile received while it + * ran, if any (Ctrl+C, SIGTERM…): the script is left to end, then the run + * must stop. A script that only has versions for other systems is skipped, + * which is a success. + */ +async function runHook( + module: UsableModule, + hook: Hook, + scripts: Scripts, + strategy: Strategy, + ctx: Context, +): Promise<{ ok: boolean; signal?: NodeJS.Signals }> { + const script = findHook(module, hook, scripts) + if (script === 'missing') { + reportHookError(module, hookNotFound(module, hook), ctx) + return { ok: false } + } + if (script === 'other-system') { + const reason = noVersionFor(ctx) + ctx.output.info(`${module.name}: ${describeHook(module, hook)} has ${reason}, skipped.`) + recordSkippedHook(module, hook, reason, ctx) + return { ok: true } + } + + ctx.output.info(`Running "${script.name}", the ${hook} script of ${module.name}…`) + let code: number + let signal: NodeJS.Signals | undefined + try { + code = await runScript(script, [], { + cwd: ctx.cwd, + onSignal: received => { + signal ??= received + }, + env: { + ...scriptEnvironment(script, scripts.repository, ctx.platform), + CONFIGFILE_HOOK: hook, + CONFIGFILE_MODULE: module.name, + CONFIGFILE_DEPLOY: strategy, + }, + }) + } catch (error) { + reportHookError(module, messageOf(error), ctx) + return { ok: false } + } + + ctx.history.record({ + kind: 'script', + name: script.name, + file: script.path, + exitCode: code, + hook, + module: module.name, + }) + if (code !== 0) { + ctx.output.error(`${module.name}: ${describeHook(module, hook)} exited with code ${code}.`) + } + return { ok: code === 0, ...(signal != null && { signal }) } } async function undeploy(names: string[], options: Options, ctx: Context): Promise { @@ -321,36 +792,48 @@ async function undeploy(names: string[], options: Options, ctx: Context): Promis const repo = await loadRepository(ctx) const run = options.removed - ? { files: [], failures: 0, all: false } + ? { modules: [], failures: 0, all: false } : await prepare('undeploy', repo, names, options, ctx) // Undeploying every module also undeploys what the repository no longer deploys. const withRemoved = options.removed || options.all || run?.all === true if (run == null && !withRemoved) return - const files = run?.files ?? [] + const units = unitsOf(run?.modules ?? [], options.strategy) + const scripts = await loadHookScripts( + 'undeploy', + { modules: run?.modules ?? [], named: run != null && !run.all }, + units, + options, + ctx, + ) let failures = run?.failures ?? 0 - const undeployOne = async (file: UndeployableFile, context: DeployContext) => { + /** Returns whether the file was handled (removed, kept on purpose, or not deployed). */ + const undeployOne = async (file: UndeployableFile, context: DeployContext): Promise => { try { if (options.dryRun) { output.print( `- ${file.target} (${describePlannedUndeploy(await planUndeploy(file, context))})`, ) - return + return true } const result = await undeployFile(file, context) output.print(`- ${file.target} ${describeUndeploy(result)}`) recordUndeploy(file, result, ctx) + return true } catch (error) { failures++ reportFileError(file, error, ctx) + return false } } + const undeployFiles = async (files: readonly ModuleFile[], context: DeployContext) => { + let complete = true + for (const file of files) complete = (await undeployOne(file, context)) && complete + return complete + } - /** Returns the number of files worked on. */ - const undeployEach = async (context: DeployContext): Promise => { - for (const file of files) await undeployOne(file, context) - if (!withRemoved) return files.length - + /** The files the repository no longer deploys: no module is left to name a script for them. */ + const undeployRemoved = async (context: DeployContext): Promise => { const { removed, heldBack } = await removedFiles(context.record, repo, options.strategy, ctx) for (const { target, reason } of heldBack) { output.warn(`${target} was not checked: ${reason}.`) @@ -359,7 +842,7 @@ async function undeploy(names: string[], options: Options, ctx: Context): Promis if (options.removed && heldBack.length === 0) { output.info(`Every deployed ${options.strategy} file is still deployed by the repository.`) } - return files.length + return 0 } output.info( @@ -391,17 +874,53 @@ async function undeploy(names: string[], options: Options, ctx: Context): Promis reportFileError(file, error, ctx) } } - return files.length + removed.length + return removed.length } - const count = options.dryRun - ? await undeployEach(await deployContext(ctx)) - : await withLock(ctx.home, async () => undeployEach(await deployContext(ctx))) - if (count === 0) { - if (failures > 0) finish(failures, [], '', ctx) + let count = units.reduce((sum, unit) => sum + unit.files.length, 0) + if (options.dryRun) { + const context = await deployContext(ctx) + const failed = new FailedHooks('undeploy') + for (const { module, files } of units) { + // Without its pre_undeploy script, the files of a module would stay deployed. + if (!planHook(module, 'pre_undeploy', scripts, ctx)) { + failed.add('pre_undeploy', module) + continue + } + const complete = await undeployFiles(files, context) + if (!planHook(module, 'post_undeploy', scripts, ctx, { complete })) { + failed.add('post_undeploy', module) + } + } + if (withRemoved) count += await undeployRemoved(context) + const problems = failed.problems(true) + if (count > 0 || failures > 0 || problems.length > 0) { + finish(failures, problems, count > 0 ? 'Dry run finished.' : '', ctx) + } return } - finish(failures, [], options.dryRun ? 'Dry run finished.' : 'Undeployment finished.', ctx) + + const outcome = await eachModule('undeploy', units, scripts, options, ctx, unit => + withCommandLock(ctx, async () => undeployFiles(unit.files, await deployContext(ctx))), + ) + const problems = outcome.problems + if (withRemoved) { + if (outcome.stopped) { + problems.push('The files the repository no longer deploys were not checked.') + } else { + try { + count += await withCommandLock(ctx, async () => undeployRemoved(await deployContext(ctx))) + } catch (error) { + // After the modules: what they did, and what failed, is still reported. + if (!(error instanceof CliError) || (units.length === 0 && failures === 0)) throw error + problems.push( + `The files the repository no longer deploys were not checked: ${messageOf(error)}`, + ) + } + } + } + if (count === 0 && failures === 0 && problems.length === 0) return + finish(failures, problems, count > 0 ? 'Undeployment finished.' : '', ctx, outcome.exitCode) } /** Loads the record: call it after taking the lock when files are changed. */ @@ -414,24 +933,29 @@ async function deployContext(ctx: Context): Promise { /** * Selects the modules and files a deploy or undeploy works on, and reports - * modules and settings entries that are skipped (they count as failures). - * Returns `null` when there is nothing to do. + * modules and settings entries that are skipped (they count as failures, as + * do the settings that cannot name a script this command would run). + * + * Returns `null` when no module is selected. When the selected modules have + * no file of the kind the command works on, says so and still returns them: + * their scripts are reported as not run. */ async function prepare( - verb: 'deploy' | 'undeploy', + verb: Verb, { modules }: Repository, names: string[], options: Options, ctx: Context, ): Promise<{ - files: ModuleFile[] + /** The selected modules that can be used, in repository order. */ + modules: UsableModule[] failures: number /** Every module was selected (with --all, or at the question), even when there is none. */ all: boolean } | null> { const { output } = ctx const { selected, all } = await selectModules(verb, modules, names, options, ctx) - if (selected.length === 0) return all ? { files: [], failures: 0, all } : null + if (selected.length === 0) return all ? { modules: [], failures: 0, all } : null let failures = 0 const usable: UsableModule[] = [] @@ -448,7 +972,14 @@ async function prepare( for (const problem of module.invalidEntries) { ctx.history.record({ kind: 'failed', module: module.name, reason: problem }) } - reportSettings(module, ctx) + // Only the scripts this command would run. + for (const hook of hooksOf(verb)) { + const problem = module.hookProblems[hook] + if (problem == null) continue + failures++ + ctx.history.record({ kind: 'failed', module: module.name, reason: problem }) + } + reportSettings(module, ctx, verb) for (const source of module.undecided) { output.warn( `${module.name}: "${source}" was not ${verb}ed because no deployment strategy is defined. ` + @@ -465,10 +996,10 @@ async function prepare( if (files.length === 0) { if (failures > 0) throw new CliError( - `Nothing could be ${verb}ed. ${plural(failures, 'module or settings entry')} failed.`, + `Nothing could be ${verb}ed. ${plural(failures, 'module or settings entry', 'modules or settings entries')} failed.`, ) output.info(`No ${options.strategy} file to ${verb} in ${moduleNames}.`) - return { files, failures, all } + return { modules: usable, failures, all } } const action = verb === 'deploy' ? 'Deploying' : 'Undeploying' @@ -477,7 +1008,7 @@ async function prepare( ? `Dry run, nothing is changed. ${action} ${moduleNames} would do:` : `${action} ${moduleNames}…`, ) - return { files, failures, all } + return { modules: usable, failures, all } } async function selectModules( @@ -539,12 +1070,23 @@ function pickModules(modules: Module[], names: string[]): Module[] { return modules.filter(module => names.includes(module.name)) } -/** Invalid entries are errors (the file is not deployed), deprecations are warnings. */ -function reportSettings(module: Module, ctx: Context): void { +/** + * Invalid entries (the file is not deployed) and settings that cannot name a + * script are errors, deprecations are warnings. With `verb`, the command + * only fails on the scripts it would run: the other settings are warnings. + */ +function reportSettings(module: Module, ctx: Context, verb?: Verb): void { if (module.error != null) return for (const problem of module.invalidEntries) { ctx.output.error(`${module.name}: ${problem}.`) } + for (const hook of HOOKS) { + const problem = module.hookProblems[hook] + if (problem == null) continue + if (verb == null || hooksOf(verb).includes(hook)) + ctx.output.error(`${module.name}: ${problem}.`) + else ctx.output.warn(`${module.name}: ${problem}.`) + } for (const deprecation of module.deprecations) { ctx.output.warn(`${module.name}: ${deprecation}.`) } @@ -556,12 +1098,20 @@ function skippedProblems(skipped: number, interactive: boolean): string[] { : [] } -function finish(failures: number, problems: string[], success: string, ctx: Context): void { +function finish( + failures: number, + problems: string[], + success: string, + ctx: Context, + exitCode?: number, +): void { if (failures > 0) { - problems.push(`${plural(failures, 'file, module or settings entry')} failed.`) + problems.push( + `${plural(failures, 'file, module or settings entry', 'files, modules or settings entries')} failed.`, + ) } if (problems.length > 0) { - throw new CliError(problems.join(' ')) + throw new CliError(problems.join(' '), exitCode == null ? {} : { exitCode }) } ctx.output.success(success) } diff --git a/src/commands/scripts.ts b/src/commands/scripts.ts index 00f7f19..f1cd0f2 100644 --- a/src/commands/scripts.ts +++ b/src/commands/scripts.ts @@ -1,11 +1,10 @@ import type { Command } from 'commander' -import { ConfigStore } from '../config.ts' import type { Context } from '../context.ts' import { CliError } from '../errors.ts' import { plural } from '../output.ts' import { runScript } from '../process.ts' -import { listScripts, type Script } from '../repository.ts' +import { loadScripts, scriptEnvironment } from '../repository.ts' export function registerScriptsCommand(program: Command, ctx: Context): void { const scripts = program @@ -28,18 +27,8 @@ export function registerScriptsCommand(program: Command, ctx: Context): void { .action((name: string, args: string[]) => run(name, args, ctx)) } -async function loadScripts(ctx: Context): Promise { - const config = await new ConfigStore(ctx.home, { - warn: message => ctx.output.warn(message), - }).read() - - return listScripts(config.folderPath, config.scriptExtensions, ctx.platform, { - warn: message => ctx.output.warn(message), - }) -} - async function list(ctx: Context): Promise { - const scripts = await loadScripts(ctx) + const { scripts } = await loadScripts(ctx) if (scripts.length === 0) { ctx.output.info('No script found.') @@ -53,7 +42,8 @@ async function list(ctx: Context): Promise { } async function run(name: string, args: string[], ctx: Context): Promise { - const script = (await loadScripts(ctx)).find(candidate => candidate.name === name) + const { repository, scripts } = await loadScripts(ctx) + const script = scripts.find(candidate => candidate.name === name) if (script == null) { throw new CliError( @@ -65,7 +55,8 @@ async function run(name: string, args: string[], ctx: Context): Promise { const output = ctx.output.toStderr() output.info(`Running "${name}"…`) - const code = await runScript(script, args, ctx) + const env = scriptEnvironment(script, repository, ctx.platform) + const code = await runScript(script, args, { cwd: ctx.cwd, env }) // The arguments are never recorded: they may contain secrets. ctx.history.record({ kind: 'script', name: script.name, file: script.path, exitCode: code }) diff --git a/src/commands/update.ts b/src/commands/update.ts index 85dd83c..758c1a8 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -7,6 +7,7 @@ import { ConfigStore } from '../config.ts' import type { Context } from '../context.ts' import { CliError } from '../errors.ts' import { errnoCode, messageOf } from '../fsutil.ts' +import { withCommandLock } from '../lock.ts' import { plural } from '../output.ts' import { configfilePaths } from '../paths.ts' import { ensureGit } from '../process.ts' @@ -39,9 +40,12 @@ async function update(ctx: Context): Promise { } await ensureGit() - output.info(`Syncing ${folderPath}…`) - const result = await syncMirror(folderPath, configfilePaths(ctx.home).saved, { - onSaved: file => ctx.history.record({ kind: 'saved-patch', file }), + // Deployments read the mirror, which the sync replaces: they never overlap. + const result = await withCommandLock(ctx, () => { + output.info(`Syncing ${folderPath}…`) + return syncMirror(folderPath, configfilePaths(ctx.home).saved, { + onSaved: file => ctx.history.record({ kind: 'saved-patch', file }), + }) }) ctx.history.record({ kind: 'synced', diff --git a/src/history.ts b/src/history.ts index 5e61d20..98d4e97 100644 --- a/src/history.ts +++ b/src/history.ts @@ -10,6 +10,7 @@ import { CliError, isPromptExit } from './errors.ts' import { errnoCode, lstatOrNull, messageOf } from './fsutil.ts' import { redactUrl } from './output.ts' import { configfilePaths } from './paths.ts' +import { HOOKS, type Hook } from './repository.ts' /** At most this many changes are written per run; the rest are counted. */ const MAX_CHANGES = 1000 @@ -53,7 +54,10 @@ export type Change = | { kind: 'failed'; module: string; reason: string } | { kind: 'synced'; folder: string; upstream: string; from: string | null; to: string } | { kind: 'saved-patch'; file: string } - | { kind: 'script'; name: string; file: string; exitCode: number } + /** `hook` and `module`: the script ran as that setting (`pre_deploy`, `post_undeploy`…) of that module. */ + | { kind: 'script'; name: string; file: string; exitCode: number; hook?: Hook; module?: string } + /** A script a module names was not run, without this being a failure. */ + | { kind: 'script-skipped'; name: string; hook: Hook; module: string; reason: string } | { kind: 'cloned' | 'reused'; repository: string; folder: string } | { kind: 'configured'; file: string } @@ -135,7 +139,9 @@ export function describeInvocation(action: Command): Invocation { case 'modules undeploy': case 'modules status': options.modules = Array.isArray(args[0]) ? args[0] : [] - copy('local', 'all', 'force', 'dryRun', 'removed') + copy('local', 'all', 'force', 'dryRun', 'removed', 'keepGoing') + // On unless turned off with --no-hooks. + if (given.hooks === false) options.hooks = false break case 'scripts run': options.script = args[0] @@ -514,7 +520,16 @@ function isChange(value: unknown): value is Change { case 'configured': return typeof value.file === 'string' case 'script': - return areStrings(value.name, value.file) && Number.isInteger(value.exitCode) + return ( + areStrings(value.name, value.file) && + Number.isInteger(value.exitCode) && + (value.module === undefined || typeof value.module === 'string') && + (value.hook === undefined || HOOKS.includes(value.hook as Hook)) + ) + case 'script-skipped': + return ( + areStrings(value.name, value.module, value.reason) && HOOKS.includes(value.hook as Hook) + ) case 'cloned': case 'reused': return areStrings(value.repository, value.folder) diff --git a/src/lock.ts b/src/lock.ts index 290500b..dd4afd1 100644 --- a/src/lock.ts +++ b/src/lock.ts @@ -1,67 +1,284 @@ -import { mkdir, open, readFile, rm } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { link, mkdir, open, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' +import type { Context } from './context.ts' import { CliError } from './errors.ts' -import { errnoCode } from './fsutil.ts' +import { errnoCode, messageOf } from './fsutil.ts' import { configfilePaths } from './paths.ts' const RETRY_DELAY_MS = 100 const MAX_WAIT_MS = 10_000 +/** The locks this process holds: a lock naming this process that is not here was left behind. */ +const held = new Set() +/** Calls of this process waiting for a lock, in order: one at a time, whatever the file says. */ +const queues = new Map>() + /** * Runs `task` while holding `~/.configfile/lock`, so that two configfile - * processes never change the same files or the deployment record at once. - * Waits a little for another process to finish, and takes over the lock of a - * process that no longer exists. + * processes never change the same files, the deployment record, the mirror of + * the repository or the configuration at once. Waits a little for another + * process to finish, and takes over the lock of a process that no longer + * exists. + * + * The lock always holds the pid of its owner: it is written to a draft file + * first, then linked as the lock, which fails when a lock exists. A lock that + * cannot be read stops the command: its owner cannot be checked. Calls made + * by the same process take their turn: the file cannot tell them apart. */ export async function withLock( home: string, task: () => Promise, - { maxWaitMs = MAX_WAIT_MS }: { maxWaitMs?: number } = {}, + { + maxWaitMs = MAX_WAIT_MS, + onWait, + onLeftBehind, + }: { + maxWaitMs?: number + /** Called once, when another process holds the lock: `who` names it. */ + onWait?: (who: string) => void + /** + * Called when the lock cannot be released as it should at the end (the + * task's result stands), with what to tell the user. + */ + onLeftBehind?: (message: string) => void + } = {}, ): Promise { const lock = path.join(configfilePaths(home).dir, 'lock') - await mkdir(path.dirname(lock), { recursive: true, mode: 0o700 }) + const previous = queues.get(lock) ?? Promise.resolve() + let done = () => {} + const turn = previous.then(() => new Promise(resolve => (done = resolve))) + queues.set(lock, turn) + await previous + try { + await acquire(lock, maxWaitMs, onWait) + held.add(lock) + try { + await sweepDrafts(lock) + return await task() + } finally { + held.delete(lock) + await release(lock, onLeftBehind) + } + } finally { + done() + if (queues.get(lock) === turn) queues.delete(lock) + } +} + +/** `withLock` for a command: says so when it has to wait, or when the lock is left behind. */ +export function withCommandLock( + ctx: Pick, + task: () => Promise, +): Promise { + return withLock(ctx.home, task, { + onWait: who => ctx.output.info(`Waiting for ${who} to finish…`), + onLeftBehind: message => ctx.output.warn(message), + }) +} + +async function acquire( + lock: string, + maxWaitMs: number, + onWait: ((who: string) => void) | undefined, +): Promise { + const unusable = (error: unknown) => + new CliError(`Cannot take the lock ${lock}: ${messageOf(error)}`, { cause: error }) + const guarded = (promise: Promise) => + promise.catch(error => Promise.reject(unusable(error))) + await guarded(mkdir(path.dirname(lock), { recursive: true, mode: 0o700 })) const started = Date.now() + let waiting = false for (;;) { - try { - await using handle = await open(lock, 'wx', 0o600) - await handle.writeFile(String(process.pid)) - break - } catch (error) { - if (errnoCode(error) !== 'EEXIST') throw error - } + if (await guarded(createExclusive(lock))) return - const content = await readFile(lock, 'utf8').catch(() => '') - const owner = Number.parseInt(content, 10) - // An empty lock is being created by another process: wait for its pid. - if (content !== '' && !isRunning(owner)) { - // Re-read just before removing, so a lock another process has just taken is kept. - if ((await readFile(lock, 'utf8').catch(() => '')) === content) { - await rm(lock, { force: true }) - } - continue + const owner = await guarded(readOwner(lock)) + if (owner === 'gone') continue + let blocker = { file: lock, owner } + if (owner != null && isLeftBehind(lock, owner)) { + const taken = await guarded(takeOver(lock, owner)) + if (taken === 'removed') continue + // Another process is taking it over: waited for like a lock. + blocker = taken } + + const who = + blocker.owner == null ? 'another configfile' : `another configfile (process ${blocker.owner})` if (Date.now() - started > maxWaitMs) { - const who = Number.isNaN(owner) - ? 'Another configfile' - : `Another configfile (process ${owner})` throw new CliError( - `${who} is changing files. Try again when it is done, ` + - `or delete ${lock} if no configfile is running.`, + `${who[0]?.toUpperCase()}${who.slice(1)} is changing files. Try again when it is done, ` + + `or delete ${blocker.file} if no configfile is running.`, ) } + if (!waiting) { + waiting = true + onWait?.(who) + } await sleep(RETRY_DELAY_MS) } +} +/** + * Removes our own lock. Anything else is said, never thrown: the task's + * result stands, and the next command takes over a lock whose owner is gone. + */ +async function release( + lock: string, + onLeftBehind: ((message: string) => void) | undefined, +): Promise { try { - return await task() - } finally { - // Only remove our own lock. - if ((await readFile(lock, 'utf8').catch(() => '')) === String(process.pid)) { + const owner = await readOwner(lock) + if (owner === process.pid) { await rm(lock, { force: true }) + return } + onLeftBehind?.( + owner === 'gone' + ? `${lock} was removed while this configfile held it: another configfile may have changed files at the same time.` + : `${lock} was replaced while this configfile held it: another configfile may have changed files at the same time. ` + + `Delete ${lock} if no configfile is running.`, + ) + } catch (error) { + onLeftBehind?.( + `${lock} could not be removed (${messageOf(error)}). Delete it if no configfile is running.`, + ) + } +} + +/** + * Creates `file` holding this process's pid, unless it exists. It never + * exists empty or half written: the pid is written to a draft file, which is + * then linked under the name `file` (this fails when it exists). Where hard + * links are not supported, `file` is created and written in two steps, as + * configfile 1.0 did. + */ +async function createExclusive(file: string): Promise { + const draft = draftOf(file) + await writeFile(draft, String(process.pid), { flag: 'wx', mode: 0o600 }) + try { + await link(draft, file) + return true + } catch (error) { + const code = errnoCode(error) + if (code === 'EEXIST') return false + if (code !== 'EPERM' && code !== 'ENOTSUP' && code !== 'ENOSYS') throw error + } finally { + await rm(draft, { force: true }).catch(() => {}) + } + + try { + await using handle = await open(file, 'wx', 0o600) + await handle.writeFile(String(process.pid)) + return true + } catch (error) { + if (errnoCode(error) === 'EEXIST') return false + throw error + } +} + +/** A file name of its own, next to `file`: `..`. */ +function draftOf(file: string): string { + return `${file}.${process.pid}.${randomUUID()}` +} + +/** + * Removes the drafts of processes that no longer exist (killed between + * writing a draft and removing it). Called with the lock held; never fails. + */ +async function sweepDrafts(lock: string): Promise { + const folder = path.dirname(lock) + const pattern = new RegExp( + `^${path.basename(lock).replace('.', '\\.')}(?:\\.takeover)?\\.(\\d+)\\.[0-9a-f-]{36}$`, + ) + const names = await readdir(folder).catch(() => []) + for (const name of names) { + const pid = Number(pattern.exec(name)?.[1]) + if (Number.isInteger(pid) && pid !== process.pid && !isRunning(pid)) { + await rm(path.join(folder, name), { force: true }).catch(() => {}) + } + } +} + +/** + * The pid in a lock: `null` when it holds none (being written by configfile + * 1.0, or damaged), `gone` when the lock no longer exists. Any other error is + * thrown: a lock that cannot be read is never taken over. + */ +async function readOwner(file: string): Promise { + let content: string + try { + content = await readFile(file, 'utf8') + } catch (error) { + if (errnoCode(error) === 'ENOENT') return 'gone' + throw error + } + const pid = Number.parseInt(content, 10) + return Number.isInteger(pid) && pid > 0 ? pid : null +} + +/** + * Whether the lock was left behind: by a process that no longer exists, or by + * this one, which does not hold it (its calls take their turn, so none is + * holding it while this one asks). A lock without pid is never: configfile + * 1.0 may be writing it, as it created the lock empty and wrote its pid after. + */ +function isLeftBehind(lock: string, owner: number): boolean { + if (owner === process.pid) return !held.has(lock) + return !isRunning(owner) +} + +/** + * Removes a lock left behind by `owner`. Only one process at a time does it, + * holding `.takeover`: two of them could otherwise both see the old + * lock, and one remove the lock the other has just taken. Returns `removed` + * once done (by this process or another), or the guard file and its owner + * when another process is taking it over. + * + * A guard left by a process that no longer exists is moved aside, then + * checked: one moved by mistake (a new one, created meanwhile) is put back. + * That leaves one rare case, a guard left behind and found by several + * processes at the very same moment, where two of them may take over at once. + */ +async function takeOver( + lock: string, + owner: number, +): Promise<'removed' | { file: string; owner: number | null }> { + const guard = `${lock}.takeover` + if (!(await createExclusive(guard))) { + const other = await readOwner(guard) + if (other === 'gone') return 'removed' + // Our own guard can only have been left behind: this process takes its turn. + if (other != null && other !== process.pid && isRunning(other)) { + return { file: guard, owner: other } + } + await removeStale(guard, other) + return 'removed' + } + try { + // Checked again: another process may have taken it over meanwhile. + if ((await readOwner(lock)) === owner) await rm(lock, { force: true }) + } finally { + await rm(guard, { force: true }).catch(() => {}) + } + return 'removed' +} + +/** Removes `file` if it still names `owner`; puts it back otherwise. */ +async function removeStale(file: string, owner: number | null): Promise { + const aside = draftOf(file) + try { + await rename(file, aside) + } catch (error) { + if (errnoCode(error) === 'ENOENT') return + throw error + } + try { + if ((await readOwner(aside)) !== owner) await link(aside, file).catch(() => {}) + } finally { + await rm(aside, { force: true }).catch(() => {}) } } diff --git a/src/output.ts b/src/output.ts index cf0fcb1..a87584c 100644 --- a/src/output.ts +++ b/src/output.ts @@ -57,9 +57,12 @@ export class Output { } } -/** `plural(1, 'file')` → "1 file", `plural(2, 'file')` → "2 files". */ -export function plural(count: number, noun: string): string { - return `${count} ${noun}${count === 1 ? '' : 's'}` +/** + * `plural(1, 'file')` → "1 file", `plural(2, 'file')` → "2 files". Irregular + * plurals are given: `plural(2, 'entry', 'entries')` → "2 entries". + */ +export function plural(count: number, noun: string, nouns = `${noun}s`): string { + return `${count} ${count === 1 ? noun : nouns}` } /** diff --git a/src/process.ts b/src/process.ts index 79ea55a..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' @@ -15,15 +16,25 @@ import type { Script } from './repository.ts' * them to both processes) and SIGTERM / SIGHUP are forwarded to it, so the * child is not left running when configfile is asked to stop, and its own exit * code is reported. (Nothing can be done if configfile is killed with SIGKILL.) + * + * `env` is merged over configfile's own environment, which the child inherits: + * its values replace variables of the same name. `onSignal` is told about each + * of these signals configfile receives meanwhile, for callers that have more + * to do once the child ends. */ -export function run(command: string, args: string[], { cwd }: { cwd: string }): Promise { +export function run( + command: string, + args: string[], + { cwd, env, onSignal }: RunOptions, +): Promise { return new Promise((resolve, reject) => { // Installed before the child starts: a signal arriving in between would // otherwise end configfile and leave the script running on its own. let child: ChildProcess | undefined let pending: NodeJS.Signals | undefined - const ignore = () => {} + const ignore = (signal: NodeJS.Signals) => onSignal?.(signal) const forward = (signal: NodeJS.Signals) => { + onSignal?.(signal) if (child == null) pending = signal else child.kill(signal) } @@ -39,7 +50,11 @@ export function run(command: string, args: string[], { cwd }: { cwd: string }): } try { - child = spawn(command, args, { cwd, stdio: 'inherit' }) + child = spawn(command, args, { + cwd, + stdio: 'inherit', + ...(env != null && { env: { ...process.env, ...env } }), + }) } catch (error) { cleanup() reject(error) @@ -64,11 +79,23 @@ export function run(command: string, args: string[], { cwd }: { cwd: string }): }) } +interface RunOptions { + cwd: string + env?: Readonly> | undefined + 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', } @@ -78,20 +105,21 @@ 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( script: Script, args: string[], - { cwd }: { cwd: string }, + { cwd, env, onSignal }: RunOptions, ): Promise { const executable = await isExecutable(script.path) const shebang = await readShebang(script) const launch = async (command: string, commandArgs: string[]) => { try { - return await run(command, commandArgs, { cwd }) + return await run(command, commandArgs, { cwd, env, onSignal }) } catch (error) { throw launchError(script, shebang?.[0], error) } @@ -105,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/src/repository.ts b/src/repository.ts index c3b7cd8..7ff2407 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -55,6 +55,10 @@ export type Module = ModuleBase & readonly invalidEntries: readonly string[] /** Deprecated settings used by the module. */ readonly deprecations: readonly string[] + /** The names of the scripts run before and after the module is deployed or undeployed. */ + readonly hooks: Readonly>> + /** Why some of these settings cannot be used (not a string, or empty). */ + readonly hookProblems: Readonly>> } | { /** Why settings.json could not be used. Such a module has no files. */ @@ -62,6 +66,10 @@ export type Module = ModuleBase & } ) +/** The settings that name a script run when the module is deployed or undeployed. */ +export const HOOKS = ['pre_deploy', 'post_deploy', 'pre_undeploy', 'post_undeploy'] as const +export type Hook = (typeof HOOKS)[number] + export interface Script { readonly name: string /** Path relative to the `scripts/` folder. */ @@ -86,6 +94,36 @@ export async function loadRepository( return { repository: folderPath, modules } } +/** The scripts, and the folder of the repository they come from. */ +export async function loadScripts( + ctx: Context, +): Promise<{ repository: string; scripts: Script[]; otherSystems: string[] }> { + const warn = (message: string) => ctx.output.warn(message) + const config = await new ConfigStore(ctx.home, { warn }).read() + const listed = await listScripts(config.folderPath, config.scriptExtensions, ctx.platform, { + warn, + }) + return { repository: config.folderPath, ...listed } +} + +/** + * The variables every script gets. Scripts run in the current folder, and a + * folder script is one level deeper: this gives them the repository's path, + * and the system whose versions run. Documented in + * docs/src/content/docs/reference/scripts.md. + */ +export function scriptEnvironment( + script: Script, + repository: string, + platform: NodeJS.Platform, +): Record { + return { + CONFIGFILE_REPO: repository, + CONFIGFILE_SCRIPT: script.name, + CONFIGFILE_OS: systemName(platform), + } +} + /** * Reads the modules of a dotfiles repository: every folder of `files/` * (symlinks to folders included) that contains a `settings.json`. @@ -257,7 +295,18 @@ async function readModuleFiles( ) } - return { error: null, files, undecided, invalidEntries, deprecations } + // The 0.3 list format has no place for it. + const hooks: Partial> = {} + const hookProblems: Partial> = {} + for (const hook of legacyList ? [] : HOOKS) { + const name = (settings as Record)[hook] + if (name === undefined) continue + if (typeof name === 'string' && name.trim() !== '') hooks[hook] = name.trim() + else + hookProblems[hook] = `"${hook}" is ignored: it must be the name of a script of the repository` + } + + return { error: null, files, undecided, invalidEntries, deprecations, hooks, hookProblems } } /** @@ -309,17 +358,21 @@ export function unsafeTarget( * `setup` script. A second part naming a system (`setup.macos.sh`, * `setup.linux/`) makes it that system's version: used instead of `setup.sh` * on that system, and ignored on the others. + * + * `otherSystems` names the scripts that only have versions for other systems: + * they are not scripts here, but their name is not a mistake either. */ export async function listScripts( folderPath: string, extensions: readonly string[] | null, platform: NodeJS.Platform, env: Pick, -): Promise { +): Promise<{ scripts: Script[]; otherSystems: string[] }> { const scriptsDir = path.join(folderPath, 'scripts') const scripts: Script[] = [] /** Names of the scripts that are this system's version. */ const forSystem = new Set() + const elsewhere = new Set() const allowed = (name: string) => extensions == null || extensions.includes(path.extname(name)) for (const entry of await readDirOrFail(scriptsDir)) { @@ -338,8 +391,11 @@ export async function listScripts( const [baseName = '', tag] = entry.name.split('.') const system = tag == null ? undefined : SCRIPT_SYSTEMS.get(tag) - if (system != null && system !== platform) continue const name = slugify(baseName) + if (system != null && system !== platform) { + elsewhere.add(name) + continue + } const script = { name, file, path: path.join(scriptsDir, file) } // This system's version replaces the generic one, whichever comes first. @@ -357,7 +413,14 @@ export async function listScripts( if (system != null) forSystem.add(name) } - return scripts + const otherSystems = [...elsewhere].filter(name => !scripts.some(script => script.name === name)) + return { scripts, otherSystems } +} + +/** The name of `platform` in script names (`macos`, `linux`), or the platform itself. */ +export function systemName(platform: NodeJS.Platform): string { + for (const [name, system] of SCRIPT_SYSTEMS) if (system === platform) return name + return platform } /** The systems a script can be made for, by the second part of its name. */ diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 921ac45..4d89efa 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { chmod, @@ -16,10 +16,10 @@ import { } from 'node:fs/promises' import path from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { COMMANDS } from '../src/history.ts' -import { buildProgram } from '../src/program.ts' +import { buildProgram, main } from '../src/program.ts' import { createContext, createRemote, @@ -30,6 +30,48 @@ import { type Sandbox, } from './helpers.ts' +/** + * Runs the CLI in-process, and calls `then` once `when` holds for its context + * (for example once it says it waits), then returns what `runCli` returns. + */ +async function runWhile( + sandbox: Sandbox, + args: string[], + answers: Array, + when: (ctx: ReturnType) => boolean, + then: () => unknown, +) { + const ctx = createContext(sandbox, answers) + const run = main(args, ctx) + const until = Date.now() + 10_000 + while (!when(ctx) && Date.now() < until) await new Promise(resolve => setTimeout(resolve, 10)) + await then() + const code = await run + return { code, stdout: ctx.stdout.text, stderr: ctx.stderr.text, asked: ctx.asked } +} + +const waiting = (ctx: ReturnType) => ctx.stdout.text.includes('Waiting for') + +/** A process that runs until it is killed, at the end of the test at the latest. */ +const others: ReturnType[] = [] +function startOther() { + const other = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)']) + others.push(other) + return other +} +afterEach(() => { + for (const other of others.splice(0)) other.kill() +}) + +/** Whether git runs the reference-transaction hook (git 2.28 or later). */ +const gitHasReferenceHook = (() => { + const [major = 0, minor = 0] = execFileSync('git', ['--version'], { encoding: 'utf8' }) + .replace(/^\D*/, '') + .split('.') + .map(Number) + return major > 2 || (major === 2 && minor >= 28) +})() + async function withModule(sandbox: Sandbox) { await sandbox.configure() await sandbox.write('home/dotfiles/files/zsh/zshrc', 'zshrc') @@ -482,6 +524,25 @@ describe('modules status, dry run and undeploy', () => { expect(existsSync(path.join(sandbox.home, '.zshrc'))).toBe(true) }) + it('counts the failures in the plural', async () => { + const sandbox = await createSandbox() + await withModule(sandbox) + await sandbox.write( + 'home/dotfiles/files/other/settings.json', + JSON.stringify({ + files: [ + { source_path: 'x', deploy: 'global' }, + { source_path: 'y', deploy: 'global' }, + ], + }), + ) + + const result = await runCli(sandbox, ['modules', 'deploy', '--all']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('2 files, modules or settings entries failed.') + }) + it('gives a dry run the exit code of the real run', async () => { const sandbox = await createSandbox() await withModule(sandbox) @@ -1263,6 +1324,50 @@ describe('scripts', () => { expect(await readFile(path.join(sandbox.root, 'ran'), 'utf8')).toBe('--flag value\n') }) + it('gives scripts the repository, their name and the system in CONFIGFILE_ variables', async () => { + const sandbox = await createSandbox() + await sandbox.configure() + const env = path.join(sandbox.root, 'env') + // A folder script is one level deeper than a file script: a path to the + // repository written relative to $0 ("$0/../..") would point elsewhere. + await sandbox.write( + 'home/dotfiles/scripts/setup.macos/index.sh', + `printf '%s\n' "$CONFIGFILE_REPO" "$CONFIGFILE_SCRIPT" "$CONFIGFILE_OS" "$INHERITED" > "${env}"\n`, + ) + vi.stubEnv('INHERITED', 'kept') + // A value left over in the environment (a script running configfile) is replaced. + vi.stubEnv('CONFIGFILE_OS', 'stale') + + try { + const result = await runCli(sandbox, ['scripts', 'run', 'setup'], [], { platform: 'darwin' }) + + expect(result.code).toBe(0) + expect(await readFile(env, 'utf8')).toBe(`${sandbox.repo}\nsetup\nmacos\nkept\n`) + // Only the script gets them: git, run later by the same process, must not. + expect(process.env.CONFIGFILE_REPO).toBeUndefined() + expect(process.env.CONFIGFILE_OS).toBe('stale') + } finally { + vi.unstubAllEnvs() + } + }) + + it('gives an executable script the full path of a repository configured with ~', async () => { + const sandbox = await createSandbox() + await sandbox.configure({ folder_path: '~/dotfiles' }) + const env = path.join(sandbox.root, 'env') + // Run directly, through its shebang line, rather than by sh. + await sandbox.write( + 'home/dotfiles/scripts/where', + `#!/bin/sh\nprintf '%s\n' "$CONFIGFILE_REPO" "$CONFIGFILE_OS" > "${env}"\n`, + 0o755, + ) + + const result = await runCli(sandbox, ['scripts', 'run', 'where']) + + expect(result.code).toBe(0) + expect(await readFile(env, 'utf8')).toBe(`${sandbox.repo}\nlinux\n`) + }) + it('runs non-executable .js files with node, in the current folder', async () => { const sandbox = await createSandbox() await withScripts(sandbox) @@ -1450,6 +1555,94 @@ describe('init', () => { const readConfig = async (sandbox: Sandbox) => JSON.parse(await readFile(path.join(sandbox.home, '.configfilerc'), 'utf8')) + it('waits for another configfile that holds the lock, after its questions', async () => { + const sandbox = await createSandbox() + const remote = await withRemote(sandbox) + const other = startOther() + await sandbox.write('home/.configfile/lock', String(other.pid)) + let askedBeforeWaiting: string[] = [] + + const result = await runWhile(sandbox, ['init'], [remote], waiting, function release() { + other.kill() + }) + askedBeforeWaiting = result.asked + + expect(result.code, result.stderr).toBe(0) + expect(askedBeforeWaiting).toEqual(['Dotfiles repository URL:']) + // The question comes first: the wait is only said once it is answered. + expect(result.stdout.indexOf('Waiting for')).toBeGreaterThan(-1) + expect(result.stdout).toContain( + `Waiting for another configfile (process ${other.pid}) to finish…`, + ) + expect(existsSync(path.join(sandbox.home, '.configfile/dotfiles/.git'))).toBe(true) + }) + + it('checks the folder again once it has waited: another configfile may have cloned it', async () => { + const sandbox = await createSandbox() + const remote = await withRemote(sandbox) + const folder = path.join(sandbox.home, '.configfile/dotfiles') + const other = startOther() + await sandbox.write('home/.configfile/lock', String(other.pid)) + + // While this one waits, the other clones the repository. + const result = await runWhile(sandbox, ['init', '--repo', remote], [], waiting, () => { + git(sandbox.root, 'clone', '--quiet', remote, folder) + other.kill() + }) + + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain( + `${folder} already contains a git repository. It is used as is.`, + ) + }) + + it('does not replace a configuration saved while it waited, without --force', async () => { + const sandbox = await createSandbox() + const remote = await withRemote(sandbox) + const other = startOther() + await sandbox.write('home/.configfile/lock', String(other.pid)) + + const result = await runWhile(sandbox, ['init', '--repo', remote], [], waiting, async () => { + await sandbox.configure() + other.kill() + }) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('Another configfile saved a configuration to') + expect( + JSON.parse(await readFile(path.join(sandbox.home, '.configfilerc'), 'utf8')), + ).toMatchObject({ + folder_path: sandbox.repo, + }) + // Stopped before cloning anything. + expect(existsSync(path.join(sandbox.home, '.configfile/dotfiles'))).toBe(false) + }) + + it('replaces a configuration saved while it waited, with --force', async () => { + const sandbox = await createSandbox() + const remote = await withRemote(sandbox) + const other = startOther() + await sandbox.write('home/.configfile/lock', String(other.pid)) + + const result = await runWhile( + sandbox, + ['init', '--repo', remote, '--force'], + [], + waiting, + async () => { + await sandbox.configure() + other.kill() + }, + ) + + expect(result.code, result.stderr).toBe(0) + expect( + JSON.parse(await readFile(path.join(sandbox.home, '.configfilerc'), 'utf8')), + ).toMatchObject({ + folder_path: path.join(sandbox.home, '.configfile/dotfiles'), + }) + }) + it('clones the repository and saves the configuration', async () => { const sandbox = await createSandbox() const remote = await withRemote(sandbox) @@ -1680,6 +1873,8 @@ describe('init', () => { const sandbox = await createSandbox() const remote = await withRemote(sandbox) const folder = path.join(sandbox.root, 'clone') + // configfile's own folder can be written (its lock is there), not the home folder. + await mkdir(path.join(sandbox.home, '.configfile')) await chmod(sandbox.home, 0o555) try { @@ -1695,6 +1890,27 @@ describe('init', () => { }, ) + // root writes to a folder whatever its permissions. + it.skipIf(process.getuid?.() === 0)( + 'fails before cloning when the lock cannot be taken', + async () => { + const sandbox = await createSandbox() + const remote = await withRemote(sandbox) + const folder = path.join(sandbox.root, 'clone') + await chmod(sandbox.home, 0o555) + + try { + const result = await runCli(sandbox, ['init', '--repo', remote, '--folder', folder]) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('Cannot take the lock') + expect(existsSync(folder)).toBe(false) + } finally { + await chmod(sandbox.home, 0o755) + } + }, + ) + it('asks again for a repository URL git cannot use', async () => { const sandbox = await createSandbox() const remote = await withRemote(sandbox) @@ -1792,6 +2008,151 @@ describe('update', () => { return existsSync(folder) ? (await readdir(folder)).map(name => path.join(folder, name)) : [] } + /** Holds the lock as another configfile would, until the returned function is called. */ + async function holdLock(sandbox: Sandbox) { + const other = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)']) + held.push(other) + await sandbox.write('home/.configfile/lock', String(other.pid)) + return () => other.kill() + } + // Never left running, whatever the outcome of a test. + const held: ReturnType[] = [] + afterEach(() => { + for (const other of held.splice(0)) other.kill() + }) + + it('waits for another configfile that holds the lock, then syncs', async () => { + const sandbox = await createSandbox() + const { commitInRemote } = await withMirror(sandbox) + await commitInRemote('files/zsh/zshrc', 'new') + const release = await holdLock(sandbox) + setTimeout(release, 500) + + const started = Date.now() + const result = await runCli(sandbox, ['update']) + + expect(result.code, result.stderr).toBe(0) + expect(Date.now() - started).toBeGreaterThanOrEqual(400) + expect(result.stdout).toMatch(/Waiting for another configfile \(process \d+\) to finish…\n/) + expect(result.stdout.match(/Waiting for/g)).toHaveLength(1) + expect(await readFile(path.join(sandbox.repo, 'files/zsh/zshrc'), 'utf8')).toBe('new') + }) + + it.skipIf(!gitHasReferenceHook)( + 'holds the lock for the whole sync, the fetch included', + async () => { + const sandbox = await createSandbox() + const { commitInRemote } = await withMirror(sandbox) + await commitInRemote('files/zsh/zshrc', 'new') + const lock = path.join(sandbox.home, '.configfile/lock') + const seen = path.join(sandbox.root, 'seen') + // Run by git each time it updates references: by the fetch first, then the reset. + await sandbox.write( + 'hooks/reference-transaction', + `#!/bin/sh\n[ "$1" = committed ] || exit 0\n` + + `if [ -f "${lock}" ]; then cat "${lock}"; else printf none; fi >> "${seen}"\necho >> "${seen}"\n`, + 0o755, + ) + git(sandbox.repo, 'config', 'core.hooksPath', path.join(sandbox.root, 'hooks')) + + const result = await runCli(sandbox, ['update']) + + expect(result.code, result.stderr).toBe(0) + const lines = (await readFile(seen, 'utf8')).trim().split('\n') + expect(lines.length).toBeGreaterThanOrEqual(2) + expect(lines).toEqual(lines.map(() => String(process.pid))) + expect(existsSync(lock)).toBe(false) + }, + ) + + it.skipIf(!gitHasReferenceHook)( + 'makes a deployment started during the sync wait for it', + async () => { + const sandbox = await createSandbox() + const { commitInRemote } = await withMirror(sandbox) + await commitInRemote( + 'files/zsh/settings.json', + JSON.stringify({ + files: [{ source_path: 'zshrc', target_path: '~/.zshrc', deploy: 'global' }], + }), + ) + await commitInRemote('files/zsh/zshrc', 'old') + git(sandbox.repo, 'pull', '--quiet') + await commitInRemote('files/zsh/zshrc', 'new') + const lock = path.join(sandbox.home, '.configfile/lock') + // The fetch, first to update references, takes a second: long enough to + // start a deployment meanwhile. + const slowed = path.join(sandbox.root, 'slowed') + await sandbox.write( + 'hooks/reference-transaction', + `#!/bin/sh\n[ "$1" = prepared ] && [ ! -e "${slowed}" ] && touch "${slowed}" && sleep 1\nexit 0\n`, + 0o755, + ) + git(sandbox.repo, 'config', 'core.hooksPath', path.join(sandbox.root, 'hooks')) + + const update = runCli(sandbox, ['update']) + const until = Date.now() + 10_000 + while (!existsSync(lock) && Date.now() < until) { + await new Promise(resolve => setTimeout(resolve, 20)) + } + // Another configfile process, as when started from another terminal. + const deploy = await new Promise<{ code: number | null; stdout: string }>(resolve => { + const child = spawn( + process.execPath, + [path.resolve('src/cli.ts'), 'modules', 'deploy', 'zsh'], + { + cwd: sandbox.cwd, + env: { ...process.env, HOME: sandbox.home, NO_COLOR: '1' }, + }, + ) + let stdout = '' + child.stdout.on('data', chunk => { + stdout += chunk + }) + child.on('close', code => resolve({ code, stdout })) + }) + const synced = await update + + expect(synced.code, synced.stderr).toBe(0) + expect(deploy.code, deploy.stdout).toBe(0) + expect(deploy.stdout).toContain( + `Waiting for another configfile (process ${process.pid}) to finish…`, + ) + // Deployed once the sync was done: the link leads to the synced file. + expect(await readFile(path.join(sandbox.home, '.zshrc'), 'utf8')).toBe('new') + }, + ) + + // root writes to a folder whatever its permissions. + it.skipIf(process.getuid?.() === 0)( + 'fails before syncing when the lock cannot be taken, and records it', + async () => { + const sandbox = await createSandbox() + const { commitInRemote } = await withMirror(sandbox) + await commitInRemote('files/zsh/zshrc', 'new') + const folder = path.join(sandbox.home, '.configfile') + // The history can still be appended to; the lock cannot be created. + await sandbox.write('home/.configfile/history.jsonl', '') + await chmod(folder, 0o555) + + try { + const result = await runCli(sandbox, ['update']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('Cannot take the lock') + } finally { + await chmod(folder, 0o755) + } + expect(existsSync(path.join(sandbox.repo, 'files/zsh/zshrc'))).toBe(false) + const [line] = await readHistory(sandbox) + expect(line).toMatchObject({ + command: 'update', + exitCode: 1, + error: { message: expect.stringContaining('Cannot take the lock') }, + }) + }, + ) + it('syncs the mirror with the remote', async () => { const sandbox = await createSandbox() const { commitInRemote } = await withMirror(sandbox) @@ -2556,6 +2917,957 @@ describe('history', () => { }) }) +describe('scripts run when a module is deployed or undeployed', () => { + /** + * Modules `a` and `b`, one global and one local file each. `hooks` gives + * them a `post_deploy`, `before` a `pre_deploy`, and `others` any setting. + */ + async function withHooks( + sandbox: Sandbox, + hooks: Record = {}, + before: Record = {}, + others: Record> = {}, + ) { + await sandbox.configure() + for (const name of ['a', 'b']) { + await sandbox.write(`home/dotfiles/files/${name}/rc`, name) + await sandbox.write( + `home/dotfiles/files/${name}/settings.json`, + JSON.stringify({ + files: [ + { source_path: 'rc', target_path: `~/.${name}rc`, deploy: 'global' }, + { source_path: 'rc', target_path: `${name}rc`, deploy: 'local' }, + ], + ...(name in hooks && { post_deploy: hooks[name] }), + ...(name in before && { pre_deploy: before[name] }), + ...others[name], + }), + ) + } + } + + /** A script that adds a line to `/log`, then exits with `code`. */ + const logging = (sandbox: Sandbox, line: string, code = 0) => + `printf '%s\\n' "${line}" >> "${sandbox.root}/log"\nexit ${code}\n` + const logOf = (sandbox: Sandbox) => + readFile(path.join(sandbox.root, 'log'), 'utf8').catch(() => '') + + it('runs the script of a module once its files are deployed, on every deploy', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }) + // The link is there, and the lock is released: the script may run configfile. + await sandbox.write( + 'home/dotfiles/scripts/plugins.sh', + `test -L "$HOME_DIR/.arc" && test ! -e "$HOME_DIR/.configfile/lock" || exit 9\n${logging( + sandbox, + '$CONFIGFILE_SCRIPT $CONFIGFILE_MODULE $CONFIGFILE_DEPLOY $CONFIGFILE_OS', + )}`, + ) + vi.stubEnv('HOME_DIR', sandbox.home) + + try { + const first = await runCli(sandbox, ['modules', 'deploy', '--all']) + const second = await runCli(sandbox, ['modules', 'deploy', 'a']) + + expect(first.code, first.stderr).toBe(0) + expect(first.stdout).toContain('Running "plugins", the post_deploy script of a…') + expect(first.stdout).toContain('Deployment finished.') + expect(second.code).toBe(0) + expect(await logOf(sandbox)).toBe('plugins a global linux\nplugins a global linux\n') + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(true) + } finally { + vi.unstubAllEnvs() + } + }) + + it('runs the pre_deploy script before the files, and the post_deploy script after', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'step' }, { a: 'step' }) + const link = path.join(sandbox.home, '.arc') + const lock = path.join(sandbox.home, '.configfile/lock') + await sandbox.write( + 'home/dotfiles/scripts/step.sh', + `test -e "${lock}" && exit 9\nif test -L "${link}"; then files=deployed; else files=missing; fi\n` + + logging(sandbox, '$CONFIGFILE_HOOK $CONFIGFILE_MODULE $CONFIGFILE_DEPLOY $files'), + ) + + const planned = await runCli(sandbox, ['modules', 'deploy', 'a', '--dry-run']) + const result = await runCli(sandbox, ['modules', 'deploy', 'a']) + + expect(planned.stdout).toMatch( + /- script "step" \(pre_deploy of a: would run\)\n- .*\.arc \(would be linked\)\n- script "step" \(post_deploy of a: would run\)/, + ) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain('Running "step", the pre_deploy script of a…') + expect(await logOf(sandbox)).toBe( + 'pre_deploy a global missing\npost_deploy a global deployed\n', + ) + const [line] = await readHistory(sandbox) + expect(line?.changes).toMatchObject([ + { kind: 'script', hook: 'pre_deploy', module: 'a', exitCode: 0 }, + { kind: 'deployed' }, + { kind: 'script', hook: 'post_deploy', module: 'a', exitCode: 0 }, + ]) + const history = await runCli(sandbox, ['history']) + expect(history.stdout).toMatch(/script\s+step {2}exit 0 {2}\(pre_deploy of a\)/) + }) + + it('does not deploy a module whose pre_deploy script fails, nor the modules after it', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'after' }, { a: 'check' }) + await sandbox.write('home/dotfiles/scripts/check.sh', logging(sandbox, 'check', 2)) + await sandbox.write('home/dotfiles/scripts/after.sh', logging(sandbox, 'after')) + + const result = await runCli(sandbox, ['modules', 'deploy', '--all']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('a: pre_deploy script "check" exited with code 2.') + expect(result.stderr).toContain( + 'The pre_deploy script of a failed. 2 modules were not deployed: a, b. Use --keep-going', + ) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(false) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(false) + expect(await logOf(sandbox)).toBe('check\n') + + const kept = await runCli(sandbox, ['modules', 'deploy', '--all', '--keep-going']) + + expect(kept.code).toBe(1) + expect(kept.stderr).toContain('1 module was not deployed: a.') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(false) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(true) + // The post_deploy script of a module that was not deployed is not run. + expect(await logOf(sandbox)).toBe('check\ncheck\n') + }) + + it('fails before changing anything when a script does not exist', async () => { + const sandbox = await createSandbox() + // "b" comes last: without the check, "a" would be deployed first. + await withHooks(sandbox, { b: 'scripts/plugins' }, { b: 'missing' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', logging(sandbox, 'ran')) + + const planned = await runCli(sandbox, ['modules', 'deploy', '--all', '--dry-run']) + const result = await runCli(sandbox, ['modules', 'deploy', '--all']) + + for (const run of [planned, result]) { + expect(run.code).toBe(1) + expect(run.stderr).toContain('b: pre_deploy script "missing" not found.') + // The name of a script, not its path. + expect(run.stderr).toContain( + 'b: post_deploy script "scripts/plugins" not found. Run "configfile scripts list"', + ) + expect(run.stdout).not.toContain('.arc') + } + expect(planned.stderr).toContain( + 'Nothing would be deployed: 2 scripts the modules name do not exist.', + ) + expect(result.stderr).toContain( + 'Nothing was deployed: 2 scripts the modules name do not exist.', + ) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(false) + const [line] = await readHistory(sandbox) + expect(line?.changes).toMatchObject([ + { kind: 'failed', module: 'b', reason: expect.stringContaining('pre_deploy') }, + { kind: 'failed', module: 'b', reason: expect.stringContaining('post_deploy') }, + ]) + + const skipped = await runCli(sandbox, ['modules', 'deploy', '--all', '--no-hooks']) + + expect(skipped.code, skipped.stderr).toBe(0) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(true) + expect(await logOf(sandbox)).toBe('') + }) + it("runs this system's version of the script", async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', logging(sandbox, 'generic')) + await sandbox.write('home/dotfiles/scripts/plugins.macos.sh', logging(sandbox, 'macos')) + + const result = await runCli(sandbox, ['modules', 'deploy', 'a'], [], { platform: 'darwin' }) + + expect(result.code).toBe(0) + expect(await logOf(sandbox)).toBe('macos\n') + }) + + it('stops at the first script that fails, leaving the next modules undeployed', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'broken', b: 'fine' }) + await sandbox.write('home/dotfiles/scripts/broken.sh', logging(sandbox, 'broken', 3)) + await sandbox.write('home/dotfiles/scripts/fine.sh', logging(sandbox, 'fine')) + + const result = await runCli(sandbox, ['modules', 'deploy', '--all']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('a: post_deploy script "broken" exited with code 3.') + expect(result.stderr).toContain( + 'The post_deploy script of a failed. 1 module was not deployed: b. ' + + 'Use --keep-going to go on after a script that fails.', + ) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(false) + expect(await logOf(sandbox)).toBe('broken\n') + + const [line] = await readHistory(sandbox) + expect(line).toMatchObject({ + exitCode: 1, + changes: [ + { kind: 'deployed', target: path.join(sandbox.home, '.arc') }, + { kind: 'script', name: 'broken', exitCode: 3, hook: 'post_deploy', module: 'a' }, + { kind: 'failed', module: 'b' }, + ], + }) + }) + + it('deploys the next modules anyway with --keep-going, and still fails', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'broken', b: 'fine' }) + await sandbox.write('home/dotfiles/scripts/broken.sh', logging(sandbox, 'broken', 3)) + await sandbox.write('home/dotfiles/scripts/fine.sh', logging(sandbox, 'fine')) + + const result = await runCli(sandbox, ['modules', 'deploy', '--all', '--keep-going']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('The post_deploy script of a failed.') + expect(result.stderr).not.toContain('not deployed') + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(true) + expect(await logOf(sandbox)).toBe('broken\nfine\n') + }) + + it('runs no script with --no-hooks', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }, { a: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', logging(sandbox, 'ran')) + + const result = await runCli(sandbox, ['modules', 'deploy', '--all', '--no-hooks']) + + expect(result.code, result.stderr).toBe(0) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + expect(await logOf(sandbox)).toBe('') + const [line] = await readHistory(sandbox) + expect(line?.options).toMatchObject({ hooks: false }) + const history = await runCli(sandbox, ['history']) + expect(history.stdout).toContain('modules deploy --all --no-hooks ok') + }) + it('with --keep-going, a script that does not exist only fails when its turn comes', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { b: 'fine' }, { a: 'missing' }) + await sandbox.write('home/dotfiles/scripts/fine.sh', logging(sandbox, 'fine')) + + const planned = await runCli(sandbox, [ + 'modules', + 'deploy', + '--all', + '--keep-going', + '--dry-run', + ]) + const result = await runCli(sandbox, ['modules', 'deploy', '--all', '--keep-going']) + + // The files of "a" are left out, as its pre_deploy script cannot run. + expect(planned.code).toBe(1) + expect(planned.stderr).toContain('a: pre_deploy script "missing" not found.') + expect(planned.stderr).toContain('The pre_deploy script of a would fail.') + expect(planned.stdout).not.toContain('.arc') + expect(planned.stdout).toContain('.brc (would be linked)') + expect(planned.stdout).toContain('- script "fine" (post_deploy of b: would run)') + expect(result.code).toBe(1) + expect(result.stderr).toContain( + 'The pre_deploy script of a failed. 1 module was not deployed: a.', + ) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(false) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(true) + expect(await logOf(sandbox)).toBe('fine\n') + }) + it('reports a script that cannot be started', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins', 'echo no shebang\n', 0o644) + + const result = await runCli(sandbox, ['modules', 'deploy', 'a']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('a: Script "plugins" has no shebang line') + expect(result.stderr).toContain('The post_deploy script of a failed.') + }) + + it('does not run the script of a module whose files are not all deployed', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins', b: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', logging(sandbox, '$CONFIGFILE_MODULE')) + await sandbox.write('cwd/arc', 'mine') + + const result = await runCli(sandbox, ['modules', 'deploy', '--local', '--all'], [], { + interactive: false, + }) + + expect(result.code).toBe(1) + expect(result.stderr).toContain( + 'a: post_deploy script "plugins" was not run, some files of the module are not deployed.', + ) + // The other modules are not held back: no script failed. + expect(await logOf(sandbox)).toBe('b\n') + }) + + it('does not run the script of a module that has an invalid entry or a file that fails', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { b: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', logging(sandbox, '$CONFIGFILE_MODULE')) + const settings = (files: unknown[]) => + JSON.stringify({ + files: [{ source_path: 'rc', target_path: '~/.arc', deploy: 'global' }, ...files], + post_deploy: 'plugins', + }) + const moduleA = 'home/dotfiles/files/a/settings.json' + + await sandbox.write(moduleA, settings([{ source_path: 'rc', deploy: 'global' }])) + const invalid = await runCli(sandbox, ['modules', 'deploy', '--all']) + const planned = await runCli(sandbox, ['modules', 'deploy', 'a', '--dry-run']) + await sandbox.write( + moduleA, + settings([{ source_path: 'gone', target_path: '~/.gone', deploy: 'global' }]), + ) + const failed = await runCli(sandbox, ['modules', 'deploy', 'a']) + + for (const result of [invalid, failed]) { + expect(result.code).toBe(1) + expect(result.stderr).toContain('a: post_deploy script "plugins" was not run') + } + expect(planned.stdout).toContain('(post_deploy of a: would not run,') + expect(await logOf(sandbox)).toBe('b\n') + }) + + it('runs the script when the user agrees to replace a file, not when they refuse', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }) + await sandbox.write( + 'home/dotfiles/scripts/plugins.sh', + logging(sandbox, '$CONFIGFILE_DEPLOY $CONFIGFILE_REPO $PWD'), + ) + await sandbox.write('cwd/arc', 'mine') + + const refused = await runCli(sandbox, ['modules', 'deploy', '--local', 'a'], [false]) + expect(refused.code).toBe(0) + expect(refused.stderr).toContain('a: post_deploy script "plugins" was not run') + expect(await logOf(sandbox)).toBe('') + // The run succeeded: the script that was not run is not a failure. + const [line] = await readHistory(sandbox) + expect(line?.changes).toMatchObject([ + { kind: 'skipped' }, + { kind: 'script-skipped', name: 'plugins', hook: 'post_deploy', module: 'a' }, + ]) + const history = await runCli(sandbox, ['history']) + expect(history.stdout).toMatch(/skipped\s+plugins {2}\(post_deploy of a: some files/) + + const agreed = await runCli(sandbox, ['modules', 'deploy', '--local', 'a'], [true]) + expect(agreed.code, agreed.stderr).toBe(0) + expect(await logOf(sandbox)).toBe(`local ${sandbox.repo} ${sandbox.cwd}\n`) + }) + + it('with --keep-going, goes on after a post_deploy script that does not exist', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'missing', b: 'fine' }) + await sandbox.write('home/dotfiles/scripts/fine.sh', logging(sandbox, 'fine')) + + const result = await runCli(sandbox, ['modules', 'deploy', '--all', '--keep-going']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('The post_deploy script of a failed.') + expect(result.stderr).not.toContain('not deployed') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(true) + expect(await logOf(sandbox)).toBe('fine\n') + const [line] = await readHistory(sandbox) + expect(line?.changes).toMatchObject([ + { kind: 'deployed' }, + { kind: 'failed', module: 'a', reason: expect.stringContaining('not found') }, + { kind: 'deployed' }, + { kind: 'script', module: 'b' }, + ]) + }) + it('says which modules were left out when the next one cannot be deployed', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'breaks' }) + // The record is read again, under the lock, for each module. + await sandbox.write( + 'home/dotfiles/scripts/breaks.sh', + `echo broken > "${sandbox.home}/.configfile/state.json"\n`, + ) + + const result = await runCli(sandbox, ['modules', 'deploy', '--all']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('state.json') + expect(result.stderr).toContain('1 module was not deployed: b.') + expect(result.stderr).not.toContain('--keep-going') + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(false) + const [line] = await readHistory(sandbox) + expect(line?.changes).toMatchObject([ + { kind: 'deployed' }, + { kind: 'script', module: 'a', exitCode: 0 }, + { kind: 'failed', module: 'b' }, + ]) + }) + + it('only runs the scripts of the modules that have files to deploy', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', logging(sandbox, 'ran')) + await sandbox.write( + 'home/dotfiles/files/a/settings.json', + JSON.stringify({ + files: [{ source_path: 'rc', target_path: '~/.arc', deploy: 'global' }], + post_deploy: 'plugins', + }), + ) + + // With every module, a module without local files is the usual case: nothing is said. + const all = await runCli(sandbox, ['modules', 'deploy', '--local', '--all']) + const planned = await runCli(sandbox, ['modules', 'deploy', '--local', 'a', '--dry-run']) + const named = await runCli(sandbox, ['modules', 'deploy', '--local', 'a']) + + expect(all.code, all.stderr).toBe(0) + expect(all.stderr).not.toContain('post_deploy') + expect(planned.stderr).toContain( + 'a: post_deploy script "plugins" would not run, the module has no local file.', + ) + expect(named.code, named.stderr).toBe(0) + expect(named.stderr).toContain( + 'a: post_deploy script "plugins" was not run, the module has no local file.', + ) + expect(await logOf(sandbox)).toBe('') + }) + it('shows the scripts in a dry run, without running them', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', logging(sandbox, 'ran')) + + const result = await runCli(sandbox, ['modules', 'deploy', '--all', '--dry-run']) + + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain('- script "plugins" (post_deploy of a: would run)') + expect(await logOf(sandbox)).toBe('') + + const quiet = await runCli(sandbox, ['modules', 'deploy', 'a', '--dry-run', '--no-hooks']) + expect(quiet.code).toBe(0) + expect(quiet.stdout).not.toContain('post_deploy') + }) + it('says in a dry run when a script would not run', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', logging(sandbox, 'ran')) + await sandbox.write('cwd/arc', 'mine') + + const result = await runCli(sandbox, ['modules', 'deploy', '--local', 'a', '--dry-run'], [], { + interactive: false, + }) + + expect(result.code).toBe(1) + expect(result.stdout).toContain( + '- script "plugins" (post_deploy of a: would not run, some files of the module are not deployed)', + ) + }) + + it('refuses a "post_deploy" that is not a script name, and deploys the files', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: ['plugins'] }) + + const result = await runCli(sandbox, ['modules', 'deploy', 'a']) + const listed = await runCli(sandbox, ['modules', 'list']) + const undeployed = await runCli(sandbox, ['modules', 'undeploy', 'a']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain( + 'a: "post_deploy" is ignored: it must be the name of a script of the repository.', + ) + expect(result.stdout).toContain(`${path.join(sandbox.home, '.arc')} (deployed)`) + expect(listed.stderr).toContain('"post_deploy" is ignored') + // Undeploying does not need it. + expect(undeployed.code).toBe(0) + }) + + it('runs pre_undeploy before the files are removed, and post_undeploy after', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { a: { pre_undeploy: 'step', post_undeploy: 'step' } }) + const link = path.join(sandbox.home, '.arc') + const lock = path.join(sandbox.home, '.configfile/lock') + await sandbox.write( + 'home/dotfiles/scripts/step.sh', + `test -e "${lock}" && exit 9\nif test -L "${link}"; then files=deployed; else files=removed; fi\n` + + logging(sandbox, '$CONFIGFILE_HOOK $CONFIGFILE_MODULE $CONFIGFILE_DEPLOY $files'), + ) + await runCli(sandbox, ['modules', 'deploy', '--all']) + expect(await logOf(sandbox)).toBe('') + + const planned = await runCli(sandbox, ['modules', 'undeploy', 'a', '--dry-run']) + const result = await runCli(sandbox, ['modules', 'undeploy', '--all']) + + expect(planned.stdout).toMatch( + /- script "step" \(pre_undeploy of a: would run\)\n- .*\.arc \(would be removed\)\n- script "step" \(post_undeploy of a: would run\)/, + ) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain('Running "step", the pre_undeploy script of a…') + expect(result.stdout).toContain('Undeployment finished.') + expect(await logOf(sandbox)).toBe( + 'pre_undeploy a global deployed\npost_undeploy a global removed\n', + ) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(false) + const history = await readHistory(sandbox) + expect(history.at(-1)?.changes).toMatchObject([ + { kind: 'script', hook: 'pre_undeploy', module: 'a', exitCode: 0 }, + { kind: 'removed', target: link }, + { kind: 'script', hook: 'post_undeploy', module: 'a', exitCode: 0 }, + { kind: 'removed' }, + ]) + }) + + it('leaves a module deployed when its pre_undeploy script fails, and the modules after it', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { a: { pre_undeploy: 'stop', post_undeploy: 'clean' } }) + await sandbox.write('home/dotfiles/scripts/stop.sh', logging(sandbox, 'stop', 4)) + await sandbox.write('home/dotfiles/scripts/clean.sh', logging(sandbox, 'clean')) + await runCli(sandbox, ['modules', 'deploy', '--all']) + + const result = await runCli(sandbox, ['modules', 'undeploy', '--all']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('a: pre_undeploy script "stop" exited with code 4.') + expect(result.stderr).toContain( + 'The pre_undeploy script of a failed. 2 modules were not undeployed: a, b. Use --keep-going', + ) + // --all also covers them, but the run stopped before. + expect(result.stderr).toContain('The files the repository no longer deploys were not checked.') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(true) + + const kept = await runCli(sandbox, ['modules', 'undeploy', '--all', '--keep-going']) + + expect(kept.code).toBe(1) + expect(kept.stderr).toContain('1 module was not undeployed: a.') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(false) + expect(await logOf(sandbox)).toBe('stop\nstop\n') + + const skipped = await runCli(sandbox, ['modules', 'undeploy', 'a', '--no-hooks']) + + expect(skipped.code, skipped.stderr).toBe(0) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(false) + }) + + it('stops after a post_undeploy script that fails, unless --keep-going is given', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { a: { post_undeploy: 'clean' } }) + const link = path.join(sandbox.home, '.arc') + // It runs once the link is gone. + await sandbox.write( + 'home/dotfiles/scripts/clean.sh', + `test -e "${link}" && exit 9\n${logging(sandbox, 'clean', 3)}`, + ) + await runCli(sandbox, ['modules', 'deploy', '--all']) + + const result = await runCli(sandbox, ['modules', 'undeploy', 'a', 'b']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('a: post_undeploy script "clean" exited with code 3.') + expect(result.stderr).toContain( + 'The post_undeploy script of a failed. 1 module was not undeployed: b.', + ) + expect(existsSync(link)).toBe(false) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(true) + + await runCli(sandbox, ['modules', 'deploy', 'a', '--no-hooks']) + const kept = await runCli(sandbox, ['modules', 'undeploy', 'a', 'b', '--keep-going']) + + expect(kept.code).toBe(1) + expect(kept.stderr).not.toContain('not undeployed') + expect(existsSync(link)).toBe(false) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(false) + }) + + it('fails before undeploying anything when a script does not exist', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { b: { pre_undeploy: 'missing' } }) + await runCli(sandbox, ['modules', 'deploy', '--all']) + + const planned = await runCli(sandbox, ['modules', 'undeploy', '--all', '--dry-run']) + const result = await runCli(sandbox, ['modules', 'undeploy', '--all']) + + for (const run of [planned, result]) { + expect(run.code).toBe(1) + expect(run.stderr).toContain('b: pre_undeploy script "missing" not found.') + expect(run.stdout).not.toContain('.arc') + } + expect(result.stderr).toContain('Nothing was undeployed: 1 script the modules name does not') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + + const kept = await runCli(sandbox, [ + 'modules', + 'undeploy', + '--all', + '--keep-going', + '--dry-run', + ]) + + expect(kept.code).toBe(1) + expect(kept.stdout).toContain('.arc (would be removed)') + expect(kept.stdout).not.toContain('.brc') + }) + + // root removes files from a folder whatever its permissions. + it.skipIf(process.getuid?.() === 0)( + 'does not run post_undeploy when a file of the module cannot be undeployed', + async () => { + const sandbox = await createSandbox() + await withHooks( + sandbox, + {}, + {}, + { a: { post_undeploy: 'clean' }, b: { post_undeploy: 'clean' } }, + ) + await sandbox.write('home/dotfiles/scripts/clean.sh', logging(sandbox, '$CONFIGFILE_MODULE')) + await sandbox.write( + 'home/dotfiles/files/a/settings.json', + JSON.stringify({ + files: [{ source_path: 'rc', target_path: '~/locked/.arc', deploy: 'global' }], + post_undeploy: 'clean', + }), + ) + await runCli(sandbox, ['modules', 'deploy', '--all']) + const locked = path.join(sandbox.home, 'locked') + await chmod(locked, 0o555) + + try { + const result = await runCli(sandbox, ['modules', 'undeploy', '--all']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain( + 'a: post_undeploy script "clean" was not run, some files of the module could not be undeployed.', + ) + // The other modules are not held back: no script failed. + expect(await logOf(sandbox)).toBe('b\n') + const history = await readHistory(sandbox) + expect(history.at(-1)?.changes).toContainEqual({ + kind: 'script-skipped', + name: 'clean', + hook: 'post_undeploy', + module: 'a', + reason: 'some files of the module could not be undeployed', + }) + } finally { + await chmod(locked, 0o755) + } + }, + ) + + it('undeploys the files the repository no longer deploys only when the run was not stopped', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { a: { pre_undeploy: 'stop' } }) + await sandbox.write('home/dotfiles/scripts/stop.sh', 'exit 4\n') + await runCli(sandbox, ['modules', 'deploy', '--all']) + // The entry of "b" leaves the repository: its link is one of these files. + await sandbox.write('home/dotfiles/files/b/settings.json', JSON.stringify({ files: [] })) + const removed = path.join(sandbox.home, '.brc') + + const stopped = await runCli(sandbox, ['modules', 'undeploy', '--all']) + + expect(stopped.code).toBe(1) + expect(stopped.stderr).toContain('The files the repository no longer deploys were not checked.') + expect(existsSync(removed)).toBe(true) + + const kept = await runCli(sandbox, ['modules', 'undeploy', '--all', '--keep-going']) + + expect(kept.code).toBe(1) + expect(kept.stderr).not.toContain('were not checked') + expect(existsSync(removed)).toBe(false) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + }) + + it('says which modules were left out when the next one cannot be undeployed', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { a: { pre_undeploy: 'breaks', post_undeploy: 'clean' } }) + await sandbox.write( + 'home/dotfiles/scripts/breaks.sh', + `echo broken > "${sandbox.home}/.configfile/state.json"\n`, + ) + await sandbox.write('home/dotfiles/scripts/clean.sh', logging(sandbox, 'clean')) + await runCli(sandbox, ['modules', 'deploy', '--all']) + + const result = await runCli(sandbox, ['modules', 'undeploy', '--all']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('2 modules were not undeployed: a, b.') + expect(result.stderr).toContain('The files the repository no longer deploys were not checked.') + expect(result.stderr).not.toContain('--keep-going') + expect(await logOf(sandbox)).toBe('') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + }) + + it('runs the undeploy scripts of the modules that have files of the kind, deployed or not', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { a: { pre_undeploy: 'clean', post_undeploy: 'clean' } }) + await sandbox.write('home/dotfiles/scripts/clean.sh', logging(sandbox, '$CONFIGFILE_HOOK')) + await sandbox.write( + 'home/dotfiles/files/a/settings.json', + JSON.stringify({ + files: [{ source_path: 'rc', target_path: '~/.arc', deploy: 'global' }], + pre_undeploy: 'clean', + post_undeploy: 'clean', + }), + ) + + const local = await runCli(sandbox, ['modules', 'undeploy', '--local', 'a']) + + expect(local.code, local.stderr).toBe(0) + expect(local.stderr).toContain( + 'a: pre_undeploy script "clean" was not run, the module has no local file.', + ) + expect(await logOf(sandbox)).toBe('') + + // Never deployed: "not deployed" is a file handled. + const global = await runCli(sandbox, ['modules', 'undeploy', 'a']) + + expect(global.code, global.stderr).toBe(0) + expect(global.stdout).toContain('(not deployed)') + expect(await logOf(sandbox)).toBe('pre_undeploy\npost_undeploy\n') + }) + it('runs post_undeploy when a file is kept on purpose', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { a: { post_undeploy: 'clean' } }) + await sandbox.write('home/dotfiles/scripts/clean.sh', logging(sandbox, 'clean')) + // Not deployed by configfile: undeploy leaves it where it is. + await sandbox.write('cwd/arc', 'mine') + + const result = await runCli(sandbox, ['modules', 'undeploy', '--local', 'a']) + + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain('(kept:') + expect(await logOf(sandbox)).toBe('clean\n') + }) + + it('only fails on the unusable settings of the scripts a command would run', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { a: { pre_undeploy: 42 } }) + + const deployed = await runCli(sandbox, ['modules', 'deploy', 'a']) + const status = await runCli(sandbox, ['modules', 'status', 'a']) + const undeployed = await runCli(sandbox, ['modules', 'undeploy', 'a']) + + // A warning for deploy, which does not run it; an error elsewhere. + expect(deployed.code).toBe(0) + expect(deployed.stderr).toContain(' Warn a: "pre_undeploy" is ignored') + expect(deployed.stderr).not.toContain('Error') + expect(status.code).toBe(0) + expect(status.stderr).toContain(' Error a: "pre_undeploy" is ignored') + expect(undeployed.code).toBe(1) + expect(undeployed.stderr).toContain(' Error a: "pre_undeploy" is ignored') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(false) + }) + it('lists the scripts of a module, and says in status when one does not exist', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }, {}, { a: { pre_undeploy: 'missing' } }) + await sandbox.write('home/dotfiles/scripts/plugins.macos.sh', 'exit 0\n') + + const listed = await runCli(sandbox, ['modules', 'list']) + const status = await runCli(sandbox, ['modules', 'status', '--local'], [], { + platform: 'darwin', + }) + const elsewhere = await runCli(sandbox, ['modules', 'status', 'a']) + + expect(listed.stdout).toContain('- a (post_deploy, pre_undeploy)\n- b\n') + expect(status.code).toBe(0) + expect(status.stdout).toContain(' post_deploy: script "plugins"\n') + expect(status.stdout).toContain(' pre_undeploy: script "missing" (not found in scripts/)\n') + // The script only has a version for macOS. + expect(elsewhere.stdout).toContain( + ' post_deploy: script "plugins" (no version for this system)\n', + ) + }) + + it('still shows the status of the files when the scripts cannot be listed', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }) + await sandbox.write('home/dotfiles/scripts', 'not a folder') + + const status = await runCli(sandbox, ['modules', 'status']) + const deployed = await runCli(sandbox, ['modules', 'deploy', '--all']) + + expect(status.code).toBe(0) + expect(status.stderr).toContain('The scripts the modules name cannot be checked:') + expect(status.stdout).toContain(`${path.join(sandbox.home, '.arc')} (not deployed)`) + expect(status.stdout).toContain(' post_deploy: script "plugins"\n') + expect(deployed.code).toBe(1) + expect(deployed.stderr).toContain('Use --no-hooks to deploy without running the scripts') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(false) + }) + + it('ends as usual when a question is cancelled, and records the modules left out', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins', b: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', logging(sandbox, 'ran')) + await sandbox.write('cwd/arc', 'mine') + + const result = await runCli( + sandbox, + ['modules', 'deploy', '--local', '--all'], + [exitPromptError()], + ) + + expect(result.code).toBe(130) + expect(result.stderr).not.toContain('not deployed') + expect(existsSync(path.join(sandbox.cwd, 'brc'))).toBe(false) + expect(await logOf(sandbox)).toBe('') + const [line] = await readHistory(sandbox) + expect(line?.changes).toEqual([ + { kind: 'failed', module: 'b', reason: 'not deployed: cancelled' }, + ]) + }) + it('reports a lock that cannot be used as an expected error, when nothing was done yet', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox) + await sandbox.write('home/.configfile', 'not a folder') + + const result = await runCli(sandbox, ['modules', 'deploy', '--all']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain( + `Cannot take the lock ${path.join(sandbox.home, '.configfile/lock')}:`, + ) + expect(result.stderr).not.toContain('Unexpected error') + expect(result.stderr).not.toContain('not deployed') + }) + + it('skips a script that only has versions for other systems', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }, { a: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.macos.sh', logging(sandbox, 'ran')) + + const planned = await runCli(sandbox, ['modules', 'deploy', '--all', '--dry-run']) + const result = await runCli(sandbox, ['modules', 'deploy', '--all']) + + expect(planned.code, planned.stderr).toBe(0) + expect(planned.stdout).toContain( + '- script "plugins" (pre_deploy of a: would be skipped, no version for linux)', + ) + // Not a mistake in the repository: every module is deployed. + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain( + 'a: post_deploy script "plugins" has no version for linux, skipped.', + ) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(true) + expect(await logOf(sandbox)).toBe('') + const [line] = await readHistory(sandbox) + expect(line?.changes).toMatchObject([ + { kind: 'script-skipped', hook: 'pre_deploy', module: 'a', reason: 'no version for linux' }, + { kind: 'deployed' }, + { kind: 'script-skipped', hook: 'post_deploy', module: 'a' }, + { kind: 'deployed' }, + ]) + + const onMac = await runCli(sandbox, ['modules', 'deploy', 'a'], [], { platform: 'darwin' }) + + expect(onMac.code, onMac.stderr).toBe(0) + expect(await logOf(sandbox)).toBe('ran\nran\n') + }) + + it('keeps the summary of an undeployment when the removed files cannot be checked', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, {}, {}, { b: { post_undeploy: 'breaks' } }) + // The record is read again for the files the repository no longer deploys. + await sandbox.write( + 'home/dotfiles/scripts/breaks.sh', + `echo broken > "${sandbox.home}/.configfile/state.json"\n`, + ) + await runCli(sandbox, ['modules', 'deploy', '--all']) + + const result = await runCli(sandbox, ['modules', 'undeploy', '--all']) + + expect(result.code).toBe(1) + expect(result.stderr).toContain('The files the repository no longer deploys were not checked: ') + expect(result.stderr).toContain('state.json') + expect(result.stderr).not.toContain('Unexpected error') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(false) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(false) + + // Alone, the error says it all. + const alone = await runCli(sandbox, ['modules', 'undeploy', '--removed']) + + expect(alone.code).toBe(1) + expect(alone.stderr).toContain('state.json') + expect(alone.stderr).not.toContain('were not checked') + }) + it('shows which module a script ran for in the history', async () => { + const sandbox = await createSandbox() + await withHooks(sandbox, { a: 'plugins' }) + await sandbox.write('home/dotfiles/scripts/plugins.sh', 'exit 0\n') + await runCli(sandbox, ['modules', 'deploy', 'a', '--keep-going']) + + const result = await runCli(sandbox, ['history']) + + expect(result.stdout).toContain('modules deploy a --keep-going ok') + expect(result.stdout).toMatch(/script\s+plugins {2}exit 0 {2}\(post_deploy of a\)/) + }) +}) + +describe('waiting for another configfile', () => { + async function withZsh(sandbox: Sandbox) { + await sandbox.configure() + await sandbox.write('home/dotfiles/files/zsh/zshrc', 'zshrc') + await sandbox.write( + 'home/dotfiles/files/zsh/settings.json', + JSON.stringify({ + files: [{ source_path: 'zshrc', target_path: '~/.zshrc', deploy: 'global' }], + }), + ) + } + + it('makes modules deploy and undeploy say once that they wait', async () => { + const sandbox = await createSandbox() + await withZsh(sandbox) + + for (const command of ['deploy', 'undeploy']) { + const other = startOther() + await sandbox.write('home/.configfile/lock', String(other.pid)) + + const result = await runWhile(sandbox, ['modules', command, 'zsh'], [], waiting, () => + other.kill(), + ) + + expect(result.code, result.stderr).toBe(0) + expect( + result.stdout.match(/Waiting for another configfile \(process \d+\) to finish…/g), + ).toHaveLength(1) + } + expect(existsSync(path.join(sandbox.home, '.zshrc'))).toBe(false) + }) + + // root removes files from a folder whatever its permissions. + it.skipIf(process.getuid?.() === 0 || !gitHasReferenceHook)( + 'warns when the lock cannot be removed, and keeps the result of the command', + async () => { + const sandbox = await createSandbox() + const remote = await createRemote(sandbox) + git(sandbox.root, 'clone', '--quiet', remote, sandbox.repo) + await sandbox.configure() + const folder = path.join(sandbox.home, '.configfile') + // Run by git during the sync, while update holds the lock. + await sandbox.write( + 'hooks/reference-transaction', + `#!/bin/sh\nchmod 555 "${folder}"\nexit 0\n`, + 0o755, + ) + git(sandbox.repo, 'config', 'core.hooksPath', path.join(sandbox.root, 'hooks')) + + try { + const result = await runCli(sandbox, ['update']) + + expect(result.code, result.stderr).toBe(0) + expect(result.stderr).toContain(`${path.join(folder, 'lock')} could not be removed (`) + expect(result.stderr).toContain('Delete it if no configfile is running.') + } finally { + await chmod(folder, 0o755) + } + }, + ) +}) + describe('program', () => { it('prints the version', async () => { const sandbox = await createSandbox() diff --git a/tests/docs.test.ts b/tests/docs.test.ts new file mode 100644 index 0000000..ecdc474 --- /dev/null +++ b/tests/docs.test.ts @@ -0,0 +1,84 @@ +import { execFileSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { COMMANDS_PAGE, renderCommandsPage } from '../tools/docs-commands.ts' + +const page = await readFile(COMMANDS_PAGE, 'utf8') +const block = (name: string, content = '') => + `\n${content}` + +describe('commands reference', () => { + it('documents every command, with its current usage and options', () => { + // On failure, run "pnpm docs:commands" and commit the page (a new command + // first needs its block, see CONTRIBUTING.md). + expect(renderCommandsPage(page)).toBe(page) + }) + + it('rewrites stale blocks, and leaves everything else as it is', () => { + const stale = page + .replace( + /(\n)[\s\S]*?()/, + '$1STALE\n$2', + ) + .replace('', '') + expect(stale).toContain('STALE') + + const rendered = renderCommandsPage(stale) + + expect(rendered).not.toContain('STALE') + expect(rendered).toBe( + // Only the stale content changed: the marker keeps its spacing. + page.replace( + '', + '', + ), + ) + }) + + it('keeps Windows line endings', () => { + const crlf = page.replaceAll('\n', '\r\n') + + expect(renderCommandsPage(crlf)).toBe(crlf) + }) + + it('refuses unknown, missing and duplicate commands, listing every problem', () => { + const wrong = `${block('configfile nope')}\n${page}\n${block('configfile init')}\n` + + const error = (() => { + try { + renderCommandsPage( + wrong.replace(/[\s\S]*?/, ''), + ) + } catch (thrown) { + return (thrown as Error).message + } + return '' + })() + + expect(error).toContain('"configfile nope" is not a command.') + expect(error).toContain('"configfile history" has no [\s\S]*?)/, + '$1', + ) + + expect(() => renderCommandsPage(broken)).toThrow( + 'The block of "configfile modules list" has no .', + ) + }) + + it('can be run to check the page', () => { + const script = fileURLToPath(new URL('../tools/docs-commands.ts', import.meta.url)) + + const output = execFileSync(process.execPath, [script, '--check'], { encoding: 'utf8' }) + + expect(output).toContain('is up to date.') + }) +}) diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 3dacc41..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, @@ -76,11 +103,16 @@ describe('built CLI', () => { }) /** Starts a long-running script, sends `signal` to configfile only once it runs. */ - async function interrupt(sandbox: Sandbox, script: string, signal: NodeJS.Signals) { + async function interrupt( + sandbox: Sandbox, + script: string, + signal: NodeJS.Signals, + args = ['scripts', 'run', 'long'], + ) { await sandbox.configure() await sandbox.write('home/dotfiles/scripts/long.sh', script) - const child = spawn(process.execPath, [cli, 'scripts', 'run', 'long'], { + const child = spawn(...command([...args]), { cwd: sandbox.cwd, env: { ...process.env, HOME: sandbox.home, NO_COLOR: '1' }, detached: true, @@ -88,13 +120,23 @@ describe('built CLI', () => { if (child.pid != null) groups.push(child.pid) let stdout = '' + let stderr = '' + child.stderr.on('data', chunk => { + stderr += chunk + }) + let sent = false child.stdout.on('data', chunk => { stdout += chunk - if (stdout.includes('ready')) child.kill(signal) + // Once: a deployment goes on writing to stdout. + if (stdout.includes('ready') && !sent) sent = child.kill(signal) }) - return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { - child.on('close', (code, closeSignal) => resolve({ code, signal: closeSignal })) + return new Promise<{ + code: number | null + signal: NodeJS.Signals | null + stderr: string + }>(resolve => { + child.on('close', (code, closeSignal) => resolve({ code, signal: closeSignal, stderr })) }) } @@ -102,9 +144,91 @@ describe('built CLI', () => { const sandbox = await createSandbox() // Only configfile receives SIGINT here; in a terminal the script gets it too. - const result = await interrupt(sandbox, 'echo ready\nsleep 0.3\nexit 5\n', 'SIGINT') + const result = await interrupt(sandbox, 'echo ready\nsleep 1\nexit 5\n', 'SIGINT') + + expect(result).toMatchObject({ code: 5, signal: null }) + }) + + it('stops a deployment interrupted during a post_deploy script, even with --keep-going', async () => { + const sandbox = await createSandbox() + for (const name of ['a', 'b']) { + await sandbox.write(`home/dotfiles/files/${name}/rc`) + await sandbox.write( + `home/dotfiles/files/${name}/settings.json`, + JSON.stringify({ + files: [{ source_path: 'rc', target_path: `~/.${name}rc`, deploy: 'global' }], + post_deploy: 'long', + }), + ) + } + + // The script ends by itself with success: configfile was still asked to stop. + const result = await interrupt(sandbox, "trap '' INT\necho ready\nsleep 1\n", 'SIGINT', [ + 'modules', + 'deploy', + '--all', + '--keep-going', + ]) + + expect(result).toMatchObject({ code: 130, signal: null }) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(false) + }) + + /** Modules `a` and `b`, one global file each, with `settings` in both. */ + async function withModules(sandbox: Sandbox, settings: Record) { + for (const name of ['a', 'b']) { + await sandbox.write(`home/dotfiles/files/${name}/rc`) + await sandbox.write( + `home/dotfiles/files/${name}/settings.json`, + JSON.stringify({ + files: [{ source_path: 'rc', target_path: `~/.${name}rc`, deploy: 'global' }], + ...settings, + }), + ) + } + } + + it('deploys nothing more when interrupted during a pre_deploy script', async () => { + const sandbox = await createSandbox() + await withModules(sandbox, { pre_deploy: 'long' }) + + const result = await interrupt(sandbox, "trap '' INT\necho ready\nsleep 1\n", 'SIGINT', [ + 'modules', + 'deploy', + '--all', + '--keep-going', + ]) + + expect(result).toMatchObject({ code: 130, signal: null }) + expect(result.stderr).toContain('Interrupted. 2 modules were not deployed: a, b.') + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(false) + expect(existsSync(path.join(sandbox.home, '.brc'))).toBe(false) + }) + + it('stops an undeployment interrupted during a script, with the exit code of the signal', async () => { + const sandbox = await createSandbox() + await withModules(sandbox, { pre_undeploy: 'long' }) + await sandbox.configure() + spawnSync(...command(['modules', 'deploy', '--all']), { + cwd: sandbox.cwd, + env: { ...process.env, HOME: sandbox.home }, + }) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) + + const result = await interrupt( + sandbox, + "trap 'exit 0' TERM\necho ready\nwhile true; do sleep 0.05; done\n", + 'SIGTERM', + ['modules', 'undeploy', '--all'], + ) - expect(result).toEqual({ code: 5, signal: null }) + expect(result).toMatchObject({ code: 143, signal: null }) + expect(result.stderr).toContain('Interrupted. 2 modules were not undeployed: a, b.') + expect(result.stderr).toContain( + 'The files the repository no longer deploys were not checked.', + ) + expect(existsSync(path.join(sandbox.home, '.arc'))).toBe(true) }) it('forwards SIGTERM to the script', async () => { @@ -116,22 +240,18 @@ describe('built CLI', () => { 'SIGTERM', ) - expect(result).toEqual({ code: 7, signal: null }) + expect(result).toMatchObject({ code: 7, signal: null }) }) }) 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/) @@ -144,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', @@ -162,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, @@ -190,7 +310,10 @@ describe('built CLI', () => { expect(() => process.kill(gitPid, 0)).toThrow() }) - it('never loses a file when several configfile processes deploy at once', async () => { + // Eight processes: allow for slow, busy CI machines. + it('never loses a file when several configfile processes deploy at once', { + timeout: 30_000, + }, async () => { const sandbox = await createSandbox() await sandbox.configure() const modules = ['a', 'b', 'c', 'd'] @@ -208,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', @@ -226,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' }, }) @@ -246,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', @@ -295,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/lock-fallback.test.ts b/tests/lock-fallback.test.ts new file mode 100644 index 0000000..9ec9174 --- /dev/null +++ b/tests/lock-fallback.test.ts @@ -0,0 +1,38 @@ +// Where hard links are not supported (some network or FAT filesystems), the +// lock is created in two steps, as configfile 1.0 did. + +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { describe, expect, it, vi } from 'vitest' + +import { withLock } from '../src/lock.ts' +import { createSandbox } from './helpers.ts' + +vi.mock('node:fs/promises', async importOriginal => ({ + ...(await importOriginal()), + link: async () => { + throw Object.assign(new Error('operation not permitted, link'), { code: 'EPERM' }) + }, +})) + +describe('withLock without hard links', () => { + it('still takes the lock, holding the pid of its owner, then releases it', async () => { + const { home } = await createSandbox() + const lock = path.join(home, '.configfile/lock') + + const seen = await withLock(home, () => readFile(lock, 'utf8')) + + expect(seen).toBe(String(process.pid)) + await expect(readFile(lock, 'utf8')).rejects.toThrow() + }) + + it('still waits for another process holding it', async () => { + const { home, write } = await createSandbox() + await write('home/.configfile/lock', String(process.ppid)) + + await expect(withLock(home, async () => 'never', { maxWaitMs: 300 })).rejects.toThrow( + `Another configfile (process ${process.ppid}) is changing files`, + ) + }) +}) diff --git a/tests/lock.test.ts b/tests/lock.test.ts index 312308e..553b5c3 100644 --- a/tests/lock.test.ts +++ b/tests/lock.test.ts @@ -1,12 +1,31 @@ import { spawn } from 'node:child_process' -import { readFile } from 'node:fs/promises' +import { chmod, mkdir, readdir, readFile, rm, utimes, writeFile } from 'node:fs/promises' import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { CliError } from '../src/errors.ts' import { withLock } from '../src/lock.ts' import { createSandbox } from './helpers.ts' +/** The pid of a process that has ended. */ +async function deadPid(): Promise { + const dead = spawn(process.execPath, ['-e', '']) + await new Promise(resolve => dead.on('exit', resolve)) + return dead.pid as number +} + +/** A process that keeps running until it is killed (at the end of the test at the latest). */ +const living: ReturnType[] = [] +function livingPid(): number { + const other = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)']) + living.push(other) + return other.pid as number +} +afterEach(() => { + for (const other of living.splice(0)) other.kill() +}) + describe('withLock', () => { it('runs the task while holding ~/.configfile/lock, then releases it', async () => { const { home } = await createSandbox() @@ -25,6 +44,109 @@ describe('withLock', () => { await expect(withLock(home, async () => 'again')).resolves.toBe('again') }) + it('says why when its folder cannot be created', async () => { + const { home, write } = await createSandbox() + await write('home/.configfile', 'not a folder') + + const result = withLock(home, async () => 'done') + + await expect(result).rejects.toThrow(CliError) + await expect(result).rejects.toThrow(/^Cannot take the lock .*\.configfile\/lock: /) + }) + + // root writes to a folder whatever its permissions. + it.skipIf(process.getuid?.() === 0)('says why when the lock cannot be written', async () => { + const { home } = await createSandbox() + const folder = path.join(home, '.configfile') + await mkdir(folder, { mode: 0o555 }) + + try { + const result = withLock(home, async () => 'done') + + await expect(result).rejects.toThrow(CliError) + await expect(result).rejects.toThrow(/^Cannot take the lock .*: EACCES/) + } finally { + await chmod(folder, 0o755) + } + }) + + it('never takes over a lock without pid, which an older configfile may be writing', async () => { + const { home, write } = await createSandbox() + const lock = await write('home/.configfile/lock', '') + const old = new Date(Date.now() - 60_000) + await utimes(lock, old, old) + + await expect(withLock(home, async () => 'never', { maxWaitMs: 300 })).rejects.toThrow( + 'Another configfile is changing files', + ) + expect(await readFile(lock, 'utf8')).toBe('') + }) + + it('leaves only the lock in its folder, holding the pid of its owner', async () => { + const { home } = await createSandbox() + const folder = path.join(home, '.configfile') + + const seen = await withLock(home, async () => ({ + files: await readdir(folder), + lock: await readFile(path.join(folder, 'lock'), 'utf8'), + })) + + expect(seen).toEqual({ files: ['lock'], lock: String(process.pid) }) + expect(await readdir(folder)).toEqual([]) + }) + + // root reads any file whatever its permissions. + it.skipIf(process.getuid?.() === 0)( + 'never takes over a lock it cannot read: its owner cannot be checked', + async () => { + const { home, write } = await createSandbox() + const other = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)']) + const lock = await write('home/.configfile/lock', String(other.pid), 0o000) + try { + await expect(withLock(home, async () => 'never', { maxWaitMs: 300 })).rejects.toThrow( + /^Cannot take the lock .*: EACCES/, + ) + await chmod(lock, 0o600) + expect(await readFile(lock, 'utf8')).toBe(String(other.pid)) + } finally { + other.kill() + } + }, + ) + + // root removes files from a folder whatever its permissions. + it.skipIf(process.getuid?.() === 0)( + 'keeps the result of the task when the lock cannot be removed', + async () => { + const { home } = await createSandbox() + const folder = path.join(home, '.configfile') + + const left: string[] = [] + try { + const result = await withLock( + home, + async () => { + await chmod(folder, 0o555) + return 'done' + }, + { onLeftBehind: message => left.push(message) }, + ) + + expect(result).toBe('done') + expect(left).toEqual([ + expect.stringMatching( + /^.*\.configfile\/lock could not be removed \(.*EACCES.*\)\. Delete it if no configfile is running\.$/, + ), + ]) + } finally { + await chmod(folder, 0o755) + } + // The lock left behind names this process, which no longer holds it: taken over at once. + expect(await readFile(path.join(folder, 'lock'), 'utf8')).toBe(String(process.pid)) + await expect(withLock(home, async () => 'again', { maxWaitMs: 300 })).resolves.toBe('again') + }, + ) + it('takes over the lock of a process that no longer exists', async () => { const { home, write } = await createSandbox() const dead = spawn(process.execPath, ['-e', '']) @@ -49,6 +171,179 @@ describe('withLock', () => { } }) + it('says once that it waits, naming the process', async () => { + const { home, write } = await createSandbox() + const other = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)']) + const waits: string[] = [] + try { + await write('home/.configfile/lock', String(other.pid)) + setTimeout(() => other.kill(), 400) + + await withLock(home, async () => 'done', { onWait: who => waits.push(who) }) + } finally { + other.kill() + } + + expect(waits).toEqual([`another configfile (process ${other.pid})`]) + }) + + /** + * Runs `count` processes that each take the lock for a moment, and returns + * what they logged: `+` when one starts its task, `-` when it ends. + */ + async function race(home: string, count: number): Promise { + const log = path.join(home, 'log') + const go = path.join(home, 'go') + const ready = path.join(home, 'ready') + await mkdir(ready) + // Every process says it is ready, then waits for the same signal, so they + // all find the lock at once. + const script = + `import { appendFileSync, existsSync, writeFileSync } from 'node:fs'\n` + + `import { withLock } from ${JSON.stringify(new URL('../src/lock.ts', import.meta.url).href)}\n` + + `writeFileSync(${JSON.stringify(ready)} + '/' + process.pid, '')\n` + + `const until = Date.now() + 20000\n` + + `while (!existsSync(${JSON.stringify(go)}) && Date.now() < until) await new Promise(resolve => setTimeout(resolve, 1))\n` + + `await withLock(${JSON.stringify(home)}, async () => {\n` + + ` appendFileSync(${JSON.stringify(log)}, '+')\n` + + ` await new Promise(resolve => setTimeout(resolve, 50))\n` + + ` appendFileSync(${JSON.stringify(log)}, '-')\n` + + `}, { maxWaitMs: 20000 })\n` + const runs = Array.from({ length: count }, () => { + const child = spawn(process.execPath, ['--input-type=module', '-e', script]) + let stderr = '' + child.stderr.on('data', chunk => { + stderr += chunk + }) + return new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', code => + code === 0 ? resolve(code) : reject(new Error(`exit ${code}: ${stderr}`)), + ) + }) + }) + // Once they have all started. + const until = Date.now() + 20_000 + while ((await readdir(ready)).length < count && Date.now() < until) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + await writeFile(go, '') + await Promise.all(runs) + await rm(ready, { recursive: true }) + return readFile(log, 'utf8') + } + + it('never lets two processes hold the lock, also when they take over the same one', { + timeout: 30_000, + }, async () => { + const { home, write } = await createSandbox() + const dead = spawn(process.execPath, ['-e', '']) + await new Promise(resolve => dead.on('exit', resolve)) + // Every process finds the lock of a process that no longer exists. + await write('home/.configfile/lock', String(dead.pid)) + + const log = await race(home, 8) + + expect(log).toBe('+-'.repeat(8)) + // No lock and no draft left behind. + expect(await readdir(path.join(home, '.configfile'))).toEqual([]) + }) + + it('never lets two processes hold the lock when the takeover guard was left behind', { + timeout: 30_000, + }, async () => { + const { home, write } = await createSandbox() + const dead = await deadPid() + await write('home/.configfile/lock', String(dead)) + await write('home/.configfile/lock.takeover', String(dead)) + + const log = await race(home, 8) + + expect(log).toBe('+-'.repeat(8)) + expect(await readdir(path.join(home, '.configfile'))).toEqual([]) + }) + + it('waits for another process taking over the lock, and gives up in time', async () => { + const { home, write } = await createSandbox() + const other = livingPid() + await write('home/.configfile/lock', String(await deadPid())) + const guard = await write('home/.configfile/lock.takeover', String(other)) + + const started = Date.now() + await expect(withLock(home, async () => 'never', { maxWaitMs: 300 })).rejects.toThrow( + `Another configfile (process ${other}) is changing files. Try again when it is done, or delete ${guard} if no configfile is running.`, + ) + expect(Date.now() - started).toBeLessThan(2000) + expect(await readFile(guard, 'utf8')).toBe(String(other)) + }) + + it('takes over a takeover guard left behind, by another process or by this one', async () => { + for (const owner of [await deadPid(), process.pid]) { + const { home, write } = await createSandbox() + await write('home/.configfile/lock', String(await deadPid())) + await write('home/.configfile/lock.takeover', String(owner)) + + await expect(withLock(home, async () => 'done', { maxWaitMs: 300 })).resolves.toBe('done') + expect(await readdir(path.join(home, '.configfile'))).toEqual([]) + } + }) + + it('never removes a lock it does not hold any more, and says so', async () => { + const { home } = await createSandbox() + const lock = path.join(home, '.configfile/lock') + const other = livingPid() + const said: string[] = [] + + await withLock(home, async () => writeFile(lock, String(other)), { + onLeftBehind: message => said.push(message), + }) + // Left as it is: it names another process, still running. + expect(await readFile(lock, 'utf8')).toBe(String(other)) + await rm(lock) + await withLock(home, async () => rm(lock), { onLeftBehind: message => said.push(message) }) + + expect(said).toEqual([ + `${lock} was replaced while this configfile held it: another configfile may have changed files at the same time. Delete ${lock} if no configfile is running.`, + `${lock} was removed while this configfile held it: another configfile may have changed files at the same time.`, + ]) + }) + + // 600 calls: allow for slow, busy CI machines. + it('never runs two tasks of the same process at once', { timeout: 30_000 }, async () => { + const { home } = await createSandbox() + let running = 0 + let overlaps = 0 + + // Thirty callers, each taking the lock twenty times in a row. + await Promise.all( + Array.from({ length: 30 }, async () => { + for (let call = 0; call < 20; call++) { + await withLock(home, async () => { + running++ + if (running > 1) overlaps++ + await new Promise(resolve => setTimeout(resolve, 1)) + running-- + }) + } + }), + ) + + expect(overlaps).toBe(0) + }) + + it('removes the drafts left by processes that no longer exist', async () => { + const { home, write } = await createSandbox() + const uuid = '0f0e0d0c-0b0a-4908-8706-050403020100' + await write(`home/.configfile/lock.${await deadPid()}.${uuid}`, '1') + await write(`home/.configfile/lock.takeover.${await deadPid()}.${uuid}`, '1') + const living = `lock.${livingPid()}.${uuid}` + await write(`home/.configfile/${living}`, '1') + + await withLock(home, async () => 'done') + + expect(await readdir(path.join(home, '.configfile'))).toEqual([living]) + }) + it('serializes tasks', async () => { const { home } = await createSandbox() const events: string[] = [] diff --git a/tests/output.test.ts b/tests/output.test.ts new file mode 100644 index 0000000..6c8008b --- /dev/null +++ b/tests/output.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' + +import { plural } from '../src/output.ts' + +describe('plural', () => { + it('adds an s, or uses the plural given', () => { + expect(plural(1, 'file')).toBe('1 file') + expect(plural(0, 'file')).toBe('0 files') + expect(plural(2, 'file')).toBe('2 files') + expect(plural(1, 'entry', 'entries')).toBe('1 entry') + expect(plural(2, 'entry', 'entries')).toBe('2 entries') + }) +}) 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/tests/repository.test.ts b/tests/repository.test.ts index 2f39262..efdbe12 100644 --- a/tests/repository.test.ts +++ b/tests/repository.test.ts @@ -4,7 +4,13 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' import { CliError } from '../src/errors.ts' -import { listModules, listScripts, type Module, type Script } from '../src/repository.ts' +import { + listModules, + listScripts, + type Module, + type Script, + systemName, +} from '../src/repository.ts' import { createSandbox, type Sandbox } from './helpers.ts' const settings = (files: unknown) => JSON.stringify({ files }) @@ -30,10 +36,10 @@ async function scriptsOf( platform: NodeJS.Platform = 'linux', ) { const warnings: string[] = [] - const scripts = await listScripts(sandbox.repo, extensions, platform, { + const { scripts, otherSystems } = await listScripts(sandbox.repo, extensions, platform, { warn: message => warnings.push(message), }) - return { scripts, warnings } + return { scripts, otherSystems, warnings } } describe('listModules', () => { @@ -263,6 +269,29 @@ describe('listModules', () => { ]) }) + it('reads the scripts to run before and after the module is deployed', async () => { + const sandbox = await createSandbox() + const write = (module: string, content: unknown) => + sandbox.write(`home/dotfiles/files/${module}/settings.json`, JSON.stringify(content)) + await write('a', { files: [], pre_deploy: 'check', post_deploy: ' plugins ' }) + await write('b', { files: [] }) + await write('c', { files: [], pre_deploy: '', post_deploy: 42 }) + + const { modules } = await modulesOf(sandbox) + + expect(modules.map(usable)).toMatchObject([ + { hooks: { pre_deploy: 'check', post_deploy: 'plugins' }, hookProblems: {} }, + { hooks: {}, hookProblems: {} }, + { + hooks: {}, + hookProblems: { + pre_deploy: expect.stringContaining('"pre_deploy" is ignored'), + post_deploy: expect.stringContaining('"post_deploy" is ignored'), + }, + }, + ]) + }) + it('refuses a target that contains the repository', async () => { const sandbox = await createSandbox() // The repository lives in ~/.config/dot and a module targets ~/.config. @@ -327,6 +356,14 @@ describe('listModules', () => { }) }) +describe('systemName', () => { + it('names systems as script versions do', () => { + expect(systemName('darwin')).toBe('macos') + expect(systemName('linux')).toBe('linux') + expect(systemName('freebsd')).toBe('freebsd') + }) +}) + describe('listScripts', () => { it('lists every file by default, named up to the first dot (as in 0.3)', async () => { const sandbox = await createSandbox() diff --git a/tests/settings-schema.test.ts b/tests/settings-schema.test.ts new file mode 100644 index 0000000..eee88bc --- /dev/null +++ b/tests/settings-schema.test.ts @@ -0,0 +1,108 @@ +// The JSON Schema of settings.json (docs/public/schemas/settings.json, served at +// https://docs.configfile.sh/schemas/settings.json) must accept what configfile +// accepts and refuse what it refuses: each example below goes through both. + +import { readFile } from 'node:fs/promises' + +import { Ajv2020 } from 'ajv/dist/2020.js' +import { describe, expect, it } from 'vitest' + +import { listModules } from '../src/repository.ts' +import { createSandbox } from './helpers.ts' + +const schemaFile = new URL('../docs/public/schemas/settings.json', import.meta.url) +const schema = JSON.parse(await readFile(schemaFile, 'utf8')) +const validate = new Ajv2020({ allErrors: true }).compile(schema) + +/** Whether configfile uses every entry of this settings.json, with no error. */ +async function configfileAccepts(settings: unknown): Promise { + const sandbox = await createSandbox() + await sandbox.write('home/dotfiles/files/zsh/zshrc') + await sandbox.write('home/dotfiles/files/zsh/settings.json', JSON.stringify(settings)) + + const [module] = await listModules(sandbox.repo, { ...sandbox, warn: () => {} }) + if (module == null) throw new Error('no module') + return ( + module.error == null && + module.invalidEntries.length === 0 && + Object.keys(module.hookProblems).length === 0 + ) +} + +const zshrc = { source_path: 'zshrc', target_path: '~/.zshrc' } + +const accepted: [string, unknown][] = [ + ['a global file', { files: [{ ...zshrc, deploy: 'global' }] }], + ['a local file', { files: [{ source_path: 'zshrc', target_path: '.zshrc', deploy: 'local' }] }], + ['a file never deployed, without target', { files: [{ source_path: 'zshrc', deploy: 'none' }] }], + ['a file never deployed, without source', { files: [{ deploy: 'none' }] }], + ['a file without strategy (deployed nowhere, with a warning)', { files: [zshrc] }], + ['the deprecated "global": true', { files: [{ ...zshrc, global: true }] }], + ['the deprecated "global": false', { files: [{ ...zshrc, global: false }] }], + ['the deprecated 0.3 list format', [{ ...zshrc, deploy: 'global' }]], + ['no files', { files: [] }], + ['scripts to run when deployed', { files: [], pre_deploy: 'check', post_deploy: 'setup' }], + ['scripts to run when undeployed', { files: [], pre_undeploy: 'stop', post_undeploy: 'clean' }], + ['a "$schema" key', { $schema: 'https://docs.configfile.sh/schemas/settings.json', files: [] }], +] + +const refused: [string, unknown][] = [ + ['no "files" key', {}], + ['"files" that is not a list', { files: {} }], + ['an entry that is not an object', { files: ['zshrc'] }], + ['an unknown strategy', { files: [{ ...zshrc, deploy: 'linked' }] }], + ['both "deploy" and "global"', { files: [{ ...zshrc, deploy: 'global', global: true }] }], + ['a "global" that is not a boolean', { files: [{ ...zshrc, global: 'yes' }] }], + ['a deployed file without source', { files: [{ target_path: '~/.zshrc', deploy: 'global' }] }], + ['an empty source', { files: [{ ...zshrc, source_path: ' ', deploy: 'global' }] }], + ['a global file without target', { files: [{ source_path: 'zshrc', deploy: 'global' }] }], + ['a local file without target', { files: [{ source_path: 'zshrc', deploy: 'local' }] }], + ['an empty target', { files: [{ ...zshrc, target_path: '', deploy: 'global' }] }], + ['a "post_deploy" that is not a string', { files: [], post_deploy: ['setup'] }], + ['an empty "post_deploy"', { files: [], post_deploy: ' ' }], + ['a "pre_deploy" that is not a string', { files: [], pre_deploy: 42 }], + ['an empty "post_undeploy"', { files: [], post_undeploy: '' }], + ['a source that is not a string', { files: [{ ...zshrc, source_path: 42, deploy: 'global' }] }], +] + +// Stricter on purpose, to catch mistakes in an editor: configfile ignores these. +const refusedByTheSchemaOnly: [string, unknown][] = [ + ['an unknown key in an entry (a typo)', { files: [{ ...zshrc, deploy: 'global', target: 'x' }] }], + ['an unknown top-level key', { files: [], file: [] }], + ['a wrong type in an entry never deployed', { files: [{ source_path: 42, deploy: 'none' }] }], +] + +describe('settings.json schema', () => { + it.each(accepted)('accepts %s, as configfile does', async (_, settings) => { + expect(await configfileAccepts(settings)).toBe(true) + expect(validate(settings), JSON.stringify(validate.errors)).toBe(true) + }) + + it.each(refused)('refuses %s, as configfile does', async (_, settings) => { + expect(await configfileAccepts(settings)).toBe(false) + expect(validate(settings)).toBe(false) + }) + + it.each(refusedByTheSchemaOnly)('refuses %s, which configfile ignores', async (_, settings) => { + expect(await configfileAccepts(settings)).toBe(true) + expect(validate(settings)).toBe(false) + }) + + it('describes the keys the modules reference documents', async () => { + const page = await readFile( + new URL('../docs/src/content/docs/reference/modules.md', import.meta.url), + 'utf8', + ) + const documented = [...page.matchAll(/^\| `(\w+)` \|/gm)].map(match => match[1]) + + // The keys of an entry, and those of the file itself next to "files". + const { $schema, files, ...settings } = schema.oneOf[0].properties + const described = [...Object.keys(schema.$defs.entry.properties), ...Object.keys(settings)] + + expect(described.sort()).toEqual(documented.sort()) + }) + + it('is served where its $id says, from the documentation site', () => { + expect(schema.$id).toBe('https://docs.configfile.sh/schemas/settings.json') + }) +}) 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() diff --git a/tools/docs-commands.ts b/tools/docs-commands.ts new file mode 100644 index 0000000..97eceda --- /dev/null +++ b/tools/docs-commands.ts @@ -0,0 +1,192 @@ +// Generates the usage, arguments and options of each command in the commands +// reference (docs/src/content/docs/reference/commands.md) from the commands' +// own definitions, so that the page cannot drift from the code. +// +// node tools/docs-commands.ts updates the page +// node tools/docs-commands.ts --check fails if the page is out of date +// +// The page holds the prose; each command has a block between +// +// +// which this script fills. A new command needs its block added by hand first. +// tests/docs.test.ts makes the same comparison as --check. + +import { readFileSync, writeFileSync } from 'node:fs' +import { Writable } from 'node:stream' +import { fileURLToPath } from 'node:url' + +import type { Command } from 'commander' + +import type { Context } from '../src/context.ts' +import { History } from '../src/history.ts' +import { Output } from '../src/output.ts' +import { buildProgram } from '../src/program.ts' +import { nonInteractivePrompts } from '../src/prompts.ts' + +export const COMMANDS_PAGE = fileURLToPath( + new URL('../docs/src/content/docs/reference/commands.md', import.meta.url), +) + +const MARKER = //g + +interface Block { + name: string + /** Offsets of the opening marker's end and of the closing marker's start. */ + contentStart: number + contentEnd: number +} + +/** The page with every generated block rewritten from the commands' definitions. */ +export function renderCommandsPage(page: string): string { + const commands = new Map(listCommands(buildProgram(inertContext())).map(c => [path(c), c])) + const blocks = findBlocks(page) + + const problems: string[] = [] + const names = blocks.map(block => block.name) + for (const name of commands.keys()) { + if (!names.includes(name)) problems.push(`"${name}" has no block.`) + } + for (const [index, name] of names.entries()) { + if (!commands.has(name)) problems.push(`"${name}" is not a command.`) + else if (names.indexOf(name) !== index) problems.push(`"${name}" has more than one block.`) + } + for (const command of commands.values()) problems.push(...undocumented(command)) + if (problems.length > 0) throw new Error(problems.join('\n')) + + // The page's own line endings, so that a CRLF checkout stays as it is. + const eol = page.includes('\r\n') ? '\r\n' : '\n' + let result = '' + let offset = 0 + for (const block of blocks) { + const generated = describe(commands.get(block.name) as Command).replaceAll('\n', eol) + result += page.slice(offset, block.contentStart) + eol + generated + offset = block.contentEnd + } + return result + page.slice(offset) +} + +/** The generated blocks of the page, in order; throws on a malformed marker. */ +function findBlocks(page: string): Block[] { + const blocks: Block[] = [] + let open: { name: string; end: number } | null = null + + for (const match of page.matchAll(MARKER)) { + const [marker, closing, rawName] = match + const start = match.index + if (closing == null) { + const name = (rawName ?? '').trim() + if (name === '') throw new Error(`A marker names no command.`) + if (open != null) throw new Error(`The block of "${open.name}" has no .`) + open = { name, end: start + marker.length } + } else { + if (open == null) throw new Error('A marker closes no block.') + blocks.push({ name: open.name, contentStart: open.end, contentEnd: start }) + open = null + } + } + if (open != null) throw new Error(`The block of "${open.name}" has no .`) + return blocks +} + +/** Every command users run, nested ones included, but not their groups or `help`. */ +function listCommands(program: Command): Command[] { + return program.commands.flatMap(command => + command.commands.length > 0 ? listCommands(command) : [command], + ) +} + +function path(command: Command): string { + const names: string[] = [] + for (let current: Command | null = command; current != null; current = current.parent) { + names.unshift(current.name()) + } + return names.join(' ') +} + +/** Arguments and options without a description, which the page could not explain. */ +function undocumented(command: Command): string[] { + return [ + ...command.registeredArguments + .filter(arg => arg.description.trim() === '') + .map(arg => `Argument "${arg.name()}" of "${path(command)}" has no description.`), + ...command.options + .filter(option => option.description.trim() === '') + .map(option => `Option "${option.flags}" of "${path(command)}" has no description.`), + ] +} + +function describe(command: Command): string { + const help = command.createHelp() + // All arguments, documented or not: commander's visibleArguments hides them all + // when none has a description. + const args = command.registeredArguments + // --help is shown once for all commands, in the page's introduction. + const options = help.visibleOptions(command).filter(option => option.long !== '--help') + + // As --help prints it, without the alias ("deploy|d") and without + // "[options]" when --help is the only option. + const alias = command.aliases()[0] + let usage = help.commandUsage(command) + if (alias != null) usage = usage.replace(`${command.name()}|${alias}`, command.name()) + if (options.length === 0) usage = usage.replace(' [options]', '') + const lines = ['```sh', usage, '```', ''] + + const short = shortForm(command) + if (short != null) lines.push(`Short form: \`${short}\`.`, '') + + const rows = [ + ...args.map(arg => [help.argumentTerm(arg), help.argumentDescription(arg)]), + ...options.map(option => [help.optionTerm(option), help.optionDescription(option)]), + ] + if (rows.length > 0) { + lines.push('| Argument or option | Description |', '| --- | --- |') + for (const [term = '', description = ''] of rows) { + lines.push(`| \`${cell(term)}\` | ${cell(description)} |`) + } + lines.push('') + } + return lines.join('\n') +} + +/** `configfile m d` for `configfile modules deploy`, or null without aliases. */ +function shortForm(command: Command): string | null { + const names: string[] = [] + let aliased = false + for (let current: Command | null = command; current != null; current = current.parent) { + const alias = current.parent == null ? undefined : current.aliases()[0] + if (alias != null) aliased = true + names.unshift(alias ?? current.name()) + } + return aliased ? names.join(' ') : null +} + +function cell(text: string): string { + return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ') +} + +/** A context for building the program only: nothing is read, written or asked. */ +function inertContext(): Context { + const sink = new Writable({ write: (_chunk, _encoding, done) => done() }) + return { + home: '/nonexistent', + cwd: '/nonexistent', + platform: 'linux', + output: new Output(sink, sink), + prompts: nonInteractivePrompts, + history: new History('/nonexistent'), + } +} + +if (import.meta.main) { + const page = readFileSync(COMMANDS_PAGE, 'utf8') + const rendered = renderCommandsPage(page) + if (rendered === page) { + console.log(`${COMMANDS_PAGE} is up to date.`) + } else if (process.argv.includes('--check')) { + console.error(`${COMMANDS_PAGE} is out of date: run "pnpm docs:commands".`) + process.exit(1) + } else { + writeFileSync(COMMANDS_PAGE, rendered) + console.log(`Updated ${COMMANDS_PAGE}.`) + } +} diff --git a/tsconfig.json b/tsconfig.json index 4a0e5c6..6346d98 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "tools/**/*.ts", "vitest.config.ts"] }