From 63ce8ca9a138d55f4cc8c4bc3d965288686fdd0b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 29 Aug 2026 17:01:09 -0400 Subject: [PATCH 1/5] fix(packaging): avoid mandatory Bun in prebuilt installs --- .changeset/fix-pnpm-bun-projection.md | 5 + .github/workflows/install-vm.yml | 62 + package.json | 2 + scripts/check-prebuilt-pack.ts | 10 +- scripts/prebuilt-package-helpers.test.ts | 19 + scripts/prebuilt-package-helpers.ts | 15 + scripts/smoke-prebuilt-install.ts | 26 + scripts/stage-prebuilt-npm.ts | 3 +- skills/hunk-release/SKILL.md | 16 + test/cli/install-vm/.dockerignore | 8 + test/cli/install-vm/Dockerfile | 40 + test/cli/install-vm/README.md | 39 + test/cli/install-vm/contract.test.ts | 326 ++ test/cli/install-vm/contract.ts | 413 ++ .../controller-deps/package-lock.json | 3628 +++++++++++++++++ .../install-vm/controller-deps/package.json | 8 + test/cli/install-vm/controller.sh | 273 ++ .../install-vm/guest/prepare-base-image.sh | 114 + test/cli/install-vm/guest/scenario-lib.sh | 192 + test/cli/install-vm/pins.json | 30 + test/cli/install-vm/preflight.ts | 52 + test/cli/install-vm/prepare-fixtures.test.ts | 153 + test/cli/install-vm/prepare-fixtures.ts | 437 ++ test/cli/install-vm/results.test.ts | 142 + test/cli/install-vm/results.ts | 207 + test/cli/install-vm/runner.ts | 351 ++ test/cli/install-vm/runtime-lock.ts | 122 + test/cli/install-vm/scenarios.json | 89 + .../scenarios/curl-clean-machine.sh | 31 + .../scenarios/curl-failure-preservation.sh | 39 + .../historical-pnpm-bun-corruption.sh | 51 + .../scenarios/missing-platform-no-bun.sh | 16 + .../scenarios/missing-platform-npm-bun.sh | 20 + .../scenarios/missing-platform-system-bun.sh | 29 + .../scenarios/npm-prebuilt-no-bun.sh | 29 + .../scenarios/offline-after-install.sh | 18 + .../scenarios/old-npm-bun-fallback.sh | 29 + .../scenarios/pnpm-global-upgrade.sh | 32 + .../scenarios/pnpm-prebuilt-no-bun.sh | 29 + .../install-vm/scenarios/system-bun-path.sh | 37 + 40 files changed, 7136 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-pnpm-bun-projection.md create mode 100644 .github/workflows/install-vm.yml create mode 100644 test/cli/install-vm/.dockerignore create mode 100644 test/cli/install-vm/Dockerfile create mode 100644 test/cli/install-vm/README.md create mode 100644 test/cli/install-vm/contract.test.ts create mode 100644 test/cli/install-vm/contract.ts create mode 100644 test/cli/install-vm/controller-deps/package-lock.json create mode 100644 test/cli/install-vm/controller-deps/package.json create mode 100755 test/cli/install-vm/controller.sh create mode 100755 test/cli/install-vm/guest/prepare-base-image.sh create mode 100755 test/cli/install-vm/guest/scenario-lib.sh create mode 100644 test/cli/install-vm/pins.json create mode 100644 test/cli/install-vm/preflight.ts create mode 100644 test/cli/install-vm/prepare-fixtures.test.ts create mode 100644 test/cli/install-vm/prepare-fixtures.ts create mode 100644 test/cli/install-vm/results.test.ts create mode 100644 test/cli/install-vm/results.ts create mode 100644 test/cli/install-vm/runner.ts create mode 100644 test/cli/install-vm/runtime-lock.ts create mode 100644 test/cli/install-vm/scenarios.json create mode 100755 test/cli/install-vm/scenarios/curl-clean-machine.sh create mode 100755 test/cli/install-vm/scenarios/curl-failure-preservation.sh create mode 100755 test/cli/install-vm/scenarios/historical-pnpm-bun-corruption.sh create mode 100755 test/cli/install-vm/scenarios/missing-platform-no-bun.sh create mode 100755 test/cli/install-vm/scenarios/missing-platform-npm-bun.sh create mode 100755 test/cli/install-vm/scenarios/missing-platform-system-bun.sh create mode 100755 test/cli/install-vm/scenarios/npm-prebuilt-no-bun.sh create mode 100755 test/cli/install-vm/scenarios/offline-after-install.sh create mode 100755 test/cli/install-vm/scenarios/old-npm-bun-fallback.sh create mode 100755 test/cli/install-vm/scenarios/pnpm-global-upgrade.sh create mode 100755 test/cli/install-vm/scenarios/pnpm-prebuilt-no-bun.sh create mode 100755 test/cli/install-vm/scenarios/system-bun-path.sh diff --git a/.changeset/fix-pnpm-bun-projection.md b/.changeset/fix-pnpm-bun-projection.md new file mode 100644 index 000000000..3c37f0464 --- /dev/null +++ b/.changeset/fix-pnpm-bun-projection.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Stop installing Bun beside prebuilt Hunk packages so pnpm global updates cannot corrupt Bun's shared platform-package projection. Standalone platform binaries continue to work without a separate Bun installation. diff --git a/.github/workflows/install-vm.yml b/.github/workflows/install-vm.yml new file mode 100644 index 000000000..5625ec850 --- /dev/null +++ b/.github/workflows/install-vm.yml @@ -0,0 +1,62 @@ +# Runs repository-controlled KVM code only when a maintainer explicitly requests it. +# Never add pull_request or pull_request_target triggers to this privilege-bearing workflow. +name: Optional install VM suite + +on: + workflow_dispatch: + inputs: + scenario: + description: Optional scenario id (empty runs the full suite) + required: false + type: string + +permissions: + contents: read + +concurrency: + group: install-vm-${{ github.ref }} + cancel-in-progress: true + +jobs: + install-vm: + name: Firecracker install compatibility + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install ordinary development dependencies + run: bun install --frozen-lockfile + + - name: Run selected install scenarios + env: + INSTALL_VM_SCENARIO: ${{ inputs.scenario }} + run: | + args=(--allow-skip) + if [[ -n "$INSTALL_VM_SCENARIO" ]]; then + args+=(--scenario "$INSTALL_VM_SCENARIO") + fi + bun run test:install-vm -- "${args[@]}" + + - name: Upload structured install results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: install-vm-results + path: | + tmp/install-vm/runs + !tmp/install-vm/**/*.ext4 + !tmp/install-vm/**/*.socket + !tmp/install-vm/**/id_* + !tmp/install-vm/**/.lock/** + !tmp/install-vm/**/*identity* + !tmp/install-vm/**/*credential* + if-no-files-found: warn diff --git a/package.json b/package.json index 6089d8ddf..7d3624317 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,8 @@ "test:theme-contrast": "bun test src/ui/themes.test.ts --test-name-pattern contrast", "test:integration": "\"${npm_execpath:-bun}\" test ./test/pty", "test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 \"${npm_execpath:-bun}\" test ./test/smoke", + "test:install-vm": "bun run ./test/cli/install-vm/runner.ts", + "test:install-vm:clean": "bun run ./test/cli/install-vm/runner.ts --clean", "check:pack": "bun run ./scripts/check-pack.ts", "check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts", "smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts", diff --git a/scripts/check-prebuilt-pack.ts b/scripts/check-prebuilt-pack.ts index d2853892e..f35afc9f4 100644 --- a/scripts/check-prebuilt-pack.ts +++ b/scripts/check-prebuilt-pack.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { + assertNoMandatoryBunDependency, assertOptionalPeerDependencyContract, releaseNpmDir, type PackageDependencyManifest, @@ -74,11 +75,10 @@ if (!existsSync(metaDir)) { throw new Error(`Missing staged top-level package at ${metaDir}`); } -assertOptionalPeerDependencyContract( - readPackageManifest(repoRoot), - readPackageManifest(metaDir), - "@pierre/diffs", -); +const rootManifest = readPackageManifest(repoRoot); +const stagedManifest = readPackageManifest(metaDir); +assertOptionalPeerDependencyContract(rootManifest, stagedManifest, "@pierre/diffs"); +assertNoMandatoryBunDependency(stagedManifest); const metaPack = runPackDryRun(metaDir); assertPaths(metaPack, [ diff --git a/scripts/prebuilt-package-helpers.test.ts b/scripts/prebuilt-package-helpers.test.ts index 1f6447534..e2db575a2 100644 --- a/scripts/prebuilt-package-helpers.test.ts +++ b/scripts/prebuilt-package-helpers.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; import { PLATFORM_PACKAGE_MATRIX, + assertNoMandatoryBunDependency, assertOptionalPeerDependencyContract, binaryFilenameForSpec, buildOptionalDependencyMap, buildPlatformPackageManifest, + buildPrebuiltRuntimeDependencies, getHostPlatformPackageSpec, getPlatformPackageSpecByName, getPlatformPackageSpecForHost, @@ -34,6 +36,23 @@ function createOptionalPeerContract(): { } describe("prebuilt package helpers", () => { + test("prebuilt runtime dependencies exclude Bun without mutating the source manifest", () => { + const dependencies = { bun: "^1.3.14", commander: "^14.0.3" }; + + expect(buildPrebuiltRuntimeDependencies(dependencies)).toEqual({ commander: "^14.0.3" }); + expect(dependencies).toEqual({ bun: "^1.3.14", commander: "^14.0.3" }); + expect(buildPrebuiltRuntimeDependencies()).toBeUndefined(); + }); + + test("assertNoMandatoryBunDependency rejects a staged Bun runtime", () => { + expect(() => + assertNoMandatoryBunDependency({ dependencies: { commander: "1.0.0" } }), + ).not.toThrow(); + expect(() => assertNoMandatoryBunDependency({ dependencies: { bun: "1.4.0" } })).toThrow( + "omit the mandatory bun dependency", + ); + }); + test("buildOptionalDependencyMap includes every supported platform package at one version", () => { const version = "9.9.9"; const dependencies = buildOptionalDependencyMap(version); diff --git a/scripts/prebuilt-package-helpers.ts b/scripts/prebuilt-package-helpers.ts index 2a26dcb2e..dc7cc5837 100644 --- a/scripts/prebuilt-package-helpers.ts +++ b/scripts/prebuilt-package-helpers.ts @@ -21,6 +21,21 @@ export interface PackageDependencyManifest { peerDependenciesMeta?: Record; } +/** Remove Bun from dependencies shipped beside standalone prebuilt executables. */ +export function buildPrebuiltRuntimeDependencies(dependencies?: Record) { + if (!dependencies) return undefined; + + const { bun: _bundledRuntime, ...runtimeDependencies } = dependencies; + return runtimeDependencies; +} + +/** Assert a staged prebuilt package cannot install Bun as a mandatory dependency. */ +export function assertNoMandatoryBunDependency(manifest: PackageDependencyManifest) { + if (manifest.dependencies?.bun !== undefined) { + throw new Error("Expected the staged prebuilt package to omit the mandatory bun dependency."); + } +} + const PLATFORM_NAME_MAP: Partial> = { darwin: "darwin", linux: "linux", diff --git a/scripts/smoke-prebuilt-install.ts b/scripts/smoke-prebuilt-install.ts index 28d9fcab3..21624e0c8 100644 --- a/scripts/smoke-prebuilt-install.ts +++ b/scripts/smoke-prebuilt-install.ts @@ -7,6 +7,7 @@ import { mkdtempSync, mkdirSync, readFileSync, + readdirSync, rmSync, statSync, writeFileSync, @@ -19,6 +20,27 @@ import { } from "./prebuilt-package-helpers"; import { envWithPath, npmCommand } from "./script-helpers"; +/** Return whether an installed dependency tree contains Bun's npm packages. */ +function containsBunPackage(root: string) { + if (!existsSync(root)) return false; + + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop()!; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name === "bun" && path.basename(directory) === "node_modules") return true; + if (entry.name.startsWith("bun-") && path.basename(directory) === "@oven") return true; + pending.push(entryPath); + } + } + } + + return false; +} + function run(command: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) { const proc = Bun.spawnSync(command, { cwd: options?.cwd, @@ -150,6 +172,10 @@ try { throw new Error("Expected a CLI-only Hunk install to omit the optional @pierre/diffs peer."); } + if (containsBunPackage(installDir)) { + throw new Error("Expected a prebuilt Hunk install to omit bun and @oven/bun-* packages."); + } + if (process.platform !== "win32") { const installedBinaryMode = statSync(installedPlatformBinary).mode & 0o777; if ((installedBinaryMode & 0o111) === 0) { diff --git a/scripts/stage-prebuilt-npm.ts b/scripts/stage-prebuilt-npm.ts index 8feb7dc70..a001a41a1 100644 --- a/scripts/stage-prebuilt-npm.ts +++ b/scripts/stage-prebuilt-npm.ts @@ -15,6 +15,7 @@ import { binaryFilenameForSpec, buildOptionalDependencyMap, buildPlatformPackageManifest, + buildPrebuiltRuntimeDependencies, getHostPlatformPackageSpec, getPlatformPackageSpecByName, releaseNpmDir, @@ -106,7 +107,7 @@ function stageMetaPackage( homepage: rootPackage.homepage, bugs: rootPackage.bugs, engines: rootPackage.engines, - dependencies: rootPackage.dependencies, + dependencies: buildPrebuiltRuntimeDependencies(rootPackage.dependencies), peerDependencies: rootPackage.peerDependencies, peerDependenciesMeta: rootPackage.peerDependenciesMeta, optionalDependencies: buildOptionalDependencyMap(rootPackage.version, specs), diff --git a/skills/hunk-release/SKILL.md b/skills/hunk-release/SKILL.md index d2c452249..55b1b316b 100644 --- a/skills/hunk-release/SKILL.md +++ b/skills/hunk-release/SKILL.md @@ -76,6 +76,22 @@ bun run check:prebuilt-pack bun run smoke:prebuilt-install ``` +Run the full Firecracker install compatibility suite once from the reviewed release tip on a Linux +x64 host with working KVM, either locally or through the manually dispatched +`install-vm.yml` workflow: + +```sh +bun run test:install-vm +result=$(find tmp/install-vm/runs -mindepth 2 -maxdepth 2 -name result.json -printf '%T@ %p\n' \ + | sort -nr | head -1 | cut -d' ' -f2-) +jq -e '.run.status == "passed" and (.scenarios | length > 0) and all(.scenarios[]; .status == "passed")' "$result" +``` + +A skipped result does not satisfy release validation. When using the manual workflow, inspect its +uploaded `result.json`; a green job alone is insufficient because unsupported runners may use the +intentional skip path. Firecracker validates Linux x64 packaging behavior, while the existing native +release jobs remain responsible for macOS, Windows, and other architectures. + Commit the generated metadata and `benchmarks/release/bench-X.Y.Z.json`, follow normal review policy, and wait for required CI. ## 3. Tag and publish diff --git a/test/cli/install-vm/.dockerignore b/test/cli/install-vm/.dockerignore new file mode 100644 index 000000000..8c760ea10 --- /dev/null +++ b/test/cli/install-vm/.dockerignore @@ -0,0 +1,8 @@ +** +!Dockerfile +!controller.sh +!pins.json +!scenarios.json +!controller-deps/** +!guest/** +!scenarios/** diff --git a/test/cli/install-vm/Dockerfile b/test/cli/install-vm/Dockerfile new file mode 100644 index 000000000..8cd8f388d --- /dev/null +++ b/test/cli/install-vm/Dockerfile @@ -0,0 +1,40 @@ +FROM ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates=20260601~24.04.1 \ + curl=8.5.0-2ubuntu10.13 \ + e2fsprogs=1.47.0-2.4~exp1ubuntu4.1 \ + iproute2=6.1.0-1ubuntu6.4 \ + iptables=1.8.10-3ubuntu2 \ + jq=1.7.1-3ubuntu0.24.04.2 \ + openssh-client=1:9.6p1-3ubuntu13.18 \ + procps=2:4.0.4-4ubuntu3.3 \ + python3=3.12.3-0ubuntu2.1 \ + squashfs-tools=1:4.6.1-1build1 \ + xz-utils=5.6.1+really5.4.5-1ubuntu0.3 \ + && rm -rf /var/lib/apt/lists/* + +ARG NODE_VERSION=24.14.1 +ARG NODE_SHA256=84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be +RUN curl --fail --show-error --location --connect-timeout 15 --max-time 300 --retry 3 \ + -o /tmp/node.tar.xz "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + && echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \ + && tar -xJf /tmp/node.tar.xz -C /opt \ + && ln -s "/opt/node-v${NODE_VERSION}-linux-x64/bin/node" /usr/local/bin/node \ + && ln -s "/opt/node-v${NODE_VERSION}-linux-x64/bin/npm" /usr/local/bin/npm \ + && ln -s "/opt/node-v${NODE_VERSION}-linux-x64/bin/npx" /usr/local/bin/npx \ + && rm -f /tmp/node.tar.xz + +WORKDIR /opt/install-vm +COPY controller-deps ./controller-deps +RUN cd controller-deps \ + && npm ci --ignore-scripts \ + && ln -s /opt/install-vm/controller-deps/node_modules/.bin/verdaccio /usr/local/bin/verdaccio +COPY controller.sh pins.json scenarios.json ./ +COPY guest ./guest +COPY scenarios ./scenarios +RUN chmod +x controller.sh guest/*.sh scenarios/*.sh + +ENTRYPOINT ["/opt/install-vm/controller.sh"] diff --git a/test/cli/install-vm/README.md b/test/cli/install-vm/README.md new file mode 100644 index 000000000..91c86da3b --- /dev/null +++ b/test/cli/install-vm/README.md @@ -0,0 +1,39 @@ +# Optional Firecracker install compatibility suite + +This suite tests Hunk's Linux x64 npm, pnpm, legacy Bun-fallback, offline, and curl installation behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets. + +## Run + +Requirements: + +- Linux x86_64 with at least 6 GiB free; +- Docker daemon access without `sudo`; +- readable/writable `/dev/kvm` and `/dev/net/tun`. + +```sh +bun run test:install-vm -- --list +bun run test:install-vm -- --scenario pnpm-global-upgrade +bun run test:install-vm +``` + +Use `--reuse-fixtures` to reuse package fixtures only when their checkout identity and every tarball checksum still match; stale or altered fixtures are rebuilt. Automation that intentionally permits unsupported hosts may pass `--allow-skip`; a requested local run otherwise fails with an actionable preflight report. In GitHub Actions, an allowed skip emits a workflow warning and a prominent step summary in addition to a structured skipped result—it is not VM success. + +The first run lazily builds the controller image and downloads checksum-pinned Firecracker, kernel, rootfs, and Node inputs. They live under `tmp/install-vm/cache`; generated package fixtures and structured runs live under `tmp/install-vm/fixtures` and `tmp/install-vm/runs`. Remove only those harness-owned artifacts with: + +```sh +bun run test:install-vm:clean +``` + +Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, and Firecracker console output. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. + +## Security boundary + +The controller container receives only `/dev/kvm`, `/dev/net/tun`, `NET_ADMIN`, `CHOWN`, and `DAC_OVERRIDE`. The last two let it traverse the owner-only validated cache/result binds while running, then return their ownership to the invoking user; the directories are never made world-writable. The container drops all other capabilities, enables `no-new-privileges`, uses a read-only container root, and never mounts the repository or Docker socket. TAP and NAT changes stay in its Docker network namespace and are removed on exit. Third-party package lifecycle scripts run as root only inside disposable guests with no repository, host credentials, or host-writable package cache. + +This is development/test isolation, not a production Firecracker jail. Run repository-controlled KVM jobs only on trusted disposable hosts. The dedicated workflow is manual and never runs for pull requests. + +## Coverage boundaries + +Firecracker runs Linux guests on the host CPU. It cannot validate macOS or Windows, emulate Apple Silicon, or reproduce the final native macOS ARM64 exit behavior from issue #866. Native Apple Silicon coverage remains tracked by `TODO-1994d3d9`. + +The Node-resolvable npm Bun fallback remains a best-effort legacy path. A standalone `bun` on PATH is deliberately different and is not used by the launcher. The suite observes an older fallback package but does not declare a supported minimum Bun version. diff --git a/test/cli/install-vm/contract.test.ts b/test/cli/install-vm/contract.test.ts new file mode 100644 index 000000000..76059b59a --- /dev/null +++ b/test/cli/install-vm/contract.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + assertDistinctInstallVmRuntimePaths, + assertSafeCleanTarget, + assertSafeInstallVmRuntimePath, + buildDockerRunCommand, + buildInstallVmJunit, + evaluateCommandExpectation, + parseInstallVmArgs, + selectScenarios, + validateInstallVmPins, + validateScenarioManifest, + type InstallVmRunResult, +} from "./contract"; +import { collectInstallVmPreflightFailures } from "./preflight"; +import { InstallVmCommandError, InstallVmCommandRunner } from "./runner"; +import { acquireInstallVmRuntimeLock } from "./runtime-lock"; + +const manifest = validateScenarioManifest({ + schemaVersion: 1, + scenarios: [ + { + id: "negative-case", + description: "Expected negative case", + profile: "node", + script: "negative-case.sh", + network: "local", + }, + ], +}); + +describe("install VM contract", () => { + test("parses explicit selection and optional runner flags", () => { + expect( + parseInstallVmArgs([ + "--scenario", + "negative-case", + "--allow-skip", + "--reuse-fixtures", + "--cache-dir", + "tmp/install-vm/cache", + ]), + ).toEqual({ + allowSkip: true, + clean: false, + list: false, + reuseFixtures: true, + scenarios: ["negative-case"], + cacheDir: "tmp/install-vm/cache", + }); + expect(() => parseInstallVmArgs(["--scenario"])).toThrow("requires a value"); + expect(() => + parseInstallVmArgs(["--scenario", "negative-case", "--scenario", "negative-case"]), + ).toThrow("only once"); + expect(() => parseInstallVmArgs(["--unknown"])).toThrow("Unknown install VM option"); + }); + + test("requires immutable controller and remote asset pins", () => { + const validPins = { + schemaVersion: 1 as const, + controllerImage: `ubuntu@sha256:${"a".repeat(64)}`, + verdaccioVersion: "6.10.1", + pnpmVersion: "11.23.0", + historical: { hunkdiffVersion: "0.19.0", bunVersion: "1.4.0" }, + firecracker: { + version: "1.0.0", + url: "https://example.test/firecracker", + sha256: "a".repeat(64), + }, + kernel: { version: "1.0.0", url: "https://example.test/kernel", sha256: "b".repeat(64) }, + rootfs: { version: "1.0.0", url: "https://example.test/rootfs", sha256: "c".repeat(64) }, + node: { version: "1.0.0", url: "https://example.test/node", sha256: "d".repeat(64) }, + }; + expect(validateInstallVmPins(validPins)).toBe(validPins); + expect(() => validateInstallVmPins({ ...validPins, controllerImage: "ubuntu:latest" })).toThrow( + "immutable sha256", + ); + expect(() => + validateInstallVmPins({ + ...validPins, + kernel: { url: "https://example.test/kernel", sha256: "moving" }, + }), + ).toThrow("kernel pin needs"); + expect(() => validateInstallVmPins({ ...validPins, pnpmVersion: "latest" })).toThrow( + "pnpm must be pinned", + ); + expect(() => + validateInstallVmPins({ + ...validPins, + historical: { ...validPins.historical, bunVersion: "^1.4.0" }, + }), + ).toThrow("Historical bun must be pinned"); + }); + + test("validates scenarios and rejects unsafe or duplicate definitions", () => { + expect(selectScenarios(manifest, ["negative-case"])[0]?.script).toBe("negative-case.sh"); + expect(() => selectScenarios(manifest, ["missing"])).toThrow("Unknown install VM scenario"); + expect(() => + validateScenarioManifest({ + schemaVersion: 1, + scenarios: [manifest.scenarios[0], manifest.scenarios[0]], + }), + ).toThrow("Duplicate scenario id"); + expect(() => + validateScenarioManifest({ + schemaVersion: 1, + scenarios: [{ ...manifest.scenarios[0], script: "../escape.sh" }], + }), + ).toThrow("unsafe script path"); + }); + + test("treats expected nonzero commands as passes only when diagnostics match", () => { + expect(evaluateCommandExpectation(1, [1], "missing platform", ["missing platform"])).toEqual({ + passed: true, + failures: [], + }); + expect(evaluateCommandExpectation(0, [1], "").passed).toBe(false); + expect(evaluateCommandExpectation(1, [1], "wrong output", ["required"]).passed).toBe(false); + expect(() => evaluateCommandExpectation(1, [], "")).toThrow("cannot be empty"); + }); + + test("builds a Docker invocation without privileged or repository mounts", () => { + const command = buildDockerRunCommand( + "hunk-install-vm:test", + { cacheDir: "/cache", fixtureDir: "/fixtures", outputDir: "/results" }, + ["negative-case"], + { uid: 1000, gid: 1000 }, + ); + expect(command).toContain("--cap-drop=ALL"); + expect(command).toContain("--cap-add=NET_ADMIN"); + expect(command).toContain("--cap-add=CHOWN"); + expect(command).toContain("--cap-add=DAC_OVERRIDE"); + expect(command).toContain("--security-opt=no-new-privileges"); + expect(command).toContain("--read-only"); + expect(command).not.toContain("--privileged"); + expect(command.join(" ")).not.toContain("/repo"); + expect(command.join(" ")).not.toContain("docker.sock"); + }); + + test("escapes JUnit and keeps stable scenario counts", () => { + const result: InstallVmRunResult = { + schemaVersion: 1, + run: { + id: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + platform: "linux-x64", + status: "failed", + }, + tools: {}, + scenarios: [ + { + id: "negative-case", + description: "negative", + status: "failed", + durationMs: 1250, + exitCode: 1, + commands: [ + { + id: "command", + status: "failed", + expectation: "exit 0", + exitCode: 1, + logPath: "commands/command.log", + }, + ], + observations: {}, + assertions: [ + { + id: "message", + status: "failed", + expected: "safe", + actual: "unsafe", + message: 'x < y & "quoted"', + }, + ], + artifacts: [], + }, + ], + }; + const junit = buildInstallVmJunit(result); + expect(junit).toContain('tests="1" failures="1" skipped="0"'); + expect(junit).toContain("x < y & "quoted""); + expect(junit).not.toContain('x < y & "quoted"'); + }); + + test("allows cleaning only real harness-owned paths and rejects symlink ancestors", () => { + const repo = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-contract-")); + const outside = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-outside-")); + try { + const runtime = path.join(repo, "tmp", "install-vm"); + mkdirSync(runtime, { recursive: true }); + expect(assertSafeCleanTarget(repo, path.join(runtime, "cache"))).toBe( + path.join(runtime, "cache"), + ); + expect(() => assertSafeCleanTarget(repo, repo)).toThrow("outside"); + expect(() => assertSafeInstallVmRuntimePath(repo, path.join(runtime, "bad,path"))).toThrow( + "commas", + ); + symlinkSync(outside, path.join(runtime, "linked")); + symlinkSync(path.join(outside, "missing"), path.join(runtime, "dangling")); + writeFileSync(path.join(outside, "preserve"), "still here\n"); + expect(() => assertSafeCleanTarget(repo, path.join(runtime, "linked"))).toThrow( + "symlink ancestor", + ); + expect(() => assertSafeCleanTarget(repo, path.join(runtime, "dangling", "child"))).toThrow( + "symlink ancestor", + ); + expect(Bun.file(path.join(outside, "preserve")).size).toBeGreaterThan(0); + expect(() => + assertDistinctInstallVmRuntimePaths({ + cache: path.join(runtime, "cache"), + output: path.join(runtime, "cache", "results"), + }), + ).toThrow("overlap"); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + } + }); + + test("acquires one runtime lock and reclaims a stale pid without deleting a racing owner", () => { + const root = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-lock-")); + const lock = path.join(root, ".lock"); + try { + const release = acquireInstallVmRuntimeLock(lock, { pid: 101, alive: () => true }); + expect(() => acquireInstallVmRuntimeLock(lock, { pid: 202, alive: () => true })).toThrow( + "already running", + ); + release(); + mkdirSync(lock); + writeFileSync(path.join(lock, "owner.json"), '{"pid":303}\n'); + const releaseReclaimed = acquireInstallVmRuntimeLock(lock, { + pid: 404, + alive: () => false, + }); + releaseReclaimed(); + + mkdirSync(lock); + writeFileSync(path.join(lock, "owner.json"), '{"pid":505}\n'); + let raced = false; + expect(() => + acquireInstallVmRuntimeLock(lock, { + pid: 606, + alive: (pid) => pid === 707, + beforeStaleClaim: () => { + if (raced) return; + raced = true; + rmSync(lock, { recursive: true }); + mkdirSync(lock); + writeFileSync(path.join(lock, "owner.json"), '{"pid":707}\n'); + }, + }), + ).toThrow("already running under pid 707"); + expect(readFileSync(path.join(lock, "owner.json"), "utf8")).toContain("707"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("terminates an asynchronously spawned host command at its deadline", async () => { + const runner = new InstallVmCommandRunner(); + runner.start(); + try { + const failure = await runner + .run([process.execPath, "-e", "setTimeout(() => {}, 60_000)"], { timeoutMs: 10 }) + .then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(InstallVmCommandError); + expect((failure as InstallVmCommandError).exitCode).toBe(124); + } finally { + runner.stop(); + } + }); + + test("replaces a pending timeout kill when an interrupt overlaps it", async () => { + const runner = new InstallVmCommandRunner(); + runner.start(); + try { + const command = runner.run( + [process.execPath, "-e", 'process.on("SIGTERM", () => {}); setTimeout(() => {}, 60_000)'], + { timeoutMs: 10 }, + ); + await Bun.sleep(20); + process.emit("SIGINT", "SIGINT"); + const startedAt = Date.now(); + const failure = await command.then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(InstallVmCommandError); + expect((failure as InstallVmCommandError).exitCode).toBe(130); + expect(Date.now() - startedAt).toBeLessThan(1_000); + } finally { + runner.stop(); + } + }); + + test("reports optional preflight failures through injected probes", async () => { + expect( + await collectInstallVmPreflightFailures("/tmp", { + platform: "darwin", + arch: "arm64", + dockerProbe: () => 1, + accessProbe: () => { + throw new Error("missing"); + }, + availableBytes: 0, + }), + ).toHaveLength(5); + expect( + await collectInstallVmPreflightFailures("/tmp", { + platform: "linux", + arch: "x64", + dockerProbe: () => 0, + accessProbe: () => {}, + availableBytes: 10 * 1024 ** 3, + }), + ).toEqual([]); + }); +}); diff --git a/test/cli/install-vm/contract.ts b/test/cli/install-vm/contract.ts new file mode 100644 index 000000000..c4e9a3fd0 --- /dev/null +++ b/test/cli/install-vm/contract.ts @@ -0,0 +1,413 @@ +import { lstatSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; + +export type InstallVmProfile = "minimal" | "node"; +export type InstallVmNetwork = "local" | "live"; + +export interface InstallVmScenario { + id: string; + description: string; + profile: InstallVmProfile; + script: string; + network: InstallVmNetwork; +} + +export interface InstallVmScenarioManifest { + schemaVersion: 1; + scenarios: InstallVmScenario[]; +} + +export interface InstallVmArgs { + allowSkip: boolean; + clean: boolean; + list: boolean; + reuseFixtures: boolean; + scenarios: string[]; + cacheDir?: string; + outputDir?: string; +} + +export interface InstallVmAssertion { + id: string; + status: "passed" | "failed"; + expected: string; + actual: string; + message: string; +} + +export interface InstallVmCommandResult { + id: string; + status: "passed" | "failed"; + expectation: string; + exitCode: number; + logPath: string; +} + +export interface InstallVmScenarioObservations { + hunkVersion?: string; + installSource?: string; + resolvedExecutable?: string; + dependencyTreePath?: string; + storeProjectionPath?: string; + [key: string]: string | undefined; +} + +export interface InstallVmScenarioResult { + id: string; + description: string; + status: "passed" | "failed" | "skipped"; + durationMs: number; + exitCode: number; + commands: InstallVmCommandResult[]; + observations: InstallVmScenarioObservations; + assertions: InstallVmAssertion[]; + artifacts: string[]; +} + +export interface InstallVmRunResult { + schemaVersion: 1; + run: { + id: string; + startedAt: string; + finishedAt: string; + platform: "linux-x64"; + status: "passed" | "failed" | "skipped"; + skipReason?: string; + }; + tools: Record; + scenarios: InstallVmScenarioResult[]; +} + +export interface InstallVmPins { + schemaVersion: 1; + controllerImage: string; + verdaccioVersion: string; + pnpmVersion: string; + historical: { + hunkdiffVersion: string; + bunVersion: string; + }; + firecracker: { version: string; url: string; sha256: string }; + kernel: { version: string; url: string; sha256: string }; + rootfs: { version: string; url: string; sha256: string }; + node: { version: string; url: string; sha256: string }; +} + +const SCENARIO_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+$/; + +/** Validate that every remote VM input is immutable and checksum-pinned. */ +export function validateInstallVmPins(value: unknown) { + if (!value || typeof value !== "object") throw new Error("Pin manifest must be an object."); + const pins = value as Record; + if (pins.schemaVersion !== 1) throw new Error("Pin manifest must use schemaVersion 1."); + if ( + typeof pins.controllerImage !== "string" || + !/@sha256:[a-f0-9]{64}$/.test(pins.controllerImage) + ) { + throw new Error("Controller image must use an immutable sha256 digest."); + } + for (const name of ["firecracker", "kernel", "rootfs", "node"] as const) { + const pin = pins[name]; + if (!pin || typeof pin !== "object") throw new Error(`Missing ${name} pin.`); + const record = pin as Record; + if (typeof record.version !== "string" || record.version.length === 0) { + throw new Error(`${name} pin needs a version label.`); + } + if (typeof record.url !== "string" || !record.url.startsWith("https://")) { + throw new Error(`${name} pin needs an HTTPS URL.`); + } + if (typeof record.sha256 !== "string" || !SHA256_PATTERN.test(record.sha256)) { + throw new Error(`${name} pin needs a lowercase SHA-256 digest.`); + } + } + if ( + typeof pins.verdaccioVersion !== "string" || + !EXACT_VERSION_PATTERN.test(pins.verdaccioVersion) + ) { + throw new Error("Verdaccio must be pinned to an exact version."); + } + if (typeof pins.pnpmVersion !== "string" || !EXACT_VERSION_PATTERN.test(pins.pnpmVersion)) { + throw new Error("pnpm must be pinned to an exact version."); + } + if (!pins.historical || typeof pins.historical !== "object") { + throw new Error("Historical package pins are required."); + } + const historical = pins.historical as Record; + for (const [name, version] of [ + ["hunkdiff", historical.hunkdiffVersion], + ["bun", historical.bunVersion], + ] as const) { + if (typeof version !== "string" || !EXACT_VERSION_PATTERN.test(version)) { + throw new Error(`Historical ${name} must be pinned to an exact version.`); + } + } + return pins as unknown as InstallVmPins; +} + +/** Parse the explicit install-VM runner command line without consulting the host. */ +export function parseInstallVmArgs(argv: string[]): InstallVmArgs { + const options: InstallVmArgs = { + allowSkip: false, + clean: false, + list: false, + reuseFixtures: false, + scenarios: [], + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--allow-skip") { + options.allowSkip = true; + } else if (argument === "--clean") { + options.clean = true; + } else if (argument === "--list") { + options.list = true; + } else if (argument === "--reuse-fixtures") { + options.reuseFixtures = true; + } else if (argument === "--scenario" || argument === "--cache-dir" || argument === "--output") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${argument} requires a value.`); + } + index += 1; + if (argument === "--scenario") options.scenarios.push(value); + if (argument === "--cache-dir") options.cacheDir = value; + if (argument === "--output") options.outputDir = value; + } else { + throw new Error(`Unknown install VM option: ${argument}`); + } + } + + if (new Set(options.scenarios).size !== options.scenarios.length) { + throw new Error("Each --scenario id may be selected only once."); + } + if (options.clean && (options.list || options.scenarios.length > 0 || options.outputDir)) { + throw new Error("--clean cannot be combined with listing, selection, or output options."); + } + + return options; +} + +/** Validate and return a JSON scenario manifest. */ +export function validateScenarioManifest(value: unknown): InstallVmScenarioManifest { + if (!value || typeof value !== "object") throw new Error("Scenario manifest must be an object."); + const manifest = value as Partial; + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.scenarios)) { + throw new Error("Scenario manifest must use schemaVersion 1 and contain scenarios."); + } + + const ids = new Set(); + for (const scenario of manifest.scenarios) { + if (!scenario || typeof scenario !== "object") + throw new Error("Every scenario must be an object."); + if (!SCENARIO_ID_PATTERN.test(scenario.id)) + throw new Error(`Invalid scenario id: ${scenario.id}`); + if (ids.has(scenario.id)) throw new Error(`Duplicate scenario id: ${scenario.id}`); + ids.add(scenario.id); + if (!scenario.description.trim()) + throw new Error(`Scenario ${scenario.id} needs a description.`); + if (scenario.profile !== "minimal" && scenario.profile !== "node") { + throw new Error(`Scenario ${scenario.id} has an unsupported profile.`); + } + if (scenario.network !== "local" && scenario.network !== "live") { + throw new Error(`Scenario ${scenario.id} has an unsupported network policy.`); + } + if (path.basename(scenario.script) !== scenario.script || !scenario.script.endsWith(".sh")) { + throw new Error(`Scenario ${scenario.id} has an unsafe script path.`); + } + } + + return manifest as InstallVmScenarioManifest; +} + +/** Load the committed scenario manifest. */ +export function loadScenarioManifest(manifestPath: string) { + return validateScenarioManifest(JSON.parse(readFileSync(manifestPath, "utf8")) as unknown); +} + +/** Resolve selected scenario ids or reject unknown ids before starting Docker. */ +export function selectScenarios( + manifest: InstallVmScenarioManifest, + selectedIds: readonly string[], +) { + if (selectedIds.length === 0) return [...manifest.scenarios]; + const byId = new Map(manifest.scenarios.map((scenario) => [scenario.id, scenario])); + return selectedIds.map((id) => { + const scenario = byId.get(id); + if (!scenario) throw new Error(`Unknown install VM scenario: ${id}`); + return scenario; + }); +} + +/** Decide whether an expected command outcome passed, including negative commands. */ +export function evaluateCommandExpectation( + exitCode: number, + allowedExitCodes: readonly number[], + output: string, + requiredMarkers: readonly string[] = [], +) { + if (allowedExitCodes.length === 0) throw new Error("Expected exit-code set cannot be empty."); + const failures: string[] = []; + if (!allowedExitCodes.includes(exitCode)) { + failures.push(`expected exit ${allowedExitCodes.join(" or ")}, got ${exitCode}`); + } + for (const marker of requiredMarkers) { + if (!output.includes(marker)) failures.push(`missing marker: ${marker}`); + } + return { passed: failures.length === 0, failures }; +} + +/** Escape one value for XML text and attributes. */ +export function escapeXml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** Project one structured install run into deterministic JUnit XML. */ +export function buildInstallVmJunit(result: InstallVmRunResult) { + const scenarios = [...result.scenarios].sort((left, right) => left.id.localeCompare(right.id)); + const failures = scenarios.filter((scenario) => scenario.status === "failed").length; + const skipped = scenarios.filter((scenario) => scenario.status === "skipped").length; + const time = scenarios.reduce((total, scenario) => total + scenario.durationMs, 0) / 1000; + const cases = scenarios + .map((scenario) => { + const attributes = `classname="install-vm" name="${escapeXml(scenario.id)}" time="${( + scenario.durationMs / 1000 + ).toFixed(3)}"`; + if (scenario.status === "skipped") { + return ` `; + } + if (scenario.status === "failed") { + const messages = scenario.assertions + .filter((assertion) => assertion.status === "failed") + .map((assertion) => `${assertion.id}: ${assertion.message}`) + .join("\n"); + return ` ${escapeXml(messages || `exit ${scenario.exitCode}`)}`; + } + return ` `; + }) + .join("\n"); + + return `\n\n${cases}\n\n`; +} + +/** Resolve one runtime path without following a symlink outside the harness-owned tmp tree. */ +export function assertSafeInstallVmRuntimePath( + repoRoot: string, + target: string, + options: { allowRoot?: boolean } = {}, +) { + if (/[,\0-\x1f\x7f]/.test(target)) { + throw new Error( + `Install VM runtime paths cannot contain commas or control characters: ${target}`, + ); + } + + const physicalRepoRoot = realpathSync(repoRoot); + const allowedRoot = path.join(physicalRepoRoot, "tmp", "install-vm"); + const resolved = path.resolve(target); + const relative = path.relative(allowedRoot, resolved); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Refusing install VM path outside ${allowedRoot}: ${resolved}`); + } + if (!options.allowRoot && relative === "") { + throw new Error(`Install VM runtime path must be below ${allowedRoot}.`); + } + + let cursor = physicalRepoRoot; + for (const segment of path.relative(physicalRepoRoot, resolved).split(path.sep)) { + if (!segment) continue; + cursor = path.join(cursor, segment); + try { + if (lstatSync(cursor).isSymbolicLink()) { + throw new Error(`Refusing install VM path with symlink ancestor: ${cursor}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + return resolved; +} + +/** Ensure a destructive clean target stays inside this repository's harness-owned tmp root. */ +export function assertSafeCleanTarget(repoRoot: string, target: string) { + const resolved = assertSafeInstallVmRuntimePath(repoRoot, target, { allowRoot: true }); + if (resolved === path.parse(resolved).root) { + throw new Error(`Refusing unsafe install VM clean target: ${resolved}`); + } + return resolved; +} + +/** Reject bind roots that overlap and could let one cleanup remove another resource. */ +export function assertDistinctInstallVmRuntimePaths(paths: Record) { + const entries = Object.entries(paths); + for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) { + const left = entries[leftIndex]!; + for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) { + const right = entries[rightIndex]!; + const relative = path.relative(left[1], right[1]); + const reverse = path.relative(right[1], left[1]); + const leftContainsRight = + relative === "" || + (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); + const rightContainsLeft = + reverse === "" || + (!reverse.startsWith(`..${path.sep}`) && reverse !== ".." && !path.isAbsolute(reverse)); + if (leftContainsRight || rightContainsLeft) { + throw new Error(`Install VM runtime paths overlap: ${left[0]} and ${right[0]}.`); + } + } + } +} + +export interface DockerRunPaths { + cacheDir: string; + fixtureDir: string; + outputDir: string; +} + +/** Build the least-privilege Docker command used only by the explicit VM runner. */ +export function buildDockerRunCommand( + image: string, + paths: DockerRunPaths, + scenarioIds: readonly string[], + hostIdentity: { uid: number; gid: number }, +) { + for (const mountPath of Object.values(paths)) { + if (/[,\0-\x1f\x7f]/.test(mountPath)) { + throw new Error(`Unsafe Docker bind path for install VM: ${mountPath}`); + } + } + if (scenarioIds.some((id) => !SCENARIO_ID_PATTERN.test(id))) { + throw new Error("Unsafe install VM scenario id in Docker command."); + } + return [ + "docker", + "run", + "--rm", + "--cap-drop=ALL", + "--cap-add=NET_ADMIN", + "--cap-add=CHOWN", + "--cap-add=DAC_OVERRIDE", + "--device=/dev/kvm", + "--device=/dev/net/tun", + "--security-opt=no-new-privileges", + "--sysctl=net.ipv4.ip_forward=1", + "--read-only", + "--tmpfs=/tmp:rw,nosuid,nodev,mode=1777", + "--tmpfs=/run:rw,nosuid,nodev,mode=755", + `--env=INSTALL_VM_SCENARIOS=${scenarioIds.join(",")}`, + `--env=HOST_UID=${hostIdentity.uid}`, + `--env=HOST_GID=${hostIdentity.gid}`, + `--mount=type=bind,src=${paths.cacheDir},dst=/cache`, + `--mount=type=bind,src=${paths.fixtureDir},dst=/fixtures,readonly`, + `--mount=type=bind,src=${paths.outputDir},dst=/artifacts`, + image, + ]; +} diff --git a/test/cli/install-vm/controller-deps/package-lock.json b/test/cli/install-vm/controller-deps/package-lock.json new file mode 100644 index 000000000..b36bcd76b --- /dev/null +++ b/test/cli/install-vm/controller-deps/package-lock.json @@ -0,0 +1,3628 @@ +{ + "name": "hunk-install-vm-controller", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hunk-install-vm-controller", + "version": "1.0.0", + "dependencies": { + "verdaccio": "6.10.1" + } + }, + "node_modules/@cypress/request": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-4.0.1.tgz", + "integrity": "sha512-y20e+e6dFYkOUUJLVUZTsJRuTiXZaUQ32WD+R/ux/HBybbTx4ge7cNINcua0pU8+SNkKuRbOF12mBmzuzM8n5w==", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~4.0.4", + "http-signature": "~1.4.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "performance-now": "^2.1.0", + "qs": "^6.15.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "^5.0.0", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">= 14.17.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-8.1.0.tgz", + "integrity": "sha512-2SX/1jW6CIMAiebvVv5ZInoCEuWQmMyBoJXXGC6Vjakjp/fpxP5eHs7/V6WKuPEIbuK06+VpjH+vjLQhr98rDQ==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, + "node_modules/@verdaccio/auth": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@verdaccio/auth/-/auth-8.1.3.tgz", + "integrity": "sha512-6fI5jKyu7p8qRDLhGgNAa+gwqe+AEpXOcmVUUv6GLQBPY+lovKgfl5Sui72CTRzrkKPg7zNWS+T2tvODR6nIBw==", + "license": "MIT", + "dependencies": { + "@verdaccio/config": "8.3.0", + "@verdaccio/core": "8.3.0", + "@verdaccio/loaders": "8.1.3", + "@verdaccio/signature": "8.1.3", + "debug": "4.4.3", + "lodash": "4.18.1", + "verdaccio-htpasswd": "13.1.3" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/config": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@verdaccio/config/-/config-8.3.0.tgz", + "integrity": "sha512-kzjh1p/VmXjeJQ3X8D630OU+q8QUjhECaWZs0O1AzpSVjWJhQP4jjQmVBtF7a/oNGLFzJ+RnU5KUh+lERRkSRQ==", + "license": "MIT", + "dependencies": { + "@verdaccio/core": "8.3.0", + "debug": "4.4.3", + "js-yaml": "5.2.2", + "lodash": "4.18.1" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/core": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@verdaccio/core/-/core-8.3.0.tgz", + "integrity": "sha512-axlsbrXnENRM1+VeTNbTrR0VOD+9pBxA8sN+41rpt9UfThIQhDgf89YPPlCfu/ks/LH1KTcSzhR7nZuDYypeCw==", + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "http-errors": "2.0.1", + "http-status-codes": "2.3.0", + "lodash": "4.18.1", + "minimatch": "10.2.6", + "process-warning": "1.0.0", + "semver": "7.8.5" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/file-locking": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@verdaccio/file-locking/-/file-locking-13.1.0.tgz", + "integrity": "sha512-rGFfdyCZdgpbkROJJfjOA01R5BFtzrnDAhNIkxxc/yWZ6Cir+MRHpEbN2weOEPAwYDke9Wms4JKhpsStu+iV8Q==", + "license": "MIT", + "dependencies": { + "lockfile": "1.0.4" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/hooks": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/@verdaccio/hooks/-/hooks-8.1.4.tgz", + "integrity": "sha512-B55gNADad6uoo0tAn6cCuE/feXMhUJruE//o814SZqy7X50gbqa+d4+I/RqTgANmvkN+Pvowe0IMCbHH69V8Dw==", + "license": "MIT", + "dependencies": { + "@verdaccio/core": "8.3.0", + "@verdaccio/logger": "8.1.3", + "debug": "4.4.3", + "got": "15.1.0", + "handlebars": "4.7.9" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/loaders": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@verdaccio/loaders/-/loaders-8.1.3.tgz", + "integrity": "sha512-07Iz4Ar4sZUuWoS7/gr5CHuxEvao6Bhs8ZcvoIpxP0DS02QQK18LrDKn6BXCz8nQs3BbPXEghaQk/2kpC1EzpA==", + "license": "MIT", + "dependencies": { + "@verdaccio/core": "8.3.0", + "debug": "4.4.3", + "lodash": "4.18.1" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/local-storage-legacy": { + "version": "11.4.3", + "resolved": "https://registry.npmjs.org/@verdaccio/local-storage-legacy/-/local-storage-legacy-11.4.3.tgz", + "integrity": "sha512-TV8hQ8E+meh0pnQ1IhP7B3psOcUd5RDwITrCtf3FLKL18FlBJHiZZqZYG0JuTR4fCzmw5RX6ppQTDuWl7KJ8xw==", + "license": "MIT", + "dependencies": { + "@verdaccio/core": "8.3.0", + "@verdaccio/file-locking": "13.1.0", + "@verdaccio/streams": "10.3.0", + "debug": "4.4.3", + "globby": "11.1.0", + "lodash": "4.18.1", + "lowdb": "1.0.0", + "mkdirp": "1.0.4", + "sanitize-filename": "1.6.4" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/logger": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@verdaccio/logger/-/logger-8.1.3.tgz", + "integrity": "sha512-+VucLvCr8ic08kJsOmYsv1FQ5hfvI1vbu61bGPlf8tnwJGdCvtw4DupzShbuvgwjmsix1t0kxGF3ipWVODrMEw==", + "license": "MIT", + "dependencies": { + "@verdaccio/logger-commons": "8.1.3", + "pino": "9.14.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/logger-commons": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@verdaccio/logger-commons/-/logger-commons-8.1.3.tgz", + "integrity": "sha512-e8W0Ac8/nq+BBn8nKbD30X6XbEszVQKtO8PTjvOXzzVqbVq8O+P4dTeAGqCH9FOurOQY3xhJfSV6DT2ODK1gUg==", + "license": "MIT", + "dependencies": { + "@verdaccio/core": "8.3.0", + "@verdaccio/logger-prettify": "8.1.0", + "colorette": "2.0.20", + "debug": "4.4.3" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/logger-prettify": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@verdaccio/logger-prettify/-/logger-prettify-8.1.0.tgz", + "integrity": "sha512-Mriivx1LPx8/8ux0MlNoj/FtxQiQmf5BG2casfWEf9R7jzBDAUCqQJUR4s0PaV2Mlc9cdHcETOucx3RGPuWHeQ==", + "license": "MIT", + "dependencies": { + "colorette": "2.0.20", + "dayjs": "1.11.18", + "lodash": "4.18.1", + "on-exit-leak-free": "2.1.2", + "pino-abstract-transport": "1.2.0", + "sonic-boom": "3.8.1" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/middleware": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@verdaccio/middleware/-/middleware-8.1.3.tgz", + "integrity": "sha512-R2ApL57L7TKM1iPOUmYpVoLm1xA0C4x6gvyKUfuGFEf0xYtlaUbway+O8bzIjzJ9h0+uss3ZCEeOFxvFVIY+Zg==", + "license": "MIT", + "dependencies": { + "@verdaccio/config": "8.3.0", + "@verdaccio/core": "8.3.0", + "@verdaccio/url": "13.1.3", + "debug": "4.4.3", + "express": "4.22.2", + "express-rate-limit": "5.5.1", + "lodash": "4.18.1", + "lru-cache": "7.18.3" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/package-filter": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@verdaccio/package-filter/-/package-filter-13.2.1.tgz", + "integrity": "sha512-AnuHwDAzFXdtV6bpWwgTsCoLuZiHSKC161RH7HwA+0yU2KUrvKpADU8l0CxScQmhz+2yd1+orPZNVigjYMyz7A==", + "license": "MIT", + "dependencies": { + "@verdaccio/core": "8.3.0", + "debug": "4.4.3", + "semver": "7.8.5" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/search-indexer": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@verdaccio/search-indexer/-/search-indexer-8.1.0.tgz", + "integrity": "sha512-N0vHWnSCZVEU3ffvbH/g4cRvGkXO+FJQcNggdY1wxT8LP7K6NJ0F6aq7jSF3ebW5IokdQMJuqWTNx18h8dgrsg==", + "license": "MIT", + "dependencies": { + "debug": "4.4.3", + "fuse.js": "7.3.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/signature": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@verdaccio/signature/-/signature-8.1.3.tgz", + "integrity": "sha512-h1HwxOXEf7gdjvuxeLH3R2ppoAXI16YV846YpdLZCEFekmhnwJO2Zb4mnvptIoyWdpDABq+sKJGF3O8mJOTwGQ==", + "license": "MIT", + "dependencies": { + "@verdaccio/config": "8.3.0", + "@verdaccio/core": "8.3.0", + "debug": "4.4.3", + "jsonwebtoken": "9.0.3" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/streams": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@verdaccio/streams/-/streams-10.3.0.tgz", + "integrity": "sha512-MARQAzAgS42GIawkrBVMTV81vRf2yqZmwonnQb9FWeGj+tsMcgpT/f2fWMQm/UubLpWxBBH6oCkmhFd29R2xGA==", + "license": "MIT", + "engines": { + "node": ">=22", + "npm": ">=5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/tarball": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/@verdaccio/tarball/-/tarball-13.1.3.tgz", + "integrity": "sha512-Up/Dz+bWt+658cq9wdrC5qZrMxQx1FHlfivJJdlzJiS95kaiMqUFaT7NOoYWp7fhkbPYmLXgXdfsTW/NdZuCTA==", + "license": "MIT", + "dependencies": { + "@verdaccio/core": "8.3.0", + "@verdaccio/url": "13.1.3", + "debug": "4.4.3", + "gunzip-maybe": "1.4.2", + "tar-stream": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/@verdaccio/ui-theme": { + "version": "9.0.0-next-9.28", + "resolved": "https://registry.npmjs.org/@verdaccio/ui-theme/-/ui-theme-9.0.0-next-9.28.tgz", + "integrity": "sha512-VVI4dhEsQnHa1cikKHATZAuhbldu/qkrng0VSeoDe9ylyvcNOaQGQWWVxMEmpOAr0EO4R49O448eP9o4Vf4kGQ==", + "license": "MIT", + "dependencies": { + "debug": "4.4.3" + } + }, + "node_modules/@verdaccio/url": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/@verdaccio/url/-/url-13.1.3.tgz", + "integrity": "sha512-NansR8Pnp42DGTmuqU+hS14y+AESO6kACuD26oRsRFi6ngvX3WsNHnsSrH4sCdBCzOAQ06jgk7GAwn361eFnEg==", + "license": "MIT", + "dependencies": { + "@verdaccio/core": "8.3.0", + "debug": "4.4.3", + "validator": "13.15.26" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/apache-md5": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/apache-md5/-/apache-md5-1.1.8.tgz", + "integrity": "sha512-FCAJojipPn0bXjuEpjOOOMN8FZDkxfWWp4JGN9mifU2IhxvKyXZYqpzPHdnTSUpmPDy+tsslB6Z1g+Vg6nVbYA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", + "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserify-zlib": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", + "integrity": "sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==", + "license": "MIT", + "dependencies": { + "pako": "~0.2.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/byte-counter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz", + "integrity": "sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "13.0.19", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.19.tgz", + "integrity": "sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.2.0", + "get-stream": "^9.0.1", + "http-cache-semantics": "^4.2.0", + "keyv": "^5.6.0", + "mimic-response": "^4.0.0", + "normalize-url": "^8.1.1", + "responselike": "^4.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/chunk-data": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chunk-data/-/chunk-data-0.1.0.tgz", + "integrity": "sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz", + "integrity": "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==", + "license": "MIT", + "dependencies": { + "mimic-response": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.5.1.tgz", + "integrity": "sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg==", + "license": "MIT" + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuse.js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", + "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "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/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/got/-/got-15.1.0.tgz", + "integrity": "sha512-DG+DAAkRtno+oDr/GBsliAkhN9+zczOPM5qXk3efDZY3qvyRnZU+NwQlb/IOY6cSnxxTR9z3aHCcShJyjb0hJA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^8.0.0", + "byte-counter": "^0.1.0", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^13.0.18", + "chunk-data": "^0.1.0", + "decompress-response": "^10.0.0", + "http2-wrapper": "^2.2.1", + "keyv": "^5.6.0", + "lowercase-keys": "^4.0.1", + "responselike": "^4.0.2", + "type-fest": "^5.6.0", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/gunzip-maybe": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", + "integrity": "sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==", + "license": "MIT", + "dependencies": { + "browserify-zlib": "^0.1.4", + "is-deflate": "^1.0.0", + "is-gzip": "^1.0.0", + "peek-stream": "^1.1.0", + "pumpify": "^1.3.3", + "through2": "^2.0.3" + }, + "bin": { + "gunzip-maybe": "bin.js" + } + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-signature": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", + "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^2.0.2", + "sshpk": "^1.18.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-deflate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-deflate/-/is-deflate-1.0.0.tgz", + "integrity": "sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-gzip": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-gzip/-/is-gzip-1.0.0.tgz", + "integrity": "sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", + "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsprim": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", + "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/lockfile": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz", + "integrity": "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==", + "license": "ISC", + "dependencies": { + "signal-exit": "^3.0.2" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lowdb": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-1.0.0.tgz", + "integrity": "sha512-2+x8esE/Wb9SQ1F9IHaYWfsC9FIecLOPrK4g17FGEayjUWH172H6nwicRovGvSE2CPZouc2MCIqCI7h9d+GftQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.3", + "is-promise": "^2.1.0", + "lodash": "4", + "pify": "^3.0.0", + "steno": "^0.4.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lowercase-keys": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-4.0.1.tgz", + "integrity": "sha512-wI9Nui/L8VfADa/cr/7NQruaASk1k23/Uh1khQ02BCVYiiy8F4AhOGnQzJy3Fl/c44GnYSbZHv8g7EcG3kJ1Qg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/peek-stream": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz", + "integrity": "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "duplexify": "^3.5.0", + "through2": "^2.0.3" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-abstract-transport/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-abstract-transport/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pino/node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino/node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pino/node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", + "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pumpify": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", + "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", + "license": "MIT", + "dependencies": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "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/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/responselike": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", + "integrity": "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/responselike/node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "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": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sonic-boom": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz", + "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/steno": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/steno/-/steno-0.4.4.tgz", + "integrity": "sha512-EEHMVYHNXFHfGtgjNITnka0aHhiAlo93F7z2/Pwd+g0teG9CnM3JIINM7hVVB5/rhw9voufD7Wukwgtw2uqh6w==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.3" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/typanion": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", + "license": "MIT", + "workspaces": [ + "website" + ] + }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unix-crypt-td-js": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/unix-crypt-td-js/-/unix-crypt-td-js-1.1.4.tgz", + "integrity": "sha512-8rMeVYWSIyccIJscb9NdCfZKSRBKYTeVnwmiRYT2ulE3qd1RaDQ0xQDP+rI3ccIWbhu/zuo5cgN8z73belNZgw==", + "license": "BSD-3-Clause" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verdaccio": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/verdaccio/-/verdaccio-6.10.1.tgz", + "integrity": "sha512-pcZOrQ44P76y3UrS0S7CiQkHJaA9v5vOQ79zFv0FyQyGpCMvmnBDAeed/iGJZQo/qW38hSqrJizOWBXqC8LqBg==", + "license": "MIT", + "dependencies": { + "@cypress/request": "4.0.1", + "@verdaccio/auth": "8.1.3", + "@verdaccio/config": "8.3.0", + "@verdaccio/core": "8.3.0", + "@verdaccio/hooks": "8.1.4", + "@verdaccio/loaders": "8.1.3", + "@verdaccio/local-storage-legacy": "11.4.3", + "@verdaccio/logger": "8.1.3", + "@verdaccio/middleware": "8.1.3", + "@verdaccio/package-filter": "13.2.1", + "@verdaccio/search-indexer": "8.1.0", + "@verdaccio/signature": "8.1.3", + "@verdaccio/streams": "10.3.0", + "@verdaccio/tarball": "13.1.3", + "@verdaccio/ui-theme": "9.0.0-next-9.28", + "@verdaccio/url": "13.1.3", + "async": "3.2.6", + "clipanion": "4.0.0-rc.4", + "compression": "1.8.1", + "cors": "2.8.6", + "debug": "4.4.3", + "envinfo": "7.21.0", + "express": "4.22.2", + "JSONStream": "1.3.5", + "lodash": "4.18.1", + "lru-cache": "7.18.3", + "mime": "3.0.0", + "semver": "7.8.5", + "verdaccio-audit": "13.1.3", + "verdaccio-htpasswd": "13.1.3" + }, + "bin": { + "verdaccio": "bin/verdaccio" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/verdaccio-audit": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/verdaccio-audit/-/verdaccio-audit-13.1.3.tgz", + "integrity": "sha512-chCJw1+72wBS4heJgL7a08ACNgZEDoQMz3oZo/4nF4mqanV6pny3xU6BwEru8cRQHOiBvmmvMVDfWNVB6HDJ+A==", + "license": "MIT", + "dependencies": { + "@verdaccio/config": "8.3.0", + "@verdaccio/core": "8.3.0", + "express": "4.22.2", + "https-proxy-agent": "5.0.1", + "node-fetch": "cjs" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/verdaccio-htpasswd": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/verdaccio-htpasswd/-/verdaccio-htpasswd-13.1.3.tgz", + "integrity": "sha512-q2od2foCPblMasnUdKPLcNYXN5iXW0Q9Imh58qTpNsvCeYGtlfMqyC+aO+/0wtTGkD+H+mWfbqtb/8G9TMc0DQ==", + "license": "MIT", + "dependencies": { + "@verdaccio/core": "8.3.0", + "@verdaccio/file-locking": "13.1.0", + "apache-md5": "1.1.8", + "bcryptjs": "2.4.3", + "debug": "4.4.3", + "http-errors": "2.0.1", + "unix-crypt-td-js": "1.1.4" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/verdaccio" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/test/cli/install-vm/controller-deps/package.json b/test/cli/install-vm/controller-deps/package.json new file mode 100644 index 000000000..c424ea2b3 --- /dev/null +++ b/test/cli/install-vm/controller-deps/package.json @@ -0,0 +1,8 @@ +{ + "name": "hunk-install-vm-controller", + "version": "1.0.0", + "private": true, + "dependencies": { + "verdaccio": "6.10.1" + } +} diff --git a/test/cli/install-vm/controller.sh b/test/cli/install-vm/controller.sh new file mode 100755 index 000000000..9dacb872b --- /dev/null +++ b/test/cli/install-vm/controller.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +cache=/cache +fixtures=/fixtures +artifacts=/artifacts +run_root=$(mktemp -d /tmp/hunk-install-vm.XXXXXX) +export HOME="$run_root/home" +export npm_config_cache="$run_root/npm-cache" +mkdir -p "$HOME" "$npm_config_cache" +tap=hunkvm0 +subnet=172.16.0.0/30 +controller_ip=172.16.0.1 +guest_ip=172.16.0.2 +fc_pid= +registry_pid= +http_pid= +uplink= +network_ready=0 + +# Let daemons and Firecracker clean up normally, then bound teardown with SIGKILL. +terminate_process() { + local pid=$1 + [[ -n $pid ]] || return 0 + kill -TERM "$pid" 2>/dev/null || return 0 + for ((attempt = 0; attempt < 50; attempt += 1)); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.1 + done + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true +} + +cleanup() { + local status=$? + set +e + terminate_process "$fc_pid" + terminate_process "$registry_pid" + terminate_process "$http_pid" + if [[ $network_ready == 1 ]]; then + iptables -t nat -D POSTROUTING -s "$subnet" -o "$uplink" -j MASQUERADE 2>/dev/null + iptables -D FORWARD -i "$tap" -o "$uplink" -j ACCEPT 2>/dev/null + iptables -D FORWARD -i "$uplink" -o "$tap" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null + fi + ip link del "$tap" 2>/dev/null + rm -rf "$run_root" + if [[ ${HOST_UID:-} =~ ^[0-9]+$ && ${HOST_GID:-} =~ ^[0-9]+$ ]]; then + chown -R "$HOST_UID:$HOST_GID" "$cache" "$artifacts" 2>/dev/null + fi + trap - EXIT + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +mkdir -p "$cache" "$artifacts/scenarios" +# Take temporary ownership inside the container; cleanup restores the invoking host user. +if [[ ${HOST_UID:-} =~ ^[0-9]+$ && ${HOST_GID:-} =~ ^[0-9]+$ ]]; then + chown -R 0:0 "$cache" "$artifacts" +fi +/opt/install-vm/guest/prepare-base-image.sh "$cache" /opt/install-vm/pins.json +ssh-keygen -q -t ed25519 -f "$run_root/id_ed25519" -N '' +chmod 0600 "$run_root/id_ed25519" + +# Keep Hunk fixtures local while proxying only uncached third-party dependencies to npm. +cat >"$run_root/verdaccio.yml" <<'YAML' +storage: /tmp/verdaccio-storage +max_body_size: 100mb +web: + enable: false +auth: + htpasswd: + file: /tmp/verdaccio.htpasswd + max_users: 10 +uplinks: + npmjs: + url: https://registry.npmjs.org/ +packages: + 'hunkdiff': + access: $all + publish: $authenticated + unpublish: $authenticated + 'hunkdiff-*': + access: $all + publish: $authenticated + unpublish: $authenticated + '@*/*': + access: $all + publish: $authenticated + unpublish: $authenticated + proxy: npmjs + '**': + access: $all + publish: $authenticated + unpublish: $authenticated + proxy: npmjs +log: + type: stdout + format: pretty + level: warn +YAML +verdaccio --config "$run_root/verdaccio.yml" --listen 0.0.0.0:4873 >"$artifacts/registry.log" 2>&1 & +registry_pid=$! +python3 -m http.server 18080 --bind 0.0.0.0 --directory "$fixtures/http" >"$artifacts/http.log" 2>&1 & +http_pid=$! + +for ((attempt = 0; attempt < 60; attempt += 1)); do + if curl -fsS http://127.0.0.1:4873/-/ping >/dev/null; then break; fi + kill -0 "$registry_pid" 2>/dev/null || { tail -100 "$artifacts/registry.log" >&2; exit 1; } + sleep 0.5 +done +curl -fsS http://127.0.0.1:4873/-/ping >/dev/null +user_response=$(curl -fsS -X PUT -H 'content-type: application/json' \ + -d '{"name":"hunk-install-vm","password":"hunk-install-vm","email":"install-vm@hunk.dev","type":"user","roles":[]}' \ + http://127.0.0.1:4873/-/user/org.couchdb.user:hunk-install-vm) +token=$(jq -er '.token' <<<"$user_response") +printf '//127.0.0.1:4873/:_authToken=%s\nregistry=http://127.0.0.1:4873/\n' "$token" >"$run_root/npmrc" + +# Reverify staged tarballs at the trust boundary before publishing them into the guest registry. +jq -r '.packages[] | [.sha256, .tarball] | @tsv' "$fixtures/fixture-manifest.json" | while IFS=$'\t' read -r expected tarball; do + [[ $(basename "$tarball") == "$tarball" && $tarball == *.tgz ]] || { + echo "Unsafe fixture tarball name: $tarball" >&2 + exit 1 + } + echo "$expected $fixtures/packages/$tarball" | sha256sum -c - >/dev/null + npm publish "$fixtures/packages/$tarball" \ + --registry http://127.0.0.1:4873 \ + --userconfig "$run_root/npmrc" \ + --tag latest \ + --ignore-scripts >/dev/null +done + +# NAT the guest through the controller namespace; local scenarios later remove the guest route. +uplink=$(ip route show default | awk 'NR == 1 { print $5 }') +[[ -n $uplink ]] || { echo 'Could not resolve controller uplink.' >&2; exit 1; } +ip tuntap add "$tap" mode tap +ip addr add "$controller_ip/30" dev "$tap" +ip link set "$tap" up +[[ $(cat /proc/sys/net/ipv4/ip_forward) == 1 ]] || { + echo 'Controller network namespace does not have IP forwarding enabled.' >&2 + exit 1 +} +iptables -A FORWARD -i "$tap" -o "$uplink" -j ACCEPT +iptables -A FORWARD -i "$uplink" -o "$tap" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +iptables -t nat -A POSTROUTING -s "$subnet" -o "$uplink" -j MASQUERADE +network_ready=1 + +ssh_options=( + -i "$run_root/id_ed25519" + -o BatchMode=yes + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o ConnectTimeout=2 + -o ServerAliveInterval=5 + -o ServerAliveCountMax=3 + -o LogLevel=ERROR +) + +selected=${INSTALL_VM_SCENARIOS:-} +[[ -n $selected ]] || selected=$(jq -r '[.scenarios[].id] | join(",")' /opt/install-vm/scenarios.json) +pnpm_version=$(jq -er '.pnpmVersion' /opt/install-vm/pins.json) +historical_hunkdiff_version=$(jq -er '.historical.hunkdiffVersion' /opt/install-vm/pins.json) +historical_bun_version=$(jq -er '.historical.bunVersion' /opt/install-vm/pins.json) +IFS=',' read -r -a scenario_ids <<<"$selected" + +# Each scenario mutates a fresh sparse clone, so package stores and lifecycle scripts cannot leak. +for scenario_id in "${scenario_ids[@]}"; do + scenario=$(jq -ce --arg id "$scenario_id" '.scenarios[] | select(.id == $id)' /opt/install-vm/scenarios.json) || { + echo "Unknown scenario: $scenario_id" >&2 + exit 1 + } + script=$(jq -r '.script' <<<"$scenario") + profile=$(jq -r '.profile' <<<"$scenario") + network=$(jq -r '.network' <<<"$scenario") + scenario_dir="$artifacts/scenarios/$scenario_id" + mkdir -p "$scenario_dir" + disk="$run_root/$scenario_id.ext4" + socket="$run_root/$scenario_id.socket" + config="$run_root/$scenario_id.json" + cp --reflink=auto --sparse=always "$cache/base/rootfs.base.ext4" "$disk" + debugfs -w -R 'rm /root/.ssh/authorized_keys' "$disk" >/dev/null 2>&1 + debugfs -w -R "write $run_root/id_ed25519.pub /root/.ssh/authorized_keys" "$disk" >/dev/null 2>&1 + cat >"$config" <"$scenario_dir/firecracker.console.log" 2>&1 & + fc_pid=$! + ready=0 + deadline=$((SECONDS + 90)) + while ((SECONDS < deadline)); do + if ssh "${ssh_options[@]}" "root@$guest_ip" true >/dev/null 2>&1; then + ready=1 + break + fi + kill -0 "$fc_pid" 2>/dev/null || break + sleep 1 + done + + guest_status=125 + if [[ $ready == 1 ]]; then + ssh "${ssh_options[@]}" "root@$guest_ip" 'rm -rf /tmp/hunk-install-vm /var/tmp/hunk-install-vm; mkdir -p /tmp/hunk-install-vm /var/tmp/hunk-install-vm' + scp "${ssh_options[@]}" \ + /opt/install-vm/guest/scenario-lib.sh "/opt/install-vm/scenarios/$script" \ + "root@$guest_ip:/tmp/hunk-install-vm/" >/dev/null + # The expanded address is a fixed controller constant. + # shellcheck disable=SC2029 + ssh "${ssh_options[@]}" "root@$guest_ip" \ + "ip route replace default via $controller_ip dev eth0; rm -f /etc/resolv.conf; printf 'nameserver 1.1.1.1\\noptions single-request-reopen\\n' > /etc/resolv.conf" + # Local scenarios can reach controller fixtures over the connected subnet but not the internet. + if [[ $network == local ]]; then + ssh "${ssh_options[@]}" "root@$guest_ip" "ip route del default 2>/dev/null || true" + fi + set +e + timeout --signal=TERM --kill-after=10s 10m \ + ssh "${ssh_options[@]}" "root@$guest_ip" \ + "INSTALL_VM_PROFILE='$profile' PNPM_VERSION='$pnpm_version' HISTORICAL_HUNKDIFF_VERSION='$historical_hunkdiff_version' HISTORICAL_BUN_VERSION='$historical_bun_version' REGISTRY_URL='http://$controller_ip:4873' HTTP_URL='http://$controller_ip:18080' bash '/tmp/hunk-install-vm/$script'" \ + >"$scenario_dir/guest.log" 2>&1 + guest_status=$? + set -e + scp -r "${ssh_options[@]}" "root@$guest_ip:/var/tmp/hunk-install-vm/." "$scenario_dir/" \ + >/dev/null 2>&1 || true + else + printf 'Firecracker guest did not become SSH-ready before the deadline.\n' >"$scenario_dir/guest.log" + fi + + # Missing protocol files become explicit failures instead of disappearing during aggregation. + [[ -f $scenario_dir/assertions.tsv ]] || printf 'guest-protocol\tfailed\tresult artifact\tmissing\tguest did not return structured assertions\n' >"$scenario_dir/assertions.tsv" + [[ -f $scenario_dir/commands.tsv ]] || : >"$scenario_dir/commands.tsv" + [[ -f $scenario_dir/observations.tsv ]] || : >"$scenario_dir/observations.tsv" + terminate_process "$fc_pid" + fc_pid= + rm -f "$disk" "$socket" "$config" + finished_ms=$(date +%s%3N) + jq -n \ + --arg id "$scenario_id" \ + --argjson exitCode "$guest_status" \ + --argjson durationMs "$((finished_ms - started_ms))" \ + '{id: $id, exitCode: $exitCode, durationMs: $durationMs}' \ + >"$scenario_dir/result.json" + echo "[$scenario_id] guest exit $guest_status" +done + +jq -n \ + --arg firecracker "$("$cache/base/firecracker" --version | head -n 1)" \ + --arg kernel "$(jq -r '.kernel.version' /opt/install-vm/pins.json)" \ + --arg node "$(node --version)" \ + --arg npm "$(npm --version)" \ + --arg pnpm "$pnpm_version" \ + --arg verdaccio "$(verdaccio --version)" \ + '{firecracker: $firecracker, kernel: $kernel, node: $node, npm: $npm, pnpm: $pnpm, verdaccio: $verdaccio}' \ + >"$artifacts/tools.json" diff --git a/test/cli/install-vm/guest/prepare-base-image.sh b/test/cli/install-vm/guest/prepare-base-image.sh new file mode 100755 index 000000000..5c1a39ffa --- /dev/null +++ b/test/cli/install-vm/guest/prepare-base-image.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +cache=${1:?cache directory required} +pins=${2:?pins file required} +mkdir -p "$cache/downloads" "$cache/base" + +pin_value() { + jq -er "$1" "$pins" +} + +# Download through a partial file so an interrupted fetch never becomes a trusted cache hit. +download_checked() { + local name=$1 url=$2 expected=$3 + local destination="$cache/downloads/$name" + if [[ -f $destination ]]; then + if echo "$expected $destination" | sha256sum -c - >/dev/null; then + return + fi + rm -f "$destination" + echo "Cached $name failed its pinned checksum; downloading it again." >&2 + fi + local partial="${destination}.partial.$$" + trap 'rm -f "$partial"' RETURN + curl --fail --show-error --location --connect-timeout 15 --max-time 600 --retry 3 \ + -o "$partial" "$url" + echo "$expected $partial" | sha256sum -c - >/dev/null + mv "$partial" "$destination" + trap - RETURN +} + +fc_version=$(pin_value '.firecracker.version') +download_checked \ + "firecracker-${fc_version}.tgz" \ + "$(pin_value '.firecracker.url')" \ + "$(pin_value '.firecracker.sha256')" +download_checked vmlinux "$(pin_value '.kernel.url')" "$(pin_value '.kernel.sha256')" +download_checked rootfs.squashfs "$(pin_value '.rootfs.url')" "$(pin_value '.rootfs.sha256')" +download_checked node.tar.xz "$(pin_value '.node.url')" "$(pin_value '.node.sha256')" + +extract_dir=$(mktemp -d) +trap 'rm -rf "$extract_dir"' RETURN +tar --no-same-owner -xzf "$cache/downloads/firecracker-${fc_version}.tgz" -C "$extract_dir" +install -m 0755 \ + "$extract_dir/release-v${fc_version}-x86_64/firecracker-v${fc_version}-x86_64" \ + "$cache/base/firecracker.partial.$$" +mv "$cache/base/firecracker.partial.$$" "$cache/base/firecracker" +trap - RETURN +rm -rf "$extract_dir" +cp "$cache/downloads/vmlinux" "$cache/base/vmlinux.partial" +mv "$cache/base/vmlinux.partial" "$cache/base/vmlinux" + +# Older harness revisions cached an SSH identity; current runs use an ephemeral key. +rm -f "$cache/base/id_ed25519" "$cache/base/id_ed25519.pub" + +base=$cache/base/rootfs.base.ext4 +base_digest=$cache/base/rootfs.base.ext4.sha256 +base_identity=$cache/base/rootfs.base.identity +# Rebuild whenever the inputs or image-building logic changes, not merely when the file is absent. +identity=$( + { + sha256sum "$pins" "$0" + jq -c '{rootfs: .rootfs.sha256, node: .node.sha256, kernel: .kernel.sha256}' "$pins" + } | sha256sum | cut -d' ' -f1 +) +if [[ ! -f $base_identity || $(cat "$base_identity") != "$identity" ]]; then + rm -f "$base" "$base_digest" "$base_identity" +fi +if [[ -f $base && -f $base_digest ]]; then + (cd "$(dirname "$base")" && sha256sum -c "$(basename "$base_digest")" >/dev/null) || { + rm -f "$base" "$base_digest" "$base_identity" + } +fi + +if [[ ! -f $base ]]; then + # Expand the read-only rootfs into a sparse ext4 image that each scenario can clone and mutate. + build_dir=$(mktemp -d) + partial="${base}.partial.$$" + cleanup_base() { + rm -rf "$build_dir" "$partial" + } + trap cleanup_base RETURN + unsquashfs -d "$build_dir/root" "$cache/downloads/rootfs.squashfs" >/dev/null + mkdir -p "$build_dir/root/root/.ssh" "$build_dir/root/opt" "$build_dir/root/etc/systemd/network" + : >"$build_dir/root/root/.ssh/authorized_keys" + chmod 0700 "$build_dir/root/root/.ssh" + chmod 0600 "$build_dir/root/root/.ssh/authorized_keys" + tar --no-same-owner -xJf "$cache/downloads/node.tar.xz" -C "$build_dir/root/opt" + node_version=$(pin_value '.node.version') + ln -s "node-v${node_version}-linux-x64" "$build_dir/root/opt/node" + cat >"$build_dir/root/etc/systemd/network/10-eth0.network" <<'NETWORK' +[Match] +Name=eth0 + +[Network] +Address=172.16.0.2/30 +Gateway=172.16.0.1 +DNS=1.1.1.1 +NETWORK + printf 'nameserver 1.1.1.1\noptions single-request-reopen\n' >"$build_dir/root/etc/resolv.conf" + truncate -s 4G "$partial" + mkfs.ext4 -q -d "$build_dir/root" -F "$partial" + e2fsck -fn "$partial" >/dev/null + mv "$partial" "$base" + (cd "$(dirname "$base")" && sha256sum "$(basename "$base")" >"$(basename "$base_digest").partial") + printf '%s\n' "$identity" >"${base_identity}.partial" + mv "${base_digest}.partial" "$base_digest" + mv "${base_identity}.partial" "$base_identity" + trap - RETURN + rm -rf "$build_dir" +fi + +(cd "$(dirname "$base")" && sha256sum -c "$(basename "$base_digest")" >/dev/null) diff --git a/test/cli/install-vm/guest/scenario-lib.sh b/test/cli/install-vm/guest/scenario-lib.sh new file mode 100755 index 000000000..beb1cfc03 --- /dev/null +++ b/test/cli/install-vm/guest/scenario-lib.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +set -u +umask 077 + +artifact_dir=/var/tmp/hunk-install-vm +command_dir="$artifact_dir/commands" +mkdir -p "$command_dir" +: >"$artifact_dir/assertions.tsv" +: >"$artifact_dir/commands.tsv" +: >"$artifact_dir/observations.tsv" +scenario_failures=0 +last_command_status=0 + +# The host parses these TSV files strictly, so keep guest-controlled output on one field-safe line. +sanitize_field() { + printf '%s' "$1" | tr '\t\r\n' ' ' +} + +record_assertion() { + local id=$1 status=$2 expected=$3 actual=$4 message=$5 + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$(sanitize_field "$id")" \ + "$(sanitize_field "$status")" \ + "$(sanitize_field "$expected")" \ + "$(sanitize_field "$actual")" \ + "$(sanitize_field "$message")" >>"$artifact_dir/assertions.tsv" + [[ $status == passed ]] || scenario_failures=1 +} + +record_command() { + local id=$1 status=$2 expectation=$3 actual=$4 log_path=$5 + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$(sanitize_field "$id")" \ + "$(sanitize_field "$status")" \ + "$(sanitize_field "$expectation")" \ + "$(sanitize_field "$actual")" \ + "$(sanitize_field "$log_path")" >>"$artifact_dir/commands.tsv" +} + +record_observation() { + local key=$1 value=$2 + printf '%s\t%s\n' "$(sanitize_field "$key")" "$(sanitize_field "$value")" \ + >>"$artifact_dir/observations.tsv" +} + +# Record mismatches without aborting so one scenario returns all useful assertion evidence. +run_expect() { + local id=$1 expected=$2 + shift 2 + local log="$command_dir/$id.log" actual + "$@" >"$log" 2>&1 + actual=$? + if [[ $actual == "$expected" ]]; then + record_command "$id" passed "exit $expected" "$actual" "commands/$id.log" + record_assertion "$id" passed "exit $expected" "exit $actual" "command matched expected exit" + else + record_command "$id" failed "exit $expected" "$actual" "commands/$id.log" + record_assertion "$id" failed "exit $expected" "exit $actual" "see commands/$id.log" + fi + return 0 +} + +run_expect_nonzero() { + local id=$1 + shift + local log="$command_dir/$id.log" actual + "$@" >"$log" 2>&1 + actual=$? + if [[ $actual -ne 0 ]]; then + record_command "$id" passed "nonzero exit" "$actual" "commands/$id.log" + record_assertion "$id" passed "nonzero exit" "exit $actual" "expected failure occurred" + else + record_command "$id" failed "nonzero exit" 0 "commands/$id.log" + record_assertion "$id" failed "nonzero exit" "exit 0" "unexpected success; see commands/$id.log" + fi + return 0 +} + +run_capture() { + local id=$1 + shift + local log="$command_dir/$id.log" + "$@" >"$log" 2>&1 + last_command_status=$? + record_command "$id" passed "observed exit" "$last_command_status" "commands/$id.log" + return 0 +} + +assert_contains() { + local id=$1 file=$2 marker=$3 + if grep -Fq -- "$marker" "$file"; then + record_assertion "$id" passed "contains $marker" present "marker found" + else + record_assertion "$id" failed "contains $marker" missing "see ${file#"$artifact_dir"/}" + fi +} + +assert_not_contains() { + local id=$1 file=$2 marker=$3 + if grep -Fq -- "$marker" "$file"; then + record_assertion "$id" failed "does not contain $marker" present "unexpected marker" + else + record_assertion "$id" passed "does not contain $marker" absent "marker absent" + fi +} + +assert_equals() { + local id=$1 expected=$2 actual=$3 + if [[ $actual == "$expected" ]]; then + record_assertion "$id" passed "$expected" "$actual" "values match" + else + record_assertion "$id" failed "$expected" "$actual" "values differ" + fi +} + +assert_path_state() { + local id=$1 expected=$2 target=$3 + local actual=missing + [[ -f $target ]] && actual="file" + [[ -d $target ]] && actual="directory" + [[ -x $target ]] && actual="executable" + if [[ $actual == "$expected" ]]; then + record_assertion "$id" passed "$expected" "$actual" "$target" + else + record_assertion "$id" failed "$expected" "$actual" "$target" + fi +} + +assert_tree_has_no_bun_packages() { + local id=$1 root=$2 found + found=$(find "$root" -type d \( -path '*/node_modules/bun' -o -path '*/node_modules/@oven/bun-*' \) -print -quit 2>/dev/null) + if [[ -z $found ]]; then + record_assertion "$id" passed "no bun or @oven/bun-* package" absent "$root" + else + record_assertion "$id" failed "no bun or @oven/bun-* package" "$found" "unexpected package" + fi +} + +# Build an isolated user/package-manager environment and deliberately hide Node for minimal profiles. +setup_profile() { + export HOME=/root/scenario + export XDG_CONFIG_HOME="$HOME/.config" + export XDG_CACHE_HOME="$HOME/.cache" + export npm_config_prefix="$HOME/npm" + export npm_config_cache="$HOME/npm-cache" + mkdir -p "$HOME" "$npm_config_prefix" "$npm_config_cache" + if [[ ${INSTALL_VM_PROFILE:-node} == node ]]; then + export PATH="/opt/node/bin:$npm_config_prefix/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + else + export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + fi + { + uname -a + printf 'PATH=%s\n' "$PATH" + command -v node >/dev/null 2>&1 && node --version || printf 'node=absent\n' + command -v bun >/dev/null 2>&1 && bun --version || printf 'bun=absent\n' + } >"$artifact_dir/environment.txt" 2>&1 + if command -v node >/dev/null 2>&1; then + record_observation nodeVersion "$(node --version)" + record_observation npmVersion "$(npm --version)" + fi +} + +remove_platform_package() { + rm -rf \ + "$npm_config_prefix/lib/node_modules/hunkdiff/node_modules/hunkdiff-linux-x64" \ + "$npm_config_prefix/lib/node_modules/hunkdiff-linux-x64" +} + +# Recreate the shared global virtual-store configuration involved in issue #866. +install_pnpm() { + local version=${PNPM_VERSION:?PNPM_VERSION is required} + npm install -g "pnpm@$version" --registry "$REGISTRY_URL" >"$command_dir/install-pnpm.log" 2>&1 + export PNPM_HOME="$HOME/pnpm/bin" + mkdir -p "$PNPM_HOME" + export PATH="$PNPM_HOME:$PATH" + pnpm config set globalBinDir "$PNPM_HOME" + pnpm config set globalDir "$HOME/pnpm/global" + pnpm config set storeDir "$HOME/pnpm/store" + pnpm config set enableGlobalVirtualStore true + pnpm config set dangerouslyAllowAllBuilds true + pnpm config set minimumReleaseAge 0 + pnpm config set registry "$REGISTRY_URL" + record_observation pnpmVersion "$(pnpm --version)" +} + +scenario_finish() { + if [[ $scenario_failures == 0 ]]; then + exit 0 + fi + exit 1 +} diff --git a/test/cli/install-vm/pins.json b/test/cli/install-vm/pins.json new file mode 100644 index 000000000..d4952028d --- /dev/null +++ b/test/cli/install-vm/pins.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "controllerImage": "ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517", + "verdaccioVersion": "6.10.1", + "firecracker": { + "version": "1.16.1", + "url": "https://github.com/firecracker-microvm/firecracker/releases/download/v1.16.1/firecracker-v1.16.1-x86_64.tgz", + "sha256": "382a02a869e4d6d5cb14c40577f9545e8458021ea8b0b2d3fc10ec14d9c242e6" + }, + "kernel": { + "version": "6.18.44", + "url": "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/20260826-761f88fbb951-0/x86_64/vmlinux-6.18.44", + "sha256": "435466ec838656f59e464ce941e7fe9f3697d5da6a73c5e5dad60dae5ad93ceb" + }, + "rootfs": { + "version": "ubuntu-24.04", + "url": "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/20260826-761f88fbb951-0/x86_64/ubuntu-24.04.squashfs", + "sha256": "1e424bde9bfaaca5c74fcc2b1c78be6285ae515528ef4acc9e64ff78b4225b48" + }, + "node": { + "version": "24.14.1", + "url": "https://nodejs.org/dist/v24.14.1/node-v24.14.1-linux-x64.tar.xz", + "sha256": "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be" + }, + "pnpmVersion": "11.23.0", + "historical": { + "hunkdiffVersion": "0.19.0", + "bunVersion": "1.4.0" + } +} diff --git a/test/cli/install-vm/preflight.ts b/test/cli/install-vm/preflight.ts new file mode 100644 index 000000000..53355c495 --- /dev/null +++ b/test/cli/install-vm/preflight.ts @@ -0,0 +1,52 @@ +import { accessSync, constants, statfsSync } from "node:fs"; + +/** Return all actionable host prerequisite failures without mutating the machine. */ +export async function collectInstallVmPreflightFailures( + runtimeRoot: string, + options: { + platform?: NodeJS.Platform; + arch?: string; + dockerProbe?: () => number | Promise; + accessProbe?: (target: string) => void; + availableBytes?: number; + } = {}, +) { + const failures: string[] = []; + if ( + (options.platform ?? process.platform) !== "linux" || + (options.arch ?? process.arch) !== "x64" + ) { + failures.push("Firecracker install scenarios require a Linux x86_64 host."); + } + const accessProbe = + options.accessProbe ?? + ((target: string) => accessSync(target, constants.R_OK | constants.W_OK)); + for (const device of ["/dev/kvm", "/dev/net/tun"]) { + try { + accessProbe(device); + } catch { + failures.push(`${device} must exist and be readable/writable by the current user.`); + } + } + const dockerProbe = + options.dockerProbe ?? + (async () => { + const proc = Bun.spawn(["docker", "info"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + return await proc.exited; + }); + if ((await dockerProbe()) !== 0) { + failures.push("Docker CLI and daemon access are required without sudo."); + } + const availableBytes = + options.availableBytes ?? + (() => { + const stats = statfsSync(runtimeRoot, { bigint: true }); + return Number(stats.bavail * stats.bsize); + })(); + if (availableBytes < 6 * 1024 ** 3) failures.push("At least 6 GiB of free disk is required."); + return failures; +} diff --git a/test/cli/install-vm/prepare-fixtures.test.ts b/test/cli/install-vm/prepare-fixtures.test.ts new file mode 100644 index 000000000..70839fb7d --- /dev/null +++ b/test/cli/install-vm/prepare-fixtures.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + buildSyntheticPackageManifests, + computeInstallVmFixtureSourceIdentity, + CURL_BAD_CHECKSUM_VERSION, + CURL_TRUNCATED_VERSION, + CURL_UNAVAILABLE_VERSION, + FIXTURE_VERSION_A, + FIXTURE_VERSION_B, + verifyInstallVmFixtures, + type InstallVmFixtureManifest, +} from "./prepare-fixtures"; + +/** Hash one small test fixture. */ +function sha256(filePath: string) { + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +/** Write a complete lightweight fixture topology for verification tests. */ +function writeTestFixtures(repo: string, fixtures: string) { + const packageRoot = path.join(fixtures, "packages"); + const httpRoot = path.join(fixtures, "http"); + mkdirSync(packageRoot, { recursive: true }); + mkdirSync(httpRoot, { recursive: true }); + const versions = ["1.0.0", FIXTURE_VERSION_A, FIXTURE_VERSION_B]; + const packages = versions.flatMap((version) => + ["hunkdiff-linux-x64", "hunkdiff"].map((name) => { + const tarball = `${name}-${version}.tgz`; + const tarballPath = path.join(packageRoot, tarball); + writeFileSync(tarballPath, `${name}@${version}\n`); + return { name, version, tarball, sha256: sha256(tarballPath) }; + }), + ); + const manifest: InstallVmFixtureManifest = { + schemaVersion: 1, + sourceIdentity: computeInstallVmFixtureSourceIdentity(repo), + currentVersion: "1.0.0", + versionA: FIXTURE_VERSION_A, + versionB: FIXTURE_VERSION_B, + packages, + }; + const manifestBytes = `${JSON.stringify(manifest, null, 2)}\n`; + writeFileSync(path.join(fixtures, "fixture-manifest.json"), manifestBytes); + writeFileSync(path.join(httpRoot, "fixture-manifest.json"), manifestBytes); + const curlVersions = `${JSON.stringify( + { + badChecksum: CURL_BAD_CHECKSUM_VERSION, + truncated: CURL_TRUNCATED_VERSION, + unavailable: CURL_UNAVAILABLE_VERSION, + }, + null, + 2, + )}\n`; + writeFileSync(path.join(fixtures, "curl-versions.json"), curlVersions); + writeFileSync(path.join(httpRoot, "curl-versions.json"), curlVersions); + writeFileSync(path.join(httpRoot, "latest"), '{"tag_name":"v1.0.0"}\n'); + writeFileSync(path.join(httpRoot, "install.sh"), "#!/bin/sh\n"); + + const archiveName = "hunkdiff-linux-x64.tar.gz"; + for (const [version, digestOverride] of [ + ["1.0.0", undefined], + [CURL_BAD_CHECKSUM_VERSION, "0".repeat(64)], + [CURL_TRUNCATED_VERSION, undefined], + ] as const) { + const directory = path.join(httpRoot, "download", `v${version}`); + mkdirSync(directory, { recursive: true }); + const archive = path.join(directory, archiveName); + writeFileSync(archive, `archive ${version}\n`); + writeFileSync( + path.join(directory, "SHA256SUMS"), + `${digestOverride ?? sha256(archive)} ${archiveName}\n`, + ); + } + return manifest; +} + +describe("install VM package fixtures", () => { + test("builds two distinct Linux x64 package topologies without mandatory Bun", () => { + const fixtureA = buildSyntheticPackageManifests(FIXTURE_VERSION_A); + const fixtureB = buildSyntheticPackageManifests(FIXTURE_VERSION_B); + + expect(fixtureA.meta.version).not.toBe(fixtureB.meta.version); + expect("dependencies" in fixtureA.meta).toBe(false); + expect(fixtureA.meta.optionalDependencies).toEqual({ + "hunkdiff-linux-x64": FIXTURE_VERSION_A, + }); + expect(fixtureB.meta.optionalDependencies).toEqual({ + "hunkdiff-linux-x64": FIXTURE_VERSION_B, + }); + expect(fixtureA.platform).toMatchObject({ + name: "hunkdiff-linux-x64", + version: FIXTURE_VERSION_A, + os: ["linux"], + cpu: ["x64"], + bin: { hunk: "bin/hunk" }, + }); + }); + + test("checkout identity includes root documentation and workspace package inputs", () => { + const repo = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-identity-")); + try { + writeFileSync(path.join(repo, "package.json"), '{"version":"1.0.0"}\n'); + const initial = computeInstallVmFixtureSourceIdentity(repo); + writeFileSync(path.join(repo, "README.md"), "docs\n"); + const withReadme = computeInstallVmFixtureSourceIdentity(repo); + mkdirSync(path.join(repo, "packages", "fixture"), { recursive: true }); + writeFileSync(path.join(repo, "packages", "fixture", "index.ts"), "export {};\n"); + const withPackage = computeInstallVmFixtureSourceIdentity(repo); + expect(withReadme).not.toBe(initial); + expect(withPackage).not.toBe(withReadme); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("verifies checkout identity, exact package set, duplicate manifests, and tarball digests", () => { + const repo = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-source-")); + const fixtures = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-fixtures-")); + try { + mkdirSync(path.join(repo, "test", "cli", "install-vm"), { recursive: true }); + writeFileSync(path.join(repo, "package.json"), '{"name":"fixture","version":"1.0.0"}\n'); + writeFileSync(path.join(repo, "test", "cli", "install-vm", "source.txt"), "source\n"); + const manifest = writeTestFixtures(repo, fixtures); + expect(verifyInstallVmFixtures(repo, fixtures).sourceIdentity).toBe(manifest.sourceIdentity); + + const httpManifest = path.join(fixtures, "http", "fixture-manifest.json"); + writeFileSync(httpManifest, `${JSON.stringify({ ...manifest, currentVersion: "2.0.0" })}\n`); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow( + "HTTP fixture manifest differs", + ); + + const manifestBytes = `${JSON.stringify(manifest, null, 2)}\n`; + writeFileSync(httpManifest, manifestBytes); + const drifted = { ...manifest, packages: manifest.packages.slice(0, -1) }; + writeFileSync(path.join(fixtures, "fixture-manifest.json"), `${JSON.stringify(drifted)}\n`); + writeFileSync(httpManifest, `${JSON.stringify(drifted)}\n`); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("exactly six"); + + writeFileSync(path.join(fixtures, "fixture-manifest.json"), manifestBytes); + writeFileSync(httpManifest, manifestBytes); + const tarball = path.join(fixtures, "packages", manifest.packages[0]!.tarball); + writeFileSync(tarball, "tampered\n"); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow("checksum mismatch"); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(fixtures, { recursive: true, force: true }); + } + }); +}); diff --git a/test/cli/install-vm/prepare-fixtures.ts b/test/cli/install-vm/prepare-fixtures.ts new file mode 100644 index 000000000..d945df4bb --- /dev/null +++ b/test/cli/install-vm/prepare-fixtures.ts @@ -0,0 +1,437 @@ +import { + chmodSync, + copyFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + readlinkSync, + renameSync, + rmSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { + assertNoMandatoryBunDependency, + buildOptionalDependencyMap, + buildPlatformPackageManifest, + getPlatformPackageSpecForHost, + releaseNpmDir, +} from "../../../scripts/prebuilt-package-helpers"; +import { stagePrebuiltArtifact } from "../../../scripts/build-prebuilt-artifact"; +import { npmCommand } from "../../../scripts/script-helpers"; + +export const FIXTURE_VERSION_A = "900.0.0"; +export const FIXTURE_VERSION_B = "900.0.1"; +export const CURL_BAD_CHECKSUM_VERSION = "900.0.2"; +export const CURL_TRUNCATED_VERSION = "900.0.3"; +export const CURL_UNAVAILABLE_VERSION = "900.0.4"; + +export interface FixturePackage { + name: string; + version: string; + tarball: string; + sha256: string; +} + +export interface InstallVmFixtureManifest { + schemaVersion: 1; + sourceIdentity: string; + currentVersion: string; + versionA: string; + versionB: string; + packages: FixturePackage[]; +} + +const SOURCE_IDENTITY_PATHS = [ + "package.json", + "bun.lock", + "README.md", + "bin", + "packages", + "src", + "scripts", + "skills", + "install.sh", + "LICENSE", + "tsconfig.json", + "tsconfig.opentui.json", + "tsconfig.extension.json", + "test/cli/install-vm", +] as const; + +/** Build reduced meta/platform manifests for deterministic package-manager topology tests. */ +export function buildSyntheticPackageManifests(version: string) { + const platformSpec = getPlatformPackageSpecForHost("linux", "x64"); + const platform = buildPlatformPackageManifest( + { + version, + description: "Hunk install VM fixture", + license: "MIT", + }, + platformSpec, + ); + const meta = { + name: "hunkdiff", + version, + description: "Hunk install VM fixture", + type: "module", + bin: { hunk: "./bin/hunk.cjs", hunkdiff: "./bin/hunk.cjs" }, + files: ["bin", "dist/npm", "skills"], + optionalDependencies: buildOptionalDependencyMap(version, [platformSpec]), + engines: { node: ">=18" }, + license: "MIT", + }; + return { meta, platform }; +} + +/** Hash one fixture file for checksum manifests and evidence. */ +function sha256(filePath: string) { + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +/** Hash every source input that can change the generated VM package and curl fixtures. */ +export function computeInstallVmFixtureSourceIdentity(repoRoot: string) { + const hash = createHash("sha256"); + const pending = SOURCE_IDENTITY_PATHS.map((entry) => path.join(repoRoot, entry)); + const files: string[] = []; + while (pending.length > 0) { + const current = pending.pop()!; + if (!existsSync(current)) continue; + const stats = lstatSync(current); + if (stats.isDirectory()) { + for (const entry of readdirSync(current)) pending.push(path.join(current, entry)); + } else { + files.push(current); + } + } + for (const file of files.sort()) { + hash.update(path.relative(repoRoot, file)); + const stats = lstatSync(file); + hash.update(stats.isSymbolicLink() ? readlinkSync(file) : readFileSync(file)); + } + return hash.digest("hex"); +} + +/** Verify reusable fixtures still match this checkout and every declared tarball digest. */ +export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { + const manifestPath = path.join(outputRoot, "fixture-manifest.json"); + const manifestBytes = readFileSync(manifestPath, "utf8"); + const manifest = JSON.parse(manifestBytes) as InstallVmFixtureManifest; + if (manifest.schemaVersion !== 1) throw new Error("Fixture manifest must use schemaVersion 1."); + const expectedIdentity = computeInstallVmFixtureSourceIdentity(repoRoot); + if (manifest.sourceIdentity !== expectedIdentity) { + throw new Error("Install VM fixtures do not match the current checkout identity."); + } + + const rootVersion = ( + JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { + version?: unknown; + } + ).version; + if (manifest.currentVersion !== rootVersion) { + throw new Error("Fixture current version does not match the checkout package version."); + } + if (manifest.versionA !== FIXTURE_VERSION_A || manifest.versionB !== FIXTURE_VERSION_B) { + throw new Error("Fixture upgrade versions do not match the harness contract."); + } + const expectedIdentities = new Set( + [manifest.currentVersion, manifest.versionA, manifest.versionB].flatMap((version) => [ + `hunkdiff-linux-x64@${version}`, + `hunkdiff@${version}`, + ]), + ); + if (!Array.isArray(manifest.packages) || manifest.packages.length !== expectedIdentities.size) { + throw new Error("Fixture manifest must contain exactly six coupled packages."); + } + + const identities = new Set(); + for (const fixturePackage of manifest.packages) { + if ( + path.basename(fixturePackage.tarball) !== fixturePackage.tarball || + !fixturePackage.tarball.endsWith(".tgz") || + !/^[a-f0-9]{64}$/.test(fixturePackage.sha256) + ) { + throw new Error(`Unsafe fixture package entry: ${fixturePackage.tarball}`); + } + const identity = `${fixturePackage.name}@${fixturePackage.version}`; + if (identities.has(identity)) throw new Error(`Duplicate fixture package: ${identity}`); + if (!expectedIdentities.has(identity)) + throw new Error(`Unexpected fixture package: ${identity}`); + identities.add(identity); + const tarballPath = path.join(outputRoot, "packages", fixturePackage.tarball); + if (!existsSync(tarballPath) || sha256(tarballPath) !== fixturePackage.sha256) { + throw new Error(`Fixture tarball checksum mismatch: ${fixturePackage.tarball}`); + } + } + if (identities.size !== expectedIdentities.size) { + throw new Error("Fixture package identities do not cover every required version coupling."); + } + + const httpRoot = path.join(outputRoot, "http"); + if (readFileSync(path.join(httpRoot, "fixture-manifest.json"), "utf8") !== manifestBytes) { + throw new Error("HTTP fixture manifest differs from the registry fixture manifest."); + } + const expectedCurlVersions = { + badChecksum: CURL_BAD_CHECKSUM_VERSION, + truncated: CURL_TRUNCATED_VERSION, + unavailable: CURL_UNAVAILABLE_VERSION, + }; + const curlVersionBytes = `${JSON.stringify(expectedCurlVersions, null, 2)}\n`; + if ( + readFileSync(path.join(outputRoot, "curl-versions.json"), "utf8") !== curlVersionBytes || + readFileSync(path.join(httpRoot, "curl-versions.json"), "utf8") !== curlVersionBytes + ) { + throw new Error("Curl failure fixture versions differ from the harness contract."); + } + const latest = JSON.parse(readFileSync(path.join(httpRoot, "latest"), "utf8")) as { + tag_name?: unknown; + }; + if (latest.tag_name !== `v${manifest.currentVersion}`) { + throw new Error("Curl latest-release fixture does not match the current version."); + } + const archiveName = "hunkdiff-linux-x64.tar.gz"; + const verifyArchiveChecksum = (version: string, expectedDigest?: string) => { + const directory = path.join(httpRoot, "download", `v${version}`); + const archive = path.join(directory, archiveName); + if (!existsSync(archive)) throw new Error(`Missing curl archive fixture for ${version}.`); + const checksum = readFileSync(path.join(directory, "SHA256SUMS"), "utf8"); + const digest = expectedDigest ?? sha256(archive); + if (checksum !== `${digest} ${archiveName}\n`) { + throw new Error(`Invalid curl archive/checksum fixture for ${version}.`); + } + }; + verifyArchiveChecksum(manifest.currentVersion); + verifyArchiveChecksum(CURL_BAD_CHECKSUM_VERSION, "0".repeat(64)); + verifyArchiveChecksum(CURL_TRUNCATED_VERSION); + if (!existsSync(path.join(httpRoot, "install.sh"))) { + throw new Error("Missing rewritten curl installer fixture."); + } + if (existsSync(path.join(httpRoot, "download", `v${CURL_UNAVAILABLE_VERSION}`, archiveName))) { + throw new Error("Unavailable curl fixture unexpectedly contains an archive."); + } + return manifest; +} + +function writeJson(filePath: string, value: unknown) { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +async function run(command: string[], cwd?: string) { + const proc = Bun.spawn(command, { + cwd, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + env: process.env, + }); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error(`${command.join(" ")} failed with ${exitCode}`); +} + +async function packPackage(packageDirectory: string, packageOutput: string) { + await run( + [npmCommand, "pack", "--pack-destination", packageOutput, "--ignore-scripts"], + packageDirectory, + ); + const manifest = JSON.parse( + readFileSync(path.join(packageDirectory, "package.json"), "utf8"), + ) as { + name: string; + version: string; + }; + return `${manifest.name.replace(/^@/, "").replaceAll("/", "-")}-${manifest.version}.tgz`; +} + +async function stageSyntheticPackage( + repoRoot: string, + stageRoot: string, + version: string, + packageOutput: string, +) { + const { meta, platform } = buildSyntheticPackageManifests(version); + const platformDir = path.join(stageRoot, `${platform.name}-${version}`); + mkdirSync(path.join(platformDir, "bin"), { recursive: true }); + writeJson(path.join(platformDir, "package.json"), platform); + const binaryPath = path.join(platformDir, "bin", "hunk"); + writeFileSync( + binaryPath, + `#!/bin/sh\ncase "\${1:-}" in\n --version|-v|version) printf '%s\\n' '${version}' ;;\n --help|-h) printf '%s\\n' 'Usage: hunk [options]' ;;\n *) printf '%s\\n' 'fixture hunk ${version}' ;;\nesac\n`, + ); + chmodSync(binaryPath, 0o755); + + const metaDir = path.join(stageRoot, `hunkdiff-${version}`); + mkdirSync(path.join(metaDir, "bin"), { recursive: true }); + mkdirSync(path.join(metaDir, "dist", "npm"), { recursive: true }); + copyFileSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); + chmodSync(path.join(metaDir, "bin", "hunk.cjs"), 0o755); + cpSync( + path.join(repoRoot, "skills", "hunk-review"), + path.join(metaDir, "skills", "hunk-review"), + { + recursive: true, + }, + ); + cpSync( + path.join(repoRoot, "skills", "hunk-extensions"), + path.join(metaDir, "skills", "hunk-extensions"), + { recursive: true }, + ); + writeFileSync( + path.join(metaDir, "dist", "npm", "main.js"), + `const args = process.argv.slice(2);\nif (args.includes('--version') || args[0] === 'version') console.log('fallback-${version}');\nelse console.log('fallback fixture ${version}');\n`, + ); + writeJson(path.join(metaDir, "package.json"), meta); + + const platformTarball = await packPackage(platformDir, packageOutput); + const metaTarball = await packPackage(metaDir, packageOutput); + return [ + { + name: platform.name, + version, + tarball: platformTarball, + sha256: sha256(path.join(packageOutput, platformTarball)), + }, + { + name: meta.name, + version, + tarball: metaTarball, + sha256: sha256(path.join(packageOutput, metaTarball)), + }, + ]; +} + +/** Prepare local registry and curl fixtures from the explicitly built checkout. */ +export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: string) { + const releaseRoot = releaseNpmDir(repoRoot); + const currentManifest = JSON.parse( + readFileSync(path.join(releaseRoot, "hunkdiff", "package.json"), "utf8"), + ) as { version: string; dependencies?: Record }; + assertNoMandatoryBunDependency(currentManifest); + const currentVersion = currentManifest.version; + const sourceIdentity = computeInstallVmFixtureSourceIdentity(repoRoot); + const temporaryRoot = `${outputRoot}.partial-${process.pid}`; + const backupRoot = `${outputRoot}.backup-${process.pid}`; + rmSync(temporaryRoot, { recursive: true, force: true }); + rmSync(backupRoot, { recursive: true, force: true }); + + try { + const packageOutput = path.join(temporaryRoot, "packages"); + const stageRoot = path.join(temporaryRoot, "stage"); + mkdirSync(packageOutput, { recursive: true }); + mkdirSync(stageRoot, { recursive: true }); + + const currentPlatform = path.join(releaseRoot, "hunkdiff-linux-x64"); + if (!existsSync(currentPlatform)) { + throw new Error("Install VM fixtures require a Linux x64 prebuilt package."); + } + const packages: FixturePackage[] = []; + for (const packageDirectory of [currentPlatform, path.join(releaseRoot, "hunkdiff")]) { + const packageManifest = JSON.parse( + readFileSync(path.join(packageDirectory, "package.json"), "utf8"), + ) as { name: string; version: string }; + const tarball = await packPackage(packageDirectory, packageOutput); + packages.push({ + name: packageManifest.name, + version: packageManifest.version, + tarball, + sha256: sha256(path.join(packageOutput, tarball)), + }); + } + packages.push( + ...(await stageSyntheticPackage(repoRoot, stageRoot, FIXTURE_VERSION_A, packageOutput)), + ); + packages.push( + ...(await stageSyntheticPackage(repoRoot, stageRoot, FIXTURE_VERSION_B, packageOutput)), + ); + + const httpRoot = path.join(temporaryRoot, "http"); + const downloads = path.join(httpRoot, "download"); + mkdirSync(httpRoot, { recursive: true }); + writeFileSync( + path.join(httpRoot, "latest"), + `${JSON.stringify({ tag_name: `v${currentVersion}` })}\n`, + ); + const artifactRoot = path.join(stageRoot, "artifacts"); + const artifactDir = stagePrebuiltArtifact({ repoRoot, outputRoot: artifactRoot }); + const archiveName = "hunkdiff-linux-x64.tar.gz"; + const goodDownloadDir = path.join(downloads, `v${currentVersion}`); + mkdirSync(goodDownloadDir, { recursive: true }); + const goodArchive = path.join(goodDownloadDir, archiveName); + await run(["tar", "-czf", goodArchive, "-C", artifactRoot, path.basename(artifactDir)]); + writeFileSync( + path.join(goodDownloadDir, "SHA256SUMS"), + `${sha256(goodArchive)} ${archiveName}\n`, + ); + + const badChecksumDir = path.join(downloads, `v${CURL_BAD_CHECKSUM_VERSION}`); + mkdirSync(badChecksumDir, { recursive: true }); + copyFileSync(goodArchive, path.join(badChecksumDir, archiveName)); + writeFileSync(path.join(badChecksumDir, "SHA256SUMS"), `${"0".repeat(64)} ${archiveName}\n`); + + const truncatedDir = path.join(downloads, `v${CURL_TRUNCATED_VERSION}`); + mkdirSync(truncatedDir, { recursive: true }); + const truncatedArchive = path.join(truncatedDir, archiveName); + copyFileSync(goodArchive, truncatedArchive); + truncateSync(truncatedArchive, 512); + writeFileSync( + path.join(truncatedDir, "SHA256SUMS"), + `${sha256(truncatedArchive)} ${archiveName}\n`, + ); + + const installer = readFileSync(path.join(repoRoot, "install.sh"), "utf8") + .replace( + 'RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest"', + 'RELEASES_API="http://172.16.0.1:18080/latest"', + ) + .replace( + 'DOWNLOAD_BASE="https://github.com/${REPO}/releases/download"', + 'DOWNLOAD_BASE="http://172.16.0.1:18080/download"', + ); + writeFileSync(path.join(httpRoot, "install.sh"), installer); + + const fixtureManifest: InstallVmFixtureManifest = { + schemaVersion: 1, + sourceIdentity, + currentVersion, + versionA: FIXTURE_VERSION_A, + versionB: FIXTURE_VERSION_B, + packages, + }; + writeJson(path.join(temporaryRoot, "fixture-manifest.json"), fixtureManifest); + writeJson(path.join(httpRoot, "fixture-manifest.json"), fixtureManifest); + const curlVersions = { + badChecksum: CURL_BAD_CHECKSUM_VERSION, + truncated: CURL_TRUNCATED_VERSION, + unavailable: CURL_UNAVAILABLE_VERSION, + }; + writeJson(path.join(temporaryRoot, "curl-versions.json"), curlVersions); + writeJson(path.join(httpRoot, "curl-versions.json"), curlVersions); + verifyInstallVmFixtures(repoRoot, temporaryRoot); + + if (existsSync(outputRoot)) renameSync(outputRoot, backupRoot); + try { + renameSync(temporaryRoot, outputRoot); + } catch (error) { + if (existsSync(backupRoot)) renameSync(backupRoot, outputRoot); + throw error; + } + rmSync(backupRoot, { recursive: true, force: true }); + return fixtureManifest; + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + const repoRoot = path.resolve(import.meta.dir, "../../.."); + const outputRoot = path.resolve( + process.argv[2] ?? path.join(repoRoot, "tmp/install-vm/fixtures"), + ); + await prepareInstallVmFixtures(repoRoot, outputRoot); + console.log(`Prepared install VM fixtures in ${outputRoot}`); +} diff --git a/test/cli/install-vm/results.test.ts b/test/cli/install-vm/results.test.ts new file mode 100644 index 000000000..ae07a02bf --- /dev/null +++ b/test/cli/install-vm/results.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + aggregateInstallVmResults, + parseAssertionTsv, + parseCommandTsv, + parseObservationTsv, +} from "./results"; + +const scenario = { + id: "negative-case", + description: "Expected failure contract", + profile: "node" as const, + script: "negative-case.sh", + network: "local" as const, +}; + +describe("install VM results", () => { + test("parses assertion protocol and rejects malformed fields", () => { + expect(parseAssertionTsv("missing\tpassed\texit 1\texit 1\texpected failure\n")).toEqual([ + { + id: "missing", + status: "passed", + expected: "exit 1", + actual: "exit 1", + message: "expected failure", + }, + ]); + expect(() => parseAssertionTsv("bad\tunknown\tx\ty\tz\n")).toThrow("Invalid assertion status"); + expect(() => parseAssertionTsv("too\tfew\tfields\n")).toThrow("Malformed assertion TSV"); + }); + + test("parses structured commands and observations without embedding logs", () => { + expect(parseCommandTsv("version\tpassed\texit 0\t0\tcommands/version.log\n")).toEqual([ + { + id: "version", + status: "passed", + expectation: "exit 0", + exitCode: 0, + logPath: "commands/version.log", + }, + ]); + expect( + parseObservationTsv("hunkVersion\t1.2.3\ndependencyTreePath\tdependency.json\n"), + ).toEqual({ + hunkVersion: "1.2.3", + dependencyTreePath: "dependency.json", + }); + expect(() => parseCommandTsv("bad\tpassed\texit 0\tNaN\tcommands/bad.log\n")).toThrow( + "exit code", + ); + expect(() => parseObservationTsv("dependencyTreePath\t../secret\n")).toThrow("Unsafe"); + }); + + test("writes deterministic JSON and JUnit projections", () => { + const output = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-result-")); + try { + const scenarioDir = path.join(output, "scenarios", scenario.id); + mkdirSync(scenarioDir, { recursive: true }); + writeFileSync( + path.join(scenarioDir, "result.json"), + `${JSON.stringify({ id: scenario.id, exitCode: 0, durationMs: 10 })}\n`, + ); + writeFileSync( + path.join(scenarioDir, "assertions.tsv"), + "expected-negative\tpassed\tnonzero\texit 1\tfailure was expected\n", + ); + writeFileSync( + path.join(scenarioDir, "commands.tsv"), + "expected-negative\tpassed\tnonzero exit\t1\tcommands/expected-negative.log\n", + ); + mkdirSync(path.join(scenarioDir, "commands")); + writeFileSync(path.join(scenarioDir, "commands", "expected-negative.log"), "expected\n"); + writeFileSync(path.join(scenarioDir, "observations.tsv"), "hunkVersion\t1.2.3\n"); + writeFileSync(path.join(scenarioDir, "guest.log"), "evidence\n"); + writeFileSync(path.join(scenarioDir, "secret.ext4"), "excluded\n"); + + const result = aggregateInstallVmResults({ + outputDir: output, + runId: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + scenarios: [scenario], + tools: { zeta: "2", alpha: "1" }, + }); + + expect(result.run.status).toBe("passed"); + expect(Object.keys(result.tools)).toEqual(["alpha", "zeta"]); + expect(result.scenarios[0]?.commands[0]?.exitCode).toBe(1); + expect(result.scenarios[0]?.observations.hunkVersion).toBe("1.2.3"); + expect(result.scenarios[0]?.artifacts).not.toContain("scenarios/negative-case/secret.ext4"); + expect(readFileSync(path.join(output, "result.json"), "utf8")).toEndWith("\n"); + expect(readFileSync(path.join(output, "junit.xml"), "utf8")).toContain('failures="0"'); + } finally { + rmSync(output, { recursive: true, force: true }); + } + }); + + test("fails vacuous zero-exit scenarios and rejects fractional raw values", () => { + const output = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-vacuous-")); + try { + const scenarioDir = path.join(output, "scenarios", scenario.id); + mkdirSync(scenarioDir, { recursive: true }); + writeFileSync( + path.join(scenarioDir, "result.json"), + `${JSON.stringify({ id: scenario.id, exitCode: 0, durationMs: 10 })}\n`, + ); + writeFileSync(path.join(scenarioDir, "assertions.tsv"), ""); + writeFileSync(path.join(scenarioDir, "commands.tsv"), ""); + writeFileSync(path.join(scenarioDir, "observations.tsv"), ""); + expect( + aggregateInstallVmResults({ + outputDir: output, + runId: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + scenarios: [scenario], + tools: {}, + }).run.status, + ).toBe("failed"); + + writeFileSync( + path.join(scenarioDir, "result.json"), + `${JSON.stringify({ id: scenario.id, exitCode: 0.5, durationMs: 10 })}\n`, + ); + expect(() => + aggregateInstallVmResults({ + outputDir: output, + runId: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + scenarios: [scenario], + tools: {}, + }), + ).toThrow("Malformed raw scenario result"); + } finally { + rmSync(output, { recursive: true, force: true }); + } + }); +}); diff --git a/test/cli/install-vm/results.ts b/test/cli/install-vm/results.ts new file mode 100644 index 000000000..1379b2a06 --- /dev/null +++ b/test/cli/install-vm/results.ts @@ -0,0 +1,207 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + buildInstallVmJunit, + type InstallVmAssertion, + type InstallVmCommandResult, + type InstallVmRunResult, + type InstallVmScenarioObservations, + type InstallVmScenario, + type InstallVmScenarioResult, +} from "./contract"; + +interface RawScenarioResult { + id: string; + exitCode: number; + durationMs: number; +} + +const RESULT_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/; + +/** Return one safe relative artifact path, rejecting traversal and absolute paths. */ +function safeArtifactPath(value: string) { + const normalized = path.posix.normalize(value.replaceAll("\\", "/")); + if (normalized !== value || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) { + throw new Error(`Unsafe install VM artifact path: ${value}`); + } + return normalized; +} + +/** Parse guest assertion TSV without allowing embedded control fields. */ +export function parseAssertionTsv(contents: string): InstallVmAssertion[] { + if (!contents.trim()) return []; + return contents + .trimEnd() + .split("\n") + .map((line, index) => { + const fields = line.split("\t"); + if (fields.length !== 5) throw new Error(`Malformed assertion TSV line ${index + 1}.`); + const [id, status, expected, actual, message] = fields as [ + string, + string, + string, + string, + string, + ]; + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error(`Invalid assertion id: ${id}`); + if (status !== "passed" && status !== "failed") { + throw new Error(`Invalid assertion status for ${id}: ${status}`); + } + return { id, status, expected, actual, message }; + }); +} + +/** Parse guest command TSV into bounded references to full command logs. */ +export function parseCommandTsv(contents: string): InstallVmCommandResult[] { + if (!contents.trim()) return []; + return contents + .trimEnd() + .split("\n") + .map((line, index) => { + const fields = line.split("\t"); + if (fields.length !== 5) throw new Error(`Malformed command TSV line ${index + 1}.`); + const [id, status, expectation, exitCodeText, logPath] = fields as [ + string, + string, + string, + string, + string, + ]; + const exitCode = Number(exitCodeText); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error(`Invalid command id: ${id}`); + if (status !== "passed" && status !== "failed") { + throw new Error(`Invalid command status for ${id}: ${status}`); + } + if (!Number.isSafeInteger(exitCode)) throw new Error(`Invalid command exit code for ${id}.`); + return { id, status, expectation, exitCode, logPath: safeArtifactPath(logPath) }; + }); +} + +/** Parse guest observations without allowing duplicate or unsafe keys. */ +export function parseObservationTsv(contents: string): InstallVmScenarioObservations { + const observations: InstallVmScenarioObservations = {}; + if (!contents.trim()) return observations; + for (const [index, line] of contents.trimEnd().split("\n").entries()) { + const fields = line.split("\t"); + if (fields.length !== 2) throw new Error(`Malformed observation TSV line ${index + 1}.`); + const [key, value] = fields as [string, string]; + if (!RESULT_KEY_PATTERN.test(key)) throw new Error(`Invalid observation key: ${key}`); + if (observations[key] !== undefined) throw new Error(`Duplicate observation key: ${key}`); + observations[key] = key.endsWith("Path") ? safeArtifactPath(value) : value; + } + return observations; +} + +/** Aggregate bounded scenario result files into stable JSON and JUnit artifacts. */ +export function aggregateInstallVmResults(options: { + outputDir: string; + runId: string; + startedAt: string; + finishedAt: string; + scenarios: readonly InstallVmScenario[]; + tools: Record; + skipReason?: string; +}) { + const scenarioResults: InstallVmScenarioResult[] = options.scenarios.map((scenario) => { + const directory = path.join(options.outputDir, "scenarios", scenario.id); + const raw = JSON.parse( + readFileSync(path.join(directory, "result.json"), "utf8"), + ) as RawScenarioResult; + if ( + raw.id !== scenario.id || + !Number.isSafeInteger(raw.exitCode) || + !Number.isSafeInteger(raw.durationMs) || + raw.durationMs < 0 + ) { + throw new Error(`Malformed raw scenario result for ${scenario.id}.`); + } + const assertionsPath = path.join(directory, "assertions.tsv"); + const assertions = parseAssertionTsv(readFileSync(assertionsPath, "utf8")); + const commands = parseCommandTsv(readFileSync(path.join(directory, "commands.tsv"), "utf8")); + const observations = parseObservationTsv( + readFileSync(path.join(directory, "observations.tsv"), "utf8"), + ); + for (const command of commands) { + if (!existsSync(path.join(directory, command.logPath))) { + throw new Error(`Missing command log for ${scenario.id}/${command.id}.`); + } + } + for (const [key, value] of Object.entries(observations)) { + if (key.endsWith("Path") && value !== undefined && !existsSync(path.join(directory, value))) { + throw new Error(`Missing observation artifact for ${scenario.id}/${key}.`); + } + } + if (assertions.length === 0) { + assertions.push({ + id: "guest-assertions", + status: "failed", + expected: "at least one assertion", + actual: "none", + message: "guest returned no assertion evidence", + }); + } + if (commands.length === 0) { + assertions.push({ + id: "guest-commands", + status: "failed", + expected: "at least one command", + actual: "none", + message: "guest returned no command evidence", + }); + } + const artifacts = readdirSync(directory) + .filter( + (entry) => + !entry.endsWith(".ext4") && + !entry.endsWith(".socket") && + !entry.startsWith("id_") && + !entry.includes("credential") && + !entry.includes("identity"), + ) + .sort() + .map((entry) => path.posix.join("scenarios", scenario.id, entry)); + const failed = + raw.exitCode !== 0 || + assertions.some((assertion) => assertion.status === "failed") || + commands.some((command) => command.status === "failed"); + return { + id: scenario.id, + description: scenario.description, + status: failed ? "failed" : "passed", + durationMs: raw.durationMs, + exitCode: raw.exitCode, + commands, + observations, + assertions, + artifacts, + }; + }); + + const status = options.skipReason + ? "skipped" + : scenarioResults.some((scenario) => scenario.status === "failed") + ? "failed" + : "passed"; + const result: InstallVmRunResult = { + schemaVersion: 1, + run: { + id: options.runId, + startedAt: options.startedAt, + finishedAt: options.finishedAt, + platform: "linux-x64", + status, + ...(options.skipReason ? { skipReason: options.skipReason } : {}), + }, + tools: Object.fromEntries( + Object.entries(options.tools).sort(([left], [right]) => left.localeCompare(right)), + ), + scenarios: scenarioResults.sort((left, right) => left.id.localeCompare(right.id)), + }; + + writeFileSync( + path.join(options.outputDir, "result.json"), + `${JSON.stringify(result, null, 2)}\n`, + ); + writeFileSync(path.join(options.outputDir, "junit.xml"), buildInstallVmJunit(result)); + return result; +} diff --git a/test/cli/install-vm/runner.ts b/test/cli/install-vm/runner.ts new file mode 100644 index 000000000..4d00ab576 --- /dev/null +++ b/test/cli/install-vm/runner.ts @@ -0,0 +1,351 @@ +#!/usr/bin/env bun + +/** + * Runs opt-in install compatibility scenarios inside clean Firecracker Linux guests. + * + * Normal tests import only the pure contract helpers. Docker, KVM, downloads, package builds, + * and VM setup begin only from this explicit executable entrypoint. + */ + +import { + appendFileSync, + chmodSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { + assertDistinctInstallVmRuntimePaths, + assertSafeCleanTarget, + assertSafeInstallVmRuntimePath, + buildDockerRunCommand, + buildInstallVmJunit, + loadScenarioManifest, + parseInstallVmArgs, + selectScenarios, + validateInstallVmPins, + type InstallVmPins, + type InstallVmRunResult, +} from "./contract"; +import { collectInstallVmPreflightFailures } from "./preflight"; +import { prepareInstallVmFixtures, verifyInstallVmFixtures } from "./prepare-fixtures"; +import { aggregateInstallVmResults } from "./results"; +import { acquireInstallVmRuntimeLock } from "./runtime-lock"; + +const repoRoot = path.resolve(import.meta.dir, "../../.."); +const harnessRoot = import.meta.dir; +const defaultRuntimeRoot = path.join(repoRoot, "tmp", "install-vm"); + +const COMMAND_TIMEOUT_MS = 30 * 60 * 1_000; +const VM_SUITE_TIMEOUT_MS = 45 * 60 * 1_000; +const TERMINATION_GRACE_MS = 10_000; + +export class InstallVmCommandError extends Error { + constructor( + message: string, + readonly exitCode: number, + ) { + super(message); + } +} + +/** Runs host commands asynchronously while forwarding interrupts and bounding shutdown. */ +export class InstallVmCommandRunner { + private activeProcess: ReturnType | undefined; + private interruptedExitCode: number | undefined; + private terminationTimer: ReturnType | undefined; + + private readonly handleSigint = () => this.interrupt("SIGINT", 130); + private readonly handleSigterm = () => this.interrupt("SIGTERM", 143); + + start() { + process.once("SIGINT", this.handleSigint); + process.once("SIGTERM", this.handleSigterm); + } + + stop() { + process.off("SIGINT", this.handleSigint); + process.off("SIGTERM", this.handleSigterm); + if (this.terminationTimer) clearTimeout(this.terminationTimer); + } + + private interrupt(signal: NodeJS.Signals, exitCode: number) { + if (this.interruptedExitCode !== undefined) return; + this.interruptedExitCode = exitCode; + this.activeProcess?.kill(signal); + if (this.activeProcess) { + if (this.terminationTimer) clearTimeout(this.terminationTimer); + this.terminationTimer = setTimeout( + () => this.activeProcess?.kill("SIGKILL"), + TERMINATION_GRACE_MS, + ); + } + } + + checkInterrupted() { + if (this.interruptedExitCode !== undefined) { + throw new InstallVmCommandError("Install VM suite interrupted.", this.interruptedExitCode); + } + } + + async run(command: string[], options: { cwd?: string; timeoutMs?: number } = {}) { + this.checkInterrupted(); + const proc = Bun.spawn(command, { + cwd: options.cwd ?? repoRoot, + env: process.env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + this.activeProcess = proc; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + proc.kill("SIGTERM"); + this.terminationTimer = setTimeout(() => proc.kill("SIGKILL"), TERMINATION_GRACE_MS); + }, options.timeoutMs ?? COMMAND_TIMEOUT_MS); + + let exitCode: number; + try { + exitCode = await proc.exited; + } finally { + clearTimeout(timeout); + if (this.terminationTimer) clearTimeout(this.terminationTimer); + this.terminationTimer = undefined; + this.activeProcess = undefined; + } + + this.checkInterrupted(); + if (timedOut) { + throw new InstallVmCommandError(`${command.join(" ")} exceeded its timeout.`, 124); + } + if (exitCode !== 0) { + throw new InstallVmCommandError( + `${command.join(" ")} failed with exit ${exitCode}`, + exitCode, + ); + } + } +} + +/** Compute a stable local image tag from every checked-in controller input. */ +function controllerImageTag() { + const hash = createHash("sha256"); + const files = [ + path.join(harnessRoot, "Dockerfile"), + path.join(harnessRoot, "controller.sh"), + path.join(harnessRoot, "pins.json"), + path.join(harnessRoot, "scenarios.json"), + ]; + const pending = [ + path.join(harnessRoot, "controller-deps"), + path.join(harnessRoot, "guest"), + path.join(harnessRoot, "scenarios"), + ]; + while (pending.length > 0) { + const entryPath = pending.pop()!; + const entries = readdirSync(entryPath, { withFileTypes: true }); + for (const entry of entries) { + const child = path.join(entryPath, entry.name); + if (entry.isDirectory()) pending.push(child); + if (entry.isFile()) files.push(child); + } + } + for (const file of files.sort()) { + hash.update(path.relative(harnessRoot, file)); + hash.update(readFileSync(file)); + } + return `hunk-install-vm:${hash.digest("hex").slice(0, 12)}`; +} + +function writeSkippedResult( + outputDir: string, + runId: string, + startedAt: string, + scenarios: ReturnType, + reason: string, +) { + const result: InstallVmRunResult = { + schemaVersion: 1, + run: { + id: runId, + startedAt, + finishedAt: new Date().toISOString(), + platform: "linux-x64", + status: "skipped", + skipReason: reason, + }, + tools: {}, + scenarios: scenarios.map((scenario) => ({ + id: scenario.id, + description: scenario.description, + status: "skipped", + durationMs: 0, + exitCode: 0, + commands: [], + observations: {}, + assertions: [], + artifacts: [], + })), + }; + writeFileSync(path.join(outputDir, "result.json"), `${JSON.stringify(result, null, 2)}\n`); + writeFileSync(path.join(outputDir, "junit.xml"), buildInstallVmJunit(result)); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `## Firecracker install suite\n\n⚠️ **Skipped** — ${reason}\n\nResult: \`${path.join(outputDir, "result.json")}\`\n`, + ); + } + if (process.env.GITHUB_ACTIONS === "true") + console.warn(`::warning::Install VM suite skipped: ${reason}`); +} + +/** Execute the opt-in VM runner. */ +export async function main(argv = process.argv.slice(2)) { + const options = parseInstallVmArgs(argv); + const pins: InstallVmPins = validateInstallVmPins( + JSON.parse(readFileSync(path.join(harnessRoot, "pins.json"), "utf8")), + ); + const manifest = loadScenarioManifest(path.join(harnessRoot, "scenarios.json")); + + if (options.list) { + for (const scenario of manifest.scenarios) { + console.log(`${scenario.id}\t${scenario.description}`); + } + return 0; + } + + mkdirSync(defaultRuntimeRoot, { recursive: true, mode: 0o700 }); + if (options.clean) { + const target = assertSafeCleanTarget(repoRoot, options.cacheDir ?? defaultRuntimeRoot); + const releaseLock = acquireInstallVmRuntimeLock(path.join(defaultRuntimeRoot, ".lock")); + try { + const revalidatedTarget = assertSafeCleanTarget(repoRoot, target); + if (revalidatedTarget === defaultRuntimeRoot) { + for (const entry of readdirSync(revalidatedTarget)) { + if (entry !== ".lock") { + rmSync(path.join(revalidatedTarget, entry), { recursive: true, force: true }); + } + } + } else { + rmSync(revalidatedTarget, { recursive: true, force: true }); + } + } finally { + releaseLock(); + } + console.log(`Removed install VM cache and results at ${target}`); + return 0; + } + + const selected = selectScenarios(manifest, options.scenarios); + const runId = `${new Date().toISOString().replaceAll(/[:.]/g, "-")}-${process.pid}`; + const startedAt = new Date().toISOString(); + const outputDir = assertSafeInstallVmRuntimePath( + repoRoot, + options.outputDir ?? path.join(defaultRuntimeRoot, "runs", runId), + ); + const cacheDir = assertSafeInstallVmRuntimePath( + repoRoot, + options.cacheDir ?? path.join(defaultRuntimeRoot, "cache"), + ); + const fixtureDir = assertSafeInstallVmRuntimePath( + repoRoot, + path.join(defaultRuntimeRoot, "fixtures"), + ); + assertDistinctInstallVmRuntimePaths({ cacheDir, fixtureDir, outputDir }); + mkdirSync(outputDir, { recursive: true, mode: 0o700 }); + mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); + chmodSync(outputDir, 0o700); + chmodSync(cacheDir, 0o700); + + const failures = await collectInstallVmPreflightFailures(defaultRuntimeRoot); + if (failures.length > 0) { + const reason = failures.join(" "); + if (options.allowSkip) { + writeSkippedResult(outputDir, runId, startedAt, selected, reason); + console.warn(`Skipped install VM suite: ${reason}`); + console.warn(`Result: ${path.join(outputDir, "result.json")}`); + return 0; + } + throw new Error(`Install VM preflight failed:\n- ${failures.join("\n- ")}`); + } + + const releaseLock = acquireInstallVmRuntimeLock(path.join(defaultRuntimeRoot, ".lock")); + const commandRunner = new InstallVmCommandRunner(); + commandRunner.start(); + try { + let fixturesReusable = false; + if (options.reuseFixtures && existsSync(path.join(fixtureDir, "fixture-manifest.json"))) { + try { + verifyInstallVmFixtures(repoRoot, fixtureDir); + fixturesReusable = true; + console.log("Reusing checksum-verified install VM fixtures for this checkout."); + } catch (error) { + console.warn( + `Rejected stale install VM fixtures: ${error instanceof Error ? error.message : error}`, + ); + } + } + if (!fixturesReusable) { + await commandRunner.run([process.execPath, "run", "build:prebuilt:npm"]); + await prepareInstallVmFixtures(repoRoot, fixtureDir); + } + + const image = controllerImageTag(); + await commandRunner.run(["docker", "build", "--tag", image, harnessRoot]); + const revalidatedPaths = { + cacheDir: assertSafeInstallVmRuntimePath(repoRoot, cacheDir), + fixtureDir: assertSafeInstallVmRuntimePath(repoRoot, fixtureDir), + outputDir: assertSafeInstallVmRuntimePath(repoRoot, outputDir), + }; + assertDistinctInstallVmRuntimePaths(revalidatedPaths); + verifyInstallVmFixtures(repoRoot, revalidatedPaths.fixtureDir); + const dockerCommand = buildDockerRunCommand( + image, + revalidatedPaths, + selected.map((scenario) => scenario.id), + { uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0 }, + ); + await commandRunner.run(dockerCommand, { timeoutMs: VM_SUITE_TIMEOUT_MS }); + + const tools = JSON.parse(readFileSync(path.join(outputDir, "tools.json"), "utf8")) as Record< + string, + string + >; + if (tools.pnpm !== pins.pnpmVersion) { + throw new Error( + `Guest pnpm version drifted: expected ${pins.pnpmVersion}, got ${tools.pnpm}`, + ); + } + commandRunner.checkInterrupted(); + const result = aggregateInstallVmResults({ + outputDir, + runId, + startedAt, + finishedAt: new Date().toISOString(), + scenarios: selected, + tools, + }); + console.log(`Install VM result: ${path.join(outputDir, "result.json")}`); + console.log(`Install VM JUnit: ${path.join(outputDir, "junit.xml")}`); + commandRunner.checkInterrupted(); + return result.run.status === "passed" ? 0 : 1; + } finally { + commandRunner.stop(); + releaseLock(); + } +} + +if (import.meta.main) { + try { + process.exitCode = await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = error instanceof InstallVmCommandError ? error.exitCode : 1; + } +} diff --git a/test/cli/install-vm/runtime-lock.ts b/test/cli/install-vm/runtime-lock.ts new file mode 100644 index 000000000..3b1692e71 --- /dev/null +++ b/test/cli/install-vm/runtime-lock.ts @@ -0,0 +1,122 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; + +/** Return whether a process id still names a live process, treating permission denial as live. */ +function processIsAlive(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** Read and validate the pid recorded by one lock directory. */ +function readOwnerPid(lockDirectory: string) { + const owner = JSON.parse(readFileSync(path.join(lockDirectory, "owner.json"), "utf8")) as { + pid?: unknown; + }; + if (!Number.isSafeInteger(owner.pid) || Number(owner.pid) <= 0) throw new Error("invalid pid"); + return Number(owner.pid); +} + +/** Acquire the shared install-VM runtime lock, reclaiming only a demonstrably stale owner. */ +export function acquireInstallVmRuntimeLock( + lockDirectory: string, + options: { + pid?: number; + alive?: (pid: number) => boolean; + beforeStaleClaim?: () => void; + } = {}, +) { + const pid = options.pid ?? process.pid; + const alive = options.alive ?? processIsAlive; + if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Install VM lock needs a valid pid."); + + const create = () => { + mkdirSync(lockDirectory); + writeFileSync(path.join(lockDirectory, "owner.json"), `${JSON.stringify({ pid })}\n`, { + mode: 0o600, + }); + }; + + for (;;) { + try { + create(); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (lstatSync(lockDirectory).isSymbolicLink()) { + throw new Error(`Refusing symlinked install VM lock: ${lockDirectory}`); + } + + let observedOwner: number; + try { + observedOwner = readOwnerPid(lockDirectory); + } catch { + throw new Error( + `Install VM lock has no valid owner; remove it after confirming no suite is running: ${lockDirectory}`, + ); + } + if (alive(observedOwner)) { + throw new Error(`Install VM suite is already running under pid ${observedOwner}.`); + } + + options.beforeStaleClaim?.(); + const quarantine = `${lockDirectory}.stale-${pid}-${randomUUID()}`; + try { + renameSync(lockDirectory, quarantine); + } catch (claimError) { + if ((claimError as NodeJS.ErrnoException).code === "ENOENT") continue; + throw claimError; + } + + let claimedOwner: number | undefined; + try { + claimedOwner = readOwnerPid(quarantine); + } catch { + // An invalid stale lock is safe to quarantine, but never silently replace a new valid one. + } + if (claimedOwner !== undefined && claimedOwner !== observedOwner) { + try { + renameSync(quarantine, lockDirectory); + } catch { + rmSync(quarantine, { recursive: true, force: true }); + } + continue; + } + + try { + create(); + } catch (createError) { + rmSync(quarantine, { recursive: true, force: true }); + if ((createError as NodeJS.ErrnoException).code === "EEXIST") continue; + throw createError; + } + rmSync(quarantine, { recursive: true, force: true }); + break; + } + } + + let released = false; + return () => { + if (released) return; + released = true; + if (!existsSync(lockDirectory) || lstatSync(lockDirectory).isSymbolicLink()) return; + try { + if (readOwnerPid(lockDirectory) !== pid) return; + } catch { + return; + } + rmSync(lockDirectory, { recursive: true, force: true }); + }; +} diff --git a/test/cli/install-vm/scenarios.json b/test/cli/install-vm/scenarios.json new file mode 100644 index 000000000..6edb86380 --- /dev/null +++ b/test/cli/install-vm/scenarios.json @@ -0,0 +1,89 @@ +{ + "schemaVersion": 1, + "scenarios": [ + { + "id": "npm-prebuilt-no-bun", + "description": "Install the current prebuilt package with npm and no Bun runtime.", + "profile": "node", + "script": "npm-prebuilt-no-bun.sh", + "network": "local" + }, + { + "id": "pnpm-prebuilt-no-bun", + "description": "Install the current prebuilt package with pnpm and no Bun runtime.", + "profile": "node", + "script": "pnpm-prebuilt-no-bun.sh", + "network": "local" + }, + { + "id": "pnpm-global-upgrade", + "description": "Upgrade between two registry versions without pruning pnpm's shared store.", + "profile": "node", + "script": "pnpm-global-upgrade.sh", + "network": "local" + }, + { + "id": "system-bun-path", + "description": "Keep old and current standalone PATH Bun commands behind the platform binary.", + "profile": "node", + "script": "system-bun-path.sh", + "network": "local" + }, + { + "id": "missing-platform-no-bun", + "description": "Explain how to repair an install with neither a platform package nor Bun.", + "profile": "node", + "script": "missing-platform-no-bun.sh", + "network": "local" + }, + { + "id": "missing-platform-system-bun", + "description": "Do not mistake a standalone PATH Bun command for the npm Bun fallback.", + "profile": "node", + "script": "missing-platform-system-bun.sh", + "network": "local" + }, + { + "id": "missing-platform-npm-bun", + "description": "Use the best-effort Node-resolvable npm Bun fallback when the platform package is absent.", + "profile": "node", + "script": "missing-platform-npm-bun.sh", + "network": "live" + }, + { + "id": "old-npm-bun-fallback", + "description": "Exercise the legacy fallback with an older pinned npm Bun package without declaring a support boundary.", + "profile": "node", + "script": "old-npm-bun-fallback.sh", + "network": "live" + }, + { + "id": "offline-after-install", + "description": "Run the installed prebuilt executable after removing the guest default route.", + "profile": "node", + "script": "offline-after-install.sh", + "network": "live" + }, + { + "id": "curl-clean-machine", + "description": "Install the standalone release archive without Node or Bun.", + "profile": "minimal", + "script": "curl-clean-machine.sh", + "network": "local" + }, + { + "id": "curl-failure-preservation", + "description": "Preserve an existing curl install after checksum, truncation, and unavailable-asset failures.", + "profile": "minimal", + "script": "curl-failure-preservation.sh", + "network": "local" + }, + { + "id": "historical-pnpm-bun-corruption", + "description": "Reproduce the pinned issue #866 projection mutation and report Linux recovery separately.", + "profile": "node", + "script": "historical-pnpm-bun-corruption.sh", + "network": "live" + } + ] +} diff --git a/test/cli/install-vm/scenarios/curl-clean-machine.sh b/test/cli/install-vm/scenarios/curl-clean-machine.sh new file mode 100755 index 000000000..5aff24059 --- /dev/null +++ b/test/cli/install-vm/scenarios/curl-clean-machine.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +current=$(curl -fsS "$HTTP_URL/fixture-manifest.json" | sed -n 's/.*"currentVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +curl -fsS "$HTTP_URL/install.sh" -o "$HOME/install.sh" + +# The minimal profile hides the Node embedded in the base image to model a clean user machine. +if command -v node >/dev/null 2>&1 || command -v bun >/dev/null 2>&1; then + record_assertion minimal-profile failed "no Node or Bun" present "unexpected runtime on minimal PATH" +else + record_assertion minimal-profile passed "no Node or Bun" absent "minimal PATH has no language runtime" +fi +run_expect curl-install 0 env HOME="$HOME" HUNK_NO_MODIFY_PATH=1 sh "$HOME/install.sh" "$current" +assert_contains checksum-verified "$command_dir/curl-install.log" "Verifying checksum" +assert_path_state installed-binary executable "$HOME/.hunk/bin/hunk" +assert_path_state installed-review-skill file "$HOME/.hunk/skills/hunk-review/SKILL.md" +assert_path_state installed-extension-skill file "$HOME/.hunk/skills/hunk-extensions/SKILL.md" +run_expect curl-version 0 "$HOME/.hunk/bin/hunk" --version +assert_contains curl-version-output "$command_dir/curl-version.log" "$current" +run_expect curl-help 0 "$HOME/.hunk/bin/hunk" --help +assert_contains curl-help-output "$command_dir/curl-help.log" "Usage: hunk" +run_expect review-skill-path 0 "$HOME/.hunk/bin/hunk" skill path hunk-review +assert_contains review-skill-path-output "$command_dir/review-skill-path.log" "$HOME/.hunk/skills/hunk-review/SKILL.md" +run_expect extension-skill-path 0 "$HOME/.hunk/bin/hunk" skill path hunk-extensions +assert_contains extension-skill-path-output "$command_dir/extension-skill-path.log" "$HOME/.hunk/skills/hunk-extensions/SKILL.md" +record_observation hunkVersion "$current" +record_observation installSource curl +record_observation resolvedExecutable "$HOME/.hunk/bin/hunk" +scenario_finish diff --git a/test/cli/install-vm/scenarios/curl-failure-preservation.sh b/test/cli/install-vm/scenarios/curl-failure-preservation.sh new file mode 100755 index 000000000..0b1f076a0 --- /dev/null +++ b/test/cli/install-vm/scenarios/curl-failure-preservation.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +manifest=$(curl -fsS "$HTTP_URL/fixture-manifest.json") +current=$(printf '%s\n' "$manifest" | sed -n 's/.*"currentVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +versions=$(curl -fsS "$HTTP_URL/curl-versions.json") +bad=$(printf '%s\n' "$versions" | sed -n 's/.*"badChecksum"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +truncated=$(printf '%s\n' "$versions" | sed -n 's/.*"truncated"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +unavailable=$(printf '%s\n' "$versions" | sed -n 's/.*"unavailable"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +curl -fsS "$HTTP_URL/install.sh" -o "$HOME/install.sh" +run_expect seed-install 0 env HOME="$HOME" HUNK_NO_MODIFY_PATH=1 sh "$HOME/install.sh" "$current" +binary="$HOME/.hunk/bin/hunk" +skill="$HOME/.hunk/skills/hunk-review/SKILL.md" +# Hash a known-good install so every failure path proves it leaves active files byte-for-byte intact. +before_binary=$(sha256sum "$binary" | cut -d' ' -f1) +before_skill=$(sha256sum "$skill" | cut -d' ' -f1) + +run_expect_nonzero bad-checksum env HOME="$HOME" HUNK_NO_MODIFY_PATH=1 sh "$HOME/install.sh" "$bad" +assert_contains bad-checksum-message "$command_dir/bad-checksum.log" "Checksum verification failed" +assert_equals binary-after-checksum "$before_binary" "$(sha256sum "$binary" | cut -d' ' -f1)" +assert_equals skill-after-checksum "$before_skill" "$(sha256sum "$skill" | cut -d' ' -f1)" + +run_expect_nonzero truncated-payload env HOME="$HOME" HUNK_NO_MODIFY_PATH=1 sh "$HOME/install.sh" "$truncated" +assert_equals binary-after-truncation "$before_binary" "$(sha256sum "$binary" | cut -d' ' -f1)" +assert_equals skill-after-truncation "$before_skill" "$(sha256sum "$skill" | cut -d' ' -f1)" + +run_expect_nonzero unavailable-asset env HOME="$HOME" HUNK_NO_MODIFY_PATH=1 sh "$HOME/install.sh" "$unavailable" +assert_contains unavailable-message "$command_dir/unavailable-asset.log" "Could not download" +assert_equals binary-after-unavailable "$before_binary" "$(sha256sum "$binary" | cut -d' ' -f1)" +assert_equals skill-after-unavailable "$before_skill" "$(sha256sum "$skill" | cut -d' ' -f1)" +assert_path_state no-partial-binary missing "$HOME/.hunk/bin/hunk.new" +run_expect preserved-version 0 "$binary" --version +assert_contains preserved-version-output "$command_dir/preserved-version.log" "$current" +record_observation hunkVersion "$current" +record_observation installSource curl-preserved +record_observation resolvedExecutable "$binary" +scenario_finish diff --git a/test/cli/install-vm/scenarios/historical-pnpm-bun-corruption.sh b/test/cli/install-vm/scenarios/historical-pnpm-bun-corruption.sh new file mode 100755 index 000000000..fb02ff86d --- /dev/null +++ b/test/cli/install-vm/scenarios/historical-pnpm-bun-corruption.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +install_pnpm +pnpm config set registry https://registry.npmjs.org/ +hunkdiff_version=${HISTORICAL_HUNKDIFF_VERSION:?HISTORICAL_HUNKDIFF_VERSION is required} +bun_version=${HISTORICAL_BUN_VERSION:?HISTORICAL_BUN_VERSION is required} + +# Force the historical Hunk dependency to the exact Bun release that mutated pnpm's projection. +cat >"$HOME/pnpm/global/pnpm-workspace.yaml" </dev/null) +run_expect historical-bun-version 0 node -e 'console.log(require(process.argv[1]).version)' "$bun_manifest" +assert_contains historical-bun-pinned "$command_dir/historical-bun-version.log" "$bun_version" +# Bun's postinstall moves this executable out of the shared projection; its absence is the oracle. +find "$HOME/pnpm/store" \( -path '*/@oven/bun-linux-x64/*/bin' -o -path '*/node_modules/bun' \) -print >"$artifact_dir/store-projection.txt" 2>/dev/null +platform_bin_dir=$(find "$HOME/pnpm/store" -path '*/@oven/bun-linux-x64/*/bin' -type d -print -quit 2>/dev/null) +if [[ -n $platform_bin_dir && ! -e $platform_bin_dir/bun ]]; then + record_assertion projection-mutated passed "platform executable missing after Bun postinstall" missing "$platform_bin_dir/bun" +else + record_assertion projection-mutated failed "platform executable missing after Bun postinstall" present "${platform_bin_dir:-not found}" +fi + +update_log="$command_dir/historical-update.log" +run_capture historical-update pnpm update -g hunkdiff +update_status=$last_command_status +if grep -Fq 'Failed to find package "@oven/bun-linux-x64"' "$update_log"; then + record_assertion missing-package-diagnostic passed "historical missing-package error" present "issue #866 diagnostic observed" +else + record_assertion missing-package-diagnostic failed "historical missing-package error" missing "see commands/historical-update.log" +fi +run_expect dependency-tree 0 pnpm list -g hunkdiff --depth Infinity --json +record_observation hunkVersion "$hunkdiff_version" +record_observation installSource historical-pnpm-live +record_observation bunVersion "$bun_version" +record_observation dependencyTreePath commands/dependency-tree.log +record_observation storeProjectionPath store-projection.txt +# Linux may download a replacement and recover; preserve that distinction from macOS's final failure. +if [[ $update_status -eq 0 ]]; then + printf 'linuxRecovery=true\nupdateExitCode=0\n' >"$artifact_dir/linux-recovery.txt" + record_assertion linux-outcome passed "recovery or failure reported separately" recovery "Linux installer recovered" +else + printf 'linuxRecovery=false\nupdateExitCode=%s\n' "$update_status" >"$artifact_dir/linux-recovery.txt" + record_assertion linux-outcome passed "recovery or failure reported separately" "exit $update_status" "historical failure observed" +fi +scenario_finish diff --git a/test/cli/install-vm/scenarios/missing-platform-no-bun.sh b/test/cli/install-vm/scenarios/missing-platform-no-bun.sh new file mode 100755 index 000000000..07c243a2c --- /dev/null +++ b/test/cli/install-vm/scenarios/missing-platform-no-bun.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +version_b=$(curl -fsS "$HTTP_URL/fixture-manifest.json" | sed -n 's/.*"versionB"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + +# Omit optional packages at install time, then remove either possible layout defensively. +run_expect install-without-platform 0 npm install -g --omit=optional "hunkdiff@$version_b" --registry "$REGISTRY_URL" +remove_platform_package +run_expect missing-platform 1 node "$npm_config_prefix/lib/node_modules/hunkdiff/bin/hunk.cjs" --version +assert_contains repair-package "$command_dir/missing-platform.log" 'manually installing "hunkdiff-linux-x64"' +assert_tree_has_no_bun_packages no-bun-packages "$npm_config_prefix" +record_observation installSource missing-platform +record_observation hunkVersion unavailable +scenario_finish diff --git a/test/cli/install-vm/scenarios/missing-platform-npm-bun.sh b/test/cli/install-vm/scenarios/missing-platform-npm-bun.sh new file mode 100755 index 000000000..50af52ae6 --- /dev/null +++ b/test/cli/install-vm/scenarios/missing-platform-npm-bun.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +version_b=$(curl -fsS "$HTTP_URL/fixture-manifest.json" | sed -n 's/.*"versionB"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + +# Install Bun as an npm package, which is a separate fallback path from a `bun` command on PATH. +run_expect install-npm-bun 0 npm install -g "bun@1.3.14" --registry "$REGISTRY_URL" +run_expect install-without-platform 0 npm install -g --omit=optional "hunkdiff@$version_b" --registry "$REGISTRY_URL" +remove_platform_package +run_expect fallback-version 0 node "$npm_config_prefix/lib/node_modules/hunkdiff/bin/hunk.cjs" --version +assert_contains npm-bun-fallback "$command_dir/fallback-version.log" "fallback-$version_b" +assert_path_state no-platform missing "$npm_config_prefix/lib/node_modules/hunkdiff/node_modules/hunkdiff-linux-x64" +bun_executable="$npm_config_prefix/lib/node_modules/bun/bin/bun.exe" +assert_path_state npm-bun executable "$bun_executable" +record_observation hunkVersion "fallback-$version_b" +record_observation installSource npm-bun-fallback +record_observation resolvedExecutable "$bun_executable" +scenario_finish diff --git a/test/cli/install-vm/scenarios/missing-platform-system-bun.sh b/test/cli/install-vm/scenarios/missing-platform-system-bun.sh new file mode 100755 index 000000000..15727dfcd --- /dev/null +++ b/test/cli/install-vm/scenarios/missing-platform-system-bun.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +version_b=$(curl -fsS "$HTTP_URL/fixture-manifest.json" | sed -n 's/.*"versionB"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +run_expect install-without-platform 0 npm install -g --omit=optional "hunkdiff@$version_b" --registry "$REGISTRY_URL" +remove_platform_package + +mkdir -p "$HOME/system-bin" +marker="$artifact_dir/system-bun-invoked" +# A marker-writing stub proves the launcher does not confuse PATH Bun with its npm fallback. +cat >"$HOME/system-bin/bun" <>'$marker' +exit 42 +STUB +chmod 0755 "$HOME/system-bin/bun" +export PATH="$HOME/system-bin:$PATH" +run_expect missing-platform 1 node "$npm_config_prefix/lib/node_modules/hunkdiff/bin/hunk.cjs" --version +assert_contains repair-package "$command_dir/missing-platform.log" 'manually installing "hunkdiff-linux-x64"' +record_observation installSource missing-platform-system-bun +record_observation hunkVersion unavailable +if [[ -e $marker ]]; then + record_assertion path-bun-not-fallback failed absent present "PATH Bun was invoked" +else + record_assertion path-bun-not-fallback passed absent absent "Node-resolvable Bun remains distinct" +fi +scenario_finish diff --git a/test/cli/install-vm/scenarios/npm-prebuilt-no-bun.sh b/test/cli/install-vm/scenarios/npm-prebuilt-no-bun.sh new file mode 100755 index 000000000..da58801a3 --- /dev/null +++ b/test/cli/install-vm/scenarios/npm-prebuilt-no-bun.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +current=$(curl -fsS "$HTTP_URL/fixture-manifest.json" | sed -n 's/.*"currentVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + +run_expect install-current 0 npm install -g "hunkdiff@$current" --registry "$REGISTRY_URL" +run_expect version 0 hunk --version +assert_contains version-output "$command_dir/version.log" "$current" +run_expect help 0 hunk --help +assert_contains help-output "$command_dir/help.log" "Usage: hunk" +run_expect markup-guide 0 hunk markup guide +assert_contains markup-guide-output "$command_dir/markup-guide.log" "STML" +# Assert the launcher resolved the compiled platform package without installing any Bun package. +platform_binary="$npm_config_prefix/lib/node_modules/hunkdiff/node_modules/hunkdiff-linux-x64/bin/hunk" +assert_path_state platform-binary executable "$platform_binary" +run_expect dependency-tree 0 npm ls -g --all --json +record_observation hunkVersion "$current" +record_observation installSource npm-prebuilt +record_observation resolvedExecutable "$platform_binary" +record_observation dependencyTreePath commands/dependency-tree.log +assert_tree_has_no_bun_packages no-bun-packages "$npm_config_prefix" +if command -v bun >/dev/null 2>&1; then + record_assertion no-path-bun failed absent present "bun unexpectedly resolved on PATH" +else + record_assertion no-path-bun passed absent absent "bun is not on PATH" +fi +scenario_finish diff --git a/test/cli/install-vm/scenarios/offline-after-install.sh b/test/cli/install-vm/scenarios/offline-after-install.sh new file mode 100755 index 000000000..c24ffd4b7 --- /dev/null +++ b/test/cli/install-vm/scenarios/offline-after-install.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +current=$(curl -fsS "$HTTP_URL/fixture-manifest.json" | sed -n 's/.*"currentVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +run_expect install-online 0 npm install -g "hunkdiff@$current" --registry "$REGISTRY_URL" +# Remove egress only after installation, then prove both the network block and local execution. +ip route del default 2>/dev/null || true +printf '%s\n' offline >"$artifact_dir/network-events.txt" +run_expect_nonzero public-network-blocked curl -fsS --connect-timeout 2 --max-time 3 https://registry.npmjs.org/-/ping +run_expect offline-version 0 hunk --version +assert_contains offline-version-output "$command_dir/offline-version.log" "$current" +run_expect offline-help 0 hunk --help +record_observation hunkVersion "$current" +record_observation installSource npm-prebuilt-offline +record_observation resolvedExecutable "$npm_config_prefix/lib/node_modules/hunkdiff/node_modules/hunkdiff-linux-x64/bin/hunk" +scenario_finish diff --git a/test/cli/install-vm/scenarios/old-npm-bun-fallback.sh b/test/cli/install-vm/scenarios/old-npm-bun-fallback.sh new file mode 100755 index 000000000..28d55fc1a --- /dev/null +++ b/test/cli/install-vm/scenarios/old-npm-bun-fallback.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +version_b=$(curl -fsS "$HTTP_URL/fixture-manifest.json" | sed -n 's/.*"versionB"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + +run_expect install-old-npm-bun 0 npm install -g "bun@1.1.0" --registry "$REGISTRY_URL" +run_expect install-without-platform 0 npm install -g --omit=optional "hunkdiff@$version_b" --registry "$REGISTRY_URL" +remove_platform_package +old_log="$command_dir/old-fallback-version.log" +run_capture old-fallback-version node "$npm_config_prefix/lib/node_modules/hunkdiff/bin/hunk.cjs" --version +old_status=$last_command_status +# Accept and describe either known outcome without turning this observation into a support policy. +if [[ $old_status -eq 0 ]] && grep -Fq "fallback-$version_b" "$old_log"; then + record_assertion old-npm-bun-observation passed "best-effort outcome recorded" fallback "older npm Bun executed the fallback" + printf 'fallbackAvailable=true\nexitCode=0\n' >"$artifact_dir/fallback-policy.txt" + record_observation hunkVersion "fallback-$version_b" + record_observation resolvedExecutable "$npm_config_prefix/lib/node_modules/bun/bin/bun.exe" +elif [[ $old_status -ne 0 ]] && grep -Fq 'manually installing "hunkdiff-linux-x64"' "$old_log"; then + record_assertion old-npm-bun-observation passed "best-effort outcome recorded" unavailable "older npm Bun did not expose the current resolvable fallback path" + printf 'fallbackAvailable=false\nexitCode=%s\n' "$old_status" >"$artifact_dir/fallback-policy.txt" + record_observation hunkVersion unavailable +else + record_assertion old-npm-bun-observation failed "recognized fallback or repair outcome" "exit $old_status" "see commands/old-fallback-version.log" +fi +printf '%s\n' 'This observation does not declare a minimum supported Bun version.' >>"$artifact_dir/fallback-policy.txt" +record_observation installSource npm-bun-fallback-best-effort +scenario_finish diff --git a/test/cli/install-vm/scenarios/pnpm-global-upgrade.sh b/test/cli/install-vm/scenarios/pnpm-global-upgrade.sh new file mode 100755 index 000000000..1c9adcae9 --- /dev/null +++ b/test/cli/install-vm/scenarios/pnpm-global-upgrade.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +install_pnpm +manifest=$(curl -fsS "$HTTP_URL/fixture-manifest.json") +version_a=$(printf '%s\n' "$manifest" | sed -n 's/.*"versionA"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +version_b=$(printf '%s\n' "$manifest" | sed -n 's/.*"versionB"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + +# Upgrade in place without pruning so both versions exercise the same shared virtual store. +run_expect install-a 0 pnpm add -g "hunkdiff@$version_a" +run_expect version-a 0 hunk --version +assert_contains installed-a "$command_dir/version-a.log" "$version_a" +run_expect upgrade-b 0 pnpm update -g hunkdiff --latest +run_expect version-b 0 hunk --version +assert_contains installed-b "$command_dir/version-b.log" "$version_b" +assert_not_contains no-stale-version "$command_dir/version-b.log" "$version_a" +record_observation hunkVersion "$version_b" +record_observation previousHunkVersion "$version_a" +record_observation installSource pnpm-prebuilt +assert_tree_has_no_bun_packages no-bun-packages "$HOME/pnpm" +find "$HOME/pnpm/store" -path '*/@oven/bun-*' -o -path '*/node_modules/bun' >"$artifact_dir/store-projection.txt" 2>/dev/null +record_observation storeProjectionPath store-projection.txt +run_expect dependency-tree 0 pnpm list -g --depth Infinity --json +record_observation dependencyTreePath commands/dependency-tree.log +if [[ -s $artifact_dir/store-projection.txt ]]; then + record_assertion store-projection-clean failed empty nonempty "Bun entered pnpm store projection" +else + record_assertion store-projection-clean passed empty empty "pnpm store projection contains no Bun package" +fi +scenario_finish diff --git a/test/cli/install-vm/scenarios/pnpm-prebuilt-no-bun.sh b/test/cli/install-vm/scenarios/pnpm-prebuilt-no-bun.sh new file mode 100755 index 000000000..94b1f8d96 --- /dev/null +++ b/test/cli/install-vm/scenarios/pnpm-prebuilt-no-bun.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +install_pnpm +current=$(curl -fsS "$HTTP_URL/fixture-manifest.json" | sed -n 's/.*"currentVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + +run_expect install-current 0 pnpm add -g "hunkdiff@$current" +run_expect version 0 hunk --version +assert_contains version-output "$command_dir/version.log" "$current" +run_expect help 0 hunk --help +assert_contains help-output "$command_dir/help.log" "Usage: hunk" +run_expect markup-guide 0 hunk markup guide +assert_contains markup-guide-output "$command_dir/markup-guide.log" "STML" +run_expect dependency-tree 0 pnpm list -g --depth Infinity --json +# Locate the actual projected platform binary rather than trusting only the global shim. +resolved_binary=$(find "$HOME/pnpm" -path '*/hunkdiff-linux-x64/*/bin/hunk' -type f -print -quit 2>/dev/null) +record_observation hunkVersion "$current" +record_observation installSource pnpm-prebuilt +record_observation resolvedExecutable "$resolved_binary" +record_observation dependencyTreePath commands/dependency-tree.log +assert_tree_has_no_bun_packages no-bun-packages "$HOME/pnpm" +if command -v bun >/dev/null 2>&1; then + record_assertion no-path-bun failed absent present "bun unexpectedly resolved on PATH" +else + record_assertion no-path-bun passed absent absent "bun is not on PATH" +fi +scenario_finish diff --git a/test/cli/install-vm/scenarios/system-bun-path.sh b/test/cli/install-vm/scenarios/system-bun-path.sh new file mode 100755 index 000000000..efb631926 --- /dev/null +++ b/test/cli/install-vm/scenarios/system-bun-path.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +manifest=$(curl -fsS "$HTTP_URL/fixture-manifest.json") +version_b=$(printf '%s\n' "$manifest" | sed -n 's/.*"versionB"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +run_expect install-hunk 0 npm install -g "hunkdiff@$version_b" --registry "$REGISTRY_URL" + +mkdir -p "$HOME/system-bin" +marker="$artifact_dir/system-bun-invoked" +# Swap marker-writing Bun stubs across versions to verify the platform binary always wins. +write_stub() { + local version=$1 + cat >"$HOME/system-bin/bun" <>'$marker' +printf '%s\\n' '$version' +STUB + chmod 0755 "$HOME/system-bin/bun" +} +export PATH="$HOME/system-bin:$PATH" +write_stub 1.0.0 +run_expect old-path-bun 0 hunk --version +assert_contains old-platform-wins "$command_dir/old-path-bun.log" "$version_b" +write_stub 1.3.14 +run_expect current-path-bun 0 hunk --version +assert_contains current-platform-wins "$command_dir/current-path-bun.log" "$version_b" +record_observation hunkVersion "$version_b" +record_observation installSource npm-prebuilt +record_observation resolvedExecutable "$npm_config_prefix/lib/node_modules/hunkdiff/node_modules/hunkdiff-linux-x64/bin/hunk" +if [[ -e $marker ]]; then + record_assertion path-bun-not-spawned failed absent present "standalone Bun was invoked" +else + record_assertion path-bun-not-spawned passed absent absent "platform package stayed primary" +fi +scenario_finish From b13a39e4f7cfbbca7795d41eb8a9deb5ccddfd40 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 29 Aug 2026 17:28:08 -0400 Subject: [PATCH 2/5] fix(test): harden install VM orchestration --- test/cli/install-vm/Dockerfile | 11 +- test/cli/install-vm/README.md | 4 + test/cli/install-vm/contract.test.ts | 38 +++--- test/cli/install-vm/contract.ts | 25 +++- .../install-vm/guest/prepare-base-image.sh | 65 ++++++---- test/cli/install-vm/guest/scenario-lib.sh | 2 +- .../cli/install-vm/prepare-base-image.test.ts | 113 ++++++++++++++++++ test/cli/install-vm/runner.ts | 3 +- test/cli/install-vm/runtime-lock.ts | 62 ++-------- .../scenarios/pnpm-prebuilt-no-bun.sh | 7 +- 10 files changed, 231 insertions(+), 99 deletions(-) create mode 100644 test/cli/install-vm/prepare-base-image.test.ts diff --git a/test/cli/install-vm/Dockerfile b/test/cli/install-vm/Dockerfile index 8cd8f388d..be52e026d 100644 --- a/test/cli/install-vm/Dockerfile +++ b/test/cli/install-vm/Dockerfile @@ -1,4 +1,6 @@ -FROM ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 +# The runner always overrides this parse-safe default with the digest from pins.json. +ARG CONTROLLER_IMAGE=scratch +FROM ${CONTROLLER_IMAGE} ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ @@ -16,10 +18,11 @@ RUN apt-get update \ xz-utils=5.6.1+really5.4.5-1ubuntu0.3 \ && rm -rf /var/lib/apt/lists/* -ARG NODE_VERSION=24.14.1 -ARG NODE_SHA256=84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be +ARG NODE_VERSION +ARG NODE_URL +ARG NODE_SHA256 RUN curl --fail --show-error --location --connect-timeout 15 --max-time 300 --retry 3 \ - -o /tmp/node.tar.xz "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + -o /tmp/node.tar.xz "${NODE_URL}" \ && echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \ && tar -xJf /tmp/node.tar.xz -C /opt \ && ln -s "/opt/node-v${NODE_VERSION}-linux-x64/bin/node" /usr/local/bin/node \ diff --git a/test/cli/install-vm/README.md b/test/cli/install-vm/README.md index 91c86da3b..7913955f6 100644 --- a/test/cli/install-vm/README.md +++ b/test/cli/install-vm/README.md @@ -24,6 +24,10 @@ The first run lazily builds the controller image and downloads checksum-pinned F bun run test:install-vm:clean ``` +The runner deliberately does not reclaim a stale `tmp/install-vm/.lock`, because deleting a lock +owned by a racing process is unsafe. After an interrupted host dies, confirm no suite is running and +remove that lock directory manually before retrying. + Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, and Firecracker console output. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. ## Security boundary diff --git a/test/cli/install-vm/contract.test.ts b/test/cli/install-vm/contract.test.ts index 76059b59a..337c6b36a 100644 --- a/test/cli/install-vm/contract.test.ts +++ b/test/cli/install-vm/contract.test.ts @@ -6,6 +6,7 @@ import { assertDistinctInstallVmRuntimePaths, assertSafeCleanTarget, assertSafeInstallVmRuntimePath, + buildControllerImageCommand, buildDockerRunCommand, buildInstallVmJunit, evaluateCommandExpectation, @@ -93,6 +94,12 @@ describe("install VM contract", () => { historical: { ...validPins.historical, bunVersion: "^1.4.0" }, }), ).toThrow("Historical bun must be pinned"); + + const buildCommand = buildControllerImageCommand("hunk-install-vm:test", "/harness", validPins); + expect(buildCommand).toContain(`CONTROLLER_IMAGE=${validPins.controllerImage}`); + expect(buildCommand).toContain(`NODE_VERSION=${validPins.node.version}`); + expect(buildCommand).toContain(`NODE_URL=${validPins.node.url}`); + expect(buildCommand).toContain(`NODE_SHA256=${validPins.node.sha256}`); }); test("validates scenarios and rejects unsafe or duplicate definitions", () => { @@ -222,7 +229,7 @@ describe("install VM contract", () => { } }); - test("acquires one runtime lock and reclaims a stale pid without deleting a racing owner", () => { + test("acquires one runtime lock and refuses to reclaim stale or invalid owners", () => { const root = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-lock-")); const lock = path.join(root, ".lock"); try { @@ -231,31 +238,20 @@ describe("install VM contract", () => { "already running", ); release(); - mkdirSync(lock); - writeFileSync(path.join(lock, "owner.json"), '{"pid":303}\n'); - const releaseReclaimed = acquireInstallVmRuntimeLock(lock, { - pid: 404, - alive: () => false, - }); - releaseReclaimed(); mkdirSync(lock); - writeFileSync(path.join(lock, "owner.json"), '{"pid":505}\n'); - let raced = false; + writeFileSync(path.join(lock, "owner.json"), '{"pid":303}\n'); expect(() => acquireInstallVmRuntimeLock(lock, { - pid: 606, - alive: (pid) => pid === 707, - beforeStaleClaim: () => { - if (raced) return; - raced = true; - rmSync(lock, { recursive: true }); - mkdirSync(lock); - writeFileSync(path.join(lock, "owner.json"), '{"pid":707}\n'); - }, + pid: 404, + alive: () => false, }), - ).toThrow("already running under pid 707"); - expect(readFileSync(path.join(lock, "owner.json"), "utf8")).toContain("707"); + ).toThrow("belongs to stale pid 303"); + expect(readFileSync(path.join(lock, "owner.json"), "utf8")).toContain("303"); + + writeFileSync(path.join(lock, "owner.json"), '{"pid":"broken"}\n'); + expect(() => acquireInstallVmRuntimeLock(lock, { pid: 505 })).toThrow("has no valid owner"); + expect(readFileSync(path.join(lock, "owner.json"), "utf8")).toContain("broken"); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/test/cli/install-vm/contract.ts b/test/cli/install-vm/contract.ts index c4e9a3fd0..380b62b11 100644 --- a/test/cli/install-vm/contract.ts +++ b/test/cli/install-vm/contract.ts @@ -97,7 +97,7 @@ const SCENARIO_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+$/; -/** Validate that every remote VM input is immutable and checksum-pinned. */ +/** Validate checksum-attested VM inputs and exact versions used by compatibility scenarios. */ export function validateInstallVmPins(value: unknown) { if (!value || typeof value !== "object") throw new Error("Pin manifest must be an object."); const pins = value as Record; @@ -372,6 +372,29 @@ export interface DockerRunPaths { outputDir: string; } +/** Build the controller image from the same validated image and Node pins used by the guest. */ +export function buildControllerImageCommand( + image: string, + harnessRoot: string, + pins: InstallVmPins, +) { + return [ + "docker", + "build", + "--build-arg", + `CONTROLLER_IMAGE=${pins.controllerImage}`, + "--build-arg", + `NODE_VERSION=${pins.node.version}`, + "--build-arg", + `NODE_URL=${pins.node.url}`, + "--build-arg", + `NODE_SHA256=${pins.node.sha256}`, + "--tag", + image, + harnessRoot, + ]; +} + /** Build the least-privilege Docker command used only by the explicit VM runner. */ export function buildDockerRunCommand( image: string, diff --git a/test/cli/install-vm/guest/prepare-base-image.sh b/test/cli/install-vm/guest/prepare-base-image.sh index 5c1a39ffa..22c5a5024 100755 --- a/test/cli/install-vm/guest/prepare-base-image.sh +++ b/test/cli/install-vm/guest/prepare-base-image.sh @@ -39,17 +39,27 @@ download_checked vmlinux "$(pin_value '.kernel.url')" "$(pin_value '.kernel.sha2 download_checked rootfs.squashfs "$(pin_value '.rootfs.url')" "$(pin_value '.rootfs.sha256')" download_checked node.tar.xz "$(pin_value '.node.url')" "$(pin_value '.node.sha256')" -extract_dir=$(mktemp -d) -trap 'rm -rf "$extract_dir"' RETURN -tar --no-same-owner -xzf "$cache/downloads/firecracker-${fc_version}.tgz" -C "$extract_dir" -install -m 0755 \ - "$extract_dir/release-v${fc_version}-x86_64/firecracker-v${fc_version}-x86_64" \ - "$cache/base/firecracker.partial.$$" -mv "$cache/base/firecracker.partial.$$" "$cache/base/firecracker" -trap - RETURN -rm -rf "$extract_dir" -cp "$cache/downloads/vmlinux" "$cache/base/vmlinux.partial" -mv "$cache/base/vmlinux.partial" "$cache/base/vmlinux" +install_firecracker() ( + local extract_dir partial + extract_dir=$(mktemp -d) + partial="$cache/base/firecracker.partial.$$" + trap 'rm -rf "$extract_dir" "$partial"' EXIT + tar --no-same-owner -xzf "$cache/downloads/firecracker-${fc_version}.tgz" -C "$extract_dir" + install -m 0755 \ + "$extract_dir/release-v${fc_version}-x86_64/firecracker-v${fc_version}-x86_64" \ + "$partial" + mv "$partial" "$cache/base/firecracker" +) + +install_kernel() ( + local partial="$cache/base/vmlinux.partial.$$" + trap 'rm -f "$partial"' EXIT + cp "$cache/downloads/vmlinux" "$partial" + mv "$partial" "$cache/base/vmlinux" +) + +install_firecracker +install_kernel # Older harness revisions cached an SSH identity; current runs use an ephemeral key. rm -f "$cache/base/id_ed25519" "$cache/base/id_ed25519.pub" @@ -73,14 +83,23 @@ if [[ -f $base && -f $base_digest ]]; then } fi -if [[ ! -f $base ]]; then - # Expand the read-only rootfs into a sparse ext4 image that each scenario can clone and mutate. +build_base_image() ( + local build_dir partial digest_partial identity_partial node_version digest committed=0 build_dir=$(mktemp -d) partial="${base}.partial.$$" + digest_partial="${base_digest}.partial.$$" + identity_partial="${base_identity}.partial.$$" + # ShellCheck cannot see that the EXIT trap invokes this cleanup function. + # shellcheck disable=SC2317 cleanup_base() { - rm -rf "$build_dir" "$partial" + rm -rf "$build_dir" "$partial" "$digest_partial" "$identity_partial" + if [[ $committed == 0 ]]; then + rm -f "$base" "$base_digest" "$base_identity" + fi } - trap cleanup_base RETURN + trap cleanup_base EXIT + + # Expand the read-only rootfs into a sparse ext4 image that each scenario can clone and mutate. unsquashfs -d "$build_dir/root" "$cache/downloads/rootfs.squashfs" >/dev/null mkdir -p "$build_dir/root/root/.ssh" "$build_dir/root/opt" "$build_dir/root/etc/systemd/network" : >"$build_dir/root/root/.ssh/authorized_keys" @@ -102,13 +121,17 @@ NETWORK truncate -s 4G "$partial" mkfs.ext4 -q -d "$build_dir/root" -F "$partial" e2fsck -fn "$partial" >/dev/null + digest=$(sha256sum "$partial" | cut -d' ' -f1) + printf '%s %s\n' "$digest" "$(basename "$base")" >"$digest_partial" + printf '%s\n' "$identity" >"$identity_partial" mv "$partial" "$base" - (cd "$(dirname "$base")" && sha256sum "$(basename "$base")" >"$(basename "$base_digest").partial") - printf '%s\n' "$identity" >"${base_identity}.partial" - mv "${base_digest}.partial" "$base_digest" - mv "${base_identity}.partial" "$base_identity" - trap - RETURN - rm -rf "$build_dir" + mv "$digest_partial" "$base_digest" + mv "$identity_partial" "$base_identity" + committed=1 +) + +if [[ ! -f $base ]]; then + build_base_image fi (cd "$(dirname "$base")" && sha256sum -c "$(basename "$base_digest")" >/dev/null) diff --git a/test/cli/install-vm/guest/scenario-lib.sh b/test/cli/install-vm/guest/scenario-lib.sh index beb1cfc03..e08b6f390 100755 --- a/test/cli/install-vm/guest/scenario-lib.sh +++ b/test/cli/install-vm/guest/scenario-lib.sh @@ -128,7 +128,7 @@ assert_path_state() { assert_tree_has_no_bun_packages() { local id=$1 root=$2 found - found=$(find "$root" -type d \( -path '*/node_modules/bun' -o -path '*/node_modules/@oven/bun-*' \) -print -quit 2>/dev/null) + found=$(find "$root" \( -type d -o -type l \) \( -path '*/node_modules/bun' -o -path '*/node_modules/@oven/bun-*' \) -print -quit 2>/dev/null) if [[ -z $found ]]; then record_assertion "$id" passed "no bun or @oven/bun-* package" absent "$root" else diff --git a/test/cli/install-vm/prepare-base-image.test.ts b/test/cli/install-vm/prepare-base-image.test.ts new file mode 100644 index 000000000..cc1bf8a2e --- /dev/null +++ b/test/cli/install-vm/prepare-base-image.test.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "bun:test"; + +const requiredToolsAvailable = ["bash", "jq", "tar"].every( + (tool) => Bun.spawnSync(["sh", "-c", `command -v "$1" >/dev/null`, "sh", tool]).exitCode === 0, +); + +/** Return the SHA-256 digest of one local fixture file. */ +function sha256(file: string) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +/** Return every relative path below one test directory. */ +function listTree(root: string, current = root): string[] { + return readdirSync(current, { withFileTypes: true }).flatMap((entry) => { + const child = path.join(current, entry.name); + const relative = path.relative(root, child); + return entry.isDirectory() ? [relative, ...listTree(root, child)] : [relative]; + }); +} + +test.skipIf(process.platform !== "linux" || !requiredToolsAvailable)( + "base-image preparation removes partial disks after a build failure", + () => { + const root = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-base-cleanup-")); + const cache = path.join(root, "cache"); + const downloads = path.join(cache, "downloads"); + const archive = path.join(root, "firecracker-archive"); + const nodeArchive = path.join(root, "node-archive"); + const stubs = path.join(root, "bin"); + const scratch = path.join(root, "scratch"); + mkdirSync(downloads, { recursive: true }); + mkdirSync(path.join(archive, "release-v1.0.0-x86_64"), { recursive: true }); + mkdirSync(path.join(nodeArchive, "node-v1.0.0-linux-x64"), { recursive: true }); + mkdirSync(stubs); + mkdirSync(scratch); + + try { + const firecracker = path.join(archive, "release-v1.0.0-x86_64", "firecracker-v1.0.0-x86_64"); + writeFileSync(firecracker, "#!/bin/sh\nexit 0\n"); + chmodSync(firecracker, 0o755); + + const firecrackerTar = path.join(downloads, "firecracker-1.0.0.tgz"); + const nodeTar = path.join(downloads, "node.tar.xz"); + expect(Bun.spawnSync(["tar", "-czf", firecrackerTar, "-C", archive, "."]).exitCode).toBe(0); + expect(Bun.spawnSync(["tar", "-cJf", nodeTar, "-C", nodeArchive, "."]).exitCode).toBe(0); + writeFileSync(path.join(downloads, "vmlinux"), "kernel\n"); + writeFileSync(path.join(downloads, "rootfs.squashfs"), "rootfs\n"); + + const unsquashfs = path.join(stubs, "unsquashfs"); + writeFileSync( + unsquashfs, + '#!/bin/sh\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = -d ]; then mkdir -p "$2"; exit 0; fi\n shift\ndone\nexit 2\n', + ); + chmodSync(unsquashfs, 0o755); + const mkfs = path.join(stubs, "mkfs.ext4"); + writeFileSync(mkfs, "#!/bin/sh\nexit 23\n"); + chmodSync(mkfs, 0o755); + + const pinsPath = path.join(root, "pins.json"); + writeFileSync( + pinsPath, + `${JSON.stringify({ + firecracker: { + version: "1.0.0", + url: "https://example.test/firecracker", + sha256: sha256(firecrackerTar), + }, + kernel: { + version: "1.0.0", + url: "https://example.test/kernel", + sha256: sha256(path.join(downloads, "vmlinux")), + }, + rootfs: { + version: "1.0.0", + url: "https://example.test/rootfs", + sha256: sha256(path.join(downloads, "rootfs.squashfs")), + }, + node: { + version: "1.0.0", + url: "https://example.test/node", + sha256: sha256(nodeTar), + }, + })}\n`, + ); + + const script = path.join(import.meta.dir, "guest", "prepare-base-image.sh"); + const result = Bun.spawnSync(["bash", script, cache, pinsPath], { + env: { ...process.env, PATH: `${stubs}:${process.env.PATH}`, TMPDIR: scratch }, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode).toBe(23); + expect(listTree(cache).filter((entry) => entry.includes(".partial."))).toEqual([]); + expect(readdirSync(scratch)).toEqual([]); + expect(existsSync(path.join(cache, "base", "rootfs.base.ext4"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +); diff --git a/test/cli/install-vm/runner.ts b/test/cli/install-vm/runner.ts index 4d00ab576..392e44fbc 100644 --- a/test/cli/install-vm/runner.ts +++ b/test/cli/install-vm/runner.ts @@ -23,6 +23,7 @@ import { assertDistinctInstallVmRuntimePaths, assertSafeCleanTarget, assertSafeInstallVmRuntimePath, + buildControllerImageCommand, buildDockerRunCommand, buildInstallVmJunit, loadScenarioManifest, @@ -297,7 +298,7 @@ export async function main(argv = process.argv.slice(2)) { } const image = controllerImageTag(); - await commandRunner.run(["docker", "build", "--tag", image, harnessRoot]); + await commandRunner.run(buildControllerImageCommand(image, harnessRoot, pins)); const revalidatedPaths = { cacheDir: assertSafeInstallVmRuntimePath(repoRoot, cacheDir), fixtureDir: assertSafeInstallVmRuntimePath(repoRoot, fixtureDir), diff --git a/test/cli/install-vm/runtime-lock.ts b/test/cli/install-vm/runtime-lock.ts index 3b1692e71..6eb8c8a1e 100644 --- a/test/cli/install-vm/runtime-lock.ts +++ b/test/cli/install-vm/runtime-lock.ts @@ -1,13 +1,4 @@ -import { - existsSync, - lstatSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { randomUUID } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; /** Return whether a process id still names a live process, treating permission denial as live. */ @@ -29,13 +20,12 @@ function readOwnerPid(lockDirectory: string) { return Number(owner.pid); } -/** Acquire the shared install-VM runtime lock, reclaiming only a demonstrably stale owner. */ +/** Acquire the shared install-VM runtime lock without racing stale-owner reclamation. */ export function acquireInstallVmRuntimeLock( lockDirectory: string, options: { pid?: number; alive?: (pid: number) => boolean; - beforeStaleClaim?: () => void; } = {}, ) { const pid = options.pid ?? process.pid; @@ -44,9 +34,14 @@ export function acquireInstallVmRuntimeLock( const create = () => { mkdirSync(lockDirectory); - writeFileSync(path.join(lockDirectory, "owner.json"), `${JSON.stringify({ pid })}\n`, { - mode: 0o600, - }); + try { + writeFileSync(path.join(lockDirectory, "owner.json"), `${JSON.stringify({ pid })}\n`, { + mode: 0o600, + }); + } catch (error) { + rmSync(lockDirectory, { recursive: true, force: true }); + throw error; + } }; for (;;) { @@ -70,40 +65,9 @@ export function acquireInstallVmRuntimeLock( if (alive(observedOwner)) { throw new Error(`Install VM suite is already running under pid ${observedOwner}.`); } - - options.beforeStaleClaim?.(); - const quarantine = `${lockDirectory}.stale-${pid}-${randomUUID()}`; - try { - renameSync(lockDirectory, quarantine); - } catch (claimError) { - if ((claimError as NodeJS.ErrnoException).code === "ENOENT") continue; - throw claimError; - } - - let claimedOwner: number | undefined; - try { - claimedOwner = readOwnerPid(quarantine); - } catch { - // An invalid stale lock is safe to quarantine, but never silently replace a new valid one. - } - if (claimedOwner !== undefined && claimedOwner !== observedOwner) { - try { - renameSync(quarantine, lockDirectory); - } catch { - rmSync(quarantine, { recursive: true, force: true }); - } - continue; - } - - try { - create(); - } catch (createError) { - rmSync(quarantine, { recursive: true, force: true }); - if ((createError as NodeJS.ErrnoException).code === "EEXIST") continue; - throw createError; - } - rmSync(quarantine, { recursive: true, force: true }); - break; + throw new Error( + `Install VM lock belongs to stale pid ${observedOwner}; remove it after confirming no suite is running: ${lockDirectory}`, + ); } } diff --git a/test/cli/install-vm/scenarios/pnpm-prebuilt-no-bun.sh b/test/cli/install-vm/scenarios/pnpm-prebuilt-no-bun.sh index 94b1f8d96..9e8088834 100755 --- a/test/cli/install-vm/scenarios/pnpm-prebuilt-no-bun.sh +++ b/test/cli/install-vm/scenarios/pnpm-prebuilt-no-bun.sh @@ -15,7 +15,12 @@ run_expect markup-guide 0 hunk markup guide assert_contains markup-guide-output "$command_dir/markup-guide.log" "STML" run_expect dependency-tree 0 pnpm list -g --depth Infinity --json # Locate the actual projected platform binary rather than trusting only the global shim. -resolved_binary=$(find "$HOME/pnpm" -path '*/hunkdiff-linux-x64/*/bin/hunk' -type f -print -quit 2>/dev/null) +resolved_binary=$(find "$HOME/pnpm" -path '*/hunkdiff-linux-x64/*/bin/hunk' \( -type f -o -type l \) -print -quit 2>/dev/null) +if [[ -n $resolved_binary ]]; then + assert_path_state projected-platform-binary executable "$resolved_binary" +else + record_assertion projected-platform-binary failed executable missing "pnpm projection did not contain hunkdiff-linux-x64" +fi record_observation hunkVersion "$current" record_observation installSource pnpm-prebuilt record_observation resolvedExecutable "$resolved_binary" From 6713822774eb88c896b06d16bfa3bf2bf76481b8 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 29 Aug 2026 17:43:48 -0400 Subject: [PATCH 3/5] test(install): cover npm and curl upgrades --- test/cli/install-vm/README.md | 2 +- test/cli/install-vm/prepare-fixtures.test.ts | 15 ++++ test/cli/install-vm/prepare-fixtures.ts | 68 ++++++++++++++----- test/cli/install-vm/scenarios.json | 14 ++++ test/cli/install-vm/scenarios/curl-upgrade.sh | 42 ++++++++++++ .../scenarios/npm-global-upgrade.sh | 29 ++++++++ 6 files changed, 151 insertions(+), 19 deletions(-) create mode 100755 test/cli/install-vm/scenarios/curl-upgrade.sh create mode 100755 test/cli/install-vm/scenarios/npm-global-upgrade.sh diff --git a/test/cli/install-vm/README.md b/test/cli/install-vm/README.md index 7913955f6..1c1886357 100644 --- a/test/cli/install-vm/README.md +++ b/test/cli/install-vm/README.md @@ -1,6 +1,6 @@ # Optional Firecracker install compatibility suite -This suite tests Hunk's Linux x64 npm, pnpm, legacy Bun-fallback, offline, and curl installation behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets. +This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets. ## Run diff --git a/test/cli/install-vm/prepare-fixtures.test.ts b/test/cli/install-vm/prepare-fixtures.test.ts index 70839fb7d..24c806d97 100644 --- a/test/cli/install-vm/prepare-fixtures.test.ts +++ b/test/cli/install-vm/prepare-fixtures.test.ts @@ -63,6 +63,8 @@ function writeTestFixtures(repo: string, fixtures: string) { const archiveName = "hunkdiff-linux-x64.tar.gz"; for (const [version, digestOverride] of [ ["1.0.0", undefined], + [FIXTURE_VERSION_A, undefined], + [FIXTURE_VERSION_B, undefined], [CURL_BAD_CHECKSUM_VERSION, "0".repeat(64)], [CURL_TRUNCATED_VERSION, undefined], ] as const) { @@ -127,6 +129,19 @@ describe("install VM package fixtures", () => { const manifest = writeTestFixtures(repo, fixtures); expect(verifyInstallVmFixtures(repo, fixtures).sourceIdentity).toBe(manifest.sourceIdentity); + const curlUpgradeArchive = path.join( + fixtures, + "http", + "download", + `v${FIXTURE_VERSION_B}`, + "hunkdiff-linux-x64.tar.gz", + ); + writeFileSync(curlUpgradeArchive, "tampered\n"); + expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow( + "Invalid curl archive/checksum fixture", + ); + writeFileSync(curlUpgradeArchive, `archive ${FIXTURE_VERSION_B}\n`); + const httpManifest = path.join(fixtures, "http", "fixture-manifest.json"); writeFileSync(httpManifest, `${JSON.stringify({ ...manifest, currentVersion: "2.0.0" })}\n`); expect(() => verifyInstallVmFixtures(repo, fixtures)).toThrow( diff --git a/test/cli/install-vm/prepare-fixtures.ts b/test/cli/install-vm/prepare-fixtures.ts index d945df4bb..7a4a4a384 100644 --- a/test/cli/install-vm/prepare-fixtures.ts +++ b/test/cli/install-vm/prepare-fixtures.ts @@ -206,6 +206,8 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { } }; verifyArchiveChecksum(manifest.currentVersion); + verifyArchiveChecksum(manifest.versionA); + verifyArchiveChecksum(manifest.versionB); verifyArchiveChecksum(CURL_BAD_CHECKSUM_VERSION, "0".repeat(64)); verifyArchiveChecksum(CURL_TRUNCATED_VERSION); if (!existsSync(path.join(httpRoot, "install.sh"))) { @@ -247,6 +249,22 @@ async function packPackage(packageDirectory: string, packageOutput: string) { return `${manifest.name.replace(/^@/, "").replaceAll("/", "-")}-${manifest.version}.tgz`; } +function writeSyntheticBinary(binaryPath: string, version: string) { + writeFileSync( + binaryPath, + `#!/bin/sh\ncase "\${1:-}" in\n --version|-v|version) printf '%s\\n' '${version}' ;;\n --help|-h) printf '%s\\n' 'Usage: hunk [options]' ;;\n *) printf '%s\\n' 'fixture hunk ${version}' ;;\nesac\n`, + ); + chmodSync(binaryPath, 0o755); +} + +function copyFixtureSkills(repoRoot: string, destination: string) { + for (const skill of ["hunk-review", "hunk-extensions"]) { + cpSync(path.join(repoRoot, "skills", skill), path.join(destination, "skills", skill), { + recursive: true, + }); + } +} + async function stageSyntheticPackage( repoRoot: string, stageRoot: string, @@ -257,30 +275,14 @@ async function stageSyntheticPackage( const platformDir = path.join(stageRoot, `${platform.name}-${version}`); mkdirSync(path.join(platformDir, "bin"), { recursive: true }); writeJson(path.join(platformDir, "package.json"), platform); - const binaryPath = path.join(platformDir, "bin", "hunk"); - writeFileSync( - binaryPath, - `#!/bin/sh\ncase "\${1:-}" in\n --version|-v|version) printf '%s\\n' '${version}' ;;\n --help|-h) printf '%s\\n' 'Usage: hunk [options]' ;;\n *) printf '%s\\n' 'fixture hunk ${version}' ;;\nesac\n`, - ); - chmodSync(binaryPath, 0o755); + writeSyntheticBinary(path.join(platformDir, "bin", "hunk"), version); const metaDir = path.join(stageRoot, `hunkdiff-${version}`); mkdirSync(path.join(metaDir, "bin"), { recursive: true }); mkdirSync(path.join(metaDir, "dist", "npm"), { recursive: true }); copyFileSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); chmodSync(path.join(metaDir, "bin", "hunk.cjs"), 0o755); - cpSync( - path.join(repoRoot, "skills", "hunk-review"), - path.join(metaDir, "skills", "hunk-review"), - { - recursive: true, - }, - ); - cpSync( - path.join(repoRoot, "skills", "hunk-extensions"), - path.join(metaDir, "skills", "hunk-extensions"), - { recursive: true }, - ); + copyFixtureSkills(repoRoot, metaDir); writeFileSync( path.join(metaDir, "dist", "npm", "main.js"), `const args = process.argv.slice(2);\nif (args.includes('--version') || args[0] === 'version') console.log('fallback-${version}');\nelse console.log('fallback fixture ${version}');\n`, @@ -305,6 +307,34 @@ async function stageSyntheticPackage( ]; } +async function stageSyntheticCurlArchive( + repoRoot: string, + stageRoot: string, + downloads: string, + version: string, +) { + const archiveRoot = path.join(stageRoot, `curl-${version}`); + const artifactDir = path.join(archiveRoot, "hunkdiff-linux-x64"); + mkdirSync(artifactDir, { recursive: true }); + writeSyntheticBinary(path.join(artifactDir, "hunk"), version); + copyFixtureSkills(repoRoot, artifactDir); + writeFileSync(path.join(artifactDir, "skills", ".fixture-version"), `${version}\n`); + writeJson(path.join(artifactDir, "metadata.json"), { + packageName: "hunkdiff-linux-x64", + os: "linux", + cpu: "x64", + binaryName: "hunk", + fixtureVersion: version, + }); + + const downloadDir = path.join(downloads, `v${version}`); + const archiveName = "hunkdiff-linux-x64.tar.gz"; + mkdirSync(downloadDir, { recursive: true }); + const archive = path.join(downloadDir, archiveName); + await run(["tar", "-czf", archive, "-C", archiveRoot, path.basename(artifactDir)]); + writeFileSync(path.join(downloadDir, "SHA256SUMS"), `${sha256(archive)} ${archiveName}\n`); +} + /** Prepare local registry and curl fixtures from the explicitly built checkout. */ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: string) { const releaseRoot = releaseNpmDir(repoRoot); @@ -367,6 +397,8 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str path.join(goodDownloadDir, "SHA256SUMS"), `${sha256(goodArchive)} ${archiveName}\n`, ); + await stageSyntheticCurlArchive(repoRoot, stageRoot, downloads, FIXTURE_VERSION_A); + await stageSyntheticCurlArchive(repoRoot, stageRoot, downloads, FIXTURE_VERSION_B); const badChecksumDir = path.join(downloads, `v${CURL_BAD_CHECKSUM_VERSION}`); mkdirSync(badChecksumDir, { recursive: true }); diff --git a/test/cli/install-vm/scenarios.json b/test/cli/install-vm/scenarios.json index 6edb86380..5e9bbd08c 100644 --- a/test/cli/install-vm/scenarios.json +++ b/test/cli/install-vm/scenarios.json @@ -8,6 +8,13 @@ "script": "npm-prebuilt-no-bun.sh", "network": "local" }, + { + "id": "npm-global-upgrade", + "description": "Upgrade between two registry versions through npm without introducing Bun.", + "profile": "node", + "script": "npm-global-upgrade.sh", + "network": "local" + }, { "id": "pnpm-prebuilt-no-bun", "description": "Install the current prebuilt package with pnpm and no Bun runtime.", @@ -71,6 +78,13 @@ "script": "curl-clean-machine.sh", "network": "local" }, + { + "id": "curl-upgrade", + "description": "Upgrade a working curl installation to a newer verified release.", + "profile": "minimal", + "script": "curl-upgrade.sh", + "network": "local" + }, { "id": "curl-failure-preservation", "description": "Preserve an existing curl install after checksum, truncation, and unavailable-asset failures.", diff --git a/test/cli/install-vm/scenarios/curl-upgrade.sh b/test/cli/install-vm/scenarios/curl-upgrade.sh new file mode 100755 index 000000000..db74c7004 --- /dev/null +++ b/test/cli/install-vm/scenarios/curl-upgrade.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +manifest=$(curl -fsS "$HTTP_URL/fixture-manifest.json") +version_a=$(printf '%s\n' "$manifest" | sed -n 's/.*"versionA"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +version_b=$(printf '%s\n' "$manifest" | sed -n 's/.*"versionB"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +curl -fsS "$HTTP_URL/install.sh" -o "$HOME/install.sh" + +# Install A first, then exercise the production installer's successful replacement path with B. +run_expect install-a 0 env HOME="$HOME" HUNK_NO_MODIFY_PATH=1 sh "$HOME/install.sh" "$version_a" +binary="$HOME/.hunk/bin/hunk" +run_expect version-a 0 "$binary" --version +assert_contains installed-a "$command_dir/version-a.log" "$version_a" +assert_contains checksum-a "$command_dir/install-a.log" "Verifying checksum" +before_binary=$(sha256sum "$binary" | cut -d' ' -f1) + +run_expect upgrade-b 0 env HOME="$HOME" HUNK_NO_MODIFY_PATH=1 sh "$HOME/install.sh" "$version_b" +run_expect version-b 0 "$binary" --version +assert_contains installed-b "$command_dir/version-b.log" "$version_b" +assert_not_contains no-stale-version "$command_dir/version-b.log" "$version_a" +assert_contains checksum-b "$command_dir/upgrade-b.log" "Verifying checksum" +after_binary=$(sha256sum "$binary" | cut -d' ' -f1) +if [[ $after_binary != "$before_binary" ]]; then + record_assertion binary-replaced passed "different binary digest" changed "curl upgrade activated version B" +else + record_assertion binary-replaced failed "different binary digest" unchanged "curl upgrade retained version A bytes" +fi +assert_path_state upgraded-binary executable "$binary" +assert_path_state upgraded-review-skill file "$HOME/.hunk/skills/hunk-review/SKILL.md" +assert_path_state upgraded-extension-skill file "$HOME/.hunk/skills/hunk-extensions/SKILL.md" +assert_contains skills-version-b "$HOME/.hunk/skills/.fixture-version" "$version_b" +assert_contains metadata-version-b "$HOME/.hunk/metadata.json" "\"fixtureVersion\": \"$version_b\"" +assert_path_state no-partial-binary missing "$HOME/.hunk/bin/hunk.new" +assert_path_state no-staged-skills missing "$HOME/.hunk/skills.new" +assert_path_state no-retired-skills missing "$HOME/.hunk/skills.old" +record_observation hunkVersion "$version_b" +record_observation previousHunkVersion "$version_a" +record_observation installSource curl-upgrade +record_observation resolvedExecutable "$binary" +scenario_finish diff --git a/test/cli/install-vm/scenarios/npm-global-upgrade.sh b/test/cli/install-vm/scenarios/npm-global-upgrade.sh new file mode 100755 index 000000000..7e0408aa7 --- /dev/null +++ b/test/cli/install-vm/scenarios/npm-global-upgrade.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# shellcheck source=../guest/scenario-lib.sh +# shellcheck disable=SC1091,SC2154 +source /tmp/hunk-install-vm/scenario-lib.sh +setup_profile +manifest=$(curl -fsS "$HTTP_URL/fixture-manifest.json") +version_a=$(printf '%s\n' "$manifest" | sed -n 's/.*"versionA"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +version_b=$(printf '%s\n' "$manifest" | sed -n 's/.*"versionB"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + +# Reinstall the global package at B through npm's normal upgrade path without clearing its cache. +run_expect install-a 0 npm install -g "hunkdiff@$version_a" --registry "$REGISTRY_URL" +run_expect version-a 0 hunk --version +assert_contains installed-a "$command_dir/version-a.log" "$version_a" +run_expect upgrade-b 0 npm install -g "hunkdiff@$version_b" --registry "$REGISTRY_URL" +run_expect version-b 0 hunk --version +assert_contains installed-b "$command_dir/version-b.log" "$version_b" +assert_not_contains no-stale-version "$command_dir/version-b.log" "$version_a" +platform_root="$npm_config_prefix/lib/node_modules/hunkdiff/node_modules/hunkdiff-linux-x64" +assert_path_state platform-binary-b executable "$platform_root/bin/hunk" +run_expect platform-manifest-b 0 node -e 'console.log(require(process.argv[1]).version)' "$platform_root/package.json" +assert_contains platform-version-b "$command_dir/platform-manifest-b.log" "$version_b" +run_expect dependency-tree 0 npm ls -g --all --json +assert_tree_has_no_bun_packages no-bun-packages "$npm_config_prefix" +record_observation hunkVersion "$version_b" +record_observation previousHunkVersion "$version_a" +record_observation installSource npm-prebuilt-upgrade +record_observation resolvedExecutable "$platform_root/bin/hunk" +record_observation dependencyTreePath commands/dependency-tree.log +scenario_finish From b0817d8772828ae391797bdbcac77d19c9db828a Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 29 Aug 2026 18:37:30 -0400 Subject: [PATCH 4/5] test(install): harden VM release evidence --- skills/hunk-release/SKILL.md | 31 ++-- test/cli/install-vm/README.md | 2 +- test/cli/install-vm/contract.test.ts | 57 ++++++- test/cli/install-vm/contract.ts | 1 + test/cli/install-vm/prepare-fixtures.test.ts | 67 +++++++- test/cli/install-vm/prepare-fixtures.ts | 103 ++++++++---- test/cli/install-vm/results.test.ts | 135 +++++++++++++++ test/cli/install-vm/results.ts | 155 +++++++++++++++++- test/cli/install-vm/runner.ts | 78 +++++++-- .../cli/install-vm/validate-release-result.ts | 28 ++++ 10 files changed, 578 insertions(+), 79 deletions(-) create mode 100644 test/cli/install-vm/validate-release-result.ts diff --git a/skills/hunk-release/SKILL.md b/skills/hunk-release/SKILL.md index 55b1b316b..09b33d095 100644 --- a/skills/hunk-release/SKILL.md +++ b/skills/hunk-release/SKILL.md @@ -76,23 +76,30 @@ bun run check:prebuilt-pack bun run smoke:prebuilt-install ``` -Run the full Firecracker install compatibility suite once from the reviewed release tip on a Linux -x64 host with working KVM, either locally or through the manually dispatched +Commit the generated metadata and `benchmarks/release/bench-X.Y.Z.json`, then follow normal review +policy. The Firecracker evidence must come from that reviewed release tip, not the pre-generation +commit. Push the reviewed tip before using the manual workflow. + +Run the full Firecracker install compatibility suite once from the clean reviewed release tip on a +Linux x64 host with working KVM, either locally or through the manually dispatched `install-vm.yml` workflow: ```sh -bun run test:install-vm -result=$(find tmp/install-vm/runs -mindepth 2 -maxdepth 2 -name result.json -printf '%T@ %p\n' \ - | sort -nr | head -1 | cut -d' ' -f2-) -jq -e '.run.status == "passed" and (.scenarios | length > 0) and all(.scenarios[]; .status == "passed")' "$result" +set -euo pipefail +mkdir -p tmp/install-vm/runs +result_dir=$(mktemp -d tmp/install-vm/runs/release-XXXXXXXX) +bun run test:install-vm -- --output "$result_dir" +bun run ./test/cli/install-vm/validate-release-result.ts "$result_dir/result.json" ``` -A skipped result does not satisfy release validation. When using the manual workflow, inspect its -uploaded `result.json`; a green job alone is insufficient because unsupported runners may use the -intentional skip path. Firecracker validates Linux x64 packaging behavior, while the existing native -release jobs remain responsible for macOS, Windows, and other architectures. - -Commit the generated metadata and `benchmarks/release/bench-X.Y.Z.json`, follow normal review policy, and wait for required CI. +The explicit output directory prevents a failed invocation from falling back to stale evidence. The +validator requires the complete checked-in scenario manifest, passing statuses, and the current +checkout's source identity. A skipped result does not satisfy release validation. For the manual +workflow, dispatch the full suite from the reviewed tip, download its `result.json` beneath the +ignored `tmp/install-vm/` directory of a checkout at that exact tip, and run the validator there; a +green job alone is insufficient because unsupported runners may use the intentional skip path. +Firecracker validates Linux x64 packaging behavior, while the existing native release jobs remain +responsible for macOS, Windows, and other architectures. Wait for required CI before continuing. ## 3. Tag and publish diff --git a/test/cli/install-vm/README.md b/test/cli/install-vm/README.md index 1c1886357..91e8ea319 100644 --- a/test/cli/install-vm/README.md +++ b/test/cli/install-vm/README.md @@ -28,7 +28,7 @@ The runner deliberately does not reclaim a stale `tmp/install-vm/.lock`, because owned by a racing process is unsafe. After an interrupted host dies, confirm no suite is running and remove that lock directory manually before retrying. -Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, and Firecracker console output. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. +Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with `bun run ./test/cli/install-vm/validate-release-result.ts `. ## Security boundary diff --git a/test/cli/install-vm/contract.test.ts b/test/cli/install-vm/contract.test.ts index 337c6b36a..93d21690b 100644 --- a/test/cli/install-vm/contract.test.ts +++ b/test/cli/install-vm/contract.test.ts @@ -155,6 +155,7 @@ describe("install VM contract", () => { startedAt: "2026-01-01T00:00:00Z", finishedAt: "2026-01-01T00:00:01Z", platform: "linux-x64", + sourceIdentity: "a".repeat(64), status: "failed", }, tools: {}, @@ -274,24 +275,62 @@ describe("install VM contract", () => { } }); - test("replaces a pending timeout kill when an interrupt overlaps it", async () => { - const runner = new InstallVmCommandRunner(); + test("replaces the timeout's pending kill when an interrupt overlaps it", async () => { + let nextTimer = 0; + const timers = new Map void; delayMs: number }>(); + const cancelled: number[] = []; + const kills: NodeJS.Signals[] = []; + let resolveExit!: (exitCode: number) => void; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const runner = new InstallVmCommandRunner({ + spawn: () => ({ + exited, + kill: (signal) => { + kills.push(signal); + if (signal === "SIGINT") resolveExit(130); + }, + }), + schedule: (callback, delayMs) => { + const timer = ++nextTimer; + timers.set(timer, { callback, delayMs }); + return timer; + }, + cancel: (timer) => { + const id = timer as number; + cancelled.push(id); + timers.delete(id); + }, + }); runner.start(); try { - const command = runner.run( - [process.execPath, "-e", 'process.on("SIGTERM", () => {}); setTimeout(() => {}, 60_000)'], - { timeoutMs: 10 }, - ); - await Bun.sleep(20); + const command = runner.run(["fake-command"], { timeoutMs: 10 }); + const timeoutTimer = [...timers.keys()][0]!; + expect(timers.get(timeoutTimer)?.delayMs).toBe(10); + const timeoutCallback = timers.get(timeoutTimer)!.callback; + timers.delete(timeoutTimer); + timeoutCallback(); + + expect(kills).toEqual(["SIGTERM"]); + const timeoutKillTimer = [...timers.keys()][0]!; + expect(timers.get(timeoutKillTimer)?.delayMs).toBe(10_000); + process.emit("SIGINT", "SIGINT"); - const startedAt = Date.now(); + expect(kills).toEqual(["SIGTERM", "SIGINT"]); + expect(cancelled).toContain(timeoutKillTimer); + const interruptKillTimer = [...timers.keys()][0]!; + expect(interruptKillTimer).not.toBe(timeoutKillTimer); + expect(timers.get(interruptKillTimer)?.delayMs).toBe(10_000); + const failure = await command.then( () => undefined, (error: unknown) => error, ); expect(failure).toBeInstanceOf(InstallVmCommandError); expect((failure as InstallVmCommandError).exitCode).toBe(130); - expect(Date.now() - startedAt).toBeLessThan(1_000); + expect(cancelled).toContain(interruptKillTimer); + expect(timers.size).toBe(0); } finally { runner.stop(); } diff --git a/test/cli/install-vm/contract.ts b/test/cli/install-vm/contract.ts index 380b62b11..282d74184 100644 --- a/test/cli/install-vm/contract.ts +++ b/test/cli/install-vm/contract.ts @@ -71,6 +71,7 @@ export interface InstallVmRunResult { startedAt: string; finishedAt: string; platform: "linux-x64"; + sourceIdentity: string; status: "passed" | "failed" | "skipped"; skipReason?: string; }; diff --git a/test/cli/install-vm/prepare-fixtures.test.ts b/test/cli/install-vm/prepare-fixtures.test.ts index 24c806d97..76bfc8339 100644 --- a/test/cli/install-vm/prepare-fixtures.test.ts +++ b/test/cli/install-vm/prepare-fixtures.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -15,6 +23,12 @@ import { type InstallVmFixtureManifest, } from "./prepare-fixtures"; +/** Initialize the minimal Git checkout required by source-identity discovery. */ +function initializeTestGitRepo(repo: string) { + const result = Bun.spawnSync(["git", "init", "--quiet"], { cwd: repo, stderr: "pipe" }); + if (result.exitCode !== 0) throw new Error("Unable to initialize test Git repository."); +} + /** Hash one small test fixture. */ function sha256(filePath: string) { return createHash("sha256").update(readFileSync(filePath)).digest("hex"); @@ -102,27 +116,62 @@ describe("install VM package fixtures", () => { }); }); - test("checkout identity includes root documentation and workspace package inputs", () => { + test("checkout identity includes every non-ignored file with platform-neutral paths", () => { const repo = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-identity-")); try { + initializeTestGitRepo(repo); + writeFileSync(path.join(repo, ".gitignore"), "ignored.txt\n"); writeFileSync(path.join(repo, "package.json"), '{"version":"1.0.0"}\n'); const initial = computeInstallVmFixtureSourceIdentity(repo); - writeFileSync(path.join(repo, "README.md"), "docs\n"); - const withReadme = computeInstallVmFixtureSourceIdentity(repo); - mkdirSync(path.join(repo, "packages", "fixture"), { recursive: true }); - writeFileSync(path.join(repo, "packages", "fixture", "index.ts"), "export {};\n"); - const withPackage = computeInstallVmFixtureSourceIdentity(repo); - expect(withReadme).not.toBe(initial); - expect(withPackage).not.toBe(withReadme); + mkdirSync(path.join(repo, ".github", "workflows"), { recursive: true }); + writeFileSync(path.join(repo, ".github", "workflows", "install-vm.yml"), "workflow\n"); + const withWorkflow = computeInstallVmFixtureSourceIdentity(repo); + writeFileSync(path.join(repo, "ignored.txt"), "ignored\n"); + const withIgnoredFile = computeInstallVmFixtureSourceIdentity(repo); + expect(withWorkflow).not.toBe(initial); + expect(withIgnoredFile).toBe(withWorkflow); + + const tool = path.join(repo, "tool.sh"); + writeFileSync(tool, "#!/bin/sh\n"); + chmodSync(tool, 0o644); + const regularTool = computeInstallVmFixtureSourceIdentity(repo); + chmodSync(tool, 0o755); + const executableTool = computeInstallVmFixtureSourceIdentity(repo); + if (process.platform !== "win32") expect(executableTool).not.toBe(regularTool); + + const link = path.join(repo, "fixture-link"); + symlinkSync("first-target", link, "file"); + const firstLink = computeInstallVmFixtureSourceIdentity(repo); + rmSync(link); + symlinkSync("second-target", link, "file"); + expect(computeInstallVmFixtureSourceIdentity(repo)).not.toBe(firstLink); } finally { rmSync(repo, { recursive: true, force: true }); } }); + test("checkout identity frames paths and contents without concatenation collisions", () => { + const first = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-collision-a-")); + const second = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-collision-b-")); + try { + initializeTestGitRepo(first); + initializeTestGitRepo(second); + writeFileSync(path.join(first, "a"), "bc"); + writeFileSync(path.join(second, "ab"), "c"); + expect(computeInstallVmFixtureSourceIdentity(first)).not.toBe( + computeInstallVmFixtureSourceIdentity(second), + ); + } finally { + rmSync(first, { recursive: true, force: true }); + rmSync(second, { recursive: true, force: true }); + } + }); + test("verifies checkout identity, exact package set, duplicate manifests, and tarball digests", () => { const repo = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-source-")); const fixtures = mkdtempSync(path.join(tmpdir(), "hunk-install-vm-fixtures-")); try { + initializeTestGitRepo(repo); mkdirSync(path.join(repo, "test", "cli", "install-vm"), { recursive: true }); writeFileSync(path.join(repo, "package.json"), '{"name":"fixture","version":"1.0.0"}\n'); writeFileSync(path.join(repo, "test", "cli", "install-vm", "source.txt"), "source\n"); diff --git a/test/cli/install-vm/prepare-fixtures.ts b/test/cli/install-vm/prepare-fixtures.ts index 7a4a4a384..5129be591 100644 --- a/test/cli/install-vm/prepare-fixtures.ts +++ b/test/cli/install-vm/prepare-fixtures.ts @@ -6,7 +6,6 @@ import { lstatSync, mkdirSync, readFileSync, - readdirSync, readlinkSync, renameSync, rmSync, @@ -47,23 +46,6 @@ export interface InstallVmFixtureManifest { packages: FixturePackage[]; } -const SOURCE_IDENTITY_PATHS = [ - "package.json", - "bun.lock", - "README.md", - "bin", - "packages", - "src", - "scripts", - "skills", - "install.sh", - "LICENSE", - "tsconfig.json", - "tsconfig.opentui.json", - "tsconfig.extension.json", - "test/cli/install-vm", -] as const; - /** Build reduced meta/platform manifests for deterministic package-manager topology tests. */ export function buildSyntheticPackageManifests(version: string) { const platformSpec = getPlatformPackageSpecForHost("linux", "x64"); @@ -94,25 +76,78 @@ function sha256(filePath: string) { return createHash("sha256").update(readFileSync(filePath)).digest("hex"); } -/** Hash every source input that can change the generated VM package and curl fixtures. */ +function readCheckoutGitList(repoRoot: string, args: string[]) { + const listed = Bun.spawnSync(["git", ...args], { + cwd: repoRoot, + stdout: "pipe", + stderr: "pipe", + }); + if (listed.exitCode !== 0) { + throw new Error( + `Unable to enumerate the install VM checkout: ${new TextDecoder().decode(listed.stderr).trim()}`, + ); + } + return new TextDecoder().decode(listed.stdout).split("\0").filter(Boolean); +} + +/** Add one unambiguous length-delimited field to a checkout identity. */ +function updateIdentityField(hash: ReturnType, value: string | Uint8Array) { + const bytes = typeof value === "string" ? Buffer.from(value) : value; + hash.update(`${bytes.byteLength}:`); + hash.update(bytes); +} + +/** Hash every tracked or non-ignored untracked file in the current checkout. */ export function computeInstallVmFixtureSourceIdentity(repoRoot: string) { + const indexModes = new Map(); + for (const entry of readCheckoutGitList(repoRoot, ["ls-files", "--stage", "-z"])) { + const separator = entry.indexOf("\t"); + if (separator < 0) throw new Error("Git returned malformed install VM checkout metadata."); + const header = entry.slice(0, separator); + const relativePath = entry.slice(separator + 1); + indexModes.set(relativePath, header.split(" ", 1)[0]!); + } + const relativePaths = readCheckoutGitList(repoRoot, [ + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + ]).sort(); + const hash = createHash("sha256"); - const pending = SOURCE_IDENTITY_PATHS.map((entry) => path.join(repoRoot, entry)); - const files: string[] = []; - while (pending.length > 0) { - const current = pending.pop()!; - if (!existsSync(current)) continue; - const stats = lstatSync(current); - if (stats.isDirectory()) { - for (const entry of readdirSync(current)) pending.push(path.join(current, entry)); - } else { - files.push(current); + for (const relativePath of relativePaths) { + const filePath = path.join(repoRoot, ...relativePath.split("/")); + updateIdentityField(hash, relativePath); + let stats: ReturnType; + try { + stats = lstatSync(filePath); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; + updateIdentityField(hash, "missing"); + updateIdentityField(hash, ""); + continue; } - } - for (const file of files.sort()) { - hash.update(path.relative(repoRoot, file)); - const stats = lstatSync(file); - hash.update(stats.isSymbolicLink() ? readlinkSync(file) : readFileSync(file)); + + const indexedMode = indexModes.get(relativePath); + const mode = stats.isSymbolicLink() + ? "120000" + : process.platform === "win32" && indexedMode + ? indexedMode + : stats.isFile() + ? stats.mode & 0o111 + ? "100755" + : "100644" + : "unsupported"; + updateIdentityField(hash, mode); + updateIdentityField( + hash, + stats.isSymbolicLink() + ? readlinkSync(filePath) + : stats.isFile() + ? readFileSync(filePath) + : "", + ); } return hash.digest("hex"); } diff --git a/test/cli/install-vm/results.test.ts b/test/cli/install-vm/results.test.ts index ae07a02bf..c4471cbe9 100644 --- a/test/cli/install-vm/results.test.ts +++ b/test/cli/install-vm/results.test.ts @@ -7,8 +7,11 @@ import { parseAssertionTsv, parseCommandTsv, parseObservationTsv, + validateInstallVmReleaseResult, } from "./results"; +const sourceIdentity = "a".repeat(64); + const scenario = { id: "negative-case", description: "Expected failure contract", @@ -52,6 +55,134 @@ describe("install VM results", () => { "exit code", ); expect(() => parseObservationTsv("dependencyTreePath\t../secret\n")).toThrow("Unsafe"); + expect(() => parseObservationTsv("dependencyTreePath\t..\n")).toThrow("Unsafe"); + expect(() => parseCommandTsv("bad\tpassed\texit 0\t0\t..\n")).toThrow("Unsafe"); + }); + + test("release validation rejects stale, partial, and non-passing evidence", () => { + const releaseExpected = { + sourceIdentity, + pnpmVersion: "11.23.0", + scenarios: [scenario], + }; + const result = { + schemaVersion: 1 as const, + run: { + id: "run", + startedAt: "2026-01-01T00:00:00Z", + finishedAt: "2026-01-01T00:00:01Z", + platform: "linux-x64" as const, + sourceIdentity, + status: "passed" as const, + }, + tools: { + firecracker: "Firecracker v1.16.1", + kernel: "6.18.44", + node: "v24.14.1", + npm: "11.11.0", + pnpm: releaseExpected.pnpmVersion, + verdaccio: "v6.10.1", + }, + scenarios: [ + { + id: scenario.id, + description: scenario.description, + status: "passed" as const, + durationMs: 10, + exitCode: 0, + commands: [ + { + id: "command", + status: "passed" as const, + expectation: "exit 0", + exitCode: 0, + logPath: "commands/command.log", + }, + ], + observations: {}, + assertions: [ + { + id: "assertion", + status: "passed" as const, + expected: "safe", + actual: "safe", + message: "evidence matched", + }, + ], + artifacts: ["scenarios/negative-case/commands"], + }, + ], + }; + expect(validateInstallVmReleaseResult(result, releaseExpected)).toBe(result); + expect(() => + validateInstallVmReleaseResult(result, { + ...releaseExpected, + sourceIdentity: "b".repeat(64), + }), + ).toThrow("current checkout identity"); + expect(() => + validateInstallVmReleaseResult(result, { + ...releaseExpected, + scenarios: [scenario, { ...scenario, id: "second-scenario", script: "second-scenario.sh" }], + }), + ).toThrow("complete scenario manifest"); + expect(() => + validateInstallVmReleaseResult( + { ...result, run: { ...result.run, status: "skipped" } }, + releaseExpected, + ), + ).toThrow("not passed"); + expect(() => + validateInstallVmReleaseResult( + { + schemaVersion: 1, + run: { sourceIdentity, status: "passed" }, + scenarios: [{ id: scenario.id, status: "passed" }], + }, + releaseExpected, + ), + ).toThrow("malformed run metadata"); + expect(() => + validateInstallVmReleaseResult( + { + ...result, + scenarios: [ + { + ...result.scenarios[0], + commands: [{ ...result.scenarios[0]!.commands[0], status: "failed" }], + }, + ], + }, + releaseExpected, + ), + ).toThrow("malformed command evidence"); + + for (const tools of [ + { ...result.tools, pnpm: "11.22.0" }, + { ...result.tools, npm: "" }, + { ...result.tools, extra: "unexpected" }, + Object.fromEntries(Object.entries(result.tools).filter(([key]) => key !== "kernel")), + ]) { + expect(() => validateInstallVmReleaseResult({ ...result, tools }, releaseExpected)).toThrow( + "tool evidence", + ); + } + + for (const scenarioEvidence of [ + { ...result.scenarios[0], artifacts: [".."] }, + { ...result.scenarios[0], observations: { dependencyTreePath: ".." } }, + { + ...result.scenarios[0], + commands: [{ ...result.scenarios[0]!.commands[0], logPath: ".." }], + }, + ]) { + expect(() => + validateInstallVmReleaseResult( + { ...result, scenarios: [scenarioEvidence] }, + releaseExpected, + ), + ).toThrow("Unsafe install VM artifact path"); + } }); test("writes deterministic JSON and JUnit projections", () => { @@ -82,11 +213,13 @@ describe("install VM results", () => { runId: "run", startedAt: "2026-01-01T00:00:00Z", finishedAt: "2026-01-01T00:00:01Z", + sourceIdentity, scenarios: [scenario], tools: { zeta: "2", alpha: "1" }, }); expect(result.run.status).toBe("passed"); + expect(result.run.sourceIdentity).toBe(sourceIdentity); expect(Object.keys(result.tools)).toEqual(["alpha", "zeta"]); expect(result.scenarios[0]?.commands[0]?.exitCode).toBe(1); expect(result.scenarios[0]?.observations.hunkVersion).toBe("1.2.3"); @@ -116,6 +249,7 @@ describe("install VM results", () => { runId: "run", startedAt: "2026-01-01T00:00:00Z", finishedAt: "2026-01-01T00:00:01Z", + sourceIdentity, scenarios: [scenario], tools: {}, }).run.status, @@ -131,6 +265,7 @@ describe("install VM results", () => { runId: "run", startedAt: "2026-01-01T00:00:00Z", finishedAt: "2026-01-01T00:00:01Z", + sourceIdentity, scenarios: [scenario], tools: {}, }), diff --git a/test/cli/install-vm/results.ts b/test/cli/install-vm/results.ts index 1379b2a06..722471e30 100644 --- a/test/cli/install-vm/results.ts +++ b/test/cli/install-vm/results.ts @@ -17,11 +17,18 @@ interface RawScenarioResult { } const RESULT_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/; +const SOURCE_IDENTITY_PATTERN = /^[a-f0-9]{64}$/; /** Return one safe relative artifact path, rejecting traversal and absolute paths. */ function safeArtifactPath(value: string) { const normalized = path.posix.normalize(value.replaceAll("\\", "/")); - if (normalized !== value || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) { + if ( + normalized !== value || + normalized === "." || + normalized === ".." || + normalized.startsWith("../") || + path.posix.isAbsolute(normalized) + ) { throw new Error(`Unsafe install VM artifact path: ${value}`); } return normalized; @@ -92,12 +99,157 @@ export function parseObservationTsv(contents: string): InstallVmScenarioObservat return observations; } +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasOnlyStringValues(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); +} + +function hasUniqueIds(records: readonly Record[]) { + const ids = records.map((record) => record.id); + return ids.every((id) => typeof id === "string") && new Set(ids).size === ids.length; +} + +/** Validate that release evidence is complete, consistent, and matches this checkout. */ +export function validateInstallVmReleaseResult( + value: unknown, + expected: { + sourceIdentity: string; + pnpmVersion: string; + scenarios: readonly InstallVmScenario[]; + }, +) { + if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.run)) { + throw new Error("Install VM result must use schemaVersion 1 and contain run evidence."); + } + const run = value.run; + if ( + typeof run.id !== "string" || + run.id.length === 0 || + typeof run.startedAt !== "string" || + !Number.isFinite(Date.parse(run.startedAt)) || + typeof run.finishedAt !== "string" || + !Number.isFinite(Date.parse(run.finishedAt)) || + Date.parse(run.finishedAt) < Date.parse(run.startedAt) || + run.platform !== "linux-x64" || + run.skipReason !== undefined + ) { + throw new Error("Install VM result has malformed run metadata."); + } + if ( + !SOURCE_IDENTITY_PATTERN.test(expected.sourceIdentity) || + run.sourceIdentity !== expected.sourceIdentity + ) { + throw new Error("Install VM result does not match the current checkout identity."); + } + if (run.status !== "passed") { + throw new Error(`Install VM release result is ${String(run.status)}, not passed.`); + } + const expectedToolKeys = ["firecracker", "kernel", "node", "npm", "pnpm", "verdaccio"]; + if ( + !hasOnlyStringValues(value.tools) || + Object.keys(value.tools).sort().join("\0") !== expectedToolKeys.join("\0") || + Object.values(value.tools).some((tool) => tool.trim().length === 0) || + value.tools.pnpm !== expected.pnpmVersion + ) { + throw new Error("Install VM release result has malformed or drifted tool evidence."); + } + if (!Array.isArray(value.scenarios)) { + throw new Error("Install VM release result has no scenario evidence."); + } + + const scenarioRecords = value.scenarios.filter(isRecord); + if (scenarioRecords.length !== value.scenarios.length || !hasUniqueIds(scenarioRecords)) { + throw new Error("Install VM release result has malformed or duplicate scenarios."); + } + const expectedById = new Map(expected.scenarios.map((scenario) => [scenario.id, scenario])); + const expectedIds = [...expectedById.keys()].sort(); + const actualIds = scenarioRecords.map((scenario) => scenario.id as string).sort(); + if (JSON.stringify(actualIds) !== JSON.stringify(expectedIds)) { + throw new Error("Install VM release result does not cover the complete scenario manifest."); + } + + for (const scenario of scenarioRecords) { + const id = scenario.id as string; + const definition = expectedById.get(id)!; + if ( + scenario.description !== definition.description || + scenario.status !== "passed" || + scenario.exitCode !== 0 || + !Number.isSafeInteger(scenario.durationMs) || + (scenario.durationMs as number) < 0 || + !Array.isArray(scenario.commands) || + scenario.commands.length === 0 || + !Array.isArray(scenario.assertions) || + scenario.assertions.length === 0 || + !hasOnlyStringValues(scenario.observations) || + !Array.isArray(scenario.artifacts) || + scenario.artifacts.length === 0 + ) { + throw new Error(`Install VM release result has incomplete evidence for ${id}.`); + } + + for (const [key, observation] of Object.entries(scenario.observations)) { + if (!RESULT_KEY_PATTERN.test(key)) { + throw new Error(`Install VM release result has malformed observations for ${id}.`); + } + if (key.endsWith("Path")) safeArtifactPath(observation); + } + + const commandRecords = scenario.commands.filter(isRecord); + if (commandRecords.length !== scenario.commands.length || !hasUniqueIds(commandRecords)) { + throw new Error(`Install VM release result has malformed commands for ${id}.`); + } + for (const command of commandRecords) { + if ( + typeof command.id !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(command.id) || + command.status !== "passed" || + typeof command.expectation !== "string" || + !Number.isSafeInteger(command.exitCode) || + typeof command.logPath !== "string" + ) { + throw new Error(`Install VM release result has malformed command evidence for ${id}.`); + } + safeArtifactPath(command.logPath); + } + + const assertionRecords = scenario.assertions.filter(isRecord); + if (assertionRecords.length !== scenario.assertions.length || !hasUniqueIds(assertionRecords)) { + throw new Error(`Install VM release result has malformed assertions for ${id}.`); + } + for (const assertion of assertionRecords) { + if ( + typeof assertion.id !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(assertion.id) || + assertion.status !== "passed" || + typeof assertion.expected !== "string" || + typeof assertion.actual !== "string" || + typeof assertion.message !== "string" + ) { + throw new Error(`Install VM release result has malformed assertion evidence for ${id}.`); + } + } + for (const artifact of scenario.artifacts) { + if (typeof artifact !== "string") { + throw new Error(`Install VM release result has malformed artifacts for ${id}.`); + } + safeArtifactPath(artifact); + } + } + + return value as unknown as InstallVmRunResult; +} + /** Aggregate bounded scenario result files into stable JSON and JUnit artifacts. */ export function aggregateInstallVmResults(options: { outputDir: string; runId: string; startedAt: string; finishedAt: string; + sourceIdentity: string; scenarios: readonly InstallVmScenario[]; tools: Record; skipReason?: string; @@ -189,6 +341,7 @@ export function aggregateInstallVmResults(options: { startedAt: options.startedAt, finishedAt: options.finishedAt, platform: "linux-x64", + sourceIdentity: options.sourceIdentity, status, ...(options.skipReason ? { skipReason: options.skipReason } : {}), }, diff --git a/test/cli/install-vm/runner.ts b/test/cli/install-vm/runner.ts index 392e44fbc..73623a641 100644 --- a/test/cli/install-vm/runner.ts +++ b/test/cli/install-vm/runner.ts @@ -34,7 +34,11 @@ import { type InstallVmRunResult, } from "./contract"; import { collectInstallVmPreflightFailures } from "./preflight"; -import { prepareInstallVmFixtures, verifyInstallVmFixtures } from "./prepare-fixtures"; +import { + computeInstallVmFixtureSourceIdentity, + prepareInstallVmFixtures, + verifyInstallVmFixtures, +} from "./prepare-fixtures"; import { aggregateInstallVmResults } from "./results"; import { acquireInstallVmRuntimeLock } from "./runtime-lock"; @@ -55,15 +59,45 @@ export class InstallVmCommandError extends Error { } } +interface HostCommandProcess { + exited: Promise; + kill(signal: NodeJS.Signals): void; +} + +interface HostCommandRunnerDependencies { + spawn: ( + command: string[], + options: { + cwd: string; + env: NodeJS.ProcessEnv; + stdin: "inherit"; + stdout: "inherit"; + stderr: "inherit"; + }, + ) => HostCommandProcess; + schedule: (callback: () => void, delayMs: number) => unknown; + cancel: (timer: unknown) => void; +} + /** Runs host commands asynchronously while forwarding interrupts and bounding shutdown. */ export class InstallVmCommandRunner { - private activeProcess: ReturnType | undefined; + private activeProcess: HostCommandProcess | undefined; private interruptedExitCode: number | undefined; - private terminationTimer: ReturnType | undefined; + private terminationTimer: unknown; + private readonly dependencies: HostCommandRunnerDependencies; private readonly handleSigint = () => this.interrupt("SIGINT", 130); private readonly handleSigterm = () => this.interrupt("SIGTERM", 143); + constructor(dependencies: Partial = {}) { + this.dependencies = { + spawn: dependencies.spawn ?? ((command, options) => Bun.spawn(command, options)), + schedule: dependencies.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)), + cancel: + dependencies.cancel ?? ((timer) => clearTimeout(timer as ReturnType)), + }; + } + start() { process.once("SIGINT", this.handleSigint); process.once("SIGTERM", this.handleSigterm); @@ -72,7 +106,8 @@ export class InstallVmCommandRunner { stop() { process.off("SIGINT", this.handleSigint); process.off("SIGTERM", this.handleSigterm); - if (this.terminationTimer) clearTimeout(this.terminationTimer); + if (this.terminationTimer !== undefined) this.dependencies.cancel(this.terminationTimer); + this.terminationTimer = undefined; } private interrupt(signal: NodeJS.Signals, exitCode: number) { @@ -80,8 +115,10 @@ export class InstallVmCommandRunner { this.interruptedExitCode = exitCode; this.activeProcess?.kill(signal); if (this.activeProcess) { - if (this.terminationTimer) clearTimeout(this.terminationTimer); - this.terminationTimer = setTimeout( + if (this.terminationTimer !== undefined) { + this.dependencies.cancel(this.terminationTimer); + } + this.terminationTimer = this.dependencies.schedule( () => this.activeProcess?.kill("SIGKILL"), TERMINATION_GRACE_MS, ); @@ -96,7 +133,7 @@ export class InstallVmCommandRunner { async run(command: string[], options: { cwd?: string; timeoutMs?: number } = {}) { this.checkInterrupted(); - const proc = Bun.spawn(command, { + const proc = this.dependencies.spawn(command, { cwd: options.cwd ?? repoRoot, env: process.env, stdin: "inherit", @@ -105,18 +142,23 @@ export class InstallVmCommandRunner { }); this.activeProcess = proc; let timedOut = false; - const timeout = setTimeout(() => { + const timeout = this.dependencies.schedule(() => { timedOut = true; proc.kill("SIGTERM"); - this.terminationTimer = setTimeout(() => proc.kill("SIGKILL"), TERMINATION_GRACE_MS); + this.terminationTimer = this.dependencies.schedule( + () => proc.kill("SIGKILL"), + TERMINATION_GRACE_MS, + ); }, options.timeoutMs ?? COMMAND_TIMEOUT_MS); let exitCode: number; try { exitCode = await proc.exited; } finally { - clearTimeout(timeout); - if (this.terminationTimer) clearTimeout(this.terminationTimer); + this.dependencies.cancel(timeout); + if (this.terminationTimer !== undefined) { + this.dependencies.cancel(this.terminationTimer); + } this.terminationTimer = undefined; this.activeProcess = undefined; } @@ -169,6 +211,7 @@ function writeSkippedResult( runId: string, startedAt: string, scenarios: ReturnType, + sourceIdentity: string, reason: string, ) { const result: InstallVmRunResult = { @@ -178,6 +221,7 @@ function writeSkippedResult( startedAt, finishedAt: new Date().toISOString(), platform: "linux-x64", + sourceIdentity, status: "skipped", skipReason: reason, }, @@ -264,11 +308,12 @@ export async function main(argv = process.argv.slice(2)) { chmodSync(outputDir, 0o700); chmodSync(cacheDir, 0o700); + const sourceIdentity = computeInstallVmFixtureSourceIdentity(repoRoot); const failures = await collectInstallVmPreflightFailures(defaultRuntimeRoot); if (failures.length > 0) { const reason = failures.join(" "); if (options.allowSkip) { - writeSkippedResult(outputDir, runId, startedAt, selected, reason); + writeSkippedResult(outputDir, runId, startedAt, selected, sourceIdentity, reason); console.warn(`Skipped install VM suite: ${reason}`); console.warn(`Result: ${path.join(outputDir, "result.json")}`); return 0; @@ -305,7 +350,10 @@ export async function main(argv = process.argv.slice(2)) { outputDir: assertSafeInstallVmRuntimePath(repoRoot, outputDir), }; assertDistinctInstallVmRuntimePaths(revalidatedPaths); - verifyInstallVmFixtures(repoRoot, revalidatedPaths.fixtureDir); + const fixtureManifest = verifyInstallVmFixtures(repoRoot, revalidatedPaths.fixtureDir); + if (fixtureManifest.sourceIdentity !== sourceIdentity) { + throw new Error("Install VM checkout changed while the suite was preparing fixtures."); + } const dockerCommand = buildDockerRunCommand( image, revalidatedPaths, @@ -323,12 +371,16 @@ export async function main(argv = process.argv.slice(2)) { `Guest pnpm version drifted: expected ${pins.pnpmVersion}, got ${tools.pnpm}`, ); } + if (computeInstallVmFixtureSourceIdentity(repoRoot) !== sourceIdentity) { + throw new Error("Install VM checkout changed while the suite was running."); + } commandRunner.checkInterrupted(); const result = aggregateInstallVmResults({ outputDir, runId, startedAt, finishedAt: new Date().toISOString(), + sourceIdentity, scenarios: selected, tools, }); diff --git a/test/cli/install-vm/validate-release-result.ts b/test/cli/install-vm/validate-release-result.ts new file mode 100644 index 000000000..3a0d29f73 --- /dev/null +++ b/test/cli/install-vm/validate-release-result.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env bun + +/** Validates Firecracker release evidence against the current checkout and full scenario manifest. */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { loadScenarioManifest, validateInstallVmPins } from "./contract"; +import { computeInstallVmFixtureSourceIdentity } from "./prepare-fixtures"; +import { validateInstallVmReleaseResult } from "./results"; + +const repoRoot = path.resolve(import.meta.dir, "../../.."); +const resultPath = process.argv[2]; +if (!resultPath || process.argv.length !== 3) { + throw new Error("Usage: validate-release-result.ts "); +} + +const manifest = loadScenarioManifest(path.join(import.meta.dir, "scenarios.json")); +const pins = validateInstallVmPins( + JSON.parse(readFileSync(path.join(import.meta.dir, "pins.json"), "utf8")), +); +const result = validateInstallVmReleaseResult(JSON.parse(readFileSync(resultPath, "utf8")), { + sourceIdentity: computeInstallVmFixtureSourceIdentity(repoRoot), + pnpmVersion: pins.pnpmVersion, + scenarios: manifest.scenarios, +}); +console.log( + `Validated ${result.scenarios.length} install VM scenarios for source ${result.run.sourceIdentity}.`, +); From ad6233924e6ed65648af97c9884c36c5a359ac90 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 29 Aug 2026 20:16:47 -0400 Subject: [PATCH 5/5] docs(test): document install VM helpers --- test/cli/install-vm/prepare-fixtures.ts | 9 +++++++++ test/cli/install-vm/runner.ts | 7 +++++++ test/cli/install-vm/runtime-lock.ts | 2 ++ 3 files changed, 18 insertions(+) diff --git a/test/cli/install-vm/prepare-fixtures.ts b/test/cli/install-vm/prepare-fixtures.ts index 5129be591..62ad26b88 100644 --- a/test/cli/install-vm/prepare-fixtures.ts +++ b/test/cli/install-vm/prepare-fixtures.ts @@ -76,6 +76,7 @@ function sha256(filePath: string) { return createHash("sha256").update(readFileSync(filePath)).digest("hex"); } +/** Read one NUL-delimited Git path listing without shell interpretation. */ function readCheckoutGitList(repoRoot: string, args: string[]) { const listed = Bun.spawnSync(["git", ...args], { cwd: repoRoot, @@ -230,6 +231,7 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { throw new Error("Curl latest-release fixture does not match the current version."); } const archiveName = "hunkdiff-linux-x64.tar.gz"; + /** Verify one curl fixture's archive and declared checksum. */ const verifyArchiveChecksum = (version: string, expectedDigest?: string) => { const directory = path.join(httpRoot, "download", `v${version}`); const archive = path.join(directory, archiveName); @@ -254,10 +256,12 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { return manifest; } +/** Write stable indented JSON with a trailing newline. */ function writeJson(filePath: string, value: unknown) { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); } +/** Run one fixture-building command with inherited output. */ async function run(command: string[], cwd?: string) { const proc = Bun.spawn(command, { cwd, @@ -270,6 +274,7 @@ async function run(command: string[], cwd?: string) { if (exitCode !== 0) throw new Error(`${command.join(" ")} failed with ${exitCode}`); } +/** Pack one fixture package without running package lifecycle scripts. */ async function packPackage(packageDirectory: string, packageOutput: string) { await run( [npmCommand, "pack", "--pack-destination", packageOutput, "--ignore-scripts"], @@ -284,6 +289,7 @@ async function packPackage(packageDirectory: string, packageOutput: string) { return `${manifest.name.replace(/^@/, "").replaceAll("/", "-")}-${manifest.version}.tgz`; } +/** Write an executable fixture binary that reports the requested synthetic version. */ function writeSyntheticBinary(binaryPath: string, version: string) { writeFileSync( binaryPath, @@ -292,6 +298,7 @@ function writeSyntheticBinary(binaryPath: string, version: string) { chmodSync(binaryPath, 0o755); } +/** Copy bundled skills into a synthetic install fixture. */ function copyFixtureSkills(repoRoot: string, destination: string) { for (const skill of ["hunk-review", "hunk-extensions"]) { cpSync(path.join(repoRoot, "skills", skill), path.join(destination, "skills", skill), { @@ -300,6 +307,7 @@ function copyFixtureSkills(repoRoot: string, destination: string) { } } +/** Stage coupled meta and platform packages for one synthetic upgrade version. */ async function stageSyntheticPackage( repoRoot: string, stageRoot: string, @@ -342,6 +350,7 @@ async function stageSyntheticPackage( ]; } +/** Stage one synthetic standalone archive and matching checksum manifest. */ async function stageSyntheticCurlArchive( repoRoot: string, stageRoot: string, diff --git a/test/cli/install-vm/runner.ts b/test/cli/install-vm/runner.ts index 73623a641..5080bf82c 100644 --- a/test/cli/install-vm/runner.ts +++ b/test/cli/install-vm/runner.ts @@ -89,6 +89,7 @@ export class InstallVmCommandRunner { private readonly handleSigint = () => this.interrupt("SIGINT", 130); private readonly handleSigterm = () => this.interrupt("SIGTERM", 143); + /** Create a runner with injectable process and timer operations for deterministic tests. */ constructor(dependencies: Partial = {}) { this.dependencies = { spawn: dependencies.spawn ?? ((command, options) => Bun.spawn(command, options)), @@ -98,11 +99,13 @@ export class InstallVmCommandRunner { }; } + /** Begin forwarding host interrupts to the active command. */ start() { process.once("SIGINT", this.handleSigint); process.once("SIGTERM", this.handleSigterm); } + /** Stop forwarding interrupts and cancel any pending forced termination. */ stop() { process.off("SIGINT", this.handleSigint); process.off("SIGTERM", this.handleSigterm); @@ -110,6 +113,7 @@ export class InstallVmCommandRunner { this.terminationTimer = undefined; } + /** Forward the first interrupt and schedule forced termination when a command is active. */ private interrupt(signal: NodeJS.Signals, exitCode: number) { if (this.interruptedExitCode !== undefined) return; this.interruptedExitCode = exitCode; @@ -125,12 +129,14 @@ export class InstallVmCommandRunner { } } + /** Throw the conventional exit code after an interrupt reaches the runner. */ checkInterrupted() { if (this.interruptedExitCode !== undefined) { throw new InstallVmCommandError("Install VM suite interrupted.", this.interruptedExitCode); } } + /** Run one host command with inherited I/O and a bounded termination sequence. */ async run(command: string[], options: { cwd?: string; timeoutMs?: number } = {}) { this.checkInterrupted(); const proc = this.dependencies.spawn(command, { @@ -206,6 +212,7 @@ function controllerImageTag() { return `hunk-install-vm:${hash.digest("hex").slice(0, 12)}`; } +/** Write explicit skipped evidence when the host cannot run the optional VM suite. */ function writeSkippedResult( outputDir: string, runId: string, diff --git a/test/cli/install-vm/runtime-lock.ts b/test/cli/install-vm/runtime-lock.ts index 6eb8c8a1e..ea6c25690 100644 --- a/test/cli/install-vm/runtime-lock.ts +++ b/test/cli/install-vm/runtime-lock.ts @@ -32,6 +32,7 @@ export function acquireInstallVmRuntimeLock( const alive = options.alive ?? processIsAlive; if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Install VM lock needs a valid pid."); + /** Create the lock and publish its owner atomically with cleanup on failure. */ const create = () => { mkdirSync(lockDirectory); try { @@ -72,6 +73,7 @@ export function acquireInstallVmRuntimeLock( } let released = false; + /** Release only the lock that this caller still owns. */ return () => { if (released) return; released = true;