diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..0c461f3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,91 @@ +# Verification gate for everything that is not a `main` push. +# +# `main` is covered by publish-snapshot.yml, which runs the identical gate +# before it publishes — running both here would just duplicate the work. + +name: CI + +on: + pull_request: + push: + branches-ignore: + - main + tags-ignore: + - '**' + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up pnpm + # Version comes from `packageManager` in the root package.json. + uses: pnpm/action-setup@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Locate the pnpm store + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Restore the pnpm store + # A runner without a cache server should be slow, not broken. + continue-on-error: true + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint:ci + + - name: Typecheck + run: pnpm run typecheck + + - name: Test with coverage + # Fails the job below 85% branch coverage — see the `thresholds` block + # in each package's vitest.config.ts. + run: pnpm run test:coverage + + - name: Summarise coverage + # Reporting only — the gate is the step above. + if: always() + continue-on-error: true + run: pnpm run coverage:summary + + - name: Build + run: pnpm run build + + - name: Check the published file lists + # Scripts are off because the build already ran; `prepack` would only + # repeat it. Catches a `files`/`bin` entry that no longer exists. + run: | + set -euo pipefail + for pkg in packages/*/; do + echo "::group::npm pack $pkg" + (cd "$pkg" && npm pack --dry-run --ignore-scripts) + echo "::endgroup::" + done + + - name: Upload coverage reports + if: always() + continue-on-error: true + uses: actions/upload-artifact@v3 + with: + name: coverage + path: packages/*/coverage + retention-days: 7 diff --git a/.gitea/workflows/publish-snapshot.yml b/.gitea/workflows/publish-snapshot.yml new file mode 100644 index 0000000..4d03077 --- /dev/null +++ b/.gitea/workflows/publish-snapshot.yml @@ -0,0 +1,124 @@ +# Every commit that lands on `main` publishes a prerelease of both packages to +# the Gitea npm registry under the `main` dist-tag: +# +# pnpm add @bitstack/nopy@main +# +# The verification gate runs here rather than in ci.yml so a snapshot can never +# be published from a red `main`. Versions are derived, never committed — +# releases to npmjs are cut by hand via a tag (see release.yml). + +name: Publish snapshot + +on: + push: + branches: + - main + +concurrency: + group: snapshot-${{ github.ref }} + cancel-in-progress: false + +jobs: + snapshot: + runs-on: ubuntu-latest + + env: + # e.g. https://gitea.example.com/api/packages/BitSquare/npm/ + REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/npm/ + # GITEA_TOKEN is injected automatically; override with a PAT that carries + # `write:package` if the automatic token is not scoped for the registry. + REGISTRY_TOKEN: ${{ secrets.GITEA_NPM_TOKEN || secrets.GITEA_TOKEN }} + NPMRC: ${{ github.workspace }}/.npmrc-gitea + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up pnpm + # Version comes from `packageManager` in the root package.json. + uses: pnpm/action-setup@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Locate the pnpm store + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Restore the pnpm store + continue-on-error: true + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint:ci + + - name: Typecheck + run: pnpm run typecheck + + - name: Test with coverage + run: pnpm run test:coverage + + - name: Summarise coverage + # Reporting only — the gate is the step above. + if: always() + continue-on-error: true + run: pnpm run coverage:summary + + - name: Build + # Explicit, so the publish step can skip lifecycle scripts entirely. + run: pnpm run build + + - name: Authenticate against the Gitea registry + run: | + set -euo pipefail + if [ -z "${REGISTRY_TOKEN}" ]; then + echo "::error::No registry token. Add a GITEA_NPM_TOKEN secret with write:package scope." + exit 1 + fi + install -m 600 /dev/null "$NPMRC" + { + printf '@bitstack:registry=%s\n' "$REGISTRY" + printf '//%s:_authToken=%s\n' "${REGISTRY#*://}" "$REGISTRY_TOKEN" + } >> "$NPMRC" + + - name: Publish snapshots + run: | + set -euo pipefail + export npm_config_userconfig="$NPMRC" + : "${GITHUB_STEP_SUMMARY:=/dev/null}" + short_sha=$(git rev-parse --short=7 HEAD) + + for dir in packages/*/; do + name=$(node -p "require('./${dir}package.json').name") + base=$(node -p "require('./${dir}package.json').version") + # `g` prefix keeps the identifier a valid semver one even when the + # abbreviated sha happens to be all digits. + version="${base}-main.${{ github.run_number }}.g${short_sha}" + + echo "::group::${name}@${version}" + if npm view "${name}@${version}" version --registry "$REGISTRY" >/dev/null 2>&1; then + echo "Already published — skipping (this is a re-run of the same workflow)." + else + ( + cd "$dir" + npm pkg set "version=${version}" + npm publish --ignore-scripts --tag main --registry "$REGISTRY" + ) + fi + echo "::endgroup::" + + echo "- \`pnpm add ${name}@${version}\`" >> "$GITHUB_STEP_SUMMARY" + done + + - name: Remove the registry credentials + if: always() + run: rm -f "$NPMRC" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..44388c2 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,243 @@ +# Tag-driven release of a single package. +# +# git tag nopy-v1.2.0 && git push origin nopy-v1.2.0 +# git tag keyman-v1.2.0 && git push origin keyman-v1.2.0 +# +# The tag is the source of truth for *which* package ships; package.json is the +# source of truth for the version, and the two must agree or the run fails. +# A version with a prerelease part (1.2.0-rc.1) publishes under `next` instead +# of `latest`. +# +# Required secrets: +# NPM_TOKEN npmjs automation token with publish rights on @bitstack +# GITEA_NPM_TOKEN optional; PAT with write:package if GITEA_TOKEN is not enough + +name: Release + +on: + push: + tags: + - '*-v*' + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + + env: + GITEA_REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/npm/ + GITEA_REGISTRY_TOKEN: ${{ secrets.GITEA_NPM_TOKEN || secrets.GITEA_TOKEN }} + NPMJS_REGISTRY: https://registry.npmjs.org/ + NPMJS_TOKEN: ${{ secrets.NPM_TOKEN }} + NPMRC: ${{ github.workspace }}/.npmrc-release + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Resolve the release from the tag + id: target + run: | + set -euo pipefail + tag="${GITHUB_REF#refs/tags/}" + pkg="${tag%-v*}" + version="${tag##*-v}" + dir="packages/${pkg}" + + if [ ! -f "${dir}/package.json" ]; then + echo "::error::Tag '${tag}' names package '${pkg}', but ${dir}/package.json does not exist." + exit 1 + fi + + declared=$(node -p "require('./${dir}/package.json').version") + if [ "$declared" != "$version" ]; then + echo "::error::Tag '${tag}' asks for ${version}, but ${dir}/package.json declares ${declared}. Bump the manifest and re-tag." + exit 1 + fi + + name=$(node -p "require('./${dir}/package.json').name") + case "$version" in + *-*) dist_tag=next ;; + *) dist_tag=latest ;; + esac + + { + echo "tag=${tag}" + echo "dir=${dir}" + echo "name=${name}" + echo "version=${version}" + echo "dist_tag=${dist_tag}" + } >> "$GITHUB_OUTPUT" + + echo "Releasing ${name}@${version} from ${dir} as '${dist_tag}'." + + - name: Check the required secrets are present + run: | + set -euo pipefail + missing=0 + [ -n "${NPMJS_TOKEN}" ] || { echo "::error::NPM_TOKEN secret is not set."; missing=1; } + [ -n "${GITEA_REGISTRY_TOKEN}" ] || { echo "::error::No Gitea registry token available."; missing=1; } + exit "$missing" + + - name: Set up pnpm + # Version comes from `packageManager` in the root package.json. + uses: pnpm/action-setup@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Locate the pnpm store + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Restore the pnpm store + continue-on-error: true + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint:ci + + - name: Typecheck + run: pnpm run typecheck + + - name: Test with coverage + run: pnpm run test:coverage + + - name: Build + # Explicit, so the publish steps can skip lifecycle scripts entirely. + run: pnpm run build + + - name: Publish to the Gitea registry + env: + NAME: ${{ steps.target.outputs.name }} + VERSION: ${{ steps.target.outputs.version }} + DIST_TAG: ${{ steps.target.outputs.dist_tag }} + DIR: ${{ steps.target.outputs.dir }} + run: | + set -euo pipefail + install -m 600 /dev/null "$NPMRC" + { + printf '@bitstack:registry=%s\n' "$GITEA_REGISTRY" + printf '//%s:_authToken=%s\n' "${GITEA_REGISTRY#*://}" "$GITEA_REGISTRY_TOKEN" + } >> "$NPMRC" + export npm_config_userconfig="$NPMRC" + + if npm view "${NAME}@${VERSION}" version --registry "$GITEA_REGISTRY" >/dev/null 2>&1; then + echo "${NAME}@${VERSION} is already on Gitea — skipping." + else + (cd "$DIR" && npm publish --ignore-scripts --tag "$DIST_TAG" --registry "$GITEA_REGISTRY") + fi + + - name: Publish to npmjs + env: + NAME: ${{ steps.target.outputs.name }} + VERSION: ${{ steps.target.outputs.version }} + DIST_TAG: ${{ steps.target.outputs.dist_tag }} + DIR: ${{ steps.target.outputs.dir }} + run: | + set -euo pipefail + install -m 600 /dev/null "$NPMRC" + { + printf '@bitstack:registry=%s\n' "$NPMJS_REGISTRY" + printf '//%s:_authToken=%s\n' "${NPMJS_REGISTRY#*://}" "$NPMJS_TOKEN" + } >> "$NPMRC" + export npm_config_userconfig="$NPMRC" + + if npm view "${NAME}@${VERSION}" version --registry "$NPMJS_REGISTRY" >/dev/null 2>&1; then + echo "${NAME}@${VERSION} is already on npmjs — skipping." + else + # No --provenance: that needs GitHub Actions OIDC, which Gitea has no + # equivalent for. + (cd "$DIR" && npm publish --ignore-scripts --tag "$DIST_TAG" --access public --registry "$NPMJS_REGISTRY") + fi + + - name: Remove the registry credentials + if: always() + run: rm -f "$NPMRC" + + - name: Create the Gitea release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + TAG: ${{ steps.target.outputs.tag }} + NAME: ${{ steps.target.outputs.name }} + VERSION: ${{ steps.target.outputs.version }} + DIST_TAG: ${{ steps.target.outputs.dist_tag }} + DIR: ${{ steps.target.outputs.dir }} + run: | + set -euo pipefail + api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases" + + status=$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${GITEA_TOKEN}" "${api}/tags/${TAG}") + if [ "$status" = "200" ]; then + echo "A release for ${TAG} already exists — leaving it alone." + exit 0 + fi + + # The section of the hand-written changelog that names this version. + notes="" + if [ -f "${DIR}/CHANGELOG.md" ]; then + notes=$(awk -v v="$VERSION" ' + /^## / { if (found) exit; if (index($0, v)) { found = 1; next } } + found { print } + ' "${DIR}/CHANGELOG.md") + fi + export NOTES="$notes" + + node -e ' + const { NAME, VERSION, TAG, DIST_TAG, NOTES } = process.env; + const install = + DIST_TAG === "latest" + ? `npm install -g ${NAME}` + : `npm install -g ${NAME}@${VERSION}`; + const body = [ + NOTES.trim(), + "", + "```sh", + install, + "```", + ].join("\n").trim(); + console.log(JSON.stringify({ + tag_name: TAG, + name: `${NAME} v${VERSION}`, + body, + draft: false, + prerelease: DIST_TAG !== "latest", + })); + ' > release.json + + curl -sS -f -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + --data-binary @release.json \ + "$api" + rm -f release.json + + - name: Summarise + # Reporting only; never the reason a green release goes red. + continue-on-error: true + env: + NAME: ${{ steps.target.outputs.name }} + VERSION: ${{ steps.target.outputs.version }} + DIST_TAG: ${{ steps.target.outputs.dist_tag }} + run: | + set -euo pipefail + : "${GITHUB_STEP_SUMMARY:=/dev/null}" + { + echo "### Released \`${NAME}@${VERSION}\` (\`${DIST_TAG}\`)" + echo "" + echo "- npmjs: \`npm install -g ${NAME}@${VERSION}\`" + echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --registry ${GITEA_REGISTRY}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index a2b5508..354721f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,18 @@ cache vault/tmp age.key +dist +coverage tsconfig.tsbuildinfo +*.tsbuildinfo *.log .nopy.history.json *.img *.img.gz + +# Registry credentials and payloads written into the workspace by the publish +# workflows — never wanted in a commit. +.npmrc +.npmrc-* +release.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9536813 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 bitsquare + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.PUBLISH.md b/README.PUBLISH.md new file mode 100644 index 0000000..7532ee5 --- /dev/null +++ b/README.PUBLISH.md @@ -0,0 +1,402 @@ +# Publishing + +Everything about how the packages in this repository are verified, versioned and +shipped. If you only want to cut a release, jump to +[Cutting a release](#cutting-a-release). + +- [What ships](#what-ships) +- [The three workflows](#the-three-workflows) +- [The verification gate](#the-verification-gate) +- [Versions and dist-tags](#versions-and-dist-tags) +- [Snapshots](#snapshots) +- [Cutting a release](#cutting-a-release) +- [Changelogs and release notes](#changelogs-and-release-notes) +- [Secrets](#secrets) +- [Registry authentication in the workflows](#registry-authentication-in-the-workflows) +- [Installing the packages](#installing-the-packages) +- [Design decisions](#design-decisions) +- [Checking things locally](#checking-things-locally) +- [Troubleshooting](#troubleshooting) +- [Recovering from a bad publish](#recovering-from-a-bad-publish) + +## What ships + +| Directory | Package | Binary | +| ----------------- | ------------------ | -------- | +| `packages/nopy` | `@bitstack/nopy` | `nopy` | +| `packages/keyman` | `@bitstack/keyman` | `keyman` | + +Both are ESM, both declare `engines.node >= 22`, and both expose a single +executable through `bin`, so `npm install -g` puts `nopy` / `keyman` on the +`PATH`. `cubes/` is not a package and is never published. + +The tarball contents are pinned by `files: ["dist", "README.md", "LICENSE"]` — +sources and tests are not shipped. `publishConfig.access: "public"` is what makes +a scoped package publishable to npmjs without an extra flag; the workflows pass +`--access public` anyway. + +Versions and changelogs are maintained **by hand**. Nothing in CI commits a +version bump, opens a release PR, or pushes a tag. A release happens because you +tagged a commit whose manifest already carries the version you want. + +## The three workflows + +All three live in [`.gitea/workflows`](.gitea/workflows) and run on the +`ubuntu-latest` runner label. + +| Workflow | Trigger | Publishes | +| ---------------------- | -------------------------------- | ------------------------------------------ | +| `ci.yml` | pull requests, non-`main` pushes | nothing | +| `publish-snapshot.yml` | pushes to `main` | **both** packages → Gitea, tag `main` | +| `release.yml` | tags matching `*-v*` | **one** package → Gitea **and** npmjs | + +`ci.yml` explicitly excludes `main` and all tags (`branches-ignore` + +`tags-ignore`). That is not an oversight: both publish workflows carry the full +gate themselves, so excluding them here means no commit is ever verified twice, +while nothing is ever published from an unverified tree. + +Concurrency: `ci.yml` cancels superseded runs for the same ref +(`cancel-in-progress: true`); the two publish workflows never cancel, because a +half-finished publish is worse than a slow queue. + +### `ci.yml` + +``` +checkout → pnpm → node → pnpm store cache → install + → lint:ci → typecheck → test:coverage → coverage summary + → build → npm pack --dry-run (per package) → upload coverage +``` + +The `npm pack --dry-run --ignore-scripts` step prints the exact file list that +would be published. It is there to catch a `files` or `bin` entry pointing at +something the build no longer produces — a failure that would otherwise only +surface after the version is already on a registry and immutable. + +Coverage HTML/JSON reports are uploaded as a `coverage` artifact with a 7-day +retention. Both the artifact upload and the store cache are +`continue-on-error: true`, so a runner without a cache or artifact server gets +slower CI rather than broken CI. + +### `publish-snapshot.yml` + +``` +checkout → pnpm → node → cache → install + → lint:ci → typecheck → test:coverage → coverage summary → build + → write .npmrc → publish both packages → delete .npmrc +``` + +One job, no `needs:` barrier, so install and build happen exactly once and +nothing has to be passed between jobs as an artifact. + +### `release.yml` + +``` +checkout → resolve tag → check secrets + → pnpm → node → cache → install + → lint:ci → typecheck → test:coverage → build + → publish to Gitea → publish to npmjs → delete .npmrc + → create the Gitea release → step summary +``` + +Tag resolution and the secret check run **before** anything is installed or +built, so a malformed tag or a missing token fails in seconds instead of after +the whole gate. + +## The verification gate + +The same three commands guard every path into a registry: + +| Step | Command | +| --------------- | ----------------------- | +| Lint | `pnpm run lint:ci` | +| Typecheck | `pnpm run typecheck` | +| Test + coverage | `pnpm run test:coverage`| + +Coverage is a hard failure below **85 % branches** (and 85 % functions, 80 % +lines, 80 % statements). The thresholds live in `coverage.thresholds` in each +package's `vitest.config.ts`, not in a CI-only flag, which means +`pnpm run test:coverage` fails identically on a laptop, in the `pre-push` hook, +and on the runner. Barrel files and CLI argv wiring are excluded from +measurement; everything with behaviour in it is not. + +The `pre-push` hook installed by `simple-git-hooks` runs exactly this gate, so a +push that survives locally will not surprise you on the runner. Bypass with +`SKIP_SIMPLE_GIT_HOOKS=1` when you must — CI will still catch it. + +`pnpm run coverage:summary` renders the per-package `json-summary` reports as a +Markdown table. In CI it appends to `$GITHUB_STEP_SUMMARY` so the numbers show up +on the run page without opening the log. It is reporting only — `if: always()` +and `continue-on-error: true` — and can never be the reason a run goes red. + +## Versions and dist-tags + +| Source | Version | Registry | dist-tag | +| ----------------------------------------- | ----------------------------- | ------------ | -------- | +| push to `main` | `1.0.0-main.42.g736c012` | Gitea | `main` | +| tag `nopy-v1.2.0` | `1.2.0` | Gitea, npmjs | `latest` | +| tag `nopy-v1.2.0-rc.1` | `1.2.0-rc.1` | Gitea, npmjs | `next` | + +The rule for the dist-tag is mechanical: a version containing a prerelease part +(anything with a `-` in it) goes out as `next` and is marked as a prerelease on +the Gitea release; anything else goes out as `latest`. There is no way to publish +a prerelease over `latest` by accident. + +## Snapshots + +Every commit that lands on `main` publishes both packages to the Gitea registry, +versioned as: + +``` +-main..g +``` + +for example `1.0.0-main.42.g736c012`. The `g` prefix keeps the identifier valid +semver even when the abbreviated sha happens to be all digits. The run number is +monotonic, so every push produces a version that has never existed before. + +```sh +pnpm add @bitstack/nopy@main +``` + +Snapshots never reach npmjs and never move `latest`. The version is written into +the manifest on the runner with `npm pkg set` immediately before publishing; that +edit is discarded with the workspace and is never committed. + +## Cutting a release + +1. Bump `version` in `packages//package.json`. +2. Add a changelog entry (see below). +3. Commit, merge to `main`, and let the snapshot workflow go green. +4. Tag that commit and push the tag: + + ```sh + git tag nopy-v1.2.0 + git push origin nopy-v1.2.0 + ``` + +The tag name is `-v` — the directory under `packages/`, not +the npm name. `nopy-v1.2.0`, not `@bitstack/nopy-v1.2.0`. + +The tag decides **which** package ships; `package.json` decides the **version**. +The workflow re-reads the manifest and refuses to continue if the two disagree: + +``` +::error::Tag 'nopy-v1.2.0' asks for 1.2.0, but packages/nopy/package.json +declares 1.1.0. Bump the manifest and re-tag. +``` + +An unknown package name fails the same way. Both checks run before install, so a +typo costs you seconds. + +Tagging one package does not touch the other. Two packages at the same version is +a coincidence, not a requirement. + +What a successful run leaves behind: + +- `@bitstack/@` on the Gitea registry +- the same tarball on npmjs, public, under `latest` or `next` +- a Gitea release on the tag, with notes and an install snippet +- a step summary with both install commands + +## Changelogs and release notes + +Neither package has a `CHANGELOG.md` yet. Without one, the Gitea release body is +just the install snippet — nothing fails. + +When you add one, `release.yml` extracts the section for the version being +released. The parser is deliberately dumb: it looks for the first `## ` heading +whose text contains the version string, and takes every line until the next `## ` +heading. Any of these work: + +```markdown +## 1.2.0 + +## [1.2.0] - 2026-07-27 + +## v1.2.0 — Faster inventory parsing +``` + +Version strings that are prefixes of each other are the one thing to watch: with +both `## 1.2.0` and `## 1.2.0-rc.1` in the file, releasing `1.2.0` matches +whichever heading comes first. Keep the newest at the top, as usual, and this +resolves itself. + +## Secrets + +Configure under **Settings → Actions → Secrets**, on the repository or on the +organisation to share across repos. + +| Secret | Required | Purpose | +| ----------------- | -------- | ---------------------------------------------------------------- | +| `NPM_TOKEN` | yes | npmjs token with publish rights on the `@bitstack` scope | +| `GITEA_NPM_TOKEN` | maybe | Gitea PAT with `write:package`, if the built-in token is not enough | + +`GITEA_TOKEN` is injected into every run by Gitea itself, and the workflows fall +back to it via `${{ secrets.GITEA_NPM_TOKEN || secrets.GITEA_TOKEN }}`. Add +`GITEA_NPM_TOKEN` only if the automatic token turns out not to carry +package-write scope on your instance — the failure mode is a `401` or `403` from +the registry at the publish step, with the gate already green. + +Create the npmjs token as an **automation** token, or as a granular token scoped +to `@bitstack/*` with read-and-write permission. Classic *publish* tokens tied to +2FA prompt interactively and cannot work on a runner. + +`release.yml` refuses to start if either token is missing, and says which one. + +> npm `--provenance` is deliberately not used. It requires GitHub Actions OIDC, +> which Gitea has no equivalent for; passing the flag would only fail the +> publish. Releases are therefore unsigned in the provenance sense — the audit +> trail is the tag, the run log, and the Gitea release. + +## Registry authentication in the workflows + +`release.yml` has to talk to two different registries about the same `@bitstack` +scope inside one job. It does that without ever mutating `~/.npmrc`: + +- each publish step writes its own credentials file, created with + `install -m 600 /dev/null` (which both sets the mode and truncates whatever was + there before); +- `npm_config_userconfig` points npm at that file for the duration of the step; +- the npmjs step therefore starts from a file that no longer contains the Gitea + token; +- a final `if: always()` step deletes it, so it does not survive into a later + step or a cached workspace. + +`.npmrc`, `.npmrc-*` and `release.json` are in `.gitignore`, so a credentials +file written into the workspace can never be committed by accident. + +## Installing the packages + +From npmjs — public, no configuration: + +```sh +npm install -g @bitstack/nopy @bitstack/keyman +``` + +From the Gitea registry, which holds every snapshot plus a mirror of every +release. Per-project, in the repo's `.npmrc`: + +```ini +@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/ +``` + +Globally with credentials, in `~/.npmrc`: + +```ini +@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/ +//gitea.bitsquare.dev/api/packages/BitSquare/npm/:_authToken= +``` + +Reads only need a token if the repository is private. The registry URL is derived +in the workflows as `{server_url}/api/packages/{owner}/npm/`, so it follows the +instance and organisation automatically. + +To track snapshots in another project: + +```sh +pnpm add @bitstack/nopy@main +``` + +## Design decisions + +**Every publish is idempotent.** Each step asks the registry whether that exact +version already exists (`npm view @`) and skips if it does. A +release that publishes to Gitea and then fails on npmjs can simply be re-run: the +Gitea publish is skipped, the npmjs publish proceeds. This matters because Gitea +refuses to overwrite an existing version — without the check, a re-run would fail +on the first registry and never reach the second. + +**The build is explicit, publishes are `--ignore-scripts`.** `prepack` exists for +humans running `npm pack` locally; in CI the build has already run as its own +step, and repeating it inside `npm publish` would only cost time and add a way +for a lifecycle script to change what ships after the gate looked at it. + +**One job per workflow.** No artifact hand-off, no second install, no risk of +publishing a tree that a different job built. + +**Caching is best-effort.** `actions/cache@v4` is wrapped in +`continue-on-error: true`, and `setup-node`'s built-in `cache:` is not used, so +a Gitea runner without a cache backend still works. + +**`actions/upload-artifact@v3`, not v4** — v4 depends on a backend API that many +Gitea runner setups do not implement. + +**pnpm and Node versions come from the repo.** `pnpm/action-setup@v4` reads +`packageManager` from the root manifest; `setup-node` reads `.nvmrc`. There is no +version pinned in the workflow files to drift out of sync. + +## Checking things locally + +Reproduce the CI gate exactly: + +```sh +pnpm install --frozen-lockfile +pnpm run lint:ci && pnpm run typecheck && pnpm run test:coverage +``` + +See what would actually be in the tarball: + +```sh +pnpm run build +cd packages/nopy && npm pack --dry-run --ignore-scripts +``` + +Try the binary as an end user would get it, without publishing: + +```sh +cd packages/nopy && pnpm run link:local # build + npm link +nopy --help +npm unlink -g @bitstack/nopy +``` + +Check that a version is not already taken before you tag: + +```sh +npm view @bitstack/nopy@1.2.0 version # npmjs +npm view @bitstack/nopy@1.2.0 version \ + --registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/ +``` + +## Troubleshooting + +| Symptom | Cause and fix | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `Tag ... asks for X, but package.json declares Y` | The manifest was not bumped, or the tag is on the wrong commit. Fix the manifest, re-tag. | +| `Tag ... names package 'foo', but packages/foo/package.json does not exist` | Tag prefix must be the directory name under `packages/`. | +| `NPM_TOKEN secret is not set` | Add the secret; the run stops before installing anything. | +| `401`/`403` from the Gitea registry | The automatic `GITEA_TOKEN` lacks `write:package`. Add a `GITEA_NPM_TOKEN` PAT — no edit needed. | +| `E409 Conflict` / version already exists | Only reachable if a version was published outside the workflow; the `npm view` guard covers re-runs. | +| Coverage step fails, thresholds look met | Thresholds are per package. Read which package failed — the summary table shows both. | +| `npm pack --dry-run` step fails | A `files` or `bin` path no longer exists after the build. Fix before it reaches a registry. | +| Snapshot workflow green, nothing installable | Snapshots are only on Gitea and only under `@main`. Point the scope at the Gitea registry. | +| The release workflow did not trigger | The tag must match `*-v*` and must be pushed (`git push origin `), not just created. | + +## Recovering from a bad publish + +**On npmjs**, a version is permanent. Do not try to re-use it — publish a patch. +Meanwhile: + +```sh +npm dist-tag add @bitstack/nopy@1.1.9 latest # point users back +npm deprecate @bitstack/nopy@1.2.0 "Broken build, use 1.2.1" +``` + +`npm unpublish` is only possible within 72 hours and burns the version number +forever; a deprecation with a working `latest` is almost always the better move. + +**On Gitea**, delete the version under **Packages → @bitstack/… → Settings** +before that exact version can be published again. + +**A bad tag** can be moved, but only before the release workflow has published +anything: + +```sh +git push --delete origin nopy-v1.2.0 +git tag -d nopy-v1.2.0 +``` + +Once a tarball is on npmjs, the tag is a historical record — leave it and roll +forward. + +**A bad snapshot** needs no action at all: the next push to `main` produces a new +run number and therefore a new version, and `@main` moves to it. diff --git a/README.md b/README.md index ae1be18..ffbf8ce 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ # ansiblings +Infrastructure tooling monorepo: two published CLIs plus the pyinfra "cubes" +they deploy. + +| Path | Package | Binary | What it is | +| ----------------- | ------------------ | -------- | --------------------------------------------------- | +| `packages/nopy` | `@bitstack/nopy` | `nopy` | interactive pyinfra script management and execution | +| `packages/keyman` | `@bitstack/keyman` | `keyman` | SSH key management with `age` encryption | +| `cubes/` | — | — | the deployment units `nopy` runs | + +```sh +npm install -g @bitstack/nopy @bitstack/keyman +``` + +See each package's README for usage, and +[README.PUBLISH.md](README.PUBLISH.md) for how they get published. + +## Development + +Requires Node ≥ 22 (the repo pins 24 in `.nvmrc`) and pnpm — the version is +pinned by `packageManager`, so `corepack enable` is enough. + +```sh +pnpm install +``` + +| Command | Does | +| --------------------------- | --------------------------------------------------- | +| `pnpm run build` | compiles both packages with `tsc` | +| `pnpm run typecheck` | `tsc --build --noEmit` across the workspace | +| `pnpm run lint` | Biome check | +| `pnpm run lint:fix` | Biome check with fixes applied | +| `pnpm test` | vitest, both packages | +| `pnpm run test:coverage` | vitest with the coverage gate | +| `pnpm run coverage:summary` | renders the last coverage run as a Markdown table | + +`typescript` is on the 7.x native compiler, so `tsc` *is* the fast one — there is +no separate `tsgo` binary to keep in sync. Each package also has a dev-run script +(`pnpm --filter @bitstack/nopy run nopy`) that executes the TypeScript sources +directly through `tsx`. + +## Git hooks + +Installed by `simple-git-hooks` on `pnpm install`, configured in the root +`package.json`: + +- **pre-commit** — Biome check with fixes, on staged files only, re-staging what + it fixed. Fast; blocks only on problems it cannot fix itself. +- **pre-push** — `lint:ci` → `typecheck` → `test:coverage`. This is the same gate + CI runs, so a push that survives it will not surprise you on the runner. + +Set `SKIP_SIMPLE_GIT_HOOKS=1` to bypass either one; re-install them after +changing the config with `pnpm exec simple-git-hooks`. + +## Coverage + +Both packages hold a hard **85 % branch** floor, enforced by +`coverage.thresholds` in their `vitest.config.ts` rather than by a CI-only flag — +`pnpm run test:coverage` fails the same way locally, in the `pre-push` hook, and +on the runner. Barrel files and CLI argv wiring are excluded; everything with +behaviour in it is not. diff --git a/biome.json b/biome.json index 5992bab..1a7718f 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -7,11 +7,9 @@ }, "files": { "ignoreUnknown": true, - "ignore": ["dist", "node_modules", ".yarn", "*.lock"] - }, - "organizeImports": { - "enabled": true + "includes": ["**", "!**/dist", "!**/node_modules", "!**/.yarn", "!**/*.lock"] }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, "formatter": { "enabled": true, "indentStyle": "space", @@ -21,7 +19,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "complexity": { "noForEach": "off" }, @@ -38,5 +36,17 @@ "quoteStyle": "single", "trailingCommas": "es5" } - } + }, + "overrides": [ + { + "includes": ["**/tests/**"], + "linter": { + "rules": { + "performance": { + "noDelete": "off" + } + } + } + } + ] } diff --git a/cubes/admin/cockpit/manifest.mjs b/cubes/admin/cockpit/manifest.mjs index 0d386d5..4a418a8 100644 --- a/cubes/admin/cockpit/manifest.mjs +++ b/cubes/admin/cockpit/manifest.mjs @@ -1,5 +1,4 @@ import { cubes } from '@bitstack/nopy'; -import { z } from 'zod'; export default cubes.Manifest({ id: 'admin:cockpit', diff --git a/cubes/admin/hostname/manifest.mjs b/cubes/admin/hostname/manifest.mjs index 26c0e56..82c9fe6 100644 --- a/cubes/admin/hostname/manifest.mjs +++ b/cubes/admin/hostname/manifest.mjs @@ -1,16 +1,20 @@ -import { z } from 'zod'; import { cubes, uniqid } from '@bitstack/nopy'; +import { z } from 'zod'; /** * Manifest for the admin:hostname cube. * This cube allows for setting and persistently changing the system's hostname. */ export default cubes.Manifest({ - id: 'admin:hostname', - name: 'Permanently change the hostname', - dependencies: () => [], - schema: z.object({ - HOSTNAME: z.string().min(1).max(64).describe('The new hostname for the target host') - .default(`host-${uniqid()}`), - }) + id: 'admin:hostname', + name: 'Permanently change the hostname', + dependencies: () => [], + schema: z.object({ + HOSTNAME: z + .string() + .min(1) + .max(64) + .describe('The new hostname for the target host') + .default(`host-${uniqid()}`), + }), }); diff --git a/cubes/admin/locale/manifest.mjs b/cubes/admin/locale/manifest.mjs index 6630b65..d478fff 100644 --- a/cubes/admin/locale/manifest.mjs +++ b/cubes/admin/locale/manifest.mjs @@ -1,5 +1,5 @@ -import { z } from 'zod'; import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; export default cubes.Manifest({ id: 'admin:locale', diff --git a/cubes/apt/install/manifest.mjs b/cubes/apt/install/manifest.mjs index ad74328..c5ce827 100644 --- a/cubes/apt/install/manifest.mjs +++ b/cubes/apt/install/manifest.mjs @@ -7,6 +7,9 @@ export default cubes.Manifest({ dependencies: () => [], schema: z.object({ UPDATE: z.boolean().describe('Update package cache before installing').default(false), - PACKAGES: z.string().describe('Space-separated list of packages to install').default('vim htop curl'), + PACKAGES: z + .string() + .describe('Space-separated list of packages to install') + .default('vim htop curl'), }), }); diff --git a/cubes/network/wifi/connection/manifest.mjs b/cubes/network/wifi/connection/manifest.mjs index e7c9b8c..25229b2 100644 --- a/cubes/network/wifi/connection/manifest.mjs +++ b/cubes/network/wifi/connection/manifest.mjs @@ -1,5 +1,5 @@ -import { z } from 'zod'; import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; // [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"} @@ -8,12 +8,18 @@ import { cubes } from '@bitstack/nopy'; * Configures a WiFi client connection using NetworkManager (nmcli). */ export default cubes.Manifest({ - id: 'net:wifi:connection', - name: 'network:wifi:connection - Connect to a WiFi network', - schema: z.object({ - SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'), - PASSWORD: z.string().min(8).describe('The password for the WiFi network'), - AUTOCONNECT: z.boolean().default(true).describe('Whether to automatically connect to this network'), - CONNECTION_NAME: z.string().optional().describe('Optional name for the connection (defaults to SSID)'), - }) + id: 'net:wifi:connection', + name: 'network:wifi:connection - Connect to a WiFi network', + schema: z.object({ + SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'), + PASSWORD: z.string().min(8).describe('The password for the WiFi network'), + AUTOCONNECT: z + .boolean() + .default(true) + .describe('Whether to automatically connect to this network'), + CONNECTION_NAME: z + .string() + .optional() + .describe('Optional name for the connection (defaults to SSID)'), + }), }); diff --git a/cubes/service/autostart/manifest.mjs b/cubes/service/autostart/manifest.mjs index 863b3fd..bd8b849 100644 --- a/cubes/service/autostart/manifest.mjs +++ b/cubes/service/autostart/manifest.mjs @@ -7,7 +7,11 @@ export default cubes.Manifest({ dependencies: () => [], schema: z.object({ APP: z.string().describe('The name of the systemd service (e.g., flintstone)'), - SERVICE_NAME: z.string().optional().describe('Display name for the service').default('Application'), + SERVICE_NAME: z + .string() + .optional() + .describe('Display name for the service') + .default('Application'), AUTOSTART: z.boolean().describe('Should the service be enabled and started?').default(true), }), }); diff --git a/cubes/user/edit/manifest.mjs b/cubes/user/edit/manifest.mjs index fd7f342..4e25ea9 100644 --- a/cubes/user/edit/manifest.mjs +++ b/cubes/user/edit/manifest.mjs @@ -1,5 +1,5 @@ -import { z } from 'zod'; import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; // [agnt://cogen/cogen/user-edit-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"} @@ -8,13 +8,19 @@ import { cubes } from '@bitstack/nopy'; * Allows modifying existing user accounts (password, groups). */ export default cubes.Manifest({ - id: 'user:edit', - name: 'user:edit - Modify an existing user account', - dependencies: () => [], - schema: z.object({ - USER: z.string().describe('The username of the account to modify'), - PASSWORD: z.string().optional().describe('New password for the user (optional)'), - GROUPS: z.string().optional().describe('Comma-separated list of groups the user SHOULD be in (optional)'), - GROUPS_ABSENT: z.string().optional().describe('Comma-separated list of groups to REMOVE from the user (optional)'), - }) + id: 'user:edit', + name: 'user:edit - Modify an existing user account', + dependencies: () => [], + schema: z.object({ + USER: z.string().describe('The username of the account to modify'), + PASSWORD: z.string().optional().describe('New password for the user (optional)'), + GROUPS: z + .string() + .optional() + .describe('Comma-separated list of groups the user SHOULD be in (optional)'), + GROUPS_ABSENT: z + .string() + .optional() + .describe('Comma-separated list of groups to REMOVE from the user (optional)'), + }), }); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index ea49fb1..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2340 +0,0 @@ -{ - "name": "@bitsquare/ansiblings", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@bitsquare/ansiblings", - "workspaces": [ - "packages/**", - "projects/**" - ], - "dependencies": { - "commander": "^13.1.0" - }, - "devDependencies": { - "@logtape/logtape": "*", - "@types/jest": "*", - "@types/node": "*", - "ts-node": "*", - "typescript": "*" - }, - "engines": { - "node": ">=21" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.26.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bitstack/keyman": { - "resolved": "packages/keyman", - "link": true - }, - "node_modules/@bitstack/nopy": { - "resolved": "packages/nopy", - "link": true - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@logtape/logtape": { - "version": "0.8.0", - "funding": [ - "https://github.com/sponsors/dahlia" - ], - "license": "MIT" - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "license": "MIT" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/fs-extra": { - "version": "11.0.4", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/jsonfile": "*", - "@types/node": "*" - } - }, - "node_modules/@types/inquirer": { - "version": "8.2.12", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/through": "*", - "rxjs": "^7.2.0" - } - }, - "node_modules/@types/inquirer/node_modules/rxjs": { - "version": "7.8.2", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "29.5.5", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, - "node_modules/@types/jsonfile": { - "version": "6.1.4", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "22.10.7", - "license": "MIT", - "dependencies": { - "undici-types": "~6.20.0" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/through": { - "version": "0.0.33", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/uniqid": { - "version": "5.3.4", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "chai": "^4.3.10" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "1.6.1", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^2.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.14.0", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bl": { - "version": "4.1.0", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "4.5.0", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "0.7.0", - "license": "MIT" - }, - "node_modules/check-error": { - "version": "1.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "3.0.0", - "license": "ISC", - "engines": { - "node": ">= 10" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "license": "MIT" - }, - "node_modules/commander": { - "version": "13.1.0", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "dev": true, - "license": "MIT" - }, - "node_modules/create-require": { - "version": "1.1.1", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cross-spawn/node_modules/path-key": { - "version": "3.1.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "4.1.4", - "dev": true, - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/diff": { - "version": "4.0.2", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dotenv": { - "version": "10.0.0", - "license": "BSD-2-Clause", - "engines": { - "node": ">=10" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "license": "MIT" - }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/execa": { - "version": "9.5.2", - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.3", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.0", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/figures": { - "version": "6.1.0", - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/is-unicode-supported": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/external-editor": { - "version": "3.1.0", - "license": "MIT", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/figures": { - "version": "3.2.0", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/fuzzy": { - "version": "0.1.3", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/get-func-name": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-stream": { - "version": "9.0.1", - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/human-signals": { - "version": "8.0.0", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "license": "ISC" - }, - "node_modules/inquirer": { - "version": "8.2.4", - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/inquirer-checkbox-plus-prompt": { - "version": "1.4.2", - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "cli-cursor": "^3.1.0", - "figures": "^3.0.0", - "lodash": "^4.17.5", - "rxjs": "^6.6.7" - }, - "peerDependencies": { - "inquirer": "< 9.x" - } - }, - "node_modules/inquirer-checkbox-plus-prompt/node_modules/lodash": { - "version": "4.17.23", - "license": "MIT" - }, - "node_modules/inquirer-checkbox-plus-prompt/node_modules/rxjs": { - "version": "6.6.7", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^1.9.0" - }, - "engines": { - "npm": ">=2.0.0" - } - }, - "node_modules/inquirer-checkbox-plus-prompt/node_modules/tslib": { - "version": "1.14.1", - "license": "0BSD" - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-stream": { - "version": "4.0.1", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "license": "ISC" - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/local-pkg": { - "version": "0.5.1", - "dev": true, - "license": "MIT", - "dependencies": { - "mlly": "^1.7.3", - "pkg-types": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loupe": { - "version": "2.3.7", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magic-string/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "dev": true, - "license": "MIT" - }, - "node_modules/make-error": { - "version": "1.3.6", - "license": "ISC" - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mlly": { - "version": "1.8.0", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" - } - }, - "node_modules/mlly/node_modules/acorn": { - "version": "8.16.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/mlly/node_modules/pathe": { - "version": "2.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "0.0.8", - "license": "ISC" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/npm-run-path": { - "version": "6.0.0", - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/p-limit": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-ms": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-key": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss": { - "version": "8.5.6", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/pretty-ms": { - "version": "9.2.0", - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "license": "ISC" - }, - "node_modules/rollup": { - "version": "4.59.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-async": { - "version": "2.4.1", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/rxjs": { - "version": "7.8.1", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/through": { - "version": "2.3.8", - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "0.8.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "2.2.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tmp": { - "version": "0.0.33", - "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "license": "0BSD" - }, - "node_modules/type-detect": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-dotenv": { - "version": "10.0.2", - "license": "ISC", - "dependencies": { - "dotenv": "^10.0.0", - "lodash": "^4.17.20" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/typescript": { - "version": "5.7.3", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.3", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.20.0", - "license": "MIT" - }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "license": "MIT" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "1.6.1", - "@vitest/runner": "1.6.1", - "@vitest/snapshot": "1.6.1", - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", - "vite": "^5.0.0", - "vite-node": "1.6.1", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.6.1", - "@vitest/ui": "1.6.1", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/acorn-walk": { - "version": "8.3.5", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/vitest/node_modules/execa": { - "version": "8.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/vitest/node_modules/get-stream": { - "version": "8.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/human-signals": { - "version": "5.0.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/vitest/node_modules/is-stream": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/mimic-fn": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/npm-run-path": { - "version": "5.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/onetime": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/strip-final-newline": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/which": { - "version": "2.0.2", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "3.24.1", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zx": { - "version": "8.3.0", - "license": "Apache-2.0", - "bin": { - "zx": "build/cli.js" - }, - "engines": { - "node": ">= 12.17.0" - }, - "optionalDependencies": { - "@types/fs-extra": ">=11", - "@types/node": ">=20" - } - }, - "packages/keyman": { - "name": "@bitstack/keyman", - "version": "1.0.0", - "dependencies": { - "execa": "9.5.2", - "inquirer": "8.2.4", - "ts-node": ">=10.9.1", - "typed-dotenv": "10.0.2", - "typescript": ">=5.6.3", - "zod": "^3.24.1", - "zx": "^8.3.0" - }, - "bin": { - "keyman": "dist/keyman.cli.js" - }, - "engines": { - "node": ">=21.0.0" - } - }, - "packages/nopy": { - "name": "@bitstack/nopy", - "version": "1.0.0", - "dependencies": { - "@logtape/logtape": "^0.8.0", - "commander": "^13.1.0", - "enquirer": "^2.4.1", - "execa": "9.5.2", - "fuzzy": "^0.1.3", - "inquirer": "8.2.4", - "inquirer-checkbox-plus-prompt": "^1.0.1", - "ts-node": ">=10.9.1", - "typescript": ">=5.6.3", - "yaml": "^2.8.2", - "zod": "^3.24.1", - "zx": "^8.3.0" - }, - "bin": { - "nopy": "dist/nopy.cli.js" - }, - "devDependencies": { - "@types/inquirer": "^8.2.10", - "@types/node": "^20.0.0", - "@types/uniqid": "^5.3.4", - "vitest": "^1.6.0" - } - }, - "packages/nopy/node_modules/@types/node": { - "version": "20.19.34", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", - "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "packages/nopy/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json index 9fdbe4c..0d57ad4 100644 --- a/package.json +++ b/package.json @@ -1,41 +1,33 @@ { "name": "@bitsquare/ansiblings", "private": true, + "packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72", "engines": { - "node": ">=21" + "node": ">=22" }, "scripts": { "build": "pnpm -r run build", "build:clean": "pnpm clean && pnpm build", - "build:tsgo": "tsgo --build", "clean": "pnpm -r run clean", "test": "pnpm -r run test", - "typecheck": "tsgo --noEmit", - "typecheck:legacy": "tsc --noEmit", + "test:coverage": "pnpm -r run test:coverage", + "coverage:summary": "node scripts/coverage-summary.mjs", + "typecheck": "tsc --build --noEmit", "lint": "biome check .", "lint:fix": "biome check --write .", - "format": "biome format --write ." + "lint:ci": "biome ci .", + "format": "biome format --write .", + "prepare": "git rev-parse --git-dir > /dev/null 2>&1 && simple-git-hooks || true" + }, + "simple-git-hooks": { + "pre-commit": "pnpm exec biome check --write --staged --no-errors-on-unmatched && git update-index --again", + "pre-push": "pnpm run lint:ci && pnpm run typecheck && pnpm run test:coverage" }, "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@logtape/logtape": "0.8.0", - "@types/jest": "29.5.5", - "@types/node": ">=21", - "@typescript/native-preview": "7.0.0-dev.20260303.1", - "ts-node": "10.9.2", - "typescript": "5.7.3" - }, - "dependencies": { - "commander": "^13.1.0" - }, - "pnpm": { - "overrides": { - "@types/jest": "29.5.5", - "@types/node": ">=21", - "@logtape/logtape": "0.8.0", - "ts-node": "10.9.2", - "typescript": "5.7.3", - "commander": "^13.1.0" - } + "@biomejs/biome": "^2.5.5", + "@logtape/logtape": "^2.2.4", + "@types/node": "^26.1.1", + "simple-git-hooks": "^2.13.1", + "typescript": "^7.0.2" } } diff --git a/packages/keyman/LICENSE b/packages/keyman/LICENSE new file mode 100644 index 0000000..9536813 --- /dev/null +++ b/packages/keyman/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 bitsquare + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/keyman/dist/index.d.ts b/packages/keyman/dist/index.d.ts deleted file mode 100644 index 29f29e8..0000000 --- a/packages/keyman/dist/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -export * from './keyman.main.js'; -export { loadConfig, resolveConfigPaths } from './keyman.config.js'; diff --git a/packages/keyman/dist/index.js b/packages/keyman/dist/index.js deleted file mode 100644 index 29f29e8..0000000 --- a/packages/keyman/dist/index.js +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -export * from './keyman.main.js'; -export { loadConfig, resolveConfigPaths } from './keyman.config.js'; diff --git a/packages/keyman/dist/keyman.cli.d.ts b/packages/keyman/dist/keyman.cli.d.ts deleted file mode 100644 index b798801..0000000 --- a/packages/keyman/dist/keyman.cli.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env node -export {}; diff --git a/packages/keyman/dist/keyman.cli.js b/packages/keyman/dist/keyman.cli.js deleted file mode 100755 index 40b5966..0000000 --- a/packages/keyman/dist/keyman.cli.js +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node -import { loadConfig, resolveConfigPaths } from './keyman.config.js'; -import { keyman } from './keyman.main.js'; -const args = process.argv.slice(2); -if (args.includes('--print-config')) { - const config = loadConfig(); - const paths = resolveConfigPaths(config); - console.log(JSON.stringify(paths)); - process.exit(0); -} -keyman(); diff --git a/packages/keyman/dist/keyman.config.d.ts b/packages/keyman/dist/keyman.config.d.ts deleted file mode 100644 index 9ff8a71..0000000 --- a/packages/keyman/dist/keyman.config.d.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { z } from 'zod'; -/** - * Configuration schema for keyman - */ -declare const KeymanConfigSchema: z.ZodObject<{ - vaultRoot: z.ZodDefault; - keysDir: z.ZodDefault; - tmpDir: z.ZodDefault; - ageKeyFile: z.ZodDefault; -}, "strip", z.ZodTypeAny, { - vaultRoot: string; - keysDir: string; - tmpDir: string; - ageKeyFile: string; -}, { - vaultRoot?: string | undefined; - keysDir?: string | undefined; - tmpDir?: string | undefined; - ageKeyFile?: string | undefined; -}>; -export type KeymanConfig = z.infer; -/** - * Resolution strategy for merging config properties - * - 'merge': Arrays are concatenated, objects are deep merged (default) - * - 'override': Child value completely replaces parent value - */ -export type ResolutionStrategy = 'merge' | 'override'; -/** - * Resolution configuration for customizing merge behavior - */ -export type KeymanResolutionConfig = { - [K in keyof KeymanConfig]?: ResolutionStrategy; -}; -/** - * Raw config file structure (includes resolution) - */ -export interface KeymanConfigFile extends Partial { - /** Customize merge behavior for specific properties */ - resolution?: KeymanResolutionConfig; -} -/** - * Loads configuration from .keymanrc.json files - * - * Searches for `.keymanrc.json` by traversing upwards from cwd to root. - * Multiple config files are merged, with child configs overriding parent configs. - * - * Use the `resolution` property to customize merge behavior: - * ```json - * { - * "vaultRoot": "../vault", - * "resolution": { - * "vaultRoot": "override" - * } - * } - * ``` - * - * @returns Validated keyman configuration - */ -export declare function loadConfig(): KeymanConfig; -/** - * Resolves configuration paths relative to VAULT_ROOT or current directory - * @param config The keyman configuration - * @returns Resolved absolute paths - */ -export declare function resolveConfigPaths(config: KeymanConfig): { - vaultRoot: string; - keysDir: string; - tmpDir: string; - keyPath: string; -}; -/** - * Gets the paths of all discovered config files (for debugging) - * @returns Array of paths to .keymanrc.json files, ordered from root to cwd - */ -export declare function getConfigPaths(): string[]; -export {}; diff --git a/packages/keyman/dist/keyman.config.js b/packages/keyman/dist/keyman.config.js deleted file mode 100644 index ca75a8d..0000000 --- a/packages/keyman/dist/keyman.config.js +++ /dev/null @@ -1,212 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { z } from 'zod'; -/** - * Configuration schema for keyman - */ -const KeymanConfigSchema = z.object({ - vaultRoot: z.string().default('vault'), - keysDir: z.string().default('keys'), - tmpDir: z.string().default('tmp'), - ageKeyFile: z.string().default('age.key'), -}); -/** - * Default configuration values - */ -const DEFAULT_CONFIG = { - vaultRoot: 'vault', - keysDir: 'keys', - tmpDir: 'tmp', - ageKeyFile: 'age.key', -}; -const CONFIG_FILENAME = '.keymanrc.json'; -/** - * Path-based properties that should be resolved relative to config file location - */ -const PATH_PROPERTIES = ['vaultRoot']; -/** - * Resolves path properties in a config object relative to the config file's directory - * @param configFile The raw config file contents - * @param configDir Directory containing the config file - * @returns Config with path properties resolved to absolute paths - */ -function resolvePathsRelativeToConfig(configFile, configDir) { - const resolved = { ...configFile }; - for (const prop of PATH_PROPERTIES) { - const value = configFile[prop]; - if (typeof value === 'string' && !path.isAbsolute(value)) { - resolved[prop] = path.resolve(configDir, value); - } - } - return resolved; -} -/** - * Finds all config files by traversing upwards from cwd to root - * Returns configs in order from root to cwd (parent first, child last) - * @param startDir Directory to start searching from - * @returns Array of paths to .keymanrc.json files - */ -function findConfigFiles(startDir) { - const configPaths = []; - let currentDir = startDir; - // Traverse upwards - while (true) { - const configPath = path.join(currentDir, CONFIG_FILENAME); - if (fs.existsSync(configPath)) { - configPaths.unshift(configPath); // Add to front (root first) - } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - break; // Reached root - } - currentDir = parentDir; - } - // Also check home directory (lowest priority) - const homeConfig = path.join(os.homedir(), CONFIG_FILENAME); - if (fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) { - configPaths.unshift(homeConfig); - } - return configPaths; -} -/** - * Deep merges two values based on resolution strategy - */ -function mergeValue(parentValue, childValue, strategy) { - // Override strategy: child replaces parent completely - if (strategy === 'override') { - return childValue; - } - // Merge strategy (default) - if (Array.isArray(parentValue) && Array.isArray(childValue)) { - // Concatenate arrays, remove duplicates for primitives - const combined = [...parentValue, ...childValue]; - if (combined.every((v) => typeof v !== 'object')) { - return [...new Set(combined)]; - } - return combined; - } - if (typeof parentValue === 'object' && - parentValue !== null && - typeof childValue === 'object' && - childValue !== null && - !Array.isArray(parentValue) && - !Array.isArray(childValue)) { - // Deep merge objects - const result = { ...parentValue }; - for (const [key, value] of Object.entries(childValue)) { - if (key in result) { - result[key] = mergeValue(result[key], value, 'merge'); - } - else { - result[key] = value; - } - } - return result; - } - // Primitives: child overrides parent - return childValue; -} -/** - * Merges a child config into a parent config - */ -function mergeConfigs(parent, childFile) { - const resolution = childFile.resolution || {}; - const result = { ...parent }; - for (const [key, value] of Object.entries(childFile)) { - if (key === 'resolution') - continue; // Skip resolution property itself - const strategy = resolution[key] || 'merge'; - if (key in result) { - result[key] = mergeValue(result[key], value, strategy); - } - else { - result[key] = value; - } - } - return result; -} -/** - * Loads configuration from .keymanrc.json files - * - * Searches for `.keymanrc.json` by traversing upwards from cwd to root. - * Multiple config files are merged, with child configs overriding parent configs. - * - * Use the `resolution` property to customize merge behavior: - * ```json - * { - * "vaultRoot": "../vault", - * "resolution": { - * "vaultRoot": "override" - * } - * } - * ``` - * - * @returns Validated keyman configuration - */ -export function loadConfig() { - const startDir = process.cwd(); - const configPaths = findConfigFiles(startDir); - if (configPaths.length === 0) { - console.error('ℹ️ No .keymanrc.json found, using default configuration'); - return DEFAULT_CONFIG; - } - // Start with defaults and merge each config file - let config = { ...DEFAULT_CONFIG }; - for (const configPath of configPaths) { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const rawConfig = JSON.parse(content); - // Resolve path properties relative to the config file's directory - const configDir = path.dirname(configPath); - const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir); - config = mergeConfigs(config, resolvedConfig); - console.error(`✅ Loaded configuration from ${configPath}`); - } - catch (error) { - if (error instanceof SyntaxError) { - console.warn(`⚠️ Skipping invalid JSON in ${configPath}: ${error.message}`); - } - else { - console.warn(`⚠️ Skipping config ${configPath}: ${error}`); - } - // Continue with other configs instead of failing entirely - } - } - // Validate the final merged result - try { - return KeymanConfigSchema.parse(config); - } - catch (error) { - if (error instanceof z.ZodError) { - console.error('❌ ERROR: Invalid merged configuration:'); - error.errors.forEach((err) => { - console.error(` - ${err.path.join('.')}: ${err.message}`); - }); - } - console.error('ℹ️ Falling back to default configuration'); - return DEFAULT_CONFIG; - } -} -/** - * Resolves configuration paths relative to VAULT_ROOT or current directory - * @param config The keyman configuration - * @returns Resolved absolute paths - */ -export function resolveConfigPaths(config) { - // VAULT_ROOT environment variable takes precedence - const vaultRoot = path.resolve(process.env.VAULT_ROOT ?? config.vaultRoot); - return { - vaultRoot, - keysDir: path.resolve(vaultRoot, config.keysDir), - tmpDir: path.resolve(vaultRoot, config.tmpDir), - keyPath: path.resolve(vaultRoot, config.ageKeyFile), - }; -} -/** - * Gets the paths of all discovered config files (for debugging) - * @returns Array of paths to .keymanrc.json files, ordered from root to cwd - */ -export function getConfigPaths() { - return findConfigFiles(process.cwd()); -} diff --git a/packages/keyman/dist/keyman.copy.d.ts b/packages/keyman/dist/keyman.copy.d.ts deleted file mode 100644 index 78c481b..0000000 --- a/packages/keyman/dist/keyman.copy.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function copyKey(sshDir: string, tmpDir: string): Promise; diff --git a/packages/keyman/dist/keyman.copy.js b/packages/keyman/dist/keyman.copy.js deleted file mode 100644 index 666557a..0000000 --- a/packages/keyman/dist/keyman.copy.js +++ /dev/null @@ -1,50 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { execa } from 'execa'; -import inquirer from 'inquirer'; -export async function copyKey(sshDir, tmpDir) { - const getKeys = (dir) => { - if (!fs.existsSync(dir)) - return []; - return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub')); - }; - const sshKeys = getKeys(sshDir); - const tmpKeys = getKeys(tmpDir); - const keys = [...new Set([...sshKeys, ...tmpKeys])]; - if (keys.length === 0) { - console.log('⚠️ No SSH keys found.'); - return; - } - const { selectedKey } = await inquirer.prompt([ - { - type: 'list', - name: 'selectedKey', - message: 'Select key to copy public key from:', - choices: keys, - }, - ]); - // Determine location of the public key - // Prefer tmpDir if it exists there, otherwise sshDir - let pubKeyPath = path.join(tmpDir, `${selectedKey}.pub`); - if (!fs.existsSync(pubKeyPath)) { - pubKeyPath = path.join(sshDir, `${selectedKey}.pub`); - } - if (!fs.existsSync(pubKeyPath)) { - console.error(`❌ Public key not found for ${selectedKey}`); - return; - } - try { - const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim(); - // Detect OS and use appropriate clipboard command - // Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check. - // For this specific request on Darwin: - const proc = execa('pbcopy'); - proc.stdin?.write(pubKeyContent); - proc.stdin?.end(); - await proc; - console.log(`✅ Public key for ${selectedKey} copied to clipboard!`); - } - catch (error) { - console.error(`❌ Failed to copy to clipboard: ${error}`); - } -} diff --git a/packages/keyman/dist/keyman.decrypt.d.ts b/packages/keyman/dist/keyman.decrypt.d.ts deleted file mode 100644 index 93231fa..0000000 --- a/packages/keyman/dist/keyman.decrypt.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise; diff --git a/packages/keyman/dist/keyman.decrypt.js b/packages/keyman/dist/keyman.decrypt.js deleted file mode 100644 index 069f58c..0000000 --- a/packages/keyman/dist/keyman.decrypt.js +++ /dev/null @@ -1,45 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { execa } from 'execa'; -import inquirer from 'inquirer'; -export async function decryptKeys(sshDir, vaultDir, ageKey) { - const keyDir = path.join(vaultDir, 'keys'); - const vaultKeys = fs.readdirSync(keyDir).filter((key) => { - const keyfile = path.join(keyDir, key, `id_${key}.age`); - console.log(keyfile); - return fs.existsSync(keyfile); - }); - if (vaultKeys.length === 0) { - console.log('⚠️ No encrypted keys found.'); - return; - } - const { selectedKeys, decryptMode } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'selectedKeys', - message: 'Select keys to decrypt:', - choices: vaultKeys, - }, - { - type: 'list', - name: 'decryptMode', - message: 'Choose decryption location:', - choices: ['Local (vault/tmp)', 'SSH (~/.ssh)'], - }, - ]); - for (const key of selectedKeys) { - const encryptedKey = path.join(keyDir, key, `id_${key}.age`); - const publicKey = path.join(keyDir, key, `id_${key}.pub`); - const privateKeyOut = decryptMode === 'Local (vault/tmp)' - ? path.join(vaultDir, 'tmp', `id_${key}`) - : path.join(sshDir, `id_${key}`); - const publicKeyOut = decryptMode === 'Local (vault/tmp)' - ? path.join(vaultDir, 'tmp', `id_${key}.pub`) - : path.join(sshDir, `id_${key}.pub`); - // Decrypt key - await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]); - await execa('cp', [publicKey, publicKeyOut]); - await execa('chmod', ['600', privateKeyOut]); - console.log(`✅ Decrypted: ${privateKeyOut}`); - } -} diff --git a/packages/keyman/dist/keyman.encrypt.d.ts b/packages/keyman/dist/keyman.encrypt.d.ts deleted file mode 100644 index 8473cb7..0000000 --- a/packages/keyman/dist/keyman.encrypt.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise; diff --git a/packages/keyman/dist/keyman.encrypt.js b/packages/keyman/dist/keyman.encrypt.js deleted file mode 100644 index a98b02b..0000000 --- a/packages/keyman/dist/keyman.encrypt.js +++ /dev/null @@ -1,37 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { execa } from 'execa'; -import inquirer from 'inquirer'; -export async function encryptKeys(sshDir, vaultDir, tmpDir, pubkey) { - const sshKeys = fs - .readdirSync(sshDir) - .filter((key) => key.startsWith('id_') && !key.endsWith('.pub')); - const tmpKeys = fs - .readdirSync(tmpDir) - .filter((key) => key.startsWith('id_') && !key.endsWith('.pub')); - console.log(tmpKeys); - console.log(sshKeys); - const keys = [...new Set([...sshKeys, ...tmpKeys])]; - if (keys.length === 0) { - console.log('⚠️ No private SSH keys found to encrypt.'); - return; - } - const { selectedKeys } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'selectedKeys', - message: 'Select SSH keys to encrypt:', - choices: keys, - }, - ]); - for (const key of selectedKeys) { - const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key); - const vaultPath = path.join(vaultDir, 'keys', key.replace('id_', '')); - fs.mkdirSync(vaultPath, { recursive: true }); - // Encrypt key using `age` - await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${key}.age`), keyPath]); - // Copy public key and create README - fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`)); - console.log(`🔒 Encrypted and stored: ${vaultPath}/${key}`); - } -} diff --git a/packages/keyman/dist/keyman.generate.d.ts b/packages/keyman/dist/keyman.generate.d.ts deleted file mode 100644 index 5e5f541..0000000 --- a/packages/keyman/dist/keyman.generate.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise; diff --git a/packages/keyman/dist/keyman.generate.js b/packages/keyman/dist/keyman.generate.js deleted file mode 100644 index 49c0a70..0000000 --- a/packages/keyman/dist/keyman.generate.js +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { execa } from 'execa'; -import inquirer from 'inquirer'; -export async function generateKey(tmpDir, keysDir, pubkey) { - const { algorithm } = await inquirer.prompt([ - { - type: 'list', - name: 'algorithm', - message: 'Select algorithm:', - choices: ['ed25519', 'rsa'], - default: 'ed25519', - }, - ]); - const { keyName } = await inquirer.prompt([ - { - type: 'input', - name: 'keyName', - message: 'Enter key name:', - validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'), - }, - ]); - const { password } = await inquirer.prompt([ - { - type: 'password', - name: 'password', - message: 'Enter passphrase (leave empty for no passphrase):', - mask: '*', - }, - ]); - const { identity } = await inquirer.prompt([ - { - type: 'input', - name: 'identity', - message: 'Enter key identity (comment):', - }, - ]); - const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`; - const keyPath = path.join(tmpDir, fileName); - if (fs.existsSync(keyPath)) { - console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`); - return; - } - try { - console.log(`Generating ${algorithm} key pair...`); - const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity]; - if (algorithm === 'rsa') { - args.push('-b', '4096'); - } - await execa('ssh-keygen', args); - console.log(`✅ Key generated: ${keyPath}`); - // Encrypt the key - const folderName = fileName.replace('id_', ''); - const vaultPath = path.join(keysDir, folderName); - fs.mkdirSync(vaultPath, { recursive: true }); - // Encrypt key using `age` - await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${fileName}.age`), keyPath]); - // Copy public key - fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${fileName}.pub`)); - console.log(`🔒 Encrypted and stored: ${vaultPath}`); - } - catch (error) { - console.error(`❌ Error generating/encrypting key: ${error}`); - } -} diff --git a/packages/keyman/dist/keyman.list.d.ts b/packages/keyman/dist/keyman.list.d.ts deleted file mode 100644 index 0eb95b5..0000000 --- a/packages/keyman/dist/keyman.list.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise; diff --git a/packages/keyman/dist/keyman.list.js b/packages/keyman/dist/keyman.list.js deleted file mode 100644 index 22b59a2..0000000 --- a/packages/keyman/dist/keyman.list.js +++ /dev/null @@ -1,115 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -export async function listKeys(sshDir, vaultDir, tmpDir) { - console.log('\n📂 Checking keys in:'); - console.log(` SSH: ${sshDir}`); - console.log(` Vault: ${vaultDir}`); - console.log(` Tmp: ${tmpDir}\n`); - const keyMap = new Map(); - // Scan SSH directory - if (fs.existsSync(sshDir)) { - const sshFiles = fs.readdirSync(sshDir).filter((file) => file.startsWith('id_')); - for (const file of sshFiles) { - const keyName = file.replace(/\.pub$/, ''); - const isPub = file.endsWith('.pub'); - if (!keyMap.has(keyName)) { - keyMap.set(keyName, { - name: keyName, - inSsh: !isPub, - hasSshPub: isPub, - inVault: false, - inTmp: false, - hasTmpPub: false, - }); - } - else { - const key = keyMap.get(keyName); - if (isPub) { - key.hasSshPub = true; - } - else { - key.inSsh = true; - } - } - } - } - // Scan tmp directory - if (fs.existsSync(tmpDir)) { - const tmpFiles = fs.readdirSync(tmpDir).filter((file) => file.startsWith('id_')); - for (const file of tmpFiles) { - const keyName = file.replace(/\.pub$/, ''); - const isPub = file.endsWith('.pub'); - if (!keyMap.has(keyName)) { - keyMap.set(keyName, { - name: keyName, - inSsh: false, - hasSshPub: false, - inVault: false, - inTmp: !isPub, - hasTmpPub: isPub, - }); - } - else { - const key = keyMap.get(keyName); - if (isPub) { - key.hasTmpPub = true; - } - else { - key.inTmp = true; - } - } - } - } - // Scan vault directory - if (fs.existsSync(vaultDir)) { - const vaultDirs = fs.readdirSync(vaultDir).filter((dir) => { - const stat = fs.statSync(path.join(vaultDir, dir)); - return stat.isDirectory(); - }); - for (const dir of vaultDirs) { - const keyName = `id_${dir}`; - const encryptedPath = path.join(vaultDir, dir, `${keyName}.age`); - if (fs.existsSync(encryptedPath)) { - if (!keyMap.has(keyName)) { - keyMap.set(keyName, { - name: keyName, - inSsh: false, - hasSshPub: false, - inVault: true, - inTmp: false, - hasTmpPub: false, - }); - } - else { - keyMap.get(keyName).inVault = true; - } - } - } - } - // Display results - if (keyMap.size === 0) { - console.log('⚠️ No SSH keys found.\n'); - return; - } - console.log('🔑 SSH Keys:\n'); - console.log(' Key Name [Vault] [Tmp] [.ssh]'); - console.log(` ${'─'.repeat(58)}`); - const sortedKeys = Array.from(keyMap.values()).sort((a, b) => a.name.localeCompare(b.name)); - for (const key of sortedKeys) { - const vaultMark = key.inVault ? '✓' : ' '; - const tmpMark = key.inTmp ? '✓' : ' '; - const sshMark = key.inSsh ? '✓' : ' '; - // Show (.pub) if present in any location - const hasPub = key.hasSshPub || key.hasTmpPub; - const pubIndicator = hasPub ? ' (.pub)' : ''; - // Determine status - const status = key.inVault && key.inSsh ? '✅' : key.inVault && key.inTmp ? '🔓' : key.inVault ? '🔒' : '⚠️ '; - const namePart = `${key.name}${pubIndicator}`.padEnd(32); - console.log(` ${status} ${namePart} [${vaultMark}] [${tmpMark}] [${sshMark}]`); - } - console.log('\n Legend:'); - console.log(' ✅ = Managed (encrypted in vault + active in .ssh)'); - console.log(' 🔓 = Decrypted (in vault + decrypted to tmp)'); - console.log(' 🔒 = Encrypted only (in vault, not decrypted)'); - console.log(' ⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)\n'); -} diff --git a/packages/keyman/dist/keyman.main.d.ts b/packages/keyman/dist/keyman.main.d.ts deleted file mode 100644 index fbbc674..0000000 --- a/packages/keyman/dist/keyman.main.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function keyman(): Promise; diff --git a/packages/keyman/dist/keyman.main.js b/packages/keyman/dist/keyman.main.js deleted file mode 100644 index ae28df5..0000000 --- a/packages/keyman/dist/keyman.main.js +++ /dev/null @@ -1,79 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import inquirer from 'inquirer'; -import { loadConfig, resolveConfigPaths } from './keyman.config.js'; -import { copyKey } from './keyman.copy.js'; -import { decryptKeys } from './keyman.decrypt.js'; -import { encryptKeys } from './keyman.encrypt.js'; -import { generateKey } from './keyman.generate.js'; -import { listKeys } from './keyman.list.js'; -import { extractAgePublicKey } from './keyman.utils.js'; -// 🔹 Main function to resolve paths and manage flow -export async function keyman() { - // Load configuration from .keymanrc.json or use defaults - const config = loadConfig(); - const paths = resolveConfigPaths(config); - console.log(`\n📁 Vault Root: ${paths.vaultRoot}`); - console.log(`🔑 Keys Directory: ${paths.keysDir}`); - console.log(`📂 Temp Directory: ${paths.tmpDir}`); - console.log(`🔐 Age Key: ${paths.keyPath}\n`); - // Get USER input - const { user } = await inquirer.prompt([ - { - type: 'input', - name: 'user', - message: 'Specify USER (default: @current):', - default: '@current', - }, - ]); - const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`; - if (!homeDir) { - console.error('Error: Unable to determine HOME directory.'); - process.exit(1); - } - const sshDir = path.join(homeDir, '.ssh'); - fs.mkdirSync(paths.vaultRoot, { recursive: true }); - fs.mkdirSync(paths.tmpDir, { recursive: true }); - // Main loop - keep showing menu until user quits - let running = true; - while (running) { - console.log(`\n${'='.repeat(50)}`); - // 🔹 Show category selection - const { category } = await inquirer.prompt([ - { - type: 'list', - name: 'category', - message: 'Select operation:', - choices: [ - { name: '📋 List keys', value: 'list' }, - { name: '📝 Copy public key', value: 'copy' }, - { name: '🆕 Generate key', value: 'generate' }, - { name: '🔒 Encrypt keys', value: 'encrypt' }, - { name: '🔓 Decrypt keys', value: 'decrypt' }, - { name: '❌ Quit', value: 'quit' }, - ], - }, - ]); - switch (category) { - case 'list': - await listKeys(sshDir, paths.keysDir, paths.tmpDir); - break; - case 'copy': - await copyKey(sshDir, paths.tmpDir); - break; - case 'generate': - await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath)); - break; - case 'encrypt': - await encryptKeys(sshDir, paths.vaultRoot, paths.tmpDir, extractAgePublicKey(paths.keyPath)); - break; - case 'decrypt': - await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath); - break; - case 'quit': - console.log('\n👋 Goodbye!\n'); - running = false; - break; - } - } -} diff --git a/packages/keyman/dist/keyman.utils.d.ts b/packages/keyman/dist/keyman.utils.d.ts deleted file mode 100644 index f7da94a..0000000 --- a/packages/keyman/dist/keyman.utils.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Extracts the public key from an age key file. - * @param keyFilePath Path to the age key file. - * @returns The public key as a string, or null if not found. - */ -export declare function extractAgePublicKey(keyFilePath: string): string | null; diff --git a/packages/keyman/dist/keyman.utils.js b/packages/keyman/dist/keyman.utils.js deleted file mode 100644 index cfc8353..0000000 --- a/packages/keyman/dist/keyman.utils.js +++ /dev/null @@ -1,21 +0,0 @@ -import fs from 'node:fs'; -/** - * Extracts the public key from an age key file. - * @param keyFilePath Path to the age key file. - * @returns The public key as a string, or null if not found. - */ -export function extractAgePublicKey(keyFilePath) { - if (!fs.existsSync(keyFilePath)) { - console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`); - return null; - } - try { - const fileContents = fs.readFileSync(keyFilePath, 'utf-8'); - const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m); - return publicKeyMatch ? publicKeyMatch[1] : null; - } - catch (error) { - console.error(`❌ ERROR: Failed to read key file - ${error}`); - return null; - } -} diff --git a/packages/keyman/package.json b/packages/keyman/package.json index 71c12ca..5240744 100644 --- a/packages/keyman/package.json +++ b/packages/keyman/package.json @@ -1,29 +1,67 @@ { "name": "@bitstack/keyman", - "description": "A system to simplify ssh key management", - "type": "module", "version": "1.0.0", - "private": true, + "description": "A system to simplify ssh key management", + "keywords": [ + "ssh", + "keys", + "age", + "encryption", + "cli" + ], + "license": "MIT", "author": "bitsquare", - "bin": "dist/keyman.cli.js", - "scripts": { - "clean": "rm -rf dist", - "build": "tsgo && chmod +x dist/keyman.cli.js && npm link", - "build:legacy": "tsc && chmod +x dist/keyman.cli.js && npm link", - "prepublishOnly": "npm run build", - "keyman": "node --loader ts-node/esm src/keyman.bin.ts" + "type": "module", + "repository": { + "type": "git", + "url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git", + "directory": "packages/keyman" + }, + "homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/keyman", + "bugs": { + "url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues" }, "engines": { - "node": ">=21.0.0" + "node": ">=22" + }, + "bin": { + "keyman": "./dist/keyman.cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "rm -rf dist .tsbuildinfo", + "build": "tsc", + "prepack": "pnpm run build", + "link:local": "pnpm run build && npm link", + "keyman": "tsx src/keyman.cli.ts", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest" }, - "files": ["dist/"], "dependencies": { - "execa": "9.5.2", - "inquirer": "8.2.4", - "ts-node": ">=10.9.1", - "typed-dotenv": "10.0.2", - "typescript": ">=5.6.3", - "zod": "^3.24.1", - "zx": "^8.3.0" + "execa": "^10.0.0", + "inquirer": "^14.0.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "@vitest/coverage-v8": "^4.1.10", + "tsx": "^4.23.1", + "typescript": "^7.0.2", + "vitest": "^4.1.10" } } diff --git a/packages/keyman/src/index.ts b/packages/keyman/src/index.ts index 29f29e8..9d41d45 100644 --- a/packages/keyman/src/index.ts +++ b/packages/keyman/src/index.ts @@ -1,3 +1,3 @@ #!/usr/bin/env node -export * from './keyman.main.js'; export { loadConfig, resolveConfigPaths } from './keyman.config.js'; +export * from './keyman.main.js'; diff --git a/packages/keyman/src/keyman.config.ts b/packages/keyman/src/keyman.config.ts index 01645a8..c55c9e3 100644 --- a/packages/keyman/src/keyman.config.ts +++ b/packages/keyman/src/keyman.config.ts @@ -232,7 +232,7 @@ export function loadConfig(): KeymanConfig { } catch (error) { if (error instanceof z.ZodError) { console.error('❌ ERROR: Invalid merged configuration:'); - error.errors.forEach((err) => { + error.issues.forEach((err) => { console.error(` - ${err.path.join('.')}: ${err.message}`); }); } diff --git a/packages/keyman/src/keyman.main.ts b/packages/keyman/src/keyman.main.ts index f190b36..6bf7af8 100644 --- a/packages/keyman/src/keyman.main.ts +++ b/packages/keyman/src/keyman.main.ts @@ -1,6 +1,5 @@ import fs from 'node:fs'; import path from 'node:path'; -import { execa } from 'execa'; import inquirer from 'inquirer'; import { loadConfig, resolveConfigPaths } from './keyman.config.js'; import { copyKey } from './keyman.copy.js'; diff --git a/packages/keyman/tests/config.test.ts b/packages/keyman/tests/config.test.ts new file mode 100644 index 0000000..c8cc3c9 --- /dev/null +++ b/packages/keyman/tests/config.test.ts @@ -0,0 +1,294 @@ +/** + * Tests for keyman config discovery, merging and path resolution. + * + * Real .keymanrc.json files are written into temp directories and cwd is moved + * there, because discovery is defined in terms of the real filesystem walk. + * os.homedir() is stubbed so the developer's own home config cannot leak in. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getConfigPaths, + type KeymanConfigFile, + loadConfig, + resolveConfigPaths, +} from '../src/keyman.config.js'; + +const DEFAULTS = { + vaultRoot: 'vault', + keysDir: 'keys', + tmpDir: 'tmp', + ageKeyFile: 'age.key', +}; + +describe('keyman config', () => { + let originalCwd: string; + let originalVaultRoot: string | undefined; + let rootDir: string; + let emptyHome: string; + let errorSpy: ReturnType; + let warnSpy: ReturnType; + + const write = (dir: string, config: KeymanConfigFile | string) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, '.keymanrc.json'), + typeof config === 'string' ? config : JSON.stringify(config, null, 2) + ); + }; + + const messages = (spy: ReturnType) => + spy.mock.calls.map((c) => c.join(' ')).join('\n'); + + beforeEach(() => { + originalCwd = process.cwd(); + originalVaultRoot = process.env.VAULT_ROOT; + delete process.env.VAULT_ROOT; + + rootDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-config-'))); + emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-home-'))); + vi.spyOn(os, 'homedir').mockReturnValue(emptyHome); + + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + process.chdir(rootDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + vi.restoreAllMocks(); + if (originalVaultRoot === undefined) { + delete process.env.VAULT_ROOT; + } else { + process.env.VAULT_ROOT = originalVaultRoot; + } + fs.rmSync(rootDir, { recursive: true, force: true }); + fs.rmSync(emptyHome, { recursive: true, force: true }); + }); + + describe('discovery', () => { + it('falls back to defaults when no config file exists', () => { + expect(loadConfig()).toEqual(DEFAULTS); + expect(messages(errorSpy)).toContain('No .keymanrc.json found'); + }); + + it('loads the config file in the current directory', () => { + write(rootDir, { keysDir: 'my-keys' }); + + expect(loadConfig().keysDir).toBe('my-keys'); + expect(messages(errorSpy)).toContain('Loaded configuration from'); + }); + + it('fills unspecified properties from the defaults', () => { + write(rootDir, { keysDir: 'my-keys' }); + + const config = loadConfig(); + + expect(config.tmpDir).toBe(DEFAULTS.tmpDir); + expect(config.ageKeyFile).toBe(DEFAULTS.ageKeyFile); + }); + + it('lets a child config override its parent', () => { + write(rootDir, { keysDir: 'parent-keys', tmpDir: 'parent-tmp' }); + const child = path.join(rootDir, 'nested'); + write(child, { keysDir: 'child-keys' }); + process.chdir(child); + + const config = loadConfig(); + + expect(config.keysDir).toBe('child-keys'); + expect(config.tmpDir).toBe('parent-tmp'); + }); + + it('gives the home config the lowest priority', () => { + write(emptyHome, { keysDir: 'home-keys', tmpDir: 'home-tmp' }); + write(rootDir, { keysDir: 'local-keys' }); + + const config = loadConfig(); + + expect(config.keysDir).toBe('local-keys'); + expect(config.tmpDir).toBe('home-tmp'); + }); + + it('does not load the home config twice when cwd is the home directory', () => { + write(emptyHome, { keysDir: 'home-keys' }); + process.chdir(emptyHome); + + const homeConfig = path.join(emptyHome, '.keymanrc.json'); + expect(getConfigPaths().filter((p) => p === homeConfig)).toHaveLength(1); + }); + + it('orders discovered config files parent first', () => { + write(rootDir, {}); + const child = path.join(rootDir, 'a', 'b'); + write(child, {}); + process.chdir(child); + + expect(getConfigPaths()).toEqual([ + path.join(rootDir, '.keymanrc.json'), + path.join(child, '.keymanrc.json'), + ]); + }); + }); + + describe('malformed configs', () => { + it('skips a file with invalid JSON and keeps the rest', () => { + write(rootDir, { keysDir: 'parent-keys' }); + const child = path.join(rootDir, 'nested'); + write(child, '{ not json'); + process.chdir(child); + + const config = loadConfig(); + + expect(config.keysDir).toBe('parent-keys'); + expect(messages(warnSpy)).toContain('Skipping invalid JSON in'); + }); + + it('skips a config it cannot read at all', () => { + // A directory where a file is expected: readFileSync fails with EISDIR, + // which is not a SyntaxError. + fs.mkdirSync(path.join(rootDir, '.keymanrc.json')); + + expect(loadConfig()).toEqual(DEFAULTS); + expect(messages(warnSpy)).toContain('Skipping config'); + expect(messages(warnSpy)).not.toContain('invalid JSON'); + }); + + it('falls back to defaults when the merged config fails validation', () => { + write(rootDir, { keysDir: 123 } as unknown as KeymanConfigFile); + + expect(loadConfig()).toEqual(DEFAULTS); + expect(messages(errorSpy)).toContain('Invalid merged configuration'); + expect(messages(errorSpy)).toContain('keysDir'); + expect(messages(errorSpy)).toContain('Falling back to default configuration'); + }); + }); + + describe('path resolution', () => { + it('resolves a relative vaultRoot against the config file directory', () => { + write(rootDir, { vaultRoot: './secrets' }); + + expect(loadConfig().vaultRoot).toBe(path.join(rootDir, 'secrets')); + }); + + it('resolves a vaultRoot that points above the config file', () => { + const child = path.join(rootDir, 'nested'); + write(child, { vaultRoot: '../secrets' }); + process.chdir(child); + + expect(loadConfig().vaultRoot).toBe(path.join(rootDir, 'secrets')); + }); + + it('leaves an absolute vaultRoot untouched', () => { + write(rootDir, { vaultRoot: '/srv/vault' }); + + expect(loadConfig().vaultRoot).toBe('/srv/vault'); + }); + + it('leaves non-path properties alone', () => { + write(rootDir, { keysDir: './keys', tmpDir: './tmp' }); + + const config = loadConfig(); + + expect(config.keysDir).toBe('./keys'); + expect(config.tmpDir).toBe('./tmp'); + }); + + it('resolves each config file against its own directory', () => { + write(rootDir, { vaultRoot: './parent-vault' }); + const child = path.join(rootDir, 'nested'); + write(child, {}); + process.chdir(child); + + expect(loadConfig().vaultRoot).toBe(path.join(rootDir, 'parent-vault')); + }); + }); + + describe('merge strategy', () => { + it('honours an explicit override strategy', () => { + write(rootDir, { vaultRoot: '/parent-vault' }); + const child = path.join(rootDir, 'nested'); + write(child, { vaultRoot: '/child-vault', resolution: { vaultRoot: 'override' } }); + process.chdir(child); + + expect(loadConfig().vaultRoot).toBe('/child-vault'); + }); + + it('never surfaces the resolution key in the loaded config', () => { + write(rootDir, { keysDir: 'my-keys', resolution: { keysDir: 'override' } }); + + expect(loadConfig()).not.toHaveProperty('resolution'); + }); + + it('tolerates and drops array-valued keys the schema does not define', () => { + write(rootDir, { extra: ['a', 'b'] } as unknown as KeymanConfigFile); + const child = path.join(rootDir, 'nested'); + write(child, { extra: ['b', 'c'], keysDir: 'my-keys' } as unknown as KeymanConfigFile); + process.chdir(child); + + const config = loadConfig(); + + expect(config).toEqual({ ...DEFAULTS, keysDir: 'my-keys' }); + }); + + it('tolerates and drops object-valued keys the schema does not define', () => { + write(rootDir, { extra: { a: 1 } } as unknown as KeymanConfigFile); + const child = path.join(rootDir, 'nested'); + write(child, { extra: { a: 2, b: 3 } } as unknown as KeymanConfigFile); + process.chdir(child); + + expect(loadConfig()).toEqual(DEFAULTS); + }); + + it('tolerates arrays of objects, which cannot be de-duplicated', () => { + write(rootDir, { extra: [{ a: 1 }] } as unknown as KeymanConfigFile); + const child = path.join(rootDir, 'nested'); + write(child, { extra: [{ a: 2 }] } as unknown as KeymanConfigFile); + process.chdir(child); + + expect(loadConfig()).toEqual(DEFAULTS); + }); + }); + + describe('resolveConfigPaths', () => { + it('places every directory under the vault root', () => { + const paths = resolveConfigPaths({ ...DEFAULTS, vaultRoot: '/srv/vault' }); + + expect(paths).toEqual({ + vaultRoot: '/srv/vault', + keysDir: '/srv/vault/keys', + tmpDir: '/srv/vault/tmp', + keyPath: '/srv/vault/age.key', + }); + }); + + it('resolves a relative vault root against the current directory', () => { + const paths = resolveConfigPaths({ ...DEFAULTS, vaultRoot: 'vault' }); + + expect(paths.vaultRoot).toBe(path.join(rootDir, 'vault')); + }); + + it('lets VAULT_ROOT take precedence over the config', () => { + process.env.VAULT_ROOT = '/env/vault'; + + const paths = resolveConfigPaths({ ...DEFAULTS, vaultRoot: '/srv/vault' }); + + expect(paths.vaultRoot).toBe('/env/vault'); + expect(paths.keyPath).toBe('/env/vault/age.key'); + }); + + it('honours absolute sub-directory overrides', () => { + const paths = resolveConfigPaths({ + ...DEFAULTS, + vaultRoot: '/srv/vault', + keysDir: '/elsewhere/keys', + }); + + expect(paths.keysDir).toBe('/elsewhere/keys'); + }); + }); +}); diff --git a/packages/keyman/tests/copy.test.ts b/packages/keyman/tests/copy.test.ts new file mode 100644 index 0000000..7c81adf --- /dev/null +++ b/packages/keyman/tests/copy.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for copyKey. + * + * inquirer and execa are mocked so nothing touches a TTY or the real + * clipboard; the key directories are real temp directories. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execa, prompt, stdin } = vi.hoisted(() => ({ + execa: vi.fn(), + prompt: vi.fn(), + stdin: { write: vi.fn(), end: vi.fn() }, +})); + +vi.mock('execa', () => ({ execa })); +vi.mock('inquirer', () => ({ default: { prompt } })); + +import { copyKey } from '../src/keyman.copy.js'; + +describe('copyKey', () => { + let root: string; + let sshDir: string; + let tmpDir: string; + let logSpy: ReturnType; + let errorSpy: ReturnType; + + const touch = (dir: string, file: string, contents = '') => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, file), contents); + }; + + const messages = (spy: ReturnType) => + spy.mock.calls.map((c) => c.join(' ')).join('\n'); + + /** The choices offered by the last inquirer.prompt call. */ + const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[]; + + beforeEach(() => { + vi.clearAllMocks(); + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-copy-'))); + sshDir = path.join(root, '.ssh'); + tmpDir = path.join(root, 'tmp'); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const proc = Object.assign(Promise.resolve({ exitCode: 0 }), { stdin }); + execa.mockReturnValue(proc); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('warns when neither directory exists', async () => { + await copyKey(sshDir, tmpDir); + + expect(messages(logSpy)).toContain('No SSH keys found.'); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('warns when the directories hold no private keys', async () => { + touch(sshDir, 'known_hosts'); + touch(tmpDir, 'id_prod.pub'); + + await copyKey(sshDir, tmpDir); + + expect(messages(logSpy)).toContain('No SSH keys found.'); + }); + + it('offers the keys from both directories without duplicates', async () => { + touch(sshDir, 'id_prod'); + touch(sshDir, 'id_prod.pub'); + touch(tmpDir, 'id_prod'); + touch(tmpDir, 'id_stage'); + prompt.mockResolvedValue({ selectedKey: 'id_prod' }); + touch(tmpDir, 'id_prod.pub'); + + await copyKey(sshDir, tmpDir); + + expect(choices()).toEqual(['id_prod', 'id_stage']); + }); + + it('copies the trimmed public key from tmp to the clipboard', async () => { + touch(tmpDir, 'id_prod'); + touch(tmpDir, 'id_prod.pub', 'ssh-ed25519 AAAA tmp\n'); + touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh\n'); + prompt.mockResolvedValue({ selectedKey: 'id_prod' }); + + await copyKey(sshDir, tmpDir); + + expect(execa).toHaveBeenCalledWith('pbcopy'); + expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA tmp'); + expect(stdin.end).toHaveBeenCalled(); + expect(messages(logSpy)).toContain('copied to clipboard'); + }); + + it('falls back to the public key in .ssh', async () => { + touch(sshDir, 'id_prod'); + touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh\n'); + prompt.mockResolvedValue({ selectedKey: 'id_prod' }); + + await copyKey(sshDir, tmpDir); + + expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA ssh'); + }); + + it('reports a missing public key without invoking the clipboard', async () => { + touch(sshDir, 'id_prod'); + prompt.mockResolvedValue({ selectedKey: 'id_prod' }); + + await copyKey(sshDir, tmpDir); + + expect(messages(errorSpy)).toContain('Public key not found for id_prod'); + expect(execa).not.toHaveBeenCalled(); + }); + + it('reports a clipboard failure instead of throwing', async () => { + touch(sshDir, 'id_prod'); + touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh'); + prompt.mockResolvedValue({ selectedKey: 'id_prod' }); + execa.mockImplementation(() => { + throw new Error('pbcopy missing'); + }); + + await expect(copyKey(sshDir, tmpDir)).resolves.toBeUndefined(); + expect(messages(errorSpy)).toContain('Failed to copy to clipboard'); + }); +}); diff --git a/packages/keyman/tests/decrypt.test.ts b/packages/keyman/tests/decrypt.test.ts new file mode 100644 index 0000000..11b65a2 --- /dev/null +++ b/packages/keyman/tests/decrypt.test.ts @@ -0,0 +1,134 @@ +/** + * Tests for decryptKeys. + * + * age, cp and chmod are all mocked; the assertions cover which keys are + * offered and exactly where each decrypted key is written. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() })); + +vi.mock('execa', () => ({ execa })); +vi.mock('inquirer', () => ({ default: { prompt } })); + +import { decryptKeys } from '../src/keyman.decrypt.js'; + +const LOCAL = 'Local (vault/tmp)'; +const SSH = 'SSH (~/.ssh)'; + +describe('decryptKeys', () => { + let root: string; + let sshDir: string; + let vaultDir: string; + let keyDir: string; + let logSpy: ReturnType; + + const AGE_KEY = '/vault/age.key'; + + /** Creates /keys//id_.{age,pub}. */ + const vaultKey = (name: string) => { + const dir = path.join(keyDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `id_${name}.age`), 'ENCRYPTED'); + fs.writeFileSync(path.join(dir, `id_${name}.pub`), 'PUBLIC'); + }; + + const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[]; + + const argsOf = (binary: string) => + execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined; + + const messages = (spy: ReturnType) => + spy.mock.calls.map((c) => c.join(' ')).join('\n'); + + beforeEach(() => { + vi.clearAllMocks(); + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-decrypt-'))); + sshDir = path.join(root, '.ssh'); + vaultDir = path.join(root, 'vault'); + keyDir = path.join(vaultDir, 'keys'); + fs.mkdirSync(keyDir, { recursive: true }); + fs.mkdirSync(sshDir, { recursive: true }); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + execa.mockResolvedValue({ exitCode: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('warns when the vault holds no encrypted keys', async () => { + await decryptKeys(sshDir, vaultDir, AGE_KEY); + + expect(messages(logSpy)).toContain('No encrypted keys found.'); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('offers only directories that actually contain an encrypted key', async () => { + vaultKey('prod'); + fs.mkdirSync(path.join(keyDir, 'empty'), { recursive: true }); + fs.writeFileSync(path.join(keyDir, 'README.md'), ''); + prompt.mockResolvedValue({ selectedKeys: [], decryptMode: LOCAL }); + + await decryptKeys(sshDir, vaultDir, AGE_KEY); + + expect(choices()).toEqual(['prod']); + }); + + it('decrypts into the vault tmp directory', async () => { + vaultKey('prod'); + prompt.mockResolvedValue({ selectedKeys: ['prod'], decryptMode: LOCAL }); + + await decryptKeys(sshDir, vaultDir, AGE_KEY); + + const out = path.join(vaultDir, 'tmp', 'id_prod'); + expect(argsOf('age')).toEqual([ + '-d', + '-i', + AGE_KEY, + '-o', + out, + path.join(keyDir, 'prod', 'id_prod.age'), + ]); + expect(argsOf('cp')).toEqual([path.join(keyDir, 'prod', 'id_prod.pub'), `${out}.pub`]); + expect(argsOf('chmod')).toEqual(['600', out]); + expect(messages(logSpy)).toContain(`Decrypted: ${out}`); + }); + + it('decrypts into the .ssh directory when asked', async () => { + vaultKey('prod'); + prompt.mockResolvedValue({ selectedKeys: ['prod'], decryptMode: SSH }); + + await decryptKeys(sshDir, vaultDir, AGE_KEY); + + const out = path.join(sshDir, 'id_prod'); + expect(argsOf('age')?.[4]).toBe(out); + expect(argsOf('cp')?.[1]).toBe(`${out}.pub`); + expect(argsOf('chmod')).toEqual(['600', out]); + }); + + it('decrypts every selected key', async () => { + vaultKey('prod'); + vaultKey('stage'); + prompt.mockResolvedValue({ selectedKeys: ['prod', 'stage'], decryptMode: LOCAL }); + + await decryptKeys(sshDir, vaultDir, AGE_KEY); + + // age, cp and chmod for each of the two keys. + expect(execa).toHaveBeenCalledTimes(6); + }); + + it('does nothing when the selection is empty', async () => { + vaultKey('prod'); + prompt.mockResolvedValue({ selectedKeys: [], decryptMode: LOCAL }); + + await decryptKeys(sshDir, vaultDir, AGE_KEY); + + expect(execa).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/keyman/tests/encrypt.test.ts b/packages/keyman/tests/encrypt.test.ts new file mode 100644 index 0000000..0b3e74f --- /dev/null +++ b/packages/keyman/tests/encrypt.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for encryptKeys. + * + * `age` is mocked out; everything the function does to the filesystem itself + * (creating the vault layout, copying public keys) is asserted for real. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() })); + +vi.mock('execa', () => ({ execa })); +vi.mock('inquirer', () => ({ default: { prompt } })); + +import { encryptKeys } from '../src/keyman.encrypt.js'; + +describe('encryptKeys', () => { + let root: string; + let sshDir: string; + let vaultDir: string; + let tmpDir: string; + let logSpy: ReturnType; + + const PUBKEY = 'age1recipient'; + + const key = (dir: string, name: string, marker: string) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, name), `PRIVATE ${marker}`); + fs.writeFileSync(path.join(dir, `${name}.pub`), `PUBLIC ${marker}`); + }; + + const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[]; + + const messages = (spy: ReturnType) => + spy.mock.calls.map((c) => c.join(' ')).join('\n'); + + beforeEach(() => { + vi.clearAllMocks(); + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-encrypt-'))); + sshDir = path.join(root, '.ssh'); + vaultDir = path.join(root, 'vault'); + tmpDir = path.join(root, 'vault', 'tmp'); + fs.mkdirSync(sshDir, { recursive: true }); + fs.mkdirSync(tmpDir, { recursive: true }); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Stand in for `age`: record the call and write the output file. + execa.mockImplementation(async (_binary: string, args: string[]) => { + fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED'); + return { exitCode: 0 }; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('warns when there is nothing to encrypt', async () => { + await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + + expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('ignores public keys and unrelated files when building the list', async () => { + fs.writeFileSync(path.join(sshDir, 'known_hosts'), ''); + fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC'); + + await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + + expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); + }); + + it('offers the keys from .ssh and tmp without duplicates', async () => { + key(sshDir, 'id_prod', 'ssh'); + key(tmpDir, 'id_prod', 'tmp'); + key(tmpDir, 'id_stage', 'tmp'); + prompt.mockResolvedValue({ selectedKeys: [] }); + + await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + + expect(choices()).toEqual(['id_prod', 'id_stage']); + }); + + it('encrypts a key from .ssh into the vault', async () => { + key(sshDir, 'id_prod', 'ssh'); + prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); + + await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + + const vaultPath = path.join(vaultDir, 'keys', 'prod'); + expect(execa).toHaveBeenCalledWith('age', [ + '-r', + PUBKEY, + '-o', + path.join(vaultPath, 'id_prod.age'), + path.join(sshDir, 'id_prod'), + ]); + expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe('PUBLIC ssh'); + expect(messages(logSpy)).toContain('Encrypted and stored'); + }); + + it('prefers the tmp copy when a key exists in both directories', async () => { + key(sshDir, 'id_prod', 'ssh'); + key(tmpDir, 'id_prod', 'tmp'); + prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); + + await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + + expect(execa.mock.calls[0][1]).toContain(path.join(tmpDir, 'id_prod')); + expect(fs.readFileSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.pub'), 'utf-8')).toBe( + 'PUBLIC tmp' + ); + }); + + it('encrypts every selected key', async () => { + key(sshDir, 'id_prod', 'ssh'); + key(sshDir, 'id_stage', 'ssh'); + prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] }); + + await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + + expect(execa).toHaveBeenCalledTimes(2); + expect(fs.existsSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.age'))).toBe(true); + expect(fs.existsSync(path.join(vaultDir, 'keys', 'stage', 'id_stage.age'))).toBe(true); + }); + + it('does nothing when the selection is empty', async () => { + key(sshDir, 'id_prod', 'ssh'); + prompt.mockResolvedValue({ selectedKeys: [] }); + + await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); + + expect(execa).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(vaultDir, 'keys'))).toBe(false); + }); +}); diff --git a/packages/keyman/tests/generate.test.ts b/packages/keyman/tests/generate.test.ts new file mode 100644 index 0000000..dce0ec3 --- /dev/null +++ b/packages/keyman/tests/generate.test.ts @@ -0,0 +1,164 @@ +/** + * Tests for generateKey. + * + * ssh-keygen and age are mocked; the ssh-keygen mock writes the files the real + * binary would produce so the copy-into-vault step has something to work with. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() })); + +vi.mock('execa', () => ({ execa })); +vi.mock('inquirer', () => ({ default: { prompt } })); + +import { generateKey } from '../src/keyman.generate.js'; + +describe('generateKey', () => { + let root: string; + let tmpDir: string; + let keysDir: string; + let logSpy: ReturnType; + let errorSpy: ReturnType; + + const PUBKEY = 'age1recipient'; + + /** Answers each prompt by the name of the question it asks. */ + const answer = (answers: Record) => { + prompt.mockImplementation(async (questions: { name: string }[]) => { + const { name } = questions[0]; + return { [name]: answers[name] ?? '' }; + }); + }; + + /** The question object from the prompt call for `name`. */ + const question = (name: string) => + prompt.mock.calls.map((c) => c[0][0]).find((q) => q.name === name); + + /** The argv of the mocked call to `binary`. */ + const argsOf = (binary: string) => + execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined; + + const messages = (spy: ReturnType) => + spy.mock.calls.map((c) => c.join(' ')).join('\n'); + + beforeEach(() => { + vi.clearAllMocks(); + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-generate-'))); + tmpDir = path.join(root, 'tmp'); + keysDir = path.join(root, 'keys'); + fs.mkdirSync(tmpDir, { recursive: true }); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Stand in for the real binaries: ssh-keygen writes a key pair, age is a no-op. + execa.mockImplementation(async (binary: string, args: string[]) => { + if (binary === 'ssh-keygen') { + const keyPath = args[args.indexOf('-f') + 1]; + fs.writeFileSync(keyPath, 'PRIVATE'); + fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 AAAA generated'); + } + return { exitCode: 0 }; + }); + + answer({ algorithm: 'ed25519', keyName: 'prod', password: 'pw', identity: 'me@host' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('generates the key pair with the answers it collected', async () => { + await generateKey(tmpDir, keysDir, PUBKEY); + + expect(argsOf('ssh-keygen')).toEqual([ + '-t', + 'ed25519', + '-f', + path.join(tmpDir, 'id_prod'), + '-N', + 'pw', + '-C', + 'me@host', + ]); + expect(messages(logSpy)).toContain('Key generated'); + }); + + it('does not prefix a key name that already starts with id_', async () => { + answer({ algorithm: 'ed25519', keyName: 'id_prod', password: '', identity: '' }); + + await generateKey(tmpDir, keysDir, PUBKEY); + + expect(argsOf('ssh-keygen')).toContain(path.join(tmpDir, 'id_prod')); + }); + + it('requests a 4096 bit key for rsa', async () => { + answer({ algorithm: 'rsa', keyName: 'prod', password: '', identity: '' }); + + await generateKey(tmpDir, keysDir, PUBKEY); + + expect(argsOf('ssh-keygen')?.slice(-2)).toEqual(['-b', '4096']); + }); + + it('rejects an empty key name', async () => { + await generateKey(tmpDir, keysDir, PUBKEY); + + const { validate } = question('keyName'); + expect(validate(' ')).toBe('Key name cannot be empty'); + expect(validate('prod')).toBe(true); + }); + + it('encrypts the new key into the vault and copies the public key', async () => { + await generateKey(tmpDir, keysDir, PUBKEY); + + const vaultPath = path.join(keysDir, 'prod'); + expect(argsOf('age')).toEqual([ + '-r', + PUBKEY, + '-o', + path.join(vaultPath, 'id_prod.age'), + path.join(tmpDir, 'id_prod'), + ]); + expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe( + 'ssh-ed25519 AAAA generated' + ); + expect(messages(logSpy)).toContain('Encrypted and stored'); + }); + + it('refuses to overwrite an existing key file', async () => { + fs.writeFileSync(path.join(tmpDir, 'id_prod'), 'EXISTING'); + + await generateKey(tmpDir, keysDir, PUBKEY); + + expect(messages(errorSpy)).toContain('Key file id_prod already exists'); + expect(execa).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(tmpDir, 'id_prod'), 'utf-8')).toBe('EXISTING'); + }); + + it('reports a failure from ssh-keygen without leaving a vault entry', async () => { + execa.mockRejectedValue(new Error('ssh-keygen exploded')); + + await expect(generateKey(tmpDir, keysDir, PUBKEY)).resolves.toBeUndefined(); + expect(messages(errorSpy)).toContain('Error generating/encrypting key'); + expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false); + }); + + it('reports a failure from age', async () => { + execa.mockImplementation(async (binary: string, args: string[]) => { + if (binary === 'age') throw new Error('age exploded'); + const keyPath = args[args.indexOf('-f') + 1]; + fs.writeFileSync(keyPath, 'PRIVATE'); + fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 AAAA generated'); + return { exitCode: 0 }; + }); + + await generateKey(tmpDir, keysDir, PUBKEY); + + expect(messages(errorSpy)).toContain('Error generating/encrypting key'); + expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.pub'))).toBe(false); + }); +}); diff --git a/packages/keyman/tests/list.test.ts b/packages/keyman/tests/list.test.ts new file mode 100644 index 0000000..013bffe --- /dev/null +++ b/packages/keyman/tests/list.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for listKeys. + * + * listKeys is pure filesystem inspection plus console output, so it runs + * against real temp directories and the assertions are made on what it prints. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { listKeys } from '../src/keyman.list.js'; + +describe('listKeys', () => { + let root: string; + let sshDir: string; + let vaultDir: string; + let tmpDir: string; + let logSpy: ReturnType; + + /** The single output line describing `name`, without padding noise. */ + const row = (name: string) => + logSpy.mock.calls + .map((c) => c.join(' ')) + .find((line) => line.includes(`${name} `) || line.includes(`${name}(`)) + ?.replace(/ +/g, ' '); + + const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + + const touch = (dir: string, file: string) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, file), ''); + }; + + /** Creates a vault entry: //id_.age */ + const vaultKey = (name: string) => touch(path.join(vaultDir, name), `id_${name}.age`); + + beforeEach(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-list-'))); + sshDir = path.join(root, '.ssh'); + vaultDir = path.join(root, 'keys'); + tmpDir = path.join(root, 'tmp'); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reports the directories it inspected', async () => { + await listKeys(sshDir, vaultDir, tmpDir); + + expect(output()).toContain(sshDir); + expect(output()).toContain(vaultDir); + expect(output()).toContain(tmpDir); + }); + + it('warns when none of the directories exist', async () => { + await listKeys(sshDir, vaultDir, tmpDir); + + expect(output()).toContain('No SSH keys found.'); + expect(output()).not.toContain('SSH Keys:'); + }); + + it('warns when the directories exist but hold no id_ files', async () => { + fs.mkdirSync(sshDir, { recursive: true }); + fs.mkdirSync(tmpDir, { recursive: true }); + fs.mkdirSync(vaultDir, { recursive: true }); + fs.writeFileSync(path.join(sshDir, 'known_hosts'), ''); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(output()).toContain('No SSH keys found.'); + }); + + it('marks a key present in the vault and in .ssh as managed', async () => { + touch(sshDir, 'id_prod'); + vaultKey('prod'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_prod')).toContain('✅'); + expect(row('id_prod')).toContain('[✓] [ ] [✓]'); + }); + + it('marks a key decrypted into tmp as decrypted', async () => { + touch(tmpDir, 'id_stage'); + vaultKey('stage'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_stage')).toContain('🔓'); + expect(row('id_stage')).toContain('[✓] [✓] [ ]'); + }); + + it('marks a key only present in the vault as encrypted', async () => { + vaultKey('cold'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_cold')).toContain('🔒'); + expect(row('id_cold')).toContain('[✓] [ ] [ ]'); + }); + + it('marks a key missing from the vault as unmanaged', async () => { + touch(sshDir, 'id_loose'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_loose')).toContain('⚠️'); + expect(row('id_loose')).toContain('[ ] [ ] [✓]'); + }); + + it('shows a .pub indicator for a public key found in .ssh', async () => { + touch(sshDir, 'id_prod'); + touch(sshDir, 'id_prod.pub'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_prod')).toContain('id_prod (.pub)'); + }); + + it('shows a .pub indicator for a public key found in tmp', async () => { + touch(tmpDir, 'id_prod'); + touch(tmpDir, 'id_prod.pub'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_prod')).toContain('id_prod (.pub)'); + }); + + it('lists a public key with no matching private key', async () => { + touch(sshDir, 'id_orphan.pub'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_orphan')).toContain('id_orphan (.pub)'); + // No private key anywhere, so every column stays blank. + expect(row('id_orphan')).toContain('[ ] [ ] [ ]'); + }); + + it('lists a tmp public key with no matching private key', async () => { + touch(tmpDir, 'id_orphan.pub'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_orphan')).toContain('id_orphan (.pub)'); + }); + + it('merges the same key seen in .ssh, tmp and the vault', async () => { + touch(sshDir, 'id_shared'); + touch(tmpDir, 'id_shared'); + touch(tmpDir, 'id_shared.pub'); + vaultKey('shared'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_shared')).toContain('[✓] [✓] [✓]'); + // Vault plus .ssh wins over the decrypted-to-tmp status. + expect(row('id_shared')).toContain('✅'); + }); + + it('ignores files in the .ssh directory that are not keys', async () => { + touch(sshDir, 'config'); + touch(sshDir, 'known_hosts'); + touch(sshDir, 'id_real'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(output()).not.toContain('known_hosts'); + expect(row('id_real')).toBeDefined(); + }); + + it('ignores vault directories with no encrypted key inside', async () => { + fs.mkdirSync(path.join(vaultDir, 'empty'), { recursive: true }); + vaultKey('real'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(row('id_empty')).toBeUndefined(); + expect(row('id_real')).toBeDefined(); + }); + + it('ignores loose files sitting next to the vault directories', async () => { + vaultKey('real'); + fs.writeFileSync(path.join(vaultDir, 'README.md'), ''); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(output()).not.toContain('id_README'); + }); + + it('sorts keys by name', async () => { + touch(sshDir, 'id_charlie'); + touch(sshDir, 'id_alpha'); + touch(sshDir, 'id_bravo'); + + await listKeys(sshDir, vaultDir, tmpDir); + + const names = output() + .split('\n') + .filter((line) => line.includes('id_')) + .map((line) => line.match(/id_\w+/)?.[0]); + expect(names).toEqual(['id_alpha', 'id_bravo', 'id_charlie']); + }); + + it('prints the legend once keys are listed', async () => { + touch(sshDir, 'id_prod'); + + await listKeys(sshDir, vaultDir, tmpDir); + + expect(output()).toContain('Legend:'); + }); +}); diff --git a/packages/keyman/tests/main.test.ts b/packages/keyman/tests/main.test.ts new file mode 100644 index 0000000..aca6621 --- /dev/null +++ b/packages/keyman/tests/main.test.ts @@ -0,0 +1,216 @@ +/** + * Tests for the keyman() menu loop. + * + * Every operation it dispatches to has its own suite, so they are all mocked + * here: what is under test is path resolution, dispatch and the loop itself. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + prompt, + loadConfig, + resolveConfigPaths, + listKeys, + copyKey, + generateKey, + encryptKeys, + decryptKeys, + extractAgePublicKey, +} = vi.hoisted(() => ({ + prompt: vi.fn(), + loadConfig: vi.fn(), + resolveConfigPaths: vi.fn(), + listKeys: vi.fn(), + copyKey: vi.fn(), + generateKey: vi.fn(), + encryptKeys: vi.fn(), + decryptKeys: vi.fn(), + extractAgePublicKey: vi.fn(), +})); + +vi.mock('inquirer', () => ({ default: { prompt } })); +vi.mock('../src/keyman.config.js', () => ({ loadConfig, resolveConfigPaths })); +vi.mock('../src/keyman.list.js', () => ({ listKeys })); +vi.mock('../src/keyman.copy.js', () => ({ copyKey })); +vi.mock('../src/keyman.generate.js', () => ({ generateKey })); +vi.mock('../src/keyman.encrypt.js', () => ({ encryptKeys })); +vi.mock('../src/keyman.decrypt.js', () => ({ decryptKeys })); +vi.mock('../src/keyman.utils.js', () => ({ extractAgePublicKey })); + +import { keyman } from '../src/keyman.main.js'; + +describe('keyman', () => { + let root: string; + let paths: { vaultRoot: string; keysDir: string; tmpDir: string; keyPath: string }; + let originalHome: string | undefined; + let logSpy: ReturnType; + let errorSpy: ReturnType; + + /** Answers the leading `user` prompt, then walks the given menu choices. */ + const menu = (categories: string[], user = '@current') => { + const queue = [...categories, 'quit']; + prompt.mockImplementation(async (questions: { name: string }[]) => { + const { name } = questions[0]; + if (name === 'user') return { user }; + return { category: queue.shift() }; + }); + }; + + const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + + beforeEach(() => { + vi.clearAllMocks(); + originalHome = process.env.HOME; + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-main-'))); + process.env.HOME = path.join(root, 'home'); + + paths = { + vaultRoot: path.join(root, 'vault'), + keysDir: path.join(root, 'vault', 'keys'), + tmpDir: path.join(root, 'vault', 'tmp'), + keyPath: path.join(root, 'vault', 'age.key'), + }; + loadConfig.mockReturnValue({ + vaultRoot: 'vault', + keysDir: 'keys', + tmpDir: 'tmp', + ageKeyFile: 'age.key', + }); + resolveConfigPaths.mockReturnValue(paths); + extractAgePublicKey.mockReturnValue('age1recipient'); + + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + menu([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('prints the resolved paths and creates the vault directories', async () => { + await keyman(); + + expect(output()).toContain(paths.vaultRoot); + expect(output()).toContain(paths.keysDir); + expect(output()).toContain(paths.keyPath); + expect(fs.existsSync(paths.vaultRoot)).toBe(true); + expect(fs.existsSync(paths.tmpDir)).toBe(true); + }); + + it('quits without running any operation', async () => { + await keyman(); + + expect(output()).toContain('Goodbye!'); + expect(listKeys).not.toHaveBeenCalled(); + }); + + it('offers every operation in the menu', async () => { + await keyman(); + + const menuQuestion = prompt.mock.calls.at(-1)?.[0][0] as { choices: { value: string }[] }; + expect(menuQuestion.choices.map((c) => c.value)).toEqual([ + 'list', + 'copy', + 'generate', + 'encrypt', + 'decrypt', + 'quit', + ]); + }); + + it('lists keys against the .ssh directory of the current user', async () => { + menu(['list']); + + await keyman(); + + expect(listKeys).toHaveBeenCalledWith( + path.join(process.env.HOME as string, '.ssh'), + paths.keysDir, + paths.tmpDir + ); + }); + + it('copies a public key', async () => { + menu(['copy']); + + await keyman(); + + expect(copyKey).toHaveBeenCalledWith( + path.join(process.env.HOME as string, '.ssh'), + paths.tmpDir + ); + }); + + it('generates a key with the age recipient from the key file', async () => { + menu(['generate']); + + await keyman(); + + expect(extractAgePublicKey).toHaveBeenCalledWith(paths.keyPath); + expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1recipient'); + }); + + it('encrypts keys into the vault root', async () => { + menu(['encrypt']); + + await keyman(); + + expect(encryptKeys).toHaveBeenCalledWith( + path.join(process.env.HOME as string, '.ssh'), + paths.vaultRoot, + paths.tmpDir, + 'age1recipient' + ); + }); + + it('decrypts keys using the age identity file', async () => { + menu(['decrypt']); + + await keyman(); + + expect(decryptKeys).toHaveBeenCalledWith( + path.join(process.env.HOME as string, '.ssh'), + paths.vaultRoot, + paths.keyPath + ); + }); + + it('keeps showing the menu until the user quits', async () => { + menu(['list', 'copy', 'list']); + + await keyman(); + + expect(listKeys).toHaveBeenCalledTimes(2); + expect(copyKey).toHaveBeenCalledTimes(1); + }); + + it('targets another user home directory when a user is named', async () => { + menu(['list'], 'deploy'); + + await keyman(); + + expect(listKeys).toHaveBeenCalledWith('/home/deploy/.ssh', paths.keysDir, paths.tmpDir); + }); + + it('aborts when the home directory cannot be determined', async () => { + delete process.env.HOME; + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + await expect(keyman()).rejects.toThrow('process.exit'); + expect(exit).toHaveBeenCalledWith(1); + expect(errorSpy.mock.calls[0][0]).toContain('Unable to determine HOME directory'); + }); +}); diff --git a/packages/keyman/tests/utils.test.ts b/packages/keyman/tests/utils.test.ts new file mode 100644 index 0000000..15c4770 --- /dev/null +++ b/packages/keyman/tests/utils.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for extractAgePublicKey. + * + * Runs against real files in a temp directory: the function is a thin wrapper + * around fs plus a regex, and faking fs would only test the fake. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { extractAgePublicKey } from '../src/keyman.utils.js'; + +describe('extractAgePublicKey', () => { + let tmpDir: string; + let errorSpy: ReturnType; + + const keyFile = (contents: string) => { + const file = path.join(tmpDir, 'age.key'); + fs.writeFileSync(file, contents); + return file; + }; + + beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-utils-'))); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns the public key from a standard age key file', () => { + const file = keyFile( + [ + '# created: 2026-01-01T00:00:00Z', + '# public key: age1abc123xyz', + 'AGE-SECRET-KEY-1QQQ', + ].join('\n') + ); + + expect(extractAgePublicKey(file)).toBe('age1abc123xyz'); + }); + + it('tolerates extra whitespace after the label', () => { + const file = keyFile('# public key: age1spaced\n'); + + expect(extractAgePublicKey(file)).toBe('age1spaced'); + }); + + it('returns null and reports when the file does not exist', () => { + const missing = path.join(tmpDir, 'nope.key'); + + expect(extractAgePublicKey(missing)).toBeNull(); + expect(errorSpy.mock.calls[0][0]).toContain('Age key file not found'); + }); + + it('returns null when the file has no public key line', () => { + const file = keyFile('AGE-SECRET-KEY-1QQQ\n'); + + expect(extractAgePublicKey(file)).toBeNull(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('ignores a key that is not on its own line', () => { + const file = keyFile('prefix # public key: age1inline\n'); + + expect(extractAgePublicKey(file)).toBeNull(); + }); + + it('returns null and reports when the file cannot be read', () => { + const asDirectory = path.join(tmpDir, 'age.key'); + fs.mkdirSync(asDirectory); + + expect(extractAgePublicKey(asDirectory)).toBeNull(); + expect(errorSpy.mock.calls[0][0]).toContain('Failed to read key file'); + }); +}); diff --git a/packages/keyman/tsconfig.json b/packages/keyman/tsconfig.json index c0f22cd..8ae16a2 100644 --- a/packages/keyman/tsconfig.json +++ b/packages/keyman/tsconfig.json @@ -1,13 +1,13 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "tsBuildInfoFile": ".tsbuildinfo", "outDir": "dist", "rootDir": "src", "lib": ["ES2020"], "composite": true, "module": "NodeNext", - "types": ["jest", "node"] + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["coverage", "node_modules", "dist"], diff --git a/packages/keyman/vitest.config.ts b/packages/keyman/vitest.config.ts new file mode 100644 index 0000000..74ff4c1 --- /dev/null +++ b/packages/keyman/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + pool: 'forks', // Use forks instead of threads to support process.chdir() + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary', 'html'], + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.test.ts', + // Pure re-export barrel: no logic to cover. + 'src/index.ts', + // Argv wiring only; behaviour lives in the modules it calls. + 'src/keyman.cli.ts', + ], + thresholds: { + branches: 85, + functions: 85, + lines: 80, + statements: 80, + }, + }, + }, +}); diff --git a/packages/nopy/LICENSE b/packages/nopy/LICENSE new file mode 100644 index 0000000..9536813 --- /dev/null +++ b/packages/nopy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 bitsquare + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/nopy/cubes/apt/all/manifest.mjs b/packages/nopy/cubes/apt/all/manifest.mjs index 67924ba..9d8a9b7 100644 --- a/packages/nopy/cubes/apt/all/manifest.mjs +++ b/packages/nopy/cubes/apt/all/manifest.mjs @@ -1,5 +1,4 @@ import { cubes } from '@bitstack/nopy'; -import { z } from 'zod'; export default cubes.Manifest({ name: '[apt-all] Test dependencies', diff --git a/packages/nopy/cubes/apt/more/manifest.mjs b/packages/nopy/cubes/apt/more/manifest.mjs index b37794a..3f1c76f 100644 --- a/packages/nopy/cubes/apt/more/manifest.mjs +++ b/packages/nopy/cubes/apt/more/manifest.mjs @@ -1,5 +1,4 @@ import { cubes } from '@bitstack/nopy'; -import { z } from 'zod'; export default cubes.Manifest({ name: '[apt-more] Test dependencies', diff --git a/packages/nopy/dist/cubes/dependencies.d.ts b/packages/nopy/dist/cubes/dependencies.d.ts deleted file mode 100644 index c9f0915..0000000 --- a/packages/nopy/dist/cubes/dependencies.d.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Dynamic dependency resolution for cubes - * @module cubes/dependencies - */ -import type { Variables } from '../nopy.common.js'; -import type { NopyConfig } from '../nopy.config.js'; -import type { DeployCall } from '../nopy.executor.js'; -import { type CubeSession, type NopySession } from '../nopy.session.js'; -import type { Cube, CubeVariables } from './types.js'; -/** - * Context for the resolution process - */ -export declare class BuildContext { - readonly allCubes: Record; - readonly variables: Variables; - readonly session: NopySession; - readonly config: NopyConfig; - readonly auth: { - method: string; - username?: string; - password?: string; - }; - readonly options: { - useDefaults?: boolean; - isSessionReplay?: boolean; - }; - readonly deployCalls: DeployCall[]; - readonly cubeSessions: CubeSession[]; - private readonly resolvedCubes; - constructor(allCubes: Record, variables: Variables, session: NopySession, config: NopyConfig, auth: { - method: string; - username?: string; - password?: string; - }, options?: { - useDefaults?: boolean; - isSessionReplay?: boolean; - }); - /** - * Resolves a cube, its dependencies, and hooks recursively - */ - resolveCube(cubeId: string, host: string, overrides?: CubeVariables): Promise; - /** - * Builds and stores a deployment call for a resolved cube - */ - private buildDeployCall; -} diff --git a/packages/nopy/dist/cubes/dependencies.js b/packages/nopy/dist/cubes/dependencies.js deleted file mode 100644 index 7e46643..0000000 --- a/packages/nopy/dist/cubes/dependencies.js +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Dynamic dependency resolution for cubes - * @module cubes/dependencies - */ -import { getLogger } from '@logtape/logtape'; -import { VariableAssignment } from '../nopy.prompts.js'; -const log = getLogger(['nopy', 'resolution']); -/** - * Context for the resolution process - */ -export class BuildContext { - allCubes; - variables; - session; - config; - auth; - options; - deployCalls = []; - cubeSessions = []; - resolvedCubes = new Set(); - constructor(allCubes, variables, session, config, auth, options = {}) { - this.allCubes = allCubes; - this.variables = variables; - this.session = session; - this.config = config; - this.auth = auth; - this.options = options; - } - /** - * Resolves a cube, its dependencies, and hooks recursively - */ - async resolveCube(cubeId, host, overrides = {}) { - const cube = this.allCubes[cubeId]; - if (!cube) { - throw new Error(`Cube not found: ${cubeId}`); - } - log.debug('Resolving cube', { cubeId, host }); - // 1. Assign overrides and defaults - if (Object.keys(overrides).length > 0) { - this.variables.assign(cubeId, 'params', overrides); - } - this.variables.assign(cubeId, 'defaults', cube.getDefaults()); - // 2. Variable collection - if (this.options.isSessionReplay) { - const sessionCube = this.session.cubes.find(c => c.key === cubeId); - if (sessionCube) { - this.variables.assign(cubeId, 'defaults', sessionCube.variables); - } - } - else { - await VariableAssignment(cube, this.variables); - } - const currentVars = this.variables.get(cubeId); - const hookCtx = { - exec: (id, vars) => this.resolveCube(id, host, vars), - }; - // 3. Execute 'before' hooks - if (cube.manifest.before) { - for (const hook of cube.manifest.before) { - await hook(hookCtx, currentVars); - } - } - // 4. Resolve dynamic dependencies - const depSpecs = cube.manifest.dependencies?.(currentVars) ?? []; - for (const spec of depSpecs) { - const depId = typeof spec === 'string' ? spec : spec[0]; - const depVars = typeof spec === 'string' ? {} : (spec[1] ?? {}); - await this.resolveCube(depId, host, depVars); - } - // 5. Build the deployment call - this.buildDeployCall(cube, host); - // 6. Execute 'after' hooks - if (cube.manifest.after) { - for (const hook of cube.manifest.after) { - await hook(hookCtx, currentVars); - } - } - } - /** - * Builds and stores a deployment call for a resolved cube - */ - buildDeployCall(cube, host) { - const cubeId = cube.id; - const callKey = `${cubeId}:${host}`; - if (this.resolvedCubes.has(callKey)) - return; - const parts = []; - if (this.auth.method === 'password' && this.auth.username && this.auth.password) { - parts.push(`--user ${this.auth.username} --password ${this.auth.password}`); - } - const cubeVars = this.variables.get(cubeId); - Object.entries(cubeVars).forEach(([key, value]) => { - parts.push(`--data "${key}=${value}"`); - }); - parts.push(`--chdir ${cube.dir}`); - parts.push(`${cube.dir}/${cube.deployScript}`); - const command = ['pyinfra', host, '-y', ...parts]; - this.deployCalls.push({ - cube: cubeId, - host, - cwd: cube.dir, - command, - env: cubeVars, - dependencies: [], - }); - if (!this.cubeSessions.some(s => s.key === cubeId)) { - this.cubeSessions.push({ - key: cubeId, - variables: this.variables.get(cubeId, 'prompts'), - }); - } - this.resolvedCubes.add(callKey); - } -} diff --git a/packages/nopy/dist/cubes/factories.d.ts b/packages/nopy/dist/cubes/factories.d.ts deleted file mode 100644 index b580049..0000000 --- a/packages/nopy/dist/cubes/factories.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Factory functions for creating cube configurations - * @module cubes/factories - */ -import { Manifest } from './types.js'; -/** - * Creates a manifest configuration for a cube - * - * @param opts - Manifest options including name, schema, dependencies, and hooks - * @returns Manifest configuration object - */ -export declare function createManifest(opts: Pick, 'name'> & Partial, 'name'>>): Manifest; -/** - * Alias for createManifest - for backwards compatibility with existing manifests - */ -export declare const manifest: typeof createManifest; -/** - * @deprecated Use createManifest or manifest instead - */ -export declare const ManifestFactory: typeof createManifest; -export { Manifest } from './types.js'; diff --git a/packages/nopy/dist/cubes/factories.js b/packages/nopy/dist/cubes/factories.js deleted file mode 100644 index 5f2af2d..0000000 --- a/packages/nopy/dist/cubes/factories.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Factory functions for creating cube configurations - * @module cubes/factories - */ -import { Manifest } from './types.js'; -/** - * Creates a manifest configuration for a cube - * - * @param opts - Manifest options including name, schema, dependencies, and hooks - * @returns Manifest configuration object - */ -export function createManifest(opts) { - return Manifest(opts); -} -/** - * Alias for createManifest - for backwards compatibility with existing manifests - */ -export const manifest = createManifest; -/** - * @deprecated Use createManifest or manifest instead - */ -export const ManifestFactory = createManifest; -export { Manifest } from './types.js'; diff --git a/packages/nopy/dist/cubes/index.d.ts b/packages/nopy/dist/cubes/index.d.ts deleted file mode 100644 index 5ef1625..0000000 --- a/packages/nopy/dist/cubes/index.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Nopy Cubes Module - * - * Self-contained deployment units for pyinfra automation. - * - * @module cubes - */ -export { Cube, Manifest, } from './types.js'; -export type { Hook, HookContext, LoadResult, CubeVariables, DependencySpec, } from './types.js'; -export { createManifest, manifest, } from './factories.js'; -export { loadCubes, findCubeDirectories, getCube, } from './loader.js'; -export { BuildContext, } from './dependencies.js'; -export { uniqid } from './utils.js'; diff --git a/packages/nopy/dist/cubes/index.js b/packages/nopy/dist/cubes/index.js deleted file mode 100644 index d001d15..0000000 --- a/packages/nopy/dist/cubes/index.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Nopy Cubes Module - * - * Self-contained deployment units for pyinfra automation. - * - * @module cubes - */ -// Types -export { Cube, Manifest, } from './types.js'; -// Factory functions -export { createManifest, manifest, } from './factories.js'; -// Loader -export { loadCubes, findCubeDirectories, getCube, } from './loader.js'; -// Dependencies -export { BuildContext, } from './dependencies.js'; -// Utilities -export { uniqid } from './utils.js'; diff --git a/packages/nopy/dist/cubes/loader.d.ts b/packages/nopy/dist/cubes/loader.d.ts deleted file mode 100644 index 6e7cb00..0000000 --- a/packages/nopy/dist/cubes/loader.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Cube discovery and loading from the filesystem - * @module cubes/loader - */ -import { Cube, type LoadResult } from './types.js'; -/** - * Traverses upwards from the current working directory to the root - * and collects all directories that contain a `.npcubes` marker file. - * - * Also includes directories specified in the `.nopyrc.json` configuration. - * - * @returns Array of absolute paths to directories containing cubes - */ -export declare function findCubeDirectories(): string[]; -/** - * Loads all cubes from discovered cube directories. - */ -export declare function loadCubes(): Promise; -/** - * Gets information about a single cube by name. - */ -export declare function getCube(cubeName: string): Promise; diff --git a/packages/nopy/dist/cubes/loader.js b/packages/nopy/dist/cubes/loader.js deleted file mode 100644 index d3f9f7f..0000000 --- a/packages/nopy/dist/cubes/loader.js +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Cube discovery and loading from the filesystem - * @module cubes/loader - */ -import path from 'node:path'; -import { z } from 'zod'; -import { fs } from 'zx'; -import { loadConfig } from '../nopy.config.js'; -import { Cube } from './types.js'; -/** - * Traverses upwards from the current working directory to the root - * and collects all directories that contain a `.npcubes` marker file. - * - * Also includes directories specified in the `.nopyrc.json` configuration. - * - * @returns Array of absolute paths to directories containing cubes - */ -export function findCubeDirectories() { - let currentDir = process.cwd(); - const config = loadConfig(); - const dirSet = new Set(config.cubeDirs.map((dir) => path.resolve(process.cwd(), dir))); - while (true) { - const targetFile = path.join(currentDir, '.npcubes'); - if (fs.existsSync(targetFile) && fs.statSync(targetFile).isFile()) { - dirSet.add(currentDir); - } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - break; // Stop when reaching the root - } - currentDir = parentDir; - } - return [...dirSet]; -} -/** - * Extracts cube ID from name pattern [id] or explicit id field - */ -function extractCubeId(manifest) { - if (manifest.id) - return manifest.id; - const match = manifest.name.match(/^\[([^\]]+)\]/); - return match ? match[1] : undefined; -} -/** - * Loads all cubes from discovered cube directories. - */ -export async function loadCubes() { - const cubesFolders = findCubeDirectories(); - const cubes = {}; - const errors = []; - async function scanDirectory(currentDir, baseDir) { - const entries = await fs.readdir(currentDir, { withFileTypes: true }); - const files = entries.filter((e) => e.isFile()); - const manifestFile = files.find((f) => f.name === 'manifest.mjs' || f.name.endsWith('.manifest.mjs')); - const deployFile = files.find((f) => f.name === 'deploy.py' || f.name.endsWith('.deploy.py')); - if (manifestFile && deployFile) { - const cubePath = currentDir; - const manifestPath = path.join(cubePath, manifestFile.name); - try { - const manifest = (await import(manifestPath)).default; - if (!manifest || typeof manifest !== 'object') { - errors.push(`Invalid manifest export in ${manifestPath}`); - } - else if (!manifest.name) { - errors.push(`Invalid manifest format in ${manifestPath}: missing 'name'`); - } - else { - const cubeId = extractCubeId(manifest) || path.basename(cubePath); - if (cubes[cubeId]) { - errors.push(`Duplicate cube id '${cubeId}'`); - return; - } - // Ensure basic properties - manifest.id = cubeId; - manifest.schema = manifest.schema ?? z.object({}); - cubes[cubeId] = new Cube(manifest, cubePath, deployFile.name); - } - } - catch (err) { - errors.push(`Failed to load manifest ${manifestPath}: ${err}`); - } - } - for (const entry of entries) { - if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { - await scanDirectory(path.join(currentDir, entry.name), baseDir); - } - } - } - await Promise.all(cubesFolders.map(async (folder) => { - if (fs.existsSync(folder)) { - await scanDirectory(folder, folder); - } - })); - return { cubes, errors }; -} -/** - * Gets information about a single cube by name. - */ -export async function getCube(cubeName) { - const { cubes } = await loadCubes(); - return cubes[cubeName]; -} diff --git a/packages/nopy/dist/cubes/types.d.ts b/packages/nopy/dist/cubes/types.d.ts deleted file mode 100644 index 6154c91..0000000 --- a/packages/nopy/dist/cubes/types.d.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Type definitions for Nopy cubes - * @module cubes/types - */ -import { z } from 'zod'; -/** - * Variables that can be passed to a cube - */ -export type CubeVariables = Record; -/** - * A dependency specification - */ -export type DependencySpec = string | [id: string, variables?: CubeVariables]; -/** - * Context passed to cube hooks for executing other cubes - */ -export interface HookContext { - exec: (key: string, variables: CubeVariables) => Promise | void; -} -/** - * Hook function type for before/after cube execution - */ -export type Hook = (ctx: HookContext, variables: z.infer) => void | Promise; -/** - * User-defined specification for a cube - */ -export interface Manifest { - /** Unique identifier for the cube (used for dependency references) */ - id: string; - /** Human-readable name of the cube */ - name: string; - /** Zod schema for validating cube variables */ - schema: Schema; - /** Dynamic dependency resolver based on collected variables */ - dependencies?: (variables: z.infer) => DependencySpec[]; - /** Hooks to run before cube execution */ - before?: Hook[]; - /** Hooks to run after cube execution */ - after?: Hook[]; -} -/** - * Factory function and namespace for Manifest - */ -export declare function Manifest(opts: Pick, 'name'> & Partial, 'name'>>): Manifest; -export declare namespace Manifest { - /** - * Internal create helper - */ - function create(opts: Pick, 'name'> & Partial, 'name'>>): Manifest; -} -/** - * A fully loaded cube with its filesystem location and runtime state - */ -export declare class Cube { - readonly manifest: Manifest; - readonly dir: string; - readonly deployScript: string; - constructor(manifest: Manifest, dir: string, deployScript: string); - get id(): string; - get name(): string; - /** - * Returns default values for the cube's schema - */ - getDefaults(): z.infer; -} -/** - * Result of loading cubes from the filesystem - */ -export interface LoadResult { - /** Map of cube key to Cube object */ - cubes: Record; - /** List of errors encountered during loading */ - errors: string[]; -} diff --git a/packages/nopy/dist/cubes/types.js b/packages/nopy/dist/cubes/types.js deleted file mode 100644 index 98376a5..0000000 --- a/packages/nopy/dist/cubes/types.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Type definitions for Nopy cubes - * @module cubes/types - */ -import { z } from 'zod'; -/** - * Factory function and namespace for Manifest - */ -export function Manifest(opts) { - return { - id: opts.id ?? '', - name: opts.name, - schema: opts.schema ?? z.object({}), - dependencies: opts.dependencies, - before: opts.before ?? [], - after: opts.after ?? [], - }; -} -(function (Manifest) { - /** - * Internal create helper - */ - function create(opts) { - return Manifest(opts); - } - Manifest.create = create; -})(Manifest || (Manifest = {})); -/** - * A fully loaded cube with its filesystem location and runtime state - */ -export class Cube { - manifest; - dir; - deployScript; - constructor(manifest, dir, deployScript) { - this.manifest = manifest; - this.dir = dir; - this.deployScript = deployScript; - } - get id() { - return this.manifest.id; - } - get name() { - return this.manifest.name; - } - /** - * Returns default values for the cube's schema - */ - getDefaults() { - try { - return this.manifest.schema.parse({}); - } - catch { - return {}; - } - } -} diff --git a/packages/nopy/dist/cubes/utils.d.ts b/packages/nopy/dist/cubes/utils.d.ts deleted file mode 100644 index f586957..0000000 --- a/packages/nopy/dist/cubes/utils.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Utility functions for cubes - * @module cubes/utils - */ -/** - * Generates a random string of the specified length using the current nanotime as a seed. - * - * Uses a simple Linear Congruential Generator (LCG) seeded with high-resolution time. - * Suitable for generating unique identifiers, not for cryptographic purposes. - * - * @param length - The desired length of the random string (default: 5) - * @returns A random alphanumeric string of the specified length - * - * @example - * ```typescript - * const id = uniqid(); // e.g., "Kx7Pm" - * const longId = uniqid(10); // e.g., "Kx7PmQr2Yw" - * ``` - */ -export declare function uniqid(length?: number): string; diff --git a/packages/nopy/dist/cubes/utils.js b/packages/nopy/dist/cubes/utils.js deleted file mode 100644 index 05ea29e..0000000 --- a/packages/nopy/dist/cubes/utils.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Utility functions for cubes - * @module cubes/utils - */ -/** - * Generates a random string of the specified length using the current nanotime as a seed. - * - * Uses a simple Linear Congruential Generator (LCG) seeded with high-resolution time. - * Suitable for generating unique identifiers, not for cryptographic purposes. - * - * @param length - The desired length of the random string (default: 5) - * @returns A random alphanumeric string of the specified length - * - * @example - * ```typescript - * const id = uniqid(); // e.g., "Kx7Pm" - * const longId = uniqid(10); // e.g., "Kx7PmQr2Yw" - * ``` - */ -export function uniqid(length = 5) { - const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const charsetLength = charset.length; - // Use process.hrtime.bigint() for high-resolution time in nanoseconds - let seed = Number(process.hrtime.bigint() % BigInt(Number.MAX_SAFE_INTEGER)); - const randomString = []; - for (let i = 0; i < length; i++) { - // Simple linear congruential generator (LCG) for pseudo-randomness - seed = (seed * 48271) % 2147483647; - const index = seed % charsetLength; - randomString.push(charset[index]); - } - return randomString.join(''); -} diff --git a/packages/nopy/dist/index.d.ts b/packages/nopy/dist/index.d.ts deleted file mode 100644 index 7d3b995..0000000 --- a/packages/nopy/dist/index.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Nopy - A CLI tool for pyinfra script management and execution - * - * @packageDocumentation - */ -export * from './cubes/index.js'; -export { cubes } from './nopy.cubes.js'; -export { nopy } from './nopy.main.js'; -export type { NopyOptions, NopyResult } from './nopy.main.js'; -export { executeDeployCalls, outputExecutionPlan, summarizeResults, } from './nopy.executor.js'; -export type { DeployCall, ExecutionResult, ExecutionOptions, } from './nopy.executor.js'; -export { runWorkflow, runInteractiveWorkflow, runReplayWorkflow, runSessionReplayWorkflow, } from './nopy.workflow.js'; -export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js'; -export { CubeSelection, AuthSelection, HostSelection, VariableAssignment, PasswordSelection, } from './nopy.prompts.js'; -export { loadSession, saveSession, createSession, listSessions, filterInternalVariables, separateEnvAndCubeVariables, } from './nopy.session.js'; -export type { NopySession, CubeSession, AuthSession } from './nopy.session.js'; -export { loadHistory, saveHistory, addToHistory, getLastSession, getSessionById, listHistory, clearHistory, removeFromHistory, formatHistoryList, getHistoryPath, DEFAULT_HISTORY_SIZE, HISTORY_FILE, } from './nopy.history.js'; -export type { HistoryEntry, SessionHistory } from './nopy.history.js'; -export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js'; -export type { NopyConfig, NopyConfigFile, LogConfig, LogVerbosity, HistoryConfig, ExecutionConfig, ResolutionStrategy, ResolutionConfig, } from './nopy.config.js'; diff --git a/packages/nopy/dist/index.js b/packages/nopy/dist/index.js deleted file mode 100644 index 28658ef..0000000 --- a/packages/nopy/dist/index.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Nopy - A CLI tool for pyinfra script management and execution - * - * @packageDocumentation - */ -// Cubes module -export * from './cubes/index.js'; -// Backwards compatibility - cubes namespace -export { cubes } from './nopy.cubes.js'; -// Main entry point -export { nopy } from './nopy.main.js'; -// Executor -export { executeDeployCalls, outputExecutionPlan, summarizeResults, } from './nopy.executor.js'; -// Workflow -export { runWorkflow, runInteractiveWorkflow, runReplayWorkflow, runSessionReplayWorkflow, } from './nopy.workflow.js'; -// Prompts -export { CubeSelection, AuthSelection, HostSelection, VariableAssignment, PasswordSelection, } from './nopy.prompts.js'; -// Session management -export { loadSession, saveSession, createSession, listSessions, filterInternalVariables, separateEnvAndCubeVariables, } from './nopy.session.js'; -// History management -export { loadHistory, saveHistory, addToHistory, getLastSession, getSessionById, listHistory, clearHistory, removeFromHistory, formatHistoryList, getHistoryPath, DEFAULT_HISTORY_SIZE, HISTORY_FILE, } from './nopy.history.js'; -// Configuration -export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js'; diff --git a/packages/nopy/dist/nopy.cli.d.ts b/packages/nopy/dist/nopy.cli.d.ts deleted file mode 100644 index 1b05743..0000000 --- a/packages/nopy/dist/nopy.cli.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node -/** - * Nopy CLI - pyinfra deployment management - * @module nopy.cli - */ -export {}; diff --git a/packages/nopy/dist/nopy.cli.js b/packages/nopy/dist/nopy.cli.js deleted file mode 100755 index d4cf4ee..0000000 --- a/packages/nopy/dist/nopy.cli.js +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env node -/** - * Nopy CLI - pyinfra deployment management - * @module nopy.cli - */ -import { Command } from 'commander'; -import { loadConfig } from './nopy.config.js'; -import { clearHistory, formatHistoryList, getLastSession, getSessionById, listHistory, } from './nopy.history.js'; -import { nopy } from './nopy.main.js'; -const program = new Command(); -const config = loadConfig(); -program - .name('nopy') - .version('1.0.0') - .description('A CLI tool for pyinfra script management and execution.') - .addHelpText('after', ` -Examples: - $ nopy Interactive cube selection and deployment - $ nopy -R Repeat the last deployment session - $ nopy -H Run a specific session from history - $ nopy -l session.json Load and replay a saved session file - $ nopy -s session.json Save session to file after deployment - $ nopy -n Dry run (show plan without executing) - $ nopy -P Print deploy commands only - $ nopy history List all saved sessions - $ nopy clear-history Clear session history - -Session Replay: - Sessions are automatically saved to history after each deployment. - Use 'nopy history' to see available sessions and their IDs. - Use 'nopy -R' to quickly repeat the last session. - Use 'nopy -H ' to run any session from history. -`); -program - .command('install', { isDefault: true }) - .description('Install cubes on a given host') - .alias('i') - .option('-D, --use-defaults', 'Run cubes with default values without prompts') - .option('-K, --auth-method-key', 'Use SSH key authentication') - .option('-R, --repeat-last', 'Repeat the last session from history') - .option('-H, --history ', 'Run a specific session from history by ID') - .option('-s, --save-session ', 'Save session to file for later replay') - .option('-l, --load-session ', 'Load and replay session from file') - .option('-n, --dry-run', 'Show execution plan without running') - .option('-P, --print-only', 'Print deploy commands and exit (no execution)') - .option('-c, --continue-on-error', 'Continue executing after failures') - .option('-j, --json', 'Output results as JSON') - .option('--no-history', 'Do not save this session to history') - .action(async (options) => { - // Apply config defaults - const execConfig = config.execution ?? {}; - const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false; - try { - // Handle session replay - const loadSessionPath = options.loadSession; - let sessionToReplay; - if (options.repeatLast) { - const lastEntry = getLastSession(); - if (!lastEntry) { - console.error('No sessions in history. Run a deployment first.'); - process.exit(1); - } - sessionToReplay = lastEntry; - console.log(`Repeating: ${lastEntry.name}\n`); - } - else if (options.history) { - const entry = getSessionById(options.history); - if (!entry) { - console.error(`Session not found: ${options.history}`); - console.error('Use "nopy history" to list available sessions.'); - process.exit(1); - } - sessionToReplay = entry; - console.log(`Running: ${entry.name}\n`); - } - const result = await nopy({ - useDefaults: options.useDefaults, - useAuthKey: options.authMethodKey, - saveSession: options.saveSession, - loadSession: loadSessionPath, - replaySession: sessionToReplay?.session, - dryRun: options.dryRun, - printOnly: options.printOnly, - continueOnError, - jsonOutput: options.json, - saveToHistory: options.history !== false && !options.dryRun, - }); - // Exit with error code if deployment failed - if (result && !result.success) { - process.exit(1); - } - } - catch (error) { - if (options.json) { - console.log(JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : String(error), - }, null, 2)); - } - else { - console.error('Error:', error instanceof Error ? error.message : error, error); - } - process.exit(1); - } -}); -program - .command('history') - .description('List session history') - .alias('h') - .option('-j, --json', 'Output as JSON') - .action((options) => { - const entries = listHistory(); - if (options.json) { - console.log(JSON.stringify(entries, null, 2)); - } - else { - console.log(formatHistoryList(entries)); - } -}); -program - .command('clear-history') - .description('Clear all session history') - .action(() => { - clearHistory(); - console.log('Session history cleared.'); -}); -program.parse(); diff --git a/packages/nopy/dist/nopy.common.d.ts b/packages/nopy/dist/nopy.common.d.ts deleted file mode 100644 index f791e5f..0000000 --- a/packages/nopy/dist/nopy.common.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Environment variable configuration - */ -export type TVariables = Record; -export declare namespace Variables { - type ArtefactId = string; - type Scope = 'defaults' | 'prompts' | 'params'; -} -export declare class Variables { - readonly global: TVariables; - /** @summary env as configured in cube or session script */ - defaults: Record; - /** @summary env as configured via prompts */ - prompts: Record; - /** @summary env as handed via params (on hook calls) */ - params: Record; - constructor(global?: TVariables); - assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values?: TVariables): void; - get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables; -} diff --git a/packages/nopy/dist/nopy.common.js b/packages/nopy/dist/nopy.common.js deleted file mode 100644 index 50c0306..0000000 --- a/packages/nopy/dist/nopy.common.js +++ /dev/null @@ -1,32 +0,0 @@ -export class Variables { - global; - /** @summary env as configured in cube or session script */ - defaults = {}; - /** @summary env as configured via prompts */ - prompts = {}; - /** @summary env as handed via params (on hook calls) */ - params = {}; - constructor(global = {}) { - this.global = global; - } - assign(artefactId, scope, values = {}) { - console.log('Assigning', artefactId, scope, values); - if (!this[scope][artefactId]) { - this[scope][artefactId] = values; - } - else { - Object.assign(this[scope][artefactId], values); - } - } - get(artefactId, scope) { - if (scope) { - return this[scope][artefactId] || {}; - } - return { - ...this.global, - ...this.defaults[artefactId], - ...this.prompts[artefactId], - ...this.params[artefactId], - }; - } -} diff --git a/packages/nopy/dist/nopy.config.d.ts b/packages/nopy/dist/nopy.config.d.ts deleted file mode 100644 index 54f1373..0000000 --- a/packages/nopy/dist/nopy.config.d.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Configuration loading and management - * @module nopy.config - */ -import type { TVariables } from './nopy.common.js'; -/** - * Log verbosity levels for pyinfra output - */ -export type LogVerbosity = 'silent' | 'info' | 'verbose' | 'trace'; -/** - * Logging configuration - */ -export interface LogConfig { - /** Output verbosity level */ - verbosity?: LogVerbosity; - /** Enable pyinfra debug logging */ - debug?: boolean; -} -/** - * History configuration - */ -export interface HistoryConfig { - /** Maximum number of sessions to keep in history (default: 10) */ - maxSessions?: number; - /** Whether to auto-save sessions to history (default: true) */ - autoSave?: boolean; -} -/** - * Execution configuration - */ -export interface ExecutionConfig { - /** Continue executing after a cube fails (default: false) */ - continueOnError?: boolean; -} -/** - * Resolution strategy for merging config properties - * - 'merge': Arrays are concatenated, objects are deep merged (default) - * - 'override': Child value completely replaces parent value - */ -export type ResolutionStrategy = 'merge' | 'override'; -/** - * Resolution configuration for customizing merge behavior - */ -export type ResolutionConfig = { - [K in keyof NopyConfig]?: ResolutionStrategy; -}; -/** - * Raw config file structure (includes resolution) - */ -export interface NopyConfigFile extends Partial { - /** Customize merge behavior for specific properties */ - resolution?: ResolutionConfig; -} -/** - * Nopy configuration file structure - */ -export interface NopyConfig { - /** Available host addresses */ - hosts: string[]; - /** Directories to search for cubes */ - cubeDirs: string[]; - /** Global environment variables */ - env: TVariables; - /** Logging configuration */ - log?: LogConfig; - /** Session history configuration */ - history?: HistoryConfig; - /** Execution configuration */ - execution?: ExecutionConfig; -} -/** - * Loads the nopy configuration - * - * Searches for `.nopyrc.json` by traversing upwards from cwd to root. - * Multiple config files are merged, with child configs overriding parent configs. - * - * Use the `resolution` property to customize merge behavior: - * ```json - * { - * "hosts": ["local-host"], - * "resolution": { - * "hosts": "override" - * } - * } - * ``` - * - * @returns The merged configuration - * @throws Error if no config file is found - */ -export declare function loadConfig(): NopyConfig; -/** - * Gets the paths of all discovered config files (for debugging) - */ -export declare function getConfigPaths(): string[]; -/** - * Saves configuration to a file - * - * @param data - Configuration data to save - * @param configPath - Path to save to (defaults to cwd/.nopyrc.json) - */ -export declare function saveConfig(data: Partial, configPath?: string): void; -/** - * Converts log configuration to pyinfra command line flags - * - * @param logConfig - Log configuration with verbosity and debug settings - * @returns Array of pyinfra flags - * - * @example - * ```typescript - * const flags = logConfigToFlags({ verbosity: 'verbose', debug: true }); - * // Returns: ['-vv', '--debug'] - * ``` - */ -export declare function logConfigToFlags(logConfig?: LogConfig): string[]; diff --git a/packages/nopy/dist/nopy.config.js b/packages/nopy/dist/nopy.config.js deleted file mode 100644 index 101df9f..0000000 --- a/packages/nopy/dist/nopy.config.js +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Configuration loading and management - * @module nopy.config - */ -import fs from 'node:fs'; -import path from 'node:path'; -/** - * Default configuration - */ -const DEFAULT_CONFIG = { - hosts: [], - cubeDirs: [], - env: {}, -}; -const CONFIG_FILENAME = '.nopyrc.json'; -/** - * Finds all config files by traversing upwards from cwd to root - * Returns configs in order from root to cwd (parent first, child last) - */ -function findConfigFiles() { - const configPaths = []; - let currentDir = process.cwd(); - // Traverse upwards - while (true) { - const configPath = path.join(currentDir, CONFIG_FILENAME); - if (fs.existsSync(configPath)) { - configPaths.unshift(configPath); // Add to front (root first) - } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - break; // Reached root - } - currentDir = parentDir; - } - // Also check home directory (lowest priority) - const homeConfig = path.join(process.env.HOME || '', CONFIG_FILENAME); - if (homeConfig && fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) { - configPaths.unshift(homeConfig); - } - return configPaths; -} -/** - * Deep merges two values based on resolution strategy - */ -function mergeValue(parentValue, childValue, strategy) { - // Override strategy: child replaces parent completely - if (strategy === 'override') { - return childValue; - } - // Merge strategy (default) - if (Array.isArray(parentValue) && Array.isArray(childValue)) { - // Concatenate arrays, remove duplicates for primitives - const combined = [...parentValue, ...childValue]; - if (combined.every((v) => typeof v !== 'object')) { - return [...new Set(combined)]; - } - return combined; - } - if (typeof parentValue === 'object' && - parentValue !== null && - typeof childValue === 'object' && - childValue !== null && - !Array.isArray(parentValue) && - !Array.isArray(childValue)) { - // Deep merge objects - const result = { ...parentValue }; - for (const [key, value] of Object.entries(childValue)) { - if (key in result) { - result[key] = mergeValue(result[key], value, 'merge'); - } - else { - result[key] = value; - } - } - return result; - } - // Primitives: child overrides parent - return childValue; -} -/** - * Checks if a string looks like a relative path - */ -function isRelativePath(value) { - return (value.startsWith('./') || - value.startsWith('../') || - // Also match paths without ./ prefix that don't look like URLs or absolute paths - (!value.startsWith('/') && - !value.startsWith('~') && - !value.includes('://') && - (value.includes('/') || value.endsWith('.json') || value.endsWith('.yml')))); -} -/** - * Resolves relative paths in a value based on config file location - */ -function resolveRelativePaths(value, configDir) { - if (typeof value === 'string') { - if (isRelativePath(value)) { - return path.resolve(configDir, value); - } - return value; - } - if (Array.isArray(value)) { - return value.map((item) => resolveRelativePaths(item, configDir)); - } - if (typeof value === 'object' && value !== null) { - const result = {}; - for (const [key, val] of Object.entries(value)) { - result[key] = resolveRelativePaths(val, configDir); - } - return result; - } - return value; -} -/** - * Properties that contain filesystem paths and should have relative paths resolved - */ -const PATH_PROPERTIES = ['cubeDirs']; -/** - * Resolves relative paths in a config file based on its location - * Only resolves paths for properties that are known to contain filesystem paths - */ -function resolveConfigPaths(config, configPath) { - const configDir = path.dirname(configPath); - const resolved = {}; - for (const [key, value] of Object.entries(config)) { - if (key === 'resolution') { - // Don't resolve the resolution config itself - resolved[key] = value; - } - else if (PATH_PROPERTIES.includes(key)) { - // Only resolve paths for known path properties - resolved[key] = resolveRelativePaths(value, configDir); - } - else { - // Copy other properties as-is (including hosts) - resolved[key] = value; - } - } - return resolved; -} -/** - * Merges a child config into a parent config - */ -function mergeConfigs(parent, childFile) { - const resolution = childFile.resolution || {}; - const result = { ...parent }; - for (const [key, value] of Object.entries(childFile)) { - if (key === 'resolution') - continue; // Skip resolution property itself - const strategy = resolution[key] || 'merge'; - if (key in result) { - result[key] = mergeValue(result[key], value, strategy); - } - else { - result[key] = value; - } - } - return result; -} -/** - * Loads the nopy configuration - * - * Searches for `.nopyrc.json` by traversing upwards from cwd to root. - * Multiple config files are merged, with child configs overriding parent configs. - * - * Use the `resolution` property to customize merge behavior: - * ```json - * { - * "hosts": ["local-host"], - * "resolution": { - * "hosts": "override" - * } - * } - * ``` - * - * @returns The merged configuration - * @throws Error if no config file is found - */ -export function loadConfig() { - const configPaths = findConfigFiles(); - if (configPaths.length === 0) { - throw new Error(`No ${CONFIG_FILENAME} found. Create one in your project directory or any parent directory.`); - } - // Start with defaults and merge each config file - let config = { ...DEFAULT_CONFIG }; - for (const configPath of configPaths) { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const rawConfig = JSON.parse(content); - // Resolve relative paths based on config file location - const resolvedConfig = resolveConfigPaths(rawConfig, configPath); - config = mergeConfigs(config, resolvedConfig); - } - catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to load config ${configPath}: ${message}`); - } - } - return config; -} -/** - * Gets the paths of all discovered config files (for debugging) - */ -export function getConfigPaths() { - return findConfigFiles(); -} -/** - * Saves configuration to a file - * - * @param data - Configuration data to save - * @param configPath - Path to save to (defaults to cwd/.nopyrc.json) - */ -export function saveConfig(data, configPath) { - const savePath = configPath || path.resolve(process.cwd(), CONFIG_FILENAME); - // Try to load existing config from this specific file - let existing = {}; - if (fs.existsSync(savePath)) { - try { - existing = JSON.parse(fs.readFileSync(savePath, 'utf-8')); - } - catch { - // Ignore parse errors, start fresh - } - } - const merged = { ...existing, ...data }; - fs.writeFileSync(savePath, JSON.stringify(merged, null, 2)); -} -/** - * Converts log configuration to pyinfra command line flags - * - * @param logConfig - Log configuration with verbosity and debug settings - * @returns Array of pyinfra flags - * - * @example - * ```typescript - * const flags = logConfigToFlags({ verbosity: 'verbose', debug: true }); - * // Returns: ['-vv', '--debug'] - * ``` - */ -export function logConfigToFlags(logConfig) { - const flags = []; - const verbosity = logConfig?.verbosity ?? 'silent'; - // Add verbosity flags - switch (verbosity) { - case 'silent': - // No verbosity flags - break; - case 'info': - flags.push('-v'); // Print meta information - break; - case 'verbose': - flags.push('-vv'); // Print meta + input data - break; - case 'trace': - flags.push('-vvv'); // Print meta + input + output - break; - } - // Add debug flag if enabled - if (logConfig?.debug) { - flags.push('--debug'); - } - return flags; -} diff --git a/packages/nopy/dist/nopy.cubes.d.ts b/packages/nopy/dist/nopy.cubes.d.ts deleted file mode 100644 index b1ae7a1..0000000 --- a/packages/nopy/dist/nopy.cubes.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Backwards compatibility re-export - * - * This file maintains the `cubes` namespace for existing code. - * New code should import directly from './cubes/index.js' - * - * @deprecated Import from './cubes/index.js' instead - */ -import * as cubesModule from './cubes/index.js'; -export declare const cubes: { - Cube: typeof cubesModule.Cube; - Manifest: typeof cubesModule.Manifest; - createManifest: typeof cubesModule.createManifest; - manifest: typeof cubesModule.createManifest; - loadCubes: typeof cubesModule.loadCubes; - getCube: typeof cubesModule.getCube; - BuildContext: typeof cubesModule.BuildContext; - uniqid: typeof cubesModule.uniqid; - load: typeof cubesModule.loadCubes; - findCubeDirectories: typeof cubesModule.findCubeDirectories; -}; -export type { Hook, HookContext, Cube, Manifest, LoadResult, CubeVariables, } from './cubes/index.js'; diff --git a/packages/nopy/dist/nopy.cubes.js b/packages/nopy/dist/nopy.cubes.js deleted file mode 100644 index 29fed65..0000000 --- a/packages/nopy/dist/nopy.cubes.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Backwards compatibility re-export - * - * This file maintains the `cubes` namespace for existing code. - * New code should import directly from './cubes/index.js' - * - * @deprecated Import from './cubes/index.js' instead - */ -import * as cubesModule from './cubes/index.js'; -export const cubes = { - // Runtime exports - ...cubesModule, - // Aliases for backwards compatibility - load: cubesModule.loadCubes, - findCubeDirectories: cubesModule.findCubeDirectories, -}; diff --git a/packages/nopy/dist/nopy.executor.d.ts b/packages/nopy/dist/nopy.executor.d.ts deleted file mode 100644 index 5a23c29..0000000 --- a/packages/nopy/dist/nopy.executor.d.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Pyinfra command execution - * @module nopy.executor - */ -import type { DependencySpec } from './cubes/types.js'; -/** - * A deployment command ready for execution - */ -export interface DeployCall { - /** Cube being deployed */ - cube: string; - /** Target host */ - host: string; - /** Working directory for execution */ - cwd: string; - /** Full command array */ - command: string[]; - /** Environment variables for the cube */ - env: Record; - /** Cube dependencies */ - dependencies: DependencySpec[]; -} -/** - * Result of executing a deployment command - */ -export interface ExecutionResult { - /** Cube that was deployed */ - cube: string; - /** Target host */ - host: string; - /** Whether execution succeeded */ - success: boolean; - /** Execution duration in milliseconds */ - duration: number; - /** Standard output (if captured) */ - stdout?: string; - /** Standard error (if captured) */ - stderr?: string; - /** Error if execution failed */ - error?: Error; -} -/** - * Options for deployment execution - */ -export interface ExecutionOptions { - /** Continue executing remaining cubes after failure */ - continueOnError?: boolean; - /** Show what would be executed without running */ - dryRun?: boolean; - /** Callback for progress updates */ - onProgress?: (result: ExecutionResult, completed: number, total: number) => void; - /** Callback when execution starts */ - onStart?: (cube: string, host: string) => void; -} -/** - * Outputs the execution plan without running (dry run) - * - * @param calls - Array of deployment calls - * @param asJson - Output as JSON instead of text - */ -export declare function outputExecutionPlan(calls: DeployCall[], asJson?: boolean): void; -/** - * Executes an array of deployment calls - * - * @param calls - Array of deployment calls to execute - * @param options - Execution options - * @returns Array of execution results - * - * @example - * ```typescript - * const results = await executeDeployCalls(calls, { - * continueOnError: false, - * onProgress: (result, completed, total) => { - * console.log(`${completed}/${total} complete`); - * }, - * }); - * ``` - */ -export declare function executeDeployCalls(calls: DeployCall[], options?: ExecutionOptions): Promise; -/** - * Generates a summary of execution results - * - * @param results - Array of execution results - * @returns Summary object - */ -export declare function summarizeResults(results: ExecutionResult[]): { - total: number; - successful: number; - failed: number; - totalDuration: number; - failures: ExecutionResult[]; -}; diff --git a/packages/nopy/dist/nopy.executor.js b/packages/nopy/dist/nopy.executor.js deleted file mode 100644 index fe30f82..0000000 --- a/packages/nopy/dist/nopy.executor.js +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Pyinfra command execution - * @module nopy.executor - */ -import { getLogger } from '@logtape/logtape'; -import { execa } from 'execa'; -const log = getLogger(['nopy', 'executor']); -/** - * Executes a single deployment call - * - * @param call - The deployment call to execute - * @returns Execution result - */ -async function executeCall(call) { - const startTime = Date.now(); - const commandStr = call.command.join(' '); - try { - log.info(`Executing: ${call.cube} -> ${call.host}`); - log.debug(`Command: ${commandStr}`); - // Inherit stdio for live output - await execa({ shell: true })(commandStr, { - cwd: call.cwd, - stdio: 'inherit', - }); - return { - cube: call.cube, - host: call.host, - success: true, - duration: Date.now() - startTime, - }; - } - catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - log.error(`Failed: ${call.cube} -> ${call.host}`, { error: err.message }); - return { - cube: call.cube, - host: call.host, - success: false, - duration: Date.now() - startTime, - error: err, - }; - } -} -/** - * Outputs the execution plan without running (dry run) - * - * @param calls - Array of deployment calls - * @param asJson - Output as JSON instead of text - */ -export function outputExecutionPlan(calls, asJson) { - if (asJson) { - const plan = calls.map((call) => ({ - cube: call.cube, - host: call.host, - command: call.command.join(' '), - variables: call.env, - })); - console.log(JSON.stringify({ plan }, null, 2)); - return; - } - console.log('\n=== Execution Plan (Dry Run) ===\n'); - for (let i = 0; i < calls.length; i++) { - const call = calls[i]; - console.log(`Step ${i + 1}: ${call.cube} -> ${call.host}`); - console.log(` Command: ${call.command.join(' ')}`); - const envKeys = Object.keys(call.env); - if (envKeys.length > 0) { - console.log(' Variables:'); - for (const [key, value] of Object.entries(call.env)) { - // Mask sensitive values - const displayValue = key.toLowerCase().includes('password') ? '********' : String(value); - console.log(` ${key}=${displayValue}`); - } - } - console.log(); - } - console.log(`Total: ${calls.length} command(s)\n`); - console.log('Run without --dry-run to execute.\n'); -} -/** - * Executes an array of deployment calls - * - * @param calls - Array of deployment calls to execute - * @param options - Execution options - * @returns Array of execution results - * - * @example - * ```typescript - * const results = await executeDeployCalls(calls, { - * continueOnError: false, - * onProgress: (result, completed, total) => { - * console.log(`${completed}/${total} complete`); - * }, - * }); - * ``` - */ -export async function executeDeployCalls(calls, options = {}) { - if (calls.length === 0) { - log.info('No deployment calls to execute'); - return []; - } - if (options.dryRun) { - outputExecutionPlan(calls); - return []; - } - log.info(`Executing ${calls.length} deployment call(s)`); - const results = []; - for (let i = 0; i < calls.length; i++) { - const call = calls[i]; - options.onStart?.(call.cube, call.host); - const result = await executeCall(call); - results.push(result); - options.onProgress?.(result, i + 1, calls.length); - if (!result.success && !options.continueOnError) { - log.warn(`Stopping execution due to failure in ${call.cube}`); - break; - } - } - return results; -} -/** - * Generates a summary of execution results - * - * @param results - Array of execution results - * @returns Summary object - */ -export function summarizeResults(results) { - const successful = results.filter((r) => r.success); - const failed = results.filter((r) => !r.success); - const totalDuration = results.reduce((sum, r) => sum + r.duration, 0); - return { - total: results.length, - successful: successful.length, - failed: failed.length, - totalDuration, - failures: failed, - }; -} diff --git a/packages/nopy/dist/nopy.history.d.ts b/packages/nopy/dist/nopy.history.d.ts deleted file mode 100644 index 0b8c643..0000000 --- a/packages/nopy/dist/nopy.history.d.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Session history management - * @module nopy.history - */ -import type { NopySession } from './nopy.session.js'; -/** Default number of sessions to keep in history */ -export declare const DEFAULT_HISTORY_SIZE = 10; -/** History file name */ -export declare const HISTORY_FILE = ".nopy.history.json"; -/** - * A session entry in history - */ -export interface HistoryEntry { - /** Unique identifier (timestamp-based) */ - id: string; - /** Human-readable name (timestamp + cube names) */ - name: string; - /** ISO timestamp when session was executed */ - timestamp: string; - /** The full session data */ - session: NopySession; -} -/** - * History file structure - */ -export interface SessionHistory { - /** Array of session entries, newest first */ - entries: HistoryEntry[]; -} -/** - * Gets the path to the history file - */ -export declare function getHistoryPath(): string; -/** - * Loads the session history from disk - * - * @returns The session history or empty history if file doesn't exist - */ -export declare function loadHistory(): SessionHistory; -/** - * Saves the session history to disk - * - * @param history - The history to save - */ -export declare function saveHistory(history: SessionHistory): void; -/** - * Adds a session to the history - * - * @param session - The session to add - * @param maxEntries - Maximum number of entries to keep - * @returns The created history entry - */ -export declare function addToHistory(session: NopySession, maxEntries?: number): HistoryEntry; -/** - * Gets the most recent session from history - * - * @returns The last session or undefined if history is empty - */ -export declare function getLastSession(): HistoryEntry | undefined; -/** - * Gets a session by ID - * - * @param id - The session ID - * @returns The session entry or undefined - */ -export declare function getSessionById(id: string): HistoryEntry | undefined; -/** - * Lists all sessions in history - * - * @returns Array of history entries, newest first - */ -export declare function listHistory(): HistoryEntry[]; -/** - * Clears all session history - */ -export declare function clearHistory(): void; -/** - * Removes a specific session from history - * - * @param id - The session ID to remove - * @returns true if removed, false if not found - */ -export declare function removeFromHistory(id: string): boolean; -/** - * Formats history entries for display - * - * @param entries - History entries to format - * @returns Formatted string for console output - */ -export declare function formatHistoryList(entries: HistoryEntry[]): string; diff --git a/packages/nopy/dist/nopy.history.js b/packages/nopy/dist/nopy.history.js deleted file mode 100644 index 5bc4fbe..0000000 --- a/packages/nopy/dist/nopy.history.js +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Session history management - * @module nopy.history - */ -import fs from 'node:fs'; -import path from 'node:path'; -/** Default number of sessions to keep in history */ -export const DEFAULT_HISTORY_SIZE = 10; -/** History file name */ -export const HISTORY_FILE = '.nopy.history.json'; -/** - * Gets the path to the history file - */ -export function getHistoryPath() { - return path.resolve(process.cwd(), HISTORY_FILE); -} -/** - * Loads the session history from disk - * - * @returns The session history or empty history if file doesn't exist - */ -export function loadHistory() { - const historyPath = getHistoryPath(); - if (!fs.existsSync(historyPath)) { - return { entries: [] }; - } - try { - const content = fs.readFileSync(historyPath, 'utf-8'); - return JSON.parse(content); - } - catch { - return { entries: [] }; - } -} -/** - * Saves the session history to disk - * - * @param history - The history to save - */ -export function saveHistory(history) { - const historyPath = getHistoryPath(); - fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8'); -} -/** - * Generates a history entry name from session data - * - * Format: "YYYY-MM-DD HH:mm - cube1, cube2, ..." - * - * @param session - The session to name - * @param timestamp - ISO timestamp - * @returns Human-readable name - */ -function generateEntryName(session, timestamp) { - const date = new Date(timestamp); - const dateStr = date.toLocaleString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }); - const cubeNames = session.cubes.map((c) => c.key).join(', '); - const truncatedCubes = cubeNames.length > 40 ? `${cubeNames.substring(0, 37)}...` : cubeNames; - const hosts = session.hosts?.join(', ') || 'no host'; - const truncatedHosts = hosts.length > 20 ? `${hosts.substring(0, 17)}...` : hosts; - return `${dateStr} - ${truncatedCubes} → ${truncatedHosts}`; -} -/** - * Generates a unique ID for a history entry - */ -function generateEntryId() { - return Date.now().toString(36) + Math.random().toString(36).substring(2, 7); -} -/** - * Adds a session to the history - * - * @param session - The session to add - * @param maxEntries - Maximum number of entries to keep - * @returns The created history entry - */ -export function addToHistory(session, maxEntries = DEFAULT_HISTORY_SIZE) { - const history = loadHistory(); - const timestamp = new Date().toISOString(); - const entry = { - id: generateEntryId(), - name: generateEntryName(session, timestamp), - timestamp, - session, - }; - // Add to beginning (newest first) - history.entries.unshift(entry); - // Trim to max size - if (history.entries.length > maxEntries) { - history.entries = history.entries.slice(0, maxEntries); - } - saveHistory(history); - return entry; -} -/** - * Gets the most recent session from history - * - * @returns The last session or undefined if history is empty - */ -export function getLastSession() { - const history = loadHistory(); - return history.entries[0]; -} -/** - * Gets a session by ID - * - * @param id - The session ID - * @returns The session entry or undefined - */ -export function getSessionById(id) { - const history = loadHistory(); - return history.entries.find((e) => e.id === id); -} -/** - * Lists all sessions in history - * - * @returns Array of history entries, newest first - */ -export function listHistory() { - const history = loadHistory(); - return history.entries; -} -/** - * Clears all session history - */ -export function clearHistory() { - saveHistory({ entries: [] }); -} -/** - * Removes a specific session from history - * - * @param id - The session ID to remove - * @returns true if removed, false if not found - */ -export function removeFromHistory(id) { - const history = loadHistory(); - const initialLength = history.entries.length; - history.entries = history.entries.filter((e) => e.id !== id); - if (history.entries.length < initialLength) { - saveHistory(history); - return true; - } - return false; -} -/** - * Formats history entries for display - * - * @param entries - History entries to format - * @returns Formatted string for console output - */ -export function formatHistoryList(entries) { - if (entries.length === 0) { - return 'No sessions in history.'; - } - const lines = ['', 'Session History:', '']; - entries.forEach((entry, index) => { - const marker = index === 0 ? '→' : ' '; - lines.push(` ${marker} [${index + 1}] ${entry.name}`); - lines.push(` ID: ${entry.id}`); - }); - lines.push(''); - lines.push(`Total: ${entries.length} session(s)`); - lines.push(''); - return lines.join('\n'); -} diff --git a/packages/nopy/dist/nopy.main.d.ts b/packages/nopy/dist/nopy.main.d.ts deleted file mode 100644 index 72eb07a..0000000 --- a/packages/nopy/dist/nopy.main.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Main entry point for nopy - * @module nopy.main - */ -import { type ExecutionResult } from './nopy.executor.js'; -import { type NopySession } from './nopy.session.js'; -/** - * Options for the nopy main function - */ -export interface NopyOptions { - useDefaults?: boolean; - useAuthKey?: boolean; - saveSession?: string; - loadSession?: string; - replaySession?: NopySession; - dryRun?: boolean; - printOnly?: boolean; - continueOnError?: boolean; - jsonOutput?: boolean; - saveToHistory?: boolean; -} -/** - * Result of a nopy execution - */ -export interface NopyResult { - success: boolean; - results: ExecutionResult[]; - summary: { - total: number; - successful: number; - failed: number; - totalDuration: number; - }; -} -/** - * Main entry point for nopy deployments - */ -export declare function nopy(opts?: NopyOptions): Promise; diff --git a/packages/nopy/dist/nopy.main.js b/packages/nopy/dist/nopy.main.js deleted file mode 100644 index c85a3bd..0000000 --- a/packages/nopy/dist/nopy.main.js +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Main entry point for nopy - * @module nopy.main - */ -import { configure, getAnsiColorFormatter, getLogger } from '@logtape/logtape'; -import { loadCubes } from './cubes/index.js'; -import { BuildContext } from './cubes/dependencies.js'; -import { Variables } from './nopy.common.js'; -import { getConfigPaths, loadConfig } from './nopy.config.js'; -import { executeDeployCalls, summarizeResults } from './nopy.executor.js'; -import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js'; -import { saveSession } from './nopy.session.js'; -import { runWorkflow } from './nopy.workflow.js'; -/** - * Configures the logtape logger for console output - */ -function configureLogtape() { - configure({ - sinks: { - console: (() => { - const formatter = getAnsiColorFormatter(); - return (record) => { - const formatted = formatter(record); - if (typeof formatted === 'string') { - const msg = formatted.replace(/\r?\n$/, ''); - const props = record.properties; - console.log(msg, ...Object.values(props)); - } - }; - })(), - }, - loggers: [ - { - category: ['logtape', 'meta'], - level: 'error', - sinks: ['console'], - }, - { - category: 'nopy', - level: 'debug', - sinks: ['console'], - }, - ], - }); -} -// Initialize logging -configureLogtape(); -/** - * Prints the active configuration summary - */ -function printActiveConfig(config, opts) { - const configPaths = getConfigPaths(); - const cwd = process.cwd(); - const lines = ['']; - lines.push(' Configuration'); - lines.push(' ─────────────'); - const relativePaths = configPaths.map((p) => { - if (p.startsWith(cwd)) - return `.${p.slice(cwd.length)}`; - if (p.startsWith(process.env.HOME || '')) - return `~${p.slice((process.env.HOME || '').length)}`; - return p; - }); - lines.push(` Config: ${relativePaths.join(' → ')}`); - if (config.hosts.length > 0) - lines.push(` Hosts: ${config.hosts.join(', ')}`); - if (config.cubeDirs.length > 0) - lines.push(` Cube dirs: ${config.cubeDirs.join(', ')}`); - if (opts.continueOnError) - lines.push(' Execution: continue-on-error'); - const envEntries = Object.entries(config.env); - if (envEntries.length > 0) { - lines.push(' Env vars:'); - for (const [key, value] of envEntries) { - const isEmpty = value === null || value === undefined || value === ''; - lines.push(` ${key}: ${isEmpty ? '' : ''}`); - } - } - lines.push(''); - console.log(lines.join('\n')); -} -/** - * Main entry point for nopy deployments - */ -export async function nopy(opts = {}) { - const { useDefaults = false, useAuthKey, saveSession: saveSessionPath, loadSession: loadSessionPath, replaySession, dryRun = false, printOnly = false, continueOnError = false, jsonOutput = false, saveToHistory = true, } = opts; - const log = getLogger(['nopy']); - const config = loadConfig(); - if (!jsonOutput && !replaySession && !loadSessionPath) { - printActiveConfig(config, { continueOnError }); - } - const { cubes, errors } = await loadCubes(); - const variables = new Variables(config.env); - if (errors.length > 0) { - log.error('Errors found during cube loading:'); - errors.forEach((error) => log.error(error)); - if (jsonOutput) - console.log(JSON.stringify({ success: false, errors }, null, 2)); - return undefined; - } - const workflow = await runWorkflow(loadSessionPath, cubes, config, { useDefaults, useAuthKey }, replaySession); - // Step 3: Build deployment calls using BuildContext - const context = new BuildContext(cubes, variables, workflow.session, config, { - method: workflow.authMethod, - username: workflow.username, - password: workflow.password, - }, { - useDefaults, - isSessionReplay: workflow.isReplay, - }); - for (const host of workflow.session.hosts) { - for (const cubeId of workflow.selectedCubes) { - await context.resolveCube(cubeId, host); - } - } - const sessionForSaving = { - ...workflow.session, - cubes: context.cubeSessions, - env: variables.get('global'), - }; - if (saveSessionPath && !workflow.isReplay) { - saveSession(sessionForSaving, saveSessionPath); - } - if (saveToHistory && !dryRun && !workflow.isReplay && context.deployCalls.length > 0) { - const historySize = config.history?.maxSessions ?? DEFAULT_HISTORY_SIZE; - if (config.history?.autoSave !== false) { - addToHistory(sessionForSaving, historySize); - } - } - if (printOnly) { - console.log('\n Deploy Commands\n ───────────────\n'); - for (const call of context.deployCalls) { - console.log(` # ${call.cube} -> ${call.host}`); - console.log(` ${call.command.join(' ')}\n`); - } - return { - success: true, - results: [], - summary: { total: context.deployCalls.length, successful: 0, failed: 0, totalDuration: 0 }, - }; - } - const results = await executeDeployCalls(context.deployCalls, { - dryRun, - continueOnError, - onProgress: (result, completed, total) => { - if (!jsonOutput) { - const status = result.success ? '✓' : '✗'; - log.info(`[${completed}/${total}] ${status} ${result.cube} -> ${result.host}`); - } - }, - }); - const summary = summarizeResults(results); - return { - success: summary.failed === 0, - results, - summary: { - total: summary.total, - successful: summary.successful, - failed: summary.failed, - totalDuration: summary.totalDuration, - }, - }; -} diff --git a/packages/nopy/dist/nopy.prompts.d.ts b/packages/nopy/dist/nopy.prompts.d.ts deleted file mode 100644 index e775893..0000000 --- a/packages/nopy/dist/nopy.prompts.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Interactive prompts for nopy CLI - * @module nopy.prompts - */ -import { z } from 'zod'; -import type { Cube } from './cubes/index.js'; -import type { Variables } from './nopy.common.js'; -/** - * Prompts the user to select cubes to execute with filtering support - */ -export declare function CubeSelection(cubes: Record): Promise<{ - selectedCubes: string[]; -}>; -export declare function AuthSelection(useAuthKey?: boolean): Promise<{ - authMethod: string; - username?: string; - password?: string; -}>; -export declare function PasswordSelection(username: string): Promise; -export declare function HostSelection(hosts: string[]): Promise; -export declare function VariableAssignment(cube: Cube, variables: Variables): Promise; diff --git a/packages/nopy/dist/nopy.prompts.js b/packages/nopy/dist/nopy.prompts.js deleted file mode 100644 index 76a6243..0000000 --- a/packages/nopy/dist/nopy.prompts.js +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Interactive prompts for nopy CLI - * @module nopy.prompts - */ -// @ts-ignore - no types available -import Enquirer from 'enquirer'; -import fuzzy from 'fuzzy'; -import inquirer from 'inquirer'; -// @ts-ignore - no types available -import CheckboxPlus from 'inquirer-checkbox-plus-prompt'; -import { z } from 'zod'; -// Register the checkbox-plus prompt type for filterable multi-select -inquirer.registerPrompt('checkbox-plus', CheckboxPlus); -/** - * Prompts the user to select cubes to execute with filtering support - */ -export async function CubeSelection(cubes) { - const cubeChoices = Object.values(cubes) - .sort((a, b) => a.id.localeCompare(b.id)) - .map((cube) => ({ - name: `${cube.id} - ${cube.name}`, - value: cube.id, - short: cube.id, - })); - // Clear terminal and move cursor to top - process.stdout.write('\x1B[2J\x1B[0f'); - const terminalHeight = process.stdout.rows || 24; - const pageSize = Math.max(10, terminalHeight - 5); - console.log('\n Cube Selection\n'); - console.log(' Type to filter • Space to select • Enter to confirm\n'); - const answers = await inquirer.prompt([ - { - type: 'checkbox-plus', - name: 'selectedCubes', - message: 'Select cubes:', - pageSize, - highlight: true, - searchable: true, - source: (_answersSoFar, input) => { - const searchTerm = input || ''; - if (!searchTerm) - return Promise.resolve(cubeChoices); - const results = fuzzy.filter(searchTerm, cubeChoices, { - extract: (choice) => choice.name, - }); - return Promise.resolve(results.map((r) => r.original)); - }, - }, - ]); - return { selectedCubes: answers.selectedCubes }; -} -export async function AuthSelection(useAuthKey) { - if (useAuthKey) - return { authMethod: 'ssh-key' }; - const answers = await inquirer.prompt([ - { - type: 'list', - name: 'authMethod', - message: 'Select authentication method:', - choices: ['ssh-key', 'password'], - }, - { - type: 'input', - name: 'username', - message: 'Enter username:', - when: (answers) => answers.authMethod !== 'ssh-key', - }, - { - type: 'password', - name: 'password', - message: 'Enter password:', - when: (answers) => answers.authMethod !== 'ssh-key', - }, - ]); - return answers; -} -export async function PasswordSelection(username) { - const { password } = await inquirer.prompt([ - { - type: 'password', - name: 'password', - message: `Enter password for ${username}:`, - }, - ]); - return password; -} -export async function HostSelection(hosts) { - const selectedHost = await inquirer.prompt([ - { - type: 'list', - name: 'host', - message: 'Select host from inventory', - choices: ['docker', 'vagrant', ...hosts, 'custom'], - }, - { - type: 'input', - name: 'customHost', - message: 'Specify custom host address:', - when: (answers) => answers.host === 'custom', - }, - { - type: 'input', - name: 'vagrantVM', - message: 'Specify vagrant machine:', - default: 'default', - when: (answers) => answers.host === 'vagrant', - }, - { - type: 'input', - name: 'dockerContainer', - message: 'Specify docker container name:', - when: (answers) => answers.host === 'runtime:docker', - }, - ]); - if (selectedHost.host === 'vagrant') - return `@vagrant/${selectedHost.vagrantVM}`; - if (selectedHost.host === 'runtime:docker') - return `@docker/${selectedHost.dockerContainer}`; - return selectedHost.customHost ?? selectedHost.host; -} -function coerceValue(value, zodType) { - if (typeof value !== 'string') - return value; - if (zodType instanceof z.ZodDefault) - return coerceValue(value, zodType._def.innerType); - if (zodType instanceof z.ZodOptional) - return coerceValue(value, zodType._def.innerType); - if (zodType instanceof z.ZodNullable) { - if (value === 'null' || value === '') - return null; - return coerceValue(value, zodType._def.innerType); - } - if (zodType instanceof z.ZodBoolean) - return value === 'true' || value === 'yes' || value === '1'; - if (zodType instanceof z.ZodNumber) { - const num = Number(value); - return Number.isNaN(num) ? value : num; - } - return value; -} -export async function VariableAssignment(cube, variables) { - const schema = cube.manifest.schema.shape; - const defaults = cube.getDefaults(); - const variablesToConfigure = {}; - for (const [key, defaultValue] of Object.entries(defaults)) { - if (variables.get(cube.id, 'params')[key] === undefined) { - variablesToConfigure[key] = defaultValue; - } - } - if (Object.keys(variablesToConfigure).length === 0) - return; - const choices = Object.entries(variablesToConfigure).map(([key, value]) => { - const zodType = schema[key]; - const description = zodType?.description || key; - return { name: key, message: description, initial: String(value ?? '') }; - }); - const form = new Enquirer.Form({ - name: 'variables', - message: `[${cube.id}] ${cube.name}\n (↑↓ navigate, Enter to submit)`, - choices, - }); - try { - const result = await form.run(); - const coercedResult = {}; - for (const [key, value] of Object.entries(result)) { - const zodType = schema[key]; - coercedResult[key] = zodType ? coerceValue(value, zodType) : value; - } - variables.assign(cube.id, 'prompts', coercedResult); - } - catch { - // User cancelled - } -} diff --git a/packages/nopy/dist/nopy.session.d.ts b/packages/nopy/dist/nopy.session.d.ts deleted file mode 100644 index 8285d95..0000000 --- a/packages/nopy/dist/nopy.session.d.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Session management for saving and replaying deployments - * @module nopy.session - */ -import type { TVariables } from './nopy.common.js'; -/** - * Primitive value types that can be stored in session variables - */ -export type SessionValue = string | number | boolean | null | undefined; -/** - * Record of session variables - */ -export type SessionVariables = Record; -/** - * Configuration for a single cube within a session - */ -export interface CubeSession { - /** Cube identifier */ - key: string; - /** Cube-specific variables */ - variables: TVariables; -} -/** - * Authentication configuration for a session - */ -export interface AuthSession { - /** Authentication method */ - method: 'ssh-key' | 'password' | 'ssh'; - /** Username for authentication (password auth only) */ - username?: string; -} -/** - * Complete session configuration - */ -export interface NopySession { - /** Optional session name */ - name?: string; - /** Array of cube configurations */ - cubes: CubeSession[]; - /** Target hosts */ - hosts?: string[]; - /** Authentication configuration */ - auth: AuthSession; - /** Global environment variables */ - env?: TVariables; -} -/** - * Saves a session to a JSON file - * - * @param session - The session to save - * @param filePath - Path to save the session file - * - * @example - * ```typescript - * saveSession(session, './my-deployment.nopysession.json'); - * ``` - */ -export declare function saveSession(session: NopySession, filePath: string): void; -/** - * Loads a session from a JSON or MJS file - * - * @param filePath - Path to the session file (.json or .mjs) - * @returns The loaded session - * @throws Error if file not found or invalid format - * - * @example - * ```typescript - * const session = await loadSession('./deployment.nopysession.json'); - * ``` - */ -export declare function loadSession(filePath: string): Promise; -/** - * Lists all session files in a directory - * - * @param dirPath - Directory to search for session files - * @returns Array of session file paths - */ -export declare function listSessions(dirPath?: string): string[]; -/** - * Creates a session object from runtime data - * - * @param params - Session parameters - * @returns A NopySession object - */ -export declare function createSession(params: { - name?: string; - cubes: CubeSession[]; - hosts: string[]; - auth: AuthSession; - env?: TVariables; -}): NopySession; -/** - * Filters out internal variables from cube variables - * - * Internal variables are those used by the prompts system - * and should not be saved in session files. - * - * @param variables - Variables object - * @returns Filtered variables without internal keys - */ -export declare function filterInternalVariables(variables: Record): Record; -/** - * Separates environment variables from cube-specific variables - * - * @param allVariables - All variables including env and cube-specific - * @param envVariables - Known environment variables from config - * @returns Object with separate env and cube variables - */ -export declare function separateEnvAndCubeVariables(allVariables: Record, envVariables: Record): { - env: Record; - cubeVars: Record; -}; diff --git a/packages/nopy/dist/nopy.session.js b/packages/nopy/dist/nopy.session.js deleted file mode 100644 index bc1c893..0000000 --- a/packages/nopy/dist/nopy.session.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Session management for saving and replaying deployments - * @module nopy.session - */ -import fs from 'node:fs'; -import path from 'node:path'; -/** - * Saves a session to a JSON file - * - * @param session - The session to save - * @param filePath - Path to save the session file - * - * @example - * ```typescript - * saveSession(session, './my-deployment.nopysession.json'); - * ``` - */ -export function saveSession(session, filePath) { - const sessionToSave = { - ...session, - }; - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(filePath, JSON.stringify(sessionToSave, null, 2), 'utf-8'); -} -/** - * Loads a session from an MJS file - * - * @param filePath - Path to the MJS session file - * @returns The loaded session - */ -async function loadSessionFromMJS(filePath) { - const absolutePath = path.resolve(filePath); - const fileUrl = `file://${absolutePath}`; - try { - const module = (await import(fileUrl)); - const session = module.default; - if (!session) { - throw new Error('MJS file must export a default object'); - } - return session; - } - catch (error) { - throw new Error(`Failed to load MJS session: ${error instanceof Error ? error.message : String(error)}`); - } -} -/** - * Loads a session from a JSON file - * - * @param filePath - Path to the JSON session file - * @returns The loaded session - */ -function loadSessionFromJSON(filePath) { - const content = fs.readFileSync(filePath, 'utf-8'); - return JSON.parse(content); -} -/** - * Loads a session from a JSON or MJS file - * - * @param filePath - Path to the session file (.json or .mjs) - * @returns The loaded session - * @throws Error if file not found or invalid format - * - * @example - * ```typescript - * const session = await loadSession('./deployment.nopysession.json'); - * ``` - */ -export async function loadSession(filePath) { - if (!fs.existsSync(filePath)) { - throw new Error(`Session file not found: ${filePath}`); - } - const ext = path.extname(filePath); - let session; - if (ext === '.mjs') { - session = await loadSessionFromMJS(filePath); - } - else if (ext === '.json') { - session = loadSessionFromJSON(filePath); - } - else { - throw new Error(`Unsupported session file format: ${ext}. Use .json or .mjs`); - } - // Validate required fields - if (!session.cubes || !Array.isArray(session.cubes)) { - throw new Error('Invalid session format: missing or invalid "cubes" field'); - } - if (session.hosts && !Array.isArray(session.hosts)) { - throw new Error('Invalid session format: invalid "hosts" field'); - } - if (!session.auth) { - throw new Error('Invalid session format: missing "auth" field'); - } - return session; -} -/** - * Lists all session files in a directory - * - * @param dirPath - Directory to search for session files - * @returns Array of session file paths - */ -export function listSessions(dirPath = process.cwd()) { - if (!fs.existsSync(dirPath)) { - return []; - } - const files = fs.readdirSync(dirPath); - return files - .filter((file) => file.endsWith('.session.json') || file.endsWith('.session.mjs')) - .map((file) => path.join(dirPath, file)); -} -/** - * Creates a session object from runtime data - * - * @param params - Session parameters - * @returns A NopySession object - */ -export function createSession(params) { - return { - name: params.name, - cubes: params.cubes, - hosts: params.hosts, - auth: params.auth, - env: params.env, - }; -} -/** - * Filters out internal variables from cube variables - * - * Internal variables are those used by the prompts system - * and should not be saved in session files. - * - * @param variables - Variables object - * @returns Filtered variables without internal keys - */ -export function filterInternalVariables(variables) { - const internalKeys = ['customize']; - const filtered = {}; - for (const [key, value] of Object.entries(variables)) { - if (!internalKeys.includes(key)) { - filtered[key] = value; - } - } - return filtered; -} -/** - * Separates environment variables from cube-specific variables - * - * @param allVariables - All variables including env and cube-specific - * @param envVariables - Known environment variables from config - * @returns Object with separate env and cube variables - */ -export function separateEnvAndCubeVariables(allVariables, envVariables) { - const env = {}; - const cubeVars = {}; - for (const [key, value] of Object.entries(allVariables)) { - if (key in envVariables) { - env[key] = value; - } - else { - cubeVars[key] = value; - } - } - return { env, cubeVars }; -} diff --git a/packages/nopy/dist/nopy.workflow.d.ts b/packages/nopy/dist/nopy.workflow.d.ts deleted file mode 100644 index e38373b..0000000 --- a/packages/nopy/dist/nopy.workflow.d.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Workflow logic for interactive and replay modes - * @module nopy.workflow - */ -import type { Cube } from './cubes/index.js'; -import type { NopyConfig } from './nopy.config.js'; -import { type NopySession } from './nopy.session.js'; -/** - * Options for workflow execution - */ -export interface WorkflowOptions { - /** Use defaults without prompting */ - useDefaults?: boolean; - /** Force SSH key authentication */ - useAuthKey?: boolean; -} -/** - * Result of running a workflow - */ -export interface WorkflowResult { - /** The session configuration */ - session: NopySession; - /** Target cubes selected for execution */ - selectedCubes: string[]; - /** Authentication method used */ - authMethod: string; - /** Username if applicable */ - username?: string; - /** Password if applicable */ - password?: string; - /** Whether this is a session replay */ - isReplay: boolean; -} -/** - * Runs the interactive workflow for cube selection and configuration - */ -export declare function runInteractiveWorkflow(cubes: Record, config: NopyConfig, options?: WorkflowOptions): Promise; -/** - * Runs the replay workflow from a saved session file - */ -export declare function runReplayWorkflow(sessionPath: string, cubes: Record, config: NopyConfig): Promise; -/** - * Runs replay workflow from a session object (from history) - */ -export declare function runSessionReplayWorkflow(session: NopySession, cubes: Record, config: NopyConfig): Promise; -/** - * Determines the appropriate workflow based on options - */ -export declare function runWorkflow(sessionPath: string | undefined, cubes: Record, config: NopyConfig, options?: WorkflowOptions, replaySession?: NopySession): Promise; diff --git a/packages/nopy/dist/nopy.workflow.js b/packages/nopy/dist/nopy.workflow.js deleted file mode 100644 index 7ec7c17..0000000 --- a/packages/nopy/dist/nopy.workflow.js +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Workflow logic for interactive and replay modes - * @module nopy.workflow - */ -import { getLogger } from '@logtape/logtape'; -import { AuthSelection, CubeSelection, HostSelection, PasswordSelection } from './nopy.prompts.js'; -import { createSession, loadSession } from './nopy.session.js'; -const log = getLogger(['nopy', 'workflow']); -/** - * Runs the interactive workflow for cube selection and configuration - */ -export async function runInteractiveWorkflow(cubes, config, options = {}) { - const { useAuthKey } = options; - // Step 1: Select cubes - const { selectedCubes } = await CubeSelection(cubes); - log.info('Selected cubes', { selectedCubes }); - if (selectedCubes.length === 0) { - log.warn('No cubes selected'); - } - // Step 2: Select host - const host = await HostSelection(config.hosts); - // Step 3: Select authentication - const isLocalHost = host.includes('@vagrant') || host.includes('@docker'); - const authResult = isLocalHost - ? { authMethod: 'ssh', username: undefined, password: undefined } - : await AuthSelection(useAuthKey); - // Create session - const session = createSession({ - cubes: [], // Will be populated during build - hosts: [host], - auth: { - method: authResult.authMethod, - username: authResult.username, - }, - env: config.env, - }); - return { - session, - selectedCubes, - authMethod: authResult.authMethod, - username: authResult.username, - password: authResult.password, - isReplay: false, - }; -} -/** - * Runs the replay workflow from a saved session file - */ -export async function runReplayWorkflow(sessionPath, cubes, config) { - log.info('Loading session from', { path: sessionPath }); - const session = await loadSession(sessionPath); - log.info('Session loaded', { name: session.name, cubeCount: session.cubes.length }); - // Validate cubes exist - for (const cubeSession of session.cubes) { - if (!cubes[cubeSession.key]) { - log.warn(`Cube from session not found: ${cubeSession.key}`); - } - } - // Handle missing hosts - if (!session.hosts || session.hosts.length === 0) { - log.info('No hosts in session, prompting for selection'); - const host = await HostSelection(config.hosts); - session.hosts = [host]; - } - // Extract auth details - let authMethod = session.auth.method; - let username = session.auth.username; - let password; - // Prompt for password if needed (passwords are never stored) - if (authMethod === 'password') { - if (username) { - password = await PasswordSelection(username); - } - else { - log.info('Password auth requires username, prompting'); - const authResult = await AuthSelection(false); - authMethod = authResult.authMethod; - username = authResult.username; - password = authResult.password; - } - } - // Target cubes are those in the session - const selectedCubes = session.cubes.map((c) => c.key); - return { - session, - selectedCubes, - authMethod, - username, - password, - isReplay: true, - }; -} -/** - * Runs replay workflow from a session object (from history) - */ -export async function runSessionReplayWorkflow(session, cubes, config) { - log.info('Replaying session from history', { cubeCount: session.cubes.length }); - // Validate cubes exist - for (const cubeSession of session.cubes) { - if (!cubes[cubeSession.key]) { - log.warn(`Cube from session not found: ${cubeSession.key}`); - } - } - // Handle missing hosts - if (!session.hosts || session.hosts.length === 0) { - log.info('No hosts in session, prompting for selection'); - const host = await HostSelection(config.hosts); - session.hosts = [host]; - } - // Extract auth details - let authMethod = session.auth.method; - let username = session.auth.username; - let password; - // Prompt for password if needed (passwords are never stored) - if (authMethod === 'password') { - if (username) { - password = await PasswordSelection(username); - } - else { - log.info('Password auth requires username, prompting'); - const authResult = await AuthSelection(false); - authMethod = authResult.authMethod; - username = authResult.username; - password = authResult.password; - } - } - // Target cubes are those in the session - const selectedCubes = session.cubes.map((c) => c.key); - return { - session, - selectedCubes, - authMethod, - username, - password, - isReplay: true, - }; -} -/** - * Determines the appropriate workflow based on options - */ -export async function runWorkflow(sessionPath, cubes, config, options = {}, replaySession) { - if (replaySession) { - return runSessionReplayWorkflow(replaySession, cubes, config); - } - if (sessionPath) { - return runReplayWorkflow(sessionPath, cubes, config); - } - return runInteractiveWorkflow(cubes, config, options); -} diff --git a/packages/nopy/README.DOCKER.md b/packages/nopy/docs/DOCKER.md similarity index 100% rename from packages/nopy/README.DOCKER.md rename to packages/nopy/docs/DOCKER.md diff --git a/packages/nopy/NOPY.REFACTORING.md b/packages/nopy/docs/REFACTORING.md similarity index 100% rename from packages/nopy/NOPY.REFACTORING.md rename to packages/nopy/docs/REFACTORING.md diff --git a/packages/nopy/README.VAGRANT.md b/packages/nopy/docs/VAGRANT.md similarity index 100% rename from packages/nopy/README.VAGRANT.md rename to packages/nopy/docs/VAGRANT.md diff --git a/packages/nopy/package.json b/packages/nopy/package.json index b73acc9..d5cab51 100644 --- a/packages/nopy/package.json +++ b/packages/nopy/package.json @@ -1,44 +1,73 @@ { "name": "@bitstack/nopy", - "description": "A system to simplify pyinfra script management and execution.", - "type": "module", "version": "1.0.0", - "private": true, + "description": "A system to simplify pyinfra script management and execution.", + "keywords": [ + "pyinfra", + "deployment", + "cli", + "infrastructure" + ], + "license": "MIT", "author": "bitsquare", - "bin": "./dist/nopy.cli.js", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git", + "directory": "packages/nopy" + }, + "homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/nopy", + "bugs": { + "url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues" + }, + "engines": { + "node": ">=22" + }, + "bin": { + "nopy": "./dist/nopy.cli.js" + }, "exports": { - ".": "./dist/index.js" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" }, "scripts": { - "clean": "rm -rf dist", - "build": "tsgo && chmod +x dist/nopy.cli.js && npm link", - "build:legacy": "tsc && chmod +x dist/nopy.cli.js && npm link", - "prepublishOnly": "npm run build", - "nopy": "node --loader ts-node/esm src/nopy.cli.ts", - "debug": "node --inspect-brk --loader ts-node/esm src/nopy.cli.ts", + "clean": "rm -rf dist .tsbuildinfo", + "build": "tsc", + "prepack": "pnpm run build", + "link:local": "pnpm run build && npm link", + "nopy": "tsx src/nopy.cli.ts", + "debug": "tsx --inspect-brk src/nopy.cli.ts", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:integration": "vitest run --pool=forks", "test:watch": "vitest" }, - "files": ["dist/"], "dependencies": { - "@logtape/logtape": "^0.8.0", - "commander": "^13.1.0", + "@logtape/logtape": "^2.2.4", + "commander": "^15.0.0", "enquirer": "^2.4.1", - "execa": "9.5.2", + "execa": "^10.0.0", "fuzzy": "^0.1.3", - "inquirer": "8.2.4", - "inquirer-checkbox-plus-prompt": "^1.0.1", - "ts-node": ">=10.9.1", - "typescript": ">=5.6.3", - "yaml": "^2.8.2", - "zod": "^3.24.1", - "zx": "^8.3.0" + "inquirer": "^14.0.2", + "zod": "^4.4.3", + "zx": "^8.8.5" }, "devDependencies": { - "@types/inquirer": "^8.2.10", - "@types/node": "^20.0.0", - "@types/uniqid": "^5.3.4", - "vitest": "^1.6.0" + "@types/node": "^26.1.1", + "@vitest/coverage-v8": "^4.1.10", + "tsx": "^4.23.1", + "typescript": "^7.0.2", + "vitest": "^4.1.10" } } diff --git a/packages/nopy/src/cubes/dependencies.ts b/packages/nopy/src/cubes/dependencies.ts index dcd9041..966ff4b 100644 --- a/packages/nopy/src/cubes/dependencies.ts +++ b/packages/nopy/src/cubes/dependencies.ts @@ -8,8 +8,8 @@ import type { Variables } from '../nopy.common.js'; import type { NopyConfig } from '../nopy.config.js'; import type { DeployCall } from '../nopy.executor.js'; import { VariableAssignment } from '../nopy.prompts.js'; -import { type CubeSession, type NopySession } from '../nopy.session.js'; -import type { Cube, CubeVariables, DependencySpec, HookContext } from './types.js'; +import type { CubeSession, NopySession } from '../nopy.session.js'; +import type { Cube, CubeVariables, HookContext } from './types.js'; const log = getLogger(['nopy', 'resolution']); @@ -40,7 +40,11 @@ export class BuildContext { /** * Resolves a cube, its dependencies, and hooks recursively */ - public async resolveCube(cubeId: string, host: string, overrides: CubeVariables = {}): Promise { + public async resolveCube( + cubeId: string, + host: string, + overrides: CubeVariables = {} + ): Promise { const cube = this.allCubes[cubeId]; if (!cube) { throw new Error(`Cube not found: ${cubeId}`); @@ -56,7 +60,7 @@ export class BuildContext { // 2. Variable collection if (this.options.isSessionReplay) { - const sessionCube = this.session.cubes.find(c => c.key === cubeId); + const sessionCube = this.session.cubes.find((c) => c.key === cubeId); if (sessionCube) { this.variables.assign(cubeId, 'defaults', sessionCube.variables); } @@ -101,7 +105,7 @@ export class BuildContext { private buildDeployCall(cube: Cube, host: string): void { const cubeId = cube.id; const callKey = `${cubeId}:${host}`; - + if (this.resolvedCubes.has(callKey)) return; const parts: string[] = []; @@ -128,7 +132,7 @@ export class BuildContext { dependencies: [], }); - if (!this.cubeSessions.some(s => s.key === cubeId)) { + if (!this.cubeSessions.some((s) => s.key === cubeId)) { this.cubeSessions.push({ key: cubeId, variables: this.variables.get(cubeId, 'prompts'), diff --git a/packages/nopy/src/cubes/factories.ts b/packages/nopy/src/cubes/factories.ts index 647db37..794e817 100644 --- a/packages/nopy/src/cubes/factories.ts +++ b/packages/nopy/src/cubes/factories.ts @@ -3,7 +3,7 @@ * @module cubes/factories */ -import { Manifest } from './types.js'; +import { type AnyObjectSchema, Manifest } from './types.js'; /** * Creates a manifest configuration for a cube @@ -11,7 +11,7 @@ import { Manifest } from './types.js'; * @param opts - Manifest options including name, schema, dependencies, and hooks * @returns Manifest configuration object */ -export function createManifest( +export function createManifest( opts: Pick, 'name'> & Partial, 'name'>> ): Manifest { return Manifest(opts); diff --git a/packages/nopy/src/cubes/index.ts b/packages/nopy/src/cubes/index.ts index 19d0379..233074e 100644 --- a/packages/nopy/src/cubes/index.ts +++ b/packages/nopy/src/cubes/index.ts @@ -6,37 +6,32 @@ * @module cubes */ +// Dependencies +export { BuildContext } from './dependencies.js'; +// Factory functions +export { + createManifest, + manifest, +} from './factories.js'; +// Loader +export { + findCubeDirectories, + getCube, + loadCubes, +} from './loader.js'; +export type { + AnyObjectSchema, + CubeVariables, + DependencySpec, + Hook, + HookContext, + LoadResult, +} from './types.js'; // Types export { Cube, Manifest, } from './types.js'; -export type { - Hook, - HookContext, - LoadResult, - CubeVariables, - DependencySpec, -} from './types.js'; - -// Factory functions -export { - createManifest, - manifest, -} from './factories.js'; - -// Loader -export { - loadCubes, - findCubeDirectories, - getCube, -} from './loader.js'; - -// Dependencies -export { - BuildContext, -} from './dependencies.js'; - // Utilities export { uniqid } from './utils.js'; diff --git a/packages/nopy/src/cubes/loader.ts b/packages/nopy/src/cubes/loader.ts index d0a4501..6cc08d8 100644 --- a/packages/nopy/src/cubes/loader.ts +++ b/packages/nopy/src/cubes/loader.ts @@ -80,7 +80,7 @@ export async function loadCubes(): Promise { errors.push(`Invalid manifest format in ${manifestPath}: missing 'name'`); } else { const cubeId = extractCubeId(manifest) || path.basename(cubePath); - + if (cubes[cubeId]) { errors.push(`Duplicate cube id '${cubeId}'`); return; diff --git a/packages/nopy/src/cubes/types.ts b/packages/nopy/src/cubes/types.ts index eee3ff5..d902580 100644 --- a/packages/nopy/src/cubes/types.ts +++ b/packages/nopy/src/cubes/types.ts @@ -5,6 +5,13 @@ import { z } from 'zod'; +/** + * Any object schema, whatever its shape. + * + * Stands in for zod 3's `z.AnyZodObject`, which zod 4 removed. + */ +export type AnyObjectSchema = z.ZodObject>>; + /** * Variables that can be passed to a cube */ @@ -25,7 +32,7 @@ export interface HookContext { /** * Hook function type for before/after cube execution */ -export type Hook = ( +export type Hook = ( ctx: HookContext, variables: z.infer ) => void | Promise; @@ -33,7 +40,7 @@ export type Hook = ( /** * User-defined specification for a cube */ -export interface Manifest { +export interface Manifest { /** Unique identifier for the cube (used for dependency references) */ id: string; /** Human-readable name of the cube */ @@ -51,7 +58,7 @@ export interface Manifest { /** * Factory function and namespace for Manifest */ -export function Manifest( +export function Manifest( opts: Pick, 'name'> & Partial, 'name'>> ): Manifest { return { @@ -68,7 +75,7 @@ export namespace Manifest { /** * Internal create helper */ - export function create( + export function create( opts: Pick, 'name'> & Partial, 'name'>> ): Manifest { return Manifest(opts); @@ -78,7 +85,7 @@ export namespace Manifest { /** * A fully loaded cube with its filesystem location and runtime state */ -export class Cube { +export class Cube { constructor( public readonly manifest: Manifest, public readonly dir: string, diff --git a/packages/nopy/src/index.ts b/packages/nopy/src/index.ts index 919b2b3..b790869 100644 --- a/packages/nopy/src/index.ts +++ b/packages/nopy/src/index.ts @@ -6,81 +6,73 @@ // Cubes module export * from './cubes/index.js'; - +export type { + ExecutionConfig, + HistoryConfig, + LogConfig, + LogVerbosity, + NopyConfig, + NopyConfigFile, + ResolutionConfig, + ResolutionStrategy, +} from './nopy.config.js'; +// Configuration +export { getConfigPaths, loadConfig, logConfigToFlags, saveConfig } from './nopy.config.js'; // Backwards compatibility - cubes namespace export { cubes } from './nopy.cubes.js'; - -// Main entry point -export { nopy } from './nopy.main.js'; -export type { NopyOptions, NopyResult } from './nopy.main.js'; - +export type { + DeployCall, + ExecutionOptions, + ExecutionResult, +} from './nopy.executor.js'; // Executor export { executeDeployCalls, outputExecutionPlan, summarizeResults, } from './nopy.executor.js'; -export type { - DeployCall, - ExecutionResult, - ExecutionOptions, -} from './nopy.executor.js'; - +export type { HistoryEntry, SessionHistory } from './nopy.history.js'; +// History management +export { + addToHistory, + clearHistory, + DEFAULT_HISTORY_SIZE, + formatHistoryList, + getHistoryPath, + getLastSession, + getSessionById, + HISTORY_FILE, + listHistory, + loadHistory, + removeFromHistory, + saveHistory, +} from './nopy.history.js'; +export type { NopyOptions, NopyResult } from './nopy.main.js'; +// Main entry point +export { nopy } from './nopy.main.js'; +// Prompts +export { + AuthSelection, + CubeSelection, + HostSelection, + PasswordSelection, + VariableAssignment, +} from './nopy.prompts.js'; +export type { AuthSession, CubeSession, NopySession } from './nopy.session.js'; +// Session management +export { + createSession, + filterInternalVariables, + listSessions, + loadSession, + saveSession, + separateEnvAndCubeVariables, +} from './nopy.session.js'; +export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js'; // Workflow export { - runWorkflow, runInteractiveWorkflow, runReplayWorkflow, runSessionReplayWorkflow, + runWorkflow, } from './nopy.workflow.js'; -export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js'; - -// Prompts -export { - CubeSelection, - AuthSelection, - HostSelection, - VariableAssignment, - PasswordSelection, -} from './nopy.prompts.js'; - -// Session management -export { - loadSession, - saveSession, - createSession, - listSessions, - filterInternalVariables, - separateEnvAndCubeVariables, -} from './nopy.session.js'; -export type { NopySession, CubeSession, AuthSession } from './nopy.session.js'; - -// History management -export { - loadHistory, - saveHistory, - addToHistory, - getLastSession, - getSessionById, - listHistory, - clearHistory, - removeFromHistory, - formatHistoryList, - getHistoryPath, - DEFAULT_HISTORY_SIZE, - HISTORY_FILE, -} from './nopy.history.js'; -export type { HistoryEntry, SessionHistory } from './nopy.history.js'; - -// Configuration -export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js'; -export type { - NopyConfig, - NopyConfigFile, - LogConfig, - LogVerbosity, - HistoryConfig, - ExecutionConfig, - ResolutionStrategy, - ResolutionConfig, -} from './nopy.config.js'; diff --git a/packages/nopy/src/nopy.cli.ts b/packages/nopy/src/nopy.cli.ts index 203a416..6fa50eb 100644 --- a/packages/nopy/src/nopy.cli.ts +++ b/packages/nopy/src/nopy.cli.ts @@ -5,6 +5,7 @@ * @module nopy.cli */ +import { createRequire } from 'node:module'; import { Command } from 'commander'; import { loadConfig } from './nopy.config.js'; import { @@ -16,12 +17,13 @@ import { } from './nopy.history.js'; import { nopy } from './nopy.main.js'; +const { version } = createRequire(import.meta.url)('../package.json') as { version: string }; + const program = new Command(); -const config = loadConfig(); program .name('nopy') - .version('1.0.0') + .version(version) .description('A CLI tool for pyinfra script management and execution.') .addHelpText( 'after', @@ -61,8 +63,8 @@ program .option('-j, --json', 'Output results as JSON') .option('--no-history', 'Do not save this session to history') .action(async (options) => { - // Apply config defaults - const execConfig = config.execution ?? {}; + // Loaded lazily so that --help/--version work outside a configured project. + const execConfig = loadConfig().execution ?? {}; const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false; try { diff --git a/packages/nopy/src/nopy.cubes.ts b/packages/nopy/src/nopy.cubes.ts index 879f359..8b68c05 100644 --- a/packages/nopy/src/nopy.cubes.ts +++ b/packages/nopy/src/nopy.cubes.ts @@ -20,10 +20,10 @@ export const cubes = { // Re-export types for direct access export type { + Cube, + CubeVariables, Hook, HookContext, - Cube, - Manifest, LoadResult, - CubeVariables, + Manifest, } from './cubes/index.js'; diff --git a/packages/nopy/src/nopy.main.ts b/packages/nopy/src/nopy.main.ts index 04e9295..0b9c73a 100644 --- a/packages/nopy/src/nopy.main.ts +++ b/packages/nopy/src/nopy.main.ts @@ -3,13 +3,13 @@ * @module nopy.main */ -import { type LogRecord, configure, getAnsiColorFormatter, getLogger } from '@logtape/logtape'; -import { loadCubes } from './cubes/index.js'; +import { configure, getAnsiColorFormatter, getLogger, type LogRecord } from '@logtape/logtape'; import { BuildContext } from './cubes/dependencies.js'; +import { loadCubes } from './cubes/index.js'; import { Variables } from './nopy.common.js'; import { getConfigPaths, loadConfig } from './nopy.config.js'; import { type ExecutionResult, executeDeployCalls, summarizeResults } from './nopy.executor.js'; -import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js'; +import { addToHistory, DEFAULT_HISTORY_SIZE } from './nopy.history.js'; import { type NopySession, saveSession } from './nopy.session.js'; import { runWorkflow } from './nopy.workflow.js'; @@ -34,12 +34,12 @@ function configureLogtape(): void { loggers: [ { category: ['logtape', 'meta'], - level: 'error', + lowestLevel: 'error', sinks: ['console'], }, { category: 'nopy', - level: 'debug', + lowestLevel: 'debug', sinks: ['console'], }, ], @@ -52,7 +52,10 @@ configureLogtape(); /** * Prints the active configuration summary */ -function printActiveConfig(config: import('./nopy.config.js').NopyConfig, opts: { continueOnError: boolean }): void { +function printActiveConfig( + config: import('./nopy.config.js').NopyConfig, + opts: { continueOnError: boolean } +): void { const configPaths = getConfigPaths(); const cwd = process.cwd(); @@ -143,12 +146,18 @@ export async function nopy(opts: NopyOptions = {}): Promise 0) { log.error('Errors found during cube loading:'); - errors.forEach((error) => log.error(error)); + for (const error of errors) log.error(error); if (jsonOutput) console.log(JSON.stringify({ success: false, errors }, null, 2)); return undefined; } - const workflow = await runWorkflow(loadSessionPath, cubes, config, { useDefaults, useAuthKey }, replaySession); + const workflow = await runWorkflow( + loadSessionPath, + cubes, + config, + { useDefaults, useAuthKey }, + replaySession + ); // Step 3: Build deployment calls using BuildContext const context = new BuildContext( diff --git a/packages/nopy/src/nopy.prompts.ts b/packages/nopy/src/nopy.prompts.ts index 771c413..a9cdb09 100644 --- a/packages/nopy/src/nopy.prompts.ts +++ b/packages/nopy/src/nopy.prompts.ts @@ -3,23 +3,31 @@ * @module nopy.prompts */ -// @ts-ignore - no types available import Enquirer from 'enquirer'; import fuzzy from 'fuzzy'; import inquirer from 'inquirer'; -// @ts-ignore - no types available -import CheckboxPlus from 'inquirer-checkbox-plus-prompt'; import { z } from 'zod'; -import type { Cube } from './cubes/index.js'; +import type { AnyObjectSchema, Cube } from './cubes/index.js'; import type { Variables } from './nopy.common.js'; -// Register the checkbox-plus prompt type for filterable multi-select -inquirer.registerPrompt('checkbox-plus', CheckboxPlus); - interface CubeChoice { + /** Submitted value — enquirer returns the `name` of each selected choice. */ name: string; - value: string; - short: string; + /** Label rendered in the list. */ + message: string; +} + +/** + * Fuzzy-filters the cube list against what the user has typed so far. + * + * Handed to enquirer as `suggest`, which calls it on every keystroke with the + * current input and the full choice list. + */ +function suggestCubes(input: string | undefined, choices: CubeChoice[]): CubeChoice[] { + if (!input) return choices; + return fuzzy + .filter(input, choices, { extract: (choice: CubeChoice) => choice.message }) + .map((result) => result.original); } /** @@ -31,9 +39,8 @@ export async function CubeSelection( const cubeChoices: CubeChoice[] = Object.values(cubes) .sort((a, b) => a.id.localeCompare(b.id)) .map((cube) => ({ - name: `${cube.id} - ${cube.name}`, - value: cube.id, - short: cube.id, + name: cube.id, + message: `${cube.id} - ${cube.name}`, })); // Clear terminal and move cursor to top @@ -45,26 +52,21 @@ export async function CubeSelection( console.log('\n Cube Selection\n'); console.log(' Type to filter • Space to select • Enter to confirm\n'); - const answers = await inquirer.prompt([ - { - type: 'checkbox-plus', - name: 'selectedCubes', - message: 'Select cubes:', - pageSize, - highlight: true, - searchable: true, - source: (_answersSoFar: unknown, input: string | undefined) => { - const searchTerm = input || ''; - if (!searchTerm) return Promise.resolve(cubeChoices); - const results = fuzzy.filter(searchTerm, cubeChoices, { - extract: (choice: CubeChoice) => choice.name, - }); - return Promise.resolve(results.map((r) => r.original)); - }, - }, - ]); + const prompt = new (Enquirer as any).AutoComplete({ + name: 'selectedCubes', + message: 'Select cubes:', + limit: pageSize, + multiple: true, + choices: cubeChoices, + suggest: suggestCubes, + }); - return { selectedCubes: answers.selectedCubes }; + try { + return { selectedCubes: await prompt.run() }; + } catch { + // User cancelled + return { selectedCubes: [] }; + } } export async function AuthSelection(useAuthKey?: boolean): Promise<{ @@ -140,7 +142,7 @@ export async function HostSelection(hosts: string[]): Promise { return selectedHost.customHost ?? selectedHost.host; } -function coerceValue(value: unknown, zodType: z.ZodTypeAny): unknown { +function coerceValue(value: unknown, zodType: z.core.$ZodType): unknown { if (typeof value !== 'string') return value; if (zodType instanceof z.ZodDefault) return coerceValue(value, zodType._def.innerType); if (zodType instanceof z.ZodOptional) return coerceValue(value, zodType._def.innerType); @@ -162,14 +164,14 @@ interface FormChoice { initial: string; } -export async function VariableAssignment( +export async function VariableAssignment( cube: Cube, variables: Variables ) { const schema = cube.manifest.schema.shape; const defaults = cube.getDefaults(); const variablesToConfigure: Record = {}; - + for (const [key, defaultValue] of Object.entries(defaults)) { if (variables.get(cube.id, 'params')[key] === undefined) { variablesToConfigure[key] = defaultValue; diff --git a/packages/nopy/src/nopy.workflow.ts b/packages/nopy/src/nopy.workflow.ts index 232abf7..da82923 100644 --- a/packages/nopy/src/nopy.workflow.ts +++ b/packages/nopy/src/nopy.workflow.ts @@ -7,7 +7,7 @@ import { getLogger } from '@logtape/logtape'; import type { Cube } from './cubes/index.js'; import type { NopyConfig } from './nopy.config.js'; import { AuthSelection, CubeSelection, HostSelection, PasswordSelection } from './nopy.prompts.js'; -import { type AuthSession, type NopySession, createSession, loadSession } from './nopy.session.js'; +import { type AuthSession, createSession, loadSession, type NopySession } from './nopy.session.js'; const log = getLogger(['nopy', 'workflow']); diff --git a/packages/nopy/tests/config.loading.test.ts b/packages/nopy/tests/config.loading.test.ts new file mode 100644 index 0000000..237914d --- /dev/null +++ b/packages/nopy/tests/config.loading.test.ts @@ -0,0 +1,266 @@ +/** + * Tests for nopy.config loading, merging and path resolution. + * + * findConfigFiles() walks from cwd up to the filesystem root and also consults + * $HOME, so every test runs inside a fresh mkdtemp directory with HOME pointed + * at an empty directory. Without that, a developer's own ~/.nopyrc.json would + * leak into the merge result and make these tests machine-dependent. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getConfigPaths, loadConfig, type NopyConfigFile, saveConfig } from '../src/nopy.config.js'; + +describe('config loading', () => { + let originalCwd: string; + let originalHome: string | undefined; + let rootDir: string; + let emptyHome: string; + + const write = (dir: string, config: NopyConfigFile) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '.nopyrc.json'), JSON.stringify(config, null, 2)); + }; + + beforeEach(() => { + originalCwd = process.cwd(); + originalHome = process.env.HOME; + rootDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-config-'))); + emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-home-'))); + process.env.HOME = emptyHome; + process.chdir(rootDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + fs.rmSync(rootDir, { recursive: true, force: true }); + fs.rmSync(emptyHome, { recursive: true, force: true }); + }); + + describe('discovery', () => { + it('throws a helpful error when no config exists anywhere', () => { + expect(() => loadConfig()).toThrow(/No \.nopyrc\.json found/); + }); + + it('loads a config from the current directory', () => { + write(rootDir, { hosts: ['web-1'] }); + expect(loadConfig().hosts).toEqual(['web-1']); + }); + + it('applies defaults for properties the file omits', () => { + write(rootDir, { hosts: ['web-1'] }); + const config = loadConfig(); + expect(config.cubeDirs).toEqual([]); + expect(config.env).toEqual({}); + }); + + it('finds a config in a parent directory', () => { + write(rootDir, { hosts: ['parent-host'] }); + const child = path.join(rootDir, 'a', 'b'); + fs.mkdirSync(child, { recursive: true }); + process.chdir(child); + + expect(loadConfig().hosts).toEqual(['parent-host']); + }); + + it('picks up $HOME config at the lowest priority', () => { + write(emptyHome, { hosts: ['home-host'] }); + write(rootDir, { hosts: ['project-host'] }); + + // Root-first ordering means the home value is merged in first. + expect(loadConfig().hosts).toEqual(['home-host', 'project-host']); + }); + + it('does not duplicate the home config when cwd is $HOME', () => { + write(emptyHome, { hosts: ['home-host'] }); + process.chdir(emptyHome); + + expect(getConfigPaths().filter((p) => p.startsWith(emptyHome))).toHaveLength(1); + expect(loadConfig().hosts).toEqual(['home-host']); + }); + + it('tolerates an unset HOME', () => { + process.env.HOME = ''; + write(rootDir, { hosts: ['web-1'] }); + expect(loadConfig().hosts).toEqual(['web-1']); + }); + + it('reports discovered config paths parent-first', () => { + write(rootDir, { hosts: ['parent'] }); + const child = path.join(rootDir, 'child'); + write(child, { hosts: ['child'] }); + process.chdir(child); + + const paths = getConfigPaths(); + expect(paths).toEqual([path.join(rootDir, '.nopyrc.json'), path.join(child, '.nopyrc.json')]); + }); + + it('wraps malformed JSON with the offending path', () => { + fs.writeFileSync(path.join(rootDir, '.nopyrc.json'), '{ not valid json'); + expect(() => loadConfig()).toThrow(/Failed to load config .*\.nopyrc\.json/); + }); + }); + + describe('merge strategy', () => { + const nested = () => { + const child = path.join(rootDir, 'child'); + fs.mkdirSync(child, { recursive: true }); + return child; + }; + + it('concatenates arrays and de-duplicates primitives', () => { + const child = nested(); + write(rootDir, { hosts: ['a', 'b'] }); + write(child, { hosts: ['b', 'c'] }); + process.chdir(child); + + expect(loadConfig().hosts).toEqual(['a', 'b', 'c']); + }); + + it('replaces arrays entirely under the override strategy', () => { + const child = nested(); + write(rootDir, { hosts: ['a', 'b'] }); + write(child, { hosts: ['only-me'], resolution: { hosts: 'override' } }); + process.chdir(child); + + expect(loadConfig().hosts).toEqual(['only-me']); + }); + + it('deep merges nested objects', () => { + const child = nested(); + write(rootDir, { env: { SHARED: 'parent', ONLY_PARENT: 'p' } }); + write(child, { env: { SHARED: 'child', ONLY_CHILD: 'c' } }); + process.chdir(child); + + expect(loadConfig().env).toEqual({ + SHARED: 'child', + ONLY_PARENT: 'p', + ONLY_CHILD: 'c', + }); + }); + + it('lets a child primitive override a parent primitive', () => { + const child = nested(); + write(rootDir, { log: { verbosity: 'info', debug: true } }); + write(child, { log: { verbosity: 'trace' } }); + process.chdir(child); + + expect(loadConfig().log).toEqual({ verbosity: 'trace', debug: true }); + }); + + it('adds properties the parent never defined', () => { + const child = nested(); + write(rootDir, { hosts: ['a'] }); + write(child, { execution: { continueOnError: true } }); + process.chdir(child); + + expect(loadConfig().execution).toEqual({ continueOnError: true }); + }); + + it('keeps arrays of objects without de-duplicating them', () => { + const child = nested(); + write(rootDir, { env: { list: [{ a: 1 }] } as never }); + write(child, { env: { list: [{ a: 1 }] } as never }); + process.chdir(child); + + expect((loadConfig().env as Record).list).toHaveLength(2); + }); + + it('never surfaces the resolution key in the merged config', () => { + write(rootDir, { hosts: ['a'], resolution: { hosts: 'override' } }); + expect(loadConfig()).not.toHaveProperty('resolution'); + }); + }); + + describe('relative path resolution', () => { + it('resolves ./ cubeDirs against the config file location', () => { + write(rootDir, { cubeDirs: ['./cubes'] }); + expect(loadConfig().cubeDirs).toEqual([path.join(rootDir, 'cubes')]); + }); + + it('resolves ../ cubeDirs against the config file location', () => { + const child = path.join(rootDir, 'child'); + write(child, { cubeDirs: ['../shared-cubes'] }); + process.chdir(child); + + expect(loadConfig().cubeDirs).toEqual([path.join(rootDir, 'shared-cubes')]); + }); + + it('resolves bare paths containing a separator', () => { + write(rootDir, { cubeDirs: ['nested/cubes'] }); + expect(loadConfig().cubeDirs).toEqual([path.join(rootDir, 'nested', 'cubes')]); + }); + + it('leaves absolute cubeDirs untouched', () => { + write(rootDir, { cubeDirs: ['/opt/cubes'] }); + expect(loadConfig().cubeDirs).toEqual(['/opt/cubes']); + }); + + it('leaves ~ and URL-like values untouched', () => { + write(rootDir, { cubeDirs: ['~/cubes', 'https://example.com/cubes'] }); + expect(loadConfig().cubeDirs).toEqual(['~/cubes', 'https://example.com/cubes']); + }); + + it('leaves a bare single-segment name untouched', () => { + write(rootDir, { cubeDirs: ['cubes'] }); + expect(loadConfig().cubeDirs).toEqual(['cubes']); + }); + + it('does not resolve paths for non-path properties such as hosts', () => { + write(rootDir, { hosts: ['@docker/ubuntu', './not-a-path'] }); + expect(loadConfig().hosts).toEqual(['@docker/ubuntu', './not-a-path']); + }); + + it('resolves each config file against its own directory', () => { + const child = path.join(rootDir, 'child'); + write(rootDir, { cubeDirs: ['./cubes'] }); + write(child, { cubeDirs: ['./cubes'] }); + process.chdir(child); + + expect(loadConfig().cubeDirs).toEqual([ + path.join(rootDir, 'cubes'), + path.join(child, 'cubes'), + ]); + }); + }); + + describe('saveConfig', () => { + it('writes a new config file at the given path', () => { + const target = path.join(rootDir, 'custom.json'); + saveConfig({ hosts: ['web-1'] }, target); + + expect(JSON.parse(fs.readFileSync(target, 'utf-8'))).toEqual({ hosts: ['web-1'] }); + }); + + it('defaults to .nopyrc.json in the cwd', () => { + saveConfig({ hosts: ['web-1'] }); + const written = path.join(rootDir, '.nopyrc.json'); + + expect(fs.existsSync(written)).toBe(true); + expect(JSON.parse(fs.readFileSync(written, 'utf-8')).hosts).toEqual(['web-1']); + }); + + it('shallow merges over an existing file', () => { + write(rootDir, { hosts: ['old'], env: { KEEP: '1' } }); + saveConfig({ hosts: ['new'] }); + + const result = JSON.parse(fs.readFileSync(path.join(rootDir, '.nopyrc.json'), 'utf-8')); + expect(result).toEqual({ hosts: ['new'], env: { KEEP: '1' } }); + }); + + it('starts fresh when the existing file is unparseable', () => { + fs.writeFileSync(path.join(rootDir, '.nopyrc.json'), '{{{ broken'); + saveConfig({ hosts: ['new'] }); + + const result = JSON.parse(fs.readFileSync(path.join(rootDir, '.nopyrc.json'), 'utf-8')); + expect(result).toEqual({ hosts: ['new'] }); + }); + }); +}); diff --git a/packages/nopy/tests/config.test.ts b/packages/nopy/tests/config.test.ts index 9b6c215..671b117 100644 --- a/packages/nopy/tests/config.test.ts +++ b/packages/nopy/tests/config.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from 'vitest'; -import { type LogConfig, logConfigToFlags } from '../src/nopy.config.js'; +import { logConfigToFlags } from '../src/nopy.config.js'; describe('logConfigToFlags', () => { it('returns empty array for silent verbosity', () => { diff --git a/packages/nopy/tests/cubes.dependencies.edge.test.ts b/packages/nopy/tests/cubes.dependencies.edge.test.ts new file mode 100644 index 0000000..0a0bead --- /dev/null +++ b/packages/nopy/tests/cubes.dependencies.edge.test.ts @@ -0,0 +1,184 @@ +/** + * Edge cases for BuildContext: unknown cubes, session replay and auth flags. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { BuildContext } from '../src/cubes/dependencies.js'; +import { Cube, Manifest } from '../src/cubes/types.js'; +import { Variables } from '../src/nopy.common.js'; +import type { NopyConfig } from '../src/nopy.config.js'; +import type { NopySession } from '../src/nopy.session.js'; + +vi.mock('../src/nopy.prompts.js', async () => { + const actual = await vi.importActual('../src/nopy.prompts.js'); + return { ...actual, VariableAssignment: vi.fn() }; +}); + +import { VariableAssignment } from '../src/nopy.prompts.js'; + +const testCube = (id: string, schema = z.object({})) => + new Cube(Manifest.create({ id, name: `Test ${id}`, schema }), `/test/${id}`, 'deploy.py'); + +const config = { env: {} } as NopyConfig; +const session = (cubes: NopySession['cubes'] = []) => ({ cubes }) as NopySession; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('BuildContext error handling', () => { + it('throws when the requested cube does not exist', async () => { + const context = new BuildContext({}, new Variables(), session(), config, { method: 'ssh' }); + + await expect(context.resolveCube('ghost', 'host1')).rejects.toThrow('Cube not found: ghost'); + }); + + it('throws when a dependency does not exist', async () => { + const cubeB = new Cube( + Manifest.create({ + id: 'cube-b', + name: 'B', + schema: z.object({}), + dependencies: () => ['ghost'], + }), + '/test/cube-b', + 'deploy.py' + ); + const context = new BuildContext({ 'cube-b': cubeB }, new Variables(), session(), config, { + method: 'ssh', + }); + + await expect(context.resolveCube('cube-b', 'host1')).rejects.toThrow('Cube not found: ghost'); + }); +}); + +describe('BuildContext session replay', () => { + it('takes variables from the session instead of prompting', async () => { + const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') })); + const vars = new Variables(); + const context = new BuildContext( + { 'cube-a': cube }, + vars, + session([{ key: 'cube-a', variables: { PORT: '9090' } }]), + config, + { method: 'ssh' }, + { isSessionReplay: true } + ); + + await context.resolveCube('cube-a', 'host1'); + + expect(VariableAssignment).not.toHaveBeenCalled(); + expect(context.deployCalls[0].env.PORT).toBe('9090'); + }); + + it('falls back to schema defaults when the session has no entry for the cube', async () => { + const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') })); + const context = new BuildContext( + { 'cube-a': cube }, + new Variables(), + session([{ key: 'other', variables: { PORT: '9090' } }]), + config, + { method: 'ssh' }, + { isSessionReplay: true } + ); + + await context.resolveCube('cube-a', 'host1'); + + expect(VariableAssignment).not.toHaveBeenCalled(); + expect(context.deployCalls[0].env.PORT).toBe('3000'); + }); + + it('prompts when not replaying', async () => { + const context = new BuildContext( + { 'cube-a': testCube('cube-a') }, + new Variables(), + session(), + config, + { method: 'ssh' } + ); + + await context.resolveCube('cube-a', 'host1'); + + expect(VariableAssignment).toHaveBeenCalled(); + }); +}); + +describe('BuildContext command construction', () => { + const build = (auth: { method: string; username?: string; password?: string }) => { + const context = new BuildContext( + { 'cube-a': testCube('cube-a') }, + new Variables(), + session(), + config, + auth + ); + return context.resolveCube('cube-a', 'host1').then(() => context); + }; + + it('adds --user/--password for complete password auth', async () => { + const context = await build({ method: 'password', username: 'deploy', password: 'pw' }); + + expect(context.deployCalls[0].command.join(' ')).toContain('--user deploy --password pw'); + }); + + it('omits credentials for ssh auth', async () => { + const context = await build({ method: 'ssh' }); + + expect(context.deployCalls[0].command.join(' ')).not.toContain('--user'); + }); + + it('omits credentials when the password is missing', async () => { + const context = await build({ method: 'password', username: 'deploy' }); + + expect(context.deployCalls[0].command.join(' ')).not.toContain('--user'); + }); + + it('omits credentials when the username is missing', async () => { + const context = await build({ method: 'password', password: 'pw' }); + + expect(context.deployCalls[0].command.join(' ')).not.toContain('--user'); + }); + + it('passes cube variables as --data flags and points at the deploy script', async () => { + const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') })); + const context = new BuildContext({ 'cube-a': cube }, new Variables(), session(), config, { + method: 'ssh', + }); + + await context.resolveCube('cube-a', 'host1'); + const command = context.deployCalls[0].command.join(' '); + + expect(command).toContain('--data "PORT=3000"'); + expect(command).toContain('--chdir /test/cube-a'); + expect(command).toContain('/test/cube-a/deploy.py'); + expect(context.deployCalls[0].cwd).toBe('/test/cube-a'); + }); + + it('builds a separate call per host but records the cube session once', async () => { + const context = new BuildContext( + { 'cube-a': testCube('cube-a') }, + new Variables(), + session(), + config, + { method: 'ssh' } + ); + + await context.resolveCube('cube-a', 'host1'); + await context.resolveCube('cube-a', 'host2'); + + expect(context.deployCalls.map((c) => c.host)).toEqual(['host1', 'host2']); + expect(context.cubeSessions).toHaveLength(1); + }); + + it('applies caller overrides as params', async () => { + const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') })); + const context = new BuildContext({ 'cube-a': cube }, new Variables(), session(), config, { + method: 'ssh', + }); + + await context.resolveCube('cube-a', 'host1', { PORT: '8080' }); + + expect(context.deployCalls[0].env.PORT).toBe('8080'); + }); +}); diff --git a/packages/nopy/tests/cubes.dependencies.test.ts b/packages/nopy/tests/cubes.dependencies.test.ts index b3695dc..1056763 100644 --- a/packages/nopy/tests/cubes.dependencies.test.ts +++ b/packages/nopy/tests/cubes.dependencies.test.ts @@ -35,7 +35,9 @@ describe('BuildContext.resolveCube', () => { const cubeA = createTestCube('cube-a'); const cubes = { 'cube-a': cubeA }; const vars = new Variables(); - const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { + method: 'ssh', + }); await context.resolveCube('cube-a', 'host1'); @@ -49,7 +51,9 @@ describe('BuildContext.resolveCube', () => { const cubeB = createTestCube('cube-b', () => ['cube-a']); const cubes = { 'cube-a': cubeA, 'cube-b': cubeB }; const vars = new Variables(); - const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { + method: 'ssh', + }); await context.resolveCube('cube-b', 'host1'); @@ -62,35 +66,41 @@ describe('BuildContext.resolveCube', () => { it('resolves dynamic dependencies based on variables', async () => { const cubeA = createTestCube('cube-a'); const cubeB = createTestCube('cube-b'); - const cubeC = createTestCube('cube-c', (vars) => vars.USE_A ? ['cube-a'] : ['cube-b']); - + const cubeC = createTestCube('cube-c', (vars) => (vars.USE_A ? ['cube-a'] : ['cube-b'])); + cubeC.manifest.schema = z.object({ USE_A: z.boolean().default(true) }); const cubes = { 'cube-a': cubeA, 'cube-b': cubeB, 'cube-c': cubeC }; - + // Test with USE_A = true const vars1 = new Variables(); - const context1 = new BuildContext(cubes, vars1, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + const context1 = new BuildContext(cubes, vars1, { cubes: [] } as any, { env: {} } as any, { + method: 'ssh', + }); await context1.resolveCube('cube-c', 'host1'); - expect(context1.deployCalls.map(c => c.cube)).toEqual(['cube-a', 'cube-c']); + expect(context1.deployCalls.map((c) => c.cube)).toEqual(['cube-a', 'cube-c']); // Test with USE_A = false const vars2 = new Variables(); vars2.assign('cube-c', 'params', { USE_A: false }); - const context2 = new BuildContext(cubes, vars2, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + const context2 = new BuildContext(cubes, vars2, { cubes: [] } as any, { env: {} } as any, { + method: 'ssh', + }); await context2.resolveCube('cube-c', 'host1'); - expect(context2.deployCalls.map(c => c.cube)).toEqual(['cube-b', 'cube-c']); + expect(context2.deployCalls.map((c) => c.cube)).toEqual(['cube-b', 'cube-c']); }); it('passes variables to dependencies', async () => { const cubeA = createTestCube('cube-a'); cubeA.manifest.schema = z.object({ VAR: z.string() }); - + const cubeB = createTestCube('cube-b', () => [['cube-a', { VAR: 'from-b' }]]); - + const cubes = { 'cube-a': cubeA, 'cube-b': cubeB }; const vars = new Variables(); - const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { + method: 'ssh', + }); await context.resolveCube('cube-b', 'host1'); @@ -102,14 +112,16 @@ describe('BuildContext.resolveCube', () => { const cubeA = createTestCube('cube-a'); const cubeB = createTestCube('cube-b', () => ['cube-a']); const cubeC = createTestCube('cube-c', () => ['cube-a', 'cube-b']); - + const cubes = { 'cube-a': cubeA, 'cube-b': cubeB, 'cube-c': cubeC }; const vars = new Variables(); - const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { + method: 'ssh', + }); await context.resolveCube('cube-c', 'host1'); // Execution order: cube-a, cube-b, cube-c - expect(context.deployCalls.map(c => c.cube)).toEqual(['cube-a', 'cube-b', 'cube-c']); + expect(context.deployCalls.map((c) => c.cube)).toEqual(['cube-a', 'cube-b', 'cube-c']); }); }); diff --git a/packages/nopy/tests/cubes.loader.edge.test.ts b/packages/nopy/tests/cubes.loader.edge.test.ts new file mode 100644 index 0000000..dbefaeb --- /dev/null +++ b/packages/nopy/tests/cubes.loader.edge.test.ts @@ -0,0 +1,184 @@ +/** + * Error and discovery edge cases for cubes/loader. + * + * Runs against a real temp directory because loadCubes() dynamically imports + * manifest files — there is no seam worth faking here. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { findCubeDirectories, getCube, loadCubes } from '../src/cubes/loader.js'; + +describe('loader edge cases', () => { + let originalCwd: string; + let originalHome: string | undefined; + let tmpDir: string; + let emptyHome: string; + + const cube = (dir: string, manifest: string, deployName = 'deploy.py') => { + fs.mkdirSync(path.join(tmpDir, dir), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, dir, 'manifest.mjs'), manifest); + fs.writeFileSync(path.join(tmpDir, dir, deployName), '# deploy'); + }; + + beforeEach(() => { + originalCwd = process.cwd(); + originalHome = process.env.HOME; + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-loader-'))); + emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-loader-home-'))); + process.env.HOME = emptyHome; + process.chdir(tmpDir); + fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: ['./'] })); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(emptyHome, { recursive: true, force: true }); + }); + + describe('findCubeDirectories', () => { + it('includes directories from cubeDirs', () => { + expect(findCubeDirectories()).toContain(tmpDir); + }); + + it('includes directories marked with a .npcubes file', () => { + fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: [] })); + fs.writeFileSync(path.join(tmpDir, '.npcubes'), ''); + const nested = path.join(tmpDir, 'a', 'b'); + fs.mkdirSync(nested, { recursive: true }); + process.chdir(nested); + + expect(findCubeDirectories()).toContain(tmpDir); + }); + + it('does not treat a .npcubes directory as a marker', () => { + fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: [] })); + fs.mkdirSync(path.join(tmpDir, '.npcubes')); + + expect(findCubeDirectories()).not.toContain(tmpDir); + }); + + it('de-duplicates a directory listed twice', () => { + fs.writeFileSync( + path.join(tmpDir, '.nopyrc.json'), + JSON.stringify({ cubeDirs: ['./', tmpDir] }) + ); + fs.writeFileSync(path.join(tmpDir, '.npcubes'), ''); + + expect(findCubeDirectories().filter((d) => d === tmpDir)).toHaveLength(1); + }); + }); + + describe('loadCubes', () => { + it('derives the id from a [bracket] name prefix', async () => { + cube('bracketed', 'export default { name: "[apt:base] Apt Base" }'); + + const { cubes, errors } = await loadCubes(); + + expect(errors).toEqual([]); + expect(cubes['apt:base'].name).toBe('[apt:base] Apt Base'); + }); + + it('falls back to the directory name when no id is derivable', async () => { + cube('fallback-id', 'export default { name: "No Id Here" }'); + + const { cubes } = await loadCubes(); + + expect(cubes['fallback-id']).toBeDefined(); + }); + + it('defaults the schema when the manifest omits one', async () => { + cube('no-schema', 'export default { id: "no-schema", name: "No Schema" }'); + + const { cubes } = await loadCubes(); + + expect(cubes['no-schema'].getDefaults()).toEqual({}); + }); + + it('reports a manifest whose default export is not an object', async () => { + cube('bad-export', 'export default "just a string"'); + + const { cubes, errors } = await loadCubes(); + + expect(cubes['bad-export']).toBeUndefined(); + expect(errors[0]).toMatch(/Invalid manifest export/); + }); + + it('reports a manifest with no default export', async () => { + cube('no-export', 'export const nothing = 1;'); + + const { errors } = await loadCubes(); + + expect(errors[0]).toMatch(/Invalid manifest export/); + }); + + it('reports a manifest missing a name', async () => { + cube('no-name', 'export default { id: "no-name" }'); + + const { errors } = await loadCubes(); + + expect(errors[0]).toMatch(/missing 'name'/); + }); + + it('reports a manifest that fails to import', async () => { + cube('broken', 'this is not valid javascript !!!'); + + const { errors } = await loadCubes(); + + expect(errors[0]).toMatch(/Failed to load manifest/); + }); + + it('reports duplicate cube ids', async () => { + cube('first', 'export default { id: "dup", name: "First" }'); + cube('second', 'export default { id: "dup", name: "Second" }'); + + const { cubes, errors } = await loadCubes(); + + expect(Object.keys(cubes)).toEqual(['dup']); + expect(errors[0]).toMatch(/Duplicate cube id 'dup'/); + }); + + it('skips hidden and node_modules directories', async () => { + cube('.hidden/inner', 'export default { id: "hidden", name: "Hidden" }'); + cube('node_modules/pkg', 'export default { id: "vendored", name: "Vendored" }'); + cube('visible', 'export default { id: "visible", name: "Visible" }'); + + const { cubes } = await loadCubes(); + + expect(Object.keys(cubes)).toEqual(['visible']); + }); + + it('ignores configured cube directories that do not exist', async () => { + fs.writeFileSync( + path.join(tmpDir, '.nopyrc.json'), + JSON.stringify({ cubeDirs: ['./', './does-not-exist'] }) + ); + cube('visible', 'export default { id: "visible", name: "Visible" }'); + + const { cubes, errors } = await loadCubes(); + + expect(errors).toEqual([]); + expect(cubes.visible).toBeDefined(); + }); + }); + + describe('getCube', () => { + it('returns a single cube by id', async () => { + cube('one', 'export default { id: "one", name: "One" }'); + + await expect(getCube('one')).resolves.toMatchObject({ id: 'one' }); + }); + + it('returns undefined for an unknown id', async () => { + await expect(getCube('nope')).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/nopy/tests/cubes.loader.test.ts b/packages/nopy/tests/cubes.loader.test.ts index 23d05de..9c3ab5f 100644 --- a/packages/nopy/tests/cubes.loader.test.ts +++ b/packages/nopy/tests/cubes.loader.test.ts @@ -99,7 +99,7 @@ describe('loadCubes (Integration)', () => { await fs.mkdirp('only-deploy'); await fs.writeFile('only-deploy/deploy.py', '# deploy'); - const { cubes, errors } = await loadCubes(); + const { cubes } = await loadCubes(); expect(Object.keys(cubes)).not.toContain('only-manifest'); expect(Object.keys(cubes)).not.toContain('only-deploy'); diff --git a/packages/nopy/tests/executor.execute.test.ts b/packages/nopy/tests/executor.execute.test.ts new file mode 100644 index 0000000..4059c79 --- /dev/null +++ b/packages/nopy/tests/executor.execute.test.ts @@ -0,0 +1,147 @@ +/** + * Tests for the executeDeployCalls path of nopy.executor. + * + * execa is mocked so no pyinfra process is ever spawned. Note the shape: + * the module calls execa({ shell: true })(command, opts), so the mock is a + * factory returning the runner. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const runner = vi.fn(); + +vi.mock('execa', () => ({ + execa: vi.fn(() => runner), +})); + +import { execa } from 'execa'; +import { type DeployCall, executeDeployCalls } from '../src/nopy.executor.js'; + +const call = (cube: string, host = 'web-1'): DeployCall => ({ + cube, + host, + cwd: `/cubes/${cube}`, + command: ['pyinfra', host, '-y', `${cube}.deploy.py`], + env: {}, + dependencies: [], +}); + +beforeEach(() => { + vi.clearAllMocks(); + runner.mockResolvedValue({ exitCode: 0 }); +}); + +describe('executeDeployCalls', () => { + it('returns early without spawning anything for an empty list', async () => { + const results = await executeDeployCalls([]); + + expect(results).toEqual([]); + expect(runner).not.toHaveBeenCalled(); + }); + + it('prints the plan and skips execution on a dry run', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const results = await executeDeployCalls([call('cube-a')], { dryRun: true }); + + expect(results).toEqual([]); + expect(runner).not.toHaveBeenCalled(); + expect(logSpy.mock.calls.map((c) => c[0]).join('\n')).toContain('Execution Plan'); + logSpy.mockRestore(); + }); + + it('runs the joined command in the call cwd with inherited stdio', async () => { + await executeDeployCalls([call('cube-a')]); + + expect(execa).toHaveBeenCalledWith({ shell: true }); + expect(runner).toHaveBeenCalledWith('pyinfra web-1 -y cube-a.deploy.py', { + cwd: '/cubes/cube-a', + stdio: 'inherit', + }); + }); + + it('reports success with a non-negative duration', async () => { + const [result] = await executeDeployCalls([call('cube-a')]); + + expect(result.success).toBe(true); + expect(result.cube).toBe('cube-a'); + expect(result.host).toBe('web-1'); + expect(result.duration).toBeGreaterThanOrEqual(0); + expect(result.error).toBeUndefined(); + }); + + it('captures a thrown Error as a failed result rather than rejecting', async () => { + runner.mockRejectedValue(new Error('exit code 1')); + + const [result] = await executeDeployCalls([call('cube-a')]); + + expect(result.success).toBe(false); + expect(result.error).toBeInstanceOf(Error); + expect(result.error?.message).toBe('exit code 1'); + }); + + it('wraps a non-Error rejection into an Error', async () => { + runner.mockRejectedValue('boom'); + + const [result] = await executeDeployCalls([call('cube-a')]); + + expect(result.error).toBeInstanceOf(Error); + expect(result.error?.message).toBe('boom'); + }); + + it('stops after the first failure by default', async () => { + runner.mockRejectedValueOnce(new Error('nope')).mockResolvedValue({ exitCode: 0 }); + + const results = await executeDeployCalls([call('cube-a'), call('cube-b')]); + + expect(results).toHaveLength(1); + expect(results[0].cube).toBe('cube-a'); + expect(runner).toHaveBeenCalledTimes(1); + }); + + it('keeps going past a failure when continueOnError is set', async () => { + runner.mockRejectedValueOnce(new Error('nope')).mockResolvedValue({ exitCode: 0 }); + + const results = await executeDeployCalls([call('cube-a'), call('cube-b')], { + continueOnError: true, + }); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.success)).toEqual([false, true]); + }); + + it('invokes onStart before each call', async () => { + const onStart = vi.fn(); + + await executeDeployCalls([call('cube-a'), call('cube-b', 'web-2')], { onStart }); + + expect(onStart.mock.calls).toEqual([ + ['cube-a', 'web-1'], + ['cube-b', 'web-2'], + ]); + }); + + it('invokes onProgress with running completed/total counts', async () => { + const onProgress = vi.fn(); + + await executeDeployCalls([call('cube-a'), call('cube-b')], { onProgress }); + + expect(onProgress).toHaveBeenCalledTimes(2); + expect(onProgress.mock.calls[0].slice(1)).toEqual([1, 2]); + expect(onProgress.mock.calls[1].slice(1)).toEqual([2, 2]); + }); + + it('reports progress for the failing call before stopping', async () => { + const onProgress = vi.fn(); + runner.mockRejectedValue(new Error('nope')); + + await executeDeployCalls([call('cube-a'), call('cube-b')], { onProgress }); + + expect(onProgress).toHaveBeenCalledTimes(1); + expect(onProgress.mock.calls[0][0].success).toBe(false); + }); + + it('works without any callbacks supplied', async () => { + await expect(executeDeployCalls([call('cube-a')])).resolves.toHaveLength(1); + }); +}); diff --git a/packages/nopy/tests/executor.test.ts b/packages/nopy/tests/executor.test.ts index 597cd83..beebb67 100644 --- a/packages/nopy/tests/executor.test.ts +++ b/packages/nopy/tests/executor.test.ts @@ -2,7 +2,7 @@ * Tests for nopy.executor module */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type DeployCall, type ExecutionResult, @@ -104,6 +104,12 @@ describe('outputExecutionPlan', () => { consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); }); + // vitest reuses an existing spy rather than re-wrapping, so recorded calls + // would otherwise leak from one test into the next. + afterEach(() => { + vi.restoreAllMocks(); + }); + it('outputs text format by default', () => { const calls = [createTestCall('cube-a', 'host1')]; diff --git a/packages/nopy/tests/history.test.ts b/packages/nopy/tests/history.test.ts index cf9a810..60f487b 100644 --- a/packages/nopy/tests/history.test.ts +++ b/packages/nopy/tests/history.test.ts @@ -7,17 +7,17 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { - HISTORY_FILE, - type HistoryEntry, - type SessionHistory, addToHistory, clearHistory, formatHistoryList, getLastSession, getSessionById, + HISTORY_FILE, + type HistoryEntry, listHistory, loadHistory, removeFromHistory, + type SessionHistory, saveHistory, } from '../src/nopy.history.js'; import type { NopySession } from '../src/nopy.session.js'; diff --git a/packages/nopy/tests/hooks.test.ts b/packages/nopy/tests/hooks.test.ts index 4cfdde3..fcca27e 100644 --- a/packages/nopy/tests/hooks.test.ts +++ b/packages/nopy/tests/hooks.test.ts @@ -24,24 +24,28 @@ describe('Cube Hooks', () => { const cubes: Record = { main: createMockCube('main', 'Main Cube', { - before: [async ({ exec }) => { - order.push('main:before'); - await exec('before-hook', {}); - }], - after: [async ({ exec }) => { - order.push('main:after'); - await exec('after-hook', {}); - }], + before: [ + async ({ exec }) => { + order.push('main:before'); + await exec('before-hook', {}); + }, + ], + after: [ + async ({ exec }) => { + order.push('main:after'); + await exec('after-hook', {}); + }, + ], dependencies: () => ['dep'], }), 'before-hook': createMockCube('before-hook', 'Before Hook'), 'after-hook': createMockCube('after-hook', 'After Hook'), - 'dep': createMockCube('dep', 'Dependency'), + dep: createMockCube('dep', 'Dependency'), }; // Note: buildDeployCall also records the main cube execution // We can't easily spy on buildDeployCall, but we can see the resulting deployCalls order - + const vars = new Variables(); const context = new BuildContext( cubes, @@ -53,7 +57,7 @@ describe('Cube Hooks', () => { await context.resolveCube('main', 'host1'); - const callOrder = context.deployCalls.map(c => c.cube); + const callOrder = context.deployCalls.map((c) => c.cube); // Expected order: // 1. main:before (hook runs) diff --git a/packages/nopy/tests/main.test.ts b/packages/nopy/tests/main.test.ts new file mode 100644 index 0000000..526bcf4 --- /dev/null +++ b/packages/nopy/tests/main.test.ts @@ -0,0 +1,371 @@ +/** + * Tests for the nopy() orchestrator. + * + * Every collaborator is mocked: this module's job is wiring and branching, and + * the pieces it wires (config loading, cube loading, dependency resolution, + * execution) are covered by their own suites. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NopyConfig } from '../src/nopy.config.js'; +import type { DeployCall } from '../src/nopy.executor.js'; +import type { NopySession } from '../src/nopy.session.js'; + +// vi.mock factories are hoisted above module scope, so everything they close +// over has to be created inside vi.hoisted. +const { + state, + resolveCube, + loadCubes, + loadConfig, + getConfigPaths, + runWorkflow, + executeDeployCalls, + addToHistory, + saveSession, +} = vi.hoisted(() => { + const state = { + config: {} as NopyConfig, + loadResult: { cubes: {} as Record, errors: [] as string[] }, + deployCalls: [] as DeployCall[], + cubeSessions: [] as unknown[], + }; + + return { + state, + resolveCube: vi.fn(), + loadCubes: vi.fn(async () => state.loadResult), + loadConfig: vi.fn(() => state.config), + getConfigPaths: vi.fn(() => ['/project/.nopyrc.json']), + runWorkflow: vi.fn(), + executeDeployCalls: vi.fn(async () => [] as unknown[]), + addToHistory: vi.fn(), + saveSession: vi.fn(), + }; +}); + +vi.mock('../src/cubes/index.js', () => ({ loadCubes })); +vi.mock('../src/nopy.config.js', () => ({ loadConfig, getConfigPaths })); +vi.mock('../src/nopy.workflow.js', () => ({ runWorkflow })); +vi.mock('../src/nopy.history.js', () => ({ addToHistory, DEFAULT_HISTORY_SIZE: 10 })); +vi.mock('../src/nopy.session.js', () => ({ saveSession })); +vi.mock('../src/cubes/dependencies.js', () => ({ + BuildContext: class { + resolveCube = resolveCube; + get deployCalls() { + return state.deployCalls; + } + get cubeSessions() { + return state.cubeSessions; + } + }, +})); +vi.mock('../src/nopy.executor.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, executeDeployCalls }; +}); + +import { nopy } from '../src/nopy.main.js'; + +const session = (): NopySession => + ({ + version: '1.0', + name: 'test', + createdAt: '2026-01-01T00:00:00.000Z', + cubes: [], + hosts: ['web-1'], + auth: { method: 'ssh-key' }, + env: {}, + }) as NopySession; + +const call = (cube: string): DeployCall => ({ + cube, + host: 'web-1', + cwd: `/cubes/${cube}`, + command: ['pyinfra', 'web-1', '-y', `${cube}.deploy.py`], + env: {}, + dependencies: [], +}); + +let logSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + state.config = { hosts: ['web-1'], cubeDirs: [], env: {} }; + state.loadResult = { cubes: { 'cube-a': {} }, errors: [] }; + state.deployCalls = [call('cube-a')]; + state.cubeSessions = [{ key: 'cube-a', variables: {} }]; + + runWorkflow.mockResolvedValue({ + session: session(), + selectedCubes: ['cube-a'], + authMethod: 'ssh-key', + isReplay: false, + }); + executeDeployCalls.mockResolvedValue([ + { cube: 'cube-a', host: 'web-1', success: true, duration: 10 }, + ]); +}); + +const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + +describe('nopy', () => { + it('runs the happy path and reports success', async () => { + const result = await nopy(); + + expect(result?.success).toBe(true); + expect(result?.summary).toEqual({ + total: 1, + successful: 1, + failed: 0, + totalDuration: 10, + }); + expect(resolveCube).toHaveBeenCalledWith('cube-a', 'web-1'); + }); + + it('reports failure when any call fails', async () => { + executeDeployCalls.mockResolvedValue([ + { cube: 'cube-a', host: 'web-1', success: false, duration: 5, error: new Error('x') }, + ]); + + const result = await nopy(); + + expect(result?.success).toBe(false); + expect(result?.summary.failed).toBe(1); + }); + + it('resolves every cube against every host', async () => { + runWorkflow.mockResolvedValue({ + session: { ...session(), hosts: ['web-1', 'web-2'] }, + selectedCubes: ['cube-a', 'cube-b'], + authMethod: 'ssh-key', + isReplay: false, + }); + + await nopy(); + + expect(resolveCube).toHaveBeenCalledTimes(4); + }); + + describe('cube loading errors', () => { + it('aborts and returns undefined', async () => { + state.loadResult = { cubes: {}, errors: ['bad manifest'] }; + + const result = await nopy(); + + expect(result).toBeUndefined(); + expect(runWorkflow).not.toHaveBeenCalled(); + }); + + it('emits the errors as JSON when jsonOutput is set', async () => { + state.loadResult = { cubes: {}, errors: ['bad manifest'] }; + + await nopy({ jsonOutput: true }); + + const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + expect(payload).toEqual({ success: false, errors: ['bad manifest'] }); + }); + }); + + describe('config banner', () => { + it('prints the active configuration in interactive mode', async () => { + state.config = { + hosts: ['web-1'], + cubeDirs: ['/cubes'], + env: { TOKEN: 'secret', EMPTY: '' }, + }; + + await nopy({ continueOnError: true }); + + const text = output(); + expect(text).toContain('Configuration'); + expect(text).toContain('Hosts:'); + expect(text).toContain('Cube dirs:'); + expect(text).toContain('continue-on-error'); + // Values are never echoed, only their presence. + expect(text).toContain('TOKEN: '); + expect(text).toContain('EMPTY: '); + expect(text).not.toContain('secret'); + }); + + it('omits empty sections', async () => { + state.config = { hosts: [], cubeDirs: [], env: {} }; + + await nopy(); + + const text = output(); + expect(text).toContain('Configuration'); + expect(text).not.toContain('Hosts:'); + expect(text).not.toContain('Cube dirs:'); + expect(text).not.toContain('Env vars:'); + }); + + it('shortens paths under cwd and under HOME', async () => { + getConfigPaths.mockReturnValue([ + `${process.env.HOME}/.nopyrc.json`, + `${process.cwd()}/.nopyrc.json`, + '/etc/nopy/.nopyrc.json', + ]); + + await nopy(); + + const text = output(); + expect(text).toContain('~/.nopyrc.json'); + expect(text).toContain('./.nopyrc.json'); + expect(text).toContain('/etc/nopy/.nopyrc.json'); + }); + + it('is suppressed for JSON output', async () => { + await nopy({ jsonOutput: true }); + expect(output()).not.toContain('Configuration'); + }); + + it('is suppressed when replaying a session object', async () => { + await nopy({ replaySession: session() }); + expect(output()).not.toContain('Configuration'); + }); + + it('is suppressed when replaying a session file', async () => { + await nopy({ loadSession: '/tmp/s.json' }); + expect(output()).not.toContain('Configuration'); + }); + }); + + describe('session persistence', () => { + it('saves the session when a path is given', async () => { + await nopy({ saveSession: '/tmp/out.json' }); + + expect(saveSession).toHaveBeenCalledTimes(1); + const [written, path] = saveSession.mock.calls[0]; + expect(path).toBe('/tmp/out.json'); + expect(written.cubes).toEqual(state.cubeSessions); + }); + + it('does not save a replayed session back to file', async () => { + runWorkflow.mockResolvedValue({ + session: session(), + selectedCubes: ['cube-a'], + authMethod: 'ssh-key', + isReplay: true, + }); + + await nopy({ saveSession: '/tmp/out.json' }); + + expect(saveSession).not.toHaveBeenCalled(); + }); + + it('does not save when no path is given', async () => { + await nopy(); + expect(saveSession).not.toHaveBeenCalled(); + }); + }); + + describe('history', () => { + it('records the session with the default size', async () => { + await nopy(); + + expect(addToHistory).toHaveBeenCalledTimes(1); + expect(addToHistory.mock.calls[0][1]).toBe(10); + }); + + it('honours a configured maxSessions', async () => { + state.config = { ...state.config, history: { maxSessions: 3 } }; + + await nopy(); + + expect(addToHistory.mock.calls[0][1]).toBe(3); + }); + + it('respects autoSave: false', async () => { + state.config = { ...state.config, history: { autoSave: false } }; + + await nopy(); + + expect(addToHistory).not.toHaveBeenCalled(); + }); + + it('skips history on a dry run', async () => { + await nopy({ dryRun: true }); + expect(addToHistory).not.toHaveBeenCalled(); + }); + + it('skips history when the caller opts out', async () => { + await nopy({ saveToHistory: false }); + expect(addToHistory).not.toHaveBeenCalled(); + }); + + it('skips history for a replay', async () => { + runWorkflow.mockResolvedValue({ + session: session(), + selectedCubes: ['cube-a'], + authMethod: 'ssh-key', + isReplay: true, + }); + + await nopy(); + + expect(addToHistory).not.toHaveBeenCalled(); + }); + + it('skips history when nothing would be deployed', async () => { + state.deployCalls = []; + + await nopy(); + + expect(addToHistory).not.toHaveBeenCalled(); + }); + }); + + describe('printOnly', () => { + it('prints commands and never executes', async () => { + await nopy({ printOnly: true }); + + const text = output(); + expect(text).toContain('Deploy Commands'); + expect(text).toContain('# cube-a -> web-1'); + expect(text).toContain('pyinfra web-1 -y cube-a.deploy.py'); + expect(executeDeployCalls).not.toHaveBeenCalled(); + }); + + it('reports the command count as the summary total', async () => { + const result = await nopy({ printOnly: true }); + + expect(result).toEqual({ + success: true, + results: [], + summary: { total: 1, successful: 0, failed: 0, totalDuration: 0 }, + }); + }); + }); + + describe('execution options', () => { + it('forwards dryRun and continueOnError to the executor', async () => { + await nopy({ dryRun: true, continueOnError: true }); + + const [, options] = executeDeployCalls.mock.calls[0]; + expect(options.dryRun).toBe(true); + expect(options.continueOnError).toBe(true); + }); + + it('logs progress lines in interactive mode', async () => { + await nopy(); + + const [, options] = executeDeployCalls.mock.calls[0]; + options.onProgress({ cube: 'cube-a', host: 'web-1', success: true }, 1, 1); + options.onProgress({ cube: 'cube-b', host: 'web-1', success: false }, 1, 1); + // Exercises both the ✓ and ✗ branches; logtape writes via console.log. + expect(logSpy).toHaveBeenCalled(); + }); + + it('stays silent on progress when jsonOutput is set', async () => { + await nopy({ jsonOutput: true }); + + const [, options] = executeDeployCalls.mock.calls[0]; + const before = logSpy.mock.calls.length; + options.onProgress({ cube: 'cube-a', host: 'web-1', success: true }, 1, 1); + expect(logSpy.mock.calls.length).toBe(before); + }); + }); +}); diff --git a/packages/nopy/tests/prompts.test.ts b/packages/nopy/tests/prompts.test.ts new file mode 100644 index 0000000..9d79cd1 --- /dev/null +++ b/packages/nopy/tests/prompts.test.ts @@ -0,0 +1,325 @@ +/** + * Tests for nopy.prompts. + * + * inquirer and enquirer are mocked so nothing touches a TTY. What is actually + * under test is the logic wrapped around them: choice construction, the `when` + * predicates, host-string mapping and zod-driven value coercion. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +const { inquirerPrompt, formRun, autoCompleteRun, autoCompleteCtor } = vi.hoisted(() => ({ + inquirerPrompt: vi.fn(), + formRun: vi.fn(), + autoCompleteRun: vi.fn(), + autoCompleteCtor: vi.fn(), +})); + +vi.mock('inquirer', () => ({ + default: { prompt: inquirerPrompt }, +})); +vi.mock('enquirer', () => ({ + default: { + Form: class { + run = formRun; + }, + AutoComplete: class { + run = autoCompleteRun; + constructor(options: unknown) { + autoCompleteCtor(options); + } + }, + }, +})); + +import { Cube, Manifest } from '../src/cubes/types.js'; +import { Variables } from '../src/nopy.common.js'; +import { + AuthSelection, + CubeSelection, + HostSelection, + PasswordSelection, + VariableAssignment, +} from '../src/nopy.prompts.js'; + +/** Grabs the single question object passed to the last inquirer.prompt call. */ +const questions = () => inquirerPrompt.mock.calls.at(-1)?.[0] as Record[]; +const question = (name: string) => questions().find((q) => q.name === name); + +/** Grabs the options the last enquirer AutoComplete prompt was constructed with. */ +const autoComplete = () => autoCompleteCtor.mock.calls.at(-1)?.[0] as Record; + +const cube = (id: string, name: string, schema = z.object({})) => + new Cube(Manifest({ id, name, schema }), `/cubes/${id}`, 'deploy.py'); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); +}); + +describe('CubeSelection', () => { + const cubes = { + b: cube('cube-b', 'Beta'), + a: cube('cube-a', 'Alpha'), + }; + + it('returns the selection', async () => { + autoCompleteRun.mockResolvedValue(['cube-a']); + + await expect(CubeSelection(cubes)).resolves.toEqual({ selectedCubes: ['cube-a'] }); + }); + + it('sorts choices by cube id', async () => { + autoCompleteRun.mockResolvedValue([]); + + await CubeSelection(cubes); + + const { choices } = autoComplete(); + expect(choices.map((c: { name: string }) => c.name)).toEqual(['cube-a', 'cube-b']); + expect(choices[0].message).toBe('cube-a - Alpha'); + }); + + it('returns every choice for an undefined filter', async () => { + autoCompleteRun.mockResolvedValue([]); + + await CubeSelection(cubes); + + const { choices, suggest } = autoComplete(); + expect(suggest(undefined, choices)).toHaveLength(2); + expect(suggest('', choices)).toHaveLength(2); + }); + + it('fuzzy filters on the visible label', async () => { + autoCompleteRun.mockResolvedValue([]); + + await CubeSelection(cubes); + + const { choices, suggest } = autoComplete(); + expect(suggest('Alph', choices).map((c: { name: string }) => c.name)).toEqual(['cube-a']); + }); + + it('derives page size from the terminal height', async () => { + autoCompleteRun.mockResolvedValue([]); + const rows = process.stdout.rows; + + Object.defineProperty(process.stdout, 'rows', { value: 40, configurable: true }); + await CubeSelection(cubes); + expect(autoComplete().limit).toBe(35); + + // Falls back to a floor of 10 on a short (or unknown) terminal. + Object.defineProperty(process.stdout, 'rows', { value: 0, configurable: true }); + await CubeSelection(cubes); + expect(autoComplete().limit).toBe(19); + + Object.defineProperty(process.stdout, 'rows', { value: rows, configurable: true }); + }); + + it('selects nothing when the user cancels', async () => { + autoCompleteRun.mockRejectedValue(new Error('cancelled')); + + await expect(CubeSelection(cubes)).resolves.toEqual({ selectedCubes: [] }); + }); +}); + +describe('AuthSelection', () => { + it('short-circuits to ssh-key without prompting', async () => { + await expect(AuthSelection(true)).resolves.toEqual({ authMethod: 'ssh-key' }); + expect(inquirerPrompt).not.toHaveBeenCalled(); + }); + + it('prompts when no key is forced', async () => { + inquirerPrompt.mockResolvedValue({ authMethod: 'password', username: 'u', password: 'p' }); + + await expect(AuthSelection()).resolves.toEqual({ + authMethod: 'password', + username: 'u', + password: 'p', + }); + }); + + it('asks for credentials only when the method is not ssh-key', async () => { + inquirerPrompt.mockResolvedValue({ authMethod: 'ssh-key' }); + + await AuthSelection(false); + + expect(question('username')?.when({ authMethod: 'password' })).toBe(true); + expect(question('username')?.when({ authMethod: 'ssh-key' })).toBe(false); + expect(question('password')?.when({ authMethod: 'password' })).toBe(true); + expect(question('password')?.when({ authMethod: 'ssh-key' })).toBe(false); + }); +}); + +describe('PasswordSelection', () => { + it('returns the entered password', async () => { + inquirerPrompt.mockResolvedValue({ password: 'hunter2' }); + + await expect(PasswordSelection('deploy')).resolves.toBe('hunter2'); + expect(question('password')?.message).toContain('deploy'); + }); +}); + +describe('HostSelection', () => { + it('offers the configured hosts alongside the built-ins', async () => { + inquirerPrompt.mockResolvedValue({ host: 'web-1' }); + + await HostSelection(['web-1', 'web-2']); + + expect(question('host')?.choices).toEqual(['docker', 'vagrant', 'web-1', 'web-2', 'custom']); + }); + + it('returns a plain host as-is', async () => { + inquirerPrompt.mockResolvedValue({ host: 'web-1' }); + + await expect(HostSelection(['web-1'])).resolves.toBe('web-1'); + }); + + it('returns the custom address when custom is chosen', async () => { + inquirerPrompt.mockResolvedValue({ host: 'custom', customHost: '10.0.0.5' }); + + await expect(HostSelection([])).resolves.toBe('10.0.0.5'); + }); + + it('prefixes a vagrant machine', async () => { + inquirerPrompt.mockResolvedValue({ host: 'vagrant', vagrantVM: 'builder' }); + + await expect(HostSelection([])).resolves.toBe('@vagrant/builder'); + }); + + it('prefixes a docker container', async () => { + inquirerPrompt.mockResolvedValue({ host: 'runtime:docker', dockerContainer: 'box' }); + + await expect(HostSelection([])).resolves.toBe('@docker/box'); + }); + + it('gates the follow-up questions on the chosen host', async () => { + inquirerPrompt.mockResolvedValue({ host: 'web-1' }); + + await HostSelection([]); + + expect(question('customHost')?.when({ host: 'custom' })).toBe(true); + expect(question('customHost')?.when({ host: 'web-1' })).toBe(false); + expect(question('vagrantVM')?.when({ host: 'vagrant' })).toBe(true); + expect(question('vagrantVM')?.when({ host: 'web-1' })).toBe(false); + expect(question('dockerContainer')?.when({ host: 'runtime:docker' })).toBe(true); + expect(question('dockerContainer')?.when({ host: 'web-1' })).toBe(false); + }); +}); + +describe('VariableAssignment', () => { + const schema = z.object({ + port: z.number().default(8080).describe('Listen port'), + enabled: z.boolean().default(false), + name: z.string().default('svc'), + }); + + it('does nothing when every default is already supplied as a param', async () => { + const variables = new Variables(); + variables.assign('svc', 'params', { port: 1, enabled: true, name: 'x' }); + + await VariableAssignment(cube('svc', 'Service', schema), variables); + + expect(formRun).not.toHaveBeenCalled(); + }); + + it('does nothing for a cube with no defaults', async () => { + await VariableAssignment(cube('bare', 'Bare'), new Variables()); + + expect(formRun).not.toHaveBeenCalled(); + }); + + it('only asks about the variables still missing', async () => { + const variables = new Variables(); + variables.assign('svc', 'params', { port: 9090 }); + formRun.mockResolvedValue({}); + + await VariableAssignment(cube('svc', 'Service', schema), variables); + + expect(formRun).toHaveBeenCalled(); + expect(variables.get('svc', 'prompts')).toEqual({}); + }); + + it('coerces answers using the schema and stores them under prompts', async () => { + const variables = new Variables(); + formRun.mockResolvedValue({ port: '9090', enabled: 'true', name: 'api' }); + + await VariableAssignment(cube('svc', 'Service', schema), variables); + + expect(variables.get('svc', 'prompts')).toEqual({ + port: 9090, + enabled: true, + name: 'api', + }); + }); + + it('leaves an unparseable number as the raw string', async () => { + const variables = new Variables(); + formRun.mockResolvedValue({ port: 'not-a-number', enabled: 'no', name: 'api' }); + + await VariableAssignment(cube('svc', 'Service', schema), variables); + + expect(variables.get('svc', 'prompts').port).toBe('not-a-number'); + expect(variables.get('svc', 'prompts').enabled).toBe(false); + }); + + it('accepts yes and 1 as truthy booleans', async () => { + const variables = new Variables(); + formRun.mockResolvedValue({ port: '1', enabled: 'yes', name: 'api' }); + + await VariableAssignment(cube('svc', 'Service', schema), variables); + + expect(variables.get('svc', 'prompts').enabled).toBe(true); + }); + + it('unwraps optional and nullable schema types', async () => { + const nullableSchema = z.object({ + maybe: z.number().nullable().default(1), + opt: z.number().optional().default(2), + }); + const variables = new Variables(); + formRun.mockResolvedValue({ maybe: 'null', opt: '7' }); + + await VariableAssignment(cube('svc', 'Service', nullableSchema), variables); + + expect(variables.get('svc', 'prompts')).toEqual({ maybe: null, opt: 7 }); + }); + + it('treats an empty string as null for a nullable field', async () => { + const nullableSchema = z.object({ maybe: z.number().nullable().default(1) }); + const variables = new Variables(); + formRun.mockResolvedValue({ maybe: '' }); + + await VariableAssignment(cube('svc', 'Service', nullableSchema), variables); + + expect(variables.get('svc', 'prompts').maybe).toBe(null); + }); + + it('passes non-string answers through untouched', async () => { + const variables = new Variables(); + formRun.mockResolvedValue({ port: 9090, enabled: true, name: 'api' }); + + await VariableAssignment(cube('svc', 'Service', schema), variables); + + expect(variables.get('svc', 'prompts').port).toBe(9090); + }); + + it('keeps answers for keys the schema does not describe', async () => { + const variables = new Variables(); + formRun.mockResolvedValue({ port: '1', enabled: 'true', name: 'api', extra: 'kept' }); + + await VariableAssignment(cube('svc', 'Service', schema), variables); + + expect(variables.get('svc', 'prompts').extra).toBe('kept'); + }); + + it('assigns nothing when the user cancels the form', async () => { + const variables = new Variables(); + formRun.mockRejectedValue(new Error('cancelled')); + + await expect( + VariableAssignment(cube('svc', 'Service', schema), variables) + ).resolves.toBeUndefined(); + expect(variables.get('svc', 'prompts')).toEqual({}); + }); +}); diff --git a/packages/nopy/tests/session.test.ts b/packages/nopy/tests/session.test.ts index 4f2821c..3d93bbd 100644 --- a/packages/nopy/tests/session.test.ts +++ b/packages/nopy/tests/session.test.ts @@ -7,11 +7,11 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { - type NopySession, createSession, filterInternalVariables, listSessions, loadSession, + type NopySession, saveSession, separateEnvAndCubeVariables, } from '../src/nopy.session.js'; diff --git a/packages/nopy/tests/workflow.test.ts b/packages/nopy/tests/workflow.test.ts new file mode 100644 index 0000000..f224e41 --- /dev/null +++ b/packages/nopy/tests/workflow.test.ts @@ -0,0 +1,312 @@ +/** + * Tests for nopy.workflow module. + * + * The prompt layer is the only I/O in this module, so mocking nopy.prompts + * exercises every branch without touching a TTY. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/nopy.prompts.js', () => ({ + CubeSelection: vi.fn(), + HostSelection: vi.fn(), + AuthSelection: vi.fn(), + PasswordSelection: vi.fn(), +})); + +vi.mock('../src/nopy.session.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadSession: vi.fn() }; +}); + +import type { Cube } from '../src/cubes/index.js'; +import type { NopyConfig } from '../src/nopy.config.js'; +import { + AuthSelection, + CubeSelection, + HostSelection, + PasswordSelection, +} from '../src/nopy.prompts.js'; +import type { NopySession } from '../src/nopy.session.js'; +import { loadSession } from '../src/nopy.session.js'; +import { + runInteractiveWorkflow, + runReplayWorkflow, + runSessionReplayWorkflow, + runWorkflow, +} from '../src/nopy.workflow.js'; + +const mockCubeSelection = vi.mocked(CubeSelection); +const mockHostSelection = vi.mocked(HostSelection); +const mockAuthSelection = vi.mocked(AuthSelection); +const mockPasswordSelection = vi.mocked(PasswordSelection); +const mockLoadSession = vi.mocked(loadSession); + +const config: NopyConfig = { + hosts: ['web-1', 'web-2'], + cubeDirs: [], + env: { GLOBAL: 'value' }, +}; + +const cubes = { + 'cube-a': { id: 'cube-a', name: 'Cube A' } as Cube, +}; + +const session = (overrides: Partial = {}): NopySession => + ({ + version: '1.0', + name: 'test-session', + createdAt: '2026-01-01T00:00:00.000Z', + cubes: [{ key: 'cube-a', variables: {} }], + hosts: ['web-1'], + auth: { method: 'ssh-key' }, + env: {}, + ...overrides, + }) as NopySession; + +beforeEach(() => { + vi.clearAllMocks(); + mockCubeSelection.mockResolvedValue({ selectedCubes: ['cube-a'] }); + mockHostSelection.mockResolvedValue('web-1'); + mockAuthSelection.mockResolvedValue({ authMethod: 'ssh-key' }); + mockPasswordSelection.mockResolvedValue('s3cret'); +}); + +describe('runInteractiveWorkflow', () => { + it('collects cubes, host and auth into a fresh session', async () => { + const result = await runInteractiveWorkflow(cubes, config); + + expect(result.selectedCubes).toEqual(['cube-a']); + expect(result.authMethod).toBe('ssh-key'); + expect(result.isReplay).toBe(false); + expect(result.session.hosts).toEqual(['web-1']); + expect(result.session.env).toEqual({ GLOBAL: 'value' }); + expect(mockHostSelection).toHaveBeenCalledWith(config.hosts); + }); + + it('forwards useAuthKey to the auth prompt', async () => { + await runInteractiveWorkflow(cubes, config, { useAuthKey: true }); + expect(mockAuthSelection).toHaveBeenCalledWith(true); + }); + + it('carries username and password through from password auth', async () => { + mockAuthSelection.mockResolvedValue({ + authMethod: 'password', + username: 'deploy', + password: 'hunter2', + }); + + const result = await runInteractiveWorkflow(cubes, config); + + expect(result.username).toBe('deploy'); + expect(result.password).toBe('hunter2'); + expect(result.session.auth.username).toBe('deploy'); + }); + + it('skips the auth prompt entirely for vagrant hosts', async () => { + mockHostSelection.mockResolvedValue('@vagrant/default'); + + const result = await runInteractiveWorkflow(cubes, config); + + expect(mockAuthSelection).not.toHaveBeenCalled(); + expect(result.authMethod).toBe('ssh'); + expect(result.username).toBeUndefined(); + }); + + it('skips the auth prompt entirely for docker hosts', async () => { + mockHostSelection.mockResolvedValue('@docker/box'); + + const result = await runInteractiveWorkflow(cubes, config); + + expect(mockAuthSelection).not.toHaveBeenCalled(); + expect(result.authMethod).toBe('ssh'); + }); + + it('proceeds when the user selects nothing', async () => { + mockCubeSelection.mockResolvedValue({ selectedCubes: [] }); + + const result = await runInteractiveWorkflow(cubes, config); + + expect(result.selectedCubes).toEqual([]); + }); + + it('never stores a password on the session', async () => { + mockAuthSelection.mockResolvedValue({ + authMethod: 'password', + username: 'deploy', + password: 'hunter2', + }); + + const result = await runInteractiveWorkflow(cubes, config); + + expect(JSON.stringify(result.session)).not.toContain('hunter2'); + }); +}); + +describe('runReplayWorkflow', () => { + it('replays a session file without prompting', async () => { + mockLoadSession.mockResolvedValue(session()); + + const result = await runReplayWorkflow('/tmp/s.json', cubes, config); + + expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json'); + expect(result.isReplay).toBe(true); + expect(result.selectedCubes).toEqual(['cube-a']); + expect(mockHostSelection).not.toHaveBeenCalled(); + expect(mockPasswordSelection).not.toHaveBeenCalled(); + }); + + it('tolerates a session referencing an unknown cube', async () => { + mockLoadSession.mockResolvedValue( + session({ cubes: [{ key: 'ghost-cube', variables: {} }] } as Partial) + ); + + const result = await runReplayWorkflow('/tmp/s.json', cubes, config); + + expect(result.selectedCubes).toEqual(['ghost-cube']); + }); + + it('prompts for a host when the session has none', async () => { + mockLoadSession.mockResolvedValue(session({ hosts: [] })); + + const result = await runReplayWorkflow('/tmp/s.json', cubes, config); + + expect(mockHostSelection).toHaveBeenCalledWith(config.hosts); + expect(result.session.hosts).toEqual(['web-1']); + }); + + it('prompts for a host when hosts is missing entirely', async () => { + mockLoadSession.mockResolvedValue(session({ hosts: undefined })); + + const result = await runReplayWorkflow('/tmp/s.json', cubes, config); + + expect(result.session.hosts).toEqual(['web-1']); + }); + + it('re-prompts only for the password when a username is stored', async () => { + mockLoadSession.mockResolvedValue( + session({ auth: { method: 'password', username: 'deploy' } }) + ); + + const result = await runReplayWorkflow('/tmp/s.json', cubes, config); + + expect(mockPasswordSelection).toHaveBeenCalledWith('deploy'); + expect(mockAuthSelection).not.toHaveBeenCalled(); + expect(result.password).toBe('s3cret'); + expect(result.username).toBe('deploy'); + }); + + it('falls back to the full auth prompt when the username is missing', async () => { + mockLoadSession.mockResolvedValue(session({ auth: { method: 'password' } })); + mockAuthSelection.mockResolvedValue({ + authMethod: 'password', + username: 'recovered', + password: 'fresh', + }); + + const result = await runReplayWorkflow('/tmp/s.json', cubes, config); + + expect(mockAuthSelection).toHaveBeenCalledWith(false); + expect(mockPasswordSelection).not.toHaveBeenCalled(); + expect(result.username).toBe('recovered'); + expect(result.password).toBe('fresh'); + }); + + it('propagates load failures', async () => { + mockLoadSession.mockRejectedValue(new Error('missing file')); + + await expect(runReplayWorkflow('/tmp/nope.json', cubes, config)).rejects.toThrow( + 'missing file' + ); + }); +}); + +describe('runSessionReplayWorkflow', () => { + it('replays an in-memory session without prompting', async () => { + const result = await runSessionReplayWorkflow(session(), cubes, config); + + expect(result.isReplay).toBe(true); + expect(result.selectedCubes).toEqual(['cube-a']); + expect(mockLoadSession).not.toHaveBeenCalled(); + expect(mockHostSelection).not.toHaveBeenCalled(); + }); + + it('tolerates a session referencing an unknown cube', async () => { + const result = await runSessionReplayWorkflow( + session({ cubes: [{ key: 'ghost-cube', variables: {} }] } as Partial), + cubes, + config + ); + + expect(result.selectedCubes).toEqual(['ghost-cube']); + }); + + it('prompts for a host when the session has none', async () => { + const result = await runSessionReplayWorkflow(session({ hosts: [] }), cubes, config); + + expect(mockHostSelection).toHaveBeenCalled(); + expect(result.session.hosts).toEqual(['web-1']); + }); + + it('prompts for a host when hosts is missing entirely', async () => { + const result = await runSessionReplayWorkflow(session({ hosts: undefined }), cubes, config); + + expect(result.session.hosts).toEqual(['web-1']); + }); + + it('re-prompts only for the password when a username is stored', async () => { + const result = await runSessionReplayWorkflow( + session({ auth: { method: 'password', username: 'deploy' } }), + cubes, + config + ); + + expect(mockPasswordSelection).toHaveBeenCalledWith('deploy'); + expect(result.password).toBe('s3cret'); + }); + + it('falls back to the full auth prompt when the username is missing', async () => { + mockAuthSelection.mockResolvedValue({ + authMethod: 'password', + username: 'recovered', + password: 'fresh', + }); + + const result = await runSessionReplayWorkflow( + session({ auth: { method: 'password' } }), + cubes, + config + ); + + expect(mockAuthSelection).toHaveBeenCalledWith(false); + expect(result.username).toBe('recovered'); + }); +}); + +describe('runWorkflow dispatch', () => { + it('prefers an in-memory replay session over everything else', async () => { + const result = await runWorkflow('/tmp/s.json', cubes, config, {}, session()); + + expect(result.isReplay).toBe(true); + expect(mockLoadSession).not.toHaveBeenCalled(); + expect(mockCubeSelection).not.toHaveBeenCalled(); + }); + + it('uses the session file when no in-memory session is given', async () => { + mockLoadSession.mockResolvedValue(session()); + + const result = await runWorkflow('/tmp/s.json', cubes, config); + + expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json'); + expect(result.isReplay).toBe(true); + expect(mockCubeSelection).not.toHaveBeenCalled(); + }); + + it('falls back to the interactive workflow', async () => { + const result = await runWorkflow(undefined, cubes, config, { useAuthKey: true }); + + expect(result.isReplay).toBe(false); + expect(mockCubeSelection).toHaveBeenCalled(); + expect(mockAuthSelection).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/nopy/tsconfig.json b/packages/nopy/tsconfig.json index c0f22cd..8ae16a2 100644 --- a/packages/nopy/tsconfig.json +++ b/packages/nopy/tsconfig.json @@ -1,13 +1,13 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "tsBuildInfoFile": ".tsbuildinfo", "outDir": "dist", "rootDir": "src", "lib": ["ES2020"], "composite": true, "module": "NodeNext", - "types": ["jest", "node"] + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["coverage", "node_modules", "dist"], diff --git a/packages/nopy/vitest.config.ts b/packages/nopy/vitest.config.ts index a979ad5..e847dff 100644 --- a/packages/nopy/vitest.config.ts +++ b/packages/nopy/vitest.config.ts @@ -8,9 +8,23 @@ export default defineConfig({ include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], coverage: { provider: 'v8', - reporter: ['text', 'json', 'html'], + reporter: ['text', 'json-summary', 'html'], include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/nopy.cli.ts'], + exclude: [ + 'src/**/*.test.ts', + // Pure re-export barrels: no logic to cover. + 'src/index.ts', + 'src/cubes/index.ts', + 'src/nopy.cubes.ts', + // Commander wiring only; behaviour lives in the modules it calls. + 'src/nopy.cli.ts', + ], + thresholds: { + branches: 85, + functions: 85, + lines: 80, + statements: 80, + }, }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64de258..7904705 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,339 +5,480 @@ settings: excludeLinksFromLockfile: false overrides: - '@types/jest': 29.5.5 - '@types/node': '>=21' - '@logtape/logtape': 0.8.0 - ts-node: 10.9.2 - typescript: 5.7.3 - commander: ^13.1.0 + '@types/node': ^26.1.1 + '@logtape/logtape': ^2.2.4 + typescript: ^7.0.2 + commander: ^15.0.0 importers: .: - dependencies: - commander: - specifier: ^13.1.0 - version: 13.1.0 devDependencies: '@biomejs/biome': - specifier: ^1.9.4 - version: 1.9.4 + specifier: ^2.5.5 + version: 2.5.5 '@logtape/logtape': - specifier: 0.8.0 - version: 0.8.0 - '@types/jest': - specifier: 29.5.5 - version: 29.5.5 + specifier: ^2.2.4 + version: 2.2.4 '@types/node': - specifier: '>=21' - version: 22.10.7 - '@typescript/native-preview': - specifier: 7.0.0-dev.20260303.1 - version: 7.0.0-dev.20260303.1 - ts-node: - specifier: 10.9.2 - version: 10.9.2(@types/node@22.10.7)(typescript@5.7.3) + specifier: ^26.1.1 + version: 26.1.1 + simple-git-hooks: + specifier: ^2.13.1 + version: 2.13.1 typescript: - specifier: 5.7.3 - version: 5.7.3 + specifier: ^7.0.2 + version: 7.0.2 packages/keyman: dependencies: execa: - specifier: 9.5.2 - version: 9.5.2 + specifier: ^10.0.0 + version: 10.0.0 inquirer: - specifier: 8.2.4 - version: 8.2.4 - ts-node: - specifier: 10.9.2 - version: 10.9.2(@types/node@22.10.7)(typescript@5.7.3) - typed-dotenv: - specifier: 10.0.2 - version: 10.0.2 - typescript: - specifier: 5.7.3 - version: 5.7.3 + specifier: ^14.0.2 + version: 14.0.2(@types/node@26.1.1) zod: - specifier: ^3.24.1 - version: 3.24.1 - zx: - specifier: ^8.3.0 - version: 8.3.0 + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)) packages/nopy: dependencies: '@logtape/logtape': - specifier: 0.8.0 - version: 0.8.0 + specifier: ^2.2.4 + version: 2.2.4 commander: - specifier: ^13.1.0 - version: 13.1.0 + specifier: ^15.0.0 + version: 15.0.0 enquirer: specifier: ^2.4.1 version: 2.4.1 execa: - specifier: 9.5.2 - version: 9.5.2 + specifier: ^10.0.0 + version: 10.0.0 fuzzy: specifier: ^0.1.3 version: 0.1.3 inquirer: - specifier: 8.2.4 - version: 8.2.4 - inquirer-checkbox-plus-prompt: - specifier: ^1.0.1 - version: 1.4.2(inquirer@8.2.4) - ts-node: - specifier: 10.9.2 - version: 10.9.2(@types/node@22.10.7)(typescript@5.7.3) - typescript: - specifier: 5.7.3 - version: 5.7.3 - yaml: - specifier: ^2.8.2 - version: 2.8.2 + specifier: ^14.0.2 + version: 14.0.2(@types/node@26.1.1) zod: - specifier: ^3.24.1 - version: 3.24.1 + specifier: ^4.4.3 + version: 4.4.3 zx: - specifier: ^8.3.0 - version: 8.3.0 + specifier: ^8.8.5 + version: 8.8.5 devDependencies: - '@types/inquirer': - specifier: ^8.2.10 - version: 8.2.12 '@types/node': - specifier: '>=21' - version: 22.10.7 - '@types/uniqid': - specifier: ^5.3.4 - version: 5.3.4 + specifier: ^26.1.1 + version: 26.1.1 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^7.0.2 + version: 7.0.2 vitest: - specifier: ^1.6.0 - version: 1.6.1(@types/node@22.10.7) + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)) packages: - '@babel/code-frame@7.26.2': - resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.25.9': - resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@biomejs/biome@1.9.4': - resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/biome@2.5.5': + resolution: {integrity: sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@1.9.4': - resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + '@biomejs/cli-darwin-arm64@2.5.5': + resolution: {integrity: sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@1.9.4': - resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + '@biomejs/cli-darwin-x64@2.5.5': + resolution: {integrity: sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@1.9.4': - resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + '@biomejs/cli-linux-arm64-musl@2.5.5': + resolution: {integrity: sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@1.9.4': - resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + '@biomejs/cli-linux-arm64@2.5.5': + resolution: {integrity: sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@1.9.4': - resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + '@biomejs/cli-linux-x64-musl@2.5.5': + resolution: {integrity: sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@1.9.4': - resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + '@biomejs/cli-linux-x64@2.5.5': + resolution: {integrity: sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@1.9.4': - resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + '@biomejs/cli-win32-arm64@2.5.5': + resolution: {integrity: sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@1.9.4': - resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + '@biomejs/cli-win32-x64@2.5.5': + resolution: {integrity: sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} cpu: [x64] os: [win32] - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} @@ -346,454 +487,384 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@logtape/logtape@0.8.0': - resolution: {integrity: sha512-kTGbRE4tbHGlZk3+k9fIJbrLODcDiAvv9X2q+tHXjWyNjWXEH46efDBjsE63zDzR/b6Q4Abiloa4VNwKSDLyzQ==} + '@logtape/logtape@2.2.4': + resolution: {integrity: sha512-2rALzv9m4ibE5FyB8/FMm5MPMMlK7ujgy3ufricVKIxj6e7SZbw4w6J16/fRAsXgdDlOXPUA0aa6XoEQvdlKxw==} - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} - cpu: [arm] - os: [android] + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} - cpu: [x64] - os: [win32] + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/fs-extra@11.0.4': - resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} - '@types/inquirer@8.2.12': - resolution: {integrity: sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest@29.5.5': - resolution: {integrity: sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==} - - '@types/jsonfile@6.1.4': - resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - - '@types/node@22.10.7': - resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==} - - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - - '@types/through@0.0.33': - resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} - - '@types/uniqid@5.3.4': - resolution: {integrity: sha512-AgC+o3/8/QEHuU3w5w2jZ8auQtjSJ/s8G8RfEk9CYLogK1RGXqxhHH0wOEAu8uHXjvj8oh/dRtfgok4IHKxh/Q==} - - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260303.1': - resolution: {integrity: sha512-jIIQWmFi0bJY4ML8/7eyz1EGpkI6E0R1E5l4lxJdV/orpMr91vYfAajKICs7DUiMGEJX9HpeiA6TD2piw4DKPQ==} + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260303.1': - resolution: {integrity: sha512-UkaK+J3f185VXBiAGNG4UKHjGzn4R/nhAz5tArnCKHnIUI7rEnsIm4Xlo5YwmgIATFMU1sVwWUwRshVkMVeFAw==} + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260303.1': - resolution: {integrity: sha512-ELJSV2Q4/mK+ampttssOl4H9s9ZBCc3k7y/u5ivJX8TdlMvZuH/JHqI6cS4Y00flt0R5wc70X+Nlcor4I4+rpw==} + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260303.1': - resolution: {integrity: sha512-H84jTRYqUfc/vhuVGQ6VKcBvJoZ4YmomWDx9U4uwYgW6eoUcRpDXqv3S3YqcNJcUmz22d/tTwIYz8ssXNLa/Qw==} + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260303.1': - resolution: {integrity: sha512-HkddFrPJ0jcrohe+HnCqVTv8PunjqNs7FisRmtIAnc36+ccraDB6MVFEdPyAIL3PUID+TP/ESquqeKNnB7HdrQ==} + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260303.1': - resolution: {integrity: sha512-kTTMrBpWuxbHPt9hAFQSWeP//5Oa0KOdAEvceOfXUJhTS8RAA/kZSlFGE/Zw1EtrFLQx2J7uTHUZnYxH1hYXNw==} + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260303.1': - resolution: {integrity: sha512-UcVZbf4pra46Yx/eFV6m9F+awvihliPEud4Rq+A8Q3q3zI67VRaNH6R2/qeo4AqqKRahmiEdLM6Tnm+gPtLRQQ==} + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260303.1': - resolution: {integrity: sha512-BDHJjXlPldInEogbzAc7OCLvT75p3rdkmb5YIA6Je0vjg+5z1UQp3moAvcBGvZQflO/gusOd9a74EfrMVUU/4g==} - hasBin: true + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true - '@vitest/expect@1.6.1': - resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - '@vitest/runner@1.6.1': - resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - '@vitest/snapshot@1.6.1': - resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/spy@1.6.1': - resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/utils@1.6.1': - resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - acorn-walk@8.3.5: - resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} - engines: {node: '>=0.4.0'} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - cli-width@3.0.0: - resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} - engines: {node: '>= 10'} - - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} - engines: {node: '>=6'} - - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} - - dotenv@10.0.0: - resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==} - engines: {node: '>=10'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} hasBin: true - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} + execa@10.0.0: + resolution: {integrity: sha512-Cxl6MKxB1dr1H0FHmiizJ+lavKF7pV+fcDZFyqMB8d5m7qUPm/OtZYcD5vPWePKxSnTQ57KuBd9mtdZ3oNCvyQ==} + engines: {node: '>=22'} - execa@9.5.2: - resolution: {integrity: sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q==} - engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - figures@3.2.0: - resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} - engines: {node: '>=8'} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -803,426 +874,300 @@ packages: resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} engines: {node: '>= 0.6.0'} - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - human-signals@8.0.0: - resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - inquirer-checkbox-plus-prompt@1.4.2: - resolution: {integrity: sha512-W8/NL9x5A81Oq9ZfbYW5c1LuwtAhc/oB/u9YZZejna0pqrajj27XhnUHygJV0Vn5TvcDy1VJcD2Ld9kTk40dvg==} + inquirer@14.0.2: + resolution: {integrity: sha512-VsSx1JneSNp3ld1veMTLe+UDcUD8Tw2/jjOthhkX3/IX2q+xHhVELifeb/hsb1fBw31pabEPNUf/xUOyb+KZjA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: - inquirer: < 9.x - - inquirer@8.2.4: - resolution: {integrity: sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==} - engines: {node: '>=12.0.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} + '@types/node': ^26.1.1 + peerDependenciesMeta: + '@types/node': + optional: true is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - - local-pkg@0.5.1: - resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} - engines: {node: '>=14'} - - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mute-stream@0.0.8: - resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} - - p-limit@5.0.0: - resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} - engines: {node: '>=18'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - path-key@4.0.0: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - pretty-ms@9.2.0: - resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - run-async@2.4.1: - resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} engines: {node: '>=0.12.0'} - rxjs@6.6.7: - resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} - engines: {npm: '>=2.0.0'} - - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} + simple-git-hooks@2.13.1: + resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==} + hasBin: true source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-literal@2.1.1: - resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinypool@0.8.4: - resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tinyspy@2.2.1: - resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} - engines: {node: '>=14.0.0'} - - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '>=21' - typescript: 5.7.3 - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - typed-dotenv@10.0.2: - resolution: {integrity: sha512-gQKZ0vTzyQ4fqQbDPY4dI9l5BR5+oPSRHBsyh1L7n1K9RK81PwOV8WTqR29o8apxU/C83OttEj3Lu0Nw/WULxw==} - engines: {node: '>=10.0.0'} - - typescript@5.7.3: - resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} - engines: {node: '>=14.17'} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} hasBin: true - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true - undici-types@6.20.0: - resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - - vite-node@1.6.1: - resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': '>=21' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 + '@types/node': ^26.1.1 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true - less: + '@vitejs/devtools': optional: true - lightningcss: + esbuild: + optional: true + jiti: + optional: true + less: optional: true sass: optional: true @@ -1234,24 +1179,44 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true - vitest@1.6.1: - resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} - engines: {node: ^18.0.0 || >=20.0.0} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': '>=21' - '@vitest/browser': 1.6.1 - '@vitest/ui': 1.6.1 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^26.1.1 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@opentelemetry/api': + optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -1260,12 +1225,9 @@ packages: jsdom: optional: true - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} hasBin: true why-is-node-running@2.3.0: @@ -1273,1080 +1235,885 @@ packages: engines: {node: '>=8'} hasBin: true - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} - engines: {node: '>= 14.6'} - hasBin: true - - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - - yoctocolors@2.1.1: - resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zod@3.24.1: - resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zx@8.3.0: - resolution: {integrity: sha512-L8mY3yfJwo3a8ZDD6f9jZzAcRWJZYcV8GauZmBxLB/aSTwaMzMIEVpPp2Kyx+7yF0gdvuxKnMxAZRft9UCawiw==} + zx@8.8.5: + resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==} engines: {node: '>= 12.17.0'} hasBin: true snapshots: - '@babel/code-frame@7.26.2': + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': dependencies: - '@babel/helper-validator-identifier': 7.25.9 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@babel/types': 7.29.7 - '@babel/helper-validator-identifier@7.25.9': {} + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 - '@biomejs/biome@1.9.4': + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/biome@2.5.5': optionalDependencies: - '@biomejs/cli-darwin-arm64': 1.9.4 - '@biomejs/cli-darwin-x64': 1.9.4 - '@biomejs/cli-linux-arm64': 1.9.4 - '@biomejs/cli-linux-arm64-musl': 1.9.4 - '@biomejs/cli-linux-x64': 1.9.4 - '@biomejs/cli-linux-x64-musl': 1.9.4 - '@biomejs/cli-win32-arm64': 1.9.4 - '@biomejs/cli-win32-x64': 1.9.4 + '@biomejs/cli-darwin-arm64': 2.5.5 + '@biomejs/cli-darwin-x64': 2.5.5 + '@biomejs/cli-linux-arm64': 2.5.5 + '@biomejs/cli-linux-arm64-musl': 2.5.5 + '@biomejs/cli-linux-x64': 2.5.5 + '@biomejs/cli-linux-x64-musl': 2.5.5 + '@biomejs/cli-win32-arm64': 2.5.5 + '@biomejs/cli-win32-x64': 2.5.5 - '@biomejs/cli-darwin-arm64@1.9.4': + '@biomejs/cli-darwin-arm64@2.5.5': optional: true - '@biomejs/cli-darwin-x64@1.9.4': + '@biomejs/cli-darwin-x64@2.5.5': optional: true - '@biomejs/cli-linux-arm64-musl@1.9.4': + '@biomejs/cli-linux-arm64-musl@2.5.5': optional: true - '@biomejs/cli-linux-arm64@1.9.4': + '@biomejs/cli-linux-arm64@2.5.5': optional: true - '@biomejs/cli-linux-x64-musl@1.9.4': + '@biomejs/cli-linux-x64-musl@2.5.5': optional: true - '@biomejs/cli-linux-x64@1.9.4': + '@biomejs/cli-linux-x64@2.5.5': optional: true - '@biomejs/cli-win32-arm64@1.9.4': + '@biomejs/cli-win32-arm64@2.5.5': optional: true - '@biomejs/cli-win32-x64@1.9.4': + '@biomejs/cli-win32-x64@2.5.5': optional: true - '@cspotcode/source-map-support@0.8.1': + '@emnapi/core@1.11.1': dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@esbuild/aix-ppc64@0.21.5': + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 optional: true - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@jest/expect-utils@29.7.0': + '@emnapi/runtime@1.11.1': dependencies: - jest-get-type: 29.6.3 + tslib: 2.8.1 + optional: true - '@jest/schemas@29.6.3': + '@emnapi/wasi-threads@1.2.2': dependencies: - '@sinclair/typebox': 0.27.8 + tslib: 2.8.1 + optional: true - '@jest/types@29.6.3': + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@26.1.1)': dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.10.7 - '@types/yargs': 17.0.33 - chalk: 4.1.2 + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/confirm@6.1.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/core@11.2.1(@types/node@26.1.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/editor@5.2.2(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/external-editor': 3.0.3(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/expand@5.1.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/external-editor@3.0.3(@types/node@26.1.1)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/number@4.1.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/password@5.1.1(@types/node@26.1.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/prompts@8.5.2(@types/node@26.1.1)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@26.1.1) + '@inquirer/confirm': 6.1.1(@types/node@26.1.1) + '@inquirer/editor': 5.2.2(@types/node@26.1.1) + '@inquirer/expand': 5.1.1(@types/node@26.1.1) + '@inquirer/input': 5.1.2(@types/node@26.1.1) + '@inquirer/number': 4.1.1(@types/node@26.1.1) + '@inquirer/password': 5.1.1(@types/node@26.1.1) + '@inquirer/rawlist': 5.3.1(@types/node@26.1.1) + '@inquirer/search': 4.2.1(@types/node@26.1.1) + '@inquirer/select': 5.2.1(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/rawlist@5.3.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/search@4.2.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/select@5.2.1(@types/node@26.1.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/type@4.0.7(@types/node@26.1.1)': + optionalDependencies: + '@types/node': 26.1.1 '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.9': + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@logtape/logtape@0.8.0': {} + '@logtape/logtape@2.2.4': {} - '@rollup/rollup-android-arm-eabi@4.59.0': + '@napi-rs/wasm-runtime@1.1.6(@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.3 optional: true - '@rollup/rollup-android-arm64@4.59.0': + '@oxc-project/types@0.139.0': {} + + '@rolldown/binding-android-arm64@1.1.5': optional: true - '@rollup/rollup-darwin-arm64@4.59.0': + '@rolldown/binding-darwin-arm64@1.1.5': optional: true - '@rollup/rollup-darwin-x64@4.59.0': + '@rolldown/binding-darwin-x64@1.1.5': optional: true - '@rollup/rollup-freebsd-arm64@4.59.0': + '@rolldown/binding-freebsd-x64@1.1.5': optional: true - '@rollup/rollup-freebsd-x64@4.59.0': + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.59.0': + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true - '@rollup/rollup-linux-arm64-gnu@4.59.0': + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true - '@rollup/rollup-linux-arm64-musl@4.59.0': + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true - '@rollup/rollup-linux-loong64-gnu@4.59.0': + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true - '@rollup/rollup-linux-loong64-musl@4.59.0': + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.59.0': + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true - '@rollup/rollup-linux-ppc64-musl@4.59.0': + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rollup/rollup-linux-riscv64-gnu@4.59.0': + '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true - '@rollup/rollup-linux-riscv64-musl@4.59.0': + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true - '@rollup/rollup-linux-s390x-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.59.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.59.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.59.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.59.0': - optional: true + '@rolldown/pluginutils@1.0.1': {} '@sec-ant/readable-stream@0.4.1': {} - '@sinclair/typebox@0.27.8': {} - '@sindresorhus/merge-streams@4.0.0': {} - '@tsconfig/node10@1.0.11': {} + '@standard-schema/spec@1.1.0': {} - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - - '@types/estree@1.0.8': {} - - '@types/fs-extra@11.0.4': + '@tybys/wasm-util@0.10.3': dependencies: - '@types/jsonfile': 6.1.4 - '@types/node': 22.10.7 + tslib: 2.8.1 optional: true - '@types/inquirer@8.2.12': + '@types/chai@5.2.3': dependencies: - '@types/through': 0.0.33 - rxjs: 7.8.2 + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 - '@types/istanbul-lib-coverage@2.0.6': {} + '@types/deep-eql@4.0.2': {} - '@types/istanbul-lib-report@3.0.3': + '@types/estree@1.0.9': {} + + '@types/node@26.1.1': dependencies: - '@types/istanbul-lib-coverage': 2.0.6 + undici-types: 8.3.0 - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/jest@29.5.5': - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 - - '@types/jsonfile@6.1.4': - dependencies: - '@types/node': 22.10.7 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true - '@types/node@22.10.7': - dependencies: - undici-types: 6.20.0 - - '@types/stack-utils@2.0.3': {} - - '@types/through@0.0.33': - dependencies: - '@types/node': 22.10.7 - - '@types/uniqid@5.3.4': {} - - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.33': - dependencies: - '@types/yargs-parser': 21.0.3 - - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260303.1': + '@typescript/typescript-darwin-arm64@7.0.2': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260303.1': + '@typescript/typescript-darwin-x64@7.0.2': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260303.1': + '@typescript/typescript-freebsd-arm64@7.0.2': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260303.1': + '@typescript/typescript-freebsd-x64@7.0.2': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260303.1': + '@typescript/typescript-linux-arm64@7.0.2': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260303.1': + '@typescript/typescript-linux-arm@7.0.2': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260303.1': + '@typescript/typescript-linux-loong64@7.0.2': optional: true - '@typescript/native-preview@7.0.0-dev.20260303.1': - optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260303.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260303.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260303.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260303.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260303.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260303.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260303.1 + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true - '@vitest/expect@1.6.1': + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - chai: 4.5.0 + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)) - '@vitest/runner@1.6.1': + '@vitest/expect@4.1.10': dependencies: - '@vitest/utils': 1.6.1 - p-limit: 5.0.0 - pathe: 1.1.2 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@vitest/snapshot@1.6.1': + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1))': dependencies: - magic-string: 0.30.21 - pathe: 1.1.2 - pretty-format: 29.7.0 - - '@vitest/spy@1.6.1': - dependencies: - tinyspy: 2.2.1 - - '@vitest/utils@1.6.1': - dependencies: - diff-sequences: 29.6.3 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 - loupe: 2.3.7 - pretty-format: 29.7.0 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1) - acorn-walk@8.3.5: + '@vitest/pretty-format@4.1.10': dependencies: - acorn: 8.16.0 + tinyrainbow: 3.1.0 - acorn@8.16.0: {} + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 ansi-colors@4.1.3: {} - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-regex@5.0.1: {} - ansi-styles@4.3.0: + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: dependencies: - color-convert: 2.0.1 + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 - ansi-styles@5.2.0: {} + chai@6.2.2: {} - arg@4.1.3: {} + chardet@2.2.0: {} - assertion-error@1.1.0: {} + cli-width@4.1.0: {} - base64-js@1.5.1: {} + commander@15.0.0: {} - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 + convert-source-map@2.0.0: {} - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - cac@6.7.14: {} - - chai@4.5.0: - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chardet@0.7.0: {} - - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 - - ci-info@3.9.0: {} - - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} - - cli-width@3.0.0: {} - - clone@1.0.4: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - commander@13.1.0: {} - - confbox@0.1.8: {} - - create-require@1.1.1: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 - - defaults@1.0.4: - dependencies: - clone: 1.0.4 - - diff-sequences@29.6.3: {} - - diff@4.0.2: {} - - dotenv@10.0.0: {} - - emoji-regex@8.0.0: {} + detect-libc@2.1.2: {} enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - esbuild@0.21.5: + es-module-lexer@2.3.1: {} + + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - escape-string-regexp@1.0.5: {} - - escape-string-regexp@2.0.0: {} + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 - execa@8.0.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - - execa@9.5.2: + execa@10.0.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 - cross-spawn: 7.0.6 figures: 6.1.0 get-stream: 9.0.1 - human-signals: 8.0.0 + human-signals: 8.0.1 is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 - pretty-ms: 9.2.0 + path-key: 4.0.0 + pretty-ms: 9.3.0 signal-exit: 4.1.0 strip-final-newline: 4.0.0 - yoctocolors: 2.1.1 + which-command: 0.1.0 + yoctocolors: 2.1.2 - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 + expect-type@1.4.0: {} - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.0.33 + fast-string-truncated-width@3.0.3: {} - figures@3.2.0: + fast-string-width@3.0.2: dependencies: - escape-string-regexp: 1.0.5 + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - fsevents@2.3.3: optional: true fuzzy@0.1.3: {} - get-func-name@2.0.2: {} - - get-stream@8.0.1: {} - get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - graceful-fs@4.2.11: {} - has-flag@4.0.0: {} - human-signals@5.0.0: {} + html-escaper@2.0.2: {} - human-signals@8.0.0: {} + human-signals@8.0.1: {} - iconv-lite@0.4.24: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - - inherits@2.0.4: {} - - inquirer-checkbox-plus-prompt@1.4.2(inquirer@8.2.4): + inquirer@14.0.2(@types/node@26.1.1): dependencies: - chalk: 4.1.2 - cli-cursor: 3.1.0 - figures: 3.2.0 - inquirer: 8.2.4 - lodash: 4.17.23 - rxjs: 6.6.7 - - inquirer@8.2.4: - dependencies: - ansi-escapes: 4.3.2 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-width: 3.0.0 - external-editor: 3.1.0 - figures: 3.2.0 - lodash: 4.17.23 - mute-stream: 0.0.8 - ora: 5.4.1 - run-async: 2.4.1 - rxjs: 7.8.2 - string-width: 4.2.3 - strip-ansi: 6.0.1 - through: 2.3.8 - wrap-ansi: 7.0.0 - - is-fullwidth-code-point@3.0.0: {} - - is-interactive@1.0.0: {} - - is-number@7.0.0: {} + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/prompts': 8.5.2(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + mute-stream: 3.0.0 + run-async: 4.0.6 + optionalDependencies: + '@types/node': 26.1.1 is-plain-obj@4.1.0: {} - is-stream@3.0.0: {} - is-stream@4.0.1: {} - is-unicode-supported@0.1.0: {} - is-unicode-supported@2.1.0: {} - isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} - jest-diff@29.7.0: + istanbul-lib-report@3.0.1: dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 - jest-get-type@29.6.3: {} - - jest-matcher-utils@29.7.0: + istanbul-reports@3.2.0: dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 - jest-message-util@29.7.0: + js-tokens@10.0.0: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: dependencies: - '@babel/code-frame': 7.26.2 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.10.7 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.1 - - js-tokens@4.0.0: {} - - js-tokens@9.0.1: {} - - local-pkg@0.5.1: - dependencies: - mlly: 1.8.0 - pkg-types: 1.3.1 - - lodash@4.17.23: {} - - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - - loupe@2.3.7: - dependencies: - get-func-name: 2.0.2 + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - make-error@1.3.6: {} - - merge-stream@2.0.0: {} - - micromatch@4.0.8: + magicast@0.5.3: dependencies: - braces: 3.0.3 - picomatch: 2.3.1 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 - mimic-fn@2.1.0: {} - - mimic-fn@4.0.0: {} - - mlly@1.8.0: + make-dir@4.0.0: dependencies: - acorn: 8.16.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 + semver: 7.8.5 - ms@2.1.3: {} + mute-stream@3.0.0: {} - mute-stream@0.0.8: {} - - nanoid@3.3.11: {} - - npm-run-path@5.3.0: - dependencies: - path-key: 4.0.0 + nanoid@3.3.16: {} npm-run-path@6.0.0: dependencies: path-key: 4.0.0 unicorn-magic: 0.3.0 - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - - os-tmpdir@1.0.2: {} - - p-limit@5.0.0: - dependencies: - yocto-queue: 1.2.2 + obug@2.1.4: {} parse-ms@4.0.0: {} - path-key@3.1.1: {} - path-key@4.0.0: {} - pathe@1.1.2: {} - pathe@2.0.3: {} - pathval@1.1.1: {} - picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@4.0.5: {} - pkg-types@1.3.1: + postcss@8.5.23: dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - pretty-ms@9.2.0: + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 - react-is@18.3.1: {} - - readable-stream@3.6.2: + rolldown@1.1.5: dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - - rollup@4.59.0: - dependencies: - '@types/estree': 1.0.8 + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 - fsevents: 2.3.3 + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 - run-async@2.4.1: {} - - rxjs@6.6.7: - dependencies: - tslib: 1.14.1 - - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - - safe-buffer@5.2.1: {} + run-async@4.0.6: {} safer-buffer@2.1.2: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} + semver@7.8.5: {} siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} - slash@3.0.0: {} + simple-git-hooks@2.13.1: {} source-map-js@1.2.1: {} - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - stackback@0.0.2: {} - std-env@3.10.0: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 + std-env@4.2.0: {} strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - strip-final-newline@3.0.0: {} - strip-final-newline@4.0.0: {} - strip-literal@2.1.1: - dependencies: - js-tokens: 9.0.1 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 - through@2.3.8: {} - tinybench@2.9.0: {} - tinypool@0.8.4: {} + tinyexec@1.2.4: {} - tinyspy@2.2.1: {} - - tmp@0.0.33: + tinyglobby@0.2.17: dependencies: - os-tmpdir: 1.0.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 - to-regex-range@5.0.1: + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + tsx@4.23.1: dependencies: - is-number: 7.0.0 + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 - ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.10.7 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.7.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 - tslib@1.14.1: {} - - tslib@2.8.1: {} - - type-detect@4.1.0: {} - - type-fest@0.21.3: {} - - typed-dotenv@10.0.2: - dependencies: - dotenv: 10.0.0 - lodash: 4.17.23 - - typescript@5.7.3: {} - - ufo@1.6.3: {} - - undici-types@6.20.0: {} + undici-types@8.3.0: {} unicorn-magic@0.3.0: {} - util-deprecate@1.0.2: {} - - v8-compile-cache-lib@3.0.1: {} - - vite-node@1.6.1(@types/node@22.10.7): + vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1): dependencies: - cac: 6.7.14 - debug: 4.4.3 - pathe: 1.1.2 - picocolors: 1.1.1 - vite: 5.4.21(@types/node@22.10.7) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@22.10.7): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.59.0 + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.23 + rolldown: 1.1.5 + tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 22.10.7 + '@types/node': 26.1.1 + esbuild: 0.28.1 fsevents: 2.3.3 + tsx: 4.23.1 - vitest@1.6.1(@types/node@22.10.7): + vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)): dependencies: - '@vitest/expect': 1.6.1 - '@vitest/runner': 1.6.1 - '@vitest/snapshot': 1.6.1 - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - acorn-walk: 8.3.5 - chai: 4.5.0 - debug: 4.4.3 - execa: 8.0.1 - local-pkg: 0.5.1 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 magic-string: 0.30.21 - pathe: 1.1.2 - picocolors: 1.1.1 - std-env: 3.10.0 - strip-literal: 2.1.1 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 tinybench: 2.9.0 - tinypool: 0.8.4 - vite: 5.4.21(@types/node@22.10.7) - vite-node: 1.6.1(@types/node@22.10.7) + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.10.7 + '@types/node': 26.1.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser + - msw - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - - which@2.0.2: - dependencies: - isexe: 2.0.0 + which-command@0.1.0: {} why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 + yoctocolors@2.1.2: {} - yaml@2.8.2: {} + zod@4.4.3: {} - yn@3.1.1: {} - - yocto-queue@1.2.2: {} - - yoctocolors@2.1.1: {} - - zod@3.24.1: {} - - zx@8.3.0: - optionalDependencies: - '@types/fs-extra': 11.0.4 - '@types/node': 22.10.7 + zx@8.8.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 39ac5e6..b9a743b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,13 @@ packages: - 'packages/*' - - 'projects/*' -onlyBuiltDependencies: - - "@biomejs/biome" - - "esbuild" + +overrides: + '@types/node': '^26.1.1' + '@logtape/logtape': '^2.2.4' + 'typescript': '^7.0.2' + 'commander': '^15.0.0' + +allowBuilds: + '@biomejs/biome': true + esbuild: true + simple-git-hooks: true diff --git a/scripts/coverage-summary.mjs b/scripts/coverage-summary.mjs new file mode 100644 index 0000000..6adb866 --- /dev/null +++ b/scripts/coverage-summary.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +/** + * Renders the per-package vitest `json-summary` reports as one Markdown table. + * + * Appends to $GITHUB_STEP_SUMMARY when running under Actions so the numbers are + * visible on the run page without opening the log, and prints to stdout + * otherwise. Never fails the build: the hard gate is vitest's own + * `coverage.thresholds`, this is only a report. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const PACKAGES_DIR = 'packages'; +const METRICS = ['statements', 'branches', 'functions', 'lines']; + +const reports = fs + .readdirSync(PACKAGES_DIR) + .map((name) => ({ + name, + file: path.join(PACKAGES_DIR, name, 'coverage', 'coverage-summary.json'), + })) + .filter(({ file }) => fs.existsSync(file)); + +if (reports.length === 0) { + console.error('No coverage reports found under packages/*/coverage — nothing to summarise.'); + process.exit(0); +} + +const heading = (metric) => metric[0].toUpperCase() + metric.slice(1); + +const rows = reports.map(({ name, file }) => { + const { total } = JSON.parse(fs.readFileSync(file, 'utf-8')); + return `| \`${name}\` | ${METRICS.map((metric) => `${total[metric].pct}%`).join(' | ')} |`; +}); + +const table = [ + '### Coverage', + '', + `| Package | ${METRICS.map(heading).join(' | ')} |`, + `| --- ${'| --- '.repeat(METRICS.length)}|`, + ...rows, + '', +].join('\n'); + +const summaryFile = process.env.GITHUB_STEP_SUMMARY; +if (summaryFile) { + fs.appendFileSync(summaryFile, `${table}\n`); +} else { + console.log(table); +} diff --git a/tsconfig.base.json b/tsconfig.base.json index c5def38..da3ffa1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,10 +16,5 @@ "@bitstack/keyman": ["./packages/keyman/src"] } }, - "ts-node": { - "experimentalSpecifierResolution": "node", - "transpileOnly": true, - "esm": true - }, "exclude": ["coverage", "node_modules", "dist"] } diff --git a/tsconfig.json b/tsconfig.json index 77ab887..7af6468 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,5 @@ { "extends": "./tsconfig.base.json", "files": [], - "references": [ - { "path": "./packages/nopy" }, - { "path": "./packages/keyman" } - ] + "references": [{ "path": "./packages/nopy" }, { "path": "./packages/keyman" }] }