From dadb1fbdc75bca97ed3df036d8f8b647aeb0729f Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 30 Oct 2026 16:14:13 -0700 Subject: [PATCH 1/2] feat: deploy v3 clusters with optional Fly previews --- .github/workflows/test.yml | 26 + .gitignore | 3 + README.md | 157 +-- action.yml | 146 ++- bun.lock | 26 + examples/counter/.dockerignore | 3 + examples/counter/Dockerfile | 8 + examples/counter/README.md | 23 + examples/counter/package-lock.json | 1494 ++++++++++++++++++++++++++++ examples/counter/package.json | 11 + examples/counter/server.js | 25 + lefthook.yml | 6 + package.json | 13 + scripts/smoke-start.py | 52 + src/cloud.ts | 41 + src/config.ts | 101 ++ src/fly.ts | 110 ++ src/github.ts | 81 ++ src/index.ts | 675 +------------ src/process.ts | 26 + src/report.ts | 13 + tests/cloud.test.ts | 81 ++ tests/config.test.ts | 55 + tests/fly.test.ts | 81 ++ tests/github.test.ts | 80 ++ tsconfig.json | 8 + 26 files changed, 2616 insertions(+), 729 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 bun.lock create mode 100644 examples/counter/.dockerignore create mode 100644 examples/counter/Dockerfile create mode 100644 examples/counter/README.md create mode 100644 examples/counter/package-lock.json create mode 100644 examples/counter/package.json create mode 100644 examples/counter/server.js create mode 100644 lefthook.yml create mode 100644 package.json create mode 100644 scripts/smoke-start.py create mode 100644 src/cloud.ts create mode 100644 src/config.ts create mode 100644 src/fly.ts create mode 100644 src/github.ts create mode 100644 src/process.ts create mode 100644 src/report.ts create mode 100644 tests/cloud.test.ts create mode 100644 tests/config.test.ts create mode 100644 tests/fly.test.ts create mode 100644 tests/github.test.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6d565bd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Test +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' + - run: bun install --frozen-lockfile + - run: bun test + - run: bun run check + - name: Run the composite action with previews disabled + id: skip + uses: ./ + - name: Verify skipped deployment + env: + DEPLOY_STATUS: ${{ steps.skip.outputs.status }} + run: test "$DEPLOY_STATUS" = skipped + - name: Build the shared Cloud and Fly example + run: docker build -t rivet-counter examples/counter + - name: Verify plain registry.start on cold boot and restart + run: python3 scripts/smoke-start.py rivet-counter diff --git a/.gitignore b/.gitignore index a2ac9cd..d0e70db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .vscode/ .DS_Store +node_modules/ +.env* +*.tar diff --git a/README.md b/README.md index 30ad0c0..b51f186 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,105 @@ -# Rivet Deploy Action +# Rivet Deploy Action — v3 -Creates Rivet namespaces for preview deployments. +Map branches to **existing Rivet Cloud clusters**. Optionally deploy every other branch to a **single Fly Machine** running the app with RivetKit's automatic local engine, like a dev machine. Unmapped branches are skipped when previews are disabled or no Fly token is configured. + +## Setup + +Add these GitHub Actions secrets: + +- `RIVET_CLOUD_TOKEN`: a Rivet Cloud credential with cluster deployment access in your organization. An Engine admin token cannot deploy builds. The action uses the [Rivet CLI](https://rivet.dev/docs/cli/) and passes this secret through `RIVET_CLOUD_TOKEN`. +- `FLY_API_TOKEN`: a Fly organization token that can create, deploy, and delete preview apps. Only needed for Fly previews. + +Use the published CLI with v3 support, for example `rivet-cli-version: '3.0.0'`. + +**Known restart issue:** RivetKit `3.0.0` can mistake a reused PID from a previous VM boot for a still-running local Engine. The consumer test documents a temporary container startup workaround; the action does not apply it. Plain app startup remains `registry.start()`. A fixed RivetKit release is needed for an unmodified, restart-safe container. + +Create `.github/workflows/rivet-deploy.yml`: + +```yaml +name: Deploy +on: + push: + branches: [main, staging] + pull_request: + types: [opened, synchronize, reopened, closed] + workflow_dispatch: + +concurrency: + group: rivet-deploy-${{ github.head_ref || (github.event_name == 'delete' && github.event.ref) || github.ref_name }} + cancel-in-progress: false + +jobs: + deploy: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + # Use the v3 release tag once published; a commit SHA also works. + - uses: rivet-dev/deploy-action@v3 + with: + rivet-token: ${{ secrets.RIVET_CLOUD_TOKEN }} + rivet-cli-version: '3.0.0' + rivet-org: my-org + branch-clusters: | + { + "main": "production", + "staging": { "cluster": "staging", "region": "us-east4", "pool": "default" } + } + fly-enabled: 'true' + fly-token: ${{ secrets.FLY_API_TOKEN }} + fly-org: my-fly-org +``` + +The mapping uses exact **source branch names**, including for pull requests. Each cluster may be mapped to only one branch. Specify a region when a cluster has more than one; otherwise Rivet selects its only region. Existing pool settings, including its allowlist, are preserved. + +PR-close events delete the Fly app and its volume. They **never delete Cloud clusters**. Keep the Fly token configured for cleanup even if you later disable previews. Fork PRs and tag events are skipped. For push-only previews, also subscribe to the `delete` event to clean up deleted branches. Keep deployment and cleanup in the same concurrency group. + +## Container requirements + +Use RivetKit v3. The image must run your registry and serve your app's HTTP server on `0.0.0.0:$PORT` (default `8080`). `registry.start()` alone does not serve your app's HTTP endpoint. Use normal `setup({ use: … })` and `registry.start()`; no provider-specific runtime overrides are needed. + +- **Cloud:** Rivet supplies the engine connection and pool configuration. Keep the image in production mode. +- **Fly:** the action sets `NODE_ENV=development` and `RIVETKIT_STORAGE_PATH=/data`. Let RivetKit start its engine normally. Keep its native and engine packages in the image; do not hardcode `startEngine: false` or bake remote Rivet credentials into it. The container user must be able to write `/data`. + +The [counter example](examples/counter) uses the same code and Dockerfile on both providers, with `registry.start()` and no runtime overrides. + +Only the app's HTTP port is public; the local engine stays inside the Machine. Browser clients should call your app's backend. The returned app URL is **not** `RIVET_ENDPOINT`. + +Fly creates one Machine with a 20 GiB persistent volume. Increasing `fly-volume-size` grows the existing volume on redeploy; lowering it never shrinks or replaces the volume. RivetKit v3’s local engine reserves 4 GiB for its log; smaller-than-5 GiB volumes cannot start it. It stops when idle and starts on incoming HTTP requests. Actor data survives stops and image updates, but is removed on preview cleanup. Open connections can keep it awake; background work cannot wake a stopped Machine. Volume storage remains billable while stopped. See [Fly autostop](https://fly.io/docs/launch/autostop-autostart/) and [volumes](https://fly.io/docs/volumes/overview/). ## Inputs -| Input | Required | Default | Description | -|:------|:---------|:--------|:------------| -| `rivet-token` | Yes | - | Rivet Cloud API token | -| `rivet-endpoint` | No | `https://api.rivet.dev` | Rivet Engine API endpoint | -| `github-token` | No | `${{ github.token }}` | GitHub token for PR comments | -| `main-branch` | No | `main` | Main branch name for production deployments | -| `docker-build-path` | No | `.` | Docker build context directory | -| `dockerfile-path` | No | `Dockerfile` | Dockerfile location | -| `compute-pool-config` | No | `{ }` | JSON property overrides for compute pool configuration | -| `prod-namespace` | No | - | **(Advanced)** Override the production namespace slug instead of auto-detecting the first `production-*` namespace | +| Input | Default | Purpose | +| --- | --- | --- | +| `branch-clusters` | `{}` | Branch → cluster mapping | +| `rivet-token`, `rivet-org` | — | Required for mapped Cloud deployments | +| `rivet-cli-version` | `latest` | CLI release with v3 cluster support; pin for reproducibility | +| `rivet-cli-path` | — | Use a preinstalled v3 CLI instead of downloading one | +| `docker-build-path` | `.` | Build context, relative to checkout | +| `dockerfile-path` | `Dockerfile` | Dockerfile, relative to checkout | +| `fly-enabled` | `false` | Enable previews for unmapped branches | +| `fly-token`, `fly-org` | — | Required for Fly deployment and cleanup | +| `fly-region` | `iad` | Preview region; keep stable across updates | +| `fly-port` | `8080` | App HTTP port | +| `fly-memory` | `1024` | Machine memory, MiB | +| `fly-volume-size` | `20` | Data volume size, GiB (minimum 5; grows existing volumes) | +| `github-token` | `${{ github.token }}` | PR status comments | -## Setup +PR comments update in place with a status table and workflow logs. Ready deployments include an **Open preview** link. Skipped previews explain why; PR closure reports removal or retention of a mapped cluster. Cancellation reporting runs as a final action step and is best-effort if the runner is forcibly terminated. + +Outputs: `status` (`ready`, `skipped`, `closed`), `provider` (`rivet`, `fly`), `url`, `build-id` (Cloud), and `fly-app` (Fly). + +## Upgrading from v1 + +v3 uses branch-to-cluster mappings instead of creating namespaces. Replace `main-branch`, `prod-namespace`, `rivet-endpoint`, `runner-config`, and `compute-pool-config` with the mapping above. Configure cluster resources in Rivet. Keep using the v1 tag for the old namespace-based deployment flow. + +## Development -1. Get your Rivet token from [Rivet Dashboard](https://dashboard.rivet.dev) > Settings > Advanced > Manual Client Configuration - -2. Add secret to your repository: - ```bash - gh secret set RIVET_CLOUD_TOKEN - ``` - -3. Create `.github/workflows/rivet-deploy.yml`: - ```yaml - name: Rivet Deploy - - on: - pull_request: - types: [opened, synchronize, reopened, closed] - push: - branches: [main] - workflow_dispatch: - - concurrency: - group: rivet-deploy-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - - jobs: - rivet-deploy: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: rivet-dev/deploy-action@v1.1.2 - with: - rivet-token: ${{ secrets.RIVET_CLOUD_TOKEN }} - ``` - -## How It Works - -When a PR is opened or updated: - -1. The action creates a Rivet namespace for the PR (or reuses an existing one) -2. The Dockerfile is built -3. The image is uploaded to Rivet's docker registry -4. A runner pool using the image is created in the new namespace - -When a PR is closed, the action archives the corresponding Rivet namespace to keep your project tidy. +```sh +bun install --frozen-lockfile +bun test +bun run check +``` diff --git a/action.yml b/action.yml index 13445a0..a2aaba4 100644 --- a/action.yml +++ b/action.yml @@ -1,67 +1,115 @@ -name: 'Rivet Deploy' -description: 'Build and deploy Docker images to Rivet Compute' -author: 'Rivet' +name: Rivet Deploy +description: Deploy mapped branches to Rivet v3 clusters and optional Fly.io previews +author: Rivet branding: - icon: 'server' - color: 'orange' - + icon: server + color: orange inputs: + branch-clusters: + description: 'JSON map of exact branch names to cluster names or {cluster, region, pool} objects' + default: '{}' rivet-token: - description: 'Rivet Cloud API token' - required: true - rivet-endpoint: - description: 'Rivet Engine API endpoint' - required: false - default: 'https://api.rivet.dev' + description: 'Rivet Cloud credential with cluster deployment access (not an Engine admin token)' + rivet-org: + description: 'Organization slug for mapped Rivet Cloud clusters' + rivet-cli-version: + description: '@rivetkit/cli version or dist-tag with v3 --cluster support' + default: latest + rivet-cli-path: + description: Optional preinstalled v3 CLI executable; overrides rivet-cli-version github-token: - description: 'GitHub token for PR comments' - required: false + description: Token for PR status comments default: ${{ github.token }} - main-branch: - description: 'Main branch name for production deployments' - required: false - default: 'main' docker-build-path: - description: 'Docker build context directory' - required: false + description: Docker build context, relative to the checkout default: '.' dockerfile-path: - description: 'Dockerfile location' - required: false - default: 'Dockerfile' - runner-config: - description: 'JSON property overrides for runner configuration' - required: false - default: '{}' - compute-pool-config: - description: 'JSON property overrides for compute pool configuration' - required: false - default: '{}' - prod-namespace: - description: '(Advanced) Override the production namespace slug instead of auto-detecting the first prod-* namespace' - required: false - default: '' - + description: Dockerfile, relative to the checkout + default: Dockerfile + fly-enabled: + description: Deploy unmapped branches to Fly.io + default: 'false' + fly-token: + description: Fly.io token with permission to create and delete apps in fly-org + fly-org: + description: Fly.io organization slug + fly-region: + description: Fly preview region; keep unchanged while a preview exists + default: iad + fly-port: + description: HTTP port served by the app in the container + default: '8080' + fly-memory: + description: Memory in MiB for the single preview Machine + default: '1024' + fly-volume-size: + description: Size in GiB of the local RivetKit data volume; existing volumes grow when increased + default: '20' +outputs: + status: + description: ready, skipped, or closed + value: ${{ steps.deploy.outputs.status }} + provider: + description: rivet or fly + value: ${{ steps.deploy.outputs.provider }} + url: + description: Public app URL (not the Rivet Engine API) + value: ${{ steps.deploy.outputs.url }} + build-id: + description: Rivet build ID for rollback + value: ${{ steps.deploy.outputs.build-id }} + fly-app: + description: Fly preview app name + value: ${{ steps.deploy.outputs.fly-app }} runs: - using: 'composite' + using: composite steps: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest - - - name: Deploy to Rivet + bun-version: '1.3.14' + - uses: superfly/flyctl-actions/setup-flyctl@98172f70657cb3e15db199af139be99bd3e61200 + if: inputs.fly-enabled == 'true' || inputs.fly-token != '' + with: + version: '0.4.110' + - name: Install action dependencies + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + run: bun install --frozen-lockfile --cwd "$ACTION_PATH" + - name: Deploy + id: deploy shell: bash env: + ACTION_PATH: ${{ github.action_path }} GITHUB_TOKEN: ${{ inputs.github-token }} + BRANCH_CLUSTERS: ${{ inputs.branch-clusters }} RIVET_CLOUD_TOKEN: ${{ inputs.rivet-token }} - RIVET_ENGINE_ENDPOINT: ${{ inputs.rivet-endpoint }} - PR_NUMBER: ${{ github.event.pull_request.number || '' }} - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - REPO_FULL_NAME: ${{ github.repository }} - RUN_ID: ${{ github.run_id }} - MAIN_BRANCH: ${{ inputs.main-branch }} + RIVET_CLI_VERSION: ${{ inputs.rivet-cli-version }} + RIVET_CLI_PATH: ${{ inputs.rivet-cli-path }} + RIVET_ORG: ${{ inputs.rivet-org }} DOCKER_BUILD_PATH: ${{ inputs.docker-build-path }} DOCKERFILE_PATH: ${{ inputs.dockerfile-path }} - COMPUTE_POOL_CONFIG: ${{ inputs.compute-pool-config }} - PROD_NAMESPACE_OVERRIDE: ${{ inputs.prod-namespace }} - run: bun run ${{ github.action_path }}/src/index.ts + FLY_ENABLED: ${{ inputs.fly-enabled }} + FLY_API_TOKEN: ${{ inputs.fly-token }} + FLY_ORG: ${{ inputs.fly-org }} + FLY_REGION: ${{ inputs.fly-region }} + FLY_PORT: ${{ inputs.fly-port }} + FLY_MEMORY: ${{ inputs.fly-memory }} + FLY_VOLUME_SIZE: ${{ inputs.fly-volume-size }} + run: bun run "$ACTION_PATH/src/index.ts" + - name: Report failed deployment + if: failure() && !cancelled() + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + GITHUB_TOKEN: ${{ inputs.github-token }} + DEPLOY_CANCELLED: 'false' + run: bun run "$ACTION_PATH/src/report.ts" + - name: Report cancelled deployment + if: cancelled() + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + GITHUB_TOKEN: ${{ inputs.github-token }} + DEPLOY_CANCELLED: 'true' + run: bun run "$ACTION_PATH/src/report.ts" diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5118add --- /dev/null +++ b/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "rivet-deploy-action", + "devDependencies": { + "@types/bun": "1.4.4", + "typescript": "5.9.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], + + "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], + + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], + + "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/examples/counter/.dockerignore b/examples/counter/.dockerignore new file mode 100644 index 0000000..2aba702 --- /dev/null +++ b/examples/counter/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.env* +*.tar diff --git a/examples/counter/Dockerfile b/examples/counter/Dockerfile new file mode 100644 index 0000000..02b54ce --- /dev/null +++ b/examples/counter/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-bookworm-slim +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund +COPY server.js ./ +ENV NODE_ENV=production +EXPOSE 8080 +CMD ["node", "server.js"] diff --git a/examples/counter/README.md b/examples/counter/README.md new file mode 100644 index 0000000..ecf857e --- /dev/null +++ b/examples/counter/README.md @@ -0,0 +1,23 @@ +# One app for Cloud and Fly + +This counter uses `setup({ use: { counter } })`, `registry.start()`, and `createClient()` with standard configuration. The same Dockerfile runs on both providers. + +Set these action inputs for this repository: + +```yaml +docker-build-path: examples/counter +dockerfile-path: examples/counter/Dockerfile +``` + +- `/` reports the app version and Node environment. +- `/increment` increments a persistent counter. +- `/count` reads it. + +Cloud supplies the remote engine connection. Fly sets development mode so RivetKit starts its bundled engine and stores data on the preview volume. The internal metrics listener is for Rivet Cloud monitoring. + +Run the cold-start check locally: + +```sh +docker build -t rivet-counter examples/counter +python3 scripts/smoke-start.py rivet-counter +``` diff --git a/examples/counter/package-lock.json b/examples/counter/package-lock.json new file mode 100644 index 0000000..134abb9 --- /dev/null +++ b/examples/counter/package-lock.json @@ -0,0 +1,1494 @@ +{ + "name": "rivet-deploy-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rivet-deploy-example", + "dependencies": { + "rivetkit": "3.0.0-preview.4" + } + }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-9.1.0.tgz", + "integrity": "sha512-pLMeRgRYS7/vZIgAAkOe2P6+XGTifR1MT9pqDoxZ11EmcJJ+DPmdPqLN8ZZF4U8R9KHCCQCS9c2wtuE8wSrfkw==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.6.0" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.2.tgz", + "integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.2.tgz", + "integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.2.tgz", + "integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.2.tgz", + "integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.2.tgz", + "integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@hono/zod-openapi": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.6.3.tgz", + "integrity": "sha512-VYR82Y60plu8g3pN60OUXMVKpmxh3R04Qs++3DY9vtwZ9/sA/BRY0PcRIR+Z37/3itWtM7pYwT9bKiH9JhqV6Q==", + "license": "MIT", + "dependencies": { + "@asteasolutions/zod-to-openapi": "^9.1.0", + "@hono/zod-validator": "^0.9.1", + "openapi3-ts": "^4.5.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "hono": ">=4.10.0", + "zod": "^4.0.0" + } + }, + "node_modules/@hono/zod-validator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.9.1.tgz", + "integrity": "sha512-iiv6w0qrIc0arfvCtUqBWsvl4fXjzaTcQcCJTTtCnkawF9HHGE+KjEl0ox3gQJ6rKZEgE3mLExlQCF72M+mNuw==", + "license": "MIT", + "peerDependencies": { + "hono": ">=4.11.2", + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz", + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "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/@rivetkit/bare-ts": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@rivetkit/bare-ts/-/bare-ts-0.6.2.tgz", + "integrity": "sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + } + }, + "node_modules/@rivetkit/engine-api-full": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/engine-api-full/-/engine-api-full-3.0.0-preview.4.tgz", + "integrity": "sha512-mWXzmhSh/ClSbxmRVbvrx3W/RFc9GLvEc+SrR8FEEHSY7+mlCL9G6mGw5DedPp86pkGpSRSRmqsWCNc6awV5qw==", + "license": "Apache-2.0", + "dependencies": { + "form-data": "^4.0.4", + "js-base64": "^3.7.8", + "node-fetch": "^2.7.0", + "qs": "^6.14.0", + "readable-stream": "^4.7.0", + "url-join": "^5.0.0" + } + }, + "node_modules/@rivetkit/engine-cli": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/engine-cli/-/engine-cli-3.0.0-preview.4.tgz", + "integrity": "sha512-iHTIqVCv+oUnauoOHiz3CjPQxj1GUZLebXZNYNFNAD2S7cr90OjdEJsV3J2YpHgyGgWHDzvObiTN92aE4K2wXg==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20.0.0" + }, + "optionalDependencies": { + "@rivetkit/engine-cli-darwin-arm64": "3.0.0-preview.4", + "@rivetkit/engine-cli-darwin-x64": "3.0.0-preview.4", + "@rivetkit/engine-cli-linux-arm64-musl": "3.0.0-preview.4", + "@rivetkit/engine-cli-linux-x64-musl": "3.0.0-preview.4" + } + }, + "node_modules/@rivetkit/engine-cli-darwin-arm64": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/engine-cli-darwin-arm64/-/engine-cli-darwin-arm64-3.0.0-preview.4.tgz", + "integrity": "sha512-sftJA2N5rcASGz/J6crFgFNyGOY4kNB7H1yld87NG57+4rysIP2yXEIWwVkcI3YoCxKeAyuT9NFiL91KIYl9Ig==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "rivet-engine": "rivet-engine" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/engine-cli-darwin-x64": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/engine-cli-darwin-x64/-/engine-cli-darwin-x64-3.0.0-preview.4.tgz", + "integrity": "sha512-bBlv+I8R7GLju0GCBHvzk8kQaJZp3FB9XzjC3isUnbDm+YJpwPxscie9DPdFassJN+j1p9YWRvBOEPCQMnpqmg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "rivet-engine": "rivet-engine" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/engine-cli-linux-arm64-musl": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/engine-cli-linux-arm64-musl/-/engine-cli-linux-arm64-musl-3.0.0-preview.4.tgz", + "integrity": "sha512-/cZdl4iJkl9jXgYiEI0d+DxdBzhDx/fxcVSHvoS6yUFgS6uQC0TdMmHswwsubxT2lbUTM4PS1DJo5n7DAHxvLA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "rivet-engine": "rivet-engine" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/engine-cli-linux-x64-musl": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/engine-cli-linux-x64-musl/-/engine-cli-linux-x64-musl-3.0.0-preview.4.tgz", + "integrity": "sha512-FcZRZ45JYzVCKwb6jwNFKjTx51R+HWBdLt0yU/C3KIuqOpsWOR76+0S344ezHf0ZHpY4BTSHYJrgPirYkWugpg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "rivet-engine": "rivet-engine" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/engine-envoy-protocol": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/engine-envoy-protocol/-/engine-envoy-protocol-3.0.0-preview.4.tgz", + "integrity": "sha512-7mx28tsiUjciNiJnw2/7EDDTmsxL10uOzsep9Uj7wl9RQpO1dxpsQ/27PZy+oPuKgPCpVGP+uFyTNEKMtYRSyg==", + "license": "Apache-2.0", + "dependencies": { + "@rivetkit/bare-ts": "^0.6.2" + } + }, + "node_modules/@rivetkit/on-change": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@rivetkit/on-change/-/on-change-6.0.1.tgz", + "integrity": "sha512-QBN/KRBXLJdCgN4gBTL3XAc/zKm58atSnieXWMOyFSPmo6F1/yIVV/LTRdvAktfCttrGx7W6c32i/lwqCHWnsQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/on-change?sponsor=1" + } + }, + "node_modules/@rivetkit/rivetkit-napi": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/rivetkit-napi/-/rivetkit-napi-3.0.0-preview.4.tgz", + "integrity": "sha512-GJjcG32KsXyvfPaIRcavnwU6Cq2z4R17WOkZr+W0wxd3xCLlJgJ0neR/hpzerInFUoo1Xpzk8YS/Di3cWWl8jA==", + "license": "Apache-2.0", + "dependencies": { + "@rivetkit/engine-envoy-protocol": "3.0.0-preview.4" + }, + "engines": { + "node": ">= 20.0.0" + }, + "optionalDependencies": { + "@rivetkit/rivetkit-napi-darwin-arm64": "3.0.0-preview.4", + "@rivetkit/rivetkit-napi-darwin-x64": "3.0.0-preview.4", + "@rivetkit/rivetkit-napi-linux-arm64-gnu": "3.0.0-preview.4", + "@rivetkit/rivetkit-napi-linux-arm64-musl": "3.0.0-preview.4", + "@rivetkit/rivetkit-napi-linux-x64-gnu": "3.0.0-preview.4", + "@rivetkit/rivetkit-napi-linux-x64-musl": "3.0.0-preview.4" + } + }, + "node_modules/@rivetkit/rivetkit-napi-darwin-arm64": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/rivetkit-napi-darwin-arm64/-/rivetkit-napi-darwin-arm64-3.0.0-preview.4.tgz", + "integrity": "sha512-yoNf7iL8tK+K+U5zZEHvpEEexCI1a9mA5Ev76riYsAEaWK19H6kntdOO2Joj5A5hIo+H7oqdkibM44RaVfiZGw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/rivetkit-napi-darwin-x64": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/rivetkit-napi-darwin-x64/-/rivetkit-napi-darwin-x64-3.0.0-preview.4.tgz", + "integrity": "sha512-a00Xqe6B0PqRQ6jSrjEbzPS8P3/GUNeUt7xKOC7U54jqQKfXW6tAzqhjDuKr5l71EkG/j02uXyKExHg8U5JTNw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/rivetkit-napi-linux-arm64-gnu": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/rivetkit-napi-linux-arm64-gnu/-/rivetkit-napi-linux-arm64-gnu-3.0.0-preview.4.tgz", + "integrity": "sha512-lS5Wzqoy8cEm88iqzP4phGJwvrBgWXFpAOTcvx4Ttbs/YtGvL7A3XsSU+WyRObnf3pCXkUy2OsZgfDwcXQ4ALg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/rivetkit-napi-linux-arm64-musl": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/rivetkit-napi-linux-arm64-musl/-/rivetkit-napi-linux-arm64-musl-3.0.0-preview.4.tgz", + "integrity": "sha512-fJ/6ERZ8/Wo5I+MqizauuZJaixLkD8hRg3RymDtKnq2KqYVMoUPRQjQlVpdiOGIFcgklf7IMiSzsqbDQxcpC9w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/rivetkit-napi-linux-x64-gnu": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/rivetkit-napi-linux-x64-gnu/-/rivetkit-napi-linux-x64-gnu-3.0.0-preview.4.tgz", + "integrity": "sha512-VcRDMIFHrpHhiezDo3qY9X9b1bsawPRj8Pb0KyNlA2qYq5md5LWeUcAiTzQ+htZosxKJsd2vpWktlLxhtiNKYQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/rivetkit-napi-linux-x64-musl": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/rivetkit-napi-linux-x64-musl/-/rivetkit-napi-linux-x64-musl-3.0.0-preview.4.tgz", + "integrity": "sha512-25z56qsWR1xhxvR+jBldJbScV8JFx5zNbtm0+i9HdZYO2BbK9rlDsdJgH+57LR9Z2tFdL6gvK5M0vasNSTDX5Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@rivetkit/rivetkit-wasm": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/rivetkit-wasm/-/rivetkit-wasm-3.0.0-preview.4.tgz", + "integrity": "sha512-Rn1XGIQbSO8+AnpPUI4JVib/muGe3mEmbZ3n4yJdVecO05TkYJRgezzTgxp/i+d9nbq2Oyn7fJUcmHamjpzbpw==", + "license": "Apache-2.0" + }, + "node_modules/@rivetkit/traces": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/traces/-/traces-3.0.0-preview.4.tgz", + "integrity": "sha512-8abZPtvPfFnD21Gf01JV1gkrrP2KQVwL0ObVG5aOWQs03oK+tOvInX1dkw/IGDmMe1O/GlFfIbjP1G5ZYmbmzQ==", + "license": "Apache-2.0", + "dependencies": { + "@rivetkit/bare-ts": "^0.6.2", + "cbor-x": "^1.6.0", + "fdb-tuple": "^1.0.0", + "vbare": "^0.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@rivetkit/virtual-websocket": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/virtual-websocket/-/virtual-websocket-3.0.0-preview.4.tgz", + "integrity": "sha512-XL05Y1eMqb+vL/cCbmZZcH77zwXmOT9QM2rs8emp20w7aV0KEwG47pg6lL3eCC2BfVwSyK+hUO6GWial7qbd9A==", + "license": "Apache-2.0" + }, + "node_modules/@rivetkit/workflow-engine": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/@rivetkit/workflow-engine/-/workflow-engine-3.0.0-preview.4.tgz", + "integrity": "sha512-VgW3rpCZzsRUt6jfajHz3J0qQyQE+ESlX+0g5PpFvxoEEPmZwenUqFZa9UwI/L/4YH2c31WOerk5qo161XMuBA==", + "license": "Apache-2.0", + "dependencies": { + "@rivetkit/bare-ts": "^0.6.2", + "cbor-x": "^1.6.0", + "fdb-tuple": "^1.0.0", + "pino": "^9.6.0", + "vbare": "^0.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "license": "MIT" + }, + "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/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/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/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/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/cbor-extract": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.2.tgz", + "integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", + "@cbor-extract/cbor-extract-linux-x64": "2.2.2", + "@cbor-extract/cbor-extract-win32-x64": "2.2.2" + } + }, + "node_modules/cbor-x": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.8.tgz", + "integrity": "sha512-wUtIwq2J+Y2bEvDpdJj0z9QIX+PGMzms489HFvNwr0V8Zq3ejqOdZKTaTe6Zru8WU0IwuRDtpys3nUF4LDRKAQ==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.2" + } + }, + "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/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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "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/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/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/fdb-tuple": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fdb-tuple/-/fdb-tuple-1.0.0.tgz", + "integrity": "sha512-8jSvKPCYCgTpi9Pt87qlfTk6griyMx4Gk3Xv31Dp72Qp8b6XgIyFsMm8KzPmFJ9iJ8K4pGvRxvOS8D0XGnrkjw==", + "license": "MIT" + }, + "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/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/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/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/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/hono": { + "version": "4.13.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.14.tgz", + "integrity": "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.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/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-base64": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.5.tgz", + "integrity": "sha512-u0CFDUca8BbBRkMkF/fDcuoLCx9nTNbXdVtbjVnBqySA9E221u0uYKbACiYGOEk1heY69qM4caeX3OwjSyxevw==", + "license": "BSD-3-Clause" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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/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/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/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "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/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "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/openapi3-ts": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.2.tgz", + "integrity": "sha512-gyyil3DweqhXiEhTqtAs9wwRLfWHmetwhI4kmM5Rn0d6gtmK863Ki7qqbQnp9+Wwlqoe3B1LFWj7kMp5ctE4rQ==", + "license": "MIT", + "dependencies": { + "yaml": "^2.9.1" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "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-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/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-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", + "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/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/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "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/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/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rivetkit": { + "version": "3.0.0-preview.4", + "resolved": "https://registry.npmjs.org/rivetkit/-/rivetkit-3.0.0-preview.4.tgz", + "integrity": "sha512-O6PwMG3zmsAtvAR1mxZ0sabWIEraz419zHO3CkmlzDJ7N4pSL5g+QAAdi7ZKCYOCDJliu5YPAywlezxTqU/Jjw==", + "license": "Apache-2.0", + "dependencies": { + "@hono/zod-openapi": "^1.1.5", + "@opentelemetry/api": "^1.1.0", + "@rivetkit/bare-ts": "^0.6.2", + "@rivetkit/engine-api-full": "3.0.0-preview.4", + "@rivetkit/engine-cli": "3.0.0-preview.4", + "@rivetkit/engine-envoy-protocol": "3.0.0-preview.4", + "@rivetkit/on-change": "6.0.1", + "@rivetkit/rivetkit-napi": "3.0.0-preview.4", + "@rivetkit/rivetkit-wasm": "3.0.0-preview.4", + "@rivetkit/traces": "3.0.0-preview.4", + "@rivetkit/virtual-websocket": "3.0.0-preview.4", + "@rivetkit/workflow-engine": "3.0.0-preview.4", + "cbor-x": "^1.6.0", + "drizzle-orm": "0.45.2", + "hono": "^4.7.0", + "invariant": "^2.2.4", + "p-retry": "^6.2.1", + "pino": "^9.5.0", + "uuid": "^12.0.0", + "vbare": "^0.0.4", + "zod": "^4.1.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "drizzle-kit": "^0.31.2", + "eventsource": "^4.0.0", + "ws": "^8.0.0" + }, + "peerDependenciesMeta": { + "drizzle-kit": { + "optional": true + }, + "eventsource": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "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/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/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/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/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/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/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/url-join": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.2.0.tgz", + "integrity": "sha512-/tMVQdp/2eE90fGb6npbd2nLHH8jamzLLeZhLEKTz4Rc38wO5AeEuIhoWroWWfQJS50ZzL4Xod9JER/pJWTTEQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/uuid": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-12.0.1.tgz", + "integrity": "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vbare": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vbare/-/vbare-0.0.4.tgz", + "integrity": "sha512-QsxSVw76NqYUWYPVcQmOnQPX8buIVjgn+yqldTHlWISulBTB9TJ9rnzZceDu+GZmycOtzsmuPbPN1YNxvK12fg==", + "engines": { + "node": ">=18.0.0" + } + }, + "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/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/examples/counter/package.json b/examples/counter/package.json new file mode 100644 index 0000000..6b7e72c --- /dev/null +++ b/examples/counter/package.json @@ -0,0 +1,11 @@ +{ + "name": "rivet-deploy-example", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "rivetkit": "3.0.0-preview.4" + } +} diff --git a/examples/counter/server.js b/examples/counter/server.js new file mode 100644 index 0000000..963a804 --- /dev/null +++ b/examples/counter/server.js @@ -0,0 +1,25 @@ +import { createServer } from 'node:http'; +import { actor, setup } from 'rivetkit'; +import { createClient } from 'rivetkit/client'; +const counter = actor({ state: { count: 0 }, actions: { + increment(c) { return ++c.state.count; }, + getCount(c) { return c.state.count; } +} }); +const registry = setup({ use: { counter } }); +registry.start(); +const client = createClient(); +createServer(async (req, res) => { + try { + let body; + if (req.url === '/increment') body = { count: await client.counter.getOrCreate(['smoke']).increment() }; + else if (req.url === '/count') body = { count: await client.counter.getOrCreate(['smoke']).getCount() }; + else body = { ok: true, version: 'vanilla', mode: process.env.NODE_ENV }; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); + } catch (error) { res.statusCode=500; res.end(JSON.stringify({error:String(error)})); } +}).listen(Number(process.env.PORT || 8080), '0.0.0.0'); +createServer(async (req, res) => { + const response = await registry.routes.prometheusMetrics(); + res.statusCode=response.status; + res.end(await response.text()); +}).listen(Number(process.env.RIVETKIT_METRICS_PORT || 9464), '127.0.0.1'); diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..d56fc23 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,6 @@ +pre-push: + commands: + check: + run: bun run check + test: + run: bun test diff --git a/package.json b/package.json new file mode 100644 index 0000000..769db93 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "rivet-deploy-action", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "check": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "1.4.4", + "typescript": "5.9.3" + } +} diff --git a/scripts/smoke-start.py b/scripts/smoke-start.py new file mode 100644 index 0000000..96087ba --- /dev/null +++ b/scripts/smoke-start.py @@ -0,0 +1,52 @@ +"""Check plain registry.start() on a cold boot and a persistent restart.""" +import json +import subprocess +import sys +import time +import urllib.error +import urllib.request + + +def wait_for_http(endpoint): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(endpoint, timeout=1) as response: + assert response.status == 200 + return + except (OSError, urllib.error.URLError): + time.sleep(0.05) + raise RuntimeError("App did not start listening") + + +def actor_request(endpoint, path, expected): + # Deliberately make one actor request: retries here would hide startup failures. + with urllib.request.urlopen(endpoint + path, timeout=90) as response: + body = json.load(response) + assert response.status == 200 and body == {"count": expected}, body + + +image = sys.argv[1] +container = subprocess.check_output([ + "docker", "run", "-d", "-p", "127.0.0.1::8080", + "-e", "NODE_ENV=development", "-e", "RIVETKIT_STORAGE_PATH=/data", image, +], text=True).strip() +try: + def endpoint(): + port = subprocess.check_output(["docker", "port", container, "8080"], text=True).strip().split(":")[-1] + return "http://127.0.0.1:" + port + + url = endpoint() + wait_for_http(url) + actor_request(url, "/increment", 1) + print("First cold-start actor request passed", flush=True) + subprocess.run(["docker", "stop", "--time", "10", container], check=True, stdout=subprocess.DEVNULL) + subprocess.run(["docker", "start", container], check=True, stdout=subprocess.DEVNULL) + url = endpoint() + wait_for_http(url) + actor_request(url, "/count", 1) + actor_request(url, "/increment", 2) + print("First request after restart passed; actor state persisted", flush=True) +finally: + subprocess.run(["docker", "stop", "--time", "5", container], stdout=subprocess.DEVNULL) + subprocess.run(["docker", "rm", container], stdout=subprocess.DEVNULL) diff --git a/src/cloud.ts b/src/cloud.ts new file mode 100644 index 0000000..8f2d37e --- /dev/null +++ b/src/cloud.ts @@ -0,0 +1,41 @@ +import type { Cluster, Config } from "./config"; +import { run, type Run } from "./process"; + +export async function deployCloud( + config: Config, + target: Cluster, + command: Run = run, +): Promise<{ buildId: string; url: string }> { + if (!config.cloudToken || !config.cloudOrg) throw new Error("Cloud deployments need rivet-token and rivet-org; use a Cloud deployment credential, not an Engine admin token"); + const program = config.cliPath || "bun"; + const prefix = config.cliPath ? [] : ["x", "--package", `@rivetkit/cli@${config.cliVersion}`, "rivet"]; + const options = { cwd: config.workspace, env: { RIVET_CLOUD_TOKEN: config.cloudToken, NO_COLOR: "1" } }; + const cli = (args: string[], quiet = false) => command(program, [...prefix, ...args], { ...options, quiet }); + // The older namespace CLI must never silently deploy to a different target. + const help = await cli(["deploy", "--help"], true); + if (!help.includes("--cluster") || !help.includes("--build-context")) { + throw new Error("This Rivet CLI cannot deploy v3 clusters. Set rivet-cli-version to a release supporting --cluster, or rivet-cli-path to an installed v3 binary."); + } + const selection = ["--org", config.cloudOrg, "--cluster", target.cluster]; + const api = async (path: string): Promise => JSON.parse(await cli(["api", path, ...selection], true)); + // Resolve and validate the region before building or changing the pool. + const regions = await api<{ items: { region: string }[] }>("/v2/clusters/{cluster}/regions"); + const region = target.region ?? (regions.items.length === 1 ? regions.items[0].region : undefined); + if (!region || !regions.items.some((item) => item.region === region)) { + throw new Error("Set a mapped region that exists on the cluster; automatic selection requires exactly one region"); + } + const result = await cli([ + "deploy", ...selection, "--region", region, + ...(target.pool ? ["--pool", target.pool] : []), + "--dockerfile", config.dockerfile, "--build-context", config.buildPath, "--yes", + ]); + // The CLI writes progress before its final line, which is the build ID. + const buildId = result.trim().split(/\r?\n/).at(-1) || ""; + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(buildId)) throw new Error("Deployment finished but the Rivet CLI did not return a build ID; inspect the cluster before retrying"); + console.log(`Rivet build: ${buildId}`); + const pool = await api<{ build_id: string | null; endpoints: { public: string | null } }>( + `/v2/clusters/{cluster}/regions/${encodeURIComponent(region)}/runner-pools/${encodeURIComponent(target.pool ?? "default")}`, + ); + if (pool.build_id !== buildId) throw new Error("The pool changed after deployment; inspect concurrent deployments before retrying"); + return { buildId, url: pool.endpoints.public || "" }; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..472185b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; + +export type Cluster = { cluster: string; region?: string; pool?: string }; +export type Event = { + action?: string; + ref?: string; + ref_type?: string; + pull_request?: { number: number; head: { ref: string; repo?: { full_name: string } } }; +}; + +export function object(value: unknown, name: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be a JSON object`); + return value as Record; +} + +function text(value: unknown, name: string): string { + if (typeof value !== "string" || !value.trim() || /[\r\n\0]/.test(value)) throw new Error(`${name} must be a nonempty string`); + return value; +} + +export function parseClusters(raw: string): Record { + const entries = object(JSON.parse(raw), "branch-clusters"); + const result: Record = Object.create(null); + const used = new Set(); + for (const [branch, entry] of Object.entries(entries)) { + text(branch, "branch"); + const data = typeof entry === "string" ? { cluster: entry } : object(entry, `branch-clusters.${branch}`); + for (const key of Object.keys(data)) if (!["cluster", "region", "pool"].includes(key)) throw new Error(`Unknown branch-clusters field: ${key}`); + const cluster = text(data.cluster, "cluster"); + if (used.has(cluster)) throw new Error(`Map each cluster to only one branch: ${cluster}`); + used.add(cluster); + result[branch] = { + cluster, + ...(data.region === undefined ? {} : { region: text(data.region, "region") }), + ...(data.pool === undefined ? {} : { pool: text(data.pool, "pool") }), + }; + } + return result; +} + +function integer(raw: string, name: string, min: number, max: number): number { + if (!/^\d+$/.test(raw) || Number(raw) < min || Number(raw) > max) throw new Error(`${name} must be between ${min} and ${max}`); + return Number(raw); +} + +export function previewName(repo: string, branch: string): string { + const slug = repo.split("/").pop()!.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 20); + const hash = createHash("sha256").update(`${repo.toLowerCase()}\0${branch}`).digest("hex").slice(0, 20); + return `rv-preview-${slug}-${hash}`; +} + +export function readConfig(env: NodeJS.ProcessEnv, event: Event) { + const repo = text(env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); + const branch = event.pull_request?.head.ref ?? (env.GITHUB_EVENT_NAME === "delete" ? event.ref : env.GITHUB_REF_NAME); + const workspace = resolve(env.GITHUB_WORKSPACE || process.cwd()); + const flyEnabled = env.FLY_ENABLED || "false"; + if (!["true", "false"].includes(flyEnabled)) throw new Error("fly-enabled must be true or false"); + const cliVersion = env.RIVET_CLI_VERSION || "latest"; + if (!/^[a-zA-Z0-9][a-zA-Z0-9._+-]*$/.test(cliVersion)) throw new Error("rivet-cli-version must be an npm version or dist-tag"); + return { + repo, + branch: text(branch, "branch"), + pr: event.pull_request?.number, + eventName: env.GITHUB_EVENT_NAME || "workflow_dispatch", + refType: event.ref_type ?? env.GITHUB_REF_TYPE, + action: event.action, + fork: !!event.pull_request && event.pull_request.head.repo?.full_name !== repo, + clusters: parseClusters(env.BRANCH_CLUSTERS || "{}"), + workspace, + buildPath: resolve(workspace, env.DOCKER_BUILD_PATH || "."), + dockerfile: resolve(workspace, env.DOCKERFILE_PATH || "Dockerfile"), + flyEnabled: flyEnabled === "true", + flyToken: env.FLY_API_TOKEN || "", + flyOrg: env.FLY_ORG || "", + flyRegion: env.FLY_REGION || "iad", + flyPort: integer(env.FLY_PORT || "8080", "fly-port", 1, 65535), + flyMemory: integer(env.FLY_MEMORY || "1024", "fly-memory", 256, 16384), + flyVolumeSize: integer(env.FLY_VOLUME_SIZE || "20", "fly-volume-size", 5, 100), + cliVersion, + cliPath: env.RIVET_CLI_PATH || "", + cloudToken: env.RIVET_CLOUD_TOKEN || "", + cloudOrg: env.RIVET_ORG || "", + githubToken: env.GITHUB_TOKEN || "", + }; +} + +export type Config = ReturnType; +export type Plan = { kind: "skip"; reason: string } | { kind: "cleanup" } | { kind: "fly" } | { kind: "cloud"; target: Cluster }; + +export function plan(config: Config): Plan { + if (config.fork) return { kind: "skip", reason: "Fork pull requests do not receive deployment credentials." }; + if (config.refType === "tag") return { kind: "skip", reason: "Tag events do not deploy." }; + if (!["push", "pull_request", "workflow_dispatch", "delete"].includes(config.eventName)) return { kind: "skip", reason: "Unsupported event." }; + if (config.eventName === "delete" || (config.eventName === "pull_request" && config.action === "closed")) return { kind: "cleanup" }; + if (config.eventName === "pull_request" && !["opened", "synchronize", "reopened"].includes(config.action || "")) return { kind: "skip", reason: "Unsupported pull request action." }; + const target = config.clusters[config.branch]; + if (target) return { kind: "cloud", target }; + if (!config.flyEnabled || !config.flyToken) return { kind: "skip", reason: "Preview deployments are not enabled." }; + return { kind: "fly" }; +} diff --git a/src/fly.ts b/src/fly.ts new file mode 100644 index 0000000..0d00e21 --- /dev/null +++ b/src/fly.ts @@ -0,0 +1,110 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type Config, previewName } from "./config"; +import { run, type Run } from "./process"; + +type Machine = { id: string; state: string; region: string }; +type App = { name: string; organization: { slug: string } }; +type Volume = { id: string; name: string; region: string; state: string; size_gb: number }; + +export function flyConfig(config: Config) { + return { + app: previewName(config.repo, config.branch), + primary_region: config.flyRegion, + kill_signal: "SIGTERM", + kill_timeout: 30, + env: { + NODE_ENV: "development", + PORT: String(config.flyPort), + // Use RivetKit's standard dev-engine startup and storage resolution. + RIVETKIT_STORAGE_PATH: "/data", + }, + http_service: { + internal_port: config.flyPort, + force_https: true, + auto_stop_machines: "stop", + auto_start_machines: true, + min_machines_running: 0, + concurrency: { type: "connections", soft_limit: 20, hard_limit: 25 }, + }, + mounts: [{ source: "rivet_data", destination: "/data", initial_size: `${config.flyVolumeSize}gb` }], + vm: [{ cpu_kind: "shared", cpus: 1, memory_mb: config.flyMemory }], + }; +} + +export class Fly { + private app: string; + constructor(private config: Config, private command: Run = run, private request: (url: string, init: RequestInit) => Promise = fetch) { + this.app = previewName(config.repo, config.branch); + if (!config.flyToken || !config.flyOrg) throw new Error("Fly previews need fly-token and fly-org"); + } + + private async api(path: string, method = "GET", body?: unknown): Promise { + const response = await this.request(`https://api.machines.dev/v1${path}`, { + method, + headers: { Authorization: `Bearer ${this.config.flyToken}`, "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + if (method === "GET" && response.status === 404) return null; + if (!response.ok) throw new Error(`Fly ${method} ${path} failed (HTTP ${response.status})`); + if (response.status === 204) return null; + const data = await response.text(); + return data ? JSON.parse(data) as T : null; + } + + private async existing(): Promise { + const app = await this.api(`/apps/${this.app}`); + if (app && (app.name !== this.app || app.organization.slug !== this.config.flyOrg)) throw new Error("Preview app belongs to a different Fly organization"); + return app; + } + + private async machines() { + return (await this.api(`/apps/${this.app}/machines`) ?? []).filter((m) => m.state !== "destroyed"); + } + + async deploy(): Promise { + if (!await this.existing()) { + await this.api("/apps", "POST", { app_name: this.app, org_slug: this.config.flyOrg, network: this.app }); + } + const machines = await this.machines(); + if (machines.length > 1) throw new Error("Expected at most one preview Machine; reconcile the Fly app before deploying"); + if (machines.some((m) => m.region !== this.config.flyRegion)) throw new Error("Cannot move a preview's local volume to another region; keep fly-region unchanged"); + const volumes = await this.api(`/apps/${this.app}/volumes`) ?? []; + if (volumes.some((v) => v.name === "rivet_data" && v.region !== this.config.flyRegion && v.state !== "destroyed")) throw new Error("Preview data is in another region; keep fly-region unchanged"); + const dataVolumes = volumes.filter((v) => v.name === "rivet_data" && v.state !== "destroyed"); + if (dataVolumes.length > 1) throw new Error("Expected one preview data volume; reconcile the Fly app before deploying"); + const volume = dataVolumes[0]; + if (volume && volume.size_gb < this.config.flyVolumeSize) { + await this.command("flyctl", ["volumes", "extend", volume.id, "--app", this.app, "--size", String(this.config.flyVolumeSize), "--yes"], { + env: { FLY_API_TOKEN: this.config.flyToken, NO_COLOR: "1" }, + }); + } + const directory = await mkdtemp(join(tmpdir(), "rivet-preview-")); + try { + const configPath = join(directory, "fly.json"); + await writeFile(configPath, JSON.stringify(flyConfig(this.config))); + // Disable the spare Machine and wait for the updated instance to boot. + await this.command("flyctl", [ + "deploy", this.config.buildPath, "--app", this.app, + "--config", configPath, "--dockerfile", this.config.dockerfile, + "--ha=false", "--strategy", "rolling", "--yes", "--remote-only", + "--wait-timeout", "5m", "--volume-initial-size", String(this.config.flyVolumeSize), + ], { cwd: this.config.workspace, env: { FLY_API_TOKEN: this.config.flyToken, NO_COLOR: "1" } }); + const after = await this.machines(); + if (after.length !== 1) throw new Error(`Expected one preview Machine after deploy; found ${after.length}`); + return `https://${this.app}.fly.dev`; + } finally { + await rm(directory, { recursive: true, force: true }); + } + } + + async cleanup(): Promise { + if (!await this.existing()) return; + // Derive the name from the repository and source branch, never from PR comments. + await this.command("flyctl", ["apps", "destroy", this.app, "--yes"], { + env: { FLY_API_TOKEN: this.config.flyToken, NO_COLOR: "1" }, + }); + } +} diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 0000000..60b7f89 --- /dev/null +++ b/src/github.ts @@ -0,0 +1,81 @@ +import { appendFile } from "node:fs/promises"; +import type { Config } from "./config"; + +const marker = ""; +type Comment = { id: number; body: string; user?: { login: string; type: string } }; + +export type StatusState = "deploying" | "ready" | "failed" | "skipped" | "removing" | "closed" | "cleanup-failed" | "cancelled"; +export type StatusDetails = { url?: string; provider?: "rivet" | "fly"; reason?: string; retained?: boolean; removed?: boolean }; +export type CommentConfig = Pick; + +function escapeText(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">") + .replace(/\|/g, "|").replace(/`/g, "`").replace(/[\r\n]/g, " "); +} + +export function renderStatus(config: CommentConfig, state: StatusState, details: StatusDetails = {}, runId = process.env.GITHUB_RUN_ID): string { + const labels: Record = { + deploying: `⏳ Deploying…${details.provider ? ` (${details.provider === "rivet" ? "Rivet" : "Fly"})` : ""}`, + ready: "✅ Ready", + failed: "❌ Deployment failed", + skipped: "⏭️ Skipped", + removing: "⏳ Removing preview…", + closed: details.retained || !details.removed ? "PR closed" : "🗑️ Preview removed", + "cleanup-failed": "❌ Preview removal failed", + cancelled: "⏹️ Deployment cancelled", + }; + const actions: string[] = []; + if (state === "ready" && details.url) { + const url = new URL(details.url); + if (url.protocol === "https:" || url.protocol === "http:") { + url.username = ""; + url.password = ""; + actions.push(`[Open preview](<${url.href.replace(/[<>|]/g, encodeURIComponent)}>)`); + } + } + if (runId) actions.push(`[Logs](https://github.com/${config.repo.split("/").map(encodeURIComponent).join("/")}/actions/runs/${encodeURIComponent(runId)})`); + const intro = config.pr ? "Rivet preview deployment for this PR." : "Rivet deployment for this branch."; + const preview = config.pr ? `PR #${config.pr}` : escapeText(config.branch); + const reason = details.retained ? "The mapped cluster is retained." : details.reason; + return `${marker}\n${intro}\n\n| Project | Preview | Status | Actions |\n|:---|:---|:---|:---|\n| ${escapeText(config.repo.split("/").pop()!)} | ${preview} | ${labels[state]} | ${actions.join(" · ") || "—"} |${reason ? `\n\n${escapeText(reason)}` : ""}`; +} + +export async function status(config: CommentConfig, state: StatusState, details: StatusDetails = {}) { + const body = renderStatus(config, state, details); + if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, body.replace(`${marker}\n`, "") + "\n\n"); + if (!config.pr || !config.githubToken) return; + const repoPath = config.repo.split("/").map(encodeURIComponent).join("/"); + const base = `https://api.github.com/repos/${repoPath}`; + async function request(path: string, method = "GET", content?: unknown) { + const response = await fetch(base + path, { + method, + headers: { Authorization: `Bearer ${config.githubToken}`, Accept: "application/vnd.github+json", "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28" }, + body: content === undefined ? undefined : JSON.stringify(content), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`GitHub comment request failed (HTTP ${response.status})`); + return response.json(); + } + try { + let existing: Comment | undefined; + for (let page = 1; page <= 100; page++) { + const comments = await request(`/issues/${config.pr}/comments?per_page=100&page=${page}`) as Comment[]; + existing = comments.find((c) => c.user?.login === "github-actions[bot]" && c.user.type === "Bot" && (c.body.startsWith(marker) || c.body.startsWith(""))); + if (existing || comments.length < 100) break; + } + if (existing) await request(`/issues/comments/${existing.id}`, "PATCH", { body }); + else await request(`/issues/${config.pr}/comments`, "POST", { body }); + } catch (error) { + // Reporting permissions must not turn a successful rollout into a failed deployment. + console.warn(error instanceof Error ? error.message : "Could not update PR status"); + } +} + +export async function output(values: Record) { + const path = process.env.GITHUB_OUTPUT; + if (!path) return; + for (const [key, value] of Object.entries(values)) { + if (/[\r\n]/.test(value)) throw new Error(`Invalid output: ${key}`); + await appendFile(path, `${key}=${value}\n`); + } +} diff --git a/src/index.ts b/src/index.ts index ef9e731..d6210ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,623 +1,54 @@ -import fs from "node:fs"; -import { execSync } from "node:child_process"; - -// Environment variables -const GITHUB_TOKEN = process.env.GITHUB_TOKEN!; -const RIVET_CLOUD_TOKEN = process.env.RIVET_CLOUD_TOKEN!; -const RIVET_ENGINE_ENDPOINT = process.env.RIVET_ENGINE_ENDPOINT || "https://api.rivet.dev"; -const GITHUB_EVENT_NAME = process.env.GITHUB_EVENT_NAME || ""; -const GITHUB_EVENT_PATH = process.env.GITHUB_EVENT_PATH || ""; -const DOCKER_BUILD_PATH = process.env.DOCKER_BUILD_PATH || "."; -const DOCKERFILE_PATH = process.env.DOCKERFILE_PATH || "Dockerfile"; -const GITHUB_SHA = process.env.GITHUB_SHA || "latest"; -const PROD_NAMESPACE_OVERRIDE = process.env.PROD_NAMESPACE_OVERRIDE || null; - -function readGitHubEventPayload(): any | null { - if (!GITHUB_EVENT_PATH) return null; - try { - const raw = fs.readFileSync(GITHUB_EVENT_PATH, "utf8"); - return JSON.parse(raw); - } catch (error) { - console.log("Failed to read GitHub event payload:", error); - return null; - } -} - -const EVENT_PAYLOAD = readGitHubEventPayload(); -const PR_NUMBER_FROM_EVENT = EVENT_PAYLOAD?.pull_request?.number - ? String(EVENT_PAYLOAD.pull_request.number) - : undefined; - -const PR_NUMBER = process.env.PR_NUMBER || PR_NUMBER_FROM_EVENT; -const BRANCH_NAME = process.env.BRANCH_NAME || "main"; -const REPO_FULL_NAME = process.env.REPO_FULL_NAME!; -const RUN_ID = process.env.RUN_ID!; -const MAIN_BRANCH = process.env.MAIN_BRANCH || "main"; - -const IS_PR_EVENT = GITHUB_EVENT_NAME === "pull_request"; -const IS_PR_CLOSED = IS_PR_EVENT && EVENT_PAYLOAD?.action === "closed"; -const IS_CLEANUP = IS_PR_CLOSED; - -const COMPUTE_POOL_CONFIG: Record = (() => { - try { - return JSON.parse(process.env.COMPUTE_POOL_CONFIG || "{}"); - } catch (e) { - console.error("Failed to parse COMPUTE_POOL_CONFIG:", e); - return {}; - } -})(); - -const IS_PR = !!PR_NUMBER; -const IS_MAIN = BRANCH_NAME === MAIN_BRANCH; -const NS_DISPLAY_NAME = IS_MAIN ? "Production" : IS_PR ? `PR #${PR_NUMBER}` : `Branch ${BRANCH_NAME}`; -const IMAGE_TAG = GITHUB_SHA.length >= 7 ? GITHUB_SHA.substring(0, 7) : GITHUB_SHA; - -function getCloudEndpoint(): string { - const withoutScheme = RIVET_ENGINE_ENDPOINT.replace(/^https?:\/\//, ""); - return "https://" + withoutScheme.replace(/^api\./, "cloud-api."); -} - -function getRegistryEndpoint(): string { - const withoutScheme = RIVET_ENGINE_ENDPOINT.replace(/^https?:\/\//, ""); - return withoutScheme.replace(/^api\./, "registry."); -} - -function getDashboardEndpoint(): string { - const withoutScheme = RIVET_ENGINE_ENDPOINT.replace(/^https?:\/\//, ""); - return "https://" + withoutScheme.replace(/^api\./, "dashboard."); -} - -const COMMENT_MARKER = ""; - -interface RivetData { - namespace: string; - engineNamespace: string; -} - -const RIVET_DATA_REGEX = //; - -function parseRivetData(body: string): RivetData | null { - const match = body.match(RIVET_DATA_REGEX); - if (!match?.[1]) return null; - - try { - const data = JSON.parse(match[1].trim()); - if (typeof data.namespace === "string" && typeof data.engineNamespace === "string") { - return { - namespace: data.namespace, - engineNamespace: data.engineNamespace, - }; - } - return null; - } catch { - return null; - } -} - -function buildRivetDataTag(data: RivetData): string { - return ``; -} - -function expectedNamespacePrefix(): string { - if (IS_MAIN && PROD_NAMESPACE_OVERRIDE) return PROD_NAMESPACE_OVERRIDE; - if (IS_PR) return `pr-${PR_NUMBER}`; - return getSlugPrefix(NS_DISPLAY_NAME); -} - -function isAllowedNamespace(name: string): boolean { - if (typeof name !== "string" || name.length === 0) return false; - const prefix = expectedNamespacePrefix(); - if (IS_MAIN && PROD_NAMESPACE_OVERRIDE) return name === PROD_NAMESPACE_OVERRIDE; - // Namespaces created by this action have the form `-<5-char-suffix>`, - // or for PRs may equal `pr-` exactly. - return name === prefix || name.startsWith(`${prefix}-`); -} - -// Rivet Cloud API helpers -async function rivetCloudFetch( - path: string, - options: RequestInit = {}, -): Promise<{ status: number; response: any }> { - const url = `${getCloudEndpoint()}${path}`; - - const res = await fetch(url, { - ...options, - headers: { - Authorization: `Bearer ${RIVET_CLOUD_TOKEN}`, - "Content-Type": "application/json", - ...options.headers, - }, - }); - - const text = await res.text(); - let response: any = null; - if (text) { - try { - response = JSON.parse(text); - } catch { - throw new Error(`Rivet Cloud API returned non-JSON: ${res.status} ${text}`); - } - } - - return { status: res.status, response }; -} - -async function rivetCloudFetchOk( - path: string, - options: RequestInit = {}, -): Promise { - const { status, response } = await rivetCloudFetch(path, options); - if (status < 200 || status >= 300) { - throw new Error(`Rivet Cloud API error: ${status} ${response ? JSON.stringify(response) : ""}`); - } - return response; -} - -async function getComputePool( - project: string, - organization: string, - namespaceName: string, - poolName: string, -): Promise { - const { status, response } = await rivetCloudFetch( - `/projects/${project}/namespaces/${namespaceName}/managed-pools/${poolName}?org=${encodeURIComponent(organization)}`, - ); - if (status === 404) return null; - if (status < 200 || status >= 300) { - throw new Error(`Rivet Cloud API error: ${status} ${response ? JSON.stringify(response) : ""}`); - } - return response?.managedPool ?? null; -} - -async function createComputePool( - project: string, - organization: string, - namespaceName: string, - poolName: string, - body: Record, -): Promise { - await rivetCloudFetchOk( - `/projects/${project}/namespaces/${namespaceName}/managed-pools/${poolName}?org=${encodeURIComponent(organization)}`, - { method: "PUT", body: JSON.stringify(body) }, - ); -} - -async function ensureCompute( - project: string, - organization: string, - namespaceName: string, - poolName: string, - createBody: Record, -): Promise { - const existing = await getComputePool(project, organization, namespaceName, poolName); - if (existing) return; - - console.log(` Compute pool "${poolName}" not found, creating...`); - await createComputePool(project, organization, namespaceName, poolName, createBody); -} - -async function waitForComputePool( - project: string, - organization: string, - namespaceName: string, - poolName: string, - throwOnError: boolean = true, -): Promise { - let iteration = 0; - while (true) { - iteration += 1; - const pool = await getComputePool(project, organization, namespaceName, poolName); - if (!pool) throw new Error(`compute pool ${poolName} disappeared while polling`); - console.log(` Pool status: ${pool.status ?? "unknown"}`); - if (pool.status === "ready") break; - if (pool.status === "error") { - if (throwOnError) { - throw new Error(`compute pool entered error state: ${pool.error?.message ?? "unknown error"}`); - } else { - // If it hit error, its ready to be deployed to again, so we can break - break; - } - } - await new Promise(resolve => setTimeout(resolve, 2000)); - } -} - -// GitHub API helpers -interface ExistingComment { - id: number; - body: string; -} - -async function findExistingComment(): Promise { - if (!IS_PR) return null; - - const response = await fetch( - `https://api.github.com/repos/${REPO_FULL_NAME}/issues/${PR_NUMBER}/comments`, - { - headers: { - Authorization: `token ${GITHUB_TOKEN}`, - Accept: "application/vnd.github.v3+json", - }, - } - ); - const comments = await response.json(); - if (!Array.isArray(comments)) { - console.log("Comments response:", comments); - return null; - } - const existing = comments.find( - (c: any) => - c.body?.includes(COMMENT_MARKER) && - (c.user?.type === "Bot" || c.user?.login === "github-actions[bot]"), - ); - if (!existing) return null; - return { id: existing.id, body: existing.body }; -} - -async function updateComment(commentId: number | null, body: string): Promise { - if (!IS_PR) return null; - - const fullBody = `${COMMENT_MARKER}\n${body}`; - - if (commentId) { - await fetch( - `https://api.github.com/repos/${REPO_FULL_NAME}/issues/comments/${commentId}`, - { - method: "PATCH", - headers: { - Authorization: `token ${GITHUB_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ body: fullBody }), - } - ); - return commentId; - } else { - const response = await fetch( - `https://api.github.com/repos/${REPO_FULL_NAME}/issues/${PR_NUMBER}/comments`, - { - method: "POST", - headers: { - Authorization: `token ${GITHUB_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ body: fullBody }), - } - ); - const data = await response.json(); - return data.id; - } -} - -function getRepoProjectName(): string { - const parts = REPO_FULL_NAME?.split("/") || []; - return parts[1] || "unknown"; +import { readFile } from "node:fs/promises"; +import { deployCloud } from "./cloud"; +import { type Config, readConfig, plan, previewName } from "./config"; +import { Fly } from "./fly"; +import { output, status } from "./github"; + +export async function main(config: Config) { + const selected = plan(config); + if (selected.kind === "skip") { + console.log(selected.reason); + await output({ status: "skipped" }); + await status(config, "skipped", { reason: selected.reason }); + return; + } + const cleanup = selected.kind === "cleanup"; + try { + if (cleanup) { + const retained = !!config.clusters[config.branch]; + let removed = false; + // A preview may predate the current branch mapping or enabled switch. + if (config.flyToken && config.flyOrg) { + await status(config, "removing"); + await new Fly(config).cleanup(); + removed = true; + } else if (config.flyEnabled) throw new Error("Preview cleanup needs fly-token and fly-org"); + await output({ status: "closed" }); + await status(config, "closed", { retained, removed, reason: removed ? undefined : "Preview cleanup was not verified because credentials are unavailable." }); + return; + } + await status(config, "deploying", { provider: selected.kind === "cloud" ? "rivet" : "fly" }); + if (selected.kind === "cloud") { + const result = await deployCloud(config, selected.target); + await output({ status: "ready", provider: "rivet", "build-id": result.buildId, url: result.url }); + await status(config, "ready", { url: result.url || undefined }); + } else { + const url = await new Fly(config).deploy(); + await output({ status: "ready", provider: "fly", "fly-app": previewName(config.repo, config.branch), url }); + await status(config, "ready", { url }); + } + } catch (error) { + await status(config, cleanup ? "cleanup-failed" : "failed"); + throw error; + } +} + +if (import.meta.main) { + try { + const event = process.env.GITHUB_EVENT_PATH ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8")) : {}; + await main(readConfig(process.env, event)); + } catch (error) { + console.error(error instanceof Error ? error.message : "Deployment failed"); + process.exitCode = 1; + } } - -function dockerExec(cmd: string): void { - console.log(` $ ${cmd.replace(RIVET_CLOUD_TOKEN, "***")}`); - execSync(cmd, { stdio: "inherit", cwd: process.env.GITHUB_WORKSPACE }); -} - -function getSlugPrefix(displayName: string): string { - const slugPrefix = displayName - .normalize("NFD") - .toLowerCase() - .replace(/_/g, "-") - .replace(/[^a-z0-9\s\-]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 16); - - return slugPrefix; -} - -async function findOrCreateNamespace( - project: string, - organization: string, - displayName: string, - nameOverride: string | null, -): Promise { - if (!nameOverride) nameOverride = null; - - const slugPrefix = getSlugPrefix(displayName); - - let cursor: string | null = null; - let foundNamespace: any = null; - - let maxPolls = 10; - let iteration = 0; - do { - console.log(` Fetching namespaces with cursor: ${cursor}`); - const { namespaces: nsList, pagination } = await rivetCloudFetchOk( - `/projects/${project}/namespaces?org=${encodeURIComponent(organization)}&limit=100` - ); - - for (const ns of nsList) { - if (ns.name === nameOverride) { - foundNamespace = ns; - break; - } - - const slugWithoutSuffix = ns.name.slice(0, -5); - if (slugWithoutSuffix.startsWith(slugPrefix)) { - foundNamespace = ns; - break; - } - } - - cursor = pagination.cursor; - iteration++; - } while (cursor && !foundNamespace && iteration < maxPolls); - - if (foundNamespace) { - console.log(` Found existing namespace: ${foundNamespace.name}`); - return { - namespace: foundNamespace, - engineNamespaceName: foundNamespace.access?.engineNamespaceName || foundNamespace.name, - }; - } - - console.log(` No existing namespace found, creating: ${displayName}`); - const result = await rivetCloudFetchOk(`/projects/${project}/namespaces?org=${organization}`, { - method: "POST", - body: JSON.stringify({ - displayName, - }), - }); - return { - namespace: result.namespace, - engineNamespaceName: result.namespace.access?.engineNamespaceName || result.namespace.name, - }; -} - -async function cleanupFlow(): Promise { - console.log("=== Rivet Deploy Cleanup ==="); - console.log(`Event: ${GITHUB_EVENT_NAME}${EVENT_PAYLOAD?.action ? ` (${EVENT_PAYLOAD.action})` : ""}`); - console.log(`Repo: ${REPO_FULL_NAME}`); - console.log(`PR: ${PR_NUMBER || "unknown"}`); - console.log(""); - - if (!IS_PR) { - console.log("No PR context found, skipping cleanup."); - return; - } - - const existingComment = await findExistingComment(); - let commentId = existingComment?.id ?? null; - - const rawRivetData = existingComment?.body ? parseRivetData(existingComment.body) : null; - const existingRivetData = - rawRivetData && isAllowedNamespace(rawRivetData.namespace) ? rawRivetData : null; - if (rawRivetData && !existingRivetData) { - console.log( - `Ignoring rivet-data with namespace "${rawRivetData.namespace}" — does not match expected prefix for this PR.`, - ); - } - const namespaceName = existingRivetData?.namespace || (PR_NUMBER ? `pr-${PR_NUMBER}` : null); - if (namespaceName && !isAllowedNamespace(namespaceName)) { - console.log(`Refusing to clean up namespace "${namespaceName}" — not allowed for this PR.`); - return; - } - const projectName = getRepoProjectName(); - const tableHeader = `| Project | Namespace | Status | Actions |\n|:--------|:----------|:-------|:-------|\n`; - const intro = "Rivet preview namespace cleanup after PR close.\n\n"; - - if (!namespaceName) { - console.log("No namespace found for cleanup."); - if (commentId) { - await updateComment(commentId, intro + tableHeader + `| \`${projectName}\` | - | Not found | - |`); - } - return; - } - - console.log("Inspecting Rivet token..."); - const { project, organization } = await rivetCloudFetchOk("/tokens/api/inspect"); - - console.log("Deleting compute pool..."); - try { - await rivetCloudFetchOk( - `/projects/${project}/namespaces/${namespaceName}/managed-pools/default?org=${encodeURIComponent(organization)}`, - { method: "DELETE" }, - ); - } catch (error) { - console.log("Warning: failed to delete compute pool:", error); - } - - console.log(`Archiving namespace: ${namespaceName}`); - try { - await rivetCloudFetchOk( - `/projects/${project}/namespaces/${namespaceName}?org=${organization}`, - { method: "DELETE" }, - ); - } catch (error) { - console.log(`Warning: failed to archive namespace ${namespaceName}:`, error); - return; - } - - console.log("Namespace archived."); - if (commentId) { - await updateComment(commentId, intro + tableHeader + `| \`${projectName}\` | \`${namespaceName}\` | Archived | - |`); - } -} - -async function setupFlow(): Promise { - console.log("=== Rivet Deploy Action ==="); - console.log(`Mode: ${IS_PR ? `PR #${PR_NUMBER}` : `Production (${MAIN_BRANCH} branch)`}`); - console.log(`Branch: ${BRANCH_NAME}`); - console.log(`Repo: ${REPO_FULL_NAME}`); - console.log(`Namespace: ${(IS_MAIN ? PROD_NAMESPACE_OVERRIDE ?? NS_DISPLAY_NAME : NS_DISPLAY_NAME)}`); - console.log(`Dockerfile: ${DOCKERFILE_PATH}`); - console.log(`Image tag: ${IMAGE_TAG}`); - console.log(`Rivet Engine Endpoint: ${RIVET_ENGINE_ENDPOINT}`); - console.log(""); - - const runLogsUrl = `https://github.com/${REPO_FULL_NAME}/actions/runs/${RUN_ID}`; - const existingComment = await findExistingComment(); - let commentId = existingComment?.id ?? null; - - const rawRivetData = existingComment?.body ? parseRivetData(existingComment.body) : null; - const existingRivetData = - rawRivetData && isAllowedNamespace(rawRivetData.namespace) ? rawRivetData : null; - if (rawRivetData && !existingRivetData) { - console.log( - `Ignoring rivet-data with namespace "${rawRivetData.namespace}" — does not match expected prefix.`, - ); - } - if (existingRivetData) { - console.log(`Found existing namespace in comment: ${existingRivetData.namespace}`); - } - - const projectName = getRepoProjectName(); - - const intro = IS_PR - ? `This PR has a Rivet namespace. [Learn more](https://rivet.dev/docs)\n\n` - : `Rivet production namespace. [Learn more](https://rivet.dev/docs)\n\n`; - const tableHeader = `| Project | Namespace | Status | Actions |\n|:--------|:----------|:-------|:-------|\n`; - - try { - // Step 1: Inspect token - console.log("Step 1: Inspecting Rivet token..."); - const { project, organization } = await rivetCloudFetchOk("/tokens/api/inspect"); - console.log(` Project: ${project}`); - console.log(` Organization: ${organization}`); - - commentId = await updateComment( - commentId, - intro + tableHeader + `| \`${projectName}\` | - | Creating namespace... | - |` - ); - - // Step 2: Create or find namespace - console.log(""); - console.log("Step 2: Creating/finding namespace..."); - - let namespace: any; - let engineNamespace: string; - if (existingRivetData) { - console.log(` Fetching existing namespace: ${existingRivetData.namespace}`); - const { namespace: fullNs } = await rivetCloudFetchOk( - `/projects/${project}/namespaces/${existingRivetData.namespace}?org=${organization}` - ); - namespace = fullNs; - engineNamespace = existingRivetData.engineNamespace; - console.log(` Reusing namespace: ${namespace.name}`); - } else { - const result = await findOrCreateNamespace( - project, - organization, - NS_DISPLAY_NAME, - IS_MAIN ? PROD_NAMESPACE_OVERRIDE : null - ); - namespace = result.namespace; - engineNamespace = result.engineNamespaceName; - } - - const dashboardUrl = `${getDashboardEndpoint()}/orgs/${organization}/projects/${project}/ns/${namespace.name}?skipOnboarding=1`; - - const rivetDataTag = buildRivetDataTag({ - namespace: namespace.name, - engineNamespace, - }); - const registry = getRegistryEndpoint(); - const imageName = projectName; - const poolName = "default"; - const imageRef = `${registry}/${imageName}:${IMAGE_TAG}`; - - // Step 3: Ensure compute pool exists and is ready before first push - console.log(""); - console.log("Step 3: Enabling compute..."); - await ensureCompute(project, organization, namespace.name, poolName, { - displayName: NS_DISPLAY_NAME, - }); - - console.log(" Waiting for compute to be ready..."); - await waitForComputePool(project, organization, namespace.name, poolName, false); - - // Step 4: Docker login - console.log(""); - console.log("Step 4: Docker login to Rivet registry..."); - console.log(` Registry: ${registry}`); - commentId = await updateComment( - commentId, - rivetDataTag + "\n" + intro + tableHeader + `| \`${projectName}\` | \`${namespace.name}\` | Building image... | Dashboard |` - ); - dockerExec(`docker login ${registry} --username rivet --password ${RIVET_CLOUD_TOKEN}`); - - // Step 5: Docker build - console.log(""); - console.log("Step 5: Building Docker image..."); - console.log(` Image: ${imageRef}`); - dockerExec(process.platform === "darwin" - ? `docker buildx build --platform linux/amd64 ${DOCKER_BUILD_PATH} -f ${DOCKERFILE_PATH} -t ${imageRef}` - : `docker build ${DOCKER_BUILD_PATH} -f ${DOCKERFILE_PATH} -t ${imageRef}`); - - // Step 6: Docker push - console.log(""); - console.log("Step 6: Pushing image to registry..."); - commentId = await updateComment( - commentId, - rivetDataTag + "\n" + intro + tableHeader + `| \`${projectName}\` | \`${namespace.name}\` | Pushing image... | Dashboard |` - ); - dockerExec(`docker push ${imageRef}`); - - // Step 7: Upsert compute pool - console.log(""); - console.log("Step 7: Upserting compute pool..."); - console.log(` Pool: ${poolName}`); - commentId = await updateComment( - commentId, - rivetDataTag + "\n" + intro + tableHeader + `| \`${projectName}\` | \`${namespace.name}\` | Upserting pool... | Dashboard |` - ); - - const poolBody = { - displayName: NS_DISPLAY_NAME, - maxConcurrentActors: 1000, - ...COMPUTE_POOL_CONFIG, - // image is always set from the docker push and cannot be overridden - image: { - repository: imageName, - tag: IMAGE_TAG, - }, - }; - - await createComputePool(project, organization, namespace.name, poolName, poolBody); - - console.log(" Waiting for compute pool to be ready..."); - await waitForComputePool(project, organization, namespace.name, poolName); - - // Step 8: Done - console.log(""); - console.log("=== Success! ==="); - console.log(` Namespace: ${namespace.name}`); - console.log(` Image: ${imageRef}`); - console.log(` Pool: ${poolName}`); - console.log(` Dashboard: ${dashboardUrl}`); - await updateComment( - commentId, - rivetDataTag + "\n" + intro + tableHeader + `| \`${projectName}\` | \`${namespace.name}\` | Ready | Dashboard |` - ); - } catch (error: any) { - console.error("Error:", error); - - await updateComment( - commentId, - intro + tableHeader + `| \`${projectName}\` | - | Failed | [Logs](${runLogsUrl}) |` - ); - - process.exit(1); - } -} - -async function main() { - if (IS_CLEANUP) { - await cleanupFlow(); - return; - } - - await setupFlow(); -} - -main(); diff --git a/src/process.ts b/src/process.ts new file mode 100644 index 0000000..19216a7 --- /dev/null +++ b/src/process.ts @@ -0,0 +1,26 @@ +import { spawn } from "node:child_process"; + +export type RunOptions = { cwd?: string; env?: NodeJS.ProcessEnv; quiet?: boolean; timeoutMs?: number }; +export type Run = (program: string, args: string[], options?: RunOptions) => Promise; + +// Arguments are never interpreted by a shell. Credentials belong in env, not args. +export const run: Run = (program, args, options = {}) => new Promise((resolve, reject) => { + const child = spawn(program, args, { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + const timer = setTimeout(() => { child.kill("SIGTERM"); }, options.timeoutMs ?? 45 * 60_000); + child.stdout.on("data", (data: Buffer) => { + output += data.toString(); + if (!options.quiet) process.stdout.write(data); + }); + child.stderr.on("data", (data: Buffer) => { if (!options.quiet) process.stderr.write(data); }); + child.once("error", (error) => { clearTimeout(timer); reject(error); }); + child.once("close", (code, signal) => { + clearTimeout(timer); + if (code === 0) resolve(output); + else reject(new Error(`${program} failed (${signal || `exit ${code}`})`)); + }); +}); diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..39004a9 --- /dev/null +++ b/src/report.ts @@ -0,0 +1,13 @@ +import { readFile } from "node:fs/promises"; +import type { Event } from "./config"; +import { status } from "./github"; + +// Independent of deployment configuration so setup/validation failures can report too. +const event: Event = process.env.GITHUB_EVENT_PATH ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8")) : {}; +const cleanup = process.env.GITHUB_EVENT_NAME === "delete" || event.action === "closed"; +await status({ + repo: process.env.GITHUB_REPOSITORY!, + branch: event.pull_request?.head.ref || process.env.GITHUB_REF_NAME || "", + pr: event.pull_request?.number, + githubToken: process.env.GITHUB_TOKEN || "", +}, process.env.DEPLOY_CANCELLED === "true" ? "cancelled" : cleanup ? "cleanup-failed" : "failed"); diff --git a/tests/cloud.test.ts b/tests/cloud.test.ts new file mode 100644 index 0000000..09c9fb7 --- /dev/null +++ b/tests/cloud.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from 'bun:test'; +import { deployCloud } from '../src/cloud'; +import { readConfig } from '../src/config'; +import type { Run } from '../src/process'; +const config = readConfig({ + GITHUB_REPOSITORY: 'example/app', GITHUB_REF_NAME: 'main', + DOCKER_BUILD_PATH: 'path with spaces', RIVET_CLOUD_TOKEN: 'synthetic-token', + RIVET_ORG: 'test-org', RIVET_CLI_VERSION: '3.0.0-preview.3', +}, {}); + +test('Cloud delegates upload and rollout to the v3 CLI, then reads the app URL', async () => { + const calls: string[][] = []; + const command: Run = async (program, args, options) => { + expect(program).toBe('bun'); + expect(args.slice(0, 4)).toEqual(['x', '--package', '@rivetkit/cli@3.0.0-preview.3', 'rivet']); + expect(args).not.toContain('synthetic-token'); + expect(options?.env?.RIVET_CLOUD_TOKEN).toBe('synthetic-token'); + const call = args.slice(4); + calls.push(call); + if (call.includes('--help')) return '--cluster --build-context'; + expect(call).toContain('test-org'); + expect(call).toContain('production'); + if (call[1].endsWith('/regions')) return JSON.stringify({ items: [{ region: 'test-region' }] }); + if (call[0] === 'deploy') { + expect(call).toContain('test-region'); + expect(call).toContain('api'); + expect(call[call.indexOf('--build-context') + 1]).toEndWith('path with spaces'); + expect(call).toContain('--yes'); + expect(call).not.toContain('--namespace'); + return 'Docker build progress\ntest-build\n'; + } + expect(call[1]).toBe('/v2/clusters/{cluster}/regions/test-region/runner-pools/api'); + return JSON.stringify({ build_id: 'test-build', endpoints: { public: 'https://app.example.test' } }); + }; + expect(await deployCloud(config, { cluster: 'production', pool: 'api' }, command)).toEqual({ buildId: 'test-build', url: 'https://app.example.test' }); + expect(calls.filter((c) => c[0] === 'deploy' && !c.includes('--help'))).toHaveLength(1); + expect(calls.filter((c) => c[0] === 'api').every((c) => !c.includes('-X'))).toBe(true); +}); + +test('published namespace CLI fails before any Cloud mutation', async () => { + let calls = 0; + await expect(deployCloud(config, { cluster: 'production' }, async () => { + calls++; return 'deploy --namespace --project'; + })).rejects.toThrow('cannot deploy v3 clusters'); + expect(calls).toBe(1); +}); + +test('ambiguous or nonexistent regions fail before deploying', async () => { + for (const target of [{ cluster: 'production' }, { cluster: 'production', region: 'missing' }]) { + await expect(deployCloud(config, target, async (_program, args) => { + if (args.includes('--help')) return '--cluster --build-context'; + expect(args).toContain('api'); + return JSON.stringify({ items: [{ region: 'one' }, { region: 'two' }] }); + })).rejects.toThrow('mapped region'); + } +}); + +test('preinstalled CLI bypasses package installation and a failed rollout is not retried', async () => { + let deploys = 0; + await expect(deployCloud({ ...config, cliPath: '/tools/rivet' }, { cluster: 'production' }, async (program, args) => { + expect(program).toBe('/tools/rivet'); + if (args.includes('--help')) return '--cluster --build-context'; + if (args[0] === 'api') return JSON.stringify({ items: [{ region: 'one' }] }); + deploys++; + throw new Error('rollout failed'); + })).rejects.toThrow('rollout failed'); + expect(deploys).toBe(1); +}); + +test('missing deployment credentials fail without starting the CLI', async () => { + await expect(deployCloud({ ...config, cloudToken: '' }, { cluster: 'production' }, async () => { throw new Error('must not start'); })).rejects.toThrow('Cloud deployment credential'); +}); + +test('concurrent pool changes cannot produce a misleading success URL', async () => { + await expect(deployCloud(config, { cluster: 'production' }, async (_program, args) => { + if (args.includes('--help')) return '--cluster --build-context'; + if (args.includes('/v2/clusters/{cluster}/regions')) return JSON.stringify({ items: [{ region: 'one' }] }); + if (args.includes('deploy')) return 'test-build\n'; + return JSON.stringify({ build_id: 'different-build', endpoints: { public: 'https://app.example.test' } }); + })).rejects.toThrow('pool changed'); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..79471de --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test'; +import { parseClusters, plan, previewName, readConfig } from '../src/config'; +const env = { GITHUB_REPOSITORY: 'example/app', GITHUB_REF_NAME: 'feature', GITHUB_EVENT_NAME: 'push' }; + +describe('deployment selection', () => { + test('mapped branches win over enabled Fly previews', () => { + const c = readConfig({ ...env, BRANCH_CLUSTERS: '{"feature":{"cluster":"staging","region":"test-region","pool":"api"}}', FLY_ENABLED: 'true' }, {}); + expect(plan(c)).toEqual({ kind: 'cloud', target: { cluster: 'staging', region: 'test-region', pool: 'api' } }); + }); + test('unmapped branches skip without credentials or Docker', () => { + expect(plan(readConfig(env, {})).kind).toBe('skip'); + expect(plan(readConfig({ ...env, FLY_ENABLED: 'true', FLY_API_TOKEN: 'synthetic-token' }, {}))).toEqual({ kind: 'fly' }); + }); + test('PR uses head branch, not merge ref or base branch', () => { + const c = readConfig({ ...env, GITHUB_EVENT_NAME: 'pull_request', GITHUB_REF_NAME: '12/merge', BRANCH_CLUSTERS: '{"feature":"staging"}' }, { + action: 'synchronize', pull_request: { number: 12, head: { ref: 'feature', repo: { full_name: env.GITHUB_REPOSITORY } } }, + }); + expect(plan(c)).toEqual({ kind: 'cloud', target: { cluster: 'staging' } }); + }); + test('fork PR cannot deploy even if its name maps to production', () => { + const c = readConfig({ ...env, GITHUB_EVENT_NAME: 'pull_request', BRANCH_CLUSTERS: '{"main":"production"}' }, { + action: 'opened', pull_request: { number: 1, head: { ref: 'main', repo: { full_name: 'fork/app' } } }, + }); + expect(plan(c).kind).toBe('skip'); + }); + test('close and delete clean up even after Fly is disabled or mapping changes', () => { + const c = readConfig({ ...env, GITHUB_EVENT_NAME: 'pull_request', BRANCH_CLUSTERS: '{"feature":"staging"}' }, { + action: 'closed', pull_request: { number: 12, head: { ref: 'feature', repo: { full_name: env.GITHUB_REPOSITORY } } }, + }); + expect(plan(c)).toEqual({ kind: 'cleanup' }); + const deleted = readConfig({ ...env, GITHUB_EVENT_NAME: 'delete' }, { ref_type: 'branch', ref: 'feature' }); + expect(deleted.branch).toBe(c.branch); + expect(plan(deleted)).toEqual({ kind: 'cleanup' }); + }); + test('tags and pull_request_target are never deployed', () => { + expect(plan(readConfig({ ...env, FLY_ENABLED: 'true', GITHUB_REF_TYPE: 'tag' }, {})).kind).toBe('skip'); + expect(plan(readConfig({ ...env, FLY_ENABLED: 'true', GITHUB_EVENT_NAME: 'pull_request_target' }, {})).kind).toBe('skip'); + }); + test('bad configuration fails instead of falling back to the wrong provider', () => { + for (const value of ['null', '[]', '{', '{"main":{}}', '{"main":{"cluster":"prod","namespace":"x"}}', '{"main":"prod","staging":"prod"}']) expect(() => parseClusters(value)).toThrow(); + expect(() => readConfig({ ...env, FLY_ENABLED: 'yes' }, {})).toThrow(); + expect(() => readConfig({ ...env, FLY_PORT: '8080oops' }, {})).toThrow(); + }); + test('prototype-like branch names are exact map keys', () => { + expect(plan(readConfig({ ...env, GITHUB_REF_NAME: 'toString' }, {})).kind).toBe('skip'); + expect(parseClusters('{"__proto__":"test"}').__proto__).toEqual({ cluster: 'test' }); + }); + test('preview names isolate repositories and case-sensitive branches', () => { + const name = previewName('example/app', 'feature/A'); + expect(name).toMatch(/^[a-z][a-z0-9-]{0,62}$/); + expect(name).toBe(previewName('example/app', 'feature/A')); + expect(name).not.toBe(previewName('example/app', 'feature/a')); + expect(name).not.toBe(previewName('other/app', 'feature/A')); + }); +}); diff --git a/tests/fly.test.ts b/tests/fly.test.ts new file mode 100644 index 0000000..5b79bce --- /dev/null +++ b/tests/fly.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { readConfig, previewName } from '../src/config'; +import { Fly } from '../src/fly'; +import type { Run } from '../src/process'; +const config = readConfig({ GITHUB_REPOSITORY: 'example/app', GITHUB_REF_NAME: 'feature', FLY_API_TOKEN: 'synthetic-token', FLY_ORG: 'test-org' }, {}); +const app = previewName(config.repo, config.branch); + +test('deploy creates one dev Machine with durable local storage and safe argument passing', async () => { + const requests: string[] = []; + let deployed = false; + const request = (async (url: string | URL, init: RequestInit) => { + const path = new URL(url).pathname; + requests.push(`${init.method} ${path}`); + expect(new Headers(init.headers).get('authorization')).toBe('Bearer synthetic-token'); + if (path === `/v1/apps/${app}`) return new Response(null, { status: 404 }); + if (path === '/v1/apps') return Response.json({ id: 'app' }); + if (path.endsWith('/machines')) return Response.json(deployed ? [{ id: 'one', state: 'started', region: 'iad' }] : []); + return Response.json([]); + }) as (url: string, init: RequestInit) => Promise; + const command: Run = async (program, args, options) => { + expect(program).toBe('flyctl'); + expect(args).toContain('--ha=false'); + expect(args[args.indexOf('--strategy') + 1]).toBe('rolling'); + expect(args).not.toContain('synthetic-token'); + expect(options?.env?.FLY_API_TOKEN).toBe('synthetic-token'); + const data = JSON.parse(await readFile(args[args.indexOf('--config') + 1], 'utf8')); + expect(data.env.NODE_ENV).toBe('development'); + expect(data.env).not.toHaveProperty('RIVET_ENDPOINT'); + expect(data.env.RIVETKIT_STORAGE_PATH).toBe('/data'); + expect(data.http_service).toMatchObject({ auto_stop_machines: 'stop', auto_start_machines: true, min_machines_running: 0 }); + expect(data.mounts).toHaveLength(1); + deployed = true; + return ''; + }; + expect(await new Fly(config, command, request).deploy()).toBe(`https://${app}.fly.dev`); + expect(requests.filter((r) => r === 'POST /v1/apps')).toHaveLength(1); +}); + +test('redeploy refuses multiple Machines instead of risking two local engines', async () => { + let ran = false; + const request = (async (url: string) => Response.json(url.endsWith('/machines') + ? [{ id: 'one', state: 'started', region: 'iad' }, { id: 'two', state: 'stopped', region: 'iad' }] + : { name: app, organization: { slug: 'test-org' } })) as (url: string, init: RequestInit) => Promise; + await expect(new Fly(config, async () => { ran = true; return ''; }, request).deploy()).rejects.toThrow('at most one'); + expect(ran).toBe(false); +}); + +test('cleanup is idempotent for absent previews and cannot trust comment targets', async () => { + let ran = false; + await new Fly(config, async () => { ran = true; return ''; }, (async () => new Response(null, { status: 404 })) as (url: string, init: RequestInit) => Promise).cleanup(); + expect(ran).toBe(false); + await new Fly(config, async (_program, args) => { + expect(args).toEqual(['apps', 'destroy', app, '--yes']); return ''; + }, (async () => Response.json({ name: app, organization: { slug: 'test-org' } })) as (url: string, init: RequestInit) => Promise).cleanup(); +}); + +test('auth failures never masquerade as missing apps', async () => { + let ran = false; + await expect(new Fly(config, async () => { ran = true; return ''; }, (async () => new Response(null, { status: 401 })) as (url: string, init: RequestInit) => Promise).deploy()).rejects.toThrow('401'); + expect(ran).toBe(false); +}); + +test('cleanup refuses an app from another organization', async () => { + await expect(new Fly(config, async () => { throw new Error('must not delete'); }, (async () => Response.json({ name: app, organization: { slug: 'other' } })) as (url: string, init: RequestInit) => Promise).cleanup()).rejects.toThrow('different Fly organization'); +}); + +test('redeploy grows existing storage before updating the Machine and never shrinks it', async () => { + for (const size of [10, 20, 30]) { + const commands: string[][] = []; + const request = async (url: string) => Response.json(url.endsWith('/machines') + ? [{ id: 'one', state: 'started', region: 'iad' }] + : url.endsWith('/volumes') + ? [{ id: 'existing-volume', name: 'rivet_data', region: 'iad', state: 'created', size_gb: size }] + : { name: app, organization: { slug: 'test-org' } }); + await new Fly(config, async (_program, args) => { commands.push(args); return ''; }, request).deploy(); + if (size < 20) expect(commands[0]).toEqual(['volumes', 'extend', 'existing-volume', '--app', app, '--size', '20', '--yes']); + else expect(commands).toHaveLength(1); + expect(commands.at(-1)?.[0]).toBe('deploy'); + } +}); diff --git a/tests/github.test.ts b/tests/github.test.ts new file mode 100644 index 0000000..1c45865 --- /dev/null +++ b/tests/github.test.ts @@ -0,0 +1,80 @@ +import { afterEach, expect, spyOn, test } from 'bun:test'; +import { readConfig } from '../src/config'; +import { renderStatus, status, type StatusState } from '../src/github'; +import { main } from '../src/index'; +import { Fly } from '../src/fly'; + +const config = readConfig({ GITHUB_REPOSITORY: 'example/app', GITHUB_EVENT_NAME: 'pull_request', GITHUB_TOKEN: 'synthetic-token' }, { + action: 'opened', pull_request: { number: 42, head: { ref: 'feature', repo: { full_name: 'example/app' } } }, +}); +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +test('every status uses the same table and only deploying identifies the provider', () => { + const states: StatusState[] = ['deploying', 'ready', 'failed', 'skipped', 'removing', 'closed', 'cleanup-failed', 'cancelled']; + for (const state of states) { + const body = renderStatus(config, state, { provider: 'fly', url: 'https://preview.example.com', removed: true }, '123'); + expect(body).toContain('| Project | Preview | Status | Actions |'); + expect(body).toContain('| app | PR #42 |'); + expect(body).toContain('[Logs](https://github.com/example/app/actions/runs/123)'); + expect(body.includes('[Open preview]')).toBe(state === 'ready'); + expect(body.includes('(Fly)')).toBe(state === 'deploying'); + expect(body).not.toContain('Fly.io'); + expect(body).not.toContain('namespace'); + } + expect(renderStatus(config, 'deploying', { provider: 'rivet' })).toContain('Deploying… (Rivet)'); + expect(renderStatus(config, 'closed', { removed: true })).toContain('Preview removed'); + const retained = renderStatus(config, 'closed', { removed: true, retained: true }); + expect(retained).toContain('PR closed'); + expect(retained).toContain('The mapped cluster is retained.'); + expect(retained).not.toContain('Preview removed'); +}); + +test('comment escapes table content and never exposes URL credentials', () => { + const body = renderStatus({ ...config, repo: 'example/a|' }, 'ready', { url: 'https://user:secret@preview.example.com', reason: '